Skip to content

Commit 21727d5

Browse files
feat: add ignore_columns to exclude columns from reconciliation
Columns listed in ignore_columns are included in the initial INSERT but excluded from change detection, UPDATE statements, and content hash computation. Useful for timestamps, tokens, or values managed by database triggers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b46f95 commit 21727d5

5 files changed

Lines changed: 382 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Auto-tag workflow: CI automatically creates a git tag when `Cargo.toml` version changes on main, triggering the release workflow.
1212
- `/release` skill for Claude Code: guided release preparation with version determination, confirmation, and PR creation.
13+
- `ignore_columns` option for reconcile mode tables: columns listed in `ignore_columns` are included in the initial INSERT but excluded from change detection, UPDATE statements, and content hash computation. Useful for timestamps, tokens, or values managed by database triggers.
1314

1415
### Fixed
1516
- Replaced Dockerfile `--mount=type=cache` with dependency layer caching ("empty main" trick) for reliable Docker build caching in GitHub Actions, where `--mount=type=cache` does not persist across runners.

docs/seeding.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,28 @@ initium seed --spec /seeds/seed.yaml --reconcile-all
250250
- **Changed rows** (different values for same unique key) are updated.
251251
- **Removed rows** (in DB but not in spec) are deleted.
252252

253+
**Ignoring columns:** Some columns should be set on initial insert but never overwritten during reconciliation (e.g., timestamps, random tokens, or values managed by database triggers). Use `ignore_columns` to exclude them:
254+
255+
```yaml
256+
tables:
257+
- table: users
258+
unique_key: [email]
259+
ignore_columns: [created_at, api_token]
260+
rows:
261+
- email: alice@example.com
262+
name: Alice
263+
created_at: "2026-01-01"
264+
api_token: "$env:ALICE_TOKEN"
265+
```
266+
267+
Ignored columns are:
268+
- **Included** in the initial INSERT (the row is written with all columns).
269+
- **Excluded** from change detection (changing an ignored column's value in the spec does not trigger an update).
270+
- **Excluded** from UPDATE statements (manual or trigger-managed changes in the database are preserved).
271+
- **Excluded** from the content hash (so they don't affect the fast-path skip).
272+
273+
`ignore_columns` cannot overlap with `unique_key`.
274+
253275
**Requirements:**
254276
- Every table in a reconciled seed set must have a `unique_key`. Without it, there is no way to identify which rows correspond to which spec entries.
255277
- Environment variable changes trigger reconciliation (resolved values are compared, not raw templates).

src/seed/executor.rs

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,8 @@ impl<'a> SeedExecutor<'a> {
481481

482482
// Build canonical row_key JSON (sorted by unique key column name)
483483
let row_key = build_row_key(&ts.unique_key, &unique_columns, &unique_values);
484-
// Build row_values JSON (all columns, sorted)
485-
let row_values_json = build_row_values(&columns, &values);
484+
// Build row_values JSON (excluding ignored columns for comparison)
485+
let row_values_json = build_row_values_excluding(&columns, &values, &ts.ignore_columns);
486486

487487
seen_keys.insert(row_key.clone());
488488

@@ -500,16 +500,16 @@ impl<'a> SeedExecutor<'a> {
500500
continue;
501501
}
502502

503-
// Values differ — UPDATE
503+
// Values differ — UPDATE (exclude key columns and ignored columns)
504504
let non_key_columns: Vec<String> = columns
505505
.iter()
506-
.filter(|c| !ts.unique_key.contains(c))
506+
.filter(|c| !ts.unique_key.contains(c) && !ts.ignore_columns.contains(c))
507507
.cloned()
508508
.collect();
509509
let non_key_values: Vec<String> = columns
510510
.iter()
511511
.zip(values.iter())
512-
.filter(|(c, _)| !ts.unique_key.contains(c))
512+
.filter(|(c, _)| !ts.unique_key.contains(c) && !ts.ignore_columns.contains(c))
513513
.map(|(_, v)| v.clone())
514514
.collect();
515515

@@ -700,7 +700,8 @@ impl<'a> SeedExecutor<'a> {
700700
}
701701

702702
let row_key = build_row_key(&ts.unique_key, &unique_columns, &unique_values);
703-
let row_values_json = build_row_values(&columns, &values);
703+
let row_values_json =
704+
build_row_values_excluding(&columns, &values, &ts.ignore_columns);
704705
seen_keys.insert(row_key.clone());
705706

706707
match tracked_values.get(&row_key) {
@@ -740,11 +741,14 @@ fn build_row_key(unique_key_spec: &[String], columns: &[String], values: &[Strin
740741
serde_json::to_string(&map).unwrap_or_default()
741742
}
742743

743-
/// Build a canonical JSON representation of all row values (sorted by column name).
744-
fn build_row_values(columns: &[String], values: &[String]) -> String {
744+
/// Build a canonical JSON representation of row values, excluding specified columns.
745+
/// Ignored columns are excluded from tracking so changes to them don't trigger reconciliation.
746+
fn build_row_values_excluding(columns: &[String], values: &[String], exclude: &[String]) -> String {
745747
let mut map = BTreeMap::new();
746748
for (i, col) in columns.iter().enumerate() {
747-
map.insert(col.clone(), values[i].clone());
749+
if !exclude.contains(col) {
750+
map.insert(col.clone(), values[i].clone());
751+
}
748752
}
749753
serde_json::to_string(&map).unwrap_or_default()
750754
}
@@ -2425,4 +2429,217 @@ phases:
24252429
assert!(result.is_err());
24262430
assert!(result.unwrap_err().contains("no unique_key"));
24272431
}
2432+
2433+
#[test]
2434+
fn test_reconcile_ignore_columns_not_compared() {
2435+
let dir = tempfile::TempDir::new().unwrap();
2436+
let db_path = dir.path().join("test.db");
2437+
let db_path_str = db_path.to_str().unwrap();
2438+
2439+
let sqlite = SqliteDb::connect(db_path_str).unwrap();
2440+
sqlite
2441+
.conn
2442+
.execute_batch(
2443+
"CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT);",
2444+
)
2445+
.unwrap();
2446+
2447+
// Initial apply with updated_at as ignored column
2448+
let yaml1 = r#"
2449+
database:
2450+
driver: sqlite
2451+
url: ":memory:"
2452+
phases:
2453+
- name: phase1
2454+
seed_sets:
2455+
- name: config
2456+
mode: reconcile
2457+
tables:
2458+
- table: config
2459+
unique_key: [key]
2460+
ignore_columns: [updated_at]
2461+
rows:
2462+
- key: app_name
2463+
value: MyApp
2464+
updated_at: "2026-01-01"
2465+
"#;
2466+
let plan1 = SeedPlan::from_yaml(yaml1).unwrap();
2467+
let log = test_logger();
2468+
2469+
let db1 = SqliteDb::connect(db_path_str).unwrap();
2470+
let mut exec1 = SeedExecutor::new(&log, Box::new(db1), "initium_seed".into(), false);
2471+
exec1.execute(&plan1).unwrap();
2472+
2473+
// Verify initial values
2474+
let db_check = SqliteDb::connect(db_path_str).unwrap();
2475+
let val: String = db_check
2476+
.conn
2477+
.query_row(
2478+
"SELECT updated_at FROM config WHERE key = 'app_name'",
2479+
[],
2480+
|r| r.get(0),
2481+
)
2482+
.unwrap();
2483+
assert_eq!(val, "2026-01-01");
2484+
2485+
// Change the ignored column value — should NOT trigger an update
2486+
let yaml2 = r#"
2487+
database:
2488+
driver: sqlite
2489+
url: ":memory:"
2490+
phases:
2491+
- name: phase1
2492+
seed_sets:
2493+
- name: config
2494+
mode: reconcile
2495+
tables:
2496+
- table: config
2497+
unique_key: [key]
2498+
ignore_columns: [updated_at]
2499+
rows:
2500+
- key: app_name
2501+
value: MyApp
2502+
updated_at: "2026-12-31"
2503+
"#;
2504+
let plan2 = SeedPlan::from_yaml(yaml2).unwrap();
2505+
let db2 = SqliteDb::connect(db_path_str).unwrap();
2506+
let mut exec2 = SeedExecutor::new(&log, Box::new(db2), "initium_seed".into(), false);
2507+
exec2.execute(&plan2).unwrap();
2508+
2509+
// updated_at should remain unchanged (ignored column not updated)
2510+
let db_final = SqliteDb::connect(db_path_str).unwrap();
2511+
let val: String = db_final
2512+
.conn
2513+
.query_row(
2514+
"SELECT updated_at FROM config WHERE key = 'app_name'",
2515+
[],
2516+
|r| r.get(0),
2517+
)
2518+
.unwrap();
2519+
assert_eq!(val, "2026-01-01");
2520+
}
2521+
2522+
#[test]
2523+
fn test_reconcile_ignore_columns_still_inserted() {
2524+
let dir = tempfile::TempDir::new().unwrap();
2525+
let db_path = dir.path().join("test.db");
2526+
let db_path_str = db_path.to_str().unwrap();
2527+
2528+
let sqlite = SqliteDb::connect(db_path_str).unwrap();
2529+
sqlite
2530+
.conn
2531+
.execute_batch("CREATE TABLE items (name TEXT PRIMARY KEY, note TEXT);")
2532+
.unwrap();
2533+
2534+
let yaml = r#"
2535+
database:
2536+
driver: sqlite
2537+
url: ":memory:"
2538+
phases:
2539+
- name: phase1
2540+
seed_sets:
2541+
- name: items
2542+
mode: reconcile
2543+
tables:
2544+
- table: items
2545+
unique_key: [name]
2546+
ignore_columns: [note]
2547+
rows:
2548+
- name: item1
2549+
note: "initial note"
2550+
"#;
2551+
let plan = SeedPlan::from_yaml(yaml).unwrap();
2552+
let log = test_logger();
2553+
2554+
let db1 = SqliteDb::connect(db_path_str).unwrap();
2555+
let mut exec = SeedExecutor::new(&log, Box::new(db1), "initium_seed".into(), false);
2556+
exec.execute(&plan).unwrap();
2557+
2558+
// Ignored column should still be present on initial insert
2559+
let db_check = SqliteDb::connect(db_path_str).unwrap();
2560+
let note: String = db_check
2561+
.conn
2562+
.query_row("SELECT note FROM items WHERE name = 'item1'", [], |r| {
2563+
r.get(0)
2564+
})
2565+
.unwrap();
2566+
assert_eq!(note, "initial note");
2567+
}
2568+
2569+
#[test]
2570+
fn test_reconcile_ignore_columns_non_ignored_still_updated() {
2571+
let dir = tempfile::TempDir::new().unwrap();
2572+
let db_path = dir.path().join("test.db");
2573+
let db_path_str = db_path.to_str().unwrap();
2574+
2575+
let sqlite = SqliteDb::connect(db_path_str).unwrap();
2576+
sqlite
2577+
.conn
2578+
.execute_batch(
2579+
"CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT);",
2580+
)
2581+
.unwrap();
2582+
2583+
// Initial
2584+
let yaml1 = r#"
2585+
database:
2586+
driver: sqlite
2587+
url: ":memory:"
2588+
phases:
2589+
- name: phase1
2590+
seed_sets:
2591+
- name: config
2592+
mode: reconcile
2593+
tables:
2594+
- table: config
2595+
unique_key: [key]
2596+
ignore_columns: [updated_at]
2597+
rows:
2598+
- key: setting1
2599+
value: old_value
2600+
updated_at: "2026-01-01"
2601+
"#;
2602+
let plan1 = SeedPlan::from_yaml(yaml1).unwrap();
2603+
let log = test_logger();
2604+
2605+
let db1 = SqliteDb::connect(db_path_str).unwrap();
2606+
let mut exec1 = SeedExecutor::new(&log, Box::new(db1), "initium_seed".into(), false);
2607+
exec1.execute(&plan1).unwrap();
2608+
2609+
// Change value (non-ignored) — should trigger update, but NOT touch updated_at
2610+
let yaml2 = r#"
2611+
database:
2612+
driver: sqlite
2613+
url: ":memory:"
2614+
phases:
2615+
- name: phase1
2616+
seed_sets:
2617+
- name: config
2618+
mode: reconcile
2619+
tables:
2620+
- table: config
2621+
unique_key: [key]
2622+
ignore_columns: [updated_at]
2623+
rows:
2624+
- key: setting1
2625+
value: new_value
2626+
updated_at: "2026-12-31"
2627+
"#;
2628+
let plan2 = SeedPlan::from_yaml(yaml2).unwrap();
2629+
let db2 = SqliteDb::connect(db_path_str).unwrap();
2630+
let mut exec2 = SeedExecutor::new(&log, Box::new(db2), "initium_seed".into(), false);
2631+
exec2.execute(&plan2).unwrap();
2632+
2633+
let db_final = SqliteDb::connect(db_path_str).unwrap();
2634+
let (value, updated_at): (String, String) = db_final
2635+
.conn
2636+
.query_row(
2637+
"SELECT value, updated_at FROM config WHERE key = 'setting1'",
2638+
[],
2639+
|r| Ok((r.get(0)?, r.get(1)?)),
2640+
)
2641+
.unwrap();
2642+
assert_eq!(value, "new_value"); // Non-ignored column updated
2643+
assert_eq!(updated_at, "2026-01-01"); // Ignored column preserved
2644+
}
24282645
}

src/seed/hash.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ pub fn compute_seed_set_hash(
4343
if key.as_str() == "_ref" {
4444
continue;
4545
}
46+
// Ignored columns don't affect the hash — changes to them
47+
// won't trigger reconciliation.
48+
if ts.ignore_columns.contains(key) {
49+
continue;
50+
}
4651
hasher.update(key.as_bytes());
4752
hasher.update(b"=");
4853

@@ -252,4 +257,50 @@ phases:
252257
let h2 = compute_seed_set_hash(&plan2.phases[0].seed_sets[0], &identity_resolver).unwrap();
253258
assert_ne!(h1, h2);
254259
}
260+
261+
#[test]
262+
fn test_hash_ignores_ignored_columns() {
263+
let yaml1 = r#"
264+
database:
265+
driver: sqlite
266+
url: ":memory:"
267+
phases:
268+
- name: p
269+
seed_sets:
270+
- name: s
271+
mode: reconcile
272+
tables:
273+
- table: t
274+
unique_key: [k]
275+
ignore_columns: [note]
276+
rows:
277+
- k: a
278+
note: "version 1"
279+
"#;
280+
let yaml2 = r#"
281+
database:
282+
driver: sqlite
283+
url: ":memory:"
284+
phases:
285+
- name: p
286+
seed_sets:
287+
- name: s
288+
mode: reconcile
289+
tables:
290+
- table: t
291+
unique_key: [k]
292+
ignore_columns: [note]
293+
rows:
294+
- k: a
295+
note: "version 2"
296+
"#;
297+
let plan1 = SeedPlan::from_yaml(yaml1).unwrap();
298+
let plan2 = SeedPlan::from_yaml(yaml2).unwrap();
299+
let h1 = compute_seed_set_hash(&plan1.phases[0].seed_sets[0], &identity_resolver).unwrap();
300+
let h2 = compute_seed_set_hash(&plan2.phases[0].seed_sets[0], &identity_resolver).unwrap();
301+
assert_eq!(
302+
h1, h2,
303+
"hash should be identical when only ignored columns change"
304+
);
305+
}
255306
}

0 commit comments

Comments
 (0)