Skip to content

Commit 93139e2

Browse files
committed
feat(executor): version-aware binary tuple decoding for strict collections
`binary_tuple_to_value` now detects when a stored tuple's schema version is behind the current catalog version and decodes using `schema_for_version` to match the physical column layout at write time. Columns added after the tuple's version are filled with their DEFAULT value (or NULL) rather than causing an index-out-of-bounds or returning corrupt data. `binary_tuple_to_json` is refactored to delegate to `binary_tuple_to_value` so the version-aware path is shared across both read modes without duplication. Remaining `StrictSchema` construction sites in the executor initialise `dropped_columns` to keep all call sites consistent. Add integration tests covering the full lifecycle of schema-altering DDL on a strict collection: - Pre-ALTER rows return correct values for existing columns after ADD COLUMN - Pre-ALTER rows return the column DEFAULT for newly added columns - Updating a pre-ALTER row migrates it to the current schema - DROP COLUMN leaves pre-drop rows readable for surviving columns - Multiple ADD COLUMN operations in sequence remain readable - RENAME COLUMN and ALTER COLUMN TYPE on pre-existing rows
1 parent 7f5232f commit 93139e2

5 files changed

Lines changed: 318 additions & 11 deletions

File tree

nodedb/src/data/executor/handlers/convert.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ impl CoreLoop {
105105
let schema = StrictSchema {
106106
columns,
107107
version: 1,
108+
dropped_columns: Vec::new(),
108109
};
109110

110111
// Scan all existing documents.

nodedb/src/data/executor/handlers/document/read.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ mod tests {
626626
ColumnDef::nullable("age", ColumnType::Int64),
627627
],
628628
version: 1,
629+
dropped_columns: Vec::new(),
629630
};
630631
let mut map = std::collections::HashMap::new();
631632
map.insert("id".into(), Value::String("u1".into()));

nodedb/src/data/executor/strict_format.rs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,37 @@ pub(super) fn binary_tuple_to_value(tuple_bytes: &[u8], schema: &StrictSchema) -
8585
return None;
8686
}
8787

88-
let values = decoder.extract_all(tuple_bytes).ok()?;
89-
88+
// Version-aware decoding: if the tuple was written with an older schema
89+
// (fewer columns due to ADD COLUMN), build a sub-schema decoder matching
90+
// the physical layout and fill defaults for new columns.
9091
let mut map = std::collections::HashMap::with_capacity(schema.columns.len());
91-
for (i, col) in schema.columns.iter().enumerate() {
92-
map.insert(col.name.clone(), values[i].clone());
92+
if version < schema.version {
93+
let old_schema = schema.schema_for_version(version);
94+
let old_decoder = nodedb_strict::TupleDecoder::new(&old_schema);
95+
let old_values = old_decoder.extract_all(tuple_bytes).ok()?;
96+
97+
// Map old columns by name.
98+
for (i, col) in old_schema.columns.iter().enumerate() {
99+
map.insert(col.name.clone(), old_values[i].clone());
100+
}
101+
// Fill defaults for columns added after this tuple's version.
102+
for col in &schema.columns {
103+
if col.added_at_version > version {
104+
let default_val = col
105+
.default
106+
.as_deref()
107+
.map(StrictSchema::parse_default_literal)
108+
.unwrap_or(Value::Null);
109+
map.insert(col.name.clone(), default_val);
110+
}
111+
}
112+
} else {
113+
let values = decoder.extract_all(tuple_bytes).ok()?;
114+
for (i, col) in schema.columns.iter().enumerate() {
115+
map.insert(col.name.clone(), values[i].clone());
116+
}
93117
}
118+
94119
Some(Value::Object(map))
95120
}
96121

@@ -108,14 +133,20 @@ pub(super) fn binary_tuple_to_json(
108133
tuple_bytes: &[u8],
109134
schema: &StrictSchema,
110135
) -> Option<serde_json::Value> {
111-
let decoder = nodedb_strict::TupleDecoder::new(schema);
112-
let values = decoder.extract_all(tuple_bytes).ok()?;
113-
114-
let mut obj = serde_json::Map::with_capacity(schema.columns.len());
115-
for (i, col) in schema.columns.iter().enumerate() {
116-
obj.insert(col.name.clone(), value_to_json(&values[i]));
136+
// Delegate to binary_tuple_to_value (which handles version-aware decoding)
137+
// then convert Value → JSON.
138+
let val = binary_tuple_to_value(tuple_bytes, schema)?;
139+
match val {
140+
Value::Object(map) => {
141+
let mut obj = serde_json::Map::with_capacity(map.len());
142+
for col in &schema.columns {
143+
let v = map.get(&col.name).unwrap_or(&Value::Null);
144+
obj.insert(col.name.clone(), value_to_json(v));
145+
}
146+
Some(serde_json::Value::Object(obj))
147+
}
148+
_ => None,
117149
}
118-
Some(serde_json::Value::Object(obj))
119150
}
120151

121152
/// Coerce a `nodedb_types::Value` to match a column's declared type.
@@ -372,6 +403,7 @@ mod tests {
372403
ColumnDef::nullable("age", ColumnType::Int64),
373404
],
374405
version: 1,
406+
dropped_columns: Vec::new(),
375407
}
376408
}
377409

nodedb/tests/executor_tests/test_cross_type_join.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ fn document_scan_preserves_kv_rows_when_collection_has_strict_config() {
9696
ColumnDef::nullable("lang", ColumnType::String),
9797
],
9898
version: 1,
99+
dropped_columns: Vec::new(),
99100
},
100101
},
101102
enforcement: Box::new(EnforcementOptions::default()),

nodedb/tests/sql_transactions.rs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,275 @@ async fn alter_collection_alter_column_type() {
226226
.unwrap();
227227
assert_eq!(rows.len(), 1);
228228
}
229+
230+
// ── Pre-ALTER row survival tests ──────────────────────────────────────
231+
//
232+
// Every test below verifies that rows written BEFORE a schema-altering DDL
233+
// remain readable with correct values AFTER the DDL. The bug class is:
234+
// catalog schema mutated without row migration or read-time compat shim.
235+
236+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
237+
async fn add_column_preserves_pre_alter_row_existing_columns() {
238+
let server = TestServer::start().await;
239+
240+
server
241+
.exec("CREATE COLLECTION ac_preserve TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)")
242+
.await
243+
.unwrap();
244+
server
245+
.exec("INSERT INTO ac_preserve (id, name) VALUES ('a', 'alice')")
246+
.await
247+
.unwrap();
248+
249+
server
250+
.exec("ALTER TABLE ac_preserve ADD COLUMN note TEXT DEFAULT 'n/a'")
251+
.await
252+
.unwrap();
253+
254+
// Pre-ALTER row must still return correct values for original columns.
255+
let rows = server
256+
.query_text("SELECT id, name FROM ac_preserve WHERE id = 'a'")
257+
.await
258+
.unwrap();
259+
assert_eq!(rows.len(), 1, "pre-ALTER row must be visible");
260+
assert!(
261+
rows[0].contains("\"name\":\"alice\""),
262+
"original column 'name' must retain its value, got {:?}",
263+
rows[0]
264+
);
265+
// Regression guard: must NOT return null-everywhere.
266+
assert!(
267+
!rows[0].contains("\"name\":null"),
268+
"pre-ALTER row must not have null-everywhere corruption, got {:?}",
269+
rows[0]
270+
);
271+
}
272+
273+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
274+
async fn add_column_returns_default_for_pre_alter_row() {
275+
let server = TestServer::start().await;
276+
277+
server
278+
.exec("CREATE COLLECTION ac_default TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)")
279+
.await
280+
.unwrap();
281+
server
282+
.exec("INSERT INTO ac_default (id, name) VALUES ('a', 'alice')")
283+
.await
284+
.unwrap();
285+
286+
server
287+
.exec("ALTER TABLE ac_default ADD COLUMN note TEXT DEFAULT 'n/a'")
288+
.await
289+
.unwrap();
290+
291+
// The new column should virtual-fill with its DEFAULT for pre-ALTER rows.
292+
let rows = server
293+
.query_text("SELECT id, name, note FROM ac_default WHERE id = 'a'")
294+
.await
295+
.unwrap();
296+
assert_eq!(rows.len(), 1, "pre-ALTER row must be visible");
297+
assert!(
298+
rows[0].contains("\"note\":\"n/a\""),
299+
"new column must return DEFAULT value for pre-ALTER rows, got {:?}",
300+
rows[0]
301+
);
302+
}
303+
304+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
305+
async fn add_column_then_update_pre_alter_row() {
306+
let server = TestServer::start().await;
307+
308+
server
309+
.exec("CREATE COLLECTION ac_update TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)")
310+
.await
311+
.unwrap();
312+
server
313+
.exec("INSERT INTO ac_update (id, name) VALUES ('a', 'alice')")
314+
.await
315+
.unwrap();
316+
317+
server
318+
.exec("ALTER TABLE ac_update ADD COLUMN note TEXT DEFAULT 'n/a'")
319+
.await
320+
.unwrap();
321+
322+
// Updating a pre-ALTER row must succeed and preserve all columns.
323+
server
324+
.exec("UPDATE ac_update SET note = 'updated' WHERE id = 'a'")
325+
.await
326+
.unwrap();
327+
328+
let rows = server
329+
.query_text("SELECT id, name, note FROM ac_update WHERE id = 'a'")
330+
.await
331+
.unwrap();
332+
assert_eq!(rows.len(), 1);
333+
assert!(
334+
rows[0].contains("\"name\":\"alice\""),
335+
"original column must survive update, got {:?}",
336+
rows[0]
337+
);
338+
assert!(
339+
rows[0].contains("\"note\":\"updated\""),
340+
"updated column must reflect new value, got {:?}",
341+
rows[0]
342+
);
343+
}
344+
345+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
346+
async fn multiple_add_columns_preserves_pre_alter_row() {
347+
let server = TestServer::start().await;
348+
349+
server
350+
.exec("CREATE COLLECTION ac_multi TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)")
351+
.await
352+
.unwrap();
353+
server
354+
.exec("INSERT INTO ac_multi (id, name) VALUES ('a', 'alice')")
355+
.await
356+
.unwrap();
357+
358+
server
359+
.exec("ALTER TABLE ac_multi ADD COLUMN col1 INT DEFAULT 0")
360+
.await
361+
.unwrap();
362+
server
363+
.exec("ALTER TABLE ac_multi ADD COLUMN col2 TEXT DEFAULT 'x'")
364+
.await
365+
.unwrap();
366+
367+
// Two sequential ADD COLUMNs compound the schema drift — pre-ALTER row
368+
// must still be readable with correct values and defaults.
369+
let rows = server
370+
.query_text("SELECT id, name, col1, col2 FROM ac_multi WHERE id = 'a'")
371+
.await
372+
.unwrap();
373+
assert_eq!(
374+
rows.len(),
375+
1,
376+
"pre-ALTER row must be visible after two ADD COLUMNs"
377+
);
378+
assert!(
379+
rows[0].contains("\"name\":\"alice\""),
380+
"original column must retain value, got {:?}",
381+
rows[0]
382+
);
383+
// Regression guard: null-everywhere means total schema-data offset corruption.
384+
assert!(
385+
!rows[0].contains("\"name\":null"),
386+
"must not exhibit null-everywhere corruption, got {:?}",
387+
rows[0]
388+
);
389+
}
390+
391+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
392+
async fn drop_column_preserves_pre_alter_row_remaining_columns() {
393+
let server = TestServer::start().await;
394+
395+
server
396+
.exec(
397+
"CREATE COLLECTION dc_preserve TYPE DOCUMENT STRICT (\
398+
id TEXT PRIMARY KEY, \
399+
name TEXT NOT NULL, \
400+
scratch TEXT)",
401+
)
402+
.await
403+
.unwrap();
404+
server
405+
.exec("INSERT INTO dc_preserve (id, name, scratch) VALUES ('a', 'alice', 'temp')")
406+
.await
407+
.unwrap();
408+
409+
server
410+
.exec("ALTER COLLECTION dc_preserve DROP COLUMN scratch")
411+
.await
412+
.unwrap();
413+
414+
// Remaining columns of the pre-ALTER row must read correctly.
415+
let rows = server
416+
.query_text("SELECT id, name FROM dc_preserve WHERE id = 'a'")
417+
.await
418+
.unwrap();
419+
assert_eq!(
420+
rows.len(),
421+
1,
422+
"pre-ALTER row must be visible after DROP COLUMN"
423+
);
424+
assert!(
425+
rows[0].contains("\"name\":\"alice\""),
426+
"remaining column 'name' must retain its value after DROP COLUMN, got {:?}",
427+
rows[0]
428+
);
429+
}
430+
431+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
432+
async fn rename_column_preserves_pre_alter_row_value() {
433+
let server = TestServer::start().await;
434+
435+
server
436+
.exec(
437+
"CREATE COLLECTION rc_preserve TYPE DOCUMENT STRICT (\
438+
id TEXT PRIMARY KEY, \
439+
name TEXT NOT NULL, \
440+
score INT DEFAULT 0)",
441+
)
442+
.await
443+
.unwrap();
444+
server
445+
.exec("INSERT INTO rc_preserve (id, name, score) VALUES ('a', 'alice', 42)")
446+
.await
447+
.unwrap();
448+
449+
server
450+
.exec("ALTER COLLECTION rc_preserve RENAME COLUMN score TO points")
451+
.await
452+
.unwrap();
453+
454+
// Pre-ALTER row must be readable under the new column name with correct value.
455+
let rows = server
456+
.query_text("SELECT id, name, points FROM rc_preserve WHERE id = 'a'")
457+
.await
458+
.unwrap();
459+
assert_eq!(rows.len(), 1);
460+
assert!(
461+
rows[0].contains("\"points\":42") || rows[0].contains("\"points\": 42"),
462+
"renamed column must retain pre-ALTER value, got {:?}",
463+
rows[0]
464+
);
465+
}
466+
467+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
468+
async fn alter_column_type_preserves_pre_alter_row_value() {
469+
let server = TestServer::start().await;
470+
471+
server
472+
.exec(
473+
"CREATE COLLECTION at_preserve TYPE DOCUMENT STRICT (\
474+
id TEXT PRIMARY KEY, \
475+
value INT NOT NULL)",
476+
)
477+
.await
478+
.unwrap();
479+
server
480+
.exec("INSERT INTO at_preserve (id, value) VALUES ('a', 42)")
481+
.await
482+
.unwrap();
483+
484+
server
485+
.exec("ALTER COLLECTION at_preserve ALTER COLUMN value TYPE BIGINT")
486+
.await
487+
.unwrap();
488+
489+
// Pre-ALTER row must still read correctly after type widening.
490+
let rows = server
491+
.query_text("SELECT id, value FROM at_preserve WHERE id = 'a'")
492+
.await
493+
.unwrap();
494+
assert_eq!(rows.len(), 1);
495+
assert!(
496+
rows[0].contains("\"value\":42") || rows[0].contains("\"value\": 42"),
497+
"value must survive ALTER COLUMN TYPE, got {:?}",
498+
rows[0]
499+
);
500+
}

0 commit comments

Comments
 (0)