From fc3a86da1b188a40e5756abfc57c2fd763f7db47 Mon Sep 17 00:00:00 2001 From: Jesse Wright Date: Fri, 3 Jul 2026 22:03:38 +0000 Subject: [PATCH 1/2] fix: serialize xsd:boolean literals with lexical form "true" correctly (#772) The Turtle serializer normalized xsd:boolean literals by only checking for the lexical form "1", so literals with the (most common) lexical form "true" were silently serialized as "false", corrupting data on round-trip. Accept both canonical ("true"/"false") and numeric ("1"/"0") lexical forms of the xsd:boolean lexical space when normalizing, and add unit tests covering all four forms. Fixes #772 Co-Authored-By: Claude Fable 5 --- src/serializer.js | 2 +- tests/unit/serialize-test.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/serializer.js b/src/serializer.js index 4284b7e8..0d3e8287 100644 --- a/src/serializer.js +++ b/src/serializer.js @@ -577,7 +577,7 @@ export class Serializer { } case 'http://www.w3.org/2001/XMLSchema#boolean': - return expr.value === '1' ? 'true' : 'false' + return expr.value === '1' || expr.value === 'true' ? 'true' : 'false' } } var str = this.stringToN3(expr.value, this.flags) diff --git a/tests/unit/serialize-test.js b/tests/unit/serialize-test.js index 311e7061..a3c5da82 100644 --- a/tests/unit/serialize-test.js +++ b/tests/unit/serialize-test.js @@ -240,6 +240,42 @@ example:subject schema2:predicate 123e-2 . }) + describe('booleans', () => { + const serializeBoolean = (lexicalForm) => { + const doc = sym("https://example.net/doc") + const statement = st( + sym('https://subject.example'), + sym('https://predicate.example'), + lit(lexicalForm, undefined, sym("http://www.w3.org/2001/XMLSchema#boolean")), + doc + ) + const kb = graph() + kb.add(statement) + return serialize(doc, kb, null, 'text/turtle') + } + const expected = (token) => `@prefix : . + + ${token}. + +` + + it('lexical form "true" serializes to true', () => { + expect(serializeBoolean('true')).to.equal(expected('true')) + }) + + it('lexical form "false" serializes to false', () => { + expect(serializeBoolean('false')).to.equal(expected('false')) + }) + + it('lexical form "1" serializes to true', () => { + expect(serializeBoolean('1')).to.equal(expected('true')) + }) + + it('lexical form "0" serializes to false', () => { + expect(serializeBoolean('0')).to.equal(expected('false')) + }) + }) + describe('namespaces', () => { it('producing [prefix][colon] [dot]', () => { // when a symbol has a trailing slash, the automatic prefix production results in a prefixed symbol with no local name From c13c99c6c64a1680a5665d5ead28706e62cb1580 Mon Sep 17 00:00:00 2001 From: Jesse Wright Date: Sat, 4 Jul 2026 01:09:19 +0000 Subject: [PATCH 2/2] fix: serialize typed literals with invalid lexical forms verbosely Addresses @jeswr's review on #827: the Turtle serializer abbreviated xsd:integer/decimal/double/boolean literals to native tokens based only on the datatype URI, without checking the lexical form. Invalid forms were mangled or silently lost: - lit('yes', xsd:boolean) serialized as false (value lost) - lit('abc', xsd:integer) serialized as bare abc (invalid Turtle) - lit('1.2.3', xsd:decimal) serialized as 1.2.3 (invalid Turtle) - lit('NaN'/'INF', xsd:double) serialized as NaN.0e0 (mangled) Now each case abbreviates only when the value is a valid lexical form that Turtle can express natively, and otherwise falls through to the verbose "value"^^datatype serialization, so round-trips are lossless. INF/-INF/NaN are valid xsd:double values but have no native Turtle DOUBLE token, so they also serialize verbosely. Trailing-dot decimals (e.g. "2.") are valid xsd:decimal and now normalize to a Turtle-valid token ("2.0") instead of emitting invalid syntax. Adds native/verbose serialization tests for all four datatypes plus serialize-parse round-trip tests asserting no data loss for invalid lexical forms. Co-Authored-By: Claude Fable 5 --- src/serializer.js | 17 ++++- tests/unit/serialize-test.js | 131 +++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/serializer.js b/src/serializer.js index 0d3e8287..0572d639 100644 --- a/src/serializer.js +++ b/src/serializer.js @@ -546,6 +546,11 @@ export class Serializer { } // //////////////////////////////////////////// Atomic Terms + // Valid lexical forms that can be abbreviated to native Turtle tokens + validInteger = new RegExp(/^[+-]?[0-9]+$/) + validDecimal = new RegExp(/^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)$/) + validDouble = new RegExp(/^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/) + // Deal with term level things and nesting with no bnode structure atomicTermToN3 (expr, stats) { switch (expr.termType) { @@ -558,17 +563,25 @@ export class Serializer { throw new TypeError('Value of RDF literal node must be a string') } // var val = expr.value.toString() // should be a string already + // Only abbreviate to native Turtle syntax when the lexical form is valid + // for the datatype AND expressible as a Turtle token; otherwise fall + // through to the verbose "value"^^datatype form so no data is lost. if (expr.datatype && this.flags.indexOf('x') < 0) { // Supress native numbers switch (expr.datatype.uri) { case 'http://www.w3.org/2001/XMLSchema#integer': + if (!this.validInteger.test(val)) break // Invalid lexical form: serialize verbosely return val case 'http://www.w3.org/2001/XMLSchema#decimal': // In Turtle, must have dot + if (!this.validDecimal.test(val)) break // Invalid lexical form: serialize verbosely if (val.indexOf('.') < 0) val += '.0' + else if (val.charAt(val.length - 1) === '.') val += '0' // Turtle needs a digit after the dot return val case 'http://www.w3.org/2001/XMLSchema#double': { + // INF, -INF and NaN are valid xsd:double but have no native Turtle form + if (!this.validDouble.test(val)) break // Invalid lexical form: serialize verbosely // Must force use of 'e' const eNotation = val.toLowerCase().indexOf('e') > 0 if (val.indexOf('.') < 0 && !eNotation) val += '.0' @@ -577,7 +590,9 @@ export class Serializer { } case 'http://www.w3.org/2001/XMLSchema#boolean': - return expr.value === '1' || expr.value === 'true' ? 'true' : 'false' + if (val === 'true' || val === '1') return 'true' + if (val === 'false' || val === '0') return 'false' + break // Invalid lexical form: serialize verbosely } } var str = this.stringToN3(expr.value, this.flags) diff --git a/tests/unit/serialize-test.js b/tests/unit/serialize-test.js index a3c5da82..c97db935 100644 --- a/tests/unit/serialize-test.js +++ b/tests/unit/serialize-test.js @@ -274,6 +274,137 @@ example:subject schema2:predicate 123e-2 . it('lexical form "0" serializes to false', () => { expect(serializeBoolean('0')).to.equal(expected('false')) }) + + // Invalid lexical forms must fall through to the verbose form so no data is lost + const expectedVerbose = (token) => `@prefix : . +@prefix xsd: . + + ${token}. + +` + + it('invalid lexical form "yes" serializes verbosely', () => { + expect(serializeBoolean('yes')).to.equal(expectedVerbose('"yes"^^xsd:boolean')) + }) + + it('invalid lexical form "TRUE" serializes verbosely', () => { + expect(serializeBoolean('TRUE')).to.equal(expectedVerbose('"TRUE"^^xsd:boolean')) + }) + + it('invalid lexical form "2" serializes verbosely', () => { + expect(serializeBoolean('2')).to.equal(expectedVerbose('"2"^^xsd:boolean')) + }) + + it('invalid empty lexical form serializes verbosely', () => { + expect(serializeBoolean('')).to.equal(expectedVerbose('""^^xsd:boolean')) + }) + + it('invalid lexical form "01" serializes verbosely', () => { + expect(serializeBoolean('01')).to.equal(expectedVerbose('"01"^^xsd:boolean')) + }) + }) + + describe('typed literals with valid and invalid lexical forms', () => { + const serializeTyped = (lexicalForm, datatype) => { + const doc = sym("https://example.net/doc") + const statement = st( + sym('https://subject.example'), + sym('https://predicate.example'), + lit(lexicalForm, undefined, sym(`http://www.w3.org/2001/XMLSchema#${datatype}`)), + doc + ) + const kb = graph() + kb.add(statement) + return serialize(doc, kb, null, 'text/turtle') + } + const expectedNative = (token) => `@prefix : . + + ${token} . + +` + const expectedVerbose = (token) => `@prefix : . +@prefix xsd: . + + ${token}. + +` + + describe('integers', () => { + it('valid lexical forms serialize natively', () => { + expect(serializeTyped('42', 'integer')).to.equal(expectedNative('42')) + expect(serializeTyped('-7', 'integer')).to.equal(expectedNative('-7')) + expect(serializeTyped('+3', 'integer')).to.equal(expectedNative('+3')) + }) + + it('invalid lexical forms serialize verbosely', () => { + expect(serializeTyped('abc', 'integer')).to.equal(expectedVerbose('"abc"^^xsd:integer')) + expect(serializeTyped('1.5', 'integer')).to.equal(expectedVerbose('"1.5"^^xsd:integer')) + expect(serializeTyped('', 'integer')).to.equal(expectedVerbose('""^^xsd:integer')) + expect(serializeTyped('0x10', 'integer')).to.equal(expectedVerbose('"0x10"^^xsd:integer')) + }) + }) + + describe('decimals', () => { + it('valid lexical forms serialize natively', () => { + expect(serializeTyped('3.14', 'decimal')).to.equal(expectedNative('3.14')) + expect(serializeTyped('-0.5', 'decimal')).to.equal(expectedNative('-0.5')) + expect(serializeTyped('.5', 'decimal')).to.equal(expectedNative('.5')) + expect(serializeTyped('5', 'decimal')).to.equal(expectedNative('5.0')) + expect(serializeTyped('2.', 'decimal')).to.equal(expectedNative('2.0')) + }) + + it('invalid lexical forms serialize verbosely', () => { + expect(serializeTyped('1.2.3', 'decimal')).to.equal(expectedVerbose('"1.2.3"^^xsd:decimal')) + expect(serializeTyped('abc', 'decimal')).to.equal(expectedVerbose('"abc"^^xsd:decimal')) + expect(serializeTyped('.', 'decimal')).to.equal(expectedVerbose('"."^^xsd:decimal')) + expect(serializeTyped('1e5', 'decimal')).to.equal(expectedVerbose('"1e5"^^xsd:decimal')) + }) + }) + + describe('doubles', () => { + it('valid lexical forms serialize natively', () => { + expect(serializeTyped('1.0e10', 'double')).to.equal(expectedNative('1.0e10')) + expect(serializeTyped('1E5', 'double')).to.equal(expectedNative('1E5')) + expect(serializeTyped('-3.14e-2', 'double')).to.equal(expectedNative('-3.14e-2')) + }) + + it('valid xsd:double lexical forms without a native Turtle form serialize verbosely', () => { + // INF, -INF and NaN are valid xsd:double values but not valid Turtle DOUBLE tokens + expect(serializeTyped('NaN', 'double')).to.equal(expectedVerbose('"NaN"^^xsd:double')) + expect(serializeTyped('INF', 'double')).to.equal(expectedVerbose('"INF"^^xsd:double')) + expect(serializeTyped('-INF', 'double')).to.equal(expectedVerbose('"-INF"^^xsd:double')) + }) + + it('invalid lexical forms serialize verbosely', () => { + expect(serializeTyped('abc', 'double')).to.equal(expectedVerbose('"abc"^^xsd:double')) + expect(serializeTyped('1.2.3', 'double')).to.equal(expectedVerbose('"1.2.3"^^xsd:double')) + }) + }) + + describe('invalid lexical forms round-trip without data loss', () => { + const roundTrip = (lexicalForm, datatype) => { + const doc = sym("https://example.net/doc") + const ttl = serializeTyped(lexicalForm, datatype) + const kb = graph() + parse(ttl, kb, doc.uri, 'text/turtle') + return kb.any(sym('https://subject.example'), sym('https://predicate.example')) + } + const cases = { + boolean: ['yes', 'TRUE', '2', '', '01'], + integer: ['abc', '1.5', '', '0x10'], + decimal: ['1.2.3', 'abc', '.', '1e5'], + double: ['NaN', 'INF', '-INF', 'abc', '1.2.3'], + } + Object.entries(cases).forEach(([datatype, lexicalForms]) => { + lexicalForms.forEach(lexicalForm => { + it(`preserves "${lexicalForm}"^^xsd:${datatype}`, () => { + const recovered = roundTrip(lexicalForm, datatype) + expect(recovered.value).to.equal(lexicalForm) + expect(recovered.datatype.uri).to.equal(`http://www.w3.org/2001/XMLSchema#${datatype}`) + }) + }) + }) + }) }) describe('namespaces', () => {