refactor: normalize review and comment storage
This commit is contained in:
+227
-44
@@ -281,12 +281,36 @@ fn directory_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf,
|
||||
.ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into())
|
||||
}
|
||||
|
||||
// ponytail: whole-review JSON commits; normalize comment rows if large reviews make writes costly.
|
||||
const SCHEMA: &str = "CREATE TABLE reviews (
|
||||
const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
|
||||
id INTEGER PRIMARY KEY CHECK (id > 0),
|
||||
root TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
document TEXT NOT NULL CHECK (length(CAST(document AS BLOB)) <= 67108864)
|
||||
summary TEXT NOT NULL,
|
||||
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
|
||||
) STRICT";
|
||||
|
||||
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
|
||||
const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
|
||||
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
id TEXT NOT NULL CHECK (
|
||||
length(id) BETWEEN 1 AND 20 AND id NOT GLOB '*[^0-9]*'
|
||||
AND substr(id, 1, 1) BETWEEN '1' AND '9'
|
||||
AND (length(id) < 20 OR id <= '18446744073709551615')
|
||||
),
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
body TEXT NOT NULL,
|
||||
resolved INTEGER NOT NULL CHECK (resolved IN (0, 1)),
|
||||
anchor_revision TEXT NOT NULL CHECK (length(anchor_revision) > 0),
|
||||
path TEXT NOT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('before', 'after')),
|
||||
start_row INTEGER NOT NULL CHECK (start_row >= 0),
|
||||
start_column INTEGER NOT NULL CHECK (start_column >= 0),
|
||||
end_row INTEGER NOT NULL CHECK (end_row >= 0),
|
||||
end_column INTEGER NOT NULL CHECK (end_column >= 0),
|
||||
content TEXT NOT NULL,
|
||||
PRIMARY KEY (review_id, id),
|
||||
UNIQUE (review_id, position),
|
||||
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
|
||||
) STRICT";
|
||||
|
||||
pub struct Store {
|
||||
@@ -316,6 +340,9 @@ impl Store {
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
Self::check_schema(&connection)?;
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", true)
|
||||
.map_err(database_error)?;
|
||||
Ok(Some(Self { connection }))
|
||||
}
|
||||
|
||||
@@ -330,7 +357,7 @@ impl Store {
|
||||
.map_err(database_error)?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(database_error)?;
|
||||
if version != 1 || schema != [SCHEMA] {
|
||||
if version != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
|
||||
return Err(format!(
|
||||
"Unsupported review database schema (version {version}); existing data was not changed"
|
||||
));
|
||||
@@ -378,6 +405,9 @@ impl Store {
|
||||
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", true)
|
||||
.map_err(database_error)?;
|
||||
connection
|
||||
.busy_timeout(std::time::Duration::ZERO)
|
||||
.map_err(database_error)?;
|
||||
@@ -385,9 +415,14 @@ impl Store {
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_error)?;
|
||||
transaction.execute_batch(SCHEMA).map_err(database_error)?;
|
||||
transaction
|
||||
.pragma_update(None, "user_version", 1)
|
||||
.execute_batch(REVIEWS_SCHEMA)
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.execute_batch(COMMENTS_SCHEMA)
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.pragma_update(None, "user_version", 2)
|
||||
.map_err(database_error)?;
|
||||
transaction.commit().map_err(database_error)?;
|
||||
} else {
|
||||
@@ -407,17 +442,24 @@ impl Store {
|
||||
let root_text = root
|
||||
.to_str()
|
||||
.ok_or("Review repository root must be UTF-8")?;
|
||||
let mut statement = self
|
||||
let transaction = self
|
||||
.connection
|
||||
.prepare(
|
||||
"SELECT id, root, revision, document FROM reviews WHERE root = ?1 ORDER BY id DESC",
|
||||
)
|
||||
.unchecked_transaction()
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement.query([root_text]).map_err(database_error)?;
|
||||
let mut reviews = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(database_error)? {
|
||||
reviews.push(stored_review(row, &root)?);
|
||||
}
|
||||
let reviews = {
|
||||
let mut statement = transaction
|
||||
.prepare(
|
||||
"SELECT id, root, revision, summary, verdict FROM reviews WHERE root = ?1 ORDER BY id DESC",
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement.query([root_text]).map_err(database_error)?;
|
||||
let mut reviews = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(database_error)? {
|
||||
reviews.push(stored_review(&transaction, row, &root)?);
|
||||
}
|
||||
reviews
|
||||
};
|
||||
transaction.commit().map_err(database_error)?;
|
||||
Ok(reviews)
|
||||
}
|
||||
|
||||
@@ -426,18 +468,25 @@ impl Store {
|
||||
let root_text = root
|
||||
.to_str()
|
||||
.ok_or("Review repository root must be UTF-8")?;
|
||||
let mut statement = self
|
||||
let transaction = self
|
||||
.connection
|
||||
.prepare("SELECT id, root, revision, document FROM reviews WHERE id = ?1 AND root = ?2")
|
||||
.unchecked_transaction()
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement
|
||||
.query(params![id, root_text])
|
||||
.map_err(database_error)?;
|
||||
let row = rows
|
||||
.next()
|
||||
.map_err(database_error)?
|
||||
.ok_or("Review does not exist in this repository")?;
|
||||
stored_review(row, &root)
|
||||
let review = {
|
||||
let mut statement = transaction
|
||||
.prepare("SELECT id, root, revision, summary, verdict FROM reviews WHERE id = ?1 AND root = ?2")
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement
|
||||
.query(params![id, root_text])
|
||||
.map_err(database_error)?;
|
||||
let row = rows
|
||||
.next()
|
||||
.map_err(database_error)?
|
||||
.ok_or("Review does not exist in this repository")?;
|
||||
stored_review(&transaction, row, &root)?
|
||||
};
|
||||
transaction.commit().map_err(database_error)?;
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
pub fn save(&mut self, review: &mut Review) -> Result<(), String> {
|
||||
@@ -457,19 +506,34 @@ impl Store {
|
||||
None if review.storage_revision == 0 => 1,
|
||||
_ => return Err("Invalid review storage identity".into()),
|
||||
};
|
||||
let bytes = serde_json::to_vec(review).map_err(|error| error.to_string())?;
|
||||
if bytes.len() > LIMIT {
|
||||
let mut size = review.root.as_os_str().len() + review.summary.len();
|
||||
for comment in &review.comments {
|
||||
size = size
|
||||
.checked_add(
|
||||
comment.body.len()
|
||||
+ comment.anchor.revision.len()
|
||||
+ comment.anchor.path.as_os_str().len()
|
||||
+ comment.anchor.content.len()
|
||||
+ 64,
|
||||
)
|
||||
.ok_or("Review exceeds the 64 MiB limit")?;
|
||||
}
|
||||
if size > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let document = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?;
|
||||
let verdict = match review.verdict {
|
||||
Some(Verdict::LooksGood) => Some("looks_good"),
|
||||
Some(Verdict::ChangesRequested) => Some("changes_requested"),
|
||||
None => None,
|
||||
};
|
||||
let transaction = self
|
||||
.connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_error)?;
|
||||
let id = if let Some(id) = review.storage_id {
|
||||
let changed = transaction.execute(
|
||||
"UPDATE reviews SET revision = ?1, document = ?2 WHERE id = ?3 AND root = ?4 AND revision = ?5",
|
||||
params![revision, document, id, root, review.storage_revision],
|
||||
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6",
|
||||
params![revision, review.summary, verdict, id, root, review.storage_revision],
|
||||
).map_err(database_error)?;
|
||||
if changed != 1 {
|
||||
return Err(
|
||||
@@ -480,12 +544,46 @@ impl Store {
|
||||
} else {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO reviews (root, revision, document) VALUES (?1, ?2, ?3)",
|
||||
params![root, revision, document],
|
||||
"INSERT INTO reviews (root, revision, summary, verdict) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![root, revision, review.summary, verdict],
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
transaction.last_insert_rowid()
|
||||
};
|
||||
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
|
||||
transaction
|
||||
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
|
||||
.map_err(database_error)?;
|
||||
{
|
||||
let mut insert = transaction.prepare(
|
||||
"INSERT INTO comments (review_id, id, position, body, resolved, anchor_revision, path, side,
|
||||
start_row, start_column, end_row, end_column, content)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
).map_err(database_error)?;
|
||||
for (position, comment) in review.comments.iter().enumerate() {
|
||||
let anchor = &comment.anchor;
|
||||
insert
|
||||
.execute(params![
|
||||
id,
|
||||
comment.id.to_string(),
|
||||
sql_index(position)?,
|
||||
comment.body,
|
||||
comment.resolved,
|
||||
anchor.revision,
|
||||
anchor.path.to_str().unwrap(),
|
||||
match anchor.side {
|
||||
Side::Before => "before",
|
||||
Side::After => "after",
|
||||
},
|
||||
sql_index(anchor.start.row)?,
|
||||
sql_index(anchor.start.column)?,
|
||||
sql_index(anchor.end.row)?,
|
||||
sql_index(anchor.end.column)?,
|
||||
anchor.content,
|
||||
])
|
||||
.map_err(database_error)?;
|
||||
}
|
||||
}
|
||||
transaction.commit().map_err(database_error)?;
|
||||
review.storage_id = Some(id);
|
||||
review.storage_revision = revision;
|
||||
@@ -497,26 +595,111 @@ fn database_error(error: rusqlite::Error) -> String {
|
||||
format!("Review database: {error}")
|
||||
}
|
||||
|
||||
fn stored_review(row: &rusqlite::Row<'_>, root: &Path) -> Result<Review, String> {
|
||||
fn sql_index(value: usize) -> Result<i64, String> {
|
||||
i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into())
|
||||
}
|
||||
|
||||
fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result<usize, String> {
|
||||
usize::try_from(row.get::<_, i64>(index).map_err(database_error)?)
|
||||
.map_err(|_| "Stored comment coordinate exceeds this platform's range".into())
|
||||
}
|
||||
|
||||
fn stored_review(
|
||||
connection: &Connection,
|
||||
row: &rusqlite::Row<'_>,
|
||||
root: &Path,
|
||||
) -> Result<Review, String> {
|
||||
let id: i64 = row.get(0).map_err(database_error)?;
|
||||
let stored_root: String = row.get(1).map_err(database_error)?;
|
||||
let stored_root = row
|
||||
.get_ref(1)
|
||||
.map_err(database_error)?
|
||||
.as_str()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let revision: i64 = row.get(2).map_err(database_error)?;
|
||||
if id <= 0 || revision <= 0 || Path::new(&stored_root) != root {
|
||||
if id <= 0 || revision <= 0 || Path::new(stored_root) != root {
|
||||
return Err("Invalid review database identity".into());
|
||||
}
|
||||
let document = row.get_ref(3).map_err(database_error)?;
|
||||
let bytes = document
|
||||
let summary = row
|
||||
.get_ref(3)
|
||||
.map_err(database_error)?
|
||||
.as_str()
|
||||
.map_err(|error| format!("Invalid stored review: {error}"))?
|
||||
.as_bytes();
|
||||
if bytes.len() > LIMIT {
|
||||
.map_err(|error| error.to_string())?;
|
||||
let comment_bytes: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
|
||||
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
|
||||
FROM comments WHERE review_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
if comment_bytes < 0
|
||||
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
||||
{
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let mut review = Review::from_json(bytes)?;
|
||||
review.validate()?;
|
||||
if review.root != root {
|
||||
return Err("Stored review belongs to a different repository".into());
|
||||
let mut review = Review {
|
||||
root: root.to_owned(),
|
||||
..Review::default()
|
||||
};
|
||||
review.summary = summary.to_owned();
|
||||
review.verdict = match row
|
||||
.get::<_, Option<String>>(4)
|
||||
.map_err(database_error)?
|
||||
.as_deref()
|
||||
{
|
||||
Some("looks_good") => Some(Verdict::LooksGood),
|
||||
Some("changes_requested") => Some(Verdict::ChangesRequested),
|
||||
None => None,
|
||||
_ => return Err("Invalid stored review verdict".into()),
|
||||
};
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT id, position, body, resolved, anchor_revision, path, side,
|
||||
start_row, start_column, end_row, end_column, content
|
||||
FROM comments WHERE review_id = ?1 ORDER BY position",
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement.query([id]).map_err(database_error)?;
|
||||
while let Some(row) = rows.next().map_err(database_error)? {
|
||||
if stored_index(row, 1)? != review.comments.len() {
|
||||
return Err("Invalid stored comment order".into());
|
||||
}
|
||||
let side = match row.get::<_, String>(6).map_err(database_error)?.as_str() {
|
||||
"before" => Side::Before,
|
||||
"after" => Side::After,
|
||||
_ => return Err("Invalid stored comment side".into()),
|
||||
};
|
||||
let resolved = match row.get::<_, i64>(3).map_err(database_error)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err("Invalid stored comment resolution".into()),
|
||||
};
|
||||
review.comments.push(Comment {
|
||||
id: row
|
||||
.get::<_, String>(0)
|
||||
.map_err(database_error)?
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Invalid stored comment ID")?,
|
||||
body: row.get(2).map_err(database_error)?,
|
||||
resolved,
|
||||
anchor: Anchor {
|
||||
revision: row.get(4).map_err(database_error)?,
|
||||
path: PathBuf::from(row.get::<_, String>(5).map_err(database_error)?),
|
||||
side,
|
||||
start: crate::plugin::TextPosition {
|
||||
row: stored_index(row, 7)?,
|
||||
column: stored_index(row, 8)?,
|
||||
},
|
||||
end: crate::plugin::TextPosition {
|
||||
row: stored_index(row, 9)?,
|
||||
column: stored_index(row, 10)?,
|
||||
},
|
||||
content: row.get(11).map_err(database_error)?,
|
||||
},
|
||||
});
|
||||
}
|
||||
review.validate()?;
|
||||
review.storage_id = Some(id);
|
||||
review.storage_revision = revision;
|
||||
Ok(review)
|
||||
|
||||
Reference in New Issue
Block a user