From f73efc93d1b4cde338eadad7f3f9d764c55777cb Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Sat, 19 Sep 2026 15:32:26 +0200 Subject: [PATCH] fix(frame): keep reference-valued properties as IRIs A derived frame carried no subframe for reference-valued properties, on the assumption that a referenced IRI has no local triples. Where the target does carry triples in the same graph, framing embedded it as an object and the framed document stopped validating against the schema the frame came from, which declares a string there. Reference signals per OOLD-EXT-68fa: x-oold-range, an IRI-family format, or a term mapped "@type": "@id". Embedding wins where a property carries both. Adds the first tests in this repo, covering the worked example from the specification's #framing section. Refs OO-LD/oold-schema#160 --- package.json | 3 +- src/schema_to_frame.mjs | 113 ++++++++++++++++++++++------ test/schema_to_frame.test.mjs | 135 ++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 test/schema_to_frame.test.mjs diff --git a/package.json b/package.json index 533bec5..1098d4c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "node": ">=18" }, "scripts": { - "validate": "node src/validate.mjs" + "validate": "node src/validate.mjs", + "test": "node --test \"test/*.test.mjs\"" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.0.0", diff --git a/src/schema_to_frame.mjs b/src/schema_to_frame.mjs index e3ef6eb..1dca145 100644 --- a/src/schema_to_frame.mjs +++ b/src/schema_to_frame.mjs @@ -35,32 +35,36 @@ export function contextTerms(context, out = {}) { return out; } -// Properties that embed an object: their @context term carries a scoped @context. +// An embed is an *object* value: type "object" with its own properties. A $ref alone is not +// enough - it may point at a scalar DataType leaf (a literal, not an embed) - and after +// dereferencing a real embed is inlined as such an object anyway. +const isEmbed = (node) => { + if (!node || typeof node !== "object") return false; + if (node.type === "object" && node.properties) return true; + if (node.items) return isEmbed(node.items); + for (const kw of ["anyOf", "oneOf", "allOf"]) { + if (Array.isArray(node[kw]) && node[kw].some(isEmbed)) return true; + } + return false; +}; + +// Properties may live in allOf members (a dereferenced subclass chain inlines each +// superclass as an allOf entry), so collect the full composed property map. +const collectProps = (node, out = {}) => { + if (!node || typeof node !== "object") return out; + for (const [k, v] of Object.entries(node.properties || {})) if (!(k in out)) out[k] = v; + for (const sub of node.allOf || []) collectProps(sub, out); + return out; +}; + +// The IRI/URI-family formats OOLD-EXT-6ea3 recommends for an IRI-valued property. +const IRI_FORMATS = new Set(["iri", "iri-reference", "uri", "uri-reference"]); + // Properties that embed an object, detected from the JSON Schema shape - a property whose // value (or array items, or an anyOf/oneOf branch) is an object with its own properties or a // $ref to a type. A scoped @context is a strong signal too, but it is not mandatory (an // embed can be mapped by the ambient/top-level context), so shape is the primary signal. export function embeddedProperties(schema) { - // An embed is an *object* value: type "object" with its own properties. A $ref alone is not - // enough - it may point at a scalar DataType leaf (a literal, not an embed) - and after - // dereferencing a real embed is inlined as such an object anyway. - const isEmbed = (node) => { - if (!node || typeof node !== "object") return false; - if (node.type === "object" && node.properties) return true; - if (node.items) return isEmbed(node.items); - for (const kw of ["anyOf", "oneOf", "allOf"]) { - if (Array.isArray(node[kw]) && node[kw].some(isEmbed)) return true; - } - return false; - }; - // Properties may live in allOf members (a dereferenced subclass chain inlines each - // superclass as an allOf entry), so collect the full composed property map. - const collectProps = (node, out = {}) => { - if (!node || typeof node !== "object") return out; - for (const [k, v] of Object.entries(node.properties || {})) if (!(k in out)) out[k] = v; - for (const sub of node.allOf || []) collectProps(sub, out); - return out; - }; const props = collectProps(schema); const structural = Object.keys(props).filter((k) => isEmbed(props[k])); const terms = contextTerms(schema["@context"]); @@ -92,6 +96,72 @@ export function instanceRdfTypes(schema) { return null; } +// Properties whose value is a reference, so framing must leave it an IRI rather than +// pull the referenced node's triples into this document. Three signals, per +// OOLD-EXT-68fa: an x-oold-range on a string-typed value, an IRI-family format, or a +// context term mapped "@type": "@id". +// +// Without this, a referenced node that happens to carry triples in the same graph is +// embedded as an object, and the framed document no longer validates against the schema +// the frame was derived from - the schema declares a string there. Embedding wins where +// both signals appear: a property shaped like an object is an embed whatever its term says. +// Property names that alias a JSON-LD keyword, such as `id` for `@id`. These are not +// predicates: `id` names the node, it does not point at another one. A subframe under such a +// key writes { "@id": {...} } into the frame, which a processor rejects outright. +// +// Searched across the composed schema: a dereferenced subclass chain keeps each superclass's +// own @context on its allOf member, and the convention is usually declared by the base schema +// rather than repeated by every subclass. +export function keywordAliasKeys(schema) { + const found = new Set(); + + const scanContext = (context) => { + if (Array.isArray(context)) { + for (const entry of context) scanContext(entry); + return; + } + if (!context || typeof context !== "object") return; + for (const [term, def] of Object.entries(context)) { + if (term.startsWith("@")) continue; + const target = def && typeof def === "object" ? def["@id"] : def; + if (typeof target === "string" && target.startsWith("@")) found.add(term); + } + }; + + const walk = (node) => { + if (!node || typeof node !== "object") return; + scanContext(node["@context"]); + for (const sub of node.allOf || []) walk(sub); + }; + + walk(schema); + return found; +} + +export function referenceProperties(schema) { + const props = collectProps(schema); + const terms = contextTerms(schema["@context"]); + const aliases = keywordAliasKeys(schema); + + const isReference = (node) => { + if (!node || typeof node !== "object") return false; + if (node.items) return isReference(node.items); + if ("x-oold-range" in node) return true; + if (typeof node.format === "string" && IRI_FORMATS.has(node.format)) return true; + for (const kw of ["anyOf", "oneOf", "allOf"]) { + if (Array.isArray(node[kw]) && node[kw].some(isReference)) return true; + } + return false; + }; + + return Object.keys(props).filter( + (k) => + !aliases.has(k) && + !isEmbed(props[k]) && + (isReference(props[k]) || terms[k]?.["@type"] === "@id"), + ); +} + // Derive the minimal frame. contextRef, when given, is used as the frame's @context in // place of the schema's inline @context (pass the schema URL so a document loader // resolves inherited/scoped contexts). @@ -101,5 +171,6 @@ export function schemaToFrame(schema, contextRef) { const types = instanceRdfTypes(schema); if (types) frame["@type"] = types.length === 1 ? types[0] : types; for (const p of embeddedProperties(schema)) frame[p] = {}; + for (const p of referenceProperties(schema)) frame[p] = { "@embed": "@never" }; return frame; } diff --git a/test/schema_to_frame.test.mjs b/test/schema_to_frame.test.mjs new file mode 100644 index 0000000..22e7a04 --- /dev/null +++ b/test/schema_to_frame.test.mjs @@ -0,0 +1,135 @@ +// Frame derivation, checked against the worked example in the specification's +// #framing section and against the failure that example did not cover. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import jsonld from 'jsonld'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { + embeddedProperties, + keywordAliasKeys, + referenceProperties, + schemaToFrame, +} from '../src/schema_to_frame.mjs'; + +// The specification's Organization example, dereferenced: address inlines the Address +// schema, employees is an array of IRI strings carrying x-oold-range. +const organization = { + '@context': { + schema: 'http://schema.org/', + type: '@type', + id: '@id', + address: { '@id': 'schema:address', '@context': 'Address.schema.json' }, + employees: { '@reverse': 'schema:worksFor', '@type': '@id' }, + }, + $id: 'Organization.schema.json', + 'x-oold-instance-rdf-type': ['schema:Organization'], + type: 'object', + properties: { + address: { + '@context': { schema: 'http://schema.org/', postalCode: 'schema:postalCode' }, + $id: 'Address.schema.json', + 'x-oold-instance-rdf-type': ['schema:PostalAddress'], + type: 'object', + properties: { postalCode: { type: 'string' } }, + }, + employees: { + type: 'array', + items: { type: 'string', 'x-oold-range': 'Person.schema.json' }, + }, + }, +}; + +test('the worked example derives the frame the specification prints', () => { + const frame = schemaToFrame(organization, 'Organization.schema.json'); + + assert.equal(frame['@context'], 'Organization.schema.json'); + assert.equal(frame['@type'], 'schema:Organization'); + assert.deepEqual(frame.address, {}, 'an inlined object property embeds'); + assert.deepEqual( + frame.employees, + { '@embed': '@never' }, + 'a reference-valued property keeps its targets as IRIs (OOLD-EXT-68fa)', + ); +}); + +test('embedding wins where a property carries both signals', () => { + // address is shaped like an object and its term carries a scoped @context; it must + // not be demoted to a reference by the term-based signal. + assert.ok(embeddedProperties(organization).includes('address')); + assert.ok(!referenceProperties(organization).includes('address')); +}); + +test('a keyword alias never gets a subframe', () => { + // `id` names the node, it is not a predicate. It carries an IRI format, so the reference + // signals match, but a subframe there writes { "@id": {...} }, which a processor rejects. + // The alias is declared by the base schema, so it has to be found through allOf. + const schema = { + '@context': { schema: 'http://schema.org/' }, + allOf: [{ + '@context': { id: '@id', type: '@type' }, + properties: { id: { type: 'string', format: 'iri' } }, + }], + properties: { ref: { type: 'string', format: 'iri-reference' } }, + }; + assert.deepEqual([...keywordAliasKeys(schema)].sort(), ['id', 'type']); + assert.deepEqual(referenceProperties(schema), ['ref']); + assert.ok(!('id' in schemaToFrame(schema, 'X.schema.json'))); +}); + +test.describe('a reference whose target carries triples in the same graph', () => { + // OO-LD/oold-schema#160. works_for is declared a string, so framing must leave it one. + const person = { + '@context': { + schema: 'http://schema.org/', + type: '@type', + id: '@id', + works_for: { '@id': 'schema:worksFor', '@type': '@id' }, + name: 'schema:name', + }, + 'x-oold-instance-rdf-type': ['schema:Person'], + type: 'object', + properties: { + name: { type: 'string' }, + works_for: { type: 'string', format: 'iri-reference' }, + }, + }; + + const nq = [ + ' .', + ' .', + ' .', + ' .', + ' .', + ' "ACME" .', + '', + ].join('\n'); + + const framedPeople = async () => { + const rdf = await jsonld.fromRDF(nq, { format: 'application/n-quads' }); + const out = await jsonld.frame(rdf, schemaToFrame(person), { omitDefault: true }); + return out['@graph'] ?? [out]; + }; + + test('stays an IRI rather than being embedded', async () => { + for (const p of await framedPeople()) { + assert.equal( + typeof p.works_for, + 'string', + `${p.id}: a reference-valued property must not absorb the target's triples`, + ); + } + }); + + test('framed output validates against the schema the frame came from', async () => { + const ajv = new Ajv({ strict: false }); + addFormats(ajv); + const { '@context': _c, 'x-oold-instance-rdf-type': _t, ...validatable } = person; + const validate = ajv.compile(validatable); + + for (const p of await framedPeople()) { + const { '@context': _, ...doc } = p; + assert.ok(validate(doc), `${p.id}: ${ajv.errorsText(validate.errors)}`); + } + }); +});