Repository navigation
fix(sqlite): store every nested column as the JSON text Arrow encodes, and accept a list of any item type - #90
Merged
lukekim merged 1 commit intoOct 18, 2026
Conversation
…, and accept a list of any item type A SQLite table keeps a nested column as JSON text, but only a list of primitives, strings or booleans was accepted and written as JSON. `is_data_type_supported` refused a list of anything else, so a dataset with a `List(Struct)` column (the github connector's `comments`) failed at load with `The field 'comments' has an unsupported data type`. The prepared-statement path serialized a list item by item with `array.value(i)`, so a null item was written as its type's zero value (`["bug","","sqlite"]` for `["bug", NULL, "sqlite"]`), and fell back to Arrow's display text for any other item type and for every struct, which the read cast cannot decode. The inline-SQL path hit `unimplemented!` for the same items. Both paths now write a nested value as the JSON text Arrow's own JSON encoder produces for the row, built once per column per batch, and a list is accepted whenever its item type is. `Union`, `Dictionary` and `RunEndEncoded` columns keep their display-text storage, which this change does not touch. Write half of spiceai/spiceai#3551; the read half (decoding any item type) is in datafusion-federation.
This was referenced Oct 9, 2026
lukekim
marked this pull request as ready for review
October 18, 2026 20:55
There was a problem hiding this comment.
🟡 Changes recommended
Nested validation still admits incompatible types, and inline struct encoding can introduce quadratic work.
2 open issues
What changed in this PR
Updates SQLite nested-column writes to use Arrow’s JSON encoding consistently, preserving nulls and supporting more nested item types.
Changes:
- Expands list and struct validation while rejecting nested Duration values.
- Replaces manual serialization in prepared and inline insert paths.
- Adds nested-type, null-preservation, and round-trip regression tests.
| File | Description |
|---|---|
| core/src/sqlite/write.rs | Adds nested-column regression tests. |
| core/src/sqlite.rs | Uses per-batch JSON encoders for prepared inserts. |
| core/src/sql/db_connection_pool/dbconnection/sqliteconn.rs | Expands nested-type validation. |
| core/src/sql/arrow_sql_gen/statement.rs | Uses Arrow JSON encoding for inline inserts. |
Review effort: Balanced
| // the same text (explicit nulls, no trailing newline) whichever path wrote it. | ||
| let field = Arc::new(Field::new("item", DataType::Struct(fields.clone()), true)); | ||
| let options = EncoderOptions::default().with_explicit_nulls(true); | ||
| let mut encoder = make_encoder(&field, array, &options).map_err(|e| { |
Comment on lines
+41
to
+47
| /// What a column stored as JSON text can hold: every type the table accepts except a | ||
| /// `Duration`, which Arrow's JSON encoder writes as an ISO 8601 period (`PT1S`) that its | ||
| /// reader does not parse back. | ||
| fn is_json_item_supported(data_type: &DataType) -> bool { | ||
| !matches!(data_type, DataType::Duration(_)) | ||
| && <Self as SchemaValidator>::is_data_type_supported(data_type) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
A SQLite table stores a nested column as JSON text (
CreateTableBuilder::build_sqlitegives every nested type a JSON column), but only a list of primitives, strings or booleans was accepted and written as JSON:SqliteConnection::is_data_type_supportedrefused a list of anything else, so a dataset with aList(Struct)column — the github connector'scomments— failed at load withThe field 'comments' has an unsupported data type: List(Struct(...))(GitHub issues connector doesn't work with duckdb and sqlite accelerators spiceai#3551).insert_batch_prepared, the path Spice's accelerator uses) serialized a list item by item witharray.value(i), so a null item was written as its type's zero value —["bug","","sqlite"]for["bug", NULL, "sqlite"]— and fell back to Arrow's display text for any other item type and for every struct, text the read cast cannot decode.InsertBuilder) hitunimplemented!("List to json conversion is not implemented for …")for the same items, and wrote a struct with implicit nulls and a trailing newline, unlike the other path.Every nested column is now written as the JSON text Arrow's own JSON encoder produces for the row (
make_encoder, built once per column per batch), in both paths, and a list or struct is accepted whenever its item type is — except aDuration, which the encoder writes as an ISO 8601 period the reader does not parse, so it is refused at create instead of stored unreadably. Reading the text back is the federation cast's job; spiceai/datafusion-federation#92 teaches it every item type.Stacked on spiceai/datafusion-federation#92.
is_data_type_supportednow accepts lists the pinned federation revision cannot decode (itscast_string_to_liststops at primitives), so the nested-column tests below are red on the pinned2fc4617and this PR stays a draft until the pin moves to that PR's merge commit.Changes
core/src/sql/db_connection_pool/dbconnection/sqliteconn.rs: a list or struct is supported when its items are (is_json_item_supported: anything the table accepts exceptDuration).core/src/sqlite.rs:stores_as_jsonandnested_json_encodersreplaceserialize_list_to_json; the per-row match encodes a nested value with the column's encoder (nullfor a null row) and keeps Arrow's display text only forUnion,DictionaryandRunEndEncoded, whose storage this change does not touch.core/src/sql/arrow_sql_gen/statement.rs:insert_list_into_row_values_jsonandinsert_struct_into_row_values_jsonencode through the same encoder with explicit nulls; theserialize_list_values!macro and theunimplemented!arm are gone. (This path also serves MySQL's JSON columns.)core/src/sqlite/write.rs: test modulenested_columns(lib tests, somake testruns them in CI).Test plan
cargo fmt --all -- --check— clean (EXIT=0)cargo test -p datafusion-table-providers --features sqlite-bundled,sqlite-federation --libon the final HEAD, with federation at build: pin datafusion-federation at spiceai/datafusion-federation#94 #92's head —276 passed; 0 failed,EXIT=0cargo test -p datafusion-table-providers --features sqlite-bundled,sqlite-federation --test integration sqlite::—21 passed; 0 failed; 2 ignored,EXIT=0cargo clippy -p datafusion-table-providers --features sqlite-bundled,sqlite-federation --lib -- -D warnings— clean (EXIT=0)ef71560: everyBuild (*)arm passes;Testsis red exactly as stacked —486 passed; 4 failed, the 4 being the nested-column round trips failing at the cast withUnsupported list item type: Struct(…)on the pinned federation2fc4617(run 37934086334); it turns green when the pin moves to build: pin datafusion-federation at spiceai/datafusion-federation#94 #92.DuckDB Tests (release)died withSIGSEGVinsideduckdb::upsert_retention_memoryat cycle 40 — the soak flake tracked as DuckDB: upsert_retention_memory fails with engine state corruption (checksum-0 blocks, upsert PK violation, allocator bitmask) across unrelated branches spiceai#14183, which this diff does not touch (no DuckDB code changed)Red before the fix
nested_columns_round_trip_through_the_sink::case_1_prepared_statements(the same test asnested_columns_round_trip_through_prepared_statementshere, then incore/tests) on the base write path (core/srcatef2e590) with the federation fix in place:The same tests with this write fix and the base read cast (federation
2fc4617):a_null_item_in_a_list_of_strings_reads_back_nullon the base write path:Tests added (
sqlite::write::tests::nested_columns)nested_columns_round_trip_through_prepared_statements/…_through_inline_sql— one batch withcomments: List(Struct{author: Struct{login}, body})(two comments, one null body; an empty list; a null list; a body needing JSON escaping),large_comments: LargeList(Struct),tags: List(Utf8)with a null item,matrix: List(List(Int32)),dates: List(Date32),pairs: FixedSizeList(Int32, 2)with a null row and a null item,meta: Struct{n, label}with a null row; asserts the raw read is the JSON text, then exact batch equality after the cast.every_leaf_type_round_trips_inside_a_struct_and_a_list_through_prepared_statements/…_inline_sql— a struct with one field per scalar the type check admits (all integer widths,Float32/64,Utf8/LargeUtf8/Utf8View,Boolean,Binary/LargeBinary/BinaryView/FixedSizeBinary,Date32/64,Time32/64in every unit,Timestampin every unit with and without a time zone,Decimal128/256) with a value row, an all-null row and a null struct row, and a list of that struct; exact column equality after the cast.a_null_item_in_a_list_of_strings_reads_back_null— the stored text is["bug",null,"sqlite"]/[null]and the cast equals the original.a_duration_inside_a_list_or_a_struct_is_refused_at_create—List(Duration)andStruct{Duration}fail create naming the field and its type.both_insert_paths_store_the_same_json_text_for_a_struct—{"n":1,"label":"x"},{"n":null,"label":"y"},NULLon both paths.Review gate
codex): jobreview-mv0xz00z-shs0t8on the first HEADa96a8e9, verdictneeds-attention, 1 finding: the newly accepted lists cannot be read back with the committed federation pin2fc4617— valid, and the reason this PR is stacked (above); the pin moves before it leaves draft. On the final HEADef71560codex was out of credits: declared skip (receiptattempt=codex result=exit-1)./code-review high(read-only sub-agent on the diff): 1 confirmed —List(Duration)/Struct{Duration}were accepted but written asPT1S, which the reader cannot parse → refused at create (is_json_item_supported, test above); 1 low — the inline path stored a struct with implicit nulls and a trailing newline → both paths share the encoder (test above); thestores_as_jsondoc over-claimedUnion/RunEndEncoded→ corrected; its leaf-by-leaf trace of the encoder/reader pair is pinned by the leaf-type matrix tests./security-review(read-only sub-agent): no SQL injection on the inline path (sea-query quotes the literal; the JSON encoder escapes",\and control bytes including NUL); no newunsafe, dependency or panic path./simplify: applied before the reviews (shared test setup,let … elsein the encoder lookup,to_owned); fmt, clippy and the lib tests re-run after it and after each review fix.Refs spiceai/spiceai#3551