Skip to content
Draft
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
18 changes: 18 additions & 0 deletions examples/graphAuthoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
228 changes: 228 additions & 0 deletions src/data/rdf/jsonLiteral.ts
Original file line number Diff line number Diff line change
@@ -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<DataFactory, NamedNode>();
private static AS_JSON = new WeakMap<Literal, JsonLiteral | undefined>();

/**
* 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<string, unknown> = {};
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<string, unknown>)[key]
: undefined;
const bValue = Object.prototype.hasOwnProperty.call(b, key)
? (b as Record<string, unknown>)[key]
: undefined;
if (!equalJsonContent(aValue, bValue)) {
return false;
}
}
for (const key in b) {
const bValue = Object.prototype.hasOwnProperty.call(b, key)
? (b as Record<string, unknown>)[key]
: undefined;
const aValue = Object.prototype.hasOwnProperty.call(a, key)
? (a as Record<string, unknown>)[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<string, unknown>)[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;
}
35 changes: 27 additions & 8 deletions src/data/rdf/rdfModel.ts
Original file line number Diff line number Diff line change
@@ -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<T extends string = string> = RdfJs.NamedNode<T>;
Expand All @@ -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 &&
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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;
Expand Down
Loading