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)}`); + } + }); +});