Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,7 @@ impl<N: AsRef<str> + 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.
Expand Down Expand Up @@ -328,6 +325,14 @@ impl ParsedDiffSet {
}
}

/// Stably reorder table sections by `key`, preserving each schema and its operations.
pub fn sort_tables_by_key<K: Ord>(&mut self, mut key: impl FnMut(&TableSchema<String>) -> 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
Expand Down
143 changes: 143 additions & 0 deletions tests/sort_tables.rs
Original file line number Diff line number Diff line change
@@ -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<u8>; 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::<u8>::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::<u8>::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| &section[format])
.copied()
.collect::<Vec<_>>();
let mut parsed = ParsedDiffSet::parse(&input).unwrap();
parsed.sort_tables_by_key(|schema| {
schema
.name()
.strip_prefix("table_")
.unwrap()
.parse::<usize>()
.unwrap()
% 3
});
let expected: Vec<_> = (0..3)
.flat_map(|key| {
sections
.iter()
.enumerate()
.filter(move |(i, _)| i % 3 == key)
})
.flat_map(|(_, section)| &section[format])
.copied()
.collect();
assert_eq!(Vec::<u8>::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::<u8>::from(parsed), Vec::<u8>::new());
}
}
Loading