Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 92 additions & 21 deletions src/schema_to_frame.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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).
Expand All @@ -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;
}
135 changes: 135 additions & 0 deletions test/schema_to_frame.test.mjs
Original file line number Diff line number Diff line change
@@ -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 = [
'<https://example.org/jane> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://schema.org/Person> .',
'<https://example.org/jane> <http://schema.org/worksFor> <https://example.org/acme> .',
'<https://example.org/joe> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://schema.org/Person> .',
'<https://example.org/joe> <http://schema.org/worksFor> <https://example.org/acme> .',
'<https://example.org/acme> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://schema.org/Organization> .',
'<https://example.org/acme> <http://schema.org/name> "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)}`);
}
});
});
Loading