diff --git a/CHANGELOG.md b/CHANGELOG.md index da3aa1cdfa..2fa3eab5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html The following changes are pending, and will be applied on the next major release: +## Unreleased changes + +### Patch changes + +- `getThingAll(dataset, { allowacceptBlankNodes: true })` now returns all Blank Nodes + subjects in the Dataset, in particular including those part of a single chain of + predicates. For instance, given the following dataset: + + ``` + @prefix ex: . + @prefix foaf: . + + ex:camille + foaf:knows [ + foaf:name "Dominique"@en ; + ] . + ; + ``` + + `getThingAll(dataset, { allowacceptBlankNodes: true })` would have previously returned + a single element for the Named Node (`ex:camille`), it will now also include a second + element for the Blank Node. Blank Node identifiers are by definition unstable and shouldn't + be relied upon beyond local resolution. + ## [2.0.1] The following changes have been implemented but not released yet: diff --git a/src/formats/solidDatasetAsTurtle.test.ts b/src/formats/solidDatasetAsTurtle.test.ts index 6ab47e894e..05c90d3d17 100644 --- a/src/formats/solidDatasetAsTurtle.test.ts +++ b/src/formats/solidDatasetAsTurtle.test.ts @@ -33,10 +33,11 @@ async function getDataset(ttl: string): Promise { } const ttl = ` - prefix : <#> - prefix ex: - prefix foaf: - prefix vcard: + @prefix : <#> . + @prefix ex: . + @prefix foaf: . + @prefix vcard: . + @base . <> a foaf:PersonalProfileDocument ; diff --git a/src/rdf.test.ts b/src/rdf.test.ts index 8050f5f545..b4ec342194 100644 --- a/src/rdf.test.ts +++ b/src/rdf.test.ts @@ -38,9 +38,11 @@ import { serializeInteger, xmlSchemaTypes, } from "./datatypes"; -import type { ImmutableDataset } from "./rdf.internal"; +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 @@ -212,7 +214,7 @@ describe("fromRdfJsDataset", () => { expect(fromRdfJsDataset(rdfJsDataset)).toStrictEqual({ type: "Dataset", graphs: { - default: { + default: expect.objectContaining({ [subject1IriString]: { url: subject1IriString, type: "Subject", @@ -231,41 +233,35 @@ describe("fromRdfJsDataset", () => { }, }, }, - }, - [acrGraphIriString]: { + }), + [acrGraphIriString]: expect.objectContaining({ [subject2IriString]: { url: subject2IriString, type: "Subject", predicates: { [predicate1IriString]: { blankNodes: [ - { - [predicate1IriString]: { - literals: { - [xmlSchemaTypes.string]: [literalStringValue], - }, - }, - }, - { - [predicate1IriString]: { - literals: { - [xmlSchemaTypes.string]: [literalStringValue], - [xmlSchemaTypes.integer]: [literalIntegerValue], - }, - }, - [predicate2IriString]: { - literals: { - [xmlSchemaTypes.integer]: [literalIntegerValue], - }, - }, - }, + 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", () => { @@ -453,104 +449,6 @@ describe("fromRdfJsDataset", () => { ); }); - it("throws an error when passed unknown Predicate types with chain Blank Node Subjects", () => { - const mockDataset: ImmutableDataset = { - type: "Dataset", - graphs: { default: {} }, - }; - const chainBlankNode = DF.blankNode(); - const otherQuad = DF.quad( - DF.namedNode("https://arbitrary.subject"), - DF.namedNode("https://arbitrary.predicate"), - chainBlankNode, - DF.defaultGraph(), - ); - const mockQuad = DF.quad( - chainBlankNode, - { termType: "Unknown term type" } as any, - DF.namedNode("https://arbitrary.object"), - DF.defaultGraph(), - ); - expect(() => - addRdfJsQuadToDataset(mockDataset, otherQuad, { - chainBlankNodes: [chainBlankNode], - otherQuads: [mockQuad], - }), - ).toThrow( - "Cannot parse Quads with nodes of type [Unknown term type] as their Predicate node.", - ); - }); - - it("throws an error when passed unknown Predicate types in connecting Quads for chain Blank Node Objects", () => { - const mockDataset: ImmutableDataset = { - type: "Dataset", - graphs: { default: {} }, - }; - const chainBlankNode1 = DF.blankNode(); - const chainBlankNode2 = DF.blankNode(); - const otherQuad = DF.quad( - DF.namedNode("https://arbitrary.subject"), - DF.namedNode("https://arbitrary.predicate"), - chainBlankNode1, - DF.defaultGraph(), - ); - const inBetweenQuad = DF.quad( - chainBlankNode1, - { termType: "Unknown term type" } as any, - chainBlankNode2, - DF.defaultGraph(), - ); - const mockQuad = DF.quad( - chainBlankNode2, - DF.namedNode("https://arbitrary.predicate"), - DF.namedNode("https://arbitrary.object"), - DF.defaultGraph(), - ); - expect(() => - addRdfJsQuadToDataset(mockDataset, otherQuad, { - chainBlankNodes: [chainBlankNode1, chainBlankNode2], - otherQuads: [mockQuad, inBetweenQuad], - }), - ).toThrow( - "Cannot parse Quads with nodes of type [Unknown term type] as their Predicate node.", - ); - }); - - it("throws an error when passed unknown Predicate types in the terminating Quads for chain Blank Node Objects", () => { - const mockDataset: ImmutableDataset = { - type: "Dataset", - graphs: { default: {} }, - }; - const chainBlankNode1 = DF.blankNode(); - const chainBlankNode2 = DF.blankNode(); - const otherQuad = DF.quad( - DF.namedNode("https://arbitrary.subject"), - DF.namedNode("https://arbitrary.predicate"), - chainBlankNode1, - DF.defaultGraph(), - ); - const inBetweenQuad = DF.quad( - chainBlankNode1, - DF.namedNode("https://arbitrary.predicate"), - chainBlankNode2, - DF.defaultGraph(), - ); - const mockQuad = DF.quad( - chainBlankNode2, - { termType: "Unknown term type" } as any, - DF.namedNode("https://arbitrary.object"), - DF.defaultGraph(), - ); - expect(() => - addRdfJsQuadToDataset(mockDataset, otherQuad, { - chainBlankNodes: [chainBlankNode1, chainBlankNode2], - otherQuads: [mockQuad, inBetweenQuad], - }), - ).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", @@ -586,33 +484,36 @@ describe("fromRdfJsDataset", () => { DF.defaultGraph(), ); - const updatedDataset = addRdfJsQuadToDataset(mockDataset, otherQuad, { - chainBlankNodes: [chainBlankNode1], - otherQuads: [mockQuad], - }); + const updatedDataset = [mockQuad, otherQuad].reduce( + addRdfJsQuadToDataset, + mockDataset, + ); - expect(updatedDataset).toStrictEqual({ - graphs: { - default: { - "https://some.subject": { - predicates: { - "https://some.predicate/1": { - blankNodes: [ - { - "https://some.predicate/2": { - blankNodes: ["_:some-blank-node"], - }, - }, - ], - }, - }, - type: "Subject", - url: "https://some.subject", - }, - }, - }, - type: "Dataset", + // 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", () => { @@ -640,40 +541,42 @@ describe("fromRdfJsDataset", () => { DF.blankNode("some-blank-node"), DF.defaultGraph(), ); + const updatedDataset = [mockQuad, inBetweenQuad, otherQuad].reduce( + addRdfJsQuadToDataset, + mockDataset, + ); - const updatedDataset = addRdfJsQuadToDataset(mockDataset, otherQuad, { - chainBlankNodes: [chainBlankNode1, chainBlankNode2], - otherQuads: [mockQuad, inBetweenQuad], - }); - - expect(updatedDataset).toStrictEqual({ - graphs: { - default: { - "https://some.subject": { - predicates: { - "https://some.predicate/1": { - blankNodes: [ - { - "https://some.predicate/2": { - blankNodes: [ - { - "https://some.predicate/3": { - blankNodes: ["_:some-blank-node"], - }, - }, - ], - }, - }, - ], - }, - }, - type: "Subject", - url: "https://some.subject", - }, - }, - }, - type: "Dataset", - }); + // 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"); }); }); }); diff --git a/src/rdfjs.internal.ts b/src/rdfjs.internal.ts index 1c3a1dc4f7..a9ccc15c71 100644 --- a/src/rdfjs.internal.ts +++ b/src/rdfjs.internal.ts @@ -22,7 +22,6 @@ import { DataFactory } from "n3"; import type * as RdfJs from "@rdfjs/types"; import type { - BlankNodeId, Graph, ImmutableDataset, Objects, @@ -41,15 +40,9 @@ import { xmlSchemaTypes } from "./datatypes"; export { DataFactory }; -type QuadParseOptions = Partial<{ - otherQuads: RdfJs.Quad[]; - chainBlankNodes: RdfJs.BlankNode[]; -}>; - export function addRdfJsQuadToDataset( dataset: ImmutableDataset, quad: RdfJs.Quad, - quadParseOptions: QuadParseOptions = {}, ): ImmutableDataset { const supportedGraphTypes: Array = [ "NamedNode", @@ -68,16 +61,12 @@ export function addRdfJsQuadToDataset( ...dataset, graphs: freeze({ ...dataset.graphs, - [graphId]: addRdfJsQuadToGraph(graph, quad, quadParseOptions), + [graphId]: addRdfJsQuadToGraph(graph, quad), }), }); } -function addRdfJsQuadToGraph( - graph: Graph, - quad: RdfJs.Quad, - quadParseOptions: QuadParseOptions, -): Graph { +function addRdfJsQuadToGraph(graph: Graph, quad: RdfJs.Quad): Graph { const supportedSubjectTypes: Array = [ "NamedNode", "BlankNode", @@ -100,29 +89,20 @@ function addRdfJsQuadToGraph( }; return freeze({ ...graph, - [subjectIri]: addRdfJsQuadToSubject(subject, quad, quadParseOptions), + [subjectIri]: addRdfJsQuadToSubject(subject, quad), }); } -function addRdfJsQuadToSubject( - subject: Subject, - quad: RdfJs.Quad, - quadParseOptions: QuadParseOptions, -): Subject { +function addRdfJsQuadToSubject(subject: Subject, quad: RdfJs.Quad): Subject { return freeze({ ...subject, - predicates: addRdfJsQuadToPredicates( - subject.predicates, - quad, - quadParseOptions, - ), + predicates: addRdfJsQuadToPredicates(subject.predicates, quad), }); } function addRdfJsQuadToPredicates( predicates: Predicates, quad: RdfJs.Quad, - quadParseOptions: QuadParseOptions, ): Predicates { const supportedPredicateTypes: Array = [ "NamedNode", @@ -136,15 +116,11 @@ function addRdfJsQuadToPredicates( const objects = predicates[predicateIri] ?? {}; return freeze({ ...predicates, - [predicateIri]: addRdfJsQuadToObjects(objects, quad, quadParseOptions), + [predicateIri]: addRdfJsQuadToObjects(objects, quad), }); } -function addRdfJsQuadToObjects( - objects: Objects, - quad: RdfJs.Quad, - quadParseOptions: QuadParseOptions, -): Objects { +function addRdfJsQuadToObjects(objects: Objects, quad: RdfJs.Quad): Objects { if (quad.object.termType === "NamedNode") { const namedNodes = freeze([ ...(objects.namedNodes ?? []), @@ -189,13 +165,9 @@ function addRdfJsQuadToObjects( } if (quad.object.termType === "BlankNode") { - const blankNodePredicates = getPredicatesForBlankNode( - quad.object, - quadParseOptions, - ); const blankNodes = freeze([ ...(objects.blankNodes ?? []), - blankNodePredicates, + getBlankNodeId(quad.object), ]); return freeze({ ...objects, @@ -208,149 +180,6 @@ function addRdfJsQuadToObjects( ); } -function getPredicatesForBlankNode( - node: RdfJs.BlankNode, - quadParseOptions: QuadParseOptions, -): Predicates | BlankNodeId { - const chainBlankNodes = quadParseOptions.chainBlankNodes ?? []; - if ( - chainBlankNodes.find((chainBlankNode) => chainBlankNode.equals(node)) === - undefined - ) { - // If this Blank Node is not used to provide nested values for another Subject, - // just return its identifier. - // That identifier will also be listed among the Subjects in the Graph. - return getBlankNodeId(node); - } - - /* istanbul ignore next: If there are chain nodes, there will always be other Quads, so the `?? []` can't be reached: */ - const quads = quadParseOptions.otherQuads ?? []; - const quadsWithNodeAsSubject = quads.filter((quad) => - quad.subject.equals(node), - ); - - // First add the Quads with regular Objects - const predicates = quadsWithNodeAsSubject - .filter((quad) => !isBlankNode(quad.object)) - .reduce((predicatesAcc, quad) => { - const supportedPredicateTypes: Array = [ - "NamedNode", - ]; - if (!supportedPredicateTypes.includes(quad.predicate.termType)) { - throw new Error( - `Cannot parse Quads with nodes of type [${quad.predicate.termType}] as their Predicate node.`, - ); - } - const objects: Objects = predicatesAcc[quad.predicate.value] ?? {}; - return freeze({ - ...predicatesAcc, - [quad.predicate.value]: addRdfJsQuadToObjects( - objects, - quad, - quadParseOptions, - ), - }); - }, {} as Predicates); - - // And then also add the Quads that have another Blank Node as the Object - // in addition to the Blank Node `node` as the Subject: - const blankNodeObjectQuads = quadsWithNodeAsSubject.filter((quad) => - isBlankNode(quad.object), - ); - return blankNodeObjectQuads.reduce((predicatesAcc, quad) => { - const supportedPredicateTypes: Array = [ - "NamedNode", - ]; - if (!supportedPredicateTypes.includes(quad.predicate.termType)) { - throw new Error( - `Cannot parse Quads with nodes of type [${quad.predicate.termType}] as their Predicate node.`, - ); - } - /* istanbul ignore next: The `?? {}` doesn't get hit; presumably it's initialised above. */ - const objects: Objects = predicatesAcc[quad.predicate.value] ?? {}; - /* istanbul ignore next: The `?? []` doesn't get hit; presumably it's initialised above. */ - const blankNodes = objects.blankNodes ?? []; - return freeze({ - ...predicatesAcc, - // The BlankNode assertions are valid because we filtered on BlankNodes above: - [quad.predicate.value]: { - ...objects, - blankNodes: [ - ...blankNodes, - getPredicatesForBlankNode( - quad.object as RdfJs.BlankNode, - quadParseOptions, - ), - ], - }, - }); - }, predicates); -} - -/** - * Given an array of Quads, returns all Blank Nodes that are used in a single chain of Nodes. - * - * This allows you to obtain which Blank Nodes are involved in e.g. RDF lists. - * This is useful because those can be represented as nested data that will have - * a deterministic structure, whereas a representation of Blank Nodes that - * create a cycle or are re-used will need ad-hoc, non-deterministic identifiers - * to allow for representation without inifinite nesting. - */ -export function getChainBlankNodes(quads: RdfJs.Quad[]): RdfJs.BlankNode[] { - // All Blank Nodes that occur in Subject position: - const blankNodeSubjects = quads - .map((quad) => quad.subject) - .filter(isBlankNode); - // All Blank Nodes that occur in Object position: - const blankNodeObjects = quads.map((quad) => quad.object).filter(isBlankNode); - // Makes sure that all given Nodes are the same, - // which will be used to verify that a set of Quads all have the same Subject: - function everyNodeTheSame(nodes: RdfJs.Term[]): boolean { - // This could potentially be made more performant by mapping every term - // to their value and using native JS comparisons, assuming every node is - // either a Blank or a Named Node. - return nodes.every((otherNode) => - nodes.every((anotherNode) => otherNode.equals(anotherNode)), - ); - } - - // Get all Blank Nodes that are part of a cycle in the graph: - const cycleBlankNodes: RdfJs.BlankNode[] = []; - blankNodeObjects.forEach((blankNodeObject) => { - cycleBlankNodes.push(...getCycleBlankNodes(blankNodeObject, quads)); - }); - - // Get Blank Nodes that are used to provide nested values for a single Subject, - // which we'll represent as nested values as well - // (this allows us to avoid generating a non-deterministic, ad-hoc identifier - // for those Blank Nodes). - // We'll do this by taking all Blank Nodes in the given Quads... - const chainBlankNodes = blankNodeSubjects - .concat(blankNodeObjects) - .filter((blankNode) => { - // ....removing those Blank Nodes that are part of a cycle... - if ( - cycleBlankNodes.some((cycleBlankNode) => - cycleBlankNode.equals(blankNode), - ) - ) { - return false; - } - // ...and then returning only those Blank Nodes that only occur in the - // Object position for a single Subject, i.e. that are part of a single - // chain: - const subjectsWithThisNodeAsObject = quads - .filter((quad) => quad.object.equals(blankNode)) - .map((quad) => quad.subject); - return ( - subjectsWithThisNodeAsObject.length > 0 && - everyNodeTheSame(subjectsWithThisNodeAsObject) - ); - }); - - return chainBlankNodes; -} - export function toRdfJsQuads( dataset: ImmutableDataset, options: ToRdfJsOptions = {}, @@ -474,58 +303,3 @@ export function subjectToRdfJsQuads( return quads; } - -/** - * A recursive function that finds all Blank Nodes in an array of Quads that create a cycle in the graph. - * - * This function will traverse the graph starting from `currentNode`, keeping - * track of all the Blank Nodes it encounters twice while doing so, and - * returning those. - */ -function getCycleBlankNodes( - currentNode: RdfJs.BlankNode, - quads: RdfJs.Quad[], - traversedBlankNodes: RdfJs.BlankNode[] = [], -): RdfJs.BlankNode[] { - // If we've encountered `currentNode` before, all the Blank Nodes we've - // encountered so far are part of a cycle. Return those. - if ( - traversedBlankNodes.find((traversedBlankNode) => - traversedBlankNode.equals(currentNode), - ) !== undefined - ) { - return traversedBlankNodes; - } - - // Find all Blank Nodes that are connected to `currentNode`: - const blankNodeObjects = quads - .filter( - (quad) => quad.subject.equals(currentNode) && isBlankNode(quad.object), - ) - .map((quad) => quad.object as RdfJs.BlankNode); - - // If no Blank Nodes are connected to `currentNode`, and `currentNode` is not - // part of a cycle, we're done; the currently traversed Nodes do not form a - // cycle: - if (blankNodeObjects.length === 0) { - return []; - } - - // Store that we've traversed `currentNode`, then move on to all the Blank - // Nodes connected to it, which will then take up the role of `currentNode`: - const nextTraversedNodes = [...traversedBlankNodes, currentNode]; - const cycleBlankNodeArrays = blankNodeObjects.map((nextNode) => - getCycleBlankNodes(nextNode, quads, nextTraversedNodes), - ); - // Collect all the cycle Blank Nodes found in those traverals, - // then return them: - const allCycleBlankNodes: RdfJs.BlankNode[] = []; - for (const cycleBlankNodes of cycleBlankNodeArrays) { - allCycleBlankNodes.push(...cycleBlankNodes); - } - return allCycleBlankNodes; -} - -function isBlankNode(term: RdfJs.Term): term is RdfJs.BlankNode { - return term.termType === "BlankNode"; -} diff --git a/src/rdfjs.ts b/src/rdfjs.ts index 02d4093f11..be6ecaf73b 100644 --- a/src/rdfjs.ts +++ b/src/rdfjs.ts @@ -40,11 +40,7 @@ import type { DatasetCoreFactory, } from "@rdfjs/types"; import { rdfJsDataset, type ImmutableDataset } from "./rdf.internal"; -import { - addRdfJsQuadToDataset, - getChainBlankNodes, - toRdfJsQuads, -} from "./rdfjs.internal"; +import { addRdfJsQuadToDataset, toRdfJsQuads } from "./rdfjs.internal"; /** * Convert an RDF/JS Dataset into a [[SolidDataset]] @@ -58,31 +54,14 @@ import { * @returns A [[SolidDataset]] containing the same data as the given RDF/JS Dataset. * @since 1.9.0 */ -export function fromRdfJsDataset(rdfJsDataset: DatasetCore): ImmutableDataset { - const dataset: ImmutableDataset = { +export function fromRdfJsDataset(dataset: DatasetCore): ImmutableDataset { + const solidDataset: ImmutableDataset = { graphs: { default: {} }, type: "Dataset", }; - - const quads = Array.from(rdfJsDataset); - - const chainBlankNodes = getChainBlankNodes(quads); - - // Quads with chain Blank Nodes as their Subject will be parsed when those - // Blank Nodes are referred to in an Object. See `addRdfJsQuadToObjects`. - const quadsWithoutChainBlankNodeSubjects = quads.filter((quad) => - chainBlankNodes.every( - (chainBlankNode) => !chainBlankNode.equals(quad.subject), - ), - ); - - return quadsWithoutChainBlankNodeSubjects.reduce( - (datasetAcc, quad) => - addRdfJsQuadToDataset(datasetAcc, quad, { - otherQuads: quads, - chainBlankNodes, - }), - dataset, + return Array.from(dataset).reduce( + (datasetAcc, quad) => addRdfJsQuadToDataset(datasetAcc, quad), + solidDataset, ); } diff --git a/src/resource/__snapshots__/solidDataset.test.ts.snap b/src/resource/__snapshots__/solidDataset.test.ts.snap index 0d28879089..b349d4eaa3 100644 --- a/src/resource/__snapshots__/solidDataset.test.ts.snap +++ b/src/resource/__snapshots__/solidDataset.test.ts.snap @@ -105,7 +105,7 @@ exports[`getWellKnownSolid returns the contents of .well-known/solid for the giv { "graphs": { "default": { - "_:n3-2007": { + "_:n3-3": { "predicates": { "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { "namedNodes": [ @@ -143,7 +143,7 @@ exports[`getWellKnownSolid returns the contents of .well-known/solid for the giv }, }, "type": "Subject", - "url": "_:n3-2007", + "url": "_:n3-3", }, }, }, diff --git a/src/resource/solidDataset.test.ts b/src/resource/solidDataset.test.ts index 94af2c8ce1..7226422db8 100644 --- a/src/resource/solidDataset.test.ts +++ b/src/resource/solidDataset.test.ts @@ -133,6 +133,7 @@ describe("createSolidDataset", () => { describe("responseToSolidDataset", () => { it("returns a SolidDataset representing the fetched Turtle", async () => { const turtle = ` + @base . @prefix : <#>. @prefix foaf: . @prefix vcard: . @@ -154,99 +155,22 @@ describe("responseToSolidDataset", () => { "https://some.pod/resource", ); const solidDataset = await responseToSolidDataset(response); - expect(solidDataset).toEqual( expect.objectContaining({ graphs: { default: { - "https://some.pod/resource": { - predicates: { - "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { - namedNodes: [ - "http://xmlns.com/foaf/0.1/PersonalProfileDocument", - ], - }, - "http://xmlns.com/foaf/0.1/maker": { - namedNodes: ["https://some.pod/resource#me"], - }, - "http://xmlns.com/foaf/0.1/primaryTopic": { - namedNodes: ["https://some.pod/resource#me"], - }, - }, + // The blank node identifier is by definition unstable. + // If this test starts failing, it may be due to the + // identifier changing, which is not forbidden. + "_:n3-0": { type: "Subject", - url: "https://some.pod/resource", - }, - "https://some.pod/resource#me": { + url: "_:n3-0", predicates: { - "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { - namedNodes: ["http://xmlns.com/foaf/0.1/Person"], - }, - "http://www.w3.org/2006/vcard/ns#fn": { - blankNodes: [ - { - "https://some.pod/resource#predicate": { - namedNodes: ["for://a.blank/node"], - }, - }, - ], - literals: { - "http://www.w3.org/2001/XMLSchema#string": ["Vincent"], - }, + "https://some.pod/resource#predicate": { + namedNodes: ["for://a.blank/node"], }, }, - type: "Subject", - url: "https://some.pod/resource#me", }, - }, - }, - internal_resourceInfo: { - contentType: "text/turtle", - isRawData: false, - linkedResources: {}, - sourceIri: "https://some.pod/resource", - }, - type: "Dataset", - }), - ); - }); - - it("does not include non-deterministic identifiers when it detects non-cyclic chains of Blank Nodes", async () => { - const turtle = ` - @prefix : <#>. - @prefix foaf: . - @prefix vcard: . - @prefix acl: . - - <> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. - - :me - a foaf:Person; - vcard:fn "Vincent"; - acl:trustedApp - [ - acl:mode acl:Append, acl:Control, acl:Read, acl:Write; - acl:origin - ], - [ - acl:mode acl:Append, acl:Control, acl:Read, acl:Write; - acl:origin - ]. - `; - - const response = new Response(turtle, { - headers: { - "Content-Type": "text/turtle", - }, - }); - jest - .spyOn(response, "url", "get") - .mockReturnValue("https://some.pod/resource"); - const solidDataset = await responseToSolidDataset(response); - - expect(solidDataset).toEqual( - expect.objectContaining({ - graphs: { - default: { "https://some.pod/resource": { predicates: { "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": { @@ -270,40 +194,12 @@ describe("responseToSolidDataset", () => { namedNodes: ["http://xmlns.com/foaf/0.1/Person"], }, "http://www.w3.org/2006/vcard/ns#fn": { + // Here, the blank node identifier isn't referenced explicitly for resiliency. + blankNodes: [expect.stringMatching(/^_:/)], literals: { "http://www.w3.org/2001/XMLSchema#string": ["Vincent"], }, }, - "http://www.w3.org/ns/auth/acl#trustedApp": { - blankNodes: [ - { - "http://www.w3.org/ns/auth/acl#mode": { - namedNodes: [ - "http://www.w3.org/ns/auth/acl#Append", - "http://www.w3.org/ns/auth/acl#Control", - "http://www.w3.org/ns/auth/acl#Read", - "http://www.w3.org/ns/auth/acl#Write", - ], - }, - "http://www.w3.org/ns/auth/acl#origin": { - namedNodes: ["http://localhost:3000"], - }, - }, - { - "http://www.w3.org/ns/auth/acl#mode": { - namedNodes: [ - "http://www.w3.org/ns/auth/acl#Append", - "http://www.w3.org/ns/auth/acl#Control", - "http://www.w3.org/ns/auth/acl#Read", - "http://www.w3.org/ns/auth/acl#Write", - ], - }, - "http://www.w3.org/ns/auth/acl#origin": { - namedNodes: ["https://penny.vincenttunru.com"], - }, - }, - ], - }, }, type: "Subject", url: "https://some.pod/resource#me", @@ -321,53 +217,6 @@ describe("responseToSolidDataset", () => { ); }); - it("does not attempt to detect chains when there are many Blank Nodes, to avoid performance bottlenecks", async () => { - function getChainedBlankNode(iteration: number): string { - if (iteration === 1000) { - return ` "Base case"`; - } - return ` [${getChainedBlankNode( - iteration + 1, - )}]`; - } - const turtle = ` - @prefix : <#>. - @prefix vcard: . - - :me vcard:fn [${getChainedBlankNode(0)}]. - `; - - // This test uses constructs native to Node 16. - const t0 = performance.now(); - await responseToSolidDataset( - mockResponse(turtle, { - headers: { - "Content-Type": "text/turtle", - }, - }), - ); - const t1 = performance.now(); - - // Parsing a document with over 1000 statements will always be somewhat slow - // (hence allowing it to take 1.5 seconds), but if it attempts to detect - // chains, it will take on the order of >20 seconds. - // eslint-disable-next-line jest/no-conditional-expect - expect(t1 - t0).toBeLessThan(1500); - - const solidDataset = await responseToSolidDataset( - mockResponse(turtle, { - headers: { - "Content-Type": "text/turtle", - }, - }), - ); - // Blank Nodes should be listed explicitly, rather than as properties on - // https://some.pod/resource#me: - expect(Object.keys(solidDataset.graphs.default)).not.toStrictEqual([ - "https://some.pod/resource#me", - ]); - }); - it("throws a meaningful error when the server returned a 403", async () => { const response = new Response("Not allowed", { status: 403, diff --git a/src/resource/solidDataset.ts b/src/resource/solidDataset.ts index 15150d44ae..83c7dfd1f8 100644 --- a/src/resource/solidDataset.ts +++ b/src/resource/solidDataset.ts @@ -19,13 +19,9 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import type { Quad, NamedNode, Quad_Object } from "@rdfjs/types"; -import { - addRdfJsQuadToDataset, - DataFactory, - getChainBlankNodes, - toRdfJsQuads, -} from "../rdfjs.internal"; +import type { Quad, NamedNode, Quad_Object, DatasetCore } from "@rdfjs/types"; +import { Store as N3Store } from "n3"; +import { DataFactory, toRdfJsQuads } from "../rdfjs.internal"; import { ldp, pim } from "../constants"; import { getJsonLdParser } from "../formats/jsonLd"; import { triplesToTurtle, getTurtleParser } from "../formats/turtle"; @@ -65,6 +61,7 @@ import { import { getIriAll } from "../thing/get"; import { normalizeServerSideIri } from "./iri.internal"; import { freeze, getLocalNodeName, isLocalNodeIri } from "../rdf.internal"; +import { fromRdfJsDataset } from "../rdfjs"; /** * Initialise a new [[SolidDataset]] in memory. @@ -156,6 +153,7 @@ type ContentType = string; export type ParseOptions = { parsers: Record; }; + /** * @hidden This interface is not exposed yet until we've tried it out in practice. */ @@ -200,81 +198,31 @@ export async function responseToSolidDataset( } const data = await response.text(); - const parsingPromise = new Promise( - (resolve, reject) => { - let solidDataset: SolidDataset = freeze({ - graphs: freeze({ default: freeze({}) }), - type: "Dataset", - }); - - // While Quads without Blank Nodes can be added to the SolidDataset as we - // encounter them, to parse Quads with Blank Nodes, we'll have to wait until - // we've seen all the Quads, so that we can reconcile equal Blank Nodes. - const quadsWithBlankNodes: Quad[] = []; - const allQuads: Quad[] = []; - - parser.onError((error) => { - reject( - new Error( - `Encountered an error parsing the Resource at [${getSourceUrl( - resourceInfo, - )}] with content type [${contentType}]: ${error}`, - ), - ); - }); - parser.onQuad((quad) => { - allQuads.push(quad); - if ( - quad.subject.termType === "BlankNode" || - quad.object.termType === "BlankNode" - ) { - // Quads with Blank Nodes will be parsed when all Quads are known, - // so that equal Blank Nodes can be reconciled: - quadsWithBlankNodes.push(quad); - } else { - solidDataset = addRdfJsQuadToDataset(solidDataset, quad); - } - }); - parser.onComplete(async () => { - // If a Resource contains more than this number of Blank Nodes, - // we consider the detection of chains (O(n^2), I think) to be too - // expensive, and just incorporate them as regular Blank Nodes with - // non-deterministic, ad-hoc identifiers into the SolidDataset: - const maxBlankNodesToDetectChainsFor = 20; - // Some Blank Nodes only serve to use a set of Quads as the Object for a - // single Subject. Those Quads will be added to the SolidDataset when - // their Subject's Blank Node is encountered in the Object position. - const chainBlankNodes = - quadsWithBlankNodes.length <= maxBlankNodesToDetectChainsFor - ? getChainBlankNodes(quadsWithBlankNodes) - : []; - const quadsWithoutChainBlankNodeSubjects = quadsWithBlankNodes.filter( - (quad) => - chainBlankNodes.every( - (chainBlankNode) => !chainBlankNode.equals(quad.subject), - ), - ); - solidDataset = quadsWithoutChainBlankNodeSubjects.reduce( - (datasetAcc, quad) => - addRdfJsQuadToDataset(datasetAcc, quad, { - otherQuads: allQuads, - chainBlankNodes, - }), - solidDataset, - ); - const solidDatasetWithResourceInfo: SolidDataset & - WithServerResourceInfo = freeze({ - ...solidDataset, - ...resourceInfo, - }); - resolve(solidDatasetWithResourceInfo); - }); - - parser.parse(data, resourceInfo); - }, - ); + const rdfjsDataset = await new Promise((resolve, reject) => { + const store = new N3Store(); + parser.onError((error) => { + reject( + new Error( + `Encountered an error parsing the Resource at [${getSourceUrl( + resourceInfo, + )}] with content type [${contentType}]: ${error}`, + ), + ); + }); + parser.onQuad((quad) => { + store.add(quad); + }); + parser.onComplete(() => { + resolve(store); + }); - return parsingPromise; + parser.parse(data, resourceInfo); + }); + const solidDataset: SolidDataset = freeze(fromRdfJsDataset(rdfjsDataset)); + return freeze({ + ...solidDataset, + ...resourceInfo, + }); } /**