From 44f8419a8f7333f926c998ee884ea74e28cf20f2 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 16:20:19 +0200 Subject: [PATCH] Support stable sorting of parsed table sections --- CHANGELOG.md | 2 + src/parser.rs | 13 ++-- tests/sort_tables.rs | 143 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/sort_tables.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e49ae6e..1bc3134f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ A `-0.0` real keeps its sign when a changeset or patchset is built or parsed and `Affinity`, SQLite's column type affinity, with `Affinity::from_declared_type` for SQLite's declared-type rules and `Affinity::apply` for the conversion SQLite applies to a value stored in a column of that affinity, including SQLite's own text rendering of reals. +`ParsedDiffSet::sort_tables_by_key` stably orders table sections by a caller-provided key while preserving each section's schema and operations. + ### Fixed Changeset and patchset row ordering hashes composite primary keys in table-column order while key accessors retain primary-key ordinal order. diff --git a/src/parser.rs b/src/parser.rs index 54b805ebb..efdec707a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -216,10 +216,7 @@ impl + Clone + core::hash::Hash + Eq + core::fmt::Debug> SchemaWit } } -/// A parsed changeset or patchset. -/// -/// This represents a frozen (immutable) diffset produced by the binary parser. -/// To modify it, convert it to a [`DiffSetBuilder`] using `Into::into`. +/// A parsed changeset or patchset whose operations retain their stored order. #[derive(Debug, Clone, Eq)] pub enum ParsedDiffSet { /// A parsed changeset. @@ -328,6 +325,14 @@ impl ParsedDiffSet { } } + /// Stably reorder table sections by `key`, preserving each schema and its operations. + pub fn sort_tables_by_key(&mut self, mut key: impl FnMut(&TableSchema) -> K) { + match self { + ParsedDiffSet::Changeset(d) => d.tables.sort_by_key(|(schema, _)| key(schema)), + ParsedDiffSet::Patchset(d) => d.tables.sort_by_key(|(schema, _)| key(schema)), + } + } + /// Rename table sections in place. /// /// The callback receives each section name and returns a new name, or diff --git a/tests/sort_tables.rs b/tests/sort_tables.rs new file mode 100644 index 000000000..1a6c32f23 --- /dev/null +++ b/tests/sort_tables.rs @@ -0,0 +1,143 @@ +//! Stable table-section ordering for SQLite changesets and patchsets. +#![cfg(feature = "testing")] + +use rusqlite::Connection; +use sqlite_diff_rs::ParsedDiffSet; +use sqlite_diff_rs::testing::{ + apply_changeset, get_all_rows, session_changeset_and_patchset_with_setup, +}; + +const TABLE_NAMES: [&str; 4] = ["orders", "audit", "products", "tags"]; + +fn setup_table(name: &str) -> String { + format!( + "CREATE TABLE {name} ( + value TEXT, id INTEGER, group_id INTEGER, data BLOB, measure, + PRIMARY KEY (group_id, id) + ); + INSERT INTO {name} VALUES ('old', 1, 7, X'00ff', 1.5); + INSERT INTO {name} VALUES ('deleted', 2, 7, NULL, 2.5);" + ) +} + +fn capture_table(name: &str) -> [Vec; 2] { + let (changeset, patchset) = session_changeset_and_patchset_with_setup( + &[&setup_table(name)], + &[&format!( + "INSERT INTO {name} VALUES ('new', 3, 7, X'fe00', -0.0); + INSERT INTO {name} VALUES ('other', 4, 7, NULL, NULL); + UPDATE {name} SET value = 'updated', measure = 3.5 WHERE id = 1; + DELETE FROM {name} WHERE id = 2;" + )], + ); + [changeset, patchset] +} + +#[test] +fn reordered_sections_preserve_bytes_and_applied_rows() { + let sections = TABLE_NAMES.map(capture_table); + for format in 0..2 { + let input = sections + .each_ref() + .map(|section| section[format].as_slice()) + .concat(); + let mut parsed = ParsedDiffSet::parse(&input).unwrap(); + parsed.sort_tables_by_key(|schema| match schema.name().as_str() { + "products" => 0, + "orders" => 2, + _ => 1, + }); + let output = Vec::::from(parsed); + let expected = [2, 1, 3, 0] + .map(|index| sections[index][format].as_slice()) + .concat(); + assert_eq!(output, expected); + + let original = Connection::open_in_memory().unwrap(); + let reordered = Connection::open_in_memory().unwrap(); + for name in TABLE_NAMES { + original.execute_batch(&setup_table(name)).unwrap(); + reordered.execute_batch(&setup_table(name)).unwrap(); + } + apply_changeset(&original, &input).unwrap(); + apply_changeset(&reordered, &output).unwrap(); + for name in TABLE_NAMES { + assert_eq!( + get_all_rows(&reordered, name), + get_all_rows(&original, name) + ); + } + } +} + +#[test] +fn sorting_by_input_position_preserves_every_permutation() { + let sections = TABLE_NAMES.map(capture_table); + for a in 0..4 { + for b in 0..4 { + for c in 0..4 { + for d in 0..4 { + let order = [a, b, c, d]; + if (0..4).any(|i| order[i + 1..].contains(&order[i])) { + continue; + } + for (format, _) in sections[0].iter().enumerate() { + let input = order.map(|i| sections[i][format].as_slice()).concat(); + let mut parsed = ParsedDiffSet::parse(&input).unwrap(); + parsed.sort_tables_by_key(|schema| { + order + .iter() + .position(|&i| TABLE_NAMES[i] == schema.name()) + .unwrap() + }); + assert_eq!(Vec::::from(parsed), input); + } + } + } + } + } +} + +#[test] +fn equal_keys_preserve_relative_order() { + let sections: Vec<_> = (0..32) + .map(|i| capture_table(&format!("table_{i}"))) + .collect(); + for format in 0..2 { + let input = sections + .iter() + .flat_map(|section| §ion[format]) + .copied() + .collect::>(); + let mut parsed = ParsedDiffSet::parse(&input).unwrap(); + parsed.sort_tables_by_key(|schema| { + schema + .name() + .strip_prefix("table_") + .unwrap() + .parse::() + .unwrap() + % 3 + }); + let expected: Vec<_> = (0..3) + .flat_map(|key| { + sections + .iter() + .enumerate() + .filter(move |(i, _)| i % 3 == key) + }) + .flat_map(|(_, section)| §ion[format]) + .copied() + .collect(); + assert_eq!(Vec::::from(parsed), expected); + } +} + +#[test] +fn sorting_empty_diffsets_preserves_empty_output() { + for marker in *b"TP" { + let mut parsed = ParsedDiffSet::parse(&[marker, 1, 1, b't', 0]).unwrap(); + parsed.sort_tables_by_key(|_| unreachable!("empty diffset has no sections")); + assert_eq!(Vec::::from(parsed), Vec::::new()); + } +}