diff --git a/examples/graphAuthoring.tsx b/examples/graphAuthoring.tsx index bfbb5415..3630b71e 100644 --- a/examples/graphAuthoring.tsx +++ b/examples/graphAuthoring.tsx @@ -41,6 +41,24 @@ function GraphAuthoringExample() { model.createElement('http://www.w3.org/ns/org#subOrganizationOf'), model.createElement('http://www.w3.org/ns/org#unitOf'), ]; + const factory = dataProvider.factory; + for (const element of elements) { + dataProvider.addGraph([ + factory.quad( + factory.namedNode(element.iri), + factory.namedNode('urn:reactodia:hasJsonContent'), + Reactodia.Rdf.JsonLiteral.create(factory, { + authoredBy: 'alex', + likes: 42, + location: { + x: 102, + y: -12.3, + }, + tags: ['todo', { id: 'toDelete', name: 'To delete' }], + }) + ) + ]); + } model.history.execute(Reactodia.setElementExpanded(elements[0], true)); await Promise.all([ model.requestElementData(elements.map(el => el.iri)), diff --git a/src/data/rdf/jsonLiteral.ts b/src/data/rdf/jsonLiteral.ts new file mode 100644 index 00000000..0ad6df66 --- /dev/null +++ b/src/data/rdf/jsonLiteral.ts @@ -0,0 +1,228 @@ +import { chainHash, hashNumber, hashString } from '@reactodia/hashmap'; +import type * as RdfJs from '@rdfjs/types'; +import type { DataFactory, Literal, NamedNode } from './rdfModel'; + +const JSON_DATATYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'; + +export class JsonLiteral implements Literal { + private static DATATYPES = new WeakMap(); + private static AS_JSON = new WeakMap(); + + /** + * Raw JSON-serializable value. + */ + readonly content: unknown; + readonly datatype: NamedNode; + + private _value: string | undefined; + + private constructor(content: unknown, datatype: NamedNode, value?: string) { + this.content = content; + this.datatype = datatype; + this._value = value; + } + + static create(factory: DataFactory, content: unknown) { + let datatype = JsonLiteral.DATATYPES.get(factory); + if (!datatype) { + datatype = factory.namedNode(JSON_DATATYPE); + JsonLiteral.DATATYPES.set(factory, datatype); + } + return new JsonLiteral(prepareJsonContent(content), datatype); + } + + static fromLiteral(literal: Literal): JsonLiteral | undefined { + if (literal instanceof JsonLiteral) { + return literal; + } else if (literal.datatype.value === JSON_DATATYPE) { + if (JsonLiteral.AS_JSON.has(literal)) { + return JsonLiteral.AS_JSON.get(literal); + } + let result: JsonLiteral | undefined; + try { + const parsed = JSON.parse(literal.value); + result = new JsonLiteral(parsed, literal.datatype, literal.value); + } catch (err) { + /* ignore */ + } + JsonLiteral.AS_JSON.set(literal, result); + return result; + } else { + return undefined; + } + } + + static equal(a: JsonLiteral, b: JsonLiteral): boolean { + return equalJsonContent(a.content, b.content); + } + + static hash(literal: JsonLiteral): number { + return hashJsonContent(literal.content); + } + + get termType(): 'Literal' { + return 'Literal'; + } + + get language(): string { + return ''; + } + + get value(): string { + if (this._value === undefined) { + this._value = JSON.stringify(this.content); + } + return this._value; + } + + equals(other: RdfJs.Term | null | undefined): boolean { + if (other === undefined || other === null) { + return false; + } else { + return ( + other.termType === 'Literal' && + this.datatype.equals(other.datatype) && + this.language === other.language && + // Compare values last to avoid serializing content if possible + this.value === other.value + ); + } + } +} + +function prepareJsonContent(value: unknown): unknown { + switch (typeof value) { + case 'object': { + if (value === null) { + return null; + } else if ('toJSON' in value && typeof value.toJSON === 'function') { + return value.toJSON(); + } else if (Array.isArray(value)) { + return Array.from(value, prepareJsonContent); + } else { + const prepared: Record = {}; + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const property = (value as typeof prepared)[key]; + if (property !== undefined) { + prepared[key] = prepareJsonContent(property); + } + } + } + return prepared; + } + } + case 'number': { + return Number.isFinite(value) ? value : null; + } + case 'string': + case 'boolean': { + return value; + } + case 'bigint': { + // Throw native BigInt serialization error + return JSON.stringify(value); + } + case 'undefined': + default: { + return null; + } + } +} + +function equalJsonContent(a: unknown, b: unknown) { + if (a === b) { + return true; + } + switch (typeof a) { + case 'object': { + if (typeof b !== 'object') { + return false; + } else if (a === null) { + return b === null; + } else if (Array.isArray(a)) { + if (!(Array.isArray(b) && a.length === b.length)) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!equalJsonContent(a[i], b[i])) { + return false; + } + } + } else { + for (const key in a) { + const aValue = Object.prototype.hasOwnProperty.call(a, key) + ? (a as Record)[key] + : undefined; + const bValue = Object.prototype.hasOwnProperty.call(b, key) + ? (b as Record)[key] + : undefined; + if (!equalJsonContent(aValue, bValue)) { + return false; + } + } + for (const key in b) { + const bValue = Object.prototype.hasOwnProperty.call(b, key) + ? (b as Record)[key] + : undefined; + const aValue = Object.prototype.hasOwnProperty.call(a, key) + ? (a as Record)[key] + : undefined; + if (bValue !== undefined && aValue === undefined) { + return false; + } + } + } + return true; + } + default: { + return false; + } + } +} + +function hashJsonContent(value: unknown): number { + let hash = 0; + switch (typeof value) { + case 'object': { + if (value === null) { + hash = chainHash(hash, 1); + } else if (Array.isArray(value)) { + hash = chainHash(hash, 5); + hash = chainHash(hash, value.length); + for (const item of value) { + hash = chainHash(hash, hashJsonContent(item)); + } + } else { + hash = chainHash(hash, 6); + let propertiesHash = 0; + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const property = (value as Record)[key]; + if (property !== undefined) { + propertiesHash ^= chainHash(hashString(key), hashJsonContent(property)); + } + } + } + hash = chainHash(hash, propertiesHash); + } + break; + } + case 'boolean': { + hash = chainHash(hash, 2); + hash = chainHash(hash, value ? 1 : 0); + break; + } + case 'number': { + hash = chainHash(hash, 3); + hash = chainHash(hash, hashNumber(value)); + break; + } + case 'string': { + hash = chainHash(hash, 4); + hash = chainHash(hash, hashString(value)); + break; + } + } + return hash; +} diff --git a/src/data/rdf/rdfModel.ts b/src/data/rdf/rdfModel.ts index a6ddd5e6..61ba1b0d 100644 --- a/src/data/rdf/rdfModel.ts +++ b/src/data/rdf/rdfModel.ts @@ -1,7 +1,8 @@ -import * as RdfJs from '@rdfjs/types'; +import type * as RdfJs from '@rdfjs/types'; import { chainHash, dropHighestNonSignBit, hashString } from '@reactodia/hashmap'; import * as N3 from 'n3'; +import { JsonLiteral } from './jsonLiteral'; import { escapeRdfValue } from './rdfEscape'; export type NamedNode = RdfJs.NamedNode; @@ -16,6 +17,8 @@ export type DataFactory = RdfJs.DataFactory; export const DefaultDataFactory: RdfJs.DataFactory = N3.DataFactory; +export { JsonLiteral }; + export function looksLikeTerm(value: unknown): value is Term { if (!( typeof value === 'object' && value && @@ -78,10 +81,15 @@ export function hashTerm(node: Term): number { let hash = 0; switch (node.termType) { case 'NamedNode': - case 'BlankNode': + case 'BlankNode': { hash = hashString(node.value); break; - case 'Literal': + } + case 'Literal': { + const json = JsonLiteral.fromLiteral(node); + if (json) { + return JsonLiteral.hash(json); + } hash = hashString(node.value); if (node.datatype) { hash = chainHash(hash, hashString(node.datatype.value)); @@ -90,9 +98,11 @@ export function hashTerm(node: Term): number { hash = chainHash(hash, hashString(node.language)); } break; - case 'Variable': + } + case 'Variable': { hash = hashString(node.value); break; + } case 'Quad': { hash = chainHash(hash, hashTerm(node.subject)); hash = chainHash(hash, hashTerm(node.predicate)); @@ -117,10 +127,19 @@ export function equalTerms(a: Term, b: Term): boolean { return a.value === value; } case 'Literal': { - const { value, language, datatype } = b as Literal; - return a.value === value - && a.datatype.value === datatype.value - && a.language === language; + const other = b as Literal; + if (a.datatype.value !== other.datatype.value) { + return false; + } else if (a.datatype.value === Vocabulary.rdf.JSON) { + const aJson = JsonLiteral.fromLiteral(a); + const bJson = JsonLiteral.fromLiteral(other); + if (aJson) { + return bJson ? JsonLiteral.equal(aJson, bJson) : false; + } else if (bJson) { + return aJson ? JsonLiteral.equal(aJson, bJson) : false; + } + } + return a.language === other.language && a.value === other.value; } case 'Quad': { const { subject, predicate, object, graph } = b as Quad; diff --git a/test/data/jsonLiteral.test.ts b/test/data/jsonLiteral.test.ts new file mode 100644 index 00000000..7d3ad8b7 --- /dev/null +++ b/test/data/jsonLiteral.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import * as Rdf from '../../src/data/rdf/rdfModel'; + +const JSON_STRING = +`{ + "authoredBy": "user", + "likes":42, + "location": { "x":102, "y":-12.3 }, + "tags": ["todo", { "id":"toDelete", "name":"To delete" }] +}`; + +describe('JsonLiteral', () => { + it('can be created from a JS value', () => { + const serializedAsIs = [ + null, + true, + 42, + -0, + Infinity, + NaN, + 'A quick brown fox\r\njumped!', + ['test', {x: 1, y: 2.5}], + { + first: 'First', + middle: {value: 'Middle'}, + last: ['Last'], + }, + ]; + + for (const value of serializedAsIs) { + const json = Rdf.JsonLiteral.create(Rdf.DefaultDataFactory, value); + expect(json.value).toBe(JSON.stringify(value)); + } + + expect(Rdf.JsonLiteral.create(Rdf.DefaultDataFactory, undefined).value) + .toBe('null'); + }); + + it('can be parsed from valid RDF literal with JSON datatype', () => { + const json = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + JSON_STRING, + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + )); + expect(json).toBeTruthy(); + expect(json?.content).to.be.eql({ + authoredBy: 'user', + likes: 42, + location: { + x: 102, + y: -12.3, + }, + tags: ['todo', { id: 'toDelete', name: 'To delete' }], + }); + }); + + it('cannot be parsed from literal with an invalid JSON string', () => { + const json = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "x": 1, "y": 2', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + )); + expect(json).toBe(undefined); + }); + + it('has string-based equality semantics for equals() and structure-based for equalTerms()', () => { + const jsonA = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "x": 1, "y": 2 }', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + ))!; + const jsonB = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "y": 2, "x": 1 }', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + ))!; + expect(jsonA.equals(jsonB)) + .to.be.equal(false, 'JSON literals are compared by string values via equals()'); + expect(Rdf.equalTerms(jsonA, jsonB)) + .to.be.equal(true, 'JSON literals are compared by structure via equalTerms()'); + }); + + it('hashes based on value structure', () => { + const jsonA = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "x": 1, "y": 2 }', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + ))!; + const jsonB = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "y": 2, "x": 1 }', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + ))!; + const jsonC = Rdf.JsonLiteral.fromLiteral(Rdf.DefaultDataFactory.literal( + '{ "y": 2, "x": 1, "z": null }', + Rdf.DefaultDataFactory.namedNode(Rdf.Vocabulary.rdf.JSON) + ))!; + expect(Rdf.hashTerm(jsonA)).toBe(553668630); + expect(Rdf.hashTerm(jsonB)).toBe(553668630); + expect(Rdf.hashTerm(jsonC)).toBe(1132359522); + }); +});