UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

798 lines (754 loc) • 27.7 kB
// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { jest, describe, it, expect } from "@jest/globals"; import { dataset } from "@rdfjs/dataset"; import * as fc from "fast-check"; import { DataFactory as DF, Store } from "n3"; import type { BlankNode, DatasetCore, Quad, Term, DatasetCoreFactory, DataFactory, } from "@rdfjs/types"; import { serializeBoolean, serializeDatetime, serializeDecimal, serializeInteger, xmlSchemaTypes, } from "./datatypes"; import { isBlankNodeId, type ImmutableDataset } from "./rdf.internal"; import { addRdfJsQuadToDataset } from "./rdfjs.internal"; import { fromRdfJsDataset, toRdfJsDataset } from "./rdfjs"; import { asUrl, getThing, getThingAll } from "./thing/thing"; import { getTermAll } from "./thing/get"; describe("fromRdfJsDataset", () => { const fcNamedNode = fc .webUrl({ withFragments: true, withQueryParameters: true }) .map((url) => DF.namedNode(url)); const fcString = fc.string().map((value) => DF.literal(value)); const fcInteger = fc .integer() .map((value) => DF.literal(serializeInteger(value), DF.namedNode(xmlSchemaTypes.integer)), ); const fcDecimal = fc .float() .map((value) => DF.literal(serializeDecimal(value), DF.namedNode(xmlSchemaTypes.decimal)), ); const fcDatetime = fc .date({ noInvalidDate: true }) .map((value) => DF.literal( serializeDatetime(value), DF.namedNode(xmlSchemaTypes.dateTime), ), ); const fcBoolean = fc .boolean() .map((value) => DF.literal(serializeBoolean(value), DF.namedNode(xmlSchemaTypes.boolean)), ); const fcLangString = fc .tuple( fc.string(), fc.oneof(fc.constant("nl-NL"), fc.constant("en-GB"), fc.constant("fr")), ) .map(([value, lang]) => DF.literal(value, lang)); const fcArbitraryLiteral = fc .tuple(fc.string(), fc.webUrl({ withFragments: true })) .map(([value, dataType]) => DF.literal(value, DF.namedNode(dataType))); const fcLiteral = fc.oneof( fcString, fcInteger, fcDecimal, fcDatetime, fcBoolean, fcLangString, fcArbitraryLiteral, ); const fcBlankNode = fc .string() .map((asciiString) => DF.blankNode(asciiString)); const fcDefaultGraph = fc.constant(DF.defaultGraph()); const fcGraph = fc.oneof(fcDefaultGraph, fcNamedNode); const fcQuadSubject = fc.oneof(fcNamedNode, fcBlankNode); const fcQuadPredicate = fcNamedNode; const fcQuadObject = fc.oneof(fcNamedNode, fcLiteral, fcBlankNode); const fcQuad = fc .tuple(fcQuadSubject, fcQuadPredicate, fcQuadObject, fcGraph) .map(([subject, predicate, object, graph]) => DF.quad(subject, predicate, object, graph), ); const fcDatasetWithReusedBlankNodes = fc.uniqueArray(fcQuad).map((quads) => { const reusedBlankNode = DF.blankNode(); function maybeReplaceBlankNode(node: BlankNode): BlankNode { return Math.random() < 0.5 ? node : reusedBlankNode; } function maybeReplaceBlankNodesInQuad(quad: Quad): Quad { const subject = quad.subject.termType === "BlankNode" ? maybeReplaceBlankNode(quad.subject) : quad.subject; const object = quad.object.termType === "BlankNode" ? maybeReplaceBlankNode(quad.object) : quad.object; return DF.quad(subject, quad.predicate, object, quad.graph); } return dataset(quads.map(maybeReplaceBlankNodesInQuad)); }); it("loses no data", () => { const runs = process.env.CI ? 1000 : 100; expect.assertions(runs * 2 + 2); function hasMatchingQuads(a: DatasetCore, b: DatasetCore): boolean { function blankNodeToNull(term: Term): Term | null { return term.termType === "BlankNode" ? null : term; } const aQuads = Array.from(a); const bQuads = Array.from(b); return ( aQuads.every((quad) => b.match( blankNodeToNull(quad.subject), quad.predicate, blankNodeToNull(quad.object), quad.graph, ), ) && bQuads.every((quad) => a.match( blankNodeToNull(quad.subject), quad.predicate, blankNodeToNull(quad.object), quad.graph, ), ) ); } const fcResult = fc.check( fc.property(fcDatasetWithReusedBlankNodes, (data) => { const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(data)); expect(thereAndBackAgain.size).toBe(data.size); expect(hasMatchingQuads(thereAndBackAgain, data)).toBe(true); }), { numRuns: runs }, ); expect(fcResult.counterexample).toBeNull(); expect(fcResult.failed).toBe(false); }); it("can represent all Quads", () => { const blankNode1 = DF.blankNode(); const blankNode2 = DF.blankNode(); const subject1IriString = "https://some.pod/resource#subject1"; const subject1 = DF.namedNode(subject1IriString); const subject2IriString = "https://some.pod/resource#subject2"; const subject2 = DF.namedNode(subject2IriString); const predicate1IriString = "https://some.vocab/predicate1"; const predicate1 = DF.namedNode(predicate1IriString); const predicate2IriString = "https://some.vocab/predicate2"; const predicate2 = DF.namedNode(predicate2IriString); const literalStringValue = "Some string"; const literalString = DF.literal( literalStringValue, DF.namedNode(xmlSchemaTypes.string), ); const literalLangStringValue = "Some lang string"; const literalLangStringLocale = "en-gb"; const literalLangString = DF.literal( literalLangStringValue, literalLangStringLocale, ); const literalIntegerValue = "42"; const literalInteger = DF.literal( literalIntegerValue, DF.namedNode(xmlSchemaTypes.integer), ); const defaultGraph = DF.defaultGraph(); const acrGraphIriString = "https://some.pod/resource?ext=acr"; const acrGraph = DF.namedNode(acrGraphIriString); const quads = [ DF.quad(subject1, predicate1, literalString, defaultGraph), DF.quad(subject1, predicate1, literalLangString, defaultGraph), DF.quad(subject1, predicate1, literalInteger, defaultGraph), DF.quad(subject1, predicate2, subject2, defaultGraph), DF.quad(subject2, predicate1, blankNode1, acrGraph), DF.quad(subject2, predicate1, blankNode2, acrGraph), DF.quad(blankNode1, predicate1, literalString, acrGraph), DF.quad(blankNode2, predicate1, literalString, acrGraph), DF.quad(blankNode2, predicate1, literalInteger, acrGraph), DF.quad(blankNode2, predicate2, literalInteger, acrGraph), ]; const rdfJsDataset = dataset(quads); expect(fromRdfJsDataset(rdfJsDataset)).toStrictEqual({ type: "Dataset", graphs: { default: expect.objectContaining({ [subject1IriString]: { url: subject1IriString, type: "Subject", predicates: { [predicate1IriString]: { literals: { [xmlSchemaTypes.string]: [literalStringValue], [xmlSchemaTypes.integer]: [literalIntegerValue], }, langStrings: { [literalLangStringLocale]: [literalLangStringValue], }, }, [predicate2IriString]: { namedNodes: [subject2IriString], }, }, }, }), [acrGraphIriString]: expect.objectContaining({ [subject2IriString]: { url: subject2IriString, type: "Subject", predicates: { [predicate1IriString]: { blankNodes: [ expect.stringMatching(/_:/), expect.stringMatching(/_:/), ], }, }, }, }), }, }); const subjectsExcludingBlankNodes = getThingAll( fromRdfJsDataset(rdfJsDataset), { scope: acrGraphIriString }, ); const subjectsIncludingBlankNodes = getThingAll( fromRdfJsDataset(rdfJsDataset), { scope: acrGraphIriString, acceptBlankNodes: true }, ); // There should be two blank nodes in the resulting dataset. expect( subjectsIncludingBlankNodes.length - subjectsExcludingBlankNodes.length, ).toBe(2); }); it("can represent lists", () => { const first = DF.namedNode( "http://www.w3.org/1999/02/22-rdf-syntax-ns#first", ); const rest = DF.namedNode( "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest", ); const nil = DF.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#nil"); const item1Node = DF.blankNode(); const item2Node = DF.blankNode(); const quad1 = DF.quad(item1Node, first, DF.literal("First item in a list")); const quad2 = DF.quad(item1Node, rest, item2Node); const quad3 = DF.quad( item2Node, first, DF.literal("Second item in a list"), ); const quad4 = DF.quad(item2Node, rest, nil); const rdfJsDataset = dataset([quad1, quad2, quad3, quad4]); const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(rdfJsDataset)); expect(thereAndBackAgain.size).toBe(4); expect( thereAndBackAgain.match(null, null, DF.literal("First item in a list")) .size, ).toBe(1); expect( thereAndBackAgain.match(null, null, DF.literal("Second item in a list")) .size, ).toBe(1); }); it("does not lose any predicates", () => { const blankNode1 = DF.blankNode(); const blankNode2 = DF.blankNode(); const blankNode3 = DF.blankNode(); const blankNode4 = DF.blankNode(); const predicate1 = DF.namedNode("https://example.com/predicate1"); const predicate2 = DF.namedNode("https://example.com/predicate2"); const predicate3 = DF.namedNode("https://example.com/predicate3"); const acrGraph = DF.namedNode("https://example.com/acrGraph"); const literalString = DF.literal("Arbitrary literal string"); const quads = [ DF.quad(blankNode1, predicate1, blankNode2, acrGraph), DF.quad(blankNode2, predicate2, blankNode3, acrGraph), DF.quad(blankNode3, predicate3, blankNode4, acrGraph), DF.quad(blankNode4, predicate2, literalString, acrGraph), ]; const rdfJsDataset = dataset(quads); const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(rdfJsDataset)); expect(thereAndBackAgain.size).toBe(rdfJsDataset.size); }); it("does not trip over circular blank nodes", () => { const namedNode = DF.namedNode("https://example.com/namedNode"); const blankNode1 = DF.blankNode(); const blankNode2 = DF.blankNode(); const blankNode3 = DF.blankNode(); const predicate = DF.namedNode("https://example.com/predicate"); const literalString = DF.literal("Arbitrary literal string"); const quads = [ DF.quad(namedNode, predicate, blankNode2), DF.quad(blankNode1, predicate, blankNode2), DF.quad(blankNode2, predicate, blankNode3), DF.quad(blankNode3, predicate, blankNode1), DF.quad(blankNode2, predicate, literalString), ]; const rdfJsDataset = dataset(quads); const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(rdfJsDataset)); expect(thereAndBackAgain.size).toBe(rdfJsDataset.size); }); it("does not trip over blank nodes that appear as the object for different subjects", () => { const blankNode1 = DF.blankNode(); const blankNode2 = DF.blankNode(); const blankNode3 = DF.blankNode(); const predicate = DF.namedNode("https://example.com/predicate"); const literalString = DF.literal("Arbitrary literal string"); const quads = [ DF.quad(blankNode1, predicate, blankNode2), DF.quad(blankNode2, predicate, literalString), DF.quad(blankNode3, predicate, blankNode2), ]; const rdfJsDataset = dataset(quads); const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(rdfJsDataset)); expect(thereAndBackAgain.size).toBe(rdfJsDataset.size); }); it("does not trip over Datasets that only contain Blank Node Subjects", () => { const blankNode1 = DF.blankNode(); const blankNode2 = DF.blankNode(); const blankNode3 = DF.blankNode(); const predicate = DF.namedNode("https://example.com/predicate"); const literalString = DF.literal("Arbitrary literal string"); const quads = [ DF.quad(blankNode1, predicate, blankNode2), DF.quad(blankNode2, predicate, blankNode3), DF.quad(blankNode3, predicate, blankNode1), DF.quad(blankNode2, predicate, literalString), ]; const rdfJsDataset = dataset(quads); const thereAndBackAgain = toRdfJsDataset(fromRdfJsDataset(rdfJsDataset)); expect(thereAndBackAgain.size).toBe(rdfJsDataset.size); }); describe("addRdfJsQuadToDataset", () => { it("can parse a simple Quad with a Blank Node Object", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const mockQuad = DF.quad( DF.namedNode("https://some.subject"), DF.namedNode("https://some.predicate"), DF.blankNode("some-blank-node"), DF.defaultGraph(), ); const data = addRdfJsQuadToDataset(mockDataset, mockQuad); expect(data).toStrictEqual({ type: "Dataset", graphs: { default: { "https://some.subject": { type: "Subject", url: "https://some.subject", predicates: { "https://some.predicate": { blankNodes: ["_:some-blank-node"], }, }, }, }, }, }); }); it("throws an error when passed unknown Graph types", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const mockQuad = DF.quad( DF.namedNode("https://arbitrary.subject"), DF.namedNode("https://arbitrary.predicate"), DF.namedNode("https://arbitrary.object"), { termType: "Unknown term type" } as any, ); expect(() => addRdfJsQuadToDataset(mockDataset, mockQuad)).toThrow( "Cannot parse Quads with nodes of type [Unknown term type] as their Graph node.", ); }); it("throws an error when passed unknown Subject types", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const mockQuad = DF.quad( { termType: "Unknown term type" } as any, DF.namedNode("https://arbitrary.predicate"), DF.namedNode("https://arbitrary.object"), DF.defaultGraph(), ); expect(() => addRdfJsQuadToDataset(mockDataset, mockQuad)).toThrow( "Cannot parse Quads with nodes of type [Unknown term type] as their Subject node.", ); }); it("throws an error when passed unknown Predicate types", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const mockQuad = DF.quad( DF.namedNode("https://arbitrary.subject"), { termType: "Unknown term type" } as any, DF.namedNode("https://arbitrary.object"), DF.defaultGraph(), ); expect(() => addRdfJsQuadToDataset(mockDataset, mockQuad)).toThrow( "Cannot parse Quads with nodes of type [Unknown term type] as their Predicate node.", ); }); it("throws an error when passed unknown Object types", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const mockQuad = DF.quad( DF.namedNode("https://arbitrary.subject"), DF.namedNode("https://arbitrary.predicate"), { termType: "Unknown term type" } as any, DF.defaultGraph(), ); expect(() => addRdfJsQuadToDataset(mockDataset, mockQuad)).toThrow( "Objects of type [Unknown term type] are not supported.", ); }); it("can parse chained Blank Nodes with a single link that end in a dangling Blank Node", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const chainBlankNode1 = DF.blankNode(); const otherQuad = DF.quad( DF.namedNode("https://some.subject"), DF.namedNode("https://some.predicate/1"), chainBlankNode1, DF.defaultGraph(), ); const mockQuad = DF.quad( chainBlankNode1, DF.namedNode("https://some.predicate/2"), DF.blankNode("some-blank-node"), DF.defaultGraph(), ); const updatedDataset = [mockQuad, otherQuad].reduce( addRdfJsQuadToDataset, mockDataset, ); // There should be one blank node subject. expect( getThingAll(updatedDataset, { acceptBlankNodes: false }), ).toHaveLength(1); expect( getThingAll(updatedDataset, { acceptBlankNodes: true }), ).toHaveLength(2); // The blank nodes should be linked const blankNodes = getThingAll(updatedDataset, { acceptBlankNodes: true, }).filter((thing) => isBlankNodeId(asUrl(thing))); let bnAreLinked = false; blankNodes.forEach((bn) => { const candidateObjects = getTermAll(bn, "https://some.predicate/2"); bnAreLinked ||= candidateObjects.length > 0 && candidateObjects.some((obj) => obj.termType === "BlankNode"); }); // The named node should be linked to a blank node getTermAll( getThing(updatedDataset, "https://some.subject")!, "https://some.predicate/1", ).some((term) => term.termType === "BlankNode"); }); it("can parse chained Blank Nodes that end in a dangling Blank Node", () => { const mockDataset: ImmutableDataset = { type: "Dataset", graphs: { default: {} }, }; const chainBlankNode1 = DF.blankNode(); const chainBlankNode2 = DF.blankNode(); const otherQuad = DF.quad( DF.namedNode("https://some.subject"), DF.namedNode("https://some.predicate/1"), chainBlankNode1, DF.defaultGraph(), ); const inBetweenQuad = DF.quad( chainBlankNode1, DF.namedNode("https://some.predicate/2"), chainBlankNode2, DF.defaultGraph(), ); const mockQuad = DF.quad( chainBlankNode2, DF.namedNode("https://some.predicate/3"), DF.blankNode("some-blank-node"), DF.defaultGraph(), ); const updatedDataset = [mockQuad, inBetweenQuad, otherQuad].reduce( addRdfJsQuadToDataset, mockDataset, ); // There should be 2 blank node subjects expect( getThingAll(updatedDataset, { acceptBlankNodes: false }), ).toHaveLength(1); expect( getThingAll(updatedDataset, { acceptBlankNodes: true }), ).toHaveLength(3); // The blank nodes subjects and the blank node object should be linked. const blankNodes = getThingAll(updatedDataset, { acceptBlankNodes: true, }).filter((thing) => isBlankNodeId(asUrl(thing))); // Count the number of links between blank nodes, // based on known predicates. const bnLinks = blankNodes.reduce( (prev, cur) => prev + [ ...getTermAll(cur, "https://some.predicate/2"), ...getTermAll(cur, "https://some.predicate/3"), ].filter((obj) => obj.termType === "BlankNode").length, 0, ); // There should be a chain of links between blank nodes. expect(bnLinks).toBe(2); // The named node should be linked to a blank node. getTermAll( getThing(updatedDataset, "https://some.subject")!, "https://some.predicate/1", ).some((term) => term.termType === "BlankNode"); }); }); }); describe("toRdfJsDataset", () => { const isNotEmpty = (value: object) => { if (typeof value !== "object") { return false; } if (value === null) { return false; } if (Array.isArray(value)) { return value.length > 0; } return Object.keys(value).length > 0; }; const fcLiterals = fc .dictionary( fc.webUrl({ withFragments: true }), fc.uniqueArray(fc.string(), { minLength: 1 }), ) .filter(isNotEmpty); // Replaced deprecated hexaString with custom implementation for v4 const hexaChars = "0123456789abcdef"; const hexa = () => { return fc.integer({ min: 0, max: 15 }).map( (n) => hexaChars[n], (c) => hexaChars.indexOf(<string>c), ); }; const hexaString = (constraints: fc.StringConstraints = {}) => fc.string({ ...constraints, unit: hexa() }); const fcLangStrings = fc .dictionary( hexaString({ minLength: 1 }).map((str) => str.toLowerCase()), fc.uniqueArray(fc.string(), { minLength: 1 }), ) .filter(isNotEmpty); const fcLocalNodeIri = fc.webUrl({ withFragments: true }).map((url) => { const originalUrl = new URL(url); return `https://inrupt.com/.well-known/sdk-local-node/${originalUrl.hash}`; }); const fcNamedNodes = fc.uniqueArray( fc.oneof( fcLocalNodeIri, fc.webUrl({ withFragments: true, withQueryParameters: true }), ), { minLength: 1, }, ); // withDeletedKeys option was removed in v4, achieve similar functionality with filter const fcObjects = fc .record({ literals: fcLiterals, langStrings: fcLangStrings, namedNodes: fcNamedNodes, // blankNodes: fcBlankNodes, }) .map((obj) => { // Randomly delete some keys to achieve similar behavior to withDeletedKeys const keys = Object.keys(obj) as Array<keyof typeof obj>; if (keys.length <= 1) return obj; // Keep at least one property const result = { ...obj }; // Delete random keys with 50% chance for each keys.forEach((key) => { if (Math.random() < 0.5 && Object.keys(result).length > 1) { delete result[key]; } }); return result; }) .filter(isNotEmpty); // Unfortunately I haven't figured out how to generate the nested blank node // structures with fast-check yet, so this does not generate those: const fcPredicates = fc .dictionary(fc.webUrl({ withFragments: true }), fcObjects) .filter(isNotEmpty); const fcGraph = fc .dictionary( fc.oneof( fcLocalNodeIri, fc.webUrl({ withFragments: true, withQueryParameters: true }), ), fc.record({ type: fc.constant("Subject"), url: fc.webUrl({ withFragments: true, withQueryParameters: true }), predicates: fcPredicates, }), ) .filter(isNotEmpty) .map((graph) => { Object.keys(graph).forEach((subjectIri) => { graph[subjectIri].url = subjectIri; }); return graph; }); const fcDataset = fc.record({ type: fc.constant("Dataset"), graphs: fc .tuple( fc.dictionary(fc.webUrl({ withQueryParameters: true }), fcGraph), fcGraph, ) .map(([otherGraphs, defaultGraph]) => ({ ...otherGraphs, default: defaultGraph, })), }); it("loses no data when serializing and deserializing to RDF/JS Datasets", () => { const runs = process.env.CI ? 100 : 1; expect.assertions(runs + 2); const fcResult = fc.check( fc.property(fcDataset, (data) => { expect( sortObject(fromRdfJsDataset(toRdfJsDataset(data as any))), ).toStrictEqual(sortObject(data)); }), { numRuns: runs }, ); expect(fcResult.counterexample).toBeNull(); expect(fcResult.failed).toBe(false); }); it("can represent dangling Blank Nodes", () => { const datasetWithDanglingBlankNodes: ImmutableDataset = { type: "Dataset", graphs: { default: { "_:danglingSubjectBlankNode": { type: "Subject", url: "_:danglingSubjectBlankNode", predicates: { "http://www.w3.org/ns/auth/acl#origin": { blankNodes: [{}], }, }, }, }, }, }; const rdfJsDataset = toRdfJsDataset(datasetWithDanglingBlankNodes); expect(rdfJsDataset.size).toBe(1); const quad = Array.from(rdfJsDataset)[0]; expect(quad.subject.termType).toBe("BlankNode"); expect(quad.predicate.value).toBe("http://www.w3.org/ns/auth/acl#origin"); expect(quad.object.termType).toBe("BlankNode"); }); it("can take a custom DataFactory", () => { const customDataFactory = { quad: jest.fn(DF.quad), namedNode: jest.fn(DF.namedNode), literal: jest.fn(DF.literal), blankNode: jest.fn(DF.blankNode), defaultGraph: jest.fn(DF.defaultGraph), } as DataFactory; const customDatasetFactory = { dataset: jest.fn((quads: Quad[]) => new Store(quads)), } as DatasetCoreFactory; const sourceDataset: ImmutableDataset = { type: "Dataset", graphs: { default: { "https://arbitrary.pod/resource#thing": { type: "Subject", url: "https://arbitrary.pod/resource#thing", predicates: { "https://arbitrary.vocab/predicate": { namedNodes: ["https://arbitrary.pod/other-resource#thing"], literals: { "https://arbitrary.vocab/literal-type": ["Arbitrary value"], }, blankNodes: ["_:arbitrary-blank-node"], }, }, }, }, }, }; toRdfJsDataset(sourceDataset, { dataFactory: customDataFactory, datasetFactory: customDatasetFactory, }); expect(customDataFactory.quad).toHaveBeenCalled(); expect(customDataFactory.namedNode).toHaveBeenCalled(); expect(customDataFactory.literal).toHaveBeenCalled(); expect(customDataFactory.blankNode).toHaveBeenCalled(); expect(customDataFactory.defaultGraph).toHaveBeenCalled(); expect(customDatasetFactory.dataset).toHaveBeenCalled(); }); }); function sortObject(value: Record<string, any>): Record<string, any> { if (typeof value !== "object") { return value; } if (Array.isArray(value)) { return [...value].sort(); } if (value === null) { return value; } const keys = Object.keys(value); keys.sort(); return keys.reduce( (newObject, key) => ({ ...newObject, [key]: sortObject(value[key]) }), {}, ); }