feat: STRUCT widening and SAX rich-typing for read_xml(union_by_name)#75
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes read_xml(..., union_by_name := true) type-collapsing behavior by introducing recursive STRUCT/LIST type merging during multi-file schema unioning, and by extending the SAX streaming path to preserve and materialize nested structured values (so large files also participate in widening instead of degrading to VARCHAR).
Changes:
- Add
XMLSchemaInference::MergeXMLColumnTypeto recursively mergeSTRUCTfields andLISTelement types, and to use DuckDB’s implicit-cast ladder where available (fallback toVARCHARwhen not). - Refactor duplicate union-by-name merge logic into
MergeInferredColumnsand apply it to both XML and HTML binds. - Upgrade SAX handling to capture nested XML fragments, emit them structurally during inference, and re-parse them during row materialization; plus add regression tests for XML/HTML.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/xml_schema_inference.cpp | Implements recursive type merge + fragment parsing helper + improved VARCHAR extraction for widened complex values. |
| src/include/xml_schema_inference.hpp | Exposes new merge + fragment-extraction APIs used by the reader paths. |
| src/xml_sax_reader.cpp | Adds “rich typing” for SAX by tracking nested XML fragments and re-parsing them into STRUCT/LIST values. |
| src/include/xml_sax_reader.hpp | Extends the accumulator to track nested-XML scalars/lists separately from text scalars/lists. |
| src/xml_reader_functions.cpp | Deduplicates union schema merging and prevents SAX usage when schema contains unsupported types. |
| test/sql/xml_union_by_name.test | Adds union_by_name STRUCT/LIST widening and scalar promotion regression coverage (including SAX-vs-DOM cross-mode). |
| test/sql/html_union_by_name.test | Adds HTML parity coverage for STRUCT widening under union_by_name. |
| test/xml/struct_widen_a1.xml | Test fixture for disjoint STRUCT field widening. |
| test/xml/struct_widen_a2.xml | Test fixture for disjoint STRUCT field widening. |
| test/xml/struct_widen_b1.xml | Test fixture for scalar widening inside STRUCT. |
| test/xml/struct_widen_b2.xml | Test fixture for scalar widening inside STRUCT. |
| test/xml/struct_widen_c1.xml | Test fixture for nested STRUCT widening. |
| test/xml/struct_widen_c2.xml | Test fixture for nested STRUCT widening. |
| test/xml/struct_widen_d1.xml | Test fixture for LIST widening. |
| test/xml/struct_widen_d2.xml | Test fixture for LIST widening. |
| test/xml/struct_widen_e1.xml | Test fixture for scalar promotion (INTEGER side). |
| test/xml/struct_widen_e2.xml | Test fixture for scalar promotion (DOUBLE side). |
| test/xml/struct_widen_f2.xml | Test fixture for scalar collision fallback to VARCHAR. |
| test/xml/struct_widen_g1.xml | Test fixture for STRUCT-vs-VARCHAR collision (STRUCT-shaped). |
| test/xml/struct_widen_g2.xml | Test fixture for STRUCT-vs-VARCHAR collision (text-shaped). |
| test/xml/struct_widen_h1.xml | Test fixture for LIST-vs-STRUCT collision. |
| test/xml/struct_widen_case1.xml | Test fixture for case-insensitive STRUCT field collapse (Foo). |
| test/xml/struct_widen_case2.xml | Test fixture for case-insensitive STRUCT field collapse (foo). |
| test/xml/struct_widen_sax_small.xml | Small XML fixture to exercise DOM path in SAX-vs-DOM union test. |
| test/xml/struct_widen_sax_big.xml | Large XML fixture to force SAX path in SAX-vs-DOM union test. |
| test/html/struct_widen/a1.html | HTML fixture for STRUCT widening parity. |
| test/html/struct_widen/a2.html | HTML fixture for STRUCT widening parity. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for putting this together @bendrucker ! Starting some review |
|
Addressed all three Copilot review comments, plus fixed a portability issue I introduced that broke the I'll still plan to run this branch over the real-world XML dataset where I originally encountered the current behavior before marking as ready. |
read_xml(..., union_by_name := true) collapsed any cross-file STRUCT shape disagreement to VARCHAR with concatenated text. This change adds a recursive type merge and lifts the SAX path to produce STRUCT / LIST<STRUCT> so large files participate in widening instead of bailing out. - MergeXMLColumnType unions STRUCT fields and LIST element types; scalar widening uses TryGetMaxLogicalTypeUnchecked for the implicit-cast ladder and falls back to VARCHAR for pairs with no cast path. - ExtractStructFromNode uses case-insensitive child lookup so cross-file widening (case-insensitive collapse per DuckDB's STRUCT semantics) finds data under either casing. - VARCHAR widening of element-children-bearing nodes serializes via xmlNodeDump instead of concatenating descendant text. - SAX accumulator captures nested fragments in parallel current_xml_values / current_xml_lists; InferSchemaFromStream emits them structurally so DOM inference reconstructs the STRUCT shape; AccumulatorToRow re-parses each fragment via ExtractValueFromXmlFragment. LIST extraction combines text-shape and XML-shape items. Addresses teaguesterling#39 / teaguesterling#41 via the implicit-cast ladder (INTEGER->DOUBLE, DATE->TIMESTAMP, etc.) while avoiding the score-fallback regression where DATE+INTEGER would have collapsed to DATE and thrown at extract.
- Replace LogicalType::TryGetMaxLogicalTypeUnchecked with a portable combination of ForceMaxLogicalType + CastRules::ImplicitCast verification so MergeXMLColumnType compiles against DuckDB v1.4-andium (which has no Unchecked variant). When the candidate is not implicitly castable from both inputs we fall back to VARCHAR, matching the prior intent. - Update the MergeXMLColumnType doc comment to describe the actual cast-verification logic instead of pointing at ForceMaxLogicalType alone. - In the widened-to-VARCHAR fragment serializer, dump every child node (skipping libxml2-marked blank text nodes) with format=0 so mixed content like "foo <b/> bar" survives without pretty-print whitespace. - In the SAX list-of-complex branch, XML-escape raw text items before wrapping them for ExtractValueFromXmlFragment so '&' / '<' / '>' do not break the synthetic wrapper.
ed17f83 to
a45a70a
Compare
|
So glad to see the builds working here! Want to run |
|
Ran |
|
I should have time to test this on my XML data set tomorrow and make sure it handles everything. |
read_xml(union_by_name) collapsed a column to VARCHAR when an element inferred as a scalar STRUCT in one file (single occurrence) but a LIST<STRUCT> in another (multiple occurrences), keeping only the first child and dropping the rest. XML has no up-front schema, so single- vs multi-occurrence of the same element is inherently ambiguous. MergeXMLColumnType now reconciles a scalar T against LIST<T> by treating the singleton as a one-element list and widening element types to LIST<T'>. A reconcilable guard restricts this to compatible shapes (same complex kind, or both scalar) so a genuine collision such as STRUCT vs LIST<VARCHAR> still falls back to VARCHAR. Verified against real Salesforce metadata: profiles/permissionsets objectPermissions and CustomObject fields now widen to STRUCT[] instead of collapsing, with all elements preserved.
…irst read_xml/read_html inferred a repeated element's STRUCT fields by walking only the first occurrence's children. An optional child absent from the first occurrence but present in a later one was dropped from the inferred struct, and its values were silently discarded during extraction. This happened within a single file, independent of union_by_name. InferColumnType now builds the field set via CollectChildNamesInOrder, which scans every occurrence and collects the union of distinct child names in first-seen document order. Both the LIST<STRUCT> and single STRUCT inference paths use it. Extraction is type-driven and already fills absent fields with NULL, so no extraction change is needed. Updates html_complex_types.test, which previously encoded this gap as a documented limitation (skill-item, present only in the second section, was excluded); its TODO(future) assertions are now enabled. Verified against real Salesforce metadata: a single Profile whose first objectPermissions block omits viewAllFields now infers the field and preserves all 242 of its values.
| // LIST column. Combine text-shaped items (current_lists / current_values) and | ||
| // nested-XML items (current_xml_lists / current_xml_values). Document order between | ||
| // text and XML items is not preserved (the accumulator stores them in parallel maps); | ||
| // items within a single map keep their relative order. | ||
| auto child_type = ListType::GetChildType(col_type); |
There was a problem hiding this comment.
Deferring this. Order is preserved within each kind. Only a field mixing bare text and nested-XML siblings loses order between the two groups, which none of the validated Salesforce lists do (uniform complex items). Tracked in #77 for the tagged-sequence accumulator fix before release.
| // Wrap the fragment in its element so the parser sees a single root and so ExtractValueFromNode | ||
| // receives a node whose ->children are the original siblings. | ||
| std::string wrapped; | ||
| wrapped.reserve(inner_xml.size() + wrapper_name.size() * 2 + 5); | ||
| wrapped.append("<") | ||
| .append(wrapper_name) | ||
| .append(">") | ||
| .append(inner_xml) | ||
| .append("</") | ||
| .append(wrapper_name) | ||
| .append(">"); | ||
|
|
||
| XMLDocRAII doc(wrapped); | ||
| if (!doc.doc) { | ||
| return Value(target_type); | ||
| } |
There was a problem hiding this comment.
Deferring this. It only triggers with namespaces := 'keep'. The default strip path removes prefixes before capture, so reparsing is safe there. Confirmed it doesn't affect the Salesforce dataset this PR was validated against, which used default strip mode. Tracked in #77 for a fix (gate to strip, or inject the active namespace declarations into the wrapper).
|
This is looking good! Feel free to mark as "Ready for review" when you think it's ready to merge! I'll start prepping a community extensions repo update once it's all good to go! |
|
Great, planning to do another pass myself this evening, and then I'll mark it. |
The STRUCT branch and the scalar/VARCHAR fallback each read only a subset of the four accumulator maps, so the SAX streaming path diverged from DOM: - The STRUCT branch returned a fully-NULL struct for a text-only occurrence instead of DOM's non-NULL all-fields-NULL shell. It now falls back to GetValue, wrapping the escaped text through ExtractValueFromXmlFragment. - The scalar/VARCHAR fallback dropped every repeated occurrence when union_by_name widened a SAX-inferred list to VARCHAR. It now serializes both GetListValues (text) and GetXmlListValues (nested XML) items, mirroring the LIST branch. Adds test/sql/xml_sax_accumulator_parity.test covering both paths.
|
Took a pass at the follow ups, feel free to merge that branch into this PR if you want, or merge this as-is and I'll open a follow up to close #77. Much rarer edge cases so if you want to take more time to review that it would be fine to leave in a patch release too. |
teaguesterling
left a comment
There was a problem hiding this comment.
Reviewed the full series. The recursive STRUCT/LIST merge, the cardinality reconciliation, the all-occurrences CollectChildNamesInOrder fix (with the previously-disabled html_complex_types.test assertions re-enabled), and the SAX-DOM parity work in AccumulatorToRow all hang together. The portable ForceMaxLogicalType + CastRules::ImplicitCast formulation is exactly the right call for the v1.4-andium → v1.5.x window, and the reconcilable guard cleanly keeps singleton-vs-LIST widening from over-reaching into genuine STRUCT-vs-LIST collisions. Test coverage matches the changes one-to-one (xml_union_by_name, xml_union_struct_list_cardinality, xml_inference_all_siblings, xml_sax_accumulator_parity, the HTML parity fixtures). All 16 CI jobs green.
Cleared the stale pending review from May 23 — both of those questions are answered by the current code: the XmlEscapeText/XmlEscapeAttr helpers in a45a70a cover the escaping concern in xml_sax_reader.cpp, and the stylistic difference vs. ExtractValueFromXmlFragment in xml_schema_inference.cpp is intentional — that site re-wraps already-serialized XML fragments rather than synthesizing from arbitrary text.
Merging as-is. Please open the sax-ns-order follow-up against main when you're ready and I'll review #77 there — both commits look strong to me already (the FieldOccurrence refactor is the cleaner-shape accumulator you flagged as out-of-scope in this PR's own description, and the ns-decl injection is gated on namespaces:='keep' so default behavior is untouched).
Note for ordering with #79 (the v1.5.3 bump I have open in parallel): no conflict expected — your portable cast formulation builds cleanly on v1.5.3, and the v1.5.3 PR doesn't touch any of the schema-inference or SAX code paths.
Thank you for this!
- vcpkg.json manifest 2.0.1 -> 2.1.0 - RELEASE_NOTES_v2.1.0.md covering the four merged PRs since v2.0.1: - #75 STRUCT widening + SAX rich-typing (@bendrucker) - #76 DuckDB main v1.6 build compatibility (@bendrucker) - #77 SAX edge cases (FieldOccurrence + ns decls) via #80 (@bendrucker) - #79 duckdb v1.5.3 stable bump + MSVC C++17 + mingw exclusion Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
read_xml(..., union_by_name := true)collapsed any cross-fileSTRUCTshape disagreement toVARCHAR, silently flattening one side's data to concatenated text and dropping the other side entirely. This change adds a recursive type-merge step and threads structured types through the SAX path so files abovemaximum_file_sizeparticipate in widening instead of bailing out.What changed
XMLSchemaInference::MergeXMLColumnTyperecursively unionsSTRUCTfields (first-seen casing wins) andLISTelement types. For scalars it picks a candidate viaForceMaxLogicalType, then keeps it only when both sides have an implicit cast path to it (CastRules::ImplicitCast). That gives the cast ladder (INTEGER->DOUBLE,DATE->TIMESTAMP, etc., as discussed in Type inference should promote DATE to TIMESTAMP when both are present #39 / Refactor GetMostSpecificType to use DuckDB's LogicalType::ForceMaxLogicalType #41). Pairs with no cast path (e.g.DATEvsINTEGER) fall back toVARCHAR, so non-temporal data is not parsed asDATEat extract time. This avoidsTryGetMaxLogicalTypeUnchecked, which only exists on newer DuckDB.MergeXMLColumnTypealso reconciles cardinality mismatches. XML has no up-front schema, so an element occurring once infers as a scalarTwhile the same element occurring 2+ times in another file infers asLIST<T>. The merge now promotes the singleton to a one-element list and widens the element types toLIST<T'>rather than colliding toVARCHAR. Areconcilableguard limits this to compatible shapes (same complex kind, or both scalar), so a genuine mismatch such asSTRUCTvsLIST<VARCHAR>still falls back toVARCHAR.STRUCTfields from the union of child names across all occurrences (CollectChildNamesInOrder), in first-seen document order, instead of reading only the first occurrence. An optional child absent from the first occurrence but present in a later one is now part of the inferred struct (nullable) instead of being dropped at inference and discarded at extraction. This applies to bothread_xmlandread_htmland is independent ofunion_by_name.MergeInferredColumnsextracted from the two duplicate union-merge blocks in theread_xmlandread_htmlbinds.ExtractStructFromNodeuses case-insensitive child lookup so cross-file widening (which collapses case-distinct names per DuckDB's STRUCT semantics) finds data under either casing.VARCHARbut the source node has element children, the DOM extractor now serializes viaxmlNodeDumpinstead of concatenating descendant text.current_xml_values/current_xml_lists;InferSchemaFromStreamemits them structurally into the synthetic XML so DOM inference reconstructs the STRUCT shape;AccumulatorToRowre-parses each fragment via a newExtractValueFromXmlFragmenthelper. Every branch ofAccumulatorToRowreconciles the text and nested-XML accumulator maps so no occurrence is silently dropped and the SAX values match DOM: theLISTbranch combines text-shape and XML-shape items, theSTRUCTbranch falls back to text content (yielding DOM's non-NULL struct shell with NULL fields) when a field has no element children, and a column widened toVARCHARserializes its repeated text and fragment items instead of emitting NULL.Tests
test/sql/xml_union_by_name.testcovers:STRUCTfieldsSTRUCT(STRUCT(...))LIST<STRUCT>STRUCT-vs-VARCHARcollisions (with the new fragment serialization)LIST-vs-STRUCTcollisionsmaximum_file_sizeand the other through DOMtest/sql/xml_union_struct_list_cardinality.testcovers the singleton-vs-LIST<STRUCT>widening: type is a widenedLIST<STRUCT>in both file orders, all elements survive, and a nested field that is itself singleton-vs-list widens too.test/sql/xml_inference_all_siblings.testcovers single-file inference of an optional child that appears only in a later occurrence.test/sql/xml_sax_accumulator_parity.testcovers SAX/DOM parity for a text-onlySTRUCTfield and for list-shaped columns that widen toVARCHAR, exercising both accumulator-map fallbacks.test/sql/html_complex_types.testpreviously documented the all-occurrences gap as a known limitation (askill-itemfield present only in the secondsectionwas excluded); its disabled assertions are now enabled and pass.HTML parity test added under
test/html/struct_widen/.Pre-review checklist
Out of scope / follow-ups considered
current_valuesvscurrent_xml_values, and the matching list maps). The cleaner shape is one map of(name) -> Variant<Text, XmlFragment>that preserves document order. Today, when a single field has both text and XML items, the branches combine them but the source-document order between the two kinds is not preserved. Tracked in read_xml SAX rich-typing: namespace declarations dropped on reparsed fragments and list order lost for mixed text/XML occurrences #77.ExtractValueFromXmlFragmentpays axmlNewParserCtxt/xmlCtxtReadMemory/ free cycle per row. For large SAX files withSTRUCTcolumns this is the dominant cost.namespaces := 'keep', captured inner XML can reference prefixes without theirxmlns:declarations, soExtractValueFromXmlFragmentfails to parse and the value drops to NULL (this includes the newSTRUCTtext fallback). Defaultstripmode is unaffected. Tracked in read_xml SAX rich-typing: namespace declarations dropped on reparsed fragments and list order lost for mixed text/XML occurrences #77.InferSchemaFromStreambuffers the whole synthetic document before handing it to DOM inference. Could stream-feed instead.MAPwidening. The XML inference layer does not produceMAPcolumns, so the merge does not try to handle them.