fix: preserve CDATA, comments and PIs when streaming nested XML - #165
Conversation
read_xml returned different bytes for the same document depending on whether
it took the DOM path or SAX streaming. The reporter found a CDATA section:
DOM kept <![CDATA[a < b & c]]> inside the captured fragment, while SAX
emitted <k>a < b & c</k>.
CDATA turned out to be one of three. SAX reconstructs a nested fragment from
callbacks, and the handler set registered only startElementNs, endElementNs,
characters and cdataBlock -- with cdataBlock delegating straight to
characters. So the section framing was flattened, and comments and processing
instructions were dropped entirely, because libxml2 discards callbacks that
are not registered. Measured on the pinned build: four of five nested cases
diverged from DOM.
The DOM serializer is this extension's reference -- the SAX path already
mirrors it deliberately for CR and for attribute-value escaping -- so make
SAX match it:
- cdataBlock emits <![CDATA[...]]> when reconstructing a nested fragment,
splitting the section around any "]]>" the way libxml2's serializer does,
so the fragment still parses back to the same text.
- new comment and processingInstruction handlers emit <!--...--> and
<?target data?> in the same context.
All three keep the existing depth contract: only relative_depth > 1 is a
nested fragment. At depth 1 the value is scalar text, where a CDATA section
contributes its unwrapped content and comments and PIs contribute nothing --
matching xmlNodeGetContent, which both paths already agreed on and which the
test pins as a control, so preserving framing cannot leak markers into scalar
columns.
Verified against the pinned duckdb submodule (d8cdaa33fd, v1.5.5): all five
nested cases now match DOM byte for byte, the new test passes 31 assertions,
and the full suite passes 4007 assertions in 103 test cases.
Closes #158
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHU19qnngy1CJnXyX1csz
There was a problem hiding this comment.
🟡 Changes recommended
The new CDATA ]]> splitting logic is correctness-critical but currently lacks explicit regression coverage, and two newly added callbacks can be given internal linkage to avoid exporting unused global symbols.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes DOM vs SAX streaming byte-parity when reconstructing nested XML fragments by ensuring SAX preserves CDATA framing, comments, and processing instructions (Issue #158). This aligns the streaming reconstruction contract with the DOM serializer for nested fragments while keeping scalar-text behavior unchanged.
Changes:
- Implement CDATA re-framing in SAX nested-fragment reconstruction, including splitting embedded
]]>sequences. - Add SAX handlers to serialize comments (
<!-- -->) and processing instructions (<? ?>) into nested fragments. - Add a DOM/SAX parity fixture and SQL regression test covering nested vs scalar behavior boundaries.
File summaries
| File | Description |
|---|---|
| test/xml/dom_sax_parity.xml | Adds XML fixture cases for nested vs scalar parity (CDATA/comment/entity/mixed/PI). |
| test/sql/github_issue_158_dom_sax_parity.test | Adds regression tests asserting DOM/SAX byte parity for nested fragments and unchanged scalar-text semantics. |
| src/xml_sax_reader.cpp | Extends SAX handler set and nested-fragment reconstruction to preserve CDATA/comment/PI nodes. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for (size_t i = 0; i < text.size(); i++) { | ||
| if (text[i] == ']' && i + 2 < text.size() && text[i + 1] == ']' && text[i + 2] == '>') { | ||
| framed += "]]]]><![CDATA[>"; | ||
| i += 2; | ||
| } else { |
There was a problem hiding this comment.
Good catch, and already addressed on main in 534127e — independently flagged by a second reviewer with the same reasoning: the splitting loop was the one genuinely new piece of logic and it shipped with no test exercising the case it exists for.
The fixture now carries a cdataclose row with the payload before]]>after, written as the standard two-section encoding, and the test asserts three things about it: DOM and SAX emit the same bytes, the scalar text column still yields the unwrapped before]]>after under both modes, and the reconstructed SAX fragment re-parses to the original text — which is the property that actually matters if the split were wrong.
Measured: both paths emit <k><![CDATA[before]]]]><![CDATA[>after]]></k>, and the round-trip returns before]]>after.
| // fragment, so SAX must too; in scalar text a comment contributes nothing (xmlNodeGetContent skips | ||
| // it), which is what both paths already did. Registering this handler is what stops comments from | ||
| // disappearing from streamed fragments entirely (issue #158). | ||
| void SAXComment(void *ctx, const xmlChar *value) { |
There was a problem hiding this comment.
Not taking this one, though it points at something real.
The sibling SAX callbacks — SAXStartElementNs, SAXEndElementNs, SAXCharacters, SAXCdataBlock — are all non-static and declared in src/include/xml_sax_reader.hpp:106-115. Giving internal linkage to only these two would make the callback set less consistent, not more. The file-local helpers cited as precedent (XmlEscapeText, XmlEscapeAttr, ResolveElementName, MatchesRecordTag) are helpers rather than libxml2 callbacks, so they follow a different convention.
You did find a genuine inconsistency, just not this one: these two are the only SAX callbacks not declared in the header. I'm fixing it that way instead — adding the declarations alongside the other four — in a follow-up.
|
|
||
| // Same contract for processing instructions: preserved inside a nested fragment, ignored in scalar | ||
| // text (issue #158). A PI with no data serializes as "<?target?>". | ||
| void SAXProcessingInstruction(void *ctx, const xmlChar *target, const xmlChar *data) { |
There was a problem hiding this comment.
Not taking this one, though it points at something real.
The sibling SAX callbacks — SAXStartElementNs, SAXEndElementNs, SAXCharacters, SAXCdataBlock — are all non-static and declared in src/include/xml_sax_reader.hpp:106-115. Giving internal linkage to only these two would make the callback set less consistent, not more. The file-local helpers cited as precedent (XmlEscapeText, XmlEscapeAttr, ResolveElementName, MatchesRecordTag) are helpers rather than libxml2 callbacks, so they follow a different convention.
You did find a genuine inconsistency, just not this one: these two are the only SAX callbacks not declared in the header. I'm fixing it that way instead — adding the declarations alongside the other four — in a follow-up.
The splitting loop in SAXCdataBlock is the one piece of genuinely new logic in this fix, and it shipped with no test exercising the case it exists for. A CDATA section cannot contain "]]>", so both serializers have to close and reopen the section around it; if that split were wrong the fragment would be either malformed or silently decode to different text, and nothing in the suite would have noticed. Adds a 'cdataclose' case carrying the payload "before]]>after", written in the fixture as the standard two-section encoding, and asserts three things about it: DOM and SAX emit the same bytes, the scalar text column still yields the unwrapped "before]]>after" under both modes, and -- the property that actually matters -- the reconstructed SAX fragment parses back to the original text. Measured, not assumed: both paths emit <k><![CDATA[before]]]]><![CDATA[>after]]></k> and the round-trip returns before]]>after. Test now passes 38 assertions; full suite 4014 assertions in 103 test cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XoHU19qnngy1CJnXyX1csz
Closes #158.
The report
The same document read through the DOM path and through SAX streaming produced different bytes for a captured XML fragment:
Cause
SAX does not serialize a DOM tree — it reconstructs the nested fragment from parser callbacks. The handler set registered exactly four:
handler.startElementNs = SAXStartElementNs; handler.endElementNs = SAXEndElementNs; handler.characters = SAXCharacters; handler.cdataBlock = SAXCdataBlock; // delegated straight to SAXCharactersTwo consequences, one root cause:
cdataBlockdelegating tocharactersmeant a CDATA section arrived as ordinary text and was re-escaped, losing its framing.So CDATA was one of three, not a defect on its own. Measured on the pinned build, four of five nested cases diverged:
<k><![CDATA[a < b & c]]></k><k>a < b & c</k><k>x<!-- note -->y</k><k>xy</k><k>pre<![CDATA[mid & more]]>post</k><k>premid & morepost</k><k><?target data?>y</k><k>y</k><k>a < b & c</k>Which side is right
The DOM serializer is this extension's reference, and the SAX path already mirrors it deliberately elsewhere —
XmlEscapeTextescapes a literal CR as specifically because "the same node serialized to different bytes under DOM vs SAX", andXmlEscapeAttrmirrors attribute-value normalization for "DOM/SAX byte-parity". CDATA, comments and PIs are the same contract, so SAX is the side that changes.Preserving them is also the information-preserving direction: normalizing DOM instead would discard the distinction.
Fix
cdataBlockemits<![CDATA[...]]>when reconstructing a nested fragment, closing and reopening the section around any]]>exactly as libxml2's serializer does, so the reconstructed fragment still parses back to the same text.commentandprocessingInstructionhandlers emit<!--...-->and<?target data?>in the same context.All three keep the existing depth contract: only
relative_depth > 1is a nested fragment. At depth 1 the value is scalar text, where a CDATA section contributes its unwrapped content and comments and PIs contribute nothing — matchingxmlNodeGetContenton the DOM side.That boundary matters, and the test pins it: preserving CDATA framing in fragments must not start leaking
<![CDATA[markers into scalar VARCHAR columns, and registering comment/PI handlers must not start injecting them into scalar text.Verification
Built against the pinned duckdb submodule (
d8cdaa33fd, v1.5.5). The checkout's previous build was 146 commits off-pin, so it was rebuilt at the pin before anything was measured.After the fix, all five cases agree:
<k><![CDATA[a < b & c]]></k><k>x<!-- note -->y</k><k>a < b & c</k><k>pre<![CDATA[mid & more]]>post</k><k><?target data?>y</k>test/sql/github_issue_158_dom_sax_parity.test: 31 assertions, all passing. Before the fix, four of its five nested rows comparedfalsewhile the scalar control was already alltrue— so the test discriminates between the two paths rather than failing uniformly.The fixture carries each payload twice — once nested as
<s><k>…</k></s>and once as the leaf<t>— so the nested and scalar contracts are checked against the same input.🤖 Generated with Claude Code
https://claude.ai/code/session_01XoHU19qnngy1CJnXyX1csz