diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f59ddb..5a7645a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,10 +51,14 @@ left over from a previous run. Before computing deltas, the library detects field layout changes by comparing each table's stored fields in the STATE file against the current config's -`ordered_field_names()`. Tables whose layout changed are recorded in the block -as a `TableChange` with no delta (`delta: None`), signaling that patch -consolidation should use a full state snapshot for that table instead of -attempting to merge incompatible deltas. +canonical field list (primary keys first, then subsidiaries; each group sorted +lexicographically by name). + +Because tuple identity is canonical, reordering fields in `tables.toml` does not +register as a layout change. Adding, removing, or renaming a field does. Tables +whose layout changed are recorded in the block as a `TableChange` with no delta +(`delta: None`), signaling that patch consolidation should use a full state +snapshot for that table instead of attempting to merge incompatible deltas. All table changes are bundled into a block together with a parent hash and a timestamp, SHA-1 hashed, and stored as a file named by its hash. The `HEAD` diff --git a/README.md b/README.md index 8b9af0d..e35a364 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,13 @@ Config can be `config.toml` or `config.json`. - Each table must have at least one field marked `primary-key = true` - Field names within a table must be unique +- When `header = false` (the default), CSV columns are mapped to config fields by + position — the first column maps to the first field, etc. - When `header = true`, the first row of the CSV is treated as a header. Each config field is matched to a CSV column by name, so columns may appear in any order. Every config field name must be present in the header; extra CSV columns - are ignored. When `header = false` (the default), CSV columns are mapped to - config fields by position — the first column maps to the first field, etc. + are ignored. In this mode, the order in which fields are declared under the + table is cosmetic — reordering them does not invalidate existing state. - The type field controls how values are quoted in generated SQL. These are not database column types — your database may use any compatible type (e.g. `INTEGER`, `FLOAT`, `TIMESTAMP`). It is your responsibility to ensure the @@ -114,9 +116,9 @@ fields = [ ] ``` -`BOOLEAN` fields can override the strings recognised as true and false. When +`BOOLEAN` fields can override the strings recognized as true and false. When either override is set, only the configured strings are accepted — the -defaults are not honoured alongside them. The `true`, `false`, and `null` +defaults are not honored alongside them. The `true`, `false`, and `null` sentinels on a single field must all differ. ```toml diff --git a/tests/accept_field_reorder.rs b/tests/accept_field_reorder.rs new file mode 100644 index 0000000..23694e1 --- /dev/null +++ b/tests/accept_field_reorder.rs @@ -0,0 +1,67 @@ +mod common; + +use leech2::block::Block; +use leech2::config::Config; +use leech2::patch::Patch; +use leech2::sql; + +/// Reordering fields in `tables.toml` between two blocks must not produce a +/// spurious delta when the underlying CSV data is unchanged. Tuple identity +/// is canonical (lexicographic by field name) so that the field declaration +/// order in the config is cosmetic. +#[test] +fn test_field_reorder_in_config_produces_no_delta() { + common::init_logging(); + let tmp = tempfile::tempdir().unwrap(); + let work_dir = tmp.path(); + + common::write_csv( + work_dir, + "users.csv", + "id,name,email\n1,Alice,a@example.com\n2,Bob,b@example.com\n", + ); + + common::write_config( + work_dir, + "config.toml", + r#" +[tables.users] +source = "users.csv" +header = true +fields = [ + { name = "id", type = "NUMBER", primary-key = true }, + { name = "name", type = "TEXT" }, + { name = "email", type = "TEXT" }, +] +"#, + ); + let hash1 = Block::create(&Config::load(work_dir).unwrap()).unwrap(); + + // Reorder fields (id is still the only primary key, but its position in + // the declared field list moves; subsidiaries are also reordered). + common::write_config( + work_dir, + "config.toml", + r#" +[tables.users] +source = "users.csv" +header = true +fields = [ + { name = "email", type = "TEXT" }, + { name = "id", type = "NUMBER", primary-key = true }, + { name = "name", type = "TEXT" }, +] +"#, + ); + let config = Config::load(work_dir).unwrap(); + let _hash2 = Block::create(&config).unwrap(); + + // Patch from hash1 should be empty: same data, just a different + // declaration order. + let patch = Patch::create(&config, &hash1).unwrap(); + if let Some(s) = sql::patch_to_sql(&config, &patch).unwrap() { + assert_eq!(s.trim(), "BEGIN;\nCOMMIT;"); + } + + common::assert_wire_roundtrip(&config, &patch); +}