From d230929f10cffe74eb670efa94f02a4d72c5b930 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:51:44 +0100 Subject: [PATCH 01/19] feat: introduce GraphScopedDataset and ProjectedDataset for enhanced graph handling --- src/DatasetWrapper.ts | 82 +++++--- src/GraphScopedDataset.ts | 25 +++ src/NamedGraphDataset.ts | 55 ------ src/ProjectedDataset.ts | 146 ++++++++++++++ src/mod.ts | 5 +- src/type/IGraphScopedDatasetConstructor.ts | 10 + src/type/INamedGraphDatasetConstructor.ts | 4 - test/unit/named_graph.test.ts | 6 +- test/unit/named_graph_integration.test.ts | 6 +- test/unit/union_graph.test.ts | 212 +++++++++++++++++++++ 10 files changed, 461 insertions(+), 90 deletions(-) create mode 100644 src/GraphScopedDataset.ts delete mode 100644 src/NamedGraphDataset.ts create mode 100644 src/ProjectedDataset.ts create mode 100644 src/type/IGraphScopedDatasetConstructor.ts delete mode 100644 src/type/INamedGraphDatasetConstructor.ts create mode 100644 test/unit/union_graph.test.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 5419880..3904213 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -1,40 +1,61 @@ -import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" import type { ITermWrapperConstructor } from "./type/ITermWrapperConstructor.js" -import type { NamedGraphDataset } from "./NamedGraphDataset.js" -import type { INamedGraphDatasetConstructor } from "./type/INamedGraphDatasetConstructor.js" +import type { GraphScopedDataset } from "./GraphScopedDataset.js" +import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetConstructor.js" import { RDF } from "./vocabulary/RDF.js" +import { ensureDefaultGraph, ensureTermType } from "./ensure.js" -export class DatasetWrapper implements DatasetCore { +const defaultGraph: Term = Object.freeze({ + termType: "DefaultGraph", + value: "", + equals: (other: Term | null | undefined) => other?.termType === "DefaultGraph" && other.value === "" +}); + +export interface DefaultDatasetCore extends DatasetCore { + match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore +} + +export class DatasetWrapper implements DefaultDatasetCore { //#region DatasetCore - public constructor(private readonly dataset: DatasetCore, protected readonly factory: DataFactory) { + public constructor( + private readonly dataset: DatasetCore, + protected readonly factory: DataFactory, + protected readonly datasetFactory: DatasetFactory, + + ) { } public get size(): number { - return this.dataset.size + // We cannot delegate to the underlying dataset's size, as it may contain quads in named graphs that are not visible through this wrapper. + // Instead, we need to count the quads that match the default graph. + return this.match().size } public* [Symbol.iterator](): Iterator { - yield* this.dataset + yield* this.match() } public add(quad: Quad): this { + ensureDefaultGraph(quad) this.dataset.add(quad) return this } public delete(quad: Quad): this { + ensureDefaultGraph(quad) this.dataset.delete(quad) return this } public has(quad: Quad): boolean { + ensureDefaultGraph(quad) return this.dataset.has(quad) } - public match(subject?: Term, predicate?: Term, object?: Term, graph?: Term): DatasetCore { - return this.dataset.match(subject, predicate, object, graph) + public match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { + return this.dataset.match(subject, predicate, object, defaultGraph) } //#endregion @@ -54,29 +75,44 @@ export class DatasetWrapper implements DatasetCore { } /** - * Creates a view over a single named graph, projecting its contents into the default graph. + * Creates a view over a configurable set of graphs in the underlying + * dataset, projected onto the default graph. * - * The returned dataset only exposes quads from the specified named graph, with their graph component replaced by the default graph. Writes through the view are mapped back to the named graph in the underlying dataset. Any attempt to use a non-default graph on the returned dataset throws a {@link NamedGraphError}. + * Writes through the view are mapped to `writeGraph` in the underlying + * dataset. Reads come from the supplied `readGraphs`; if `readGraphs` is + * `undefined`, quads from every graph (default and named) are read and + * triples appearing in multiple graphs are yielded only once. Any attempt + * to use a non-default graph on the returned dataset throws a + * {@link NamedGraphError} (for write operations) or a + * {@link TermTypeError} (for {@link DatasetCore.match}). * - * @param graph - The name of the graph to use. - * @param klass - A constructor of a class derived from named graph dataset - * @returns An instance of a class derived from {@link NamedGraphDataset} that is a view scoped to the specified named graph. + * @param writeGraph - The graph that writes through the view are directed + * to. May be a string IRI or a {@link Quad_Graph}. + * @param readGraphs - The graphs that are read through the view, or + * `undefined` to read from every graph. + * @param klass - A constructor of a class derived from + * {@link GraphScopedDataset}. + * @returns An instance of `klass` that is a view scoped to the supplied + * graphs. */ - protected named(graph: string, klass: INamedGraphDatasetConstructor): T - protected named(graph: Quad_Graph, klass: INamedGraphDatasetConstructor): T - protected named(graph: string | Quad_Graph, klass: INamedGraphDatasetConstructor): T { - const g = typeof graph === "string" ? this.factory.namedNode(graph) : graph - return new klass(g, this.dataset, this.factory) + protected scoped( + writeGraph: string | Quad_Graph, + readGraphs: ReadonlyArray | undefined, + klass: IGraphScopedDatasetConstructor, + ): T { + const w = typeof writeGraph === "string" ? this.factory.namedNode(writeGraph) : writeGraph + const r = readGraphs?.map(g => typeof g === "string" ? this.factory.namedNode(g) : g) + return new klass(w, r, this.dataset, this.factory, this.datasetFactory) } - protected* matchSubjectsOf(termWrapper: ITermWrapperConstructor, predicate?: Term, object?: Term, graph?: Term): Iterable { - for (const q of this.match(undefined, predicate, object, graph)) { + protected* matchSubjectsOf(termWrapper: ITermWrapperConstructor, predicate?: Term, object?: Term): Iterable { + for (const q of this.match(undefined, predicate, object)) { yield new termWrapper(q.subject, this, this.factory) } } - protected* matchObjectsOf(termWrapper: ITermWrapperConstructor, subject?: Term, predicate?: Term, graph?: Term): Iterable { - for (const q of this.match(subject, predicate, undefined, graph)) { + protected* matchObjectsOf(termWrapper: ITermWrapperConstructor, subject?: Term, predicate?: Term): Iterable { + for (const q of this.match(subject, predicate, undefined)) { yield new termWrapper(q.object, this, this.factory) } } diff --git a/src/GraphScopedDataset.ts b/src/GraphScopedDataset.ts new file mode 100644 index 0000000..fdfc46b --- /dev/null +++ b/src/GraphScopedDataset.ts @@ -0,0 +1,25 @@ +import type { DataFactory, DatasetCore, DatasetFactory, Quad_Graph } from "@rdfjs/types" +import { DatasetWrapper } from "./DatasetWrapper.js" +import { ProjectedDataset } from "./ProjectedDataset.js" + +/** + * A {@link DatasetWrapper} that exposes a configurable set of graphs from an + * underlying dataset projected onto the default graph. + * + * The wrapper writes new quads to a single configured `writeGraph` and reads + * from the supplied `readGraphs`. When `readGraphs` is `undefined`, every + * graph (default and named) is read and triples are deduplicated across them. + * + * @see {@link ProjectedDataset} + */ +export class GraphScopedDataset extends DatasetWrapper { + public constructor( + writeGraph: Quad_Graph, + readGraphs: ReadonlyArray | undefined, + dataset: DatasetCore, + factory: DataFactory, + datasetFactory: DatasetFactory, + ) { + super(new ProjectedDataset(writeGraph, readGraphs, dataset, factory, datasetFactory), factory) + } +} diff --git a/src/NamedGraphDataset.ts b/src/NamedGraphDataset.ts deleted file mode 100644 index b1d42d1..0000000 --- a/src/NamedGraphDataset.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" -import { DatasetWrapper } from "./DatasetWrapper.js" -import { ensureDefaultGraph, ensureTermType } from "./ensure.js" - -export class NamedGraphDataset extends DatasetWrapper { - constructor(private readonly graph: Quad_Graph, dataset: DatasetCore, factory: DataFactory) { - super(dataset, factory) - } - - override get size(): number { - return this.subGraph.size - } - - override* [Symbol.iterator](): Iterator { - for (const quad of this.subGraph) { - yield this.asDefault(quad) - } - } - - override add(quad: Quad): this { - super.add(this.asNamed(quad)) - return this - } - - override delete(quad: Quad): this { - super.delete(this.asNamed(quad)) - return this - } - - override has(quad: Quad): boolean { - return super.has(this.asNamed(quad)) - } - - override match(subject?: Term, predicate?: Term, object?: Term, graph?: Term): DatasetCore { - if (graph !== undefined) { - ensureTermType(graph, "DefaultGraph") - } - - return new NamedGraphDataset(this.graph, super.match(subject, predicate, object, this.graph), this.factory) - } - - private get subGraph(): DatasetCore { - return super.match(undefined, undefined, undefined, this.graph); - } - - private asNamed(quad: Quad): Quad { - ensureDefaultGraph(quad) - - return this.factory.quad(quad.subject, quad.predicate, quad.object, this.graph) - } - - private asDefault(quad: Quad): Quad { - return this.factory.quad(quad.subject, quad.predicate, quad.object); - } -} diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts new file mode 100644 index 0000000..e5971a9 --- /dev/null +++ b/src/ProjectedDataset.ts @@ -0,0 +1,146 @@ +import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { DefaultDatasetCore } from "./DatasetWrapper.js" +import { ensureDefaultGraph, ensureTermType } from "./ensure.js" + +/** + * A {@link DefaultDatasetCore} view over an underlying {@link DatasetCore} that + * projects quads from one or more named graphs onto the default graph. + * + * - Reads come from the configured set of read graphs. If `readGraphs` is + * `undefined`, quads from every graph (default and named) are read and the + * same triple appearing in multiple graphs is yielded only once. + * - Writes ({@link ProjectedDataset.add}, {@link ProjectedDataset.delete}) are + * mapped onto the configured `writeGraph` in the underlying dataset. Any + * attempt to add/delete/has a quad whose graph is not the default graph + * throws a {@link NamedGraphError}. + * - {@link ProjectedDataset.match} only accepts the default graph (or no + * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. + */ +export class ProjectedDataset implements DefaultDatasetCore { + public constructor( + private readonly writeGraph: Quad_Graph, + private readonly readGraphs: ReadonlyArray | undefined, + private readonly source: DatasetCore, + private readonly factory: DataFactory, + private readonly datasetFactory: DatasetFactory, + ) { + } + + private _dataset: DatasetCore | null = null; + + private get dataset(): DatasetCore { + if (this._dataset === null) { + this._dataset = this.match() + } + return this._dataset + } + + public get size(): number { + return this.dataset.size + } + + public [Symbol.iterator](): Iterator { + return this.dataset[Symbol.iterator]() + } + + public add(quad: Quad): this { + ensureDefaultGraph(quad) + if (this._dataset) { + this._dataset.add(quad) + } + this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + return this + } + + public delete(quad: Quad): this { + ensureDefaultGraph(quad) + if (this._dataset) { + this._dataset.delete(quad) + } + this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + return this + } + + public has(quad: Quad): boolean { + ensureDefaultGraph(quad) + return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) + } + + public match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { + return new LazyMaterialize(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) + } + + private *matchInSource(subject?: Term, predicate?: Term, object?: Term): Iterable { + if (this.readGraphs === undefined) { + return this.source.match(subject, predicate, object) + } + for (const g of this.readGraphs) { + yield* this.source.match(subject, predicate, object, g) + } + } + + private *matchInSourceAsDefault(subject?: Term, predicate?: Term, object?: Term): Iterable { + for (const q of this.matchInSource(subject, predicate, object)) { + yield this.factory.quad(q.subject, q.predicate, q.object) + } + } +} + +export class LazyMaterialize implements DatasetCore { + private materialized: DatasetCore | null = null + + public constructor(private readonly source: Iterable, private readonly datasetFactory: DatasetFactory) { + } + + private get dataset(): DatasetCore { + if (this.materialized === null) { + this.materialized = this.datasetFactory.dataset() + for (const q of this.source) { + this.materialized.add(q) + } + } + return this.materialized + } + + [Symbol.iterator](): Iterator { + if (this.materialized) { + return this.materialized[Symbol.iterator]() + } + return this.source[Symbol.iterator]() + } + + get size(): number { + if (this.materialized) { + return this.materialized.size + } + let count = 0 + for (const _ of this.source) count++ + return count + } + + add(quad: Quad): this { + this.dataset.add(quad) + return this + } + + delete(quad: Quad): this { + this.dataset.delete(quad) + return this + } + + has(quad: Quad): boolean { + if (this.materialized) { + return this.materialized.has(quad) + } + for (const q of this.source) { + if (q.equals(quad)) { + return true + } + } + return false + } + + match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { + return this.dataset.match(subject, predicate, object) + } +} diff --git a/src/mod.ts b/src/mod.ts index 016c51d..1152260 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,6 +1,6 @@ export type * from "./type/ITermAsValueMapping.js" export type * from "./type/ITermWrapperConstructor.js" -export type * from "./type/INamedGraphDatasetConstructor.js" +export type * from "./type/IGraphScopedDatasetConstructor.js" export type * from "./type/ITermFromValueMapping.js" export type * from "./type/ILangString.js" @@ -20,7 +20,8 @@ export * from "./mapping/RequiredAs.js" export * from "./DatasetWrapper.js" export * from "./TermWrapper.js" -export * from "./NamedGraphDataset.js" +export * from "./ProjectedDataset.js" +export * from "./GraphScopedDataset.js" export * from "./errors/WrapperError.js" export * from "./errors/TermError.js" diff --git a/src/type/IGraphScopedDatasetConstructor.ts b/src/type/IGraphScopedDatasetConstructor.ts new file mode 100644 index 0000000..238b69b --- /dev/null +++ b/src/type/IGraphScopedDatasetConstructor.ts @@ -0,0 +1,10 @@ +import type { DataFactory, DatasetCore, DatasetFactory, Quad_Graph } from "@rdfjs/types" +import type { GraphScopedDataset } from "../GraphScopedDataset.js" + +export type IGraphScopedDatasetConstructor = new ( + writeGraph: Quad_Graph, + readGraphs: ReadonlyArray | undefined, + dataset: DatasetCore, + factory: DataFactory, + datasetFactory: DatasetFactory, +) => T diff --git a/src/type/INamedGraphDatasetConstructor.ts b/src/type/INamedGraphDatasetConstructor.ts deleted file mode 100644 index 380551f..0000000 --- a/src/type/INamedGraphDatasetConstructor.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { DataFactory, DatasetCore, Quad_Graph } from "@rdfjs/types" -import type { NamedGraphDataset } from "../NamedGraphDataset.js" - -export type INamedGraphDatasetConstructor = new (graph: Quad_Graph, dataset: DatasetCore, factory: DataFactory) => T diff --git a/test/unit/named_graph.test.ts b/test/unit/named_graph.test.ts index 86c8a6e..c916469 100644 --- a/test/unit/named_graph.test.ts +++ b/test/unit/named_graph.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory, Store } from "n3" -import { DatasetWrapper, NamedGraphDataset, NamedGraphError, TermTypeError } from "@rdfjs/wrapper" +import { DatasetWrapper, GraphScopedDataset, NamedGraphError, TermTypeError } from "@rdfjs/wrapper" const graph = DataFactory.namedNode("https://example.org/graph") const s = DataFactory.namedNode("https://example.org/s") @@ -16,8 +16,8 @@ function storeWithNamedGraph(): Store { } class SomeDataset extends DatasetWrapper { - get namedGraph(): NamedGraphDataset { - return this.named(graph, NamedGraphDataset) + get namedGraph(): GraphScopedDataset { + return this.scoped(graph, [graph], GraphScopedDataset) } } diff --git a/test/unit/named_graph_integration.test.ts b/test/unit/named_graph_integration.test.ts index 3f52f6e..bf44a04 100644 --- a/test/unit/named_graph_integration.test.ts +++ b/test/unit/named_graph_integration.test.ts @@ -4,7 +4,7 @@ import { DataFactory } from "n3" import { Parent } from "./model/Parent.js" import { ParentDataset } from "./model/ParentDataset.js" import { Example } from "./vocabulary/Example.js" -import { DatasetWrapper, NamedGraphDataset } from "@rdfjs/wrapper" +import { DatasetWrapper, GraphScopedDataset } from "@rdfjs/wrapper" import { datasetFromRdf } from "./util/datasetFromRdf.js" const rdf = ` @@ -25,11 +25,11 @@ PREFIX : class SomeDataset extends DatasetWrapper { get namedGraph(): SomeNamedDataset { - return this.named("https://example.org/graph", SomeNamedDataset) + return this.scoped("https://example.org/graph", ["https://example.org/graph"], SomeNamedDataset) } } -class SomeNamedDataset extends NamedGraphDataset { +class SomeNamedDataset extends GraphScopedDataset { get parents(): Iterable { return this.subjectsOf("https://example.org/hasString", Parent) } diff --git a/test/unit/union_graph.test.ts b/test/unit/union_graph.test.ts new file mode 100644 index 0000000..63a4626 --- /dev/null +++ b/test/unit/union_graph.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory, Store } from "n3" +import { + DatasetWrapper, + GraphScopedDataset, + NamedGraphError, + TermTypeError, +} from "@rdfjs/wrapper" +import { Parent } from "./model/Parent.js" +import { Example } from "./vocabulary/Example.js" + +const graph = DataFactory.namedNode("https://example.org/graph") +const otherGraph = DataFactory.namedNode("https://example.org/other") +const s = DataFactory.namedNode("https://example.org/s") +const p = DataFactory.namedNode("https://example.org/p") +const oDefault = DataFactory.literal("default") +const oNamed = DataFactory.literal("named") +const oOther = DataFactory.literal("other") + +function multiGraphStore(): Store { + const store = new Store() + store.addQuad(DataFactory.quad(s, p, oDefault)) + store.addQuad(DataFactory.quad(s, p, oNamed, graph)) + store.addQuad(DataFactory.quad(s, p, oOther, otherGraph)) + return store +} + +class SomeDataset extends DatasetWrapper { + get unionView(): GraphScopedDataset { + return this.scoped(graph, undefined, GraphScopedDataset) + } +} + +await describe("GraphScopedDataset (union)", async () => { + await it("iterates quads from all graphs projected to the default graph", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + const quads = Array.from(view) + + assert.equal(quads.length, 3) + for (const quad of quads) { + assert.equal(quad.graph.termType, "DefaultGraph") + } + const values = quads.map(q => q.object.value).sort() + assert.deepEqual(values, ["default", "named", "other"]) + }) + + await it("deduplicates triples that appear in multiple graphs", () => { + const store = new Store() + store.addQuad(DataFactory.quad(s, p, oDefault)) + store.addQuad(DataFactory.quad(s, p, oDefault, graph)) + store.addQuad(DataFactory.quad(s, p, oDefault, otherGraph)) + + const view = new SomeDataset(store, DataFactory).unionView + + assert.equal(view.size, 1) + assert.equal(Array.from(view).length, 1) + }) + + await it("size reflects unique triples across all graphs", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.equal(view.size, 3) + }) + + await it("has finds triples regardless of source graph", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.equal(view.has(DataFactory.quad(s, p, oDefault)), true) + assert.equal(view.has(DataFactory.quad(s, p, oNamed)), true) + assert.equal(view.has(DataFactory.quad(s, p, oOther)), true) + assert.equal(view.has(DataFactory.quad(s, p, DataFactory.literal("missing"))), false) + }) + + await it("match returns a union view filtered by subject/predicate/object", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + const matched = Array.from(view.match(s, p)) + assert.equal(matched.length, 3) + for (const quad of matched) { + assert.equal(quad.graph.termType, "DefaultGraph") + } + }) + + await it("match accepts an explicit default graph argument", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + const matched = Array.from(view.match(undefined, undefined, undefined, DataFactory.defaultGraph())) + assert.equal(matched.length, 3) + }) + + await it("add inserts into the configured named graph", () => { + const store = multiGraphStore() + const view = new SomeDataset(store, DataFactory).unionView + const newObject = DataFactory.literal("added") + + view.add(DataFactory.quad(s, p, newObject)) + + assert.equal(store.has(DataFactory.quad(s, p, newObject, graph)), true) + assert.equal(store.has(DataFactory.quad(s, p, newObject)), false) + assert.equal(store.has(DataFactory.quad(s, p, newObject, otherGraph)), false) + }) + + await it("delete only removes from the configured named graph", () => { + const store = multiGraphStore() + const view = new SomeDataset(store, DataFactory).unionView + + // The triple exists only in `graph` so it should be removed. + view.delete(DataFactory.quad(s, p, oNamed)) + assert.equal(store.has(DataFactory.quad(s, p, oNamed, graph)), false) + + // The triple exists in the default graph and is left untouched. + view.delete(DataFactory.quad(s, p, oDefault)) + assert.equal(store.has(DataFactory.quad(s, p, oDefault)), true) + + // The triple exists in another named graph and is left untouched. + view.delete(DataFactory.quad(s, p, oOther)) + assert.equal(store.has(DataFactory.quad(s, p, oOther, otherGraph)), true) + }) + + await it("throws NamedGraphError when adding a quad with a non-default graph", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.throws( + () => view.add(DataFactory.quad(s, p, oDefault, otherGraph)), + NamedGraphError, + ) + }) + + await it("throws NamedGraphError when deleting a quad with a non-default graph", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.throws( + () => view.delete(DataFactory.quad(s, p, oDefault, otherGraph)), + NamedGraphError, + ) + }) + + await it("throws NamedGraphError when checking has with a non-default graph quad", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.throws( + () => view.has(DataFactory.quad(s, p, oDefault, otherGraph)), + NamedGraphError, + ) + }) + + await it("throws TermTypeError when matching with a non-default graph", () => { + const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + + assert.throws( + () => view.match(undefined, undefined, undefined, otherGraph), + TermTypeError, + ) + }) +}) + +await describe("GraphScopedDataset (union) with TermWrapper", async () => { + const subject = DataFactory.namedNode("https://example.org/x") + + function modelStore(): Store { + const store = new Store() + store.addQuad(DataFactory.quad(subject, DataFactory.namedNode(Example.hasString), DataFactory.literal("default value"))) + store.addQuad(DataFactory.quad(subject, DataFactory.namedNode(Example.hasNullableString), DataFactory.literal("from named"), graph)) + return store + } + + class SomeUnionDataset extends GraphScopedDataset { + get parent(): Parent { + return new Parent(subject, this, this.factory) + } + } + + class Root extends DatasetWrapper { + get union(): SomeUnionDataset { + return this.scoped(graph, undefined, SomeUnionDataset) + } + } + + await it("reads properties from any graph through TermWrapper", () => { + const root = new Root(modelStore(), DataFactory) + const parent = root.union.parent + + assert.equal(parent.hasString, "default value") + assert.equal(parent.hasNullableString, "from named") + }) + + await it("writes new properties into the configured named graph", () => { + const store = modelStore() + const root = new Root(store, DataFactory) + const parent = root.union.parent + + parent.hasNullableString = "updated" + + // The new value is readable through the union view. + assert.equal(root.union.parent.hasNullableString, "updated") + // The replacement quad lives in the configured named graph. + assert.equal(store.has(DataFactory.quad( + subject, + DataFactory.namedNode(Example.hasNullableString), + DataFactory.literal("updated"), + graph, + )), true) + // The original default-graph quad for hasString is unaffected. + assert.equal(store.has(DataFactory.quad( + subject, + DataFactory.namedNode(Example.hasString), + DataFactory.literal("default value"), + )), true) + }) +}) From 4ee543c2cbcac59526f8d17314e2aac7605dd837 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:55:39 +0100 Subject: [PATCH 02/19] WIP --- src/DatasetWrapper.ts | 20 +++++++-- src/LazyMaterialize.ts | 90 +++++++++++++++++++++++++++++++++++++ src/NamedGraphDataset.ts | 10 +++++ src/NotifyingDatasetCore.ts | 72 +++++++++++++++++++++++++++++ src/ProjectedDataset.ts | 76 ++++--------------------------- 5 files changed, 197 insertions(+), 71 deletions(-) create mode 100644 src/LazyMaterialize.ts create mode 100644 src/NamedGraphDataset.ts create mode 100644 src/NotifyingDatasetCore.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 3904213..3718db0 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -5,6 +5,8 @@ import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetC import { RDF } from "./vocabulary/RDF.js" import { ensureDefaultGraph, ensureTermType } from "./ensure.js" +import { off } from "node:cluster" +import { ensureNotifyingDatasetCore, NotifyingDatasetCore } from "./NotifyingDatasetCore.js" const defaultGraph: Term = Object.freeze({ termType: "DefaultGraph", @@ -12,19 +14,21 @@ const defaultGraph: Term = Object.freeze({ equals: (other: Term | null | undefined) => other?.termType === "DefaultGraph" && other.value === "" }); -export interface DefaultDatasetCore extends DatasetCore { - match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore +export interface DefaultDatasetCore extends DatasetCore, NotifyingDatasetCore { + match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore; } export class DatasetWrapper implements DefaultDatasetCore { //#region DatasetCore + private readonly dataset: NotifyingDatasetCore + public constructor( - private readonly dataset: DatasetCore, + dataset: DatasetCore, protected readonly factory: DataFactory, protected readonly datasetFactory: DatasetFactory, - ) { + this.dataset = ensureNotifyingDatasetCore(dataset) } public get size(): number { @@ -58,6 +62,14 @@ export class DatasetWrapper implements DefaultDatasetCore { return this.dataset.match(subject, predicate, object, defaultGraph) } + public on(...args: Parameters): void { + this.dataset.on(...args) + } + + public off(...args: Parameters): void { + this.dataset.off(...args) + } + //#endregion //#region Utilities diff --git a/src/LazyMaterialize.ts b/src/LazyMaterialize.ts new file mode 100644 index 0000000..c626bb3 --- /dev/null +++ b/src/LazyMaterialize.ts @@ -0,0 +1,90 @@ +import type { Quad, DatasetCoreFactory, Term, DatasetCore, BaseQuad } from "@rdfjs/types"; +import type { DefaultDatasetCore } from "./DatasetWrapper.js"; +import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; + +export interface IterableDatasetCoreFactory = DatasetCore> + extends DatasetCoreFactory { + + dataset(quads?: Iterable): D; +} + +export interface NotifyingDatasetCoreFactory = NotifyingDatasetCore> + extends IterableDatasetCoreFactory { + + dataset(quads?: Iterable): D; +} + + +export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore { + private materialized?: NotifyingDatasetCore | undefined; + private onAdd?: (quad: IQuad) => void; + private onDelete?: (quad: IQuad) => void; + + public constructor(private readonly source: Iterable, private readonly datasetFactory: IterableDatasetCoreFactory>) { + + } + + private get dataset(): NotifyingDatasetCore { + if (this.materialized === undefined) { + this.materialized = this.datasetFactory.dataset(); + for (const q of this.source) { + this.materialized.add(q); + } + this.onAdd = q => this.materialized!.add(q); + this.onDelete = q => this.materialized!.delete(q); + this.on('add', this.onAdd); + this.on('delete', this.onDelete); + } + return this.materialized; + } + + [Symbol.iterator](): Iterator { + if (this.materialized) { + return this.materialized[Symbol.iterator](); + } + return this.source[Symbol.iterator](); + } + + get size(): number { + if (this.materialized) { + return this.materialized.size; + } + let count = 0; + for (const _ of this.source) count++; + return count; + } + + add(quad: IQuad): this { + this.dataset.add(quad); + return this; + } + + delete(quad: IQuad): this { + this.dataset.delete(quad); + return this; + } + + has(quad: IQuad): boolean { + if (this.materialized) { + return this.materialized.has(quad); + } + for (const q of this.source) { + if (q.equals(quad)) { + return true; + } + } + return false; + } + + match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { + return this.dataset.match(subject, predicate, object); + } + + on(...args: Parameters["on"]>): void { + this.dataset.on(...args); + } + + off(...args: Parameters["off"]>): void { + this.dataset.off(...args); + } +} diff --git a/src/NamedGraphDataset.ts b/src/NamedGraphDataset.ts new file mode 100644 index 0000000..fe10b82 --- /dev/null +++ b/src/NamedGraphDataset.ts @@ -0,0 +1,10 @@ +import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" +import { DatasetWrapper } from "./DatasetWrapper.js" + + +export class NamedGraphDataset extends DatasetWrapper { + constructor(protected readonly graph: Quad_Graph, dataset: DatasetCore, factory: DataFactory) { + super(dataset, factory) + } + +} diff --git a/src/NotifyingDatasetCore.ts b/src/NotifyingDatasetCore.ts new file mode 100644 index 0000000..04913f4 --- /dev/null +++ b/src/NotifyingDatasetCore.ts @@ -0,0 +1,72 @@ +import type { BaseQuad, DatasetCore, Quad, Term } from "@rdfjs/types"; + +export interface NotifyingDatasetCore extends DatasetCore { + on(event: 'add' | 'delete', listener: (quad: InQuad) => void): void; + off(event: 'add' | 'delete', listener: (quad: InQuad) => void): void; + match(subject?: Term | null, predicate?: Term | null, object?: Term | null, graph?: Term | null): NotifyingDatasetCore; +} + +export class NotifyingDatasetCoreWrapper implements NotifyingDatasetCore { + private callbacks: Map<'add' | 'delete', Array<(quad: InQuad) => void>> = new Map([ + ['add', []], + ['delete', []], + ]); + + constructor(private readonly dataset: DatasetCore) { + } + + on(event: 'add' | 'delete', listener: (quad: InQuad) => void): void { + if (event === 'add' || event === 'delete') { + this.callbacks.get(event)!.push(listener); + } else { + throw new Error(`Unsupported event type: ${event}`); + } + } + + off(event: 'add' | 'delete', listener: (quad: InQuad) => void): void { + if (event === 'add' || event === 'delete') { + const listeners = this.callbacks.get(event); + if (listeners) { + this.callbacks.set(event, listeners.filter(cb => cb !== listener)); + } + } else { + throw new Error(`Unsupported event type: ${event}`); + } + } + + get size(): number { + return this.dataset.size; + } + + public *[Symbol.iterator](): Iterator { + yield* this.dataset; + } + + add(quad: InQuad): this { + this.dataset.add(quad); + this.callbacks.get('add')!.forEach(cb => cb(quad)); + return this; + } + + delete(quad: InQuad): this { + this.dataset.delete(quad); + this.callbacks.get('delete')!.forEach(cb => cb(quad)); + return this; + } + + has(quad: InQuad): boolean { + return this.dataset.has(quad); + } + + match(...args: Parameters["match"]>): NotifyingDatasetCore { + return ensureNotifyingDatasetCore(this.dataset.match(...args)); + } +} + +export function ensureNotifyingDatasetCore(dataset: DatasetCore): NotifyingDatasetCore { + if ("on" in dataset && typeof dataset.on === "function" && "off" in dataset && typeof dataset.off === "function") { + return dataset as NotifyingDatasetCore; + } else { + return new NotifyingDatasetCoreWrapper(dataset); + } +} diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts index e5971a9..f4155e8 100644 --- a/src/ProjectedDataset.ts +++ b/src/ProjectedDataset.ts @@ -1,6 +1,11 @@ import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" -import type { DefaultDatasetCore } from "./DatasetWrapper.js" import { ensureDefaultGraph, ensureTermType } from "./ensure.js" +import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; +import { LazyMaterialize } from "./LazyMaterialize.js"; + +export interface ProjectedDatasetCore extends NotifyingDatasetCore { + match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore; +} /** * A {@link DefaultDatasetCore} view over an underlying {@link DatasetCore} that @@ -16,11 +21,11 @@ import { ensureDefaultGraph, ensureTermType } from "./ensure.js" * - {@link ProjectedDataset.match} only accepts the default graph (or no * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. */ -export class ProjectedDataset implements DefaultDatasetCore { +export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { public constructor( private readonly writeGraph: Quad_Graph, private readonly readGraphs: ReadonlyArray | undefined, - private readonly source: DatasetCore, + private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, private readonly datasetFactory: DatasetFactory, ) { @@ -45,18 +50,12 @@ export class ProjectedDataset implements DefaultDatasetCore { public add(quad: Quad): this { ensureDefaultGraph(quad) - if (this._dataset) { - this._dataset.add(quad) - } this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) return this } public delete(quad: Quad): this { ensureDefaultGraph(quad) - if (this._dataset) { - this._dataset.delete(quad) - } this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) return this } @@ -66,7 +65,7 @@ export class ProjectedDataset implements DefaultDatasetCore { return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) } - public match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { + public match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore { return new LazyMaterialize(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) } @@ -86,61 +85,4 @@ export class ProjectedDataset implements DefaultDatasetCore { } } -export class LazyMaterialize implements DatasetCore { - private materialized: DatasetCore | null = null - - public constructor(private readonly source: Iterable, private readonly datasetFactory: DatasetFactory) { - } - - private get dataset(): DatasetCore { - if (this.materialized === null) { - this.materialized = this.datasetFactory.dataset() - for (const q of this.source) { - this.materialized.add(q) - } - } - return this.materialized - } - - [Symbol.iterator](): Iterator { - if (this.materialized) { - return this.materialized[Symbol.iterator]() - } - return this.source[Symbol.iterator]() - } - - get size(): number { - if (this.materialized) { - return this.materialized.size - } - let count = 0 - for (const _ of this.source) count++ - return count - } - - add(quad: Quad): this { - this.dataset.add(quad) - return this - } - - delete(quad: Quad): this { - this.dataset.delete(quad) - return this - } - has(quad: Quad): boolean { - if (this.materialized) { - return this.materialized.has(quad) - } - for (const q of this.source) { - if (q.equals(quad)) { - return true - } - } - return false - } - - match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { - return this.dataset.match(subject, predicate, object) - } -} From fca8e4020e3772549605b7b8028a1573ab4015ea Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:59:24 +0100 Subject: [PATCH 03/19] WIP --- src/LazyMaterialize.ts | 130 ++++++++++++---- ...zyMaterializedNotifyingDatasetCore copy.ts | 88 +++++++++++ src/LazyMaterializedNotifyingDatasetCore.ts | 143 ++++++++++++++++++ src/NotifyingDatasetCore.ts | 18 ++- src/ProjectedDataset copy.ts | 77 ++++++++++ src/ProjectedDataset.ts | 98 +++++++++++- 6 files changed, 515 insertions(+), 39 deletions(-) create mode 100644 src/LazyMaterializedNotifyingDatasetCore copy.ts create mode 100644 src/LazyMaterializedNotifyingDatasetCore.ts create mode 100644 src/ProjectedDataset copy.ts diff --git a/src/LazyMaterialize.ts b/src/LazyMaterialize.ts index c626bb3..e0c02df 100644 --- a/src/LazyMaterialize.ts +++ b/src/LazyMaterialize.ts @@ -1,57 +1,125 @@ import type { Quad, DatasetCoreFactory, Term, DatasetCore, BaseQuad } from "@rdfjs/types"; import type { DefaultDatasetCore } from "./DatasetWrapper.js"; -import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; - -export interface IterableDatasetCoreFactory = DatasetCore> - extends DatasetCoreFactory { - - dataset(quads?: Iterable): D; -} - -export interface NotifyingDatasetCoreFactory = NotifyingDatasetCore> - extends IterableDatasetCoreFactory { - - dataset(quads?: Iterable): D; -} - +import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; + +/** + * Best-effort cleanup registry. When a wrapper instance is garbage + * collected without `[Symbol.dispose]()` having been invoked, the held + * cleanup function is run to detach listeners from the materialized + * dataset. This protects against listener leaks when the wrapper + * subscribes to a dataset that outlives it. + * + * IMPORTANT: the held value and unregister token must not strongly + * reference the wrapper instance, or the registry will keep it alive + * and the finalizer will never run. + */ +const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup => { + try { + cleanup(); + } catch { + // Finalizers must not throw; swallow any error from a torn-down dataset. + } +}); -export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore { +export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { private materialized?: NotifyingDatasetCore | undefined; - private onAdd?: (quad: IQuad) => void; - private onDelete?: (quad: IQuad) => void; + private onAdd?: ((quad: IQuad) => void) | undefined; + private onDelete?: ((quad: IQuad) => void) | undefined; + /** + * Token used to unregister this instance from the finalization + * registry when listeners are detached deterministically via + * `[Symbol.dispose]()`. An object literal is used so the token is + * unique per instance without referencing `this`. + */ + private readonly finalizerToken: object = {}; public constructor(private readonly source: Iterable, private readonly datasetFactory: IterableDatasetCoreFactory>) { } + private init(ds: NotifyingDatasetCore): void { + const onAdd = (q: IQuad): void => { ds.add(q); }; + const onDelete = (q: IQuad): void => { ds.delete(q); }; + this.onAdd = onAdd; + this.onDelete = onDelete; + ds.on('add', onAdd); + ds.on('delete', onDelete); + + // Register a best-effort finalizer. The cleanup closure only + // references `ds` and the local handlers - never `this` - + // so the wrapper remains eligible for garbage collection. + lazyMaterializedFinalizers.register( + this, + () => { + ds.off('add', onAdd); + ds.off('delete', onDelete); + }, + this.finalizerToken, + ); + } + private get dataset(): NotifyingDatasetCore { if (this.materialized === undefined) { - this.materialized = this.datasetFactory.dataset(); + // Capture `ds` locally so the listener closures do not close over `this`. + // This avoids creating a strong self-reference cycle through the listener list. + const ds = this.datasetFactory.dataset(); for (const q of this.source) { - this.materialized.add(q); + ds.add(q); } - this.onAdd = q => this.materialized!.add(q); - this.onDelete = q => this.materialized!.delete(q); - this.on('add', this.onAdd); - this.on('delete', this.onDelete); + this.materialized = ds; + this.init(ds); } return this.materialized; } - [Symbol.iterator](): Iterator { + /** + * Detaches listeners and releases the materialized dataset so this + * instance (and anything it referenced) becomes eligible for garbage + * collection. After disposal the wrapper must not be used again. + * + * Prefer using the `using` declaration to invoke this automatically: + * + * using ds = new LazyMaterializedNotifyingDatasetCore(src, factory); + * + * If `[Symbol.dispose]()` is never called, a `FinalizationRegistry` + * will detach the listeners on a best-effort basis once the wrapper + * is garbage collected. Finalizer execution is not guaranteed by the + * specification, so deterministic disposal should still be preferred. + */ + [Symbol.dispose](): void { if (this.materialized) { - return this.materialized[Symbol.iterator](); + if (this.onAdd) { + this.materialized.off('add', this.onAdd); + } + if (this.onDelete) { + this.materialized.off('delete', this.onDelete); + } + lazyMaterializedFinalizers.unregister(this.finalizerToken); } - return this.source[Symbol.iterator](); + this.onAdd = undefined; + this.onDelete = undefined; + this.materialized = undefined; } - get size(): number { + public *[Symbol.iterator](): Iterator { + // If already materialized, delegate to the dataset's iterator. if (this.materialized) { - return this.materialized.size; + return this.materialized[Symbol.iterator](); } - let count = 0; - for (const _ of this.source) count++; - return count; + + const materialized = this.datasetFactory.dataset(); + for (const q of this.source) { + if (!materialized.has(q)) { + yield q; + materialized.add(q); + } + } + + this.init(materialized); + } + + get size(): number { + return this.dataset.size; } add(quad: IQuad): this { diff --git a/src/LazyMaterializedNotifyingDatasetCore copy.ts b/src/LazyMaterializedNotifyingDatasetCore copy.ts new file mode 100644 index 0000000..0bb5e68 --- /dev/null +++ b/src/LazyMaterializedNotifyingDatasetCore copy.ts @@ -0,0 +1,88 @@ +import type { BaseQuad, DatasetCore, Quad, Term } from "@rdfjs/types"; +import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; + +/** + * Best-effort cleanup registry. When a wrapper instance is garbage + * collected without `[Symbol.dispose]()` having been invoked, the held + * cleanup function is run to detach listeners from the materialized + * dataset. This protects against listener leaks when the wrapper + * subscribes to a dataset that outlives it. + * + * IMPORTANT: the held value and unregister token must not strongly + * reference the wrapper instance, or the registry will keep it alive + * and the finalizer will never run. + */ +const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup => { + try { + cleanup(); + } catch { + // Finalizers must not throw; swallow any error from a torn-down dataset. + } +}); + +export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { + private materialized?: NotifyingDatasetCore | undefined; + private onAdd?: ((quad: IQuad) => void) | undefined; + private onDelete?: ((quad: IQuad) => void) | undefined; + /** + * Token used to unregister this instance from the finalization + * registry when listeners are detached deterministically via + * `[Symbol.dispose]()`. An object literal is used so the token is + * unique per instance without referencing `this`. + */ + private readonly finalizerToken: object = {}; + + public constructor(private readonly source: DatasetCore, private readonly datasetFactory: IterableDatasetCoreFactory>) { + + } + + [Symbol.iterator](): Iterator { + if (this.materialized) { + return this.materialized[Symbol.iterator](); + } + return this.source[Symbol.iterator](); + } + + get size(): number { + if (this.materialized) { + return this.materialized.size; + } + let count = 0; + for (const _ of this.source) count++; + return count; + } + + add(quad: IQuad): this { + this.dataset.add(quad); + return this; + } + + delete(quad: IQuad): this { + this.dataset.delete(quad); + return this; + } + + has(quad: IQuad): boolean { + if (this.materialized) { + return this.materialized.has(quad); + } + for (const q of this.source) { + if (q.equals(quad)) { + return true; + } + } + return false; + } + + match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { + return this.dataset.match(subject, predicate, object); + } + + on(...args: Parameters["on"]>): void { + this.dataset.on(...args); + } + + off(...args: Parameters["off"]>): void { + this.dataset.off(...args); + } +} diff --git a/src/LazyMaterializedNotifyingDatasetCore.ts b/src/LazyMaterializedNotifyingDatasetCore.ts new file mode 100644 index 0000000..42a5264 --- /dev/null +++ b/src/LazyMaterializedNotifyingDatasetCore.ts @@ -0,0 +1,143 @@ +import type { BaseQuad, Quad, Term } from "@rdfjs/types"; +import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; + +/** + * Best-effort cleanup registry. When a wrapper instance is garbage + * collected without `[Symbol.dispose]()` having been invoked, the held + * cleanup function is run to detach listeners from the materialized + * dataset. This protects against listener leaks when the wrapper + * subscribes to a dataset that outlives it. + * + * IMPORTANT: the held value and unregister token must not strongly + * reference the wrapper instance, or the registry will keep it alive + * and the finalizer will never run. + */ +const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup => { + try { + cleanup(); + } catch { + // Finalizers must not throw; swallow any error from a torn-down dataset. + } +}); + +export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { + private materialized?: NotifyingDatasetCore | undefined; + private onAdd?: ((quad: IQuad) => void) | undefined; + private onDelete?: ((quad: IQuad) => void) | undefined; + /** + * Token used to unregister this instance from the finalization + * registry when listeners are detached deterministically via + * `[Symbol.dispose]()`. An object literal is used so the token is + * unique per instance without referencing `this`. + */ + private readonly finalizerToken: object = {}; + + public constructor(private readonly source: Iterable, private readonly datasetFactory: IterableDatasetCoreFactory>) { + + } + + private get dataset(): NotifyingDatasetCore { + if (this.materialized === undefined) { + // Capture `ds` locally so the listener closures do not close over `this`. + // This avoids creating a strong self-reference cycle through the listener list. + const ds = this.datasetFactory.dataset(); + for (const q of this.source) { + ds.add(q); + } + this.materialized = ds; + const onAdd = (q: IQuad): void => { ds.add(q); }; + const onDelete = (q: IQuad): void => { ds.delete(q); }; + this.onAdd = onAdd; + this.onDelete = onDelete; + ds.on('add', onAdd); + ds.on('delete', onDelete); + + // Register a best-effort finalizer. The cleanup closure only + // references `ds` and the local handlers - never `this` - + // so the wrapper remains eligible for garbage collection. + lazyMaterializedFinalizers.register( + this, + () => { + ds.off('add', onAdd); + ds.off('delete', onDelete); + }, + this.finalizerToken, + ); + } + return this.materialized; + } + + /** + * Detaches listeners and releases the materialized dataset so this + * instance (and anything it referenced) becomes eligible for garbage + * collection. After disposal the wrapper must not be used again. + * + * Prefer using the `using` declaration to invoke this automatically: + * + * using ds = new LazyMaterializedNotifyingDatasetCore(src, factory); + * + * If `[Symbol.dispose]()` is never called, a `FinalizationRegistry` + * will detach the listeners on a best-effort basis once the wrapper + * is garbage collected. Finalizer execution is not guaranteed by the + * specification, so deterministic disposal should still be preferred. + */ + [Symbol.dispose](): void { + if (this.materialized) { + if (this.onAdd) { + this.materialized.off('add', this.onAdd); + } + if (this.onDelete) { + this.materialized.off('delete', this.onDelete); + } + lazyMaterializedFinalizers.unregister(this.finalizerToken); + } + this.onAdd = undefined; + this.onDelete = undefined; + this.materialized = undefined; + } + + [Symbol.iterator](): Iterator { + if (this.materialized) { + return this.materialized[Symbol.iterator](); + } + return this.source[Symbol.iterator](); + } + + get size(): number { + return this.dataset.size; + } + + add(quad: IQuad): this { + this.dataset.add(quad); + return this; + } + + delete(quad: IQuad): this { + this.dataset.delete(quad); + return this; + } + + has(quad: IQuad): boolean { + if (this.materialized) { + return this.materialized.has(quad); + } + for (const q of this.source) { + if (q.equals(quad)) { + return true; + } + } + return false; + } + + match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { + return this.dataset.match(subject, predicate, object); + } + + on(...args: Parameters["on"]>): void { + this.dataset.on(...args); + } + + off(...args: Parameters["off"]>): void { + this.dataset.off(...args); + } +} diff --git a/src/NotifyingDatasetCore.ts b/src/NotifyingDatasetCore.ts index 04913f4..b4b9c6d 100644 --- a/src/NotifyingDatasetCore.ts +++ b/src/NotifyingDatasetCore.ts @@ -1,4 +1,4 @@ -import type { BaseQuad, DatasetCore, Quad, Term } from "@rdfjs/types"; +import type { BaseQuad, DatasetCore, DatasetCoreFactory, Quad, Term } from "@rdfjs/types"; export interface NotifyingDatasetCore extends DatasetCore { on(event: 'add' | 'delete', listener: (quad: InQuad) => void): void; @@ -6,6 +6,18 @@ export interface NotifyingDatasetCore; } +export interface IterableDatasetCoreFactory = DatasetCore> + extends DatasetCoreFactory { + + dataset(quads?: Iterable): D; +} + +export interface NotifyingDatasetCoreFactory = NotifyingDatasetCore> + extends IterableDatasetCoreFactory { + + dataset(quads?: Iterable): D; +} + export class NotifyingDatasetCoreWrapper implements NotifyingDatasetCore { private callbacks: Map<'add' | 'delete', Array<(quad: InQuad) => void>> = new Map([ ['add', []], @@ -38,8 +50,8 @@ export class NotifyingDatasetCoreWrapper { - yield* this.dataset; + public [Symbol.iterator](): Iterator { + return this.dataset[Symbol.iterator](); } add(quad: InQuad): this { diff --git a/src/ProjectedDataset copy.ts b/src/ProjectedDataset copy.ts new file mode 100644 index 0000000..ae95816 --- /dev/null +++ b/src/ProjectedDataset copy.ts @@ -0,0 +1,77 @@ +import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" +import { ensureDefaultGraph } from "./ensure.js" +import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; +import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js"; + +export interface ProjectedDatasetCore extends DatasetCore { + match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore; +} + +/** + * A {@link DefaultDatasetCore} view over an underlying {@link DatasetCore} that + * projects quads from one or more named graphs onto the default graph. + * + * - Reads come from the configured set of read graphs. If `readGraphs` is + * `undefined`, quads from every graph (default and named) are read and the + * same triple appearing in multiple graphs is yielded only once. + * - Writes ({@link ProjectedDataset.add}, {@link ProjectedDataset.delete}) are + * mapped onto the configured `writeGraph` in the underlying dataset. Any + * attempt to add/delete/has a quad whose graph is not the default graph + * throws a {@link NamedGraphError}. + * - {@link ProjectedDataset.match} only accepts the default graph (or no + * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. + */ +export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + public constructor( + private readonly writeGraph: Quad_Graph, + private readonly readGraphs: ReadonlyArray | undefined, + private readonly source: DatasetCore, + private readonly factory: DataFactory, + private readonly datasetFactory: DatasetCore, + ) { + } + + public get size(): number { + return this.dataset.size + } + + public [Symbol.iterator](): Iterator { + return this.dataset[Symbol.iterator]() + } + + public add(quad: Quad): this { + ensureDefaultGraph(quad) + this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + return this + } + + public delete(quad: Quad): this { + ensureDefaultGraph(quad) + this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + return this + } + + public has(quad: Quad): boolean { + ensureDefaultGraph(quad) + return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) + } + + public match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore { + return new LazyMaterializedNotifyingDatasetCore(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) + } + + private *matchInSource(subject?: Term, predicate?: Term, object?: Term): Iterable { + if (this.readGraphs === undefined) { + return this.source.match(subject, predicate, object) + } + for (const g of this.readGraphs) { + yield* this.source.match(subject, predicate, object, g) + } + } + + private *matchInSourceAsDefault(subject?: Term, predicate?: Term, object?: Term): Iterable { + for (const q of this.matchInSource(subject, predicate, object)) { + yield this.factory.quad(q.subject, q.predicate, q.object) + } + } +} diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts index f4155e8..88602ed 100644 --- a/src/ProjectedDataset.ts +++ b/src/ProjectedDataset.ts @@ -1,7 +1,7 @@ import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" import { ensureDefaultGraph, ensureTermType } from "./ensure.js" -import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; -import { LazyMaterialize } from "./LazyMaterialize.js"; +import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js"; +import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js"; export interface ProjectedDatasetCore extends NotifyingDatasetCore { match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore; @@ -22,12 +22,18 @@ export interface ProjectedDatasetCore extends NotifyingDatasetCore { * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. */ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private listeners: Map<'add' | 'delete', Array<(quad: Quad) => void>> = new Map([ + ['add', []], + ['delete', []], + ]); + + public constructor( private readonly writeGraph: Quad_Graph, private readonly readGraphs: ReadonlyArray | undefined, private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, - private readonly datasetFactory: DatasetFactory, + private readonly datasetFactory: NotifyingDatasetCoreFactory, ) { } @@ -66,7 +72,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { } public match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore { - return new LazyMaterialize(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) + return new LazyMaterializedNotifyingDatasetCore(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) } private *matchInSource(subject?: Term, predicate?: Term, object?: Term): Iterable { @@ -83,6 +89,88 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { yield this.factory.quad(q.subject, q.predicate, q.object) } } -} + public on(name: 'add' | 'delete', listener: (quad: Quad) => void): void { + const listeners = this.listeners.get(name)! + + if (listeners.length === 0) { + if (name === 'add') { + this.source.on('add', this.onAdd) + } else { + this.source.on('delete', this.onDelete) + } + } + + if (!listeners.includes(listener)) { + listeners.push(listener) + } + } + + public off(name: 'add' | 'delete', listener: (quad: Quad) => void): void { + const listeners = this.listeners.get(name)! + listeners.splice(listeners.indexOf(listener), 1) + + if (listeners.length === 0) { + if (name === 'add') { + this.source.off('add', this.onAdd) + } else { + this.source.off('delete', this.onDelete) + } + } + } + + private onAdd(quad: Quad): void { + const listeners = this.listeners.get('add') + + // First make sure the addition is taking place on one of the graphs we are projecting from + if (listeners && (this.readGraphs === undefined || this.readGraphs.some(g => g.equals(quad.graph)))) { + const dfQuad = this.factory.quad(quad.subject, quad.predicate, quad.object) + + // Now make sure that the quad didn't already exist in the projected view via a different graph + if (this.readGraphs === undefined) { + for (const { graph } of this.source.match(quad.subject, quad.predicate, quad.object)) { + if (!graph.equals(quad.graph)) { + return + } + } + } else { + for (const graph of this.readGraphs) { + if (!graph.equals(quad.graph) && this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { + return + } + } + } + + listeners.forEach(cb => cb(dfQuad)) + } + } + + private onDelete(quad: Quad): void { + const listeners = this.listeners.get('delete') + + if (listeners && (this.readGraphs === undefined || this.readGraphs.some(g => g.equals(quad.graph)))) { + const dfQuad = this.factory.quad(quad.subject, quad.predicate, quad.object) + + // Now make sure that the quad doesn't still exist in the projected view via a different graph + if (this.readGraphs === undefined) { + for (const { graph } of this.source.match(quad.subject, quad.predicate, quad.object)) { + if (!graph.equals(quad.graph)) { + return + } + } + } else { + for (const graph of this.readGraphs) { + if (!graph.equals(quad.graph) && this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { + return + } + } + } + // Make sure the quad has actually been deleted from the projected view + // it is possible that this may not be the case if the quad exists in multiple read graphs + if (!this.dataset.has(dfQuad)) { + listeners.forEach(cb => cb(dfQuad)) + } + } + } +} From ccccaad72141d68ca7c0dbab35570dcc2a4de450 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:25:35 +0100 Subject: [PATCH 04/19] WIP --- src/NotifyingDatasetCore.ts | 54 ++++++----- src/ProjectedDataset.ts | 180 ++++++++++++++++++------------------ 2 files changed, 119 insertions(+), 115 deletions(-) diff --git a/src/NotifyingDatasetCore.ts b/src/NotifyingDatasetCore.ts index b4b9c6d..6372f64 100644 --- a/src/NotifyingDatasetCore.ts +++ b/src/NotifyingDatasetCore.ts @@ -1,8 +1,12 @@ import type { BaseQuad, DatasetCore, DatasetCoreFactory, Quad, Term } from "@rdfjs/types"; +import { listeners } from "cluster"; + +export type ChangeEvent = 'add' | 'delete' +export type Listener = (event: ChangeEvent, quad: InQuad) => void export interface NotifyingDatasetCore extends DatasetCore { - on(event: 'add' | 'delete', listener: (quad: InQuad) => void): void; - off(event: 'add' | 'delete', listener: (quad: InQuad) => void): void; + on(listener: Listener): void; + off(listener: Listener): void; match(subject?: Term | null, predicate?: Term | null, object?: Term | null, graph?: Term | null): NotifyingDatasetCore; } @@ -18,32 +22,36 @@ export interface NotifyingDatasetCoreFactory): D; } +export class EE { + public readonly listeners: Set<(...args: Args) => void> = new Set(); + + on(listener: (...args: Args) => void): void { + this.listeners.add(listener); + } + + off(listener: (...args: Args) => void): void { + this.listeners.delete(listener); + } + + emit(...args: Args): void { + for (const listener of this.listeners) { + listener(...args); + } + } +} + export class NotifyingDatasetCoreWrapper implements NotifyingDatasetCore { - private callbacks: Map<'add' | 'delete', Array<(quad: InQuad) => void>> = new Map([ - ['add', []], - ['delete', []], - ]); + private ee = new EE<[ChangeEvent, InQuad]>(); constructor(private readonly dataset: DatasetCore) { } - on(event: 'add' | 'delete', listener: (quad: InQuad) => void): void { - if (event === 'add' || event === 'delete') { - this.callbacks.get(event)!.push(listener); - } else { - throw new Error(`Unsupported event type: ${event}`); - } + on(listener: Listener): void { + this.ee.on(listener); } - off(event: 'add' | 'delete', listener: (quad: InQuad) => void): void { - if (event === 'add' || event === 'delete') { - const listeners = this.callbacks.get(event); - if (listeners) { - this.callbacks.set(event, listeners.filter(cb => cb !== listener)); - } - } else { - throw new Error(`Unsupported event type: ${event}`); - } + off(listener: Listener): void { + this.ee.off(listener); } get size(): number { @@ -56,13 +64,13 @@ export class NotifyingDatasetCoreWrapper cb(quad)); + this.ee.emit('add', quad); return this; } delete(quad: InQuad): this { this.dataset.delete(quad); - this.callbacks.get('delete')!.forEach(cb => cb(quad)); + this.ee.emit('delete', quad); return this; } diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts index 88602ed..65b307c 100644 --- a/src/ProjectedDataset.ts +++ b/src/ProjectedDataset.ts @@ -1,32 +1,39 @@ -import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" -import { ensureDefaultGraph, ensureTermType } from "./ensure.js" -import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js"; -import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js"; +import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" +import { ensureDefaultGraph } from "./ensure.js" +import { ChangeEvent, EE, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" +import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js" +/** + * A {@link NotifyingDatasetCore} whose quads are always exposed in the + * default graph. Returned by {@link ProjectedDatasetCoreWrapper.match}. + */ export interface ProjectedDatasetCore extends NotifyingDatasetCore { - match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore; + match(subject?: Term | null, predicate?: Term | null, object?: Term | null): ProjectedDatasetCore; } /** - * A {@link DefaultDatasetCore} view over an underlying {@link DatasetCore} that + * A {@link NotifyingDatasetCore} view over an underlying dataset that * projects quads from one or more named graphs onto the default graph. * - * - Reads come from the configured set of read graphs. If `readGraphs` is + * - Reads come from the configured set of `readGraphs`. If `readGraphs` is * `undefined`, quads from every graph (default and named) are read and the * same triple appearing in multiple graphs is yielded only once. - * - Writes ({@link ProjectedDataset.add}, {@link ProjectedDataset.delete}) are - * mapped onto the configured `writeGraph` in the underlying dataset. Any - * attempt to add/delete/has a quad whose graph is not the default graph - * throws a {@link NamedGraphError}. - * - {@link ProjectedDataset.match} only accepts the default graph (or no - * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. + * - Writes ({@link ProjectedDatasetCoreWrapper.add}, + * {@link ProjectedDatasetCoreWrapper.delete}) are mapped onto the configured + * `writeGraph` in the underlying dataset. Any attempt to add/delete/has a + * quad whose graph is not the default graph throws a `NamedGraphError`. + * - {@link ProjectedDatasetCoreWrapper.match} ignores any graph argument and + * always returns quads in the default graph. + * - `add` and `delete` listeners attached via + * {@link ProjectedDatasetCoreWrapper.on} are invoked with default-graph + * quads, and only when the projected view actually changes (a triple + * appearing in several read graphs is reported as added once and as + * deleted only once the last copy is removed). */ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { - private listeners: Map<'add' | 'delete', Array<(quad: Quad) => void>> = new Map([ - ['add', []], - ['delete', []], - ]); + private readonly ee = new EE<[ChangeEvent, Quad]>() + private _dataset: DatasetCore | null = null public constructor( private readonly writeGraph: Quad_Graph, @@ -37,8 +44,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { ) { } - private _dataset: DatasetCore | null = null; - + /** Lazily-materialized snapshot of the projected view. */ private get dataset(): DatasetCore { if (this._dataset === null) { this._dataset = this.match() @@ -54,123 +60,113 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { return this.dataset[Symbol.iterator]() } + /** + * Adds `quad` to the underlying dataset, rewriting its graph to + * `writeGraph`. Throws if `quad` is not in the default graph. + */ public add(quad: Quad): this { ensureDefaultGraph(quad) this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) return this } + /** + * Removes `quad` from the underlying dataset, rewriting its graph to + * `writeGraph`. Throws if `quad` is not in the default graph. + */ public delete(quad: Quad): this { ensureDefaultGraph(quad) this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) return this } + /** + * Returns whether the projected view contains `quad`. Throws if `quad` + * is not in the default graph. + */ public has(quad: Quad): boolean { ensureDefaultGraph(quad) return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) } - public match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore { - return new LazyMaterializedNotifyingDatasetCore(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) + /** + * Returns a {@link ProjectedDatasetCore} containing the matching quads + * projected onto the default graph. + */ + public match(subject?: Term | null, predicate?: Term | null, object?: Term | null): ProjectedDatasetCore { + return new LazyMaterializedNotifyingDatasetCore( + this.matchInSourceAsDefault(subject, predicate, object), + this.datasetFactory, + ) } - private *matchInSource(subject?: Term, predicate?: Term, object?: Term): Iterable { + /** Yields source quads matching the pattern across every read graph. */ + private *matchInSource(subject?: Term | null, predicate?: Term | null, object?: Term | null): Iterable { if (this.readGraphs === undefined) { - return this.source.match(subject, predicate, object) + yield* this.source.match(subject, predicate, object) + return } for (const g of this.readGraphs) { yield* this.source.match(subject, predicate, object, g) } } - private *matchInSourceAsDefault(subject?: Term, predicate?: Term, object?: Term): Iterable { + /** Like {@link matchInSource}, but rewrites every quad's graph to the default graph. */ + private *matchInSourceAsDefault(subject?: Term | null, predicate?: Term | null, object?: Term | null): Iterable { for (const q of this.matchInSource(subject, predicate, object)) { yield this.factory.quad(q.subject, q.predicate, q.object) } } - public on(name: 'add' | 'delete', listener: (quad: Quad) => void): void { - const listeners = this.listeners.get(name)! + /** Returns true when `graph` is one of the projection's read graphs. */ + private isReadGraph(graph: Quad_Graph): boolean { + return this.readGraphs === undefined || this.readGraphs.some(g => g.equals(graph)) + } - if (listeners.length === 0) { - if (name === 'add') { - this.source.on('add', this.onAdd) - } else { - this.source.on('delete', this.onDelete) + /** + * Returns true when the same triple as `quad` is present in the source in + * any read graph other than `quad.graph`. Used to determine whether an + * `add`/`delete` event in one read graph actually changes the projected + * view (which collapses all read graphs onto the default graph). + */ + private existsInOtherReadGraph(quad: Quad): boolean { + if (this.readGraphs === undefined) { + for (const { graph } of this.source.match(quad.subject, quad.predicate, quad.object)) { + if (!graph.equals(quad.graph)) { + return true + } } + return false } - if (!listeners.includes(listener)) { - listeners.push(listener) + for (const graph of this.readGraphs) { + if (graph.equals(quad.graph)) { + continue + } + if (this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { + return true + } } + return false } - public off(name: 'add' | 'delete', listener: (quad: Quad) => void): void { - const listeners = this.listeners.get(name)! - listeners.splice(listeners.indexOf(listener), 1) - - if (listeners.length === 0) { - if (name === 'add') { - this.source.off('add', this.onAdd) - } else { - this.source.off('delete', this.onDelete) - } + private readonly cb = (event: ChangeEvent, quad: Quad): void => { + if (this.isReadGraph(quad.graph) && !this.existsInOtherReadGraph(quad)) { + this.ee.emit(event, this.factory.quad(quad.subject, quad.predicate, quad.object)) } } - private onAdd(quad: Quad): void { - const listeners = this.listeners.get('add') - - // First make sure the addition is taking place on one of the graphs we are projecting from - if (listeners && (this.readGraphs === undefined || this.readGraphs.some(g => g.equals(quad.graph)))) { - const dfQuad = this.factory.quad(quad.subject, quad.predicate, quad.object) - - // Now make sure that the quad didn't already exist in the projected view via a different graph - if (this.readGraphs === undefined) { - for (const { graph } of this.source.match(quad.subject, quad.predicate, quad.object)) { - if (!graph.equals(quad.graph)) { - return - } - } - } else { - for (const graph of this.readGraphs) { - if (!graph.equals(quad.graph) && this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { - return - } - } - } - - listeners.forEach(cb => cb(dfQuad)) + public on(listener: (event: ChangeEvent, quad: Quad) => void): void { + if (this.ee.listeners.size === 0) { + this.source.on(this.cb) } + this.ee.on(listener) } - private onDelete(quad: Quad): void { - const listeners = this.listeners.get('delete') - - if (listeners && (this.readGraphs === undefined || this.readGraphs.some(g => g.equals(quad.graph)))) { - const dfQuad = this.factory.quad(quad.subject, quad.predicate, quad.object) - - // Now make sure that the quad doesn't still exist in the projected view via a different graph - if (this.readGraphs === undefined) { - for (const { graph } of this.source.match(quad.subject, quad.predicate, quad.object)) { - if (!graph.equals(quad.graph)) { - return - } - } - } else { - for (const graph of this.readGraphs) { - if (!graph.equals(quad.graph) && this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { - return - } - } - } - - // Make sure the quad has actually been deleted from the projected view - // it is possible that this may not be the case if the quad exists in multiple read graphs - if (!this.dataset.has(dfQuad)) { - listeners.forEach(cb => cb(dfQuad)) - } + public off(listener: (event: ChangeEvent, quad: Quad) => void): void { + this.ee.off(listener) + if (this.ee.listeners.size === 0) { + this.source.off(this.cb) } } } From dfd74e3c99007476fdf98f1e0da708448f65160d Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:38:21 +0100 Subject: [PATCH 05/19] WIP --- src/DatasetWrapper.ts | 18 +-- src/EventEmitter.ts | 17 +++ src/GraphScopedDataset.ts | 9 +- src/LazyMaterialize.ts | 126 +++++++++++---- ...zyMaterializedNotifyingDatasetCore copy.ts | 88 ----------- src/LazyMaterializedNotifyingDatasetCore.ts | 143 ------------------ src/NamedGraphDataset.ts | 10 -- src/NotifyingDatasetCore.ts | 23 +-- src/ProjectedDataset copy.ts | 77 ---------- src/ProjectedDataset.ts | 108 ++++++++----- src/ensure.ts | 5 +- src/errors/NamedGraphError.ts | 3 +- src/errors/QuadError.ts | 4 +- 13 files changed, 201 insertions(+), 430 deletions(-) create mode 100644 src/EventEmitter.ts delete mode 100644 src/LazyMaterializedNotifyingDatasetCore copy.ts delete mode 100644 src/LazyMaterializedNotifyingDatasetCore.ts delete mode 100644 src/NamedGraphDataset.ts delete mode 100644 src/ProjectedDataset copy.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 3718db0..6632fa8 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -1,14 +1,14 @@ -import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DatasetFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" import type { ITermWrapperConstructor } from "./type/ITermWrapperConstructor.js" import type { GraphScopedDataset } from "./GraphScopedDataset.js" import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetConstructor.js" import { RDF } from "./vocabulary/RDF.js" -import { ensureDefaultGraph, ensureTermType } from "./ensure.js" -import { off } from "node:cluster" +import { ensureDefaultGraph } from "./ensure.js" import { ensureNotifyingDatasetCore, NotifyingDatasetCore } from "./NotifyingDatasetCore.js" +import { Triple } from "./ProjectedDataset.js" -const defaultGraph: Term = Object.freeze({ +export const defaultGraph: DefaultGraph = Object.freeze({ termType: "DefaultGraph", value: "", equals: (other: Term | null | undefined) => other?.termType === "DefaultGraph" && other.value === "" @@ -37,23 +37,23 @@ export class DatasetWrapper implements DefaultDatasetCore { return this.match().size } - public* [Symbol.iterator](): Iterator { - yield* this.match() + public [Symbol.iterator](): Iterator { + return this.match()[Symbol.iterator]() } - public add(quad: Quad): this { + public add(quad: Triple): this { ensureDefaultGraph(quad) this.dataset.add(quad) return this } - public delete(quad: Quad): this { + public delete(quad: Triple): this { ensureDefaultGraph(quad) this.dataset.delete(quad) return this } - public has(quad: Quad): boolean { + public has(quad: Triple): boolean { ensureDefaultGraph(quad) return this.dataset.has(quad) } diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts new file mode 100644 index 0000000..5a3cee7 --- /dev/null +++ b/src/EventEmitter.ts @@ -0,0 +1,17 @@ +export class EventEmitter { + public readonly listeners: Set<(...args: Args) => void> = new Set(); + + on(listener: (...args: Args) => void): void { + this.listeners.add(listener); + } + + off(listener: (...args: Args) => void): void { + this.listeners.delete(listener); + } + + emit(...args: Args): void { + for (const listener of this.listeners) { + listener(...args); + } + } +} diff --git a/src/GraphScopedDataset.ts b/src/GraphScopedDataset.ts index fdfc46b..548ec37 100644 --- a/src/GraphScopedDataset.ts +++ b/src/GraphScopedDataset.ts @@ -1,6 +1,7 @@ -import type { DataFactory, DatasetCore, DatasetFactory, Quad_Graph } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph } from "@rdfjs/types" import { DatasetWrapper } from "./DatasetWrapper.js" -import { ProjectedDataset } from "./ProjectedDataset.js" +import { ProjectedDatasetCoreWrapper } from "./ProjectedDataset.js" +import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js" /** * A {@link DatasetWrapper} that exposes a configurable set of graphs from an @@ -16,10 +17,10 @@ export class GraphScopedDataset extends DatasetWrapper { public constructor( writeGraph: Quad_Graph, readGraphs: ReadonlyArray | undefined, - dataset: DatasetCore, + dataset: NotifyingDatasetCore, factory: DataFactory, datasetFactory: DatasetFactory, ) { - super(new ProjectedDataset(writeGraph, readGraphs, dataset, factory, datasetFactory), factory) + super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory) } } diff --git a/src/LazyMaterialize.ts b/src/LazyMaterialize.ts index e0c02df..24bb02e 100644 --- a/src/LazyMaterialize.ts +++ b/src/LazyMaterialize.ts @@ -1,6 +1,25 @@ import type { Quad, DatasetCoreFactory, Term, DatasetCore, BaseQuad } from "@rdfjs/types"; import type { DefaultDatasetCore } from "./DatasetWrapper.js"; -import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; +import { ChangeEvent, IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; +import { Triple } from "./ProjectedDataset.js"; + +interface IPattern { + subject?: OutQuad['subject'] | undefined, + predicate?: OutQuad['predicate'] | undefined, + object?: OutQuad['object'] | undefined, + graph?: OutQuad['graph'] | undefined, +}; + +interface Pattern { + pattern: IPattern; +} + +interface IterableSource { + match: (subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object'], graph?: OutQuad['graph']) => Iterable; + add: (quad: OutQuad) => void; + delete: (quad: OutQuad) => void; + has: (quad: OutQuad) => boolean; +} /** * Best-effort cleanup registry. When a wrapper instance is garbage @@ -21,10 +40,11 @@ const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup } }); -export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { +// Lazily materialized dataset, which keeps in sync with source +export class LazyMatchNotifyingDatasetCore implements NotifyingDatasetCore, Pattern, Disposable { private materialized?: NotifyingDatasetCore | undefined; - private onAdd?: ((quad: IQuad) => void) | undefined; - private onDelete?: ((quad: IQuad) => void) | undefined; + private cb?: ((event: ChangeEvent, q: IQuad) => void) | undefined; + /** * Token used to unregister this instance from the finalization * registry when listeners are detached deterministically via @@ -33,27 +53,25 @@ export class LazyMaterializedNotifyingDatasetCore */ private readonly finalizerToken: object = {}; - public constructor(private readonly source: Iterable, private readonly datasetFactory: IterableDatasetCoreFactory>) { + public constructor( + private readonly source: IterableSource, + public readonly pattern: IPattern, + private readonly datasetFactory: IterableDatasetCoreFactory>, + ) { } private init(ds: NotifyingDatasetCore): void { - const onAdd = (q: IQuad): void => { ds.add(q); }; - const onDelete = (q: IQuad): void => { ds.delete(q); }; - this.onAdd = onAdd; - this.onDelete = onDelete; - ds.on('add', onAdd); - ds.on('delete', onDelete); + const cb = (event: ChangeEvent, q: IQuad): void => { ds[event](q); }; + this.cb = cb; + ds.on(cb); // Register a best-effort finalizer. The cleanup closure only // references `ds` and the local handlers - never `this` - // so the wrapper remains eligible for garbage collection. lazyMaterializedFinalizers.register( this, - () => { - ds.off('add', onAdd); - ds.off('delete', onDelete); - }, + () => ds.off(cb), this.finalizerToken, ); } @@ -63,7 +81,7 @@ export class LazyMaterializedNotifyingDatasetCore // Capture `ds` locally so the listener closures do not close over `this`. // This avoids creating a strong self-reference cycle through the listener list. const ds = this.datasetFactory.dataset(); - for (const q of this.source) { + for (const q of this.source.match(this.pattern.subject, this.pattern.predicate, this.pattern.object, this.pattern.graph)) { ds.add(q); } this.materialized = ds; @@ -88,16 +106,12 @@ export class LazyMaterializedNotifyingDatasetCore */ [Symbol.dispose](): void { if (this.materialized) { - if (this.onAdd) { - this.materialized.off('add', this.onAdd); - } - if (this.onDelete) { - this.materialized.off('delete', this.onDelete); + if (this.cb) { + this.materialized.off(this.cb); } lazyMaterializedFinalizers.unregister(this.finalizerToken); } - this.onAdd = undefined; - this.onDelete = undefined; + this.cb = undefined; this.materialized = undefined; } @@ -108,7 +122,7 @@ export class LazyMaterializedNotifyingDatasetCore } const materialized = this.datasetFactory.dataset(); - for (const q of this.source) { + for (const q of this.source.match(this.pattern.subject, this.pattern.predicate, this.pattern.object, this.pattern.graph)) { if (!materialized.has(q)) { yield q; materialized.add(q); @@ -123,12 +137,15 @@ export class LazyMaterializedNotifyingDatasetCore } add(quad: IQuad): this { - this.dataset.add(quad); + // Add to the source dataset, which will trigger the listener to add to the materialized dataset if it exists. + // This ensures all mutations are funneled through the source and observed in the materialized dataset. + this.source.add(quad); return this; } delete(quad: IQuad): this { - this.dataset.delete(quad); + // Delete from the source dataset, which will trigger the listener to delete from the materialized dataset if it exists. + this.source.delete(quad); return this; } @@ -136,16 +153,25 @@ export class LazyMaterializedNotifyingDatasetCore if (this.materialized) { return this.materialized.has(quad); } - for (const q of this.source) { - if (q.equals(quad)) { - return true; + return this.source.has(quad); + } + + match(subject?: IQuad['subject'], predicate?: IQuad['predicate'], object?: IQuad['object'], graph?: IQuad['graph']): NotifyingDatasetCore { + let pattern: IPattern = { subject, predicate, object, graph }; + + for (const key of ['subject', 'predicate', 'object', 'graph'] as const) { + if (pattern[key] !== undefined && this.pattern[key] !== undefined && !pattern[key].equals(this.pattern[key])) { + // Pattern and argument conflict; return an empty dataset. + return EMTY_DATASET; } + pattern[key] ??= this.pattern[key]; } - return false; - } - match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { - return this.dataset.match(subject, predicate, object); + return new LazyMatchNotifyingDatasetCore( + this.source, + pattern, + this.datasetFactory, + ); } on(...args: Parameters["on"]>): void { @@ -156,3 +182,37 @@ export class LazyMaterializedNotifyingDatasetCore this.dataset.off(...args); } } + +export class EmptyDataset implements NotifyingDatasetCore { + size = 0; + + has(): boolean { + return false; + } + + add(): this { + throw new Error("Cannot add to an empty dataset"); + } + + delete(): this { + throw new Error("Cannot delete from an empty dataset"); + } + + match(): EmptyDataset { + return this; + } + + *[Symbol.iterator](): Iterator { + // No quads to iterate over + } + + on(): void { + // No-op, as there will never be any events + } + + off(): void { + // No-op, as there will never be any events + } +} + +const EMTY_DATASET = new EmptyDataset(); diff --git a/src/LazyMaterializedNotifyingDatasetCore copy.ts b/src/LazyMaterializedNotifyingDatasetCore copy.ts deleted file mode 100644 index 0bb5e68..0000000 --- a/src/LazyMaterializedNotifyingDatasetCore copy.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { BaseQuad, DatasetCore, Quad, Term } from "@rdfjs/types"; -import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; - -/** - * Best-effort cleanup registry. When a wrapper instance is garbage - * collected without `[Symbol.dispose]()` having been invoked, the held - * cleanup function is run to detach listeners from the materialized - * dataset. This protects against listener leaks when the wrapper - * subscribes to a dataset that outlives it. - * - * IMPORTANT: the held value and unregister token must not strongly - * reference the wrapper instance, or the registry will keep it alive - * and the finalizer will never run. - */ -const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup => { - try { - cleanup(); - } catch { - // Finalizers must not throw; swallow any error from a torn-down dataset. - } -}); - -export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { - private materialized?: NotifyingDatasetCore | undefined; - private onAdd?: ((quad: IQuad) => void) | undefined; - private onDelete?: ((quad: IQuad) => void) | undefined; - /** - * Token used to unregister this instance from the finalization - * registry when listeners are detached deterministically via - * `[Symbol.dispose]()`. An object literal is used so the token is - * unique per instance without referencing `this`. - */ - private readonly finalizerToken: object = {}; - - public constructor(private readonly source: DatasetCore, private readonly datasetFactory: IterableDatasetCoreFactory>) { - - } - - [Symbol.iterator](): Iterator { - if (this.materialized) { - return this.materialized[Symbol.iterator](); - } - return this.source[Symbol.iterator](); - } - - get size(): number { - if (this.materialized) { - return this.materialized.size; - } - let count = 0; - for (const _ of this.source) count++; - return count; - } - - add(quad: IQuad): this { - this.dataset.add(quad); - return this; - } - - delete(quad: IQuad): this { - this.dataset.delete(quad); - return this; - } - - has(quad: IQuad): boolean { - if (this.materialized) { - return this.materialized.has(quad); - } - for (const q of this.source) { - if (q.equals(quad)) { - return true; - } - } - return false; - } - - match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { - return this.dataset.match(subject, predicate, object); - } - - on(...args: Parameters["on"]>): void { - this.dataset.on(...args); - } - - off(...args: Parameters["off"]>): void { - this.dataset.off(...args); - } -} diff --git a/src/LazyMaterializedNotifyingDatasetCore.ts b/src/LazyMaterializedNotifyingDatasetCore.ts deleted file mode 100644 index 42a5264..0000000 --- a/src/LazyMaterializedNotifyingDatasetCore.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { BaseQuad, Quad, Term } from "@rdfjs/types"; -import { IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; - -/** - * Best-effort cleanup registry. When a wrapper instance is garbage - * collected without `[Symbol.dispose]()` having been invoked, the held - * cleanup function is run to detach listeners from the materialized - * dataset. This protects against listener leaks when the wrapper - * subscribes to a dataset that outlives it. - * - * IMPORTANT: the held value and unregister token must not strongly - * reference the wrapper instance, or the registry will keep it alive - * and the finalizer will never run. - */ -const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup => { - try { - cleanup(); - } catch { - // Finalizers must not throw; swallow any error from a torn-down dataset. - } -}); - -export class LazyMaterializedNotifyingDatasetCore implements NotifyingDatasetCore, Disposable { - private materialized?: NotifyingDatasetCore | undefined; - private onAdd?: ((quad: IQuad) => void) | undefined; - private onDelete?: ((quad: IQuad) => void) | undefined; - /** - * Token used to unregister this instance from the finalization - * registry when listeners are detached deterministically via - * `[Symbol.dispose]()`. An object literal is used so the token is - * unique per instance without referencing `this`. - */ - private readonly finalizerToken: object = {}; - - public constructor(private readonly source: Iterable, private readonly datasetFactory: IterableDatasetCoreFactory>) { - - } - - private get dataset(): NotifyingDatasetCore { - if (this.materialized === undefined) { - // Capture `ds` locally so the listener closures do not close over `this`. - // This avoids creating a strong self-reference cycle through the listener list. - const ds = this.datasetFactory.dataset(); - for (const q of this.source) { - ds.add(q); - } - this.materialized = ds; - const onAdd = (q: IQuad): void => { ds.add(q); }; - const onDelete = (q: IQuad): void => { ds.delete(q); }; - this.onAdd = onAdd; - this.onDelete = onDelete; - ds.on('add', onAdd); - ds.on('delete', onDelete); - - // Register a best-effort finalizer. The cleanup closure only - // references `ds` and the local handlers - never `this` - - // so the wrapper remains eligible for garbage collection. - lazyMaterializedFinalizers.register( - this, - () => { - ds.off('add', onAdd); - ds.off('delete', onDelete); - }, - this.finalizerToken, - ); - } - return this.materialized; - } - - /** - * Detaches listeners and releases the materialized dataset so this - * instance (and anything it referenced) becomes eligible for garbage - * collection. After disposal the wrapper must not be used again. - * - * Prefer using the `using` declaration to invoke this automatically: - * - * using ds = new LazyMaterializedNotifyingDatasetCore(src, factory); - * - * If `[Symbol.dispose]()` is never called, a `FinalizationRegistry` - * will detach the listeners on a best-effort basis once the wrapper - * is garbage collected. Finalizer execution is not guaranteed by the - * specification, so deterministic disposal should still be preferred. - */ - [Symbol.dispose](): void { - if (this.materialized) { - if (this.onAdd) { - this.materialized.off('add', this.onAdd); - } - if (this.onDelete) { - this.materialized.off('delete', this.onDelete); - } - lazyMaterializedFinalizers.unregister(this.finalizerToken); - } - this.onAdd = undefined; - this.onDelete = undefined; - this.materialized = undefined; - } - - [Symbol.iterator](): Iterator { - if (this.materialized) { - return this.materialized[Symbol.iterator](); - } - return this.source[Symbol.iterator](); - } - - get size(): number { - return this.dataset.size; - } - - add(quad: IQuad): this { - this.dataset.add(quad); - return this; - } - - delete(quad: IQuad): this { - this.dataset.delete(quad); - return this; - } - - has(quad: IQuad): boolean { - if (this.materialized) { - return this.materialized.has(quad); - } - for (const q of this.source) { - if (q.equals(quad)) { - return true; - } - } - return false; - } - - match(subject?: Term, predicate?: Term, object?: Term): NotifyingDatasetCore { - return this.dataset.match(subject, predicate, object); - } - - on(...args: Parameters["on"]>): void { - this.dataset.on(...args); - } - - off(...args: Parameters["off"]>): void { - this.dataset.off(...args); - } -} diff --git a/src/NamedGraphDataset.ts b/src/NamedGraphDataset.ts deleted file mode 100644 index fe10b82..0000000 --- a/src/NamedGraphDataset.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" -import { DatasetWrapper } from "./DatasetWrapper.js" - - -export class NamedGraphDataset extends DatasetWrapper { - constructor(protected readonly graph: Quad_Graph, dataset: DatasetCore, factory: DataFactory) { - super(dataset, factory) - } - -} diff --git a/src/NotifyingDatasetCore.ts b/src/NotifyingDatasetCore.ts index 6372f64..e5bc276 100644 --- a/src/NotifyingDatasetCore.ts +++ b/src/NotifyingDatasetCore.ts @@ -1,5 +1,6 @@ import type { BaseQuad, DatasetCore, DatasetCoreFactory, Quad, Term } from "@rdfjs/types"; import { listeners } from "cluster"; +import { EventEmitter } from "./EventEmitter.js"; export type ChangeEvent = 'add' | 'delete' export type Listener = (event: ChangeEvent, quad: InQuad) => void @@ -7,7 +8,7 @@ export type Listener = (event: ChangeEvent, quad export interface NotifyingDatasetCore extends DatasetCore { on(listener: Listener): void; off(listener: Listener): void; - match(subject?: Term | null, predicate?: Term | null, object?: Term | null, graph?: Term | null): NotifyingDatasetCore; + match(...args: Parameters["match"]>): NotifyingDatasetCore; } export interface IterableDatasetCoreFactory = DatasetCore> @@ -22,26 +23,8 @@ export interface NotifyingDatasetCoreFactory): D; } -export class EE { - public readonly listeners: Set<(...args: Args) => void> = new Set(); - - on(listener: (...args: Args) => void): void { - this.listeners.add(listener); - } - - off(listener: (...args: Args) => void): void { - this.listeners.delete(listener); - } - - emit(...args: Args): void { - for (const listener of this.listeners) { - listener(...args); - } - } -} - export class NotifyingDatasetCoreWrapper implements NotifyingDatasetCore { - private ee = new EE<[ChangeEvent, InQuad]>(); + private ee = new EventEmitter<[ChangeEvent, InQuad]>(); constructor(private readonly dataset: DatasetCore) { } diff --git a/src/ProjectedDataset copy.ts b/src/ProjectedDataset copy.ts deleted file mode 100644 index ae95816..0000000 --- a/src/ProjectedDataset copy.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" -import { ensureDefaultGraph } from "./ensure.js" -import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; -import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js"; - -export interface ProjectedDatasetCore extends DatasetCore { - match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore; -} - -/** - * A {@link DefaultDatasetCore} view over an underlying {@link DatasetCore} that - * projects quads from one or more named graphs onto the default graph. - * - * - Reads come from the configured set of read graphs. If `readGraphs` is - * `undefined`, quads from every graph (default and named) are read and the - * same triple appearing in multiple graphs is yielded only once. - * - Writes ({@link ProjectedDataset.add}, {@link ProjectedDataset.delete}) are - * mapped onto the configured `writeGraph` in the underlying dataset. Any - * attempt to add/delete/has a quad whose graph is not the default graph - * throws a {@link NamedGraphError}. - * - {@link ProjectedDataset.match} only accepts the default graph (or no - * graph) as the graph argument; otherwise a {@link TermTypeError} is thrown. - */ -export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { - public constructor( - private readonly writeGraph: Quad_Graph, - private readonly readGraphs: ReadonlyArray | undefined, - private readonly source: DatasetCore, - private readonly factory: DataFactory, - private readonly datasetFactory: DatasetCore, - ) { - } - - public get size(): number { - return this.dataset.size - } - - public [Symbol.iterator](): Iterator { - return this.dataset[Symbol.iterator]() - } - - public add(quad: Quad): this { - ensureDefaultGraph(quad) - this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) - return this - } - - public delete(quad: Quad): this { - ensureDefaultGraph(quad) - this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) - return this - } - - public has(quad: Quad): boolean { - ensureDefaultGraph(quad) - return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) - } - - public match(subject?: Term, predicate?: Term, object?: Term): ProjectedDatasetCore { - return new LazyMaterializedNotifyingDatasetCore(this.matchInSourceAsDefault(subject, predicate, object), this.datasetFactory) - } - - private *matchInSource(subject?: Term, predicate?: Term, object?: Term): Iterable { - if (this.readGraphs === undefined) { - return this.source.match(subject, predicate, object) - } - for (const g of this.readGraphs) { - yield* this.source.match(subject, predicate, object, g) - } - } - - private *matchInSourceAsDefault(subject?: Term, predicate?: Term, object?: Term): Iterable { - for (const q of this.matchInSource(subject, predicate, object)) { - yield this.factory.quad(q.subject, q.predicate, q.object) - } - } -} diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts index 65b307c..6aec2e9 100644 --- a/src/ProjectedDataset.ts +++ b/src/ProjectedDataset.ts @@ -1,14 +1,24 @@ -import type { DataFactory, DatasetCore, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { BaseQuad, DataFactory, DatasetCore, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" import { ensureDefaultGraph } from "./ensure.js" -import { ChangeEvent, EE, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" -import { LazyMaterializedNotifyingDatasetCore } from "./LazyMaterializedNotifyingDatasetCore.js" +import { ChangeEvent, Listener, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" +import { EventEmitter } from "./EventEmitter.js"; +import { LazyMatchNotifyingDatasetCore } from "./LazyMaterialize.js" +import { defaultGraph } from "./DatasetWrapper.js"; + +export interface BaseTriple extends BaseQuad { + graph: DefaultGraph; +} + +export interface Triple extends Quad { + graph: DefaultGraph; +} /** * A {@link NotifyingDatasetCore} whose quads are always exposed in the * default graph. Returned by {@link ProjectedDatasetCoreWrapper.match}. */ -export interface ProjectedDatasetCore extends NotifyingDatasetCore { - match(subject?: Term | null, predicate?: Term | null, object?: Term | null): ProjectedDatasetCore; +export interface ProjectedDatasetCore extends NotifyingDatasetCore { + match(subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object']): ProjectedDatasetCore; } /** @@ -24,28 +34,27 @@ export interface ProjectedDatasetCore extends NotifyingDatasetCore { * quad whose graph is not the default graph throws a `NamedGraphError`. * - {@link ProjectedDatasetCoreWrapper.match} ignores any graph argument and * always returns quads in the default graph. - * - `add` and `delete` listeners attached via - * {@link ProjectedDatasetCoreWrapper.on} are invoked with default-graph - * quads, and only when the projected view actually changes (a triple - * appearing in several read graphs is reported as added once and as - * deleted only once the last copy is removed). + * - Listeners attached via {@link ProjectedDatasetCoreWrapper.on} are invoked + * with default-graph quads, and only when the projected view actually + * changes (a triple appearing in several read graphs is reported as added + * once and as deleted only once the last copy is removed). */ -export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { - private readonly ee = new EE<[ChangeEvent, Quad]>() +export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private readonly ee = new EventEmitter<[ChangeEvent, Triple]>() - private _dataset: DatasetCore | null = null + private _dataset: ProjectedDatasetCore | null = null public constructor( private readonly writeGraph: Quad_Graph, private readonly readGraphs: ReadonlyArray | undefined, - private readonly source: NotifyingDatasetCore, + private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, - private readonly datasetFactory: NotifyingDatasetCoreFactory, + private readonly datasetFactory: NotifyingDatasetCoreFactory, ) { } /** Lazily-materialized snapshot of the projected view. */ - private get dataset(): DatasetCore { + private get dataset(): ProjectedDatasetCore { if (this._dataset === null) { this._dataset = this.match() } @@ -56,7 +65,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { return this.dataset.size } - public [Symbol.iterator](): Iterator { + public [Symbol.iterator](): Iterator { return this.dataset[Symbol.iterator]() } @@ -64,9 +73,9 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { * Adds `quad` to the underlying dataset, rewriting its graph to * `writeGraph`. Throws if `quad` is not in the default graph. */ - public add(quad: Quad): this { + public add(quad: Triple): this { ensureDefaultGraph(quad) - this.source.add(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + this.source.add(this.inGraph(quad, this.writeGraph)) return this } @@ -74,9 +83,9 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { * Removes `quad` from the underlying dataset, rewriting its graph to * `writeGraph`. Throws if `quad` is not in the default graph. */ - public delete(quad: Quad): this { + public delete(quad: Triple): this { ensureDefaultGraph(quad) - this.source.delete(this.factory.quad(quad.subject, quad.predicate, quad.object, this.writeGraph)) + this.source.delete(this.inGraph(quad, this.writeGraph)) return this } @@ -84,24 +93,36 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { * Returns whether the projected view contains `quad`. Throws if `quad` * is not in the default graph. */ - public has(quad: Quad): boolean { + public has(quad: Triple): boolean { ensureDefaultGraph(quad) - return this.dataset.has(this.factory.quad(quad.subject, quad.predicate, quad.object)) + if (this.readGraphs) { + return this.readGraphs.some(g => this.source.has(this.inGraph(quad, g))) + } + for (const _ of this.source.match(quad.subject, quad.predicate, quad.object)) { + return true + } + return false } /** * Returns a {@link ProjectedDatasetCore} containing the matching quads * projected onto the default graph. */ - public match(subject?: Term | null, predicate?: Term | null, object?: Term | null): ProjectedDatasetCore { - return new LazyMaterializedNotifyingDatasetCore( - this.matchInSourceAsDefault(subject, predicate, object), + public match(subject?: Triple['subject'], predicate?: Triple['predicate'], object?: Triple['object']): ProjectedDatasetCore { + return new LazyMatchNotifyingDatasetCore( + { + match: (s?: Triple['subject'], p?: Triple['predicate'], o?: Triple['object']) => this.matchInSourceAsDefault(s, p, o), + has: (quad: Triple) => this.has(quad), + add: (quad: Triple) => this.add(quad), + delete: (quad: Triple) => this.delete(quad), + }, + { subject, predicate, object, graph: defaultGraph }, this.datasetFactory, ) } /** Yields source quads matching the pattern across every read graph. */ - private *matchInSource(subject?: Term | null, predicate?: Term | null, object?: Term | null): Iterable { + private *matchInSource(subject?: Triple['subject'], predicate?: Triple['predicate'], object?: Triple['object']): Iterable { if (this.readGraphs === undefined) { yield* this.source.match(subject, predicate, object) return @@ -112,9 +133,9 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { } /** Like {@link matchInSource}, but rewrites every quad's graph to the default graph. */ - private *matchInSourceAsDefault(subject?: Term | null, predicate?: Term | null, object?: Term | null): Iterable { + private *matchInSourceAsDefault(subject?: Triple['subject'], predicate?: Triple['predicate'], object?: Triple['object']): Iterable { for (const q of this.matchInSource(subject, predicate, object)) { - yield this.factory.quad(q.subject, q.predicate, q.object) + yield this.asDefault(q) } } @@ -139,34 +160,39 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { return false } - for (const graph of this.readGraphs) { - if (graph.equals(quad.graph)) { - continue - } - if (this.source.has(this.factory.quad(quad.subject, quad.predicate, quad.object, graph))) { - return true - } - } - return false + return this.readGraphs.some(graph => + !graph.equals(quad.graph) && this.source.has(this.inGraph(quad, graph)) + ) } + /** Forwards a source change event to subscribers when it affects the projected view. */ private readonly cb = (event: ChangeEvent, quad: Quad): void => { if (this.isReadGraph(quad.graph) && !this.existsInOtherReadGraph(quad)) { - this.ee.emit(event, this.factory.quad(quad.subject, quad.predicate, quad.object)) + this.ee.emit(event, this.asDefault(quad)) } } - public on(listener: (event: ChangeEvent, quad: Quad) => void): void { + public on(listener: Listener): void { if (this.ee.listeners.size === 0) { this.source.on(this.cb) } this.ee.on(listener) } - public off(listener: (event: ChangeEvent, quad: Quad) => void): void { + public off(listener: Listener): void { this.ee.off(listener) if (this.ee.listeners.size === 0) { this.source.off(this.cb) } } + + /** Returns a copy of `quad` placed in the default graph. */ + private asDefault(quad: Quad): Triple { + return this.factory.quad(quad.subject, quad.predicate, quad.object) as Triple + } + + /** Returns a copy of `quad` placed in `graph`. */ + private inGraph(quad: Quad, graph: Quad_Graph): Quad { + return this.factory.quad(quad.subject, quad.predicate, quad.object, graph) + } } diff --git a/src/ensure.ts b/src/ensure.ts index 4bef17e..0fa3caa 100644 --- a/src/ensure.ts +++ b/src/ensure.ts @@ -1,10 +1,11 @@ -import type { DefaultGraph, Literal, Quad, Term } from "@rdfjs/types" +import type { BaseQuad, DefaultGraph, Literal, Quad, Term } from "@rdfjs/types" import { TermTypeError } from "./errors/TermTypeError.js" import { LiteralDatatypeError } from "./errors/LiteralDatatypeError.js" import type { IRdfJsTerm } from "./type/IRdfJsTerm.js" import { RDF } from "./vocabulary/RDF.js" import { ListRootError } from "./errors/ListRootError.js" import { NamedGraphError } from "./errors/NamedGraphError.js" +import { BaseTriple } from "./ProjectedDataset.js" export function ensurePresent(object: any) { if (object !== undefined && object !== null) { @@ -50,7 +51,7 @@ export function ensureListRoot(term: IRdfJsTerm) { throw new ListRootError(term as Term) } -export function ensureDefaultGraph(quad: Quad): asserts quad is Quad & { graph: DefaultGraph } { +export function ensureDefaultGraph(quad: BaseTriple): asserts quad is BaseTriple { if (quad.graph.termType === "DefaultGraph") { return } diff --git a/src/errors/NamedGraphError.ts b/src/errors/NamedGraphError.ts index be55707..ba25c80 100644 --- a/src/errors/NamedGraphError.ts +++ b/src/errors/NamedGraphError.ts @@ -1,3 +1,4 @@ +import { BaseTriple } from "../ProjectedDataset.js" import { QuadError } from "./QuadError.js" import type { Quad } from "@rdfjs/types" @@ -7,7 +8,7 @@ import type { Quad } from "@rdfjs/types" * @see {@link namedGraph} */ export class NamedGraphError extends QuadError { - constructor(quad: Quad, cause?: any) { + constructor(quad: BaseTriple, cause?: any) { super(quad, `Graph must be default (empty) but was ${quad.value}`, cause) } } diff --git a/src/errors/QuadError.ts b/src/errors/QuadError.ts index bcf02b6..48feeaf 100644 --- a/src/errors/QuadError.ts +++ b/src/errors/QuadError.ts @@ -1,8 +1,8 @@ import { WrapperError } from "./WrapperError.js" -import type { Quad } from "@rdfjs/types" +import type { BaseQuad, Quad } from "@rdfjs/types" export class QuadError extends WrapperError { - constructor(public readonly quad: Quad, message?: string, cause?: any) { + constructor(public readonly quad: BaseQuad, message?: string, cause?: any) { super(message, cause) } } From ad44fc027f24a19a896a5753ab2ffa7971384b12 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:13:41 +0100 Subject: [PATCH 06/19] feat: update dataset factory types for improved type safety in DatasetWrapper and related classes --- src/DatasetWrapper.ts | 4 ++-- src/GraphScopedDataset.ts | 10 +++++----- src/ProjectedDataset.ts | 2 +- src/type/IGraphScopedDatasetConstructor.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 6632fa8..79e14c7 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -1,4 +1,4 @@ -import type { DataFactory, DatasetCore, DatasetFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DatasetCoreFactory, DatasetFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" import type { ITermWrapperConstructor } from "./type/ITermWrapperConstructor.js" import type { GraphScopedDataset } from "./GraphScopedDataset.js" import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetConstructor.js" @@ -26,7 +26,7 @@ export class DatasetWrapper implements DefaultDatasetCore { public constructor( dataset: DatasetCore, protected readonly factory: DataFactory, - protected readonly datasetFactory: DatasetFactory, + protected readonly datasetFactory: DatasetCoreFactory, ) { this.dataset = ensureNotifyingDatasetCore(dataset) } diff --git a/src/GraphScopedDataset.ts b/src/GraphScopedDataset.ts index 548ec37..44ddb3c 100644 --- a/src/GraphScopedDataset.ts +++ b/src/GraphScopedDataset.ts @@ -1,7 +1,7 @@ -import type { DataFactory, DatasetCore, DatasetFactory, Quad, Quad_Graph } from "@rdfjs/types" +import type { DataFactory, Quad, Quad_Graph } from "@rdfjs/types" import { DatasetWrapper } from "./DatasetWrapper.js" -import { ProjectedDatasetCoreWrapper } from "./ProjectedDataset.js" -import { NotifyingDatasetCore } from "./NotifyingDatasetCore.js" +import { ProjectedDatasetCoreWrapper, Triple } from "./ProjectedDataset.js" +import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" /** * A {@link DatasetWrapper} that exposes a configurable set of graphs from an @@ -19,8 +19,8 @@ export class GraphScopedDataset extends DatasetWrapper { readGraphs: ReadonlyArray | undefined, dataset: NotifyingDatasetCore, factory: DataFactory, - datasetFactory: DatasetFactory, + datasetFactory: NotifyingDatasetCoreFactory>, ) { - super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory) + super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory, datasetFactory) } } diff --git a/src/ProjectedDataset.ts b/src/ProjectedDataset.ts index 6aec2e9..319f4bd 100644 --- a/src/ProjectedDataset.ts +++ b/src/ProjectedDataset.ts @@ -49,7 +49,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore | undefined, private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, - private readonly datasetFactory: NotifyingDatasetCoreFactory, + private readonly datasetFactory: NotifyingDatasetCoreFactory>, ) { } diff --git a/src/type/IGraphScopedDatasetConstructor.ts b/src/type/IGraphScopedDatasetConstructor.ts index 238b69b..951bfed 100644 --- a/src/type/IGraphScopedDatasetConstructor.ts +++ b/src/type/IGraphScopedDatasetConstructor.ts @@ -1,4 +1,4 @@ -import type { DataFactory, DatasetCore, DatasetFactory, Quad_Graph } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DatasetCoreFactory, Quad_Graph } from "@rdfjs/types" import type { GraphScopedDataset } from "../GraphScopedDataset.js" export type IGraphScopedDatasetConstructor = new ( @@ -6,5 +6,5 @@ export type IGraphScopedDatasetConstructor = new ( readGraphs: ReadonlyArray | undefined, dataset: DatasetCore, factory: DataFactory, - datasetFactory: DatasetFactory, + datasetFactory: DatasetCoreFactory, ) => T From cc75d573ab67185693edca6c89e456533b02b0ed Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 04:01:20 +0100 Subject: [PATCH 07/19] WIP --- src/DatasetWrapper.ts | 19 ++-- src/EventEmitter.ts | 108 ++++++++++++++++++++- src/{ => dataset}/GraphScopedDataset.ts | 7 +- src/{ => dataset}/LazyMaterialize.ts | 39 +++++--- src/{ => dataset}/NotifyingDatasetCore.ts | 5 +- src/{ => dataset}/ProjectedDataset.ts | 71 +++++++------- src/dataset/terms.ts | 7 ++ src/ensure.ts | 4 +- src/errors/NamedGraphError.ts | 4 +- src/mod.ts | 2 +- src/type/IGraphScopedDatasetConstructor.ts | 2 +- src/type/ITriple.ts | 9 ++ 12 files changed, 200 insertions(+), 77 deletions(-) rename src/{ => dataset}/GraphScopedDataset.ts (83%) rename src/{ => dataset}/LazyMaterialize.ts (87%) rename src/{ => dataset}/NotifyingDatasetCore.ts (93%) rename src/{ => dataset}/ProjectedDataset.ts (73%) create mode 100644 src/dataset/terms.ts create mode 100644 src/type/ITriple.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 79e14c7..fe33ee5 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -1,18 +1,13 @@ import type { DataFactory, DatasetCore, DatasetCoreFactory, DatasetFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" import type { ITermWrapperConstructor } from "./type/ITermWrapperConstructor.js" -import type { GraphScopedDataset } from "./GraphScopedDataset.js" +import type { GraphScopedDataset } from "./dataset/GraphScopedDataset.js" import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetConstructor.js" import { RDF } from "./vocabulary/RDF.js" import { ensureDefaultGraph } from "./ensure.js" -import { ensureNotifyingDatasetCore, NotifyingDatasetCore } from "./NotifyingDatasetCore.js" -import { Triple } from "./ProjectedDataset.js" - -export const defaultGraph: DefaultGraph = Object.freeze({ - termType: "DefaultGraph", - value: "", - equals: (other: Term | null | undefined) => other?.termType === "DefaultGraph" && other.value === "" -}); +import { ensureNotifyingDatasetCore, NotifyingDatasetCore } from "./dataset/NotifyingDatasetCore.js" +import { ITriple } from "./type/ITriple.js" +import { defaultGraph } from "./dataset/terms.js" export interface DefaultDatasetCore extends DatasetCore, NotifyingDatasetCore { match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore; @@ -41,19 +36,19 @@ export class DatasetWrapper implements DefaultDatasetCore { return this.match()[Symbol.iterator]() } - public add(quad: Triple): this { + public add(quad: ITriple): this { ensureDefaultGraph(quad) this.dataset.add(quad) return this } - public delete(quad: Triple): this { + public delete(quad: ITriple): this { ensureDefaultGraph(quad) this.dataset.delete(quad) return this } - public has(quad: Triple): boolean { + public has(quad: ITriple): boolean { ensureDefaultGraph(quad) return this.dataset.has(quad) } diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 5a3cee7..a74b526 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -1,5 +1,9 @@ +import { BaseQuad, Quad, Term } from "@rdfjs/types"; +import { IPattern } from "./dataset/LazyMaterialize.js"; +import { ChangeEvent } from "./dataset/NotifyingDatasetCore.js"; + export class EventEmitter { - public readonly listeners: Set<(...args: Args) => void> = new Set(); + private readonly listeners: Set<(...args: Args) => void> = new Set(); on(listener: (...args: Args) => void): void { this.listeners.add(listener); @@ -14,4 +18,106 @@ export class EventEmitter { listener(...args); } } + + get empty(): boolean { + return this.listeners.size === 0; + } +} + +const KEYS = ['graph', 'subject', 'predicate', 'object'] as const; +const LAST = KEYS.length - 1; + +const idxToStr = (idx: number, pattern: IPattern): string => { + return toString(pattern[KEYS[idx]!]); +} + +function handleMap(map: Map, idx: number, pattern: IPattern, listener: (event: ChangeEvent, q: IQuad) => void): void { + const str = idxToStr(idx, pattern); + if (map.has(str)) { + const item: Map | Set<(event: ChangeEvent, q: IQuad) => void> = map.get(str); + + if (idx === LAST) { + map.delete(str); + } else { + handleMap(item as Map, idx + 1, pattern, listener); + + if (item.size === 0) { + map.delete(str); + } + } + } +} + +function *yieldListeners(idx: number, pattern: IPattern, map: Map): Iterable<(event: ChangeEvent, q: IQuad) => void> { + const str = idxToStr(idx, pattern); + const elems = str === '*' ? ['*'] : [str, '*']; + + for (const elem of elems) { + const item: Map | Set<(event: ChangeEvent, q: IQuad) => void> = map.get(elem); + + if (item !== undefined) { + if (idx === LAST) { + yield *item as Set<(event: ChangeEvent, q: IQuad) => void>; + } else { + yield *yieldListeners(idx + 1, pattern, item as Map); + } + } + } +} + +export class PatternEventEmitter { + private listeners: Map = new Map(); + + on(pattern: IPattern, listener: (event: ChangeEvent, q: IQuad) => void): void { + let listenerSet: any = this.listeners; + for (const key of KEYS) { + const newListenerList = listenerSet.get(toString(pattern[key])); + if (newListenerList === undefined) { + const newListenerList = key === 'graph' ? new Set<(event: ChangeEvent, q: IQuad) => void>() : new Map(); + listenerSet.set(toString(pattern[key]), newListenerList); + } + listenerSet = newListenerList; + } + listenerSet.add(listener); + } + + off(pattern: IPattern, listener: (event: ChangeEvent, q: IQuad) => void): void { + handleMap(this.listeners, 0, pattern, listener); + } + + emit(event: ChangeEvent, q: IQuad): void { + for (const listener of yieldListeners(0, q, this.listeners)) { + listener(event, q); + } + } + + get empty(): boolean { + return this.listeners.size === 0; + } +} + +function toString(term: Term | undefined): string { + if (!term) { + return '*'; + } + switch (term.termType) { + case 'NamedNode': + return `<${term.value}>`; + case 'BlankNode': + return `_:${term.value}`; + case 'Literal': + if (term.language) { + return `"${term.value}"@${term.language}`; + } else if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') { + return `"${term.value}"^^<${term.datatype.value}>`; + } else { + return `"${term.value}"`; + } + case 'DefaultGraph': + return ''; + case 'Variable': + return `?${term.value}`; + case 'Quad': + return `${toString(term.subject)} ${toString(term.predicate)} ${toString(term.object)}${term.graph.termType !== 'DefaultGraph' ? ` ${toString(term.graph)}` : ''} .`; + } } diff --git a/src/GraphScopedDataset.ts b/src/dataset/GraphScopedDataset.ts similarity index 83% rename from src/GraphScopedDataset.ts rename to src/dataset/GraphScopedDataset.ts index 44ddb3c..acee4f0 100644 --- a/src/GraphScopedDataset.ts +++ b/src/dataset/GraphScopedDataset.ts @@ -1,6 +1,7 @@ import type { DataFactory, Quad, Quad_Graph } from "@rdfjs/types" -import { DatasetWrapper } from "./DatasetWrapper.js" -import { ProjectedDatasetCoreWrapper, Triple } from "./ProjectedDataset.js" +import { DatasetWrapper } from "../DatasetWrapper.js" +import { ProjectedDatasetCoreWrapper } from "./ProjectedDataset.js" +import { ITriple } from "../type/ITriple.js" import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" /** @@ -19,7 +20,7 @@ export class GraphScopedDataset extends DatasetWrapper { readGraphs: ReadonlyArray | undefined, dataset: NotifyingDatasetCore, factory: DataFactory, - datasetFactory: NotifyingDatasetCoreFactory>, + datasetFactory: NotifyingDatasetCoreFactory>, ) { super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory, datasetFactory) } diff --git a/src/LazyMaterialize.ts b/src/dataset/LazyMaterialize.ts similarity index 87% rename from src/LazyMaterialize.ts rename to src/dataset/LazyMaterialize.ts index 24bb02e..45f4bfb 100644 --- a/src/LazyMaterialize.ts +++ b/src/dataset/LazyMaterialize.ts @@ -1,16 +1,15 @@ -import type { Quad, DatasetCoreFactory, Term, DatasetCore, BaseQuad } from "@rdfjs/types"; -import type { DefaultDatasetCore } from "./DatasetWrapper.js"; -import { ChangeEvent, IterableDatasetCoreFactory, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; -import { Triple } from "./ProjectedDataset.js"; +import type { BaseQuad, DatasetCore, Quad } from "@rdfjs/types"; +import { ChangeEvent, IterableDatasetCoreFactory, Listener, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; +import { EventEmitter, PatternEventEmitter } from "../EventEmitter.js"; -interface IPattern { +export interface IPattern { subject?: OutQuad['subject'] | undefined, predicate?: OutQuad['predicate'] | undefined, object?: OutQuad['object'] | undefined, graph?: OutQuad['graph'] | undefined, }; -interface Pattern { +export interface Pattern { pattern: IPattern; } @@ -19,6 +18,8 @@ interface IterableSource { add: (quad: OutQuad) => void; delete: (quad: OutQuad) => void; has: (quad: OutQuad) => boolean; + on: (listener: Listener) => void; + off: (listener: Listener) => void; } /** @@ -42,7 +43,7 @@ const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup // Lazily materialized dataset, which keeps in sync with source export class LazyMatchNotifyingDatasetCore implements NotifyingDatasetCore, Pattern, Disposable { - private materialized?: NotifyingDatasetCore | undefined; + private materialized?: DatasetCore | undefined; private cb?: ((event: ChangeEvent, q: IQuad) => void) | undefined; /** @@ -61,22 +62,24 @@ export class LazyMatchNotifyingDatasetCore implem } - private init(ds: NotifyingDatasetCore): void { + private init(ds: DatasetCore): void { const cb = (event: ChangeEvent, q: IQuad): void => { ds[event](q); }; this.cb = cb; - ds.on(cb); + const self = this; + + self.on(cb); // Register a best-effort finalizer. The cleanup closure only // references `ds` and the local handlers - never `this` - // so the wrapper remains eligible for garbage collection. lazyMaterializedFinalizers.register( this, - () => ds.off(cb), + () => self.off(cb), this.finalizerToken, ); } - private get dataset(): NotifyingDatasetCore { + private get dataset(): DatasetCore { if (this.materialized === undefined) { // Capture `ds` locally so the listener closures do not close over `this`. // This avoids creating a strong self-reference cycle through the listener list. @@ -107,7 +110,7 @@ export class LazyMatchNotifyingDatasetCore implem [Symbol.dispose](): void { if (this.materialized) { if (this.cb) { - this.materialized.off(this.cb); + this.off(this.cb); } lazyMaterializedFinalizers.unregister(this.finalizerToken); } @@ -174,12 +177,20 @@ export class LazyMatchNotifyingDatasetCore implem ); } + private ee = new PatternEventEmitter(); + on(...args: Parameters["on"]>): void { - this.dataset.on(...args); + if (this.ee.empty) { + this.source.on(this.ee.emit); + } + this.ee.on(this.pattern, ...args); } off(...args: Parameters["off"]>): void { - this.dataset.off(...args); + this.ee.off(this.pattern, ...args); + if (this.ee.empty) { + this.source.off(this.ee.emit); + } } } diff --git a/src/NotifyingDatasetCore.ts b/src/dataset/NotifyingDatasetCore.ts similarity index 93% rename from src/NotifyingDatasetCore.ts rename to src/dataset/NotifyingDatasetCore.ts index e5bc276..32e1245 100644 --- a/src/NotifyingDatasetCore.ts +++ b/src/dataset/NotifyingDatasetCore.ts @@ -1,6 +1,5 @@ -import type { BaseQuad, DatasetCore, DatasetCoreFactory, Quad, Term } from "@rdfjs/types"; -import { listeners } from "cluster"; -import { EventEmitter } from "./EventEmitter.js"; +import type { BaseQuad, DatasetCore, DatasetCoreFactory, Quad } from "@rdfjs/types"; +import { EventEmitter } from "../EventEmitter.js"; export type ChangeEvent = 'add' | 'delete' export type Listener = (event: ChangeEvent, quad: InQuad) => void diff --git a/src/ProjectedDataset.ts b/src/dataset/ProjectedDataset.ts similarity index 73% rename from src/ProjectedDataset.ts rename to src/dataset/ProjectedDataset.ts index 319f4bd..b8dbffc 100644 --- a/src/ProjectedDataset.ts +++ b/src/dataset/ProjectedDataset.ts @@ -1,23 +1,16 @@ -import type { BaseQuad, DataFactory, DatasetCore, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" -import { ensureDefaultGraph } from "./ensure.js" -import { ChangeEvent, Listener, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" -import { EventEmitter } from "./EventEmitter.js"; -import { LazyMatchNotifyingDatasetCore } from "./LazyMaterialize.js" -import { defaultGraph } from "./DatasetWrapper.js"; - -export interface BaseTriple extends BaseQuad { - graph: DefaultGraph; -} - -export interface Triple extends Quad { - graph: DefaultGraph; -} +import type { DataFactory, Quad, Quad_Graph } from "@rdfjs/types"; +import { defaultGraph } from "./terms.js"; +import { ensureDefaultGraph } from "../ensure.js"; +import { EventEmitter } from "../EventEmitter.js"; +import { LazyMatchNotifyingDatasetCore } from "./LazyMaterialize.js"; +import { ChangeEvent, Listener, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js"; +import { ITriple, IBaseTriple } from "../type/ITriple.js"; /** * A {@link NotifyingDatasetCore} whose quads are always exposed in the * default graph. Returned by {@link ProjectedDatasetCoreWrapper.match}. */ -export interface ProjectedDatasetCore extends NotifyingDatasetCore { +export interface ProjectedDatasetCore extends NotifyingDatasetCore { match(subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object']): ProjectedDatasetCore; } @@ -39,22 +32,22 @@ export interface ProjectedDatasetCore { - private readonly ee = new EventEmitter<[ChangeEvent, Triple]>() +export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private readonly ee = new EventEmitter<[ChangeEvent, ITriple]>() - private _dataset: ProjectedDatasetCore | null = null + private _dataset: ProjectedDatasetCore | null = null public constructor( private readonly writeGraph: Quad_Graph, private readonly readGraphs: ReadonlyArray | undefined, private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, - private readonly datasetFactory: NotifyingDatasetCoreFactory>, + private readonly datasetFactory: NotifyingDatasetCoreFactory>, ) { } /** Lazily-materialized snapshot of the projected view. */ - private get dataset(): ProjectedDatasetCore { + private get dataset(): ProjectedDatasetCore { if (this._dataset === null) { this._dataset = this.match() } @@ -65,7 +58,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + public [Symbol.iterator](): Iterator { return this.dataset[Symbol.iterator]() } @@ -73,7 +66,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore this.source.has(this.inGraph(quad, g))) @@ -108,13 +101,15 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { - return new LazyMatchNotifyingDatasetCore( + public match(subject?: ITriple['subject'], predicate?: ITriple['predicate'], object?: ITriple['object']): ProjectedDatasetCore { + return new LazyMatchNotifyingDatasetCore( { - match: (s?: Triple['subject'], p?: Triple['predicate'], o?: Triple['object']) => this.matchInSourceAsDefault(s, p, o), - has: (quad: Triple) => this.has(quad), - add: (quad: Triple) => this.add(quad), - delete: (quad: Triple) => this.delete(quad), + match: (s?: ITriple['subject'], p?: ITriple['predicate'], o?: ITriple['object']) => this.matchInSourceAsDefault(s, p, o), + has: (quad: ITriple) => this.has(quad), + add: (quad: ITriple) => this.add(quad), + delete: (quad: ITriple) => this.delete(quad), + on: (listener: Listener) => this.on(listener), + off: (listener: Listener) => this.off(listener), }, { subject, predicate, object, graph: defaultGraph }, this.datasetFactory, @@ -122,7 +117,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private *matchInSource(subject?: ITriple['subject'], predicate?: ITriple['predicate'], object?: ITriple['object']): Iterable { if (this.readGraphs === undefined) { yield* this.source.match(subject, predicate, object) return @@ -133,7 +128,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private *matchInSourceAsDefault(subject?: ITriple['subject'], predicate?: ITriple['predicate'], object?: ITriple['object']): Iterable { for (const q of this.matchInSource(subject, predicate, object)) { yield this.asDefault(q) } @@ -172,23 +167,23 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore): void { - if (this.ee.listeners.size === 0) { + public on(listener: Listener): void { + if (this.ee.empty) { this.source.on(this.cb) } this.ee.on(listener) } - public off(listener: Listener): void { + public off(listener: Listener): void { this.ee.off(listener) - if (this.ee.listeners.size === 0) { + if (this.ee.empty) { this.source.off(this.cb) } } /** Returns a copy of `quad` placed in the default graph. */ - private asDefault(quad: Quad): Triple { - return this.factory.quad(quad.subject, quad.predicate, quad.object) as Triple + private asDefault(quad: Quad): ITriple { + return this.factory.quad(quad.subject, quad.predicate, quad.object) as ITriple } /** Returns a copy of `quad` placed in `graph`. */ diff --git a/src/dataset/terms.ts b/src/dataset/terms.ts new file mode 100644 index 0000000..6552800 --- /dev/null +++ b/src/dataset/terms.ts @@ -0,0 +1,7 @@ +import { DefaultGraph, Term } from "@rdfjs/types"; + +export const defaultGraph: DefaultGraph = Object.freeze({ + termType: "DefaultGraph", + value: "", + equals: (other: Term | null | undefined) => other?.termType === "DefaultGraph" && other.value === "" +}); diff --git a/src/ensure.ts b/src/ensure.ts index 0fa3caa..5b2e1aa 100644 --- a/src/ensure.ts +++ b/src/ensure.ts @@ -5,7 +5,7 @@ import type { IRdfJsTerm } from "./type/IRdfJsTerm.js" import { RDF } from "./vocabulary/RDF.js" import { ListRootError } from "./errors/ListRootError.js" import { NamedGraphError } from "./errors/NamedGraphError.js" -import { BaseTriple } from "./ProjectedDataset.js" +import { IBaseTriple } from "./type/ITriple.js" export function ensurePresent(object: any) { if (object !== undefined && object !== null) { @@ -51,7 +51,7 @@ export function ensureListRoot(term: IRdfJsTerm) { throw new ListRootError(term as Term) } -export function ensureDefaultGraph(quad: BaseTriple): asserts quad is BaseTriple { +export function ensureDefaultGraph(quad: IBaseTriple): asserts quad is IBaseTriple { if (quad.graph.termType === "DefaultGraph") { return } diff --git a/src/errors/NamedGraphError.ts b/src/errors/NamedGraphError.ts index ba25c80..54fea73 100644 --- a/src/errors/NamedGraphError.ts +++ b/src/errors/NamedGraphError.ts @@ -1,4 +1,4 @@ -import { BaseTriple } from "../ProjectedDataset.js" +import { IBaseTriple } from "../type/ITriple.js" import { QuadError } from "./QuadError.js" import type { Quad } from "@rdfjs/types" @@ -8,7 +8,7 @@ import type { Quad } from "@rdfjs/types" * @see {@link namedGraph} */ export class NamedGraphError extends QuadError { - constructor(quad: BaseTriple, cause?: any) { + constructor(quad: IBaseTriple, cause?: any) { super(quad, `Graph must be default (empty) but was ${quad.value}`, cause) } } diff --git a/src/mod.ts b/src/mod.ts index 1152260..5f8372b 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -20,7 +20,7 @@ export * from "./mapping/RequiredAs.js" export * from "./DatasetWrapper.js" export * from "./TermWrapper.js" -export * from "./ProjectedDataset.js" +export * from "./dataset/ProjectedDataset.js" export * from "./GraphScopedDataset.js" export * from "./errors/WrapperError.js" diff --git a/src/type/IGraphScopedDatasetConstructor.ts b/src/type/IGraphScopedDatasetConstructor.ts index 951bfed..c89c91f 100644 --- a/src/type/IGraphScopedDatasetConstructor.ts +++ b/src/type/IGraphScopedDatasetConstructor.ts @@ -1,5 +1,5 @@ import type { DataFactory, DatasetCore, DatasetCoreFactory, Quad_Graph } from "@rdfjs/types" -import type { GraphScopedDataset } from "../GraphScopedDataset.js" +import type { GraphScopedDataset } from "../dataset/GraphScopedDataset.js" export type IGraphScopedDatasetConstructor = new ( writeGraph: Quad_Graph, diff --git a/src/type/ITriple.ts b/src/type/ITriple.ts new file mode 100644 index 0000000..1ee594f --- /dev/null +++ b/src/type/ITriple.ts @@ -0,0 +1,9 @@ +import type { BaseQuad, DefaultGraph, Quad } from "@rdfjs/types"; + +export interface IBaseTriple extends BaseQuad { + graph: DefaultGraph; +} + +export interface ITriple extends Quad { + graph: DefaultGraph; +} From cc3d85c51fe614b7889d32ef7d1525a57a61f7a5 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 05:07:48 +0100 Subject: [PATCH 08/19] feat: Introduce ITriple type and enhance dataset functionality - Added ITriple type definition to improve type safety in dataset operations. - Updated IGraphScopedDatasetConstructor to utilize NotifyingDatasetCore for better event handling. - Refactored dataset imports in mod.ts for clarity and organization. - Enhanced ParentDataset to streamline subject and object matching methods. - Improved test coverage for dataset core functionalities, including union and named graph behaviors. - Introduced n3StoreFactory for consistent dataset creation in tests. - Added tests for ProjectedDatasetCoreWrapper to ensure correct behavior with read graphs. - Implemented LazyMatchNotifyingDatasetCore to optimize matching operations with event notifications. --- src/DatasetWrapper.ts | 60 ++-- src/EventEmitter.ts | 33 ++- src/WrappingMap.ts | 2 +- src/WrappingSet.ts | 4 +- src/dataset/GraphScopedDataset.ts | 4 +- src/dataset/LazyMaterialize.ts | 104 +++++-- src/dataset/NotifyingDatasetCore.ts | 38 +++ src/dataset/ProjectedDataset.ts | 63 ++-- src/dataset/terms.ts | 7 + src/ensure.ts | 4 +- src/errors/NamedGraphError.ts | 7 +- src/mapping/OptionalAs.ts | 2 +- src/mapping/OptionalFrom.ts | 2 +- src/mapping/RequiredFrom.ts | 2 +- src/mod.ts | 6 +- src/type/IGraphScopedDatasetConstructor.ts | 14 +- src/type/ITriple.ts | 4 +- test/unit/dataset_core_base.test.ts | 12 +- test/unit/dataset_module.test.ts | 322 +++++++++++++++++++++ test/unit/dataset_wrapper.test.ts | 3 +- test/unit/model/ParentDataset.ts | 4 +- test/unit/named_graph.test.ts | 49 ++-- test/unit/named_graph_integration.test.ts | 17 +- test/unit/projected_dataset.test.ts | 188 ++++++++++++ test/unit/union_graph.test.ts | 62 ++-- test/unit/util/n3StoreFactory.ts | 26 ++ 26 files changed, 870 insertions(+), 169 deletions(-) create mode 100644 test/unit/dataset_module.test.ts create mode 100644 test/unit/projected_dataset.test.ts create mode 100644 test/unit/util/n3StoreFactory.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index fe33ee5..83b0022 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -1,60 +1,84 @@ -import type { DataFactory, DatasetCore, DatasetCoreFactory, DatasetFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" +import type { DataFactory, DatasetCore, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types" import type { ITermWrapperConstructor } from "./type/ITermWrapperConstructor.js" import type { GraphScopedDataset } from "./dataset/GraphScopedDataset.js" import type { IGraphScopedDatasetConstructor } from "./type/IGraphScopedDatasetConstructor.js" import { RDF } from "./vocabulary/RDF.js" -import { ensureDefaultGraph } from "./ensure.js" -import { ensureNotifyingDatasetCore, NotifyingDatasetCore } from "./dataset/NotifyingDatasetCore.js" -import { ITriple } from "./type/ITriple.js" +import { ensureDefaultGraph, ensureTermType } from "./ensure.js" +import { ensureNotifyingDatasetCore, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./dataset/NotifyingDatasetCore.js" +import { Triple } from "./type/ITriple.js" import { defaultGraph } from "./dataset/terms.js" - +import { TermTypeError } from "./errors/TermTypeError.js" + +/** + * The view of an underlying RDF/JS dataset that {@link DatasetWrapper} + * exposes: a {@link NotifyingDatasetCore} restricted to default-graph quads. + * + * {@link match} ignores the graph dimension entirely; if a non-default-graph + * argument is passed it throws a {@link TermTypeError}. + */ export interface DefaultDatasetCore extends DatasetCore, NotifyingDatasetCore { - match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore; + match(subject?: Term, predicate?: Term, object?: Term, graph?: DefaultGraph): DefaultDatasetCore; } +/** Factory type used by {@link DatasetWrapper} to materialize match results. */ +export type DefaultDatasetCoreFactory = + NotifyingDatasetCoreFactory> + export class DatasetWrapper implements DefaultDatasetCore { //#region DatasetCore private readonly dataset: NotifyingDatasetCore + /** + * The factory used to materialize lazy match results. Subclasses receive + * the same factory and forward it to scoped views via {@link scoped}. + * + * Consumers must supply a factory; this library does not bundle a + * default implementation so it does not impose a particular RDF/JS + * dataset implementation on its users. + */ + protected readonly datasetFactory: DefaultDatasetCoreFactory + public constructor( dataset: DatasetCore, protected readonly factory: DataFactory, - protected readonly datasetFactory: DatasetCoreFactory, + datasetFactory: DefaultDatasetCoreFactory, ) { this.dataset = ensureNotifyingDatasetCore(dataset) + this.datasetFactory = datasetFactory } public get size(): number { // We cannot delegate to the underlying dataset's size, as it may contain quads in named graphs that are not visible through this wrapper. // Instead, we need to count the quads that match the default graph. - return this.match().size + return this.match(undefined, undefined, undefined, defaultGraph).size } public [Symbol.iterator](): Iterator { - return this.match()[Symbol.iterator]() + return this.match(undefined, undefined, undefined, defaultGraph)[Symbol.iterator]() } - public add(quad: ITriple): this { + public add(quad: Triple): this { ensureDefaultGraph(quad) this.dataset.add(quad) return this } - public delete(quad: ITriple): this { + public delete(quad: Triple): this { ensureDefaultGraph(quad) this.dataset.delete(quad) return this } - public has(quad: ITriple): boolean { + public has(quad: Triple): boolean { ensureDefaultGraph(quad) return this.dataset.has(quad) } - public match(subject?: Term, predicate?: Term, object?: Term): DefaultDatasetCore { - return this.dataset.match(subject, predicate, object, defaultGraph) + public match(subject: Triple['subject'] | undefined, predicate: Triple['predicate'] | undefined, object: Triple['object'] | undefined, graph: DefaultGraph): DefaultDatasetCore { + ensureTermType(graph, "DefaultGraph") + return this.dataset.match(subject, predicate, object, defaultGraph) as DefaultDatasetCore } public on(...args: Parameters): void { @@ -112,14 +136,14 @@ export class DatasetWrapper implements DefaultDatasetCore { return new klass(w, r, this.dataset, this.factory, this.datasetFactory) } - protected* matchSubjectsOf(termWrapper: ITermWrapperConstructor, predicate?: Term, object?: Term): Iterable { - for (const q of this.match(undefined, predicate, object)) { + protected* matchSubjectsOf(termWrapper: ITermWrapperConstructor, predicate?: Triple['predicate'], object?: Triple['object']): Iterable { + for (const q of this.match(undefined, predicate, object, defaultGraph)) { yield new termWrapper(q.subject, this, this.factory) } } - protected* matchObjectsOf(termWrapper: ITermWrapperConstructor, subject?: Term, predicate?: Term): Iterable { - for (const q of this.match(subject, predicate, undefined)) { + protected* matchObjectsOf(termWrapper: ITermWrapperConstructor, subject?: Triple['subject'], predicate?: Triple['predicate']): Iterable { + for (const q of this.match(subject, predicate, undefined, defaultGraph)) { yield new termWrapper(q.object, this, this.factory) } } diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index a74b526..937b88d 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -33,18 +33,20 @@ const idxToStr = (idx: number, pattern: IPattern): string => { function handleMap(map: Map, idx: number, pattern: IPattern, listener: (event: ChangeEvent, q: IQuad) => void): void { const str = idxToStr(idx, pattern); - if (map.has(str)) { - const item: Map | Set<(event: ChangeEvent, q: IQuad) => void> = map.get(str); + if (!map.has(str)) { + return; + } + const item: Map | Set<(event: ChangeEvent, q: IQuad) => void> = map.get(str); - if (idx === LAST) { - map.delete(str); - } else { - handleMap(item as Map, idx + 1, pattern, listener); + if (idx === LAST) { + // Leaf level: remove the specific listener, not every listener for this pattern. + (item as Set<(event: ChangeEvent, q: IQuad) => void>).delete(listener); + } else { + handleMap(item as Map, idx + 1, pattern, listener); + } - if (item.size === 0) { - map.delete(str); - } - } + if (item.size === 0) { + map.delete(str); } } @@ -71,12 +73,13 @@ export class PatternEventEmitter { on(pattern: IPattern, listener: (event: ChangeEvent, q: IQuad) => void): void { let listenerSet: any = this.listeners; for (const key of KEYS) { - const newListenerList = listenerSet.get(toString(pattern[key])); - if (newListenerList === undefined) { - const newListenerList = key === 'graph' ? new Set<(event: ChangeEvent, q: IQuad) => void>() : new Map(); - listenerSet.set(toString(pattern[key]), newListenerList); + const str = toString(pattern[key]); + let next = listenerSet.get(str); + if (next === undefined) { + next = key === 'object' ? new Set<(event: ChangeEvent, q: IQuad) => void>() : new Map(); + listenerSet.set(str, next); } - listenerSet = newListenerList; + listenerSet = next; } listenerSet.add(listener); } diff --git a/src/WrappingMap.ts b/src/WrappingMap.ts index fe8ee41..0c5f333 100644 --- a/src/WrappingMap.ts +++ b/src/WrappingMap.ts @@ -99,7 +99,7 @@ export class WrappingMap implements Map { private get matches(): Iterable { const p = this.subject.factory.namedNode(this.predicate) - return this.subject.dataset.match(this.subject as Term, p) + return this.subject.dataset.match(this.subject as Term, p, undefined, this.subject.factory.defaultGraph()) } private add(k: TKey, v: TValue) { diff --git a/src/WrappingSet.ts b/src/WrappingSet.ts index 6f163ff..151f64b 100644 --- a/src/WrappingSet.ts +++ b/src/WrappingSet.ts @@ -27,7 +27,7 @@ export class WrappingSet implements Set { const o = this.termFrom(value, this.subject.factory) // TODO: guards const p = this.subject.factory.namedNode(this.predicate) - for (const q of this.subject.dataset.match(this.subject as Term, p, o as Term)) { + for (const q of this.subject.dataset.match(this.subject as Term, p, o as Term, this.subject.factory.defaultGraph())) { this.subject.dataset.delete(q) } @@ -82,6 +82,6 @@ export class WrappingSet implements Set { private get matches(): DatasetCore { const p = this.subject.factory.namedNode(this.predicate) - return this.subject.dataset.match(this.subject as Term, p) + return this.subject.dataset.match(this.subject as Term, p, undefined, this.subject.factory.defaultGraph()) } } diff --git a/src/dataset/GraphScopedDataset.ts b/src/dataset/GraphScopedDataset.ts index acee4f0..342aa38 100644 --- a/src/dataset/GraphScopedDataset.ts +++ b/src/dataset/GraphScopedDataset.ts @@ -1,7 +1,7 @@ import type { DataFactory, Quad, Quad_Graph } from "@rdfjs/types" import { DatasetWrapper } from "../DatasetWrapper.js" import { ProjectedDatasetCoreWrapper } from "./ProjectedDataset.js" -import { ITriple } from "../type/ITriple.js" +import { Triple } from "../type/ITriple.js" import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" /** @@ -20,7 +20,7 @@ export class GraphScopedDataset extends DatasetWrapper { readGraphs: ReadonlyArray | undefined, dataset: NotifyingDatasetCore, factory: DataFactory, - datasetFactory: NotifyingDatasetCoreFactory>, + datasetFactory: NotifyingDatasetCoreFactory>, ) { super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory, datasetFactory) } diff --git a/src/dataset/LazyMaterialize.ts b/src/dataset/LazyMaterialize.ts index 45f4bfb..73c9c29 100644 --- a/src/dataset/LazyMaterialize.ts +++ b/src/dataset/LazyMaterialize.ts @@ -1,7 +1,11 @@ import type { BaseQuad, DatasetCore, Quad } from "@rdfjs/types"; import { ChangeEvent, IterableDatasetCoreFactory, Listener, NotifyingDatasetCore } from "./NotifyingDatasetCore.js"; -import { EventEmitter, PatternEventEmitter } from "../EventEmitter.js"; +import { PatternEventEmitter } from "../EventEmitter.js"; +/** + * A quad pattern: any subset of subject / predicate / object / graph. + * Missing fields act as wildcards that match any term in that position. + */ export interface IPattern { subject?: OutQuad['subject'] | undefined, predicate?: OutQuad['predicate'] | undefined, @@ -9,10 +13,16 @@ export interface IPattern { graph?: OutQuad['graph'] | undefined, }; +/** Carries an {@link IPattern} - implemented by lazy / projected views. */ export interface Pattern { pattern: IPattern; } +/** + * Minimal interface that a {@link LazyMatchNotifyingDatasetCore} needs from + * its backing source: the standard mutating / matching methods and a way to + * subscribe to change events. + */ interface IterableSource { match: (subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object'], graph?: OutQuad['graph']) => Iterable; add: (quad: OutQuad) => void; @@ -41,11 +51,46 @@ const lazyMaterializedFinalizers = new FinalizationRegistry<() => void>(cleanup } }); -// Lazily materialized dataset, which keeps in sync with source +/** + * A {@link NotifyingDatasetCore} that exposes a quad-pattern view over a + * notifying source dataset. + * + * The view is **lazily materialized**: the matching quads are only copied + * into an internal dataset when needed (e.g. on the first call to + * {@link size}, {@link has} or repeated iteration). Once materialized, the + * view subscribes to the source's change events and keeps the cached set in + * sync, so it remains a live view of the underlying data. + * + * - {@link add} / {@link delete} are forwarded to the source (the source's + * change notification updates the materialized cache). + * - {@link has} answers from the cache when materialized, otherwise from the + * source. + * - {@link match} returns another {@link LazyMatchNotifyingDatasetCore} that + * intersects the requested pattern with the current view's pattern. If the + * patterns conflict (different bound terms in the same position) an + * {@link EmptyDataset} is returned. + * - {@link on} / {@link off} subscribe to source changes filtered by the + * view's pattern. + * + * The class implements {@link Disposable}: prefer the `using` declaration so + * that listeners are detached deterministically. A {@link FinalizationRegistry} + * provides a best-effort safety net when explicit disposal is missed. + */ export class LazyMatchNotifyingDatasetCore implements NotifyingDatasetCore, Pattern, Disposable { private materialized?: DatasetCore | undefined; private cb?: ((event: ChangeEvent, q: IQuad) => void) | undefined; + /** + * Bound source listener that forwards change events to the pattern + * emitter. Stored once so the *same* function reference is added to and + * removed from the source - a fresh closure per call would be a different + * reference and {@link IterableSource.off} would silently fail. + * + * The `=> this.ee.emit(...)` form preserves the `this` context that + * {@link PatternEventEmitter.emit} relies on. + */ + private readonly emitToEe: (event: ChangeEvent, q: IQuad) => void; + /** * Token used to unregister this instance from the finalization * registry when listeners are detached deterministically via @@ -59,22 +104,29 @@ export class LazyMatchNotifyingDatasetCore implem public readonly pattern: IPattern, private readonly datasetFactory: IterableDatasetCoreFactory>, ) { - + const ee = this.ee; + this.emitToEe = (event, q) => ee.emit(event, q); } + /** + * Subscribes the materialized cache to source changes so it stays in + * sync. The closure intentionally captures `ds` and `source` - never + * `this` - so the wrapper remains eligible for garbage collection and + * the finalizer can run. + */ private init(ds: DatasetCore): void { const cb = (event: ChangeEvent, q: IQuad): void => { ds[event](q); }; this.cb = cb; - const self = this; + const source = this.source; - self.on(cb); + source.on(cb); // Register a best-effort finalizer. The cleanup closure only - // references `ds` and the local handlers - never `this` - + // references `source` and the local handler - never `this` - // so the wrapper remains eligible for garbage collection. lazyMaterializedFinalizers.register( this, - () => self.off(cb), + () => source.off(cb), this.finalizerToken, ); } @@ -108,22 +160,27 @@ export class LazyMatchNotifyingDatasetCore implem * specification, so deterministic disposal should still be preferred. */ [Symbol.dispose](): void { - if (this.materialized) { - if (this.cb) { - this.off(this.cb); - } + if (this.cb) { + this.source.off(this.cb); lazyMaterializedFinalizers.unregister(this.finalizerToken); } + if (!this.ee.empty) { + this.source.off(this.emitToEe); + } this.cb = undefined; this.materialized = undefined; } public *[Symbol.iterator](): Iterator { // If already materialized, delegate to the dataset's iterator. + // NOTE: `yield*` is required - `return iterator` from a generator + // would set the generator's return value rather than iterating. if (this.materialized) { - return this.materialized[Symbol.iterator](); + yield* this.materialized; + return; } + // Stream and materialize in a single pass, deduplicating along the way. const materialized = this.datasetFactory.dataset(); for (const q of this.source.match(this.pattern.subject, this.pattern.predicate, this.pattern.object, this.pattern.graph)) { if (!materialized.has(q)) { @@ -132,6 +189,7 @@ export class LazyMatchNotifyingDatasetCore implem } } + this.materialized = materialized; this.init(materialized); } @@ -160,14 +218,16 @@ export class LazyMatchNotifyingDatasetCore implem } match(subject?: IQuad['subject'], predicate?: IQuad['predicate'], object?: IQuad['object'], graph?: IQuad['graph']): NotifyingDatasetCore { - let pattern: IPattern = { subject, predicate, object, graph }; + const pattern: IPattern = { subject, predicate, object, graph }; for (const key of ['subject', 'predicate', 'object', 'graph'] as const) { - if (pattern[key] !== undefined && this.pattern[key] !== undefined && !pattern[key].equals(this.pattern[key])) { + const requested = pattern[key]; + const existing = this.pattern[key]; + if (requested !== undefined && existing !== undefined && !requested.equals(existing)) { // Pattern and argument conflict; return an empty dataset. - return EMTY_DATASET; + return EMPTY_DATASET as NotifyingDatasetCore; } - pattern[key] ??= this.pattern[key]; + pattern[key] ??= existing; } return new LazyMatchNotifyingDatasetCore( @@ -181,7 +241,7 @@ export class LazyMatchNotifyingDatasetCore implem on(...args: Parameters["on"]>): void { if (this.ee.empty) { - this.source.on(this.ee.emit); + this.source.on(this.emitToEe); } this.ee.on(this.pattern, ...args); } @@ -189,11 +249,17 @@ export class LazyMatchNotifyingDatasetCore implem off(...args: Parameters["off"]>): void { this.ee.off(this.pattern, ...args); if (this.ee.empty) { - this.source.off(this.ee.emit); + this.source.off(this.emitToEe); } } } +/** + * A {@link NotifyingDatasetCore} that contains no quads. Returned by + * {@link LazyMatchNotifyingDatasetCore.match} when the requested pattern + * conflicts with the view's existing pattern. Mutations are not supported + * and throw. + */ export class EmptyDataset implements NotifyingDatasetCore { size = 0; @@ -226,4 +292,4 @@ export class EmptyDataset = (event: ChangeEvent, quad: InQuad) => void +/** + * A {@link DatasetCore} that emits change events when quads are added or + * removed. + * + * Listeners attached via {@link on} are invoked with the change type + * (`'add'` or `'delete'`) and the affected quad. {@link match} returns a + * {@link NotifyingDatasetCore} as well so the same notification mechanism + * is available on derived views. + */ export interface NotifyingDatasetCore extends DatasetCore { + /** Subscribes `listener` to this dataset's change events. */ on(listener: Listener): void; + /** Detaches a previously {@link on}-attached listener. */ off(listener: Listener): void; match(...args: Parameters["match"]>): NotifyingDatasetCore; } +/** + * A {@link DatasetCoreFactory} whose `dataset` method accepts any iterable + * of quads (not just an array as the standard interface allows). + */ export interface IterableDatasetCoreFactory = DatasetCore> extends DatasetCoreFactory { dataset(quads?: Iterable): D; } +/** + * An {@link IterableDatasetCoreFactory} that produces + * {@link NotifyingDatasetCore} instances. + */ export interface NotifyingDatasetCoreFactory = NotifyingDatasetCore> extends IterableDatasetCoreFactory { dataset(quads?: Iterable): D; } +/** + * Wraps an arbitrary {@link DatasetCore} and turns it into a + * {@link NotifyingDatasetCore} by intercepting `add` and `delete` and + * emitting change events to subscribers. + * + * The wrapper does **not** guard against the underlying dataset's behaviour + * for redundant operations: events are emitted unconditionally on every + * `add` / `delete` call, even when adding a quad that already exists or + * deleting a quad that does not. + */ export class NotifyingDatasetCoreWrapper implements NotifyingDatasetCore { private ee = new EventEmitter<[ChangeEvent, InQuad]>(); @@ -65,6 +97,12 @@ export class NotifyingDatasetCoreWrapper(dataset: DatasetCore): NotifyingDatasetCore { if ("on" in dataset && typeof dataset.on === "function" && "off" in dataset && typeof dataset.off === "function") { return dataset as NotifyingDatasetCore; diff --git a/src/dataset/ProjectedDataset.ts b/src/dataset/ProjectedDataset.ts index b8dbffc..4a2686d 100644 --- a/src/dataset/ProjectedDataset.ts +++ b/src/dataset/ProjectedDataset.ts @@ -1,17 +1,18 @@ -import type { DataFactory, Quad, Quad_Graph } from "@rdfjs/types"; +import type { DataFactory, DefaultGraph, Quad, Quad_Graph, Term } from "@rdfjs/types"; import { defaultGraph } from "./terms.js"; -import { ensureDefaultGraph } from "../ensure.js"; +import { ensureDefaultGraph, ensureTermType } from "../ensure.js"; import { EventEmitter } from "../EventEmitter.js"; import { LazyMatchNotifyingDatasetCore } from "./LazyMaterialize.js"; import { ChangeEvent, Listener, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js"; -import { ITriple, IBaseTriple } from "../type/ITriple.js"; +import { Triple, BaseTriple } from "../type/ITriple.js"; +import { TermTypeError } from "../errors/TermTypeError.js"; /** * A {@link NotifyingDatasetCore} whose quads are always exposed in the * default graph. Returned by {@link ProjectedDatasetCoreWrapper.match}. */ -export interface ProjectedDatasetCore extends NotifyingDatasetCore { - match(subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object']): ProjectedDatasetCore; +export interface ProjectedDatasetCore extends NotifyingDatasetCore { + match(subject?: OutQuad['subject'], predicate?: OutQuad['predicate'], object?: OutQuad['object'], graph?: DefaultGraph): ProjectedDatasetCore; } /** @@ -32,24 +33,24 @@ export interface ProjectedDatasetCore { - private readonly ee = new EventEmitter<[ChangeEvent, ITriple]>() +export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private readonly ee = new EventEmitter<[ChangeEvent, Triple]>() - private _dataset: ProjectedDatasetCore | null = null + private _dataset: ProjectedDatasetCore | null = null public constructor( private readonly writeGraph: Quad_Graph, private readonly readGraphs: ReadonlyArray | undefined, private readonly source: NotifyingDatasetCore, private readonly factory: DataFactory, - private readonly datasetFactory: NotifyingDatasetCoreFactory>, + private readonly datasetFactory: NotifyingDatasetCoreFactory>, ) { } /** Lazily-materialized snapshot of the projected view. */ - private get dataset(): ProjectedDatasetCore { + private get dataset(): ProjectedDatasetCore { if (this._dataset === null) { - this._dataset = this.match() + this._dataset = this.match(undefined, undefined, undefined, defaultGraph) } return this._dataset } @@ -58,7 +59,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + public [Symbol.iterator](): Iterator { return this.dataset[Symbol.iterator]() } @@ -66,7 +67,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore this.source.has(this.inGraph(quad, g))) @@ -99,17 +100,19 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { - return new LazyMatchNotifyingDatasetCore( + public match(subject: Triple['subject'] | undefined, predicate: Triple['predicate'] | undefined, object: Triple['object'] | undefined, graph: DefaultGraph): ProjectedDatasetCore { + ensureTermType(graph, "DefaultGraph") + return new LazyMatchNotifyingDatasetCore( { - match: (s?: ITriple['subject'], p?: ITriple['predicate'], o?: ITriple['object']) => this.matchInSourceAsDefault(s, p, o), - has: (quad: ITriple) => this.has(quad), - add: (quad: ITriple) => this.add(quad), - delete: (quad: ITriple) => this.delete(quad), - on: (listener: Listener) => this.on(listener), - off: (listener: Listener) => this.off(listener), + match: (s?: Triple['subject'], p?: Triple['predicate'], o?: Triple['object']) => this.matchInSourceAsDefault(s, p, o), + has: (quad: Triple) => this.has(quad), + add: (quad: Triple) => this.add(quad), + delete: (quad: Triple) => this.delete(quad), + on: (listener: Listener) => this.on(listener), + off: (listener: Listener) => this.off(listener), }, { subject, predicate, object, graph: defaultGraph }, this.datasetFactory, @@ -117,7 +120,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private *matchInSource(subject?: Triple['subject'], predicate?: Triple['predicate'], object?: Triple['object']): Iterable { if (this.readGraphs === undefined) { yield* this.source.match(subject, predicate, object) return @@ -128,7 +131,7 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore { + private *matchInSourceAsDefault(subject?: Triple['subject'], predicate?: Triple['predicate'], object?: Triple['object']): Iterable { for (const q of this.matchInSource(subject, predicate, object)) { yield this.asDefault(q) } @@ -167,14 +170,14 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore): void { + public on(listener: Listener): void { if (this.ee.empty) { this.source.on(this.cb) } this.ee.on(listener) } - public off(listener: Listener): void { + public off(listener: Listener): void { this.ee.off(listener) if (this.ee.empty) { this.source.off(this.cb) @@ -182,8 +185,8 @@ export class ProjectedDatasetCoreWrapper implements ProjectedDatasetCore = new ( writeGraph: Quad_Graph, readGraphs: ReadonlyArray | undefined, - dataset: DatasetCore, + dataset: NotifyingDatasetCore, factory: DataFactory, - datasetFactory: DatasetCoreFactory, + datasetFactory: NotifyingDatasetCoreFactory>, ) => T diff --git a/src/type/ITriple.ts b/src/type/ITriple.ts index 1ee594f..5072998 100644 --- a/src/type/ITriple.ts +++ b/src/type/ITriple.ts @@ -1,9 +1,9 @@ import type { BaseQuad, DefaultGraph, Quad } from "@rdfjs/types"; -export interface IBaseTriple extends BaseQuad { +export interface BaseTriple extends BaseQuad { graph: DefaultGraph; } -export interface ITriple extends Quad { +export interface Triple extends Quad { graph: DefaultGraph; } diff --git a/test/unit/dataset_core_base.test.ts b/test/unit/dataset_core_base.test.ts index 9f280d2..e4dba37 100644 --- a/test/unit/dataset_core_base.test.ts +++ b/test/unit/dataset_core_base.test.ts @@ -1,8 +1,10 @@ import assert from "node:assert" import { describe, it } from "node:test" -import { DataFactory } from "n3" +import { DataFactory, Triple as N3Triple } from "n3" import { ParentDataset } from "./model/ParentDataset.js" import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" +import { defaultGraph, type Triple } from "@rdfjs/wrapper" const rdf = ` prefix : @@ -22,8 +24,8 @@ prefix : `; await describe("Dataset Core Bases", async () => { - const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory) - const newQuad = DataFactory.quad(DataFactory.blankNode(), DataFactory.namedNode("x"), DataFactory.literal("x")) + const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory) + const newQuad = DataFactory.quad(DataFactory.blankNode(), DataFactory.namedNode("x"), DataFactory.literal("x")) await it("get size", async () => { assert.equal(parentDataset.size, 8) @@ -51,8 +53,8 @@ await describe("Dataset Core Bases", async () => { await it("match quads", async () => { parentDataset.add(newQuad) - assert.equal((Array.from(parentDataset.match(newQuad.subject, newQuad.predicate, newQuad.object, newQuad.graph)).length), 1) + assert.equal((Array.from(parentDataset.match(newQuad.subject, newQuad.predicate, newQuad.object, defaultGraph)).length), 1) parentDataset.delete(newQuad) - assert.equal((Array.from(parentDataset.match(newQuad.subject, newQuad.predicate, newQuad.object, newQuad.graph)).length), 0) + assert.equal((Array.from(parentDataset.match(newQuad.subject, newQuad.predicate, newQuad.object, defaultGraph)).length), 0) }) }) diff --git a/test/unit/dataset_module.test.ts b/test/unit/dataset_module.test.ts new file mode 100644 index 0000000..edca1a2 --- /dev/null +++ b/test/unit/dataset_module.test.ts @@ -0,0 +1,322 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory, Store } from "n3" +import { + NotifyingDatasetCoreWrapper, + ensureNotifyingDatasetCore, + LazyMatchNotifyingDatasetCore, + EmptyDataset, + defaultGraph, +} from "@rdfjs/wrapper" +import { n3StoreFactory } from "./util/n3StoreFactory.js" + +const s = DataFactory.namedNode("https://example.org/s") +const s2 = DataFactory.namedNode("https://example.org/s2") +const p = DataFactory.namedNode("https://example.org/p") +const o1 = DataFactory.literal("o1") +const o2 = DataFactory.literal("o2") +const g = DataFactory.namedNode("https://example.org/g") + +await describe("defaultGraph", async () => { + await it("has the correct shape", () => { + assert.equal(defaultGraph.termType, "DefaultGraph") + assert.equal(defaultGraph.value, "") + }) + + await it("equals other default graphs", () => { + assert.equal(defaultGraph.equals(DataFactory.defaultGraph()), true) + }) + + await it("does not equal a named node", () => { + assert.equal(defaultGraph.equals(s), false) + }) + + await it("does not equal null/undefined", () => { + assert.equal(defaultGraph.equals(null), false) + assert.equal(defaultGraph.equals(undefined), false) + }) + + await it("is frozen", () => { + assert.equal(Object.isFrozen(defaultGraph), true) + }) +}) + +await describe("NotifyingDatasetCoreWrapper", async () => { + await it("emits add events", () => { + const wrapped = new NotifyingDatasetCoreWrapper(new Store()) + const events: Array = [] + wrapped.on(event => events.push(event)) + + wrapped.add(DataFactory.quad(s, p, o1)) + + assert.deepEqual(events, ["add"]) + }) + + await it("emits delete events", () => { + const inner = new Store() + inner.addQuad(DataFactory.quad(s, p, o1)) + const wrapped = new NotifyingDatasetCoreWrapper(inner) + const events: Array = [] + wrapped.on(event => events.push(event)) + + wrapped.delete(DataFactory.quad(s, p, o1)) + + assert.deepEqual(events, ["delete"]) + }) + + await it("does not emit when listener is detached", () => { + const wrapped = new NotifyingDatasetCoreWrapper(new Store()) + const events: Array = [] + const cb = (event: string) => { events.push(event) } + wrapped.on(cb) + wrapped.off(cb) + + wrapped.add(DataFactory.quad(s, p, o1)) + + assert.deepEqual(events, []) + }) + + await it("delegates iterator to inner dataset", () => { + const inner = new Store() + inner.addQuad(DataFactory.quad(s, p, o1)) + inner.addQuad(DataFactory.quad(s, p, o2)) + const wrapped = new NotifyingDatasetCoreWrapper(inner) + + assert.equal(Array.from(wrapped).length, 2) + }) + + await it("delegates size", () => { + const inner = new Store([DataFactory.quad(s, p, o1)]) + const wrapped = new NotifyingDatasetCoreWrapper(inner) + + assert.equal(wrapped.size, 1) + }) + + await it("match returns a NotifyingDatasetCore", () => { + const inner = new Store([ + DataFactory.quad(s, p, o1), + DataFactory.quad(s2, p, o1), + ]) + const wrapped = new NotifyingDatasetCoreWrapper(inner) + const matched = wrapped.match(s) + + assert.equal(matched.size, 1) + assert.equal(typeof matched.on, "function") + }) +}) + +await describe("ensureNotifyingDatasetCore", async () => { + await it("wraps a plain DatasetCore", () => { + const inner = new Store() + const wrapped = ensureNotifyingDatasetCore(inner) + + assert.ok(wrapped instanceof NotifyingDatasetCoreWrapper) + }) + + await it("returns a NotifyingDatasetCore unchanged", () => { + const wrapped = new NotifyingDatasetCoreWrapper(new Store()) + const result = ensureNotifyingDatasetCore(wrapped) + + assert.equal(result, wrapped) + }) +}) + +await describe("LazyMatchNotifyingDatasetCore", async () => { + function makeSource(): NotifyingDatasetCoreWrapper { + const store = new Store([ + DataFactory.quad(s, p, o1), + DataFactory.quad(s, p, o2), + DataFactory.quad(s2, p, o1), + ]) + return new NotifyingDatasetCoreWrapper(store) + } + + await it("iterates matching source quads on first iteration", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + { subject: s }, + n3StoreFactory, + ) + + assert.equal(Array.from(lazy).length, 2) + }) + + await it("iterates from materialized cache on subsequent iteration", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + { subject: s }, + n3StoreFactory, + ) + + // Materialize via `size`. + assert.equal(lazy.size, 2) + // Subsequent iteration must yield the cached quads (not be empty). + assert.equal(Array.from(lazy).length, 2) + }) + + await it("size triggers materialization and returns the count", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + {}, + n3StoreFactory, + ) + + assert.equal(lazy.size, 3) + }) + + await it("has uses source until materialized, then the cache", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + {}, + n3StoreFactory, + ) + + assert.equal(lazy.has(DataFactory.quad(s, p, o1)), true) + // Materialize. + assert.equal(lazy.size, 3) + assert.equal(lazy.has(DataFactory.quad(s, p, o2)), true) + assert.equal(lazy.has(DataFactory.quad(s2, p, o2)), false) + }) + + await it("add forwards to source and propagates into materialized cache", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + {}, + n3StoreFactory, + ) + + // Materialize first so the listener is wired. + assert.equal(lazy.size, 3) + + lazy.add(DataFactory.quad(s2, p, o2)) + + assert.equal(lazy.size, 4) + assert.equal(source.has(DataFactory.quad(s2, p, o2)), true) + }) + + await it("delete forwards to source and propagates into materialized cache", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + {}, + n3StoreFactory, + ) + + assert.equal(lazy.size, 3) + + lazy.delete(DataFactory.quad(s, p, o1)) + + assert.equal(lazy.size, 2) + assert.equal(source.has(DataFactory.quad(s, p, o1)), false) + }) + + await it("match intersects pattern with view's pattern", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + { subject: s }, + n3StoreFactory, + ) + + const sub = lazy.match(undefined, p, o2) + assert.equal(sub.size, 1) + }) + + await it("match returns EmptyDataset when patterns conflict", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + { subject: s }, + n3StoreFactory, + ) + + const sub = lazy.match(s2) + assert.equal(sub.size, 0) + assert.ok(sub instanceof EmptyDataset) + }) + + await it("on/off receives events filtered by pattern", () => { + const source = makeSource() + using lazy = new LazyMatchNotifyingDatasetCore( + source, + { subject: s }, + n3StoreFactory, + ) + + const events: Array = [] + const cb = (event: string, q: any) => { events.push(`${event}:${q.object.value}`) } + lazy.on(cb) + + // Matches the pattern (subject = s). + source.add(DataFactory.quad(s, p, DataFactory.literal("new"))) + // Does not match the pattern (subject = s2). + source.add(DataFactory.quad(s2, p, DataFactory.literal("ignored"))) + + assert.deepEqual(events, ["add:new"]) + + lazy.off(cb) + source.add(DataFactory.quad(s, p, DataFactory.literal("after-off"))) + assert.deepEqual(events, ["add:new"]) + }) + + await it("dispose detaches source listeners and is idempotent", () => { + const source = makeSource() + const lazy = new LazyMatchNotifyingDatasetCore( + source, + {}, + n3StoreFactory, + ) + + // Subscribe and materialize. + const cb = () => { /* noop */ } + lazy.on(cb) + assert.equal(lazy.size, 3) + + lazy[Symbol.dispose]() + // Calling dispose a second time should not throw. + lazy[Symbol.dispose]() + }) +}) + +await describe("EmptyDataset", async () => { + await it("has size 0", () => { + const ds = new EmptyDataset() + assert.equal(ds.size, 0) + }) + + await it("never has a quad", () => { + const ds = new EmptyDataset() + assert.equal(ds.has(), false) + }) + + await it("yields nothing on iteration", () => { + const ds = new EmptyDataset() + assert.equal(Array.from(ds).length, 0) + }) + + await it("returns itself on match", () => { + const ds = new EmptyDataset() + assert.equal(ds.match(), ds) + }) + + await it("throws on add", () => { + const ds = new EmptyDataset() + assert.throws(() => ds.add(), /empty/) + }) + + await it("throws on delete", () => { + const ds = new EmptyDataset() + assert.throws(() => ds.delete(), /empty/) + }) + + await it("on/off are no-ops", () => { + const ds = new EmptyDataset() + ds.on() + ds.off() + }) +}) diff --git a/test/unit/dataset_wrapper.test.ts b/test/unit/dataset_wrapper.test.ts index cdeb2bf..733a952 100644 --- a/test/unit/dataset_wrapper.test.ts +++ b/test/unit/dataset_wrapper.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "node:test" import { DataFactory } from "n3" import { ParentDataset } from "./model/ParentDataset.js" import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" const rdf = ` prefix : @@ -30,7 +31,7 @@ prefix : await describe("Dataset Wrappers", async () => { - const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory) + const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory) await it("get instances of Parent as Parent", async () => { assert.equal(Array.from(parentDataset.instancesOfParent).length, 1) diff --git a/test/unit/model/ParentDataset.ts b/test/unit/model/ParentDataset.ts index 1dde387..0492b60 100644 --- a/test/unit/model/ParentDataset.ts +++ b/test/unit/model/ParentDataset.ts @@ -17,10 +17,10 @@ export class ParentDataset extends DatasetWrapper { } public get matchSubjectsOfPropertyanyObjectparentGraphany(): Iterable { - return this.matchSubjectsOf(Parent, undefined, this.factory.namedNode(Example.Parent), undefined) + return this.matchSubjectsOf(Parent, undefined, this.factory.namedNode(Example.Parent)) } public get matchObjectsOfSubjectxPropertyhaschildGraphany(): Iterable { - return this.matchObjectsOf(Child, this.factory.namedNode("x"), this.factory.namedNode(Example.hasChild), undefined) + return this.matchObjectsOf(Child, this.factory.namedNode("x"), this.factory.namedNode(Example.hasChild)) } } diff --git a/test/unit/named_graph.test.ts b/test/unit/named_graph.test.ts index c916469..66092e0 100644 --- a/test/unit/named_graph.test.ts +++ b/test/unit/named_graph.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert" import { describe, it } from "node:test" -import { DataFactory, Store } from "n3" -import { DatasetWrapper, GraphScopedDataset, NamedGraphError, TermTypeError } from "@rdfjs/wrapper" - +import { Triple as N3Triple, DataFactory, Store } from "n3" +import { DatasetWrapper, defaultGraph, GraphScopedDataset, NamedGraphError, TermTypeError, Triple } from "@rdfjs/wrapper" +import { n3StoreFactory } from "./util/n3StoreFactory.js" const graph = DataFactory.namedNode("https://example.org/graph") const s = DataFactory.namedNode("https://example.org/s") const p = DataFactory.namedNode("https://example.org/p") @@ -23,7 +23,7 @@ class SomeDataset extends DatasetWrapper { await describe("namedGraph", async () => { await it("exposes quads from the named graph as default graph quads", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph const quads = Array.from(ds) assert.equal(quads.length, 1) @@ -34,26 +34,26 @@ await describe("namedGraph", async () => { }) await it("reports correct size", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph assert.equal(ds.size, 1) }) await it("has returns true for a matching default graph quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph - assert.equal(ds.has(DataFactory.quad(s, p, o)), true) + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + assert.equal(ds.has(DataFactory.quad(s, p, o)), true) }) await it("has returns false for a non-matching quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph - assert.equal(ds.has(DataFactory.quad(s, p, DataFactory.literal("nope"))), false) + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + assert.equal(ds.has(DataFactory.quad(s, p, DataFactory.literal("nope"))), false) }) await it("add inserts into the named graph of the underlying dataset", () => { const store = storeWithNamedGraph() - const ds = new SomeDataset(store, DataFactory).namedGraph + const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph const newObj = DataFactory.literal("new") - ds.add(DataFactory.quad(s, p, newObj)) + ds.add(DataFactory.quad(s, p, newObj)) assert.equal(ds.size, 2) assert.equal(store.has(DataFactory.quad(s, p, newObj, graph)), true) @@ -61,9 +61,9 @@ await describe("namedGraph", async () => { await it("delete removes from the named graph of the underlying dataset", () => { const store = storeWithNamedGraph() - const ds = new SomeDataset(store, DataFactory).namedGraph + const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph - ds.delete(DataFactory.quad(s, p, o)) + ds.delete(DataFactory.quad(s, p, o)) assert.equal(ds.size, 0) assert.equal(store.has(DataFactory.quad(s, p, o, graph)), false) @@ -75,8 +75,8 @@ await describe("namedGraph", async () => { store.addQuad(DataFactory.quad(s, p, o, graph)) store.addQuad(DataFactory.quad(s, p2, DataFactory.literal("other"), graph)) - const ds = new SomeDataset(store, DataFactory).namedGraph - const matched = Array.from(ds.match(undefined, p2)) + const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const matched = Array.from(ds.match(undefined, p2, undefined, defaultGraph)) assert.equal(matched.length, 1) assert.equal(matched[0]!.predicate.value, p2.value) @@ -84,44 +84,47 @@ await describe("namedGraph", async () => { }) await it("match with DefaultGraph argument works", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph const matched = Array.from(ds.match(undefined, undefined, undefined, DataFactory.defaultGraph())) assert.equal(matched.length, 1) }) await it("throws NamedGraphError when adding a quad with a named graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph assert.throws( - () => ds.add(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))), + // @ts-expect-error + () => ds.add(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))), NamedGraphError, ) }) await it("throws NamedGraphError when deleting a quad with a named graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph assert.throws( - () => ds.delete(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))), + // @ts-expect-error + () => ds.delete(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))), NamedGraphError, ) }) await it("throws NamedGraphError when checking has with a named graph quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph assert.throws( + // @ts-expect-error () => ds.has(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))), NamedGraphError, ) }) await it("throws TermTypeError when matching with a non-default graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory).namedGraph + const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph assert.throws( - () => ds.match(undefined, undefined, undefined, DataFactory.namedNode("https://other.org/g")), + () => ds.match(undefined, undefined, undefined, DataFactory.namedNode("https://other.org/g") as any), TermTypeError, ) }) diff --git a/test/unit/named_graph_integration.test.ts b/test/unit/named_graph_integration.test.ts index bf44a04..efb8107 100644 --- a/test/unit/named_graph_integration.test.ts +++ b/test/unit/named_graph_integration.test.ts @@ -6,6 +6,7 @@ import { ParentDataset } from "./model/ParentDataset.js" import { Example } from "./vocabulary/Example.js" import { DatasetWrapper, GraphScopedDataset } from "@rdfjs/wrapper" import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" const rdf = ` PREFIX : @@ -37,14 +38,14 @@ class SomeNamedDataset extends GraphScopedDataset { await describe("namedGraph with TermWrapper", async () => { await it("reads properties from the named graph via TermWrapper", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasString, "graph string") }) await it("does not see data from other graphs", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! // The value should be the one from the named graph, not the default graph @@ -53,7 +54,7 @@ await describe("namedGraph with TermWrapper", async () => { }) await it("navigates child objects within the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasChild.hasString, "graph child string") @@ -61,7 +62,7 @@ await describe("namedGraph with TermWrapper", async () => { await it("writes properties back into the named graph", () => { const store = datasetFromRdf(rdf) - const view = new SomeDataset(store, DataFactory).namedGraph + const view = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! parent.hasString = "updated" @@ -78,7 +79,7 @@ await describe("namedGraph with TermWrapper", async () => { await it("sets nullable properties through the named graph view", () => { const store = datasetFromRdf(rdf) - const view = new SomeDataset(store, DataFactory).namedGraph + const view = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasNullableString, undefined) @@ -93,8 +94,8 @@ await describe("namedGraph with TermWrapper", async () => { await describe("namedGraph with DatasetWrapper", async () => { await it("finds instances within the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory).namedGraph - const parentDataset = new ParentDataset(view, DataFactory) + const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph + const parentDataset = new ParentDataset(view, DataFactory, n3StoreFactory) const parents = Array.from(parentDataset.instancesOfParent) assert.equal(parents.length, 1) @@ -102,7 +103,7 @@ await describe("namedGraph with DatasetWrapper", async () => { }) await it("iterates only quads from the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph const quads = Array.from(view) // Named graph has 4 quads, default graph has 1 — should only see 4 diff --git a/test/unit/projected_dataset.test.ts b/test/unit/projected_dataset.test.ts new file mode 100644 index 0000000..605a64e --- /dev/null +++ b/test/unit/projected_dataset.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory, Store, Triple as N3Triple } from "n3" +import { + NotifyingDatasetCoreWrapper, + ProjectedDatasetCoreWrapper, + TermTypeError, + NamedGraphError, + type Triple, +} from "@rdfjs/wrapper" +import { n3StoreFactory } from "./util/n3StoreFactory.js" + +const s = DataFactory.namedNode("https://example.org/s") +const p = DataFactory.namedNode("https://example.org/p") +const o1 = DataFactory.literal("o1") +const o2 = DataFactory.literal("o2") +const g1 = DataFactory.namedNode("https://example.org/g1") +const g2 = DataFactory.namedNode("https://example.org/g2") +const g3 = DataFactory.namedNode("https://example.org/g3") + +const factory: any = n3StoreFactory + +function source(quads: ReadonlyArray> = []): NotifyingDatasetCoreWrapper { + const store = new Store() + for (const q of quads) store.addQuad(q as any) + return new NotifyingDatasetCoreWrapper(store) +} + +function captureEvents(view: ProjectedDatasetCoreWrapper): Array { + const events: Array = [] + view.on((event, q) => events.push(`${event}:${q.object.value}`)) + return events +} + +await describe("ProjectedDatasetCoreWrapper - explicit read graphs", async () => { + await it("iterates only quads in the configured read graphs", () => { + const src = source([ + DataFactory.quad(s, p, o1, g1), + DataFactory.quad(s, p, o2, g2), + DataFactory.quad(s, p, DataFactory.literal("ignored"), g3), + ]) + + const view = new ProjectedDatasetCoreWrapper(g1, [g1, g2], src, DataFactory, factory) + const values = Array.from(view).map(q => q.object.value).sort() + + assert.deepEqual(values, ["o1", "o2"]) + }) + + await it("write rewrites the graph to the configured write graph", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + + view.add(DataFactory.quad(s, p, o1)) + + assert.equal(src.has(DataFactory.quad(s, p, o1, g1)), true) + }) + + await it("rejects writes whose quad is not in the default graph", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + + // @ts-expect-error + assert.throws(() => view.add(DataFactory.quad(s, p, o1, g2)), NamedGraphError) + // @ts-expect-error + assert.throws(() => view.delete(DataFactory.quad(s, p, o1, g2)), NamedGraphError) + // @ts-expect-error + assert.throws(() => view.has(DataFactory.quad(s, p, o1, g2)), NamedGraphError) + }) + + await it("match throws TermTypeError on a non-default graph argument", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + + assert.throws(() => view.match(undefined, undefined, undefined, g1 as any), TermTypeError) + }) + + await it("match accepts an explicit DefaultGraph argument", () => { + const src = source([DataFactory.quad(s, p, o1, g1)]) + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + + assert.equal(view.match(undefined, undefined, undefined, DataFactory.defaultGraph()).size, 1) + }) + + await it("emits add events for triples added to a read graph", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1, g2], src, DataFactory, factory) + const events = captureEvents(view) + + src.add(DataFactory.quad(s, p, o1, g1)) + + assert.deepEqual(events, ["add:o1"]) + }) + + await it("does not emit for triples added to a graph outside read scope", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + const events = captureEvents(view) + + src.add(DataFactory.quad(s, p, o1, g2)) + + assert.deepEqual(events, []) + }) + + await it("suppresses duplicate add events when a triple already exists in another read graph", () => { + const src = source([DataFactory.quad(s, p, o1, g1)]) + const view = new ProjectedDatasetCoreWrapper(g1, [g1, g2], src, DataFactory, factory) + const events = captureEvents(view) + + // Triple already in g1; adding the same triple to g2 must not duplicate the projected view. + src.add(DataFactory.quad(s, p, o1, g2)) + + assert.deepEqual(events, []) + }) + + await it("suppresses delete events while another read graph still has the triple", () => { + const src = source([ + DataFactory.quad(s, p, o1, g1), + DataFactory.quad(s, p, o1, g2), + ]) + const view = new ProjectedDatasetCoreWrapper(g1, [g1, g2], src, DataFactory, factory) + const events = captureEvents(view) + + // Removing the copy in g1 must not surface as a delete because g2 still has it. + src.delete(DataFactory.quad(s, p, o1, g1)) + // Removing the last copy must surface as a delete. + src.delete(DataFactory.quad(s, p, o1, g2)) + + assert.deepEqual(events, ["delete:o1"]) + }) + + await it("off detaches the source listener when no view listeners remain", () => { + const src = source() + const view = new ProjectedDatasetCoreWrapper(g1, [g1], src, DataFactory, factory) + const events: Array = [] + const cb = (event: string, q: any) => { events.push(`${event}:${q.object.value}`) } + + view.on(cb) + view.off(cb) + + src.add(DataFactory.quad(s, p, o1, g1)) + assert.deepEqual(events, []) + }) +}) + +await describe("ProjectedDatasetCoreWrapper - union (readGraphs undefined)", async () => { + await it("iterates triples from any graph deduplicated", () => { + const src = source([ + DataFactory.quad(s, p, o1), + DataFactory.quad(s, p, o1, g1), + DataFactory.quad(s, p, o2, g2), + ]) + const view = new ProjectedDatasetCoreWrapper(g1, undefined, src, DataFactory, factory) + + assert.equal(view.size, 2) + }) + + await it("has finds triples in any graph", () => { + const src = source([DataFactory.quad(s, p, o1, g3)]) + const view = new ProjectedDatasetCoreWrapper(g1, undefined, src, DataFactory, factory) + + assert.equal(view.has(DataFactory.quad(s, p, o1)), true) + }) + + await it("emits a single add event when a duplicate already exists in another graph", () => { + const src = source([DataFactory.quad(s, p, o1)]) + const view = new ProjectedDatasetCoreWrapper(g1, undefined, src, DataFactory, factory) + const events = captureEvents(view) + + // The triple already exists in the default graph; adding it to g1 must not re-emit. + src.add(DataFactory.quad(s, p, o1, g1)) + + assert.deepEqual(events, []) + }) + + await it("emits a delete event only when the last copy is removed", () => { + const src = source([ + DataFactory.quad(s, p, o1), + DataFactory.quad(s, p, o1, g1), + ]) + const view = new ProjectedDatasetCoreWrapper(g1, undefined, src, DataFactory, factory) + const events = captureEvents(view) + + src.delete(DataFactory.quad(s, p, o1)) + src.delete(DataFactory.quad(s, p, o1, g1)) + + assert.deepEqual(events, ["delete:o1"]) + }) +}) diff --git a/test/unit/union_graph.test.ts b/test/unit/union_graph.test.ts index 63a4626..e70dabc 100644 --- a/test/unit/union_graph.test.ts +++ b/test/unit/union_graph.test.ts @@ -1,14 +1,17 @@ import assert from "node:assert" import { describe, it } from "node:test" -import { DataFactory, Store } from "n3" +import { DataFactory, Store, type Triple as N3Triple } from "n3" import { DatasetWrapper, + defaultGraph, GraphScopedDataset, NamedGraphError, TermTypeError, + type Triple, } from "@rdfjs/wrapper" import { Parent } from "./model/Parent.js" import { Example } from "./vocabulary/Example.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" const graph = DataFactory.namedNode("https://example.org/graph") const otherGraph = DataFactory.namedNode("https://example.org/other") @@ -34,7 +37,7 @@ class SomeDataset extends DatasetWrapper { await describe("GraphScopedDataset (union)", async () => { await it("iterates quads from all graphs projected to the default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView const quads = Array.from(view) @@ -52,31 +55,31 @@ await describe("GraphScopedDataset (union)", async () => { store.addQuad(DataFactory.quad(s, p, oDefault, graph)) store.addQuad(DataFactory.quad(s, p, oDefault, otherGraph)) - const view = new SomeDataset(store, DataFactory).unionView + const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView assert.equal(view.size, 1) assert.equal(Array.from(view).length, 1) }) await it("size reflects unique triples across all graphs", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView assert.equal(view.size, 3) }) await it("has finds triples regardless of source graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView - assert.equal(view.has(DataFactory.quad(s, p, oDefault)), true) - assert.equal(view.has(DataFactory.quad(s, p, oNamed)), true) - assert.equal(view.has(DataFactory.quad(s, p, oOther)), true) - assert.equal(view.has(DataFactory.quad(s, p, DataFactory.literal("missing"))), false) + assert.equal(view.has(DataFactory.quad(s, p, oDefault)), true) + assert.equal(view.has(DataFactory.quad(s, p, oNamed)), true) + assert.equal(view.has(DataFactory.quad(s, p, oOther)), true) + assert.equal(view.has(DataFactory.quad(s, p, DataFactory.literal("missing"))), false) }) await it("match returns a union view filtered by subject/predicate/object", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView - const matched = Array.from(view.match(s, p)) + const matched = Array.from(view.match(s, p, undefined, defaultGraph)) assert.equal(matched.length, 3) for (const quad of matched) { assert.equal(quad.graph.termType, "DefaultGraph") @@ -84,7 +87,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("match accepts an explicit default graph argument", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView const matched = Array.from(view.match(undefined, undefined, undefined, DataFactory.defaultGraph())) assert.equal(matched.length, 3) @@ -92,10 +95,10 @@ await describe("GraphScopedDataset (union)", async () => { await it("add inserts into the configured named graph", () => { const store = multiGraphStore() - const view = new SomeDataset(store, DataFactory).unionView + const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView const newObject = DataFactory.literal("added") - view.add(DataFactory.quad(s, p, newObject)) + view.add(DataFactory.quad(s, p, newObject)) assert.equal(store.has(DataFactory.quad(s, p, newObject, graph)), true) assert.equal(store.has(DataFactory.quad(s, p, newObject)), false) @@ -104,53 +107,56 @@ await describe("GraphScopedDataset (union)", async () => { await it("delete only removes from the configured named graph", () => { const store = multiGraphStore() - const view = new SomeDataset(store, DataFactory).unionView + const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView // The triple exists only in `graph` so it should be removed. - view.delete(DataFactory.quad(s, p, oNamed)) + view.delete(DataFactory.quad(s, p, oNamed)) assert.equal(store.has(DataFactory.quad(s, p, oNamed, graph)), false) // The triple exists in the default graph and is left untouched. - view.delete(DataFactory.quad(s, p, oDefault)) + view.delete(DataFactory.quad(s, p, oDefault)) assert.equal(store.has(DataFactory.quad(s, p, oDefault)), true) // The triple exists in another named graph and is left untouched. - view.delete(DataFactory.quad(s, p, oOther)) + view.delete(DataFactory.quad(s, p, oOther)) assert.equal(store.has(DataFactory.quad(s, p, oOther, otherGraph)), true) }) await it("throws NamedGraphError when adding a quad with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView assert.throws( - () => view.add(DataFactory.quad(s, p, oDefault, otherGraph)), + // @ts-expect-error + () => view.add(DataFactory.quad(s, p, oDefault, otherGraph)), NamedGraphError, ) }) await it("throws NamedGraphError when deleting a quad with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView assert.throws( - () => view.delete(DataFactory.quad(s, p, oDefault, otherGraph)), + // @ts-expect-error + () => view.delete(DataFactory.quad(s, p, oDefault, otherGraph)), NamedGraphError, ) }) await it("throws NamedGraphError when checking has with a non-default graph quad", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView assert.throws( - () => view.has(DataFactory.quad(s, p, oDefault, otherGraph)), + // @ts-expect-error + () => view.has(DataFactory.quad(s, p, oDefault, otherGraph)), NamedGraphError, ) }) await it("throws TermTypeError when matching with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory).unionView + const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView assert.throws( - () => view.match(undefined, undefined, undefined, otherGraph), + () => view.match(undefined, undefined, undefined, otherGraph as any), TermTypeError, ) }) @@ -179,7 +185,7 @@ await describe("GraphScopedDataset (union) with TermWrapper", async () => { } await it("reads properties from any graph through TermWrapper", () => { - const root = new Root(modelStore(), DataFactory) + const root = new Root(modelStore(), DataFactory, n3StoreFactory) const parent = root.union.parent assert.equal(parent.hasString, "default value") @@ -188,7 +194,7 @@ await describe("GraphScopedDataset (union) with TermWrapper", async () => { await it("writes new properties into the configured named graph", () => { const store = modelStore() - const root = new Root(store, DataFactory) + const root = new Root(store, DataFactory, n3StoreFactory) const parent = root.union.parent parent.hasNullableString = "updated" diff --git a/test/unit/util/n3StoreFactory.ts b/test/unit/util/n3StoreFactory.ts new file mode 100644 index 0000000..0c7124a --- /dev/null +++ b/test/unit/util/n3StoreFactory.ts @@ -0,0 +1,26 @@ +import type { Quad } from "@rdfjs/types" +import { Store } from "n3" +import { + NotifyingDatasetCoreWrapper, + type NotifyingDatasetCore, + type NotifyingDatasetCoreFactory, +} from "@rdfjs/wrapper" + +/** + * Test-only {@link NotifyingDatasetCoreFactory} that produces datasets backed + * by [n3](https://www.npmjs.com/package/n3) {@link Store}s wrapped in a + * {@link NotifyingDatasetCoreWrapper}. + */ +export class N3StoreFactory implements NotifyingDatasetCoreFactory { + public dataset(quads?: Iterable): NotifyingDatasetCore { + const store = new Store() + if (quads) { + for (const q of quads) { + store.addQuad(q) + } + } + return new NotifyingDatasetCoreWrapper(store) + } +} + +export const n3StoreFactory: any = new N3StoreFactory() From a5e17ba1d2057a81290c8d9c1a24f67baaa0a0a9 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 05:32:33 +0100 Subject: [PATCH 09/19] Refactor tests to use a unified data factory and improve type handling - Introduced a new `dataFactory` utility to standardize the DataFactory usage across tests. - Updated all test files to replace instances of `DataFactory` with the new `dataFactory`. - Enhanced type safety by ensuring proper type casting for Triple and DatasetCore in various test cases. - Refactored dataset creation functions to return wrapped datasets for better integration with RDFJS types. - Improved clarity and maintainability of test code by reducing redundancy in dataset initialization. --- src/DatasetWrapper.ts | 24 +++---- src/ListItem.ts | 6 +- src/TermWrapper.ts | 16 +++-- src/WrappingMap.ts | 7 +- src/WrappingSet.ts | 11 +-- src/dataset/GraphScopedDataset.ts | 2 +- src/mapping/OptionalAs.ts | 3 +- src/mapping/OptionalFrom.ts | 4 +- src/mapping/RequiredFrom.ts | 4 +- src/type/IGraphScopedDatasetConstructor.ts | 2 +- src/type/ITermWrapperConstructor.ts | 6 +- test/unit/dataset_core_base.test.ts | 3 +- test/unit/dataset_wrapper.test.ts | 3 +- test/unit/literalAs.test.ts | 18 +++-- test/unit/named_graph.test.ts | 27 ++++---- test/unit/named_graph_integration.test.ts | 21 +++--- test/unit/rdf_list.test.ts | 79 +++++++++++----------- test/unit/term_wrapper.test.ts | 7 +- test/unit/union_graph.test.ts | 32 +++++---- test/unit/util/dataFactory.ts | 5 ++ test/unit/util/datasetFromRdf.ts | 6 +- test/unit/wrapping_map.test.ts | 27 ++++---- 22 files changed, 172 insertions(+), 141 deletions(-) create mode 100644 test/unit/util/dataFactory.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 83b0022..19c732c 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -17,18 +17,18 @@ import { TermTypeError } from "./errors/TermTypeError.js" * {@link match} ignores the graph dimension entirely; if a non-default-graph * argument is passed it throws a {@link TermTypeError}. */ -export interface DefaultDatasetCore extends DatasetCore, NotifyingDatasetCore { - match(subject?: Term, predicate?: Term, object?: Term, graph?: DefaultGraph): DefaultDatasetCore; +export interface DefaultDatasetCore extends DatasetCore, NotifyingDatasetCore { + match(subject: Triple['subject'] | undefined, predicate: Triple['predicate'] | undefined, object: Triple['object'] | undefined, graph: DefaultGraph): DefaultDatasetCore; } /** Factory type used by {@link DatasetWrapper} to materialize match results. */ export type DefaultDatasetCoreFactory = - NotifyingDatasetCoreFactory> + NotifyingDatasetCoreFactory export class DatasetWrapper implements DefaultDatasetCore { //#region DatasetCore - private readonly dataset: NotifyingDatasetCore + private readonly dataset: NotifyingDatasetCore /** * The factory used to materialize lazy match results. Subclasses receive @@ -41,11 +41,11 @@ export class DatasetWrapper implements DefaultDatasetCore { protected readonly datasetFactory: DefaultDatasetCoreFactory public constructor( - dataset: DatasetCore, - protected readonly factory: DataFactory, + dataset: DatasetCore, + protected readonly factory: DataFactory, datasetFactory: DefaultDatasetCoreFactory, ) { - this.dataset = ensureNotifyingDatasetCore(dataset) + this.dataset = ensureNotifyingDatasetCore(dataset) this.datasetFactory = datasetFactory } @@ -55,7 +55,7 @@ export class DatasetWrapper implements DefaultDatasetCore { return this.match(undefined, undefined, undefined, defaultGraph).size } - public [Symbol.iterator](): Iterator { + public [Symbol.iterator](): Iterator { return this.match(undefined, undefined, undefined, defaultGraph)[Symbol.iterator]() } @@ -78,14 +78,14 @@ export class DatasetWrapper implements DefaultDatasetCore { public match(subject: Triple['subject'] | undefined, predicate: Triple['predicate'] | undefined, object: Triple['object'] | undefined, graph: DefaultGraph): DefaultDatasetCore { ensureTermType(graph, "DefaultGraph") - return this.dataset.match(subject, predicate, object, defaultGraph) as DefaultDatasetCore + return this.dataset.match(subject, predicate, object, defaultGraph) } - public on(...args: Parameters): void { - this.dataset.on(...args) + public on(listener: Parameters[0]): void { + this.dataset.on(listener) } - public off(...args: Parameters): void { + public off(...args: Parameters): void { this.dataset.off(...args) } diff --git a/src/ListItem.ts b/src/ListItem.ts index b7aa75b..43faa8b 100644 --- a/src/ListItem.ts +++ b/src/ListItem.ts @@ -1,5 +1,7 @@ import { TermWrapper } from "./TermWrapper.js" -import type { DataFactory, DatasetCore, Term } from "@rdfjs/types" +import type { DataFactory, Term } from "@rdfjs/types" +import type { Triple } from "./type/ITriple.js" +import type { DefaultDatasetCore } from "./DatasetWrapper.js" import { RDF } from "./vocabulary/RDF.js" import type { ITermAsValueMapping } from "./type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "./type/ITermFromValueMapping.js" @@ -11,7 +13,7 @@ import { OptionalAs } from "./mapping/OptionalAs.js" import { RequiredAs } from "./mapping/RequiredAs.js" export class ListItem extends TermWrapper { - constructor(term: Term, dataset: DatasetCore, factory: DataFactory, private readonly termAs: ITermAsValueMapping, private readonly termFrom: ITermFromValueMapping) { + constructor(term: Term, dataset: DefaultDatasetCore, factory: DataFactory, private readonly termAs: ITermAsValueMapping, private readonly termFrom: ITermFromValueMapping) { super(term, dataset, factory) } diff --git a/src/TermWrapper.ts b/src/TermWrapper.ts index 192274f..5afbd34 100644 --- a/src/TermWrapper.ts +++ b/src/TermWrapper.ts @@ -1,5 +1,7 @@ import type { BaseQuad, DataFactory, DatasetCore, Literal, NamedNode, Quad_Subject, Term } from "@rdfjs/types" import type { IRdfJsTerm } from "./type/IRdfJsTerm.js" +import { DefaultDatasetCore } from "./DatasetWrapper.js" +import { Triple } from "./mod.js" /** * `TermWrapper` is one of the two central constructs of this library. It is the base class of all models that represent a mapping from RDF to JavaScript. It _is_ an {@link Term | RDF/JS term} (or node) that also has a reference to both the dataset (or graph) that is the context of (i.e. contains) the term and to a factory that can be used to create additional terms. @@ -71,8 +73,8 @@ import type { IRdfJsTerm } from "./type/IRdfJsTerm.js" */ export class TermWrapper implements IRdfJsTerm { private readonly original: Term - private readonly _dataset: DatasetCore - private readonly _factory: DataFactory + private readonly _dataset: DefaultDatasetCore + private readonly _factory: DataFactory /** * Creates a new instance of {@link TermWrapper}. @@ -81,7 +83,7 @@ export class TermWrapper implements IRdfJsTerm { * @param dataset The dataset that contains the term being wrapped. * @param factory A collection of methods for creating terms. */ - constructor(term: string, dataset: DatasetCore, factory: DataFactory) + constructor(term: string, dataset: DefaultDatasetCore, factory: DataFactory) /** * Creates a new instance of {@link TermWrapper}. @@ -90,9 +92,9 @@ export class TermWrapper implements IRdfJsTerm { * @param dataset The dataset that contains the term being wrapped. * @param factory A collection of methods for creating terms. */ - constructor(term: Term, dataset: DatasetCore, factory: DataFactory) + constructor(term: Term, dataset: DefaultDatasetCore, factory: DataFactory) - constructor(term: string | Term, dataset: DatasetCore, factory: DataFactory) { + constructor(term: string | Term, dataset: DefaultDatasetCore, factory: DataFactory) { this.original = typeof term === "string" ? factory.namedNode(term) : term this._dataset = dataset this._factory = factory @@ -141,7 +143,7 @@ export class TermWrapper implements IRdfJsTerm { * } * ``` */ - get dataset(): DatasetCore { + get dataset(): DefaultDatasetCore { return this._dataset } @@ -176,7 +178,7 @@ export class TermWrapper implements IRdfJsTerm { * } * ``` */ - get factory(): DataFactory { + get factory(): DataFactory { return this._factory } diff --git a/src/WrappingMap.ts b/src/WrappingMap.ts index 0c5f333..678b4b5 100644 --- a/src/WrappingMap.ts +++ b/src/WrappingMap.ts @@ -1,7 +1,8 @@ import { TermWrapper } from "./TermWrapper.js" import type { ITermAsValueMapping } from "./type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "./type/ITermFromValueMapping.js" -import type { Quad, Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { Quad_Object, Quad_Subject } from "@rdfjs/types" +import type { Triple } from "./type/ITriple.js" export class WrappingMap implements Map { constructor(private readonly subject: TermWrapper, private readonly predicate: string, private readonly termAs: ITermAsValueMapping<[TKey, TValue]>, private readonly termFrom: ITermFromValueMapping<[TKey, TValue]>) { @@ -96,10 +97,10 @@ export class WrappingMap implements Map { return this.constructor.name } - private get matches(): Iterable { + private get matches(): Iterable { const p = this.subject.factory.namedNode(this.predicate) - return this.subject.dataset.match(this.subject as Term, p, undefined, this.subject.factory.defaultGraph()) + return this.subject.dataset.match(this.subject as Quad_Subject, p, undefined, this.subject.factory.defaultGraph()) } private add(k: TKey, v: TValue) { diff --git a/src/WrappingSet.ts b/src/WrappingSet.ts index 151f64b..832d805 100644 --- a/src/WrappingSet.ts +++ b/src/WrappingSet.ts @@ -1,6 +1,7 @@ import type { ITermAsValueMapping } from "./type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "./type/ITermFromValueMapping.js" -import type { DatasetCore, Quad, Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { DatasetCore, Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { Triple } from "./type/ITriple.js" import { TermWrapper } from "./TermWrapper.js" export class WrappingSet implements Set { @@ -27,7 +28,7 @@ export class WrappingSet implements Set { const o = this.termFrom(value, this.subject.factory) // TODO: guards const p = this.subject.factory.namedNode(this.predicate) - for (const q of this.subject.dataset.match(this.subject as Term, p, o as Term, this.subject.factory.defaultGraph())) { + for (const q of this.subject.dataset.match(this.subject as Quad_Subject, p, o as Quad_Object, this.subject.factory.defaultGraph())) { this.subject.dataset.delete(q) } @@ -72,7 +73,7 @@ export class WrappingSet implements Set { return this.constructor.name } - private quad(value: T): Quad { + private quad(value: T): Triple { const s = this.subject as Quad_Subject // TODO: guard const p = this.subject.factory.namedNode(this.predicate) const o = this.termFrom(value, this.subject.factory) as Quad_Object // TODO: guards @@ -80,8 +81,8 @@ export class WrappingSet implements Set { return q } - private get matches(): DatasetCore { + private get matches(): DatasetCore { const p = this.subject.factory.namedNode(this.predicate) - return this.subject.dataset.match(this.subject as Term, p, undefined, this.subject.factory.defaultGraph()) + return this.subject.dataset.match(this.subject as Quad_Subject, p, undefined, this.subject.factory.defaultGraph()) } } diff --git a/src/dataset/GraphScopedDataset.ts b/src/dataset/GraphScopedDataset.ts index 342aa38..cb288a1 100644 --- a/src/dataset/GraphScopedDataset.ts +++ b/src/dataset/GraphScopedDataset.ts @@ -19,7 +19,7 @@ export class GraphScopedDataset extends DatasetWrapper { writeGraph: Quad_Graph, readGraphs: ReadonlyArray | undefined, dataset: NotifyingDatasetCore, - factory: DataFactory, + factory: DataFactory, datasetFactory: NotifyingDatasetCoreFactory>, ) { super(new ProjectedDatasetCoreWrapper(writeGraph, readGraphs, dataset, factory, datasetFactory), factory, datasetFactory) diff --git a/src/mapping/OptionalAs.ts b/src/mapping/OptionalAs.ts index 74b3b02..6f37c11 100644 --- a/src/mapping/OptionalAs.ts +++ b/src/mapping/OptionalAs.ts @@ -1,6 +1,7 @@ import { TermWrapper } from "../TermWrapper.js" import type { ITermFromValueMapping } from "../type/ITermFromValueMapping.js" import type { Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { Triple } from "../type/ITriple.js" export namespace OptionalAs { export function object(anchor: TermWrapper, p: string, value: T | undefined, termFrom: ITermFromValueMapping) { @@ -10,7 +11,7 @@ export namespace OptionalAs { const predicate = anchor.factory.namedNode(p) - for (const q of anchor.dataset.match(anchor as Term, predicate, undefined, anchor.factory.defaultGraph())) { + for (const q of anchor.dataset.match(anchor as Quad_Subject, predicate, undefined, anchor.factory.defaultGraph())) { anchor.dataset.delete(q) } diff --git a/src/mapping/OptionalFrom.ts b/src/mapping/OptionalFrom.ts index 811aa71..b9a4cc3 100644 --- a/src/mapping/OptionalFrom.ts +++ b/src/mapping/OptionalFrom.ts @@ -1,6 +1,6 @@ import { TermWrapper } from "../TermWrapper.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" -import type { Term } from "@rdfjs/types" +import type { Quad_Subject, Term } from "@rdfjs/types" export namespace OptionalFrom { export function subjectPredicate(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping): T | undefined { @@ -10,7 +10,7 @@ export namespace OptionalFrom { const predicate = anchor.factory.namedNode(p) - for (const q of anchor.dataset.match(anchor as Term, predicate, undefined, anchor.factory.defaultGraph())) { + for (const q of anchor.dataset.match(anchor as Quad_Subject, predicate, undefined, anchor.factory.defaultGraph())) { return termAs(new TermWrapper(q.object, anchor.dataset, anchor.factory)) } diff --git a/src/mapping/RequiredFrom.ts b/src/mapping/RequiredFrom.ts index 3bb8f62..a73215a 100644 --- a/src/mapping/RequiredFrom.ts +++ b/src/mapping/RequiredFrom.ts @@ -1,6 +1,6 @@ import { TermWrapper } from "../TermWrapper.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" -import type { Term } from "@rdfjs/types" +import type { Quad_Subject, Term } from "@rdfjs/types" export namespace RequiredFrom { export function subjectPredicate(anchor1: TermWrapper, p: string, termAs: ITermAsValueMapping): T { @@ -9,7 +9,7 @@ export namespace RequiredFrom { } const anchor2 = anchor1.factory.namedNode(p) - const matches = anchor1.dataset.match(anchor1 as Term, anchor2, undefined, anchor1.factory.defaultGraph())[Symbol.iterator]() + const matches = anchor1.dataset.match(anchor1 as Quad_Subject, anchor2, undefined, anchor1.factory.defaultGraph())[Symbol.iterator]() // TODO: Expose standard errors const {value: first, done: none} = matches.next() diff --git a/src/type/IGraphScopedDatasetConstructor.ts b/src/type/IGraphScopedDatasetConstructor.ts index ec5ee4f..e6d9a08 100644 --- a/src/type/IGraphScopedDatasetConstructor.ts +++ b/src/type/IGraphScopedDatasetConstructor.ts @@ -13,6 +13,6 @@ export type IGraphScopedDatasetConstructor = new ( writeGraph: Quad_Graph, readGraphs: ReadonlyArray | undefined, dataset: NotifyingDatasetCore, - factory: DataFactory, + factory: DataFactory, datasetFactory: NotifyingDatasetCoreFactory>, ) => T diff --git a/src/type/ITermWrapperConstructor.ts b/src/type/ITermWrapperConstructor.ts index 44abad9..5bae24a 100644 --- a/src/type/ITermWrapperConstructor.ts +++ b/src/type/ITermWrapperConstructor.ts @@ -1,4 +1,6 @@ -import type { DataFactory, DatasetCore, Term } from "@rdfjs/types" +import type { DataFactory, Term } from "@rdfjs/types" +import type { DefaultDatasetCore } from "../DatasetWrapper.js" +import type { Triple } from "./ITriple.js" /** * Represents the constructor signature of term mapping classes that extend {@link TermWrapper}. @@ -89,4 +91,4 @@ import type { DataFactory, DatasetCore, Term } from "@rdfjs/types" * - {@link DatasetWrapper.objectsOf} * - {@link TermAs.instance} */ -export type ITermWrapperConstructor = new (term: Term, dataset: DatasetCore, factory: DataFactory) => T +export type ITermWrapperConstructor = new (term: Term, dataset: DefaultDatasetCore, factory: DataFactory) => T diff --git a/test/unit/dataset_core_base.test.ts b/test/unit/dataset_core_base.test.ts index e4dba37..62febd2 100644 --- a/test/unit/dataset_core_base.test.ts +++ b/test/unit/dataset_core_base.test.ts @@ -1,3 +1,4 @@ +import { dataFactory } from "./util/dataFactory.js" import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory, Triple as N3Triple } from "n3" @@ -24,7 +25,7 @@ prefix : `; await describe("Dataset Core Bases", async () => { - const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory) + const parentDataset = new ParentDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory) const newQuad = DataFactory.quad(DataFactory.blankNode(), DataFactory.namedNode("x"), DataFactory.literal("x")) await it("get size", async () => { diff --git a/test/unit/dataset_wrapper.test.ts b/test/unit/dataset_wrapper.test.ts index 733a952..0eb4dfa 100644 --- a/test/unit/dataset_wrapper.test.ts +++ b/test/unit/dataset_wrapper.test.ts @@ -1,3 +1,4 @@ +import { dataFactory } from "./util/dataFactory.js" import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory } from "n3" @@ -31,7 +32,7 @@ prefix : await describe("Dataset Wrappers", async () => { - const parentDataset = new ParentDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory) + const parentDataset = new ParentDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory) await it("get instances of Parent as Parent", async () => { assert.equal(Array.from(parentDataset.instancesOfParent).length, 1) diff --git a/test/unit/literalAs.test.ts b/test/unit/literalAs.test.ts index a4588b5..5b68791 100644 --- a/test/unit/literalAs.test.ts +++ b/test/unit/literalAs.test.ts @@ -1,8 +1,12 @@ import { describe, it } from "node:test" -import { LiteralAs, LiteralDatatypeError, TermTypeError, TermWrapper } from "@rdfjs/wrapper" +import { LiteralAs, LiteralDatatypeError, NotifyingDatasetCoreWrapper, TermTypeError, TermWrapper, type DefaultDatasetCore, type Triple } from "@rdfjs/wrapper" import { DataFactory, Store } from "n3" +import type { DataFactory as IDataFactory } from "@rdfjs/types" import assert from "node:assert" +const factory = DataFactory as unknown as IDataFactory +const dataset = (): DefaultDatasetCore => new NotifyingDatasetCoreWrapper(new Store()) as unknown as DefaultDatasetCore + // TODO: Cover other methods in LiteralAS // TODO: Cover LiteralFrom await describe("LiteralAs", async () => { @@ -20,34 +24,34 @@ await describe("LiteralAs", async () => { }) await it("throws when not literal", async () => { - const wrapper = new TermWrapper(DataFactory.blankNode(), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.blankNode(), dataset(), factory) assert.throws(() => LiteralAs.uInt8Array(wrapper), TermTypeError) }) await it("throws when datatype mismatch", async () => { - const wrapper = new TermWrapper(DataFactory.literal("", DataFactory.namedNode("d")), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.literal("", DataFactory.namedNode("d")), dataset(), factory) assert.throws(() => LiteralAs.uInt8Array(wrapper), LiteralDatatypeError) }) // TODO: Enable when Node 25 await it("throws when illegal base64", {skip: "Browser functionality with Uint8Array.fromBase64"}, async () => { - const wrapper = new TermWrapper(DataFactory.literal("X", DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#base64Binary")), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.literal("X", DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#base64Binary")), dataset(), factory) assert.throws(() => LiteralAs.uInt8Array(wrapper), SyntaxError) }) // TODO: Enable when Node 25 await it("throws when illegal hex", {skip: "Browser functionality with Uint8Array.fromHex"}, async () => { - const wrapper = new TermWrapper(DataFactory.literal("X", DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#hexBinary")), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.literal("X", DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#hexBinary")), dataset(), factory) assert.throws(() => LiteralAs.uInt8Array(wrapper), SyntaxError) }) await it("converts base64", async () => { const encoded = "MDEyMzQ1Njc4OQ==" - const wrapper = new TermWrapper(DataFactory.literal(encoded, DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#base64Binary")), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.literal(encoded, DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#base64Binary")), dataset(), factory) const bytes = Uint8Array.from(Buffer.from(encoded, "base64")) assert.deepStrictEqual(LiteralAs.uInt8Array(wrapper), bytes) @@ -55,7 +59,7 @@ await describe("LiteralAs", async () => { await it("converts hex", async () => { const encoded = "30313233343536373839" - const wrapper = new TermWrapper(DataFactory.literal(encoded, DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#hexBinary")), new Store(), DataFactory) + const wrapper = new TermWrapper(DataFactory.literal(encoded, DataFactory.namedNode("http://www.w3.org/2001/XMLSchema#hexBinary")), dataset(), factory) const bytes = Uint8Array.from(Buffer.from(encoded, "hex")) assert.deepStrictEqual(LiteralAs.uInt8Array(wrapper), bytes) diff --git a/test/unit/named_graph.test.ts b/test/unit/named_graph.test.ts index 66092e0..8f078f2 100644 --- a/test/unit/named_graph.test.ts +++ b/test/unit/named_graph.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert" import { describe, it } from "node:test" import { Triple as N3Triple, DataFactory, Store } from "n3" import { DatasetWrapper, defaultGraph, GraphScopedDataset, NamedGraphError, TermTypeError, Triple } from "@rdfjs/wrapper" +import type { DataFactory as IDataFactory, DatasetCore } from "@rdfjs/types" import { n3StoreFactory } from "./util/n3StoreFactory.js" +const factory = DataFactory as unknown as IDataFactory +const asTripleStore = (store: Store) => store as unknown as DatasetCore const graph = DataFactory.namedNode("https://example.org/graph") const s = DataFactory.namedNode("https://example.org/s") const p = DataFactory.namedNode("https://example.org/p") @@ -23,7 +26,7 @@ class SomeDataset extends DatasetWrapper { await describe("namedGraph", async () => { await it("exposes quads from the named graph as default graph quads", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph const quads = Array.from(ds) assert.equal(quads.length, 1) @@ -34,23 +37,23 @@ await describe("namedGraph", async () => { }) await it("reports correct size", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.equal(ds.size, 1) }) await it("has returns true for a matching default graph quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.equal(ds.has(DataFactory.quad(s, p, o)), true) }) await it("has returns false for a non-matching quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.equal(ds.has(DataFactory.quad(s, p, DataFactory.literal("nope"))), false) }) await it("add inserts into the named graph of the underlying dataset", () => { const store = storeWithNamedGraph() - const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(store), factory, n3StoreFactory).namedGraph const newObj = DataFactory.literal("new") ds.add(DataFactory.quad(s, p, newObj)) @@ -61,7 +64,7 @@ await describe("namedGraph", async () => { await it("delete removes from the named graph of the underlying dataset", () => { const store = storeWithNamedGraph() - const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(store), factory, n3StoreFactory).namedGraph ds.delete(DataFactory.quad(s, p, o)) @@ -75,7 +78,7 @@ await describe("namedGraph", async () => { store.addQuad(DataFactory.quad(s, p, o, graph)) store.addQuad(DataFactory.quad(s, p2, DataFactory.literal("other"), graph)) - const ds = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(store), factory, n3StoreFactory).namedGraph const matched = Array.from(ds.match(undefined, p2, undefined, defaultGraph)) assert.equal(matched.length, 1) @@ -84,14 +87,14 @@ await describe("namedGraph", async () => { }) await it("match with DefaultGraph argument works", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph const matched = Array.from(ds.match(undefined, undefined, undefined, DataFactory.defaultGraph())) assert.equal(matched.length, 1) }) await it("throws NamedGraphError when adding a quad with a named graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.throws( // @ts-expect-error @@ -101,7 +104,7 @@ await describe("namedGraph", async () => { }) await it("throws NamedGraphError when deleting a quad with a named graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.throws( // @ts-expect-error @@ -111,7 +114,7 @@ await describe("namedGraph", async () => { }) await it("throws NamedGraphError when checking has with a named graph quad", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.throws( // @ts-expect-error @@ -121,7 +124,7 @@ await describe("namedGraph", async () => { }) await it("throws TermTypeError when matching with a non-default graph", () => { - const ds = new SomeDataset(storeWithNamedGraph(), DataFactory, n3StoreFactory).namedGraph + const ds = new SomeDataset(asTripleStore(storeWithNamedGraph()), factory, n3StoreFactory).namedGraph assert.throws( () => ds.match(undefined, undefined, undefined, DataFactory.namedNode("https://other.org/g") as any), diff --git a/test/unit/named_graph_integration.test.ts b/test/unit/named_graph_integration.test.ts index efb8107..b28f89b 100644 --- a/test/unit/named_graph_integration.test.ts +++ b/test/unit/named_graph_integration.test.ts @@ -1,10 +1,11 @@ +import { dataFactory } from "./util/dataFactory.js" import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory } from "n3" import { Parent } from "./model/Parent.js" import { ParentDataset } from "./model/ParentDataset.js" import { Example } from "./vocabulary/Example.js" -import { DatasetWrapper, GraphScopedDataset } from "@rdfjs/wrapper" +import { DatasetWrapper, GraphScopedDataset, type Triple } from "@rdfjs/wrapper" import { datasetFromRdf } from "./util/datasetFromRdf.js" import { n3StoreFactory } from "./util/n3StoreFactory.js" @@ -38,14 +39,14 @@ class SomeNamedDataset extends GraphScopedDataset { await describe("namedGraph with TermWrapper", async () => { await it("reads properties from the named graph via TermWrapper", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasString, "graph string") }) await it("does not see data from other graphs", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! // The value should be the one from the named graph, not the default graph @@ -54,7 +55,7 @@ await describe("namedGraph with TermWrapper", async () => { }) await it("navigates child objects within the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasChild.hasString, "graph child string") @@ -62,7 +63,7 @@ await describe("namedGraph with TermWrapper", async () => { await it("writes properties back into the named graph", () => { const store = datasetFromRdf(rdf) - const view = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(store, dataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! parent.hasString = "updated" @@ -74,12 +75,12 @@ await describe("namedGraph with TermWrapper", async () => { DataFactory.namedNode(Example.hasString), DataFactory.literal("updated"), DataFactory.namedNode("https://example.org/graph"), - )), true) + ) as unknown as Triple), true) }) await it("sets nullable properties through the named graph view", () => { const store = datasetFromRdf(rdf) - const view = new SomeDataset(store, DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(store, dataFactory, n3StoreFactory).namedGraph const parent = [...view.parents][0]! assert.equal(parent.hasNullableString, undefined) @@ -94,8 +95,8 @@ await describe("namedGraph with TermWrapper", async () => { await describe("namedGraph with DatasetWrapper", async () => { await it("finds instances within the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph - const parentDataset = new ParentDataset(view, DataFactory, n3StoreFactory) + const view = new SomeDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory).namedGraph + const parentDataset = new ParentDataset(view, dataFactory, n3StoreFactory) const parents = Array.from(parentDataset.instancesOfParent) assert.equal(parents.length, 1) @@ -103,7 +104,7 @@ await describe("namedGraph with DatasetWrapper", async () => { }) await it("iterates only quads from the named graph", () => { - const view = new SomeDataset(datasetFromRdf(rdf), DataFactory, n3StoreFactory).namedGraph + const view = new SomeDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory).namedGraph const quads = Array.from(view) // Named graph has 4 quads, default graph has 1 — should only see 4 diff --git a/test/unit/rdf_list.test.ts b/test/unit/rdf_list.test.ts index 2aa4d0f..2cc04f2 100644 --- a/test/unit/rdf_list.test.ts +++ b/test/unit/rdf_list.test.ts @@ -1,3 +1,4 @@ +import { dataFactory } from "./util/dataFactory.js" import { describe, it } from "node:test" import { DataFactory } from "n3" import { datasetFromRdf } from "./util/datasetFromRdf.js" @@ -14,7 +15,7 @@ await describe("RDF List", async () => { await describe("not implemented", async () => { await it("copyWithin", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.copyWithin(undefined!, undefined!) @@ -23,7 +24,7 @@ await describe("RDF List", async () => { await it("fill", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.fill(undefined!) @@ -32,7 +33,7 @@ await describe("RDF List", async () => { await it("flat", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.flat() @@ -41,7 +42,7 @@ await describe("RDF List", async () => { await it("reverse", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.reverse() @@ -50,7 +51,7 @@ await describe("RDF List", async () => { await it("sort", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.sort() @@ -59,7 +60,7 @@ await describe("RDF List", async () => { await it("splice", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { wrapper.list.splice(undefined!) @@ -70,35 +71,35 @@ await describe("RDF List", async () => { await describe("general", async () => { await it("not list throws", async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => wrapper.list, ListRootError) }) await it("empty", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.deepStrictEqual([...wrapper.list], []) }) await it("one item", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.deepStrictEqual([...wrapper.list], ["o1"]) }) await it("two items", async () => { const rdf = `

( "o1" "o2" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.deepStrictEqual([...wrapper.list], ["o1", "o2"]) }) await it("[Symbol.unscopables]", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.deepStrictEqual(wrapper.list[Symbol.unscopables], [][Symbol.unscopables]) }) @@ -107,21 +108,21 @@ await describe("RDF List", async () => { await describe("length", async () => { await it("empty is zero", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.strictEqual(wrapper.list.length, 0) }) await it("one is one", async () => { const rdf = `

( "o" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.strictEqual(wrapper.list.length, 1) }) await it("set not supported", async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => wrapper.list.length = undefined!) }) @@ -130,7 +131,7 @@ await describe("RDF List", async () => { await describe("pop", async () => { await it("empty undefined", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const actual = wrapper.list.pop() @@ -139,7 +140,7 @@ await describe("RDF List", async () => { await it("one returns last", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const actual = wrapper.list.pop() @@ -148,7 +149,7 @@ await describe("RDF List", async () => { await it("one removes last", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.pop() assert.deepStrictEqual([...wrapper.list], []) @@ -156,7 +157,7 @@ await describe("RDF List", async () => { await it("two returns last", async () => { const rdf = `

( "o1" "o2" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const popped = wrapper.list.pop() assert.strictEqual(popped, "o2") @@ -164,7 +165,7 @@ await describe("RDF List", async () => { await it("two removes last", async () => { const rdf = `

( "o1" "o2" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.pop() assert.deepStrictEqual([...wrapper.list], ["o1"]) @@ -174,14 +175,14 @@ await describe("RDF List", async () => { await describe("push", async () => { await it("not list", {skip: "not implemented yet"}, async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.push("o1") }) await it("empty returns new length", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const pushed = wrapper.list.push("o1") @@ -190,7 +191,7 @@ await describe("RDF List", async () => { await it("empty grows", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.push("o1") @@ -199,7 +200,7 @@ await describe("RDF List", async () => { await it("one returns new length", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const pushed = wrapper.list.push("o2") @@ -208,7 +209,7 @@ await describe("RDF List", async () => { await it("two returns new length", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const pushed = wrapper.list.push("o2", "o3") @@ -217,7 +218,7 @@ await describe("RDF List", async () => { await it("one grows", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.push("o2") @@ -226,7 +227,7 @@ await describe("RDF List", async () => { await it("two returns two", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.push("o2", "o3") @@ -237,14 +238,14 @@ await describe("RDF List", async () => { await describe("shift", async () => { await it("not list", {skip: "not implemented yet"}, async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.shift() }) await it("empty undefined", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const shifted = wrapper.list.shift() @@ -253,7 +254,7 @@ await describe("RDF List", async () => { await it("one returns first", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const shifted = wrapper.list.shift() @@ -262,7 +263,7 @@ await describe("RDF List", async () => { await it("one shrinks", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.shift() @@ -271,7 +272,7 @@ await describe("RDF List", async () => { await it("two returns first", async () => { const rdf = `

( "o1" "o2" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const shifted = wrapper.list.shift() @@ -280,7 +281,7 @@ await describe("RDF List", async () => { await it("two shrinks", async () => { const rdf = `

( "o1" "o2" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.shift() @@ -291,7 +292,7 @@ await describe("RDF List", async () => { await describe("unshift", async () => { await it("not list throws", {skip: "not implemented yet"}, async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { return wrapper.list.unshift("o1"); @@ -300,7 +301,7 @@ await describe("RDF List", async () => { await it("empty returns new length", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const unshifted = wrapper.list.unshift("o1") @@ -309,7 +310,7 @@ await describe("RDF List", async () => { await it("empty grows", async () => { const rdf = `

() .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.unshift("o1") @@ -318,7 +319,7 @@ await describe("RDF List", async () => { await it("one returns new length", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const unshifted = wrapper.list.unshift("o2") @@ -327,7 +328,7 @@ await describe("RDF List", async () => { await it("two returns new length", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) const unshifted = wrapper.list.unshift("o2", "o3") @@ -336,7 +337,7 @@ await describe("RDF List", async () => { await it("one grows", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.unshift("o2") @@ -345,7 +346,7 @@ await describe("RDF List", async () => { await it("two grows", async () => { const rdf = `

( "o1" ) .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) wrapper.list.unshift("o2", "o3") diff --git a/test/unit/term_wrapper.test.ts b/test/unit/term_wrapper.test.ts index bc67f5c..65ebc3e 100644 --- a/test/unit/term_wrapper.test.ts +++ b/test/unit/term_wrapper.test.ts @@ -1,3 +1,4 @@ +import { dataFactory } from "./util/dataFactory.js" import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory } from "n3" @@ -36,7 +37,7 @@ prefix xsd: await describe("Term Wrapper", async () => { const dataset = datasetFromRdf(rdf) - const parent = new Parent("x", dataset, DataFactory) + const parent = new Parent("x", dataset, dataFactory) await describe("Value Mapping", async () => { await it("get blank node to string", async () => { @@ -114,7 +115,7 @@ await describe("Term Wrapper", async () => { }) await it("set wrapped term", async () => { - const newChild = new Child(DataFactory.blankNode(), dataset, DataFactory) + const newChild = new Child(DataFactory.blankNode(), dataset, dataFactory) newChild.hasString = "child string 4" parent.hasChild = newChild assert.equal(parent.hasChild.hasString, "child string 4") @@ -176,7 +177,7 @@ await describe("Term Wrapper", async () => { }) await it("add to set of wrapped terms", async () => { - const newChild = new Child(DataFactory.blankNode(), dataset, DataFactory) + const newChild = new Child(DataFactory.blankNode(), dataset, dataFactory) newChild.hasString = "child string 5" parent.hasChildSet.add(newChild) assert.equal(parent.hasChildSet.size, 3) diff --git a/test/unit/union_graph.test.ts b/test/unit/union_graph.test.ts index e70dabc..ac5f941 100644 --- a/test/unit/union_graph.test.ts +++ b/test/unit/union_graph.test.ts @@ -1,6 +1,8 @@ +import { dataFactory } from "./util/dataFactory.js" import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory, Store, type Triple as N3Triple } from "n3" +import type { DatasetCore } from "@rdfjs/types" import { DatasetWrapper, defaultGraph, @@ -13,6 +15,8 @@ import { Parent } from "./model/Parent.js" import { Example } from "./vocabulary/Example.js" import { n3StoreFactory } from "./util/n3StoreFactory.js" +const asTripleStore = (store: T): T & DatasetCore => store as unknown as T & DatasetCore + const graph = DataFactory.namedNode("https://example.org/graph") const otherGraph = DataFactory.namedNode("https://example.org/other") const s = DataFactory.namedNode("https://example.org/s") @@ -37,7 +41,7 @@ class SomeDataset extends DatasetWrapper { await describe("GraphScopedDataset (union)", async () => { await it("iterates quads from all graphs projected to the default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView const quads = Array.from(view) @@ -55,20 +59,20 @@ await describe("GraphScopedDataset (union)", async () => { store.addQuad(DataFactory.quad(s, p, oDefault, graph)) store.addQuad(DataFactory.quad(s, p, oDefault, otherGraph)) - const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(store), dataFactory, n3StoreFactory).unionView assert.equal(view.size, 1) assert.equal(Array.from(view).length, 1) }) await it("size reflects unique triples across all graphs", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.equal(view.size, 3) }) await it("has finds triples regardless of source graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.equal(view.has(DataFactory.quad(s, p, oDefault)), true) assert.equal(view.has(DataFactory.quad(s, p, oNamed)), true) @@ -77,7 +81,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("match returns a union view filtered by subject/predicate/object", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView const matched = Array.from(view.match(s, p, undefined, defaultGraph)) assert.equal(matched.length, 3) @@ -87,7 +91,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("match accepts an explicit default graph argument", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView const matched = Array.from(view.match(undefined, undefined, undefined, DataFactory.defaultGraph())) assert.equal(matched.length, 3) @@ -95,7 +99,7 @@ await describe("GraphScopedDataset (union)", async () => { await it("add inserts into the configured named graph", () => { const store = multiGraphStore() - const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(store), dataFactory, n3StoreFactory).unionView const newObject = DataFactory.literal("added") view.add(DataFactory.quad(s, p, newObject)) @@ -107,7 +111,7 @@ await describe("GraphScopedDataset (union)", async () => { await it("delete only removes from the configured named graph", () => { const store = multiGraphStore() - const view = new SomeDataset(store, DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(store), dataFactory, n3StoreFactory).unionView // The triple exists only in `graph` so it should be removed. view.delete(DataFactory.quad(s, p, oNamed)) @@ -123,7 +127,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("throws NamedGraphError when adding a quad with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.throws( // @ts-expect-error @@ -133,7 +137,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("throws NamedGraphError when deleting a quad with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.throws( // @ts-expect-error @@ -143,7 +147,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("throws NamedGraphError when checking has with a non-default graph quad", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.throws( // @ts-expect-error @@ -153,7 +157,7 @@ await describe("GraphScopedDataset (union)", async () => { }) await it("throws TermTypeError when matching with a non-default graph", () => { - const view = new SomeDataset(multiGraphStore(), DataFactory, n3StoreFactory).unionView + const view = new SomeDataset(asTripleStore(multiGraphStore()), dataFactory, n3StoreFactory).unionView assert.throws( () => view.match(undefined, undefined, undefined, otherGraph as any), @@ -185,7 +189,7 @@ await describe("GraphScopedDataset (union) with TermWrapper", async () => { } await it("reads properties from any graph through TermWrapper", () => { - const root = new Root(modelStore(), DataFactory, n3StoreFactory) + const root = new Root(asTripleStore(modelStore()), dataFactory, n3StoreFactory) const parent = root.union.parent assert.equal(parent.hasString, "default value") @@ -194,7 +198,7 @@ await describe("GraphScopedDataset (union) with TermWrapper", async () => { await it("writes new properties into the configured named graph", () => { const store = modelStore() - const root = new Root(store, DataFactory, n3StoreFactory) + const root = new Root(asTripleStore(store), dataFactory, n3StoreFactory) const parent = root.union.parent parent.hasNullableString = "updated" diff --git a/test/unit/util/dataFactory.ts b/test/unit/util/dataFactory.ts new file mode 100644 index 0000000..e7abcad --- /dev/null +++ b/test/unit/util/dataFactory.ts @@ -0,0 +1,5 @@ +import type { DataFactory } from "@rdfjs/types" +import { DataFactory as N3DataFactory } from "n3" +import type { Triple } from "@rdfjs/wrapper" + +export const dataFactory: DataFactory = N3DataFactory as unknown as DataFactory diff --git a/test/unit/util/datasetFromRdf.ts b/test/unit/util/datasetFromRdf.ts index f8197d0..44149df 100644 --- a/test/unit/util/datasetFromRdf.ts +++ b/test/unit/util/datasetFromRdf.ts @@ -1,9 +1,9 @@ -import type { DatasetCore } from "@rdfjs/types"; +import { NotifyingDatasetCoreWrapper, type DefaultDatasetCore } from "@rdfjs/wrapper"; import { Parser, Store } from "n3" -export function datasetFromRdf(rdf: string): DatasetCore { +export function datasetFromRdf(rdf: string): DefaultDatasetCore { const store = new Store() store.addQuads(new Parser().parse(rdf)); - return store + return new NotifyingDatasetCoreWrapper(store) as unknown as DefaultDatasetCore } diff --git a/test/unit/wrapping_map.test.ts b/test/unit/wrapping_map.test.ts index 7d7ec6b..b7a459f 100644 --- a/test/unit/wrapping_map.test.ts +++ b/test/unit/wrapping_map.test.ts @@ -1,3 +1,4 @@ +import { dataFactory } from "./util/dataFactory.js" import { describe, it } from "node:test" import { LiteralAs, LiteralFrom, Mapping, TermWrapper } from "@rdfjs/wrapper" import { DataFactory } from "n3" @@ -14,14 +15,14 @@ await describe("Wrapping map", async () => { await describe("size", async () => { await it("get", async () => { const rdf = `

"o1"@en, "o2"@hu, "o3"@he .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.strictEqual(wrapper.dict.size, 3) }) await it("set not supported", async () => { const rdf = `

.` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.throws(() => { (wrapper.dict as any)["size"] = undefined! @@ -32,7 +33,7 @@ await describe("Wrapping map", async () => { await describe("general", async () => { await it("get", async () => { const rdf = `

"o1"@en, "o2"@fr .` - const wrapper = new Wrapper("s", datasetFromRdf(rdf), DataFactory) + const wrapper = new Wrapper("s", datasetFromRdf(rdf), dataFactory) assert.strictEqual(wrapper.dict.get("en"), "o1") assert.strictEqual(wrapper.dict.get("fr"), "o2") @@ -41,7 +42,7 @@ await describe("Wrapping map", async () => { await it("clear", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) wrapper.dict.clear() @@ -51,7 +52,7 @@ await describe("Wrapping map", async () => { await it("delete reports positive", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) assert.strictEqual(wrapper.dict.delete("en"), true) }) @@ -59,7 +60,7 @@ await describe("Wrapping map", async () => { await it("delete reports negative", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) assert.strictEqual(wrapper.dict.delete("XX"), false) }) @@ -67,7 +68,7 @@ await describe("Wrapping map", async () => { await it("delete deletes", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) wrapper.dict.delete("en") @@ -77,7 +78,7 @@ await describe("Wrapping map", async () => { await it("delete reports negative", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) wrapper.dict.delete("XX") @@ -88,7 +89,7 @@ await describe("Wrapping map", async () => { await it("forEach", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) const actual = {} as any wrapper.dict.forEach((value, key) => { @@ -101,7 +102,7 @@ await describe("Wrapping map", async () => { await it("set", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) wrapper.dict.set("en", "ox") @@ -111,7 +112,7 @@ await describe("Wrapping map", async () => { await it("keys", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) assert.deepStrictEqual([...wrapper.dict.keys()], ["en", "fr"]) }) @@ -119,7 +120,7 @@ await describe("Wrapping map", async () => { await it("values", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) assert.deepStrictEqual([...wrapper.dict.values()], ["o1", "o2"]) }) @@ -127,7 +128,7 @@ await describe("Wrapping map", async () => { await it("toStringTag", async () => { const rdf = `

"o1"@en, "o2"@fr .` const dataset = datasetFromRdf(rdf) - const wrapper = new Wrapper("s", dataset, DataFactory) + const wrapper = new Wrapper("s", dataset, dataFactory) assert.strictEqual(wrapper.dict.toString(), "[object WrappingMap]") }) From 582b131ff60263fb6cca53a212777937b7c9a2f5 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 05:59:29 +0100 Subject: [PATCH 10/19] feat: enhance event handling in DatasetWrapper and WrappingSet with detailed listener functionality and tests --- src/DatasetWrapper.ts | 32 +++ src/WrappingSet.ts | 110 +++++++++ src/mapping/SetFrom.ts | 2 +- src/mod.ts | 2 + test/unit/dataset_events.test.ts | 222 +++++++++++++++++ test/unit/dataset_events_examples.test.ts | 283 ++++++++++++++++++++++ 6 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 test/unit/dataset_events.test.ts create mode 100644 test/unit/dataset_events_examples.test.ts diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 19c732c..48ee791 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -81,10 +81,42 @@ export class DatasetWrapper implements DefaultDatasetCore { return this.dataset.match(subject, predicate, object, defaultGraph) } + /** + * Subscribes `listener` to be invoked whenever a quad is added to or + * removed from the underlying dataset. + * + * Events are emitted for every mutation, regardless of how the mutation + * was performed: direct calls to {@link add} / {@link delete}, mutating a + * mapped property on a {@link TermWrapper}, mutating a {@link WrappingSet} + * returned by a {@link SetFrom} mapping, or mutating a wrapper-managed + * {@link RdfList}. Setters that "change" a value emit a `delete` for the + * previous quad followed by an `add` for the new quad; clearing an + * optional value emits only `delete`. + * + * Listeners receive the mutation type (`'add'` or `'delete'`) and the + * affected quad. The wrapper does **not** deduplicate: setting a property + * to its current value still emits a delete and an add. Use {@link off} + * to detach a previously attached listener. + * + * @example Observing wrapper-driven mutations + * ```ts + * const events: string[] = [] + * dataset.on((event, quad) => events.push(`${event}:${quad.object.value}`)) + * + * parent.hasString = "new" // events: ["delete:old", "add:new"] + * parent.hasNullableString = undefined // events: ["delete:..."] + * dataset.add(quad) // events: ["add:..."] + * ``` + */ public on(listener: Parameters[0]): void { this.dataset.on(listener) } + /** + * Detaches a listener previously attached with {@link on}. The listener + * reference must be the same function that was passed to {@link on}; + * detaching an unknown listener is a no-op. + */ public off(...args: Parameters): void { this.dataset.off(...args) } diff --git a/src/WrappingSet.ts b/src/WrappingSet.ts index 832d805..f29502e 100644 --- a/src/WrappingSet.ts +++ b/src/WrappingSet.ts @@ -2,10 +2,35 @@ import type { ITermAsValueMapping } from "./type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "./type/ITermFromValueMapping.js" import type { DatasetCore, Quad_Object, Quad_Subject, Term } from "@rdfjs/types" import type { Triple } from "./type/ITriple.js" +import type { ChangeEvent } from "./dataset/NotifyingDatasetCore.js" import { TermWrapper } from "./TermWrapper.js" +/** + * Listener invoked when a value is added to or removed from a + * {@link WrappingSet}. The mutation type (`'add'` or `'delete'`) is + * supplied alongside the mapped JavaScript value. + */ +export type WrappingSetListener = (event: ChangeEvent, value: T) => void + +/** + * Registry of dataset-level adapters created by {@link WrappingSet.on}, + * keyed by the user listener so {@link WrappingSet.off} can detach the + * correct adapter even when called on a different {@link WrappingSet} + * instance that targets the same subject and predicate (which is the + * common case, because mappers like {@link SetFrom} construct a fresh + * {@link WrappingSet} on each property access). + * + * Inner key: `\u0000`. The literal NUL byte + * is used as a separator because it cannot appear in IRIs. + */ +const listenerAdapters = new WeakMap< + WrappingSetListener, + Map void> +>() + export class WrappingSet implements Set { // TODO: Direction + public constructor(private readonly subject: TermWrapper, private readonly predicate: string, private readonly termAs: ITermAsValueMapping, private readonly termFrom: ITermFromValueMapping) { } @@ -85,4 +110,89 @@ export class WrappingSet implements Set { const p = this.subject.factory.namedNode(this.predicate) return this.subject.dataset.match(this.subject as Quad_Subject, p, undefined, this.subject.factory.defaultGraph()) } + + /** + * Subscribes `listener` to additions and removals on this set. + * + * Internally this filters the underlying dataset's change stream for + * quads whose subject and predicate match this set, projects them to + * the mapped JavaScript value via the configured `termAs` mapping, and + * forwards the result to `listener`. Mutations performed through any + * other route (direct {@link DatasetWrapper.add} / {@link DatasetWrapper.delete} + * calls, sibling wrappers, etc.) are still observed, provided they + * affect this set's subject/predicate slot. + * + * The same `listener` may safely be passed to {@link off} on any + * {@link WrappingSet} that targets the same subject and predicate. + * This is important because mappers such as {@link SetFrom} typically + * produce a fresh {@link WrappingSet} on every property access. + */ + public on(listener: WrappingSetListener): void { + const subject = this.subject as Quad_Subject + const predicate = this.subject.factory.namedNode(this.predicate) + const dataset = this.subject.dataset + const factory = this.subject.factory + const termAs = this.termAs + const key = this.adapterKey + + const adapter = (event: ChangeEvent, q: Triple): void => { + if (!q.subject.equals(subject) || !q.predicate.equals(predicate)) { + return + } + if (q.graph.termType !== "DefaultGraph") { + return + } + listener(event, termAs(new TermWrapper(q.object, dataset, factory))) + } + + let perKey = listenerAdapters.get(listener) + if (perKey === undefined) { + perKey = new Map() + listenerAdapters.set(listener, perKey) + } + + // If the same listener was already attached to a sibling + // WrappingSet for this same (subject, predicate), detach the old + // adapter first so we don't accumulate duplicate dataset listeners. + const existing = perKey.get(key) + if (existing !== undefined) { + dataset.off(existing) + } + + perKey.set(key, adapter) + dataset.on(adapter) + } + + /** + * Detaches a listener previously attached with {@link on}. The same + * function reference must be supplied; unknown listeners are ignored. + * It is safe to call this on a different {@link WrappingSet} instance + * than the one used for {@link on}, as long as both target the same + * subject and predicate. + */ + public off(listener: WrappingSetListener): void { + const perKey = listenerAdapters.get(listener) + if (perKey === undefined) { + return + } + const key = this.adapterKey + const adapter = perKey.get(key) + if (adapter === undefined) { + return + } + perKey.delete(key) + if (perKey.size === 0) { + listenerAdapters.delete(listener) + } + this.subject.dataset.off(adapter) + } + + /** + * Stable identity for this set's (subject, predicate) pair, used as + * the inner key into {@link listenerAdapters}. The NUL separator + * cannot appear in an IRI, so the key is unambiguous. + */ + private get adapterKey(): string { + return `${(this.subject as Term).value}\u0000${this.predicate}` + } } diff --git a/src/mapping/SetFrom.ts b/src/mapping/SetFrom.ts index 9ba0e08..bbbab78 100644 --- a/src/mapping/SetFrom.ts +++ b/src/mapping/SetFrom.ts @@ -7,7 +7,7 @@ import { WrappingSet } from "../WrappingSet.js" * A collection of {@link ITermFromValueMapping | mappers} that expose RDF/JS graph patterns as mutable JavaScript {@link Set | sets}. */ export namespace SetFrom { - export function subjectPredicate(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping, termFrom: ITermFromValueMapping): Set { + export function subjectPredicate(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping, termFrom: ITermFromValueMapping): WrappingSet { if (termAs === undefined) { throw new Error // TODO: Describe } diff --git a/src/mod.ts b/src/mod.ts index 6c5b533..d3d793f 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -21,6 +21,8 @@ export * from "./mapping/RequiredAs.js" export * from "./DatasetWrapper.js" export * from "./TermWrapper.js" +export * from "./WrappingSet.js" +export * from "./EventEmitter.js" export * from "./dataset/NotifyingDatasetCore.js" export * from "./dataset/LazyMaterialize.js" export * from "./dataset/terms.js" diff --git a/test/unit/dataset_events.test.ts b/test/unit/dataset_events.test.ts new file mode 100644 index 0000000..42eff63 --- /dev/null +++ b/test/unit/dataset_events.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory, Triple as N3Triple } from "n3" +import { + type ChangeEvent, + type Triple, +} from "@rdfjs/wrapper" +import { dataFactory } from "./util/dataFactory.js" +import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" +import { ParentDataset } from "./model/ParentDataset.js" +import { Parent } from "./model/Parent.js" +import { Child } from "./model/Child.js" +import { Example } from "./vocabulary/Example.js" + +const rdf = ` +prefix : + + + a :Parent ; + :hasBlankNode "blankNodeIri" ; + :hasDate "2024-01-01T00:00:00.000Z"^^ ; + :hasLangString "hello"@en ; + :hasNumber "1.0E0"^^ ; + :hasBoolean "true"^^ ; + :hasString "o1" ; + :hasIri ; + :hasChild [ :hasString "child string 1" ] ; + :hasChildSet [ :hasString "set 1" ], [ :hasString "set 2" ] ; + :hasNullableString "nullable" ; +. +`; + +/** + * Subscribes a recorder to a {@link ParentDataset}'s change stream and + * returns the captured events plus a `stop` function. + * + * Events are recorded as `"::"` so the + * tests can assert exactly which quads were added or removed without + * caring about subjects (which are stable for a given anchor). + */ +function recordEvents(ds: ParentDataset): { events: string[], stop: () => void } { + const events: string[] = [] + const listener = (event: ChangeEvent, q: Triple) => { + events.push(`${event}:${q.predicate.value.replace("https://example.org/", "")}:${q.object.value}`) + } + ds.on(listener) + return { events, stop: () => ds.off(listener) } +} + +await describe("DatasetWrapper change notifications", async () => { + await it("emits an add event when a quad is added directly", () => { + const ds = new ParentDataset(datasetFromRdf(""), dataFactory, n3StoreFactory) + const { events, stop } = recordEvents(ds) + + ds.add(DataFactory.quad( + DataFactory.namedNode("https://example.org/x"), + DataFactory.namedNode(Example.hasString), + DataFactory.literal("added"), + )) + + assert.deepEqual(events, ["add:hasString:added"]) + stop() + }) + + await it("emits a delete event when a quad is removed directly", () => { + const ds = new ParentDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory) + const { events, stop } = recordEvents(ds) + + ds.delete(DataFactory.quad( + DataFactory.namedNode("https://example.org/x"), + DataFactory.namedNode(Example.hasString), + DataFactory.literal("o1"), + )) + + assert.deepEqual(events, ["delete:hasString:o1"]) + stop() + }) + + await it("stops emitting after off() detaches the listener", () => { + const ds = new ParentDataset(datasetFromRdf(""), dataFactory, n3StoreFactory) + const { events, stop } = recordEvents(ds) + + stop() + ds.add(DataFactory.quad( + DataFactory.namedNode("https://example.org/x"), + DataFactory.namedNode(Example.hasString), + DataFactory.literal("ignored"), + )) + + assert.deepEqual(events, []) + }) + + await it("supports multiple independent listeners", () => { + const ds = new ParentDataset(datasetFromRdf(""), dataFactory, n3StoreFactory) + const a: ChangeEvent[] = [] + const b: ChangeEvent[] = [] + ds.on(event => a.push(event)) + ds.on(event => b.push(event)) + + ds.add(DataFactory.quad( + DataFactory.namedNode("https://example.org/x"), + DataFactory.namedNode(Example.hasString), + DataFactory.literal("v"), + )) + + assert.deepEqual(a, ["add"]) + assert.deepEqual(b, ["add"]) + }) +}) + +await describe("Wrapper-driven change notifications", async () => { + /** Returns the singleton `` parent in the test fixture. */ + function load(): { ds: ParentDataset, parent: Parent } { + const ds = new ParentDataset(datasetFromRdf(rdf), dataFactory, n3StoreFactory) + const [parent] = Array.from(ds.instancesOfParent) + return { ds, parent: parent! } + } + + await it("setting a required property to a new value emits delete then add", () => { + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + parent.hasString = "o2" + + assert.deepEqual(events, ["delete:hasString:o1", "add:hasString:o2"]) + stop() + }) + + await it("setting a required property to its current value still emits both events", () => { + // OptionalAs.object always deletes existing matching quads before + // adding the new one, so a "no-op" assignment surfaces as two events. + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + parent.hasString = "o1" + + assert.deepEqual(events, ["delete:hasString:o1", "add:hasString:o1"]) + stop() + }) + + await it("clearing an optional property emits only a delete", () => { + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + parent.hasNullableString = undefined + + assert.deepEqual(events, ["delete:hasNullableString:nullable"]) + stop() + }) + + await it("setting an optional property from undefined emits only an add", () => { + const { ds, parent } = load() + parent.hasNullableString = undefined + const { events, stop } = recordEvents(ds) + + parent.hasNullableString = "first" + + assert.deepEqual(events, ["add:hasNullableString:first"]) + stop() + }) + + await it("changing a typed (number) property emits delete + add for the typed literal", () => { + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + parent.hasNumber = 2 + + assert.deepEqual(events, [ + "delete:hasNumber:1.0E0", + "add:hasNumber:2", + ]) + stop() + }) + + await it("adding to a Set-mapped property emits a single add", () => { + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + const newChild = new Child(DataFactory.namedNode("https://example.org/new-child"), ds, dataFactory) + parent.hasChildSet.add(newChild) + + assert.deepEqual(events, ["add:hasChildSet:https://example.org/new-child"]) + stop() + }) + + await it("removing from a Set-mapped property emits a single delete", () => { + const { ds, parent } = load() + const [first] = Array.from(parent.hasChildSet) + const { events, stop } = recordEvents(ds) + + parent.hasChildSet.delete(first!) + + assert.deepEqual(events, [`delete:hasChildSet:${first!.value}`]) + stop() + }) + + await it("removing a value not in the Set is a no-op and emits nothing", () => { + const { ds, parent } = load() + const { events, stop } = recordEvents(ds) + + const stranger = new Child(DataFactory.namedNode("https://example.org/stranger"), ds, dataFactory) + parent.hasChildSet.delete(stranger) + + assert.deepEqual(events, []) + stop() + }) + + await it("clearing a Set-mapped property emits a delete per remaining item", () => { + const { ds, parent } = load() + const childIris = Array.from(parent.hasChildSet, c => c.value).sort() + const { events, stop } = recordEvents(ds) + + parent.hasChildSet.clear() + + assert.deepEqual( + events.sort(), + childIris.map(iri => `delete:hasChildSet:${iri}`).sort(), + ) + stop() + }) +}) diff --git a/test/unit/dataset_events_examples.test.ts b/test/unit/dataset_events_examples.test.ts new file mode 100644 index 0000000..8bdb641 --- /dev/null +++ b/test/unit/dataset_events_examples.test.ts @@ -0,0 +1,283 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { + DatasetWrapper, + LiteralAs, + LiteralFrom, + OptionalAs, + OptionalFrom, + SetFrom, + TermAs, + TermFrom, + TermWrapper, + WrappingSet, + type ChangeEvent, +} from "@rdfjs/wrapper" +import { dataFactory } from "./util/dataFactory.js" +import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { n3StoreFactory } from "./util/n3StoreFactory.js" + +const EX = "https://example.org/" +const HAS_CHILD = `${EX}hasChild` +const HAS_EMAIL = `${EX}hasEmail` + +class Person extends TermWrapper { + /** + * A live, mutable {@link Set} of this person's children, backed by + * ` :hasChild ?child` quads in the dataset. Iteration always + * reflects the current state of the dataset; calling `add()` / + * `delete()` / `clear()` writes through to the underlying graph and + * triggers change notifications. + */ + public get children(): WrappingSet { + return SetFrom.subjectPredicate( + this, + HAS_CHILD, + TermAs.instance(Person), + TermFrom.instance, + ) + } + + /** + * Optional email address. Setting to a string emits `delete` (of any + * previous value) followed by `add`. Setting to `undefined` emits only + * `delete`, or nothing if there was no previous value. + */ + public get email(): string | undefined { + return OptionalFrom.subjectPredicate(this, HAS_EMAIL, LiteralAs.string) + } + + public set email(value: string | undefined) { + OptionalAs.object(this, HAS_EMAIL, value, LiteralFrom.string) + } +} + +class People extends DatasetWrapper { + public person(iri: string): Person { + return new Person(iri, this as any, this.factory) + } +} + +await describe("Example: notifying children iterable backed by SetFrom", async () => { + await it("emits set-level add events and the iterable reflects current state", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const bob = ds.person(`${EX}bob`) + const carol = ds.person(`${EX}carol`) + + // High-level subscription: only events on Alice's children set. + const childEvents: string[] = [] + alice.children.on((event, child) => childEvents.push(`${event}:${child.value}`)) + + assert.equal(alice.children.size, 0) + + alice.children.add(bob) + alice.children.add(carol) + + assert.deepEqual(childEvents, [ + `add:${EX}bob`, + `add:${EX}carol`, + ]) + + // The set always reflects the current state. + assert.deepEqual( + Array.from(alice.children).map(c => c.value).sort(), + [`${EX}bob`, `${EX}carol`], + ) + }) + + await it("re-iterating children inside the listener observes new additions", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const snapshots: string[][] = [] + + alice.children.on(() => { + // Each notification captures a fresh snapshot of `alice.children`, + // proving the set is a live view, not a one-shot copy. + snapshots.push(Array.from(alice.children).map(c => c.value)) + }) + + alice.children.add(ds.person(`${EX}bob`)) + alice.children.add(ds.person(`${EX}carol`)) + alice.children.add(ds.person(`${EX}dave`)) + + assert.deepEqual(snapshots, [ + [`${EX}bob`], + [`${EX}bob`, `${EX}carol`], + [`${EX}bob`, `${EX}carol`, `${EX}dave`], + ]) + }) + + await it("emits delete events for removals and clear()", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const bob = ds.person(`${EX}bob`) + const carol = ds.person(`${EX}carol`) + alice.children.add(bob) + alice.children.add(carol) + + const events: string[] = [] + alice.children.on((event, child) => events.push(`${event}:${child.value}`)) + + alice.children.delete(bob) + alice.children.clear() + + assert.deepEqual(events, [ + `delete:${EX}bob`, + `delete:${EX}carol`, + ]) + assert.equal(alice.children.size, 0) + }) + + await it("ignores additions on other subjects or predicates", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const eve = ds.person(`${EX}eve`) + + const events: string[] = [] + alice.children.on((event, child) => events.push(`${event}:${child.value}`)) + + // A child added to a different parent must not surface on Alice's set. + eve.children.add(ds.person(`${EX}mallory`)) + // A different predicate on Alice must not surface either. + alice.email = "alice@example.org" + + assert.deepEqual(events, []) + }) + + await it("off() detaches the set-level listener", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const events: string[] = [] + const listener = (event: ChangeEvent, child: Person) => { + events.push(`${event}:${child.value}`) + } + alice.children.on(listener) + alice.children.off(listener) + + alice.children.add(ds.person(`${EX}bob`)) + + assert.deepEqual(events, []) + }) + + await it("emits matching dataset-level add and delete events", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const bob = ds.person(`${EX}bob`) + + const datasetEvents: string[] = [] + ds.on((event, q) => { + datasetEvents.push( + `${event}:${q.subject.value}:${q.predicate.value}:${q.object.value}`, + ) + }) + + alice.children.add(bob) + alice.children.delete(bob) + + assert.deepEqual(datasetEvents, [ + `add:${EX}alice:${HAS_CHILD}:${EX}bob`, + `delete:${EX}alice:${HAS_CHILD}:${EX}bob`, + ]) + }) + + await it("dataset-level events fire once per item when clear() removes many", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const bob = ds.person(`${EX}bob`) + const carol = ds.person(`${EX}carol`) + alice.children.add(bob) + alice.children.add(carol) + + const datasetEvents: string[] = [] + ds.on((event, q) => { + if (q.predicate.value !== HAS_CHILD) return + datasetEvents.push(`${event}:${q.object.value}`) + }) + + alice.children.clear() + + assert.deepEqual(datasetEvents.sort(), [ + `delete:${EX}bob`, + `delete:${EX}carol`, + ]) + }) +}) + +await describe("Example: notifying optional email field", async () => { + /** Records `set:` and `unset:` notifications for `:hasEmail`. */ + function watchEmail(ds: People): string[] { + const log: string[] = [] + ds.on((event, q) => { + if (q.predicate.value !== HAS_EMAIL) return + log.push(`${event === "add" ? "set" : "unset"}:${q.object.value}`) + }) + return log + } + + await it("setting an email from undefined emits a single set event", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const log = watchEmail(ds) + + alice.email = "alice@example.org" + + assert.deepEqual(log, ["set:alice@example.org"]) + assert.equal(alice.email, "alice@example.org") + }) + + await it("changing an email emits unset (old) then set (new)", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + alice.email = "old@example.org" + const log = watchEmail(ds) + + alice.email = "new@example.org" + + assert.deepEqual(log, ["unset:old@example.org", "set:new@example.org"]) + assert.equal(alice.email, "new@example.org") + }) + + await it("clearing an email emits only an unset event", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + alice.email = "alice@example.org" + const log = watchEmail(ds) + + alice.email = undefined + + assert.deepEqual(log, ["unset:alice@example.org"]) + assert.equal(alice.email, undefined) + }) + + await it("clearing an already-empty email emits nothing", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + const log = watchEmail(ds) + + alice.email = undefined + + assert.deepEqual(log, []) + }) + + await it("emits matching dataset-level add and delete events when changing", () => { + const ds = new People(datasetFromRdf(""), dataFactory, n3StoreFactory) as People + const alice = ds.person(`${EX}alice`) + alice.email = "old@example.org" + + const datasetEvents: string[] = [] + ds.on((event, q) => { + datasetEvents.push( + `${event}:${q.subject.value}:${q.predicate.value}:${q.object.value}`, + ) + }) + + alice.email = "new@example.org" + + assert.deepEqual(datasetEvents, [ + `delete:${EX}alice:${HAS_EMAIL}:old@example.org`, + `add:${EX}alice:${HAS_EMAIL}:new@example.org`, + ]) + }) +}) + From ed3bfee9a0c8e009498d93abb69c43407a4f85de Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 06:06:04 +0100 Subject: [PATCH 11/19] feat: enhance GraphScopedDataset and WrappingSet with detailed documentation and examples for better usability --- README.md | 130 ++++++++++++++++++++++++------ src/EventEmitter.ts | 46 +++++++++++ src/WrappingSet.ts | 54 +++++++++++++ src/dataset/GraphScopedDataset.ts | 80 ++++++++++++++++-- 4 files changed, 278 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3fe3b79..2766164 100644 --- a/README.md +++ b/README.md @@ -177,50 +177,128 @@ RDF/JS Wrapper uses the interfaces described in the [RDF/JS](https://rdf.js.org/ ### Named Graphs -The `namedGraph` function creates a `DatasetCore` view over a single named graph, projecting its contents into the default graph. This lets you use any existing `TermWrapper` or `DatasetWrapper` classes unchanged, scoped to a specific graph. +The `GraphScopedDataset` class is a `DatasetWrapper` that exposes one or more named graphs of an underlying dataset projected onto the default graph. Existing `TermWrapper` and `DatasetWrapper` subclasses can be reused unchanged against quads that live in named graphs. -```javascript -import { namedGraph, DatasetWrapper } from "@rdfjs/wrapper" - -// Given a dataset with quads in a named graph: -// "Alice" . -// "Bob" . -// "Charlie" . (default graph) +The recommended entry point is `DatasetWrapper.scoped`, which constructs the projection for you from a parent wrapper: -const graphView = namedGraph(DataFactory.namedNode("https://example.org/graph1"), dataset, DataFactory) - -// graphView behaves as a DatasetCore containing only default graph quads: -// "Alice" . -// "Bob" . +```javascript +import { DatasetWrapper, GraphScopedDataset } from "@rdfjs/wrapper" -// Wrap it with your existing classes: -class People extends DatasetWrapper { +class People extends GraphScopedDataset { get all() { return this.subjectsOf("https://example.org/name", Person) } } -const people = new People(graphView, DataFactory) -for (const person of people.all) { - console.log(person.name) +class Workspace extends DatasetWrapper { + people(graphIri) { + // Read from and write to the same named graph. + return this.scoped(graphIri, [graphIri], People) + } +} +``` + +Given the following RDF: + +```turtle +PREFIX ex: + +GRAPH ex:graph1 { + ex:person1 ex:name "Alice" . + ex:person2 ex:name "Bob" . +} + +ex:person1 ex:name "Charlie" . # default graph +``` + +```javascript +const ws = new Workspace(dataset, DataFactory, datasetFactory) +const team = ws.people("https://example.org/graph1") + +for (const p of team.all) { + console.log(p.name) } // outputs "Alice", "Bob" (Charlie is excluded — different graph) ``` -Writes through the view are mapped back to the named graph in the underlying dataset: +Writes through the view are mapped back into the configured `writeGraph`: ```javascript -// Adding a quad through the view stores it in the named graph -graphView.add(DataFactory.quad(s, p, o)) -// Equivalent to: dataset.add(DataFactory.quad(s, p, o, DataFactory.namedNode("https://example.org/graph1"))) +team.add(DataFactory.quad(s, p, o)) +// stored in the underlying dataset as: +// DataFactory.quad(s, p, o, DataFactory.namedNode("https://example.org/graph1")) ``` -Any attempt to use a non-default graph on the returned `DatasetCore` throws a `NamedGraphError`: +`writeGraph` and `readGraphs` need not be the same. Passing `undefined` for `readGraphs` reads from every graph (default and named) and deduplicates triples across them — useful for read-only union views: + +```javascript +class ReadOnlyUnion extends GraphScopedDataset { /* ... */ } +const union = ws.scoped("https://example.org/scratch", undefined, ReadOnlyUnion) +``` + +Any attempt to use a non-default graph on the projected view throws a `NamedGraphError` (for `add` / `delete` / `has`) or a `TermTypeError` (for `match`): + +```javascript +// These all throw: +team.add(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))) // NamedGraphError +team.match(undefined, undefined, undefined, DataFactory.namedNode("https://other.org/g")) // TermTypeError +``` + + +### Change notifications + +Every `DatasetWrapper` exposes `on(listener)` / `off(listener)` so consumers can react to additions and removals on the underlying dataset: + +```javascript +const ds = new People(dataset, DataFactory, datasetFactory) + +const listener = (event, quad) => { + // event is "add" or "delete" + console.log(event, quad.subject.value, quad.predicate.value, quad.object.value) +} +ds.on(listener) +// ... +ds.off(listener) +``` + +Notifications fire for **every** quad-level mutation, regardless of how it was triggered: + +- direct `dataset.add(quad)` / `dataset.delete(quad)` +- a setter on a `TermWrapper` (`person.name = "..."`) +- mutations through a `WrappingSet` returned by `SetFrom` +- mutations through an `RdfList` +- writes made through a `GraphScopedDataset` view (the listener attached to the scoped view receives default-graph quads; the listener attached to the underlying dataset receives the rewritten named-graph quads) + +Setters that *change* a value emit a `delete` for the previous quad followed by an `add` for the new quad. Clearing an optional value emits only `delete`. Setting from `undefined` emits only `add`. + +#### Set-level notifications + +`WrappingSet` (the type returned by `SetFrom.subjectPredicate`) also exposes `on` / `off`. The listener receives the mutation type and the **mapped JavaScript value** for that set's subject + predicate, so callers do not need to filter dataset-wide events themselves: + +```javascript +import { SetFrom, TermAs, TermFrom, TermWrapper } from "@rdfjs/wrapper" + +class Person extends TermWrapper { + get children() { + return SetFrom.subjectPredicate(this, "https://example.org/hasChild", TermAs.instance(Person), TermFrom.instance) + } +} + +const alice = new Person("https://example.org/alice", dataset, DataFactory) + +alice.children.on((event, child) => console.log(event, child.value)) + +alice.children.add(bob) // logs: add, https://example.org/bob +alice.children.delete(bob) // logs: delete, https://example.org/bob +``` + +The set is a **live view**: iterating `alice.children` always reflects the current state of the dataset, including additions made by other code paths. + +`WrappingSet.off(listener)` is keyed by `(listener, subject, predicate)` rather than by instance, so it works correctly even when called on a fresh `WrappingSet` returned by a subsequent property access: ```javascript -// These all throw NamedGraphError: -graphView.add(DataFactory.quad(s, p, o, DataFactory.namedNode("https://other.org/g"))) -graphView.match(undefined, undefined, undefined, DataFactory.namedNode("https://other.org/g")) +alice.children.on(listener) +alice.children.off(listener) // detaches the listener attached above ``` diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 937b88d..e4ad8b7 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -2,23 +2,46 @@ import { BaseQuad, Quad, Term } from "@rdfjs/types"; import { IPattern } from "./dataset/LazyMaterialize.js"; import { ChangeEvent } from "./dataset/NotifyingDatasetCore.js"; +/** + * A minimal multi-cast event emitter generic over the listener argument + * tuple `Args`. Listeners attached with {@link on} are invoked, in + * insertion order, on every {@link emit} call until detached with + * {@link off}. + * + * Used internally to back the change-notification stream of + * {@link NotifyingDatasetCoreWrapper} and {@link ProjectedDatasetCoreWrapper}, + * but exported because consumer-level wrappers may find it useful. + * + * @example Subscribing to dataset changes + * ```ts + * const ee = new EventEmitter<[ChangeEvent, Triple]>() + * const listener = (event, quad) => console.log(event, quad.object.value) + * ee.on(listener) + * ee.emit("add", someQuad) + * ee.off(listener) + * ``` + */ export class EventEmitter { private readonly listeners: Set<(...args: Args) => void> = new Set(); + /** Adds `listener` to the set of subscribers. Re-adding the same listener has no effect. */ on(listener: (...args: Args) => void): void { this.listeners.add(listener); } + /** Removes `listener` from the set of subscribers. Removing an unknown listener is a no-op. */ off(listener: (...args: Args) => void): void { this.listeners.delete(listener); } + /** Synchronously invokes every registered listener with `args`, in insertion order. */ emit(...args: Args): void { for (const listener of this.listeners) { listener(...args); } } + /** `true` when no listeners are attached. */ get empty(): boolean { return this.listeners.size === 0; } @@ -67,6 +90,29 @@ function *yieldListeners(idx: number, pattern: IPattern< } } +/** + * An event emitter that dispatches quad change events according to a + * subscribed quad pattern. + * + * Subscribers register an {@link IPattern} along with their listener; + * calling {@link emit} delivers the event only to those listeners whose + * pattern matches the emitted quad. A field omitted (`undefined`) from + * the pattern acts as a wildcard for that position, matching any term. + * + * Used internally by {@link LazyMatchNotifyingDatasetCore} to dispatch + * pattern-filtered change notifications without re-scanning the full + * listener list on every event. + * + * @example Subscribing to changes for a specific subject + predicate + * ```ts + * const ee = new PatternEventEmitter() + * ee.on({ subject: aliceTerm, predicate: hasChildTerm }, (event, quad) => { + * console.log(event, quad.object.value) + * }) + * ee.emit("add", aliceHasBobQuad) // delivered (matches subject + predicate) + * ee.emit("add", bobHasCarolQuad) // not delivered (subject differs) + * ``` + */ export class PatternEventEmitter { private listeners: Map = new Map(); diff --git a/src/WrappingSet.ts b/src/WrappingSet.ts index f29502e..5974d9a 100644 --- a/src/WrappingSet.ts +++ b/src/WrappingSet.ts @@ -28,9 +28,63 @@ const listenerAdapters = new WeakMap< Map void> >() +/** + * A {@link Set} view over the objects of all ` ?o` + * quads in the default graph of the underlying dataset. + * + * The set is **live**: iteration, {@link size} and {@link has} re-query the + * dataset on every call, so the contents always reflect the current state. + * Mutations performed via {@link add}, {@link delete} and {@link clear} + * write through to the dataset and surface as change events on the + * underlying {@link NotifyingDatasetCore}. + * + * @example Subscribing to changes + * Use {@link on} / {@link off} to observe additions and removals filtered + * by this set's subject / predicate. The mapped JavaScript value is passed + * to the listener: + * ```ts + * const children = SetFrom.subjectPredicate(parent, ":hasChild", TermAs.instance(Person), TermFrom.instance) + * children.on((event, child) => console.log(event, child.value)) + * + * children.add(somePerson) // logs: "add", "" + * children.delete(somePerson) // logs: "delete", "" + * ``` + * + * @example Listener identity across instances + * Mappers like {@link SetFrom.subjectPredicate} typically return a fresh + * {@link WrappingSet} on every property access. {@link off} is keyed by + * `(listener, subject, predicate)` rather than by instance, so this works: + * ```ts + * parent.children.on(listener) + * parent.children.off(listener) // detaches the listener attached above + * ``` + * + * @example Mutations from outside the set + * Because notifications come from the underlying dataset, the listener + * also fires when something *else* mutates a matching quad - for example + * {@link DatasetWrapper.add} / {@link DatasetWrapper.delete} or a sibling + * {@link WrappingSet} targeting the same subject / predicate. + */ export class WrappingSet implements Set { // TODO: Direction + /** + * Constructs a {@link WrappingSet}. + * + * Application code typically does not call this constructor directly; + * use {@link SetFrom.subjectPredicate} instead, which produces a + * {@link WrappingSet} for a given anchor / predicate / mapping triple. + * + * @param subject The anchor {@link TermWrapper} - all quads in this + * set have this term as their subject. + * @param predicate The IRI of the predicate - all quads in this set + * have a {@link NamedNode} with this IRI as their + * predicate. + * @param termAs Mapping from RDF object to JavaScript value, used by + * iteration and emitted to {@link on} listeners. + * @param termFrom Mapping from JavaScript value to RDF object, used by + * {@link add}, {@link delete} and {@link has}. + */ public constructor(private readonly subject: TermWrapper, private readonly predicate: string, private readonly termAs: ITermAsValueMapping, private readonly termFrom: ITermFromValueMapping) { } diff --git a/src/dataset/GraphScopedDataset.ts b/src/dataset/GraphScopedDataset.ts index cb288a1..bcffbbd 100644 --- a/src/dataset/GraphScopedDataset.ts +++ b/src/dataset/GraphScopedDataset.ts @@ -5,16 +5,84 @@ import { Triple } from "../type/ITriple.js" import { NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js" /** - * A {@link DatasetWrapper} that exposes a configurable set of graphs from an - * underlying dataset projected onto the default graph. + * A {@link DatasetWrapper} that exposes a configurable set of graphs from + * an underlying dataset projected onto the default graph. * - * The wrapper writes new quads to a single configured `writeGraph` and reads - * from the supplied `readGraphs`. When `readGraphs` is `undefined`, every - * graph (default and named) is read and triples are deduplicated across them. + * It is the recommended way to use existing {@link DatasetWrapper} or + * {@link TermWrapper} subclasses against quads that live in named graphs: + * because the projection rewrites every read quad to the default graph, + * mappers that only operate on the default graph (which is most of them) + * just work. * - * @see {@link ProjectedDataset} + * - **Reads** come from the configured `readGraphs`. Triples appearing in + * more than one read graph are deduplicated. When `readGraphs` is + * `undefined`, every graph (default and named) is read - effectively a + * union view. + * - **Writes** ({@link DatasetWrapper.add}, {@link DatasetWrapper.delete}, + * and any wrapper-driven mutation) are rewritten into the configured + * `writeGraph` in the underlying dataset. + * - {@link DatasetWrapper.match} ignores the graph dimension; supplying a + * non-default graph throws a {@link TermTypeError}. + * - **Notifications** attached via {@link DatasetWrapper.on} are fired + * only when the *projected* view actually changes: a triple appearing + * in several read graphs is reported as added once and as deleted only + * when the last copy disappears. Events are delivered with quads in + * the default graph, regardless of which read graph triggered them. + * + * Subclasses extend {@link GraphScopedDataset} the same way they extend + * {@link DatasetWrapper}; consumers obtain instances via + * {@link DatasetWrapper.scoped}. + * + * @example Wrapping a single named graph + * ```ts + * class People extends GraphScopedDataset { + * get all(): Iterable { + * return this.subjectsOf(":name", Person) + * } + * } + * + * class Workspace extends DatasetWrapper { + * people(graph: string): People { + * return this.scoped(graph, [graph], People) + * } + * } + * + * const ws = new Workspace(dataset, factory, datasetFactory) + * for (const p of ws.people("https://example.org/team-a").all) { + * console.log(p.name) + * } + * ``` + * + * @example Observing changes scoped to a graph + * ```ts + * const teamA = ws.people("https://example.org/team-a") + * teamA.on((event, quad) => console.log(event, quad.object.value)) + * teamA.add(factory.quad(s, p, o)) // rewritten into team-a; listener fires once + * ``` + * + * @see {@link ProjectedDatasetCoreWrapper} - the underlying core view. + * @see {@link DatasetWrapper.scoped} - the recommended factory. */ export class GraphScopedDataset extends DatasetWrapper { + /** + * Constructs a {@link GraphScopedDataset}. + * + * Application code typically does not call this constructor directly; + * use {@link DatasetWrapper.scoped} on a parent {@link DatasetWrapper}, + * which resolves the graph IRIs and forwards the existing + * `factory` / `datasetFactory`. + * + * @param writeGraph The graph in the underlying dataset that + * writes through this view are directed to. + * @param readGraphs The graphs read through this view. If + * `undefined`, every graph in the underlying + * dataset is read (a deduplicated union). + * @param dataset The underlying notifying dataset to project. + * @param factory Data factory used to build the rewritten + * quads for both reads and writes. + * @param datasetFactory Factory used to materialize the projected + * view when a {@link match} result is consumed. + */ public constructor( writeGraph: Quad_Graph, readGraphs: ReadonlyArray | undefined, From 62ee8aac376737cb0311e1e30ad95804eb64adcc Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:10:17 +0100 Subject: [PATCH 12/19] feat: introduce asynchronous RDF term wrappers and mappings - Added AsyncTermWrapper to provide an asynchronous interface for RDF terms. - Implemented AsyncWrappingSet for live, mutable views over RDF quads in datasets. - Created AsyncLiteralAs, AsyncOptionalAs, AsyncRequiredAs, and AsyncSetFrom for async mappings. - Developed AsyncParent and AsyncChild models to demonstrate async property access and mutation. - Added tests for async dataset wrappers and term wrappers to ensure functionality. --- README.md | 121 +++++++++ src/async/AsyncDatasetCore.ts | 83 ++++++ src/async/AsyncDatasetWrapper.ts | 172 +++++++++++++ src/async/AsyncNotifyingDatasetCore.ts | 228 +++++++++++++++++ src/async/AsyncTermWrapper.ts | 118 +++++++++ src/async/AsyncWrappingSet.ts | 241 ++++++++++++++++++ src/async/mapping/AsyncLiteralAs.ts | 100 ++++++++ src/async/mapping/AsyncOptionalAs.ts | 65 +++++ src/async/mapping/AsyncOptionalFrom.ts | 35 +++ src/async/mapping/AsyncRequiredAs.ts | 22 ++ src/async/mapping/AsyncRequiredFrom.ts | 43 ++++ src/async/mapping/AsyncSetFrom.ts | 26 ++ src/async/mapping/AsyncTermAs.ts | 34 +++ src/async/type/IAsyncTermAsValueMapping.ts | 9 + src/async/type/IAsyncTermFromValueMapping.ts | 13 + .../type/IAsyncTermWrapperConstructor.ts | 15 ++ src/mod.ts | 19 ++ test/unit/async_dataset_wrapper.test.ts | 94 +++++++ test/unit/async_term_wrapper.test.ts | 138 ++++++++++ test/unit/model/AsyncChild.ts | 22 ++ test/unit/model/AsyncParent.ts | 111 ++++++++ test/unit/model/AsyncParentDataset.ts | 26 ++ test/unit/util/asyncDatasetFromRdf.ts | 13 + test/unit/util/asyncN3StoreFactory.ts | 34 +++ 24 files changed, 1782 insertions(+) create mode 100644 src/async/AsyncDatasetCore.ts create mode 100644 src/async/AsyncDatasetWrapper.ts create mode 100644 src/async/AsyncNotifyingDatasetCore.ts create mode 100644 src/async/AsyncTermWrapper.ts create mode 100644 src/async/AsyncWrappingSet.ts create mode 100644 src/async/mapping/AsyncLiteralAs.ts create mode 100644 src/async/mapping/AsyncOptionalAs.ts create mode 100644 src/async/mapping/AsyncOptionalFrom.ts create mode 100644 src/async/mapping/AsyncRequiredAs.ts create mode 100644 src/async/mapping/AsyncRequiredFrom.ts create mode 100644 src/async/mapping/AsyncSetFrom.ts create mode 100644 src/async/mapping/AsyncTermAs.ts create mode 100644 src/async/type/IAsyncTermAsValueMapping.ts create mode 100644 src/async/type/IAsyncTermFromValueMapping.ts create mode 100644 src/async/type/IAsyncTermWrapperConstructor.ts create mode 100644 test/unit/async_dataset_wrapper.test.ts create mode 100644 test/unit/async_term_wrapper.test.ts create mode 100644 test/unit/model/AsyncChild.ts create mode 100644 test/unit/model/AsyncParent.ts create mode 100644 test/unit/model/AsyncParentDataset.ts create mode 100644 test/unit/util/asyncDatasetFromRdf.ts create mode 100644 test/unit/util/asyncN3StoreFactory.ts diff --git a/README.md b/README.md index 2766164..c715827 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,127 @@ alice.children.off(listener) // detaches the listener attached above ``` +### Async API + +The library ships a parallel asynchronous surface for use with RDF/JS-shaped datasets that are themselves asynchronous (or that you want to expose through promises). Every type, class and mapping has a sync sibling and an async sibling; the names are prefixed with `Async`. + +The shape of the async dataset interface mirrors RDF/JS `DatasetCore` with one deliberate exception: `match` returns another `AsyncDatasetCore` synchronously (the matched view is materialised lazily on iteration), while every other read/write returns a `Promise`. Iteration is exposed via `Symbol.asyncIterator`; there is no synchronous `Symbol.iterator`. + +| Sync | Async | +| ----------------------------------- | ------------------------------------------- | +| `DatasetCore` | `AsyncDatasetCore` | +| `NotifyingDatasetCore` | `AsyncNotifyingDatasetCore` | +| `NotifyingDatasetCoreWrapper` | `AsyncNotifyingDatasetCoreWrapper` | +| `DatasetWrapper` | `AsyncDatasetWrapper` | +| `TermWrapper` | `AsyncTermWrapper` | +| `WrappingSet` | `AsyncWrappingSet` | +| `RequiredFrom` / `OptionalFrom` | `AsyncRequiredFrom` / `AsyncOptionalFrom` | +| `RequiredAs` / `OptionalAs` | `AsyncRequiredAs` / `AsyncOptionalAs` | +| `SetFrom` | `AsyncSetFrom` | +| `TermAs` / `LiteralAs` | `AsyncTermAs` / `AsyncLiteralAs` | +| `LiteralFrom` / `NamedNodeFrom` / `BlankNodeFrom` / `TermFrom` | _reused as-is_ (pure functions) | + +#### Defining async wrappers + +JavaScript property setters cannot be `async`, so write-mappings on async wrappers are exposed as `setX(value)` methods that return a `Promise`. Read-mappings are normal getters that return a `Promise`; set-mappings return an `AsyncWrappingSet`. + +```javascript +import { + AsyncLiteralAs, AsyncOptionalAs, AsyncOptionalFrom, + AsyncRequiredAs, AsyncRequiredFrom, + AsyncSetFrom, AsyncTermAs, + AsyncTermWrapper, + LiteralFrom, TermFrom, +} from "@rdfjs/wrapper" + +class AsyncPerson extends AsyncTermWrapper { + get name() { + return AsyncRequiredFrom.subjectPredicate(this, "https://example.org/name", AsyncLiteralAs.string) + } + setName(value) { + return AsyncRequiredAs.object(this, "https://example.org/name", value, LiteralFrom.string) + } + + get nickname() { + return AsyncOptionalFrom.subjectPredicate(this, "https://example.org/nickname", AsyncLiteralAs.string) + } + setNickname(value) { + return AsyncOptionalAs.object(this, "https://example.org/nickname", value, LiteralFrom.string) + } + + get children() { + return AsyncSetFrom.subjectPredicate(this, "https://example.org/hasChild", AsyncTermAs.instance(AsyncPerson), TermFrom.instance) + } +} +``` + +Usage: + +```javascript +const alice = new AsyncPerson("https://example.org/alice", asyncDataset, DataFactory) + +console.log(await alice.name) // "Alice" +await alice.setName("Alicia") +console.log(await alice.name) // "Alicia" + +for await (const child of alice.children) { + console.log(await child.name) +} +``` + +#### Async dataset wrappers + +`AsyncDatasetWrapper` is the async counterpart of `DatasetWrapper`. The same `subjectsOf` / `objectsOf` / `instancesOf` / `matchSubjectsOf` / `matchObjectsOf` helpers are available, but they return `AsyncIterable` so callers iterate with `for await`: + +```javascript +import { AsyncDatasetWrapper } from "@rdfjs/wrapper" + +class People extends AsyncDatasetWrapper { + get all() { + return this.subjectsOf("https://example.org/name", AsyncPerson) + } +} + +const people = new People(asyncDataset, DataFactory, asyncDatasetFactory) +for await (const person of people.all) { + console.log(await person.name) +} + +console.log(await people.size) // resolves to a number +``` + +#### Bridging a synchronous dataset + +`AsyncNotifyingDatasetCoreWrapper` accepts either an `AsyncDatasetCore` or any synchronous `DatasetCore` (e.g. an n3 `Store`), so existing sync stores can be exposed through the async pipeline without re-implementation: + +```javascript +import { AsyncNotifyingDatasetCoreWrapper } from "@rdfjs/wrapper" +import { Store } from "n3" + +const store = new Store() +const asyncDataset = new AsyncNotifyingDatasetCoreWrapper(store) + +await asyncDataset.add(quad) +console.log(await asyncDataset.size) +``` + +You can also implement `AsyncNotifyingDatasetCoreFactory` to plug in a genuinely asynchronous backing store (database, remote SPARQL endpoint, etc.). + +#### Async change notifications + +`AsyncDatasetWrapper` and `AsyncWrappingSet` expose the same `on` / `off` shape as their sync siblings. Listeners may be `async` - the dispatcher awaits a returned promise before invoking the next listener: + +```javascript +asyncDataset.on(async (event, quad) => { + await sendToAuditLog(event, quad) +}) + +alice.children.on(async (event, child) => { + console.log(event, await child.name) +}) +``` + + ## Background Practically, to map RDF to objects, you need to: diff --git a/src/async/AsyncDatasetCore.ts b/src/async/AsyncDatasetCore.ts new file mode 100644 index 0000000..f12e8ef --- /dev/null +++ b/src/async/AsyncDatasetCore.ts @@ -0,0 +1,83 @@ +import type { BaseQuad, Quad } from "@rdfjs/types" + +/** + * The asynchronous counterpart of the RDF/JS + * [`DatasetCore`](https://rdf.js.org/dataset-spec/#datasetcore-interface) + * interface. + * + * Every operation that touches the underlying storage returns a + * {@link Promise}, with the single deliberate exception of {@link match}, + * which immediately returns another {@link AsyncDatasetCore} that + * represents the matched view. This mirrors the synchronous interface, + * where `match` returns a dataset rather than the matched quads + * themselves; the matched view performs its actual work lazily on + * iteration / {@link size} / {@link has}. + * + * Iteration is exposed via {@link Symbol.asyncIterator}; there is no + * synchronous `Symbol.iterator`. Consumers should use `for await ... of`. + * + * @example Reading and iterating + * ```ts + * console.log(await dataset.size) + * for await (const quad of dataset) { + * console.log(quad.subject.value) + * } + * + * // match is sync; iteration is async + * for await (const quad of dataset.match(subject, predicate)) { + * console.log(quad.object.value) + * } + * ``` + * + * @typeParam OutQuad - The shape of quads yielded by iteration and + * returned by reads. + * @typeParam InQuad - The shape of quads accepted by writes. + */ +export interface AsyncDatasetCore { + /** A {@link Promise} resolving to the number of quads currently in this dataset. */ + readonly size: Promise + + /** Adds `quad` to the dataset. */ + add(quad: InQuad): Promise + + /** Removes `quad` from the dataset. */ + delete(quad: InQuad): Promise + + /** Resolves to `true` if `quad` is present in the dataset. */ + has(quad: InQuad): Promise + + /** + * Returns an {@link AsyncDatasetCore} view of the quads matching the + * supplied pattern. This call is itself synchronous; the returned + * dataset performs the actual matching lazily on iteration / size / + * has. + */ + match( + subject?: OutQuad["subject"] | null, + predicate?: OutQuad["predicate"] | null, + object?: OutQuad["object"] | null, + graph?: OutQuad["graph"] | null, + ): AsyncDatasetCore + + /** Yields the quads of this dataset asynchronously. */ + [Symbol.asyncIterator](): AsyncIterator +} + +/** + * The asynchronous counterpart of the RDF/JS + * [`DatasetCoreFactory`](https://rdf.js.org/dataset-spec/#datasetcorefactory-interface) + * interface. + * + * Accepts an `AsyncIterable` or `Iterable` of seed quads, in addition to + * an array as required by the standard interface, so any source - + * including another {@link AsyncDatasetCore} - can be used to seed a new + * dataset. + */ +export interface AsyncDatasetCoreFactory< + OutQuad extends BaseQuad = Quad, + InQuad extends BaseQuad = OutQuad, + D extends AsyncDatasetCore = AsyncDatasetCore, +> { + dataset(quads?: AsyncIterable | Iterable): Promise +} + diff --git a/src/async/AsyncDatasetWrapper.ts b/src/async/AsyncDatasetWrapper.ts new file mode 100644 index 0000000..8c7b10e --- /dev/null +++ b/src/async/AsyncDatasetWrapper.ts @@ -0,0 +1,172 @@ +import type { DataFactory, DatasetCore, DefaultGraph, Quad } from "@rdfjs/types" +import type { Triple } from "../type/ITriple.js" +import type { AsyncDefaultDatasetCore, AsyncListener, AsyncNotifyingDatasetCore, AsyncNotifyingDatasetCoreFactory } from "./AsyncNotifyingDatasetCore.js" +import type { IAsyncTermWrapperConstructor } from "./type/IAsyncTermWrapperConstructor.js" + +import { ensureAsyncNotifyingDatasetCore } from "./AsyncNotifyingDatasetCore.js" +import { defaultGraph } from "../dataset/terms.js" +import { ensureDefaultGraph, ensureTermType } from "../ensure.js" +import { RDF } from "../vocabulary/RDF.js" + +/** + * Asynchronous projection of an underlying dataset onto its default + * graph - the async counterpart to + * {@link "../DatasetWrapper.js"!DefaultDatasetCore}. + */ +export interface AsyncDefaultNotifyingDatasetCore + extends AsyncDefaultDatasetCore { + match( + subject: Triple["subject"] | undefined, + predicate: Triple["predicate"] | undefined, + object: Triple["object"] | undefined, + graph: DefaultGraph, + ): AsyncDefaultNotifyingDatasetCore +} + +/** Factory used by {@link AsyncDatasetWrapper} to materialise scoped views. */ +export type AsyncDefaultDatasetCoreFactory = + AsyncNotifyingDatasetCoreFactory + +/** + * Asynchronous counterpart of + * {@link "../DatasetWrapper.js"!DatasetWrapper}. + * + * Behaviourally identical to its synchronous sibling - it presents an + * underlying RDF/JS dataset as a default-graph-only view and offers the + * same `subjectsOf` / `objectsOf` / `instancesOf` / `match*` helpers - + * but every operation that touches storage is awaited and iteration is + * exposed via {@link Symbol.asyncIterator}. + * + * The constructor accepts either a fully async + * {@link AsyncNotifyingDatasetCore} or any synchronous + * {@link DatasetCore}; in the latter case the dataset is automatically + * wrapped in an + * {@link "./AsyncNotifyingDatasetCoreWrapper.js"!AsyncNotifyingDatasetCoreWrapper} + * so existing sync stores (e.g. an n3 `Store`) can be used unchanged. + * + * @example Subclassing for queries + * ```ts + * class People extends AsyncDatasetWrapper { + * get all(): AsyncIterable { + * return this.subjectsOf("https://example.org/name", AsyncPerson) + * } + * } + * + * const people = new People(asyncDataset, factory, asyncDatasetFactory) + * for await (const person of people.all) { + * console.log(await person.name) + * } + * console.log(await people.size) + * ``` + * + * @example Subscribing to changes + * ```ts + * people.on(async (event, quad) => { + * await audit.record(event, quad) + * }) + * ``` + */ +export class AsyncDatasetWrapper implements AsyncDefaultNotifyingDatasetCore { + private readonly dataset: AsyncNotifyingDatasetCore + protected readonly datasetFactory: AsyncDefaultDatasetCoreFactory + + public constructor( + dataset: AsyncNotifyingDatasetCore | DatasetCore, + protected readonly factory: DataFactory, + datasetFactory: AsyncDefaultDatasetCoreFactory, + ) { + this.dataset = ensureAsyncNotifyingDatasetCore(dataset) + this.datasetFactory = datasetFactory + } + + //#region AsyncDatasetCore + + get size(): Promise { + return this.match(undefined, undefined, undefined, defaultGraph).size + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.match(undefined, undefined, undefined, defaultGraph)[Symbol.asyncIterator]() + } + + async add(quad: Triple): Promise { + ensureDefaultGraph(quad) + await this.dataset.add(quad) + return this + } + + async delete(quad: Triple): Promise { + ensureDefaultGraph(quad) + await this.dataset.delete(quad) + return this + } + + async has(quad: Triple): Promise { + ensureDefaultGraph(quad) + return this.dataset.has(quad) + } + + match( + subject: Triple["subject"] | undefined, + predicate: Triple["predicate"] | undefined, + object: Triple["object"] | undefined, + graph: DefaultGraph, + ): AsyncDefaultNotifyingDatasetCore { + ensureTermType(graph, "DefaultGraph") + return this.dataset.match(subject, predicate, object, defaultGraph) as AsyncDefaultNotifyingDatasetCore + } + + //#endregion + + //#region Notifications + + on(listener: AsyncListener): void { + this.dataset.on(listener) + } + + off(listener: AsyncListener): void { + this.dataset.off(listener) + } + + //#endregion + + //#region Utilities + + protected subjectsOf(predicate: string, termWrapper: IAsyncTermWrapperConstructor): AsyncIterable { + return this.matchSubjectsOf(termWrapper, this.factory.namedNode(predicate)) + } + + protected objectsOf(predicate: string, termWrapper: IAsyncTermWrapperConstructor): AsyncIterable { + return this.matchObjectsOf(termWrapper, undefined, this.factory.namedNode(predicate)) + } + + protected instancesOf(klass: string, constructor: IAsyncTermWrapperConstructor): AsyncIterable { + return this.matchSubjectsOf(constructor, this.factory.namedNode(RDF.type), this.factory.namedNode(klass)) + } + + protected async *matchSubjectsOf( + termWrapper: IAsyncTermWrapperConstructor, + predicate?: Triple["predicate"], + object?: Triple["object"], + ): AsyncIterable { + for await (const q of this.match(undefined, predicate, object, defaultGraph)) { + yield new termWrapper(q.subject, this, this.factory) + } + } + + protected async *matchObjectsOf( + termWrapper: IAsyncTermWrapperConstructor, + subject?: Triple["subject"], + predicate?: Triple["predicate"], + ): AsyncIterable { + for await (const q of this.match(subject, predicate, undefined, defaultGraph)) { + yield new termWrapper(q.object, this, this.factory) + } + } + + //#endregion + + get [Symbol.toStringTag](): string { + return this.constructor.name + } +} diff --git a/src/async/AsyncNotifyingDatasetCore.ts b/src/async/AsyncNotifyingDatasetCore.ts new file mode 100644 index 0000000..37afee7 --- /dev/null +++ b/src/async/AsyncNotifyingDatasetCore.ts @@ -0,0 +1,228 @@ +import type { BaseQuad, DatasetCore, DefaultGraph, Quad } from "@rdfjs/types" +import type { AsyncDatasetCore, AsyncDatasetCoreFactory } from "./AsyncDatasetCore.js" +import type { ChangeEvent } from "../dataset/NotifyingDatasetCore.js" + +/** + * A change-event listener for {@link AsyncNotifyingDatasetCore}. May + * return either `void` or a {@link Promise} (which the dataset will + * await before invoking the next listener). + */ +export type AsyncListener = + (event: ChangeEvent, quad: InQuad) => void | Promise + +/** + * The asynchronous counterpart of {@link "../dataset/NotifyingDatasetCore.js"!NotifyingDatasetCore}: + * an {@link AsyncDatasetCore} that emits change events when quads are + * added or removed. + */ +export interface AsyncNotifyingDatasetCore< + OutQuad extends BaseQuad = Quad, + InQuad extends BaseQuad = OutQuad, +> extends AsyncDatasetCore { + on(listener: AsyncListener): void + off(listener: AsyncListener): void + match( + subject?: OutQuad["subject"] | null, + predicate?: OutQuad["predicate"] | null, + object?: OutQuad["object"] | null, + graph?: OutQuad["graph"] | null, + ): AsyncNotifyingDatasetCore +} + +/** + * A {@link DefaultGraph}-restricted view of an + * {@link AsyncNotifyingDatasetCore}, used by {@link AsyncDatasetWrapper}. + * Mirrors the synchronous `DefaultDatasetCore` type. + */ +export interface AsyncDefaultDatasetCore + extends AsyncNotifyingDatasetCore { + match( + subject: Q["subject"] | undefined, + predicate: Q["predicate"] | undefined, + object: Q["object"] | undefined, + graph: DefaultGraph, + ): AsyncDefaultDatasetCore +} + +/** + * An {@link AsyncDatasetCoreFactory} that produces + * {@link AsyncNotifyingDatasetCore} instances. Mirrors the relationship + * between `IterableDatasetCoreFactory` and `NotifyingDatasetCoreFactory` + * in the synchronous API. + */ +export interface AsyncNotifyingDatasetCoreFactory< + OutQuad extends BaseQuad = Quad, + InQuad extends BaseQuad = OutQuad, + D extends AsyncDatasetCore = AsyncNotifyingDatasetCore, +> extends AsyncDatasetCoreFactory { + dataset(quads?: AsyncIterable | Iterable): Promise +} + +/** + * Wraps any {@link AsyncDatasetCore} (or a synchronous {@link DatasetCore}, + * which it treats as if every call were already resolved) and surfaces + * change events on `add` / `delete`. + * + * Notifications are dispatched _after_ the underlying mutation has + * completed; if a listener returns a {@link Promise}, the dataset awaits + * it before invoking the next listener. The set of listeners snapshot + * at the start of each emit, so adding or removing listeners inside a + * listener does not affect the in-flight dispatch. + * + * @example Bridging a synchronous store + * ```ts + * import { Store } from "n3" + * + * const store = new Store() + * const asyncDataset = new AsyncNotifyingDatasetCoreWrapper(store) + * + * asyncDataset.on(async (event, quad) => { + * await audit.record(event, quad) + * }) + * + * await asyncDataset.add(quad) + * console.log(await asyncDataset.size) + * ``` + */ +export class AsyncNotifyingDatasetCoreWrapper< + OutQuad extends BaseQuad = Quad, + InQuad extends BaseQuad = OutQuad, +> implements AsyncNotifyingDatasetCore { + private readonly listeners = new Set>() + private readonly inner: AsyncDatasetCore + + public constructor(dataset: AsyncDatasetCore | DatasetCore) { + this.inner = isAsync(dataset) ? dataset : new SyncToAsyncDatasetAdapter(dataset) + } + + on(listener: AsyncListener): void { + this.listeners.add(listener) + } + + off(listener: AsyncListener): void { + this.listeners.delete(listener) + } + + get size(): Promise { + return this.inner.size + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.inner[Symbol.asyncIterator]() + } + + async add(quad: InQuad): Promise { + await this.inner.add(quad) + await this.emit("add", quad) + return this + } + + async delete(quad: InQuad): Promise { + await this.inner.delete(quad) + await this.emit("delete", quad) + return this + } + + has(quad: InQuad): Promise { + return this.inner.has(quad) + } + + match( + subject?: OutQuad["subject"] | null, + predicate?: OutQuad["predicate"] | null, + object?: OutQuad["object"] | null, + graph?: OutQuad["graph"] | null, + ): AsyncNotifyingDatasetCore { + return ensureAsyncNotifyingDatasetCore(this.inner.match(subject, predicate, object, graph)) + } + + private async emit(event: ChangeEvent, quad: InQuad): Promise { + // Snapshot listeners so detach/attach during emit does not affect + // the in-flight dispatch. + const snapshot = Array.from(this.listeners) + for (const listener of snapshot) { + const r = listener(event, quad) + if (r !== undefined) { + await r + } + } + } +} + +/** + * Returns `dataset` unchanged if it is already an + * {@link AsyncNotifyingDatasetCore}, otherwise wraps it in an + * {@link AsyncNotifyingDatasetCoreWrapper}. Convenient when the caller + * does not know whether the supplied dataset already supports change + * notifications. + */ +export function ensureAsyncNotifyingDatasetCore< + OutQuad extends BaseQuad = Quad, + InQuad extends BaseQuad = OutQuad, +>(dataset: AsyncDatasetCore | DatasetCore): AsyncNotifyingDatasetCore { + if ( + "on" in dataset && typeof (dataset as { on?: unknown }).on === "function" && + "off" in dataset && typeof (dataset as { off?: unknown }).off === "function" && + Symbol.asyncIterator in dataset + ) { + return dataset as AsyncNotifyingDatasetCore + } + return new AsyncNotifyingDatasetCoreWrapper(dataset) +} + +function isAsync( + dataset: AsyncDatasetCore | DatasetCore, +): dataset is AsyncDatasetCore { + return Symbol.asyncIterator in dataset +} + +/** + * Bridges a synchronous {@link DatasetCore} into the async surface so + * implementations like the n3 `Store` can be exposed through the async + * pipeline. Every method simply wraps the synchronous result in a + * resolved {@link Promise}. + */ +class SyncToAsyncDatasetAdapter + implements AsyncDatasetCore { + public constructor(private readonly inner: DatasetCore) {} + + get size(): Promise { + return Promise.resolve(this.inner.size) + } + + async add(quad: InQuad): Promise { + this.inner.add(quad) + return this + } + + async delete(quad: InQuad): Promise { + this.inner.delete(quad) + return this + } + + async has(quad: InQuad): Promise { + return this.inner.has(quad) + } + + match( + subject?: OutQuad["subject"] | null, + predicate?: OutQuad["predicate"] | null, + object?: OutQuad["object"] | null, + graph?: OutQuad["graph"] | null, + ): AsyncDatasetCore { + return new SyncToAsyncDatasetAdapter( + this.inner.match( + subject ?? undefined, + predicate ?? undefined, + object ?? undefined, + graph ?? undefined, + ), + ) + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for (const q of this.inner) { + yield q + } + } +} diff --git a/src/async/AsyncTermWrapper.ts b/src/async/AsyncTermWrapper.ts new file mode 100644 index 0000000..56a62c0 --- /dev/null +++ b/src/async/AsyncTermWrapper.ts @@ -0,0 +1,118 @@ +import type { BaseQuad, DataFactory, Literal, NamedNode, Term } from "@rdfjs/types" +import type { IRdfJsTerm } from "../type/IRdfJsTerm.js" +import type { AsyncDefaultDatasetCore } from "./AsyncNotifyingDatasetCore.js" +import type { Triple } from "../type/ITriple.js" + +/** + * Asynchronous counterpart of {@link "../TermWrapper.js"!TermWrapper}. + * + * Exposes the same RDF/JS {@link Term} surface but is bound to an + * {@link AsyncDefaultDatasetCore}, so any traversal that needs to read or + * write the underlying dataset returns a {@link Promise}. + * + * The term identity members ({@link termType}, {@link value}, + * {@link equals}, etc.) are intentionally synchronous because they only + * inspect the wrapped term and do not touch the dataset. + * + * @example Defining an async wrapper + * Property getters return promises (for required / optional reads) or + * an {@link "./AsyncWrappingSet.js"!AsyncWrappingSet} (for set + * mappings). Because JavaScript property setters cannot be `async`, + * write-mappings are exposed as `setX(value)` methods that return a + * {@link Promise}: + * ```ts + * class AsyncPerson extends AsyncTermWrapper { + * get name(): Promise { + * return AsyncOptionalFrom.subjectPredicate(this, "https://example.org/name", AsyncLiteralAs.string) + * } + * setName(value: string | undefined): Promise { + * return AsyncOptionalAs.object(this, "https://example.org/name", value, LiteralFrom.string) + * } + * } + * + * const alice = new AsyncPerson("https://example.org/alice", asyncDataset, factory) + * console.log(await alice.name) // "Alice" + * await alice.setName("Alicia") + * ``` + */ +export class AsyncTermWrapper implements IRdfJsTerm { + private readonly original: Term + private readonly _dataset: AsyncDefaultDatasetCore + private readonly _factory: DataFactory + + public constructor( + term: string | Term, + dataset: AsyncDefaultDatasetCore, + factory: DataFactory, + ) { + this.original = typeof term === "string" ? factory.namedNode(term) : term + this._dataset = dataset + this._factory = factory + } + + /** The dataset this term lives in. All reads/writes through this wrapper go via this dataset. */ + get dataset(): AsyncDefaultDatasetCore { + return this._dataset + } + + /** The factory for creating additional terms and quads. */ + get factory(): DataFactory { + return this._factory + } + + get [Symbol.toStringTag](): string { + return this.constructor.name + } + + //#region Term + + get termType(): Term["termType"] { + return this.original.termType + } + + get value(): string { + return this.original.value + } + + equals(other: Term | null | undefined): boolean { + return this.original.equals(other) + } + + //#region Literal + + get language(): string { + return (this.original as Literal).language + } + + get direction(): Literal["direction"] { + return (this.original as Literal).direction + } + + get datatype(): NamedNode { + return (this.original as Literal).datatype + } + + //#endregion + + //#region Quad + + get subject(): Term { + return (this.original as BaseQuad).subject + } + + get predicate(): Term { + return (this.original as BaseQuad).predicate + } + + get object(): Term { + return (this.original as BaseQuad).object + } + + get graph(): Term { + return (this.original as BaseQuad).graph + } + + //#endregion + + //#endregion +} diff --git a/src/async/AsyncWrappingSet.ts b/src/async/AsyncWrappingSet.ts new file mode 100644 index 0000000..e74c872 --- /dev/null +++ b/src/async/AsyncWrappingSet.ts @@ -0,0 +1,241 @@ +import type { DataFactory, Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { Triple } from "../type/ITriple.js" +import type { ChangeEvent } from "../dataset/NotifyingDatasetCore.js" +import type { IAsyncTermAsValueMapping } from "./type/IAsyncTermAsValueMapping.js" +import type { IAsyncTermFromValueMapping } from "./type/IAsyncTermFromValueMapping.js" +import { AsyncTermWrapper } from "./AsyncTermWrapper.js" + +/** + * Listener invoked when a value is added to or removed from an + * {@link AsyncWrappingSet}. May be synchronous or asynchronous - the + * dataset awaits returned promises before invoking the next listener. + */ +export type AsyncWrappingSetListener = + (event: ChangeEvent, value: T) => void | Promise + +const listenerAdapters = new WeakMap< + AsyncWrappingSetListener, + Map void | Promise> +>() + +/** + * Asynchronous counterpart of + * {@link "../WrappingSet.js"!WrappingSet}. + * + * Provides a live, mutable view over the objects of all + * ` ?o` quads in the wrapped dataset's default + * graph. Cannot implement {@link Set} (the standard interface is + * synchronous) but offers the same shape with `Promise`-returning + * methods and an asynchronous iterator. + * + * The set is **live**: iteration, {@link size} and {@link has} re-query + * the dataset on every call, so the contents always reflect the + * dataset's current state. Mutations performed via {@link add}, + * {@link delete} and {@link clear} write through to the dataset and + * surface as change events on the underlying + * {@link "./AsyncNotifyingDatasetCore.js"!AsyncNotifyingDatasetCore}. + * + * @example Iterating + * ```ts + * for await (const child of parent.children) { + * console.log(await child.name) + * } + * console.log(await parent.children.size) + * ``` + * + * @example Subscribing to changes + * Listeners receive the change type (`'add'` or `'delete'`) and the + * **mapped JavaScript value** for this set's subject + predicate, so + * callers do not need to filter dataset-wide events themselves. + * Listeners may be `async`; the dispatcher awaits a returned promise + * before invoking the next listener: + * ```ts + * parent.children.on(async (event, child) => { + * console.log(event, await child.name) + * }) + * + * await parent.children.add(bob) // logs: add, "Bob" + * await parent.children.delete(bob) // logs: delete, "Bob" + * ``` + * + * @example Listener identity across instances + * As with the sync sibling, {@link off} is keyed by + * `(listener, subject, predicate)` rather than by instance, so the + * fresh {@link AsyncWrappingSet} returned by every property access is + * still a valid handle for detaching: + * ```ts + * parent.children.on(listener) + * parent.children.off(listener) // detaches the listener attached above + * ``` + */ +export class AsyncWrappingSet implements AsyncIterable { + public constructor( + private readonly subject: AsyncTermWrapper, + private readonly predicate: string, + private readonly termAs: IAsyncTermAsValueMapping, + private readonly termFrom: IAsyncTermFromValueMapping, + ) {} + + async add(value: T): Promise { + await this.subject.dataset.add(this.quad(value)) + return this + } + + async clear(): Promise { + const existing: Triple[] = [] + for await (const q of this.matches) { + existing.push(q) + } + for (const q of existing) { + await this.subject.dataset.delete(q) + } + } + + async delete(value: T): Promise { + if (!(await this.has(value))) { + return false + } + + const o = this.termFrom(value, this.subject.factory) + const p = this.subject.factory.namedNode(this.predicate) + const matches = this.subject.dataset.match( + this.subject as unknown as Quad_Subject, + p, + o as Quad_Object, + this.subject.factory.defaultGraph(), + ) + + const queue: Triple[] = [] + for await (const q of matches) { + queue.push(q) + } + for (const q of queue) { + await this.subject.dataset.delete(q) + } + return true + } + + async has(value: T): Promise { + return this.subject.dataset.has(this.quad(value)) + } + + get size(): Promise { + return this.matches.size + } + + async forEach( + cb: (item: T, index: T, set: this) => void | Promise, + thisArg?: unknown, + ): Promise { + for await (const item of this) { + const r = cb.call(thisArg, item, item, this) + if (r !== undefined) { + await r + } + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + yield* this.values() + } + + async *values(): AsyncIterableIterator { + for await (const q of this.matches) { + yield await this.termAs(new AsyncTermWrapper(q.object, this.subject.dataset, this.subject.factory)) + } + } + + async *keys(): AsyncIterableIterator { + yield* this.values() + } + + async *entries(): AsyncIterableIterator<[T, T]> { + for await (const v of this.values()) { + yield [v, v] + } + } + + get [Symbol.toStringTag](): string { + return this.constructor.name + } + + private quad(value: T): Triple { + const s = this.subject as unknown as Quad_Subject + const p = this.subject.factory.namedNode(this.predicate) + const o = this.termFrom(value, this.subject.factory) as Quad_Object + return this.subject.factory.quad(s, p, o) + } + + private get matches() { + const p = this.subject.factory.namedNode(this.predicate) + return this.subject.dataset.match( + this.subject as unknown as Quad_Subject, + p, + undefined, + this.subject.factory.defaultGraph(), + ) + } + + /** + * Subscribes `listener` to additions and removals on this set. The + * listener is invoked once per matching change event and receives the + * mapped value via {@link IAsyncTermAsValueMapping}. + */ + public on(listener: AsyncWrappingSetListener): void { + const subject = this.subject as unknown as Quad_Subject + const predicate = this.subject.factory.namedNode(this.predicate) + const dataset = this.subject.dataset + const factory: DataFactory = this.subject.factory + const termAs = this.termAs + const key = this.adapterKey + + const adapter = async (event: ChangeEvent, q: Triple): Promise => { + if (!q.subject.equals(subject as unknown as Term) || !q.predicate.equals(predicate)) { + return + } + if (q.graph.termType !== "DefaultGraph") { + return + } + const value = await termAs(new AsyncTermWrapper(q.object, dataset, factory)) + const r = listener(event, value) + if (r !== undefined) { + await r + } + } + + let perKey = listenerAdapters.get(listener as AsyncWrappingSetListener) + if (perKey === undefined) { + perKey = new Map() + listenerAdapters.set(listener as AsyncWrappingSetListener, perKey) + } + + const existing = perKey.get(key) + if (existing !== undefined) { + dataset.off(existing) + } + + perKey.set(key, adapter) + dataset.on(adapter) + } + + public off(listener: AsyncWrappingSetListener): void { + const perKey = listenerAdapters.get(listener as AsyncWrappingSetListener) + if (perKey === undefined) { + return + } + const key = this.adapterKey + const adapter = perKey.get(key) + if (adapter === undefined) { + return + } + perKey.delete(key) + if (perKey.size === 0) { + listenerAdapters.delete(listener as AsyncWrappingSetListener) + } + this.subject.dataset.off(adapter) + } + + private get adapterKey(): string { + return `${(this.subject as unknown as Term).value}\u0000${this.predicate}` + } +} diff --git a/src/async/mapping/AsyncLiteralAs.ts b/src/async/mapping/AsyncLiteralAs.ts new file mode 100644 index 0000000..4aef419 --- /dev/null +++ b/src/async/mapping/AsyncLiteralAs.ts @@ -0,0 +1,100 @@ +import type { ILangString } from "../../type/ILangString.js" +import { XSD } from "../../vocabulary/XSD.js" +import { RDF } from "../../vocabulary/RDF.js" +import { ensureDatatype, ensureIs, ensurePresent, ensureTermType } from "../../ensure.js" +import { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/LiteralAs.js"!LiteralAs}. + * + * The mappings themselves are pure (they only read the term's lexical + * value, datatype and language) and therefore return their JavaScript + * value synchronously - but they accept an {@link AsyncTermWrapper} + * rather than a sync {@link "../../TermWrapper.js"!TermWrapper}, so they + * can be plugged directly into {@link IAsyncTermAsValueMapping} slots. + */ +export namespace AsyncLiteralAs { + export function bigint(term: AsyncTermWrapper): bigint { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + ensureTermType(term, "Literal") + ensureDatatype(term, ...integerDatatypes) + return BigInt(term.value) + } + + export function boolean(term: AsyncTermWrapper): boolean { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + ensureTermType(term, "Literal") + ensureDatatype(term, XSD.boolean) + return term.value === "true" || term.value === "1" + } + + export function date(term: AsyncTermWrapper): Date { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + ensureTermType(term, "Literal") + ensureDatatype(term, ...dateDatatypes) + return new Date(term.value) + } + + export function langString(term: AsyncTermWrapper): ILangString { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + ensureTermType(term, "Literal") + ensureDatatype(term, RDF.langString) + return { lang: term.language, string: term.value } + } + + export function number(term: AsyncTermWrapper): number { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + ensureTermType(term, "Literal") + ensureDatatype(term, ...numericDatatypes) + if (term.value === "INF") return Number.POSITIVE_INFINITY + if (term.value === "-INF") return Number.NEGATIVE_INFINITY + if (term.value === "NaN") return Number.NaN + return Number(term.value) + } + + export function string(term: AsyncTermWrapper): string { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + return term.value + } + + export function symbol(term: AsyncTermWrapper): symbol { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + return Symbol.for(term.value) + } +} + +const integerDatatypes: string[] = [ + XSD.integer, + XSD.long, + XSD.int, + XSD.short, + XSD.byte, + XSD.nonNegativeInteger, + XSD.positiveInteger, + XSD.unsignedLong, + XSD.unsignedInt, + XSD.unsignedShort, + XSD.unsignedByte, + XSD.nonPositiveInteger, + XSD.negativeInteger, +] + +const dateDatatypes: string[] = [ + XSD.date, + XSD.dateTime, +] + +const numericDatatypes: string[] = [ + ...integerDatatypes, + XSD.decimal, + XSD.double, + XSD.float, +] diff --git a/src/async/mapping/AsyncOptionalAs.ts b/src/async/mapping/AsyncOptionalAs.ts new file mode 100644 index 0000000..e0543a5 --- /dev/null +++ b/src/async/mapping/AsyncOptionalAs.ts @@ -0,0 +1,65 @@ +import type { Quad_Object, Quad_Subject, Term } from "@rdfjs/types" +import type { IAsyncTermFromValueMapping } from "../type/IAsyncTermFromValueMapping.js" +import type { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/OptionalAs.js"!OptionalAs}. + * + * Removes any existing quads matching the supplied subject + predicate + * and, if `value` is defined, asserts a single new quad whose object is + * derived from `value` via `termFrom`. + */ +export namespace AsyncOptionalAs { + export async function object( + anchor: AsyncTermWrapper, + p: string, + value: T | undefined, + termFrom: IAsyncTermFromValueMapping, + ): Promise { + if (termFrom === undefined) { + throw new Error("termFrom is required") + } + + const predicate = anchor.factory.namedNode(p) + const matches = anchor.dataset.match( + anchor as unknown as Quad_Subject, + predicate, + undefined, + anchor.factory.defaultGraph(), + ) + + // Materialise the existing quads first; deleting while iterating + // a live view from an async match could observe writes. + const existing: Array<{ subject: Term; predicate: Term; object: Term; graph: Term }> = [] + for await (const q of matches) { + existing.push(q) + } + for (const q of existing) { + await anchor.dataset.delete(q as never) + } + + if (value === undefined) { + return + } + if (!isQuadSubject(anchor as unknown as Term)) { + return + } + + const o = termFrom(value, anchor.factory) + if (o === undefined || !isQuadObject(o)) { + return + } + + const q = anchor.factory.quad(anchor as unknown as Quad_Subject, predicate, o as Quad_Object) + await anchor.dataset.add(q) + } +} + +function isQuadSubject(term: Term): term is Quad_Subject { + return ["NamedNode", "BlankNode", "Quad", "Variable"].includes(term.termType) +} + +function isQuadObject(term: Term): term is Quad_Object { + return ["NamedNode", "Literal", "BlankNode", "Quad", "Variable"].includes(term.termType) +} diff --git a/src/async/mapping/AsyncOptionalFrom.ts b/src/async/mapping/AsyncOptionalFrom.ts new file mode 100644 index 0000000..c219b92 --- /dev/null +++ b/src/async/mapping/AsyncOptionalFrom.ts @@ -0,0 +1,35 @@ +import type { Quad_Subject } from "@rdfjs/types" +import type { IAsyncTermAsValueMapping } from "../type/IAsyncTermAsValueMapping.js" +import { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/OptionalFrom.js"!OptionalFrom}. Returns the + * mapped value of the first matching quad, or `undefined` if none + * exists. + */ +export namespace AsyncOptionalFrom { + export async function subjectPredicate( + anchor: AsyncTermWrapper, + p: string, + termAs: IAsyncTermAsValueMapping, + ): Promise { + if (termAs === undefined) { + throw new Error("termAs is required") + } + + const predicate = anchor.factory.namedNode(p) + const matches = anchor.dataset.match( + anchor as unknown as Quad_Subject, + predicate, + undefined, + anchor.factory.defaultGraph(), + ) + + for await (const q of matches) { + return termAs(new AsyncTermWrapper(q.object, anchor.dataset, anchor.factory)) + } + + return undefined + } +} diff --git a/src/async/mapping/AsyncRequiredAs.ts b/src/async/mapping/AsyncRequiredAs.ts new file mode 100644 index 0000000..ce01044 --- /dev/null +++ b/src/async/mapping/AsyncRequiredAs.ts @@ -0,0 +1,22 @@ +import type { IAsyncTermFromValueMapping } from "../type/IAsyncTermFromValueMapping.js" +import type { AsyncTermWrapper } from "../AsyncTermWrapper.js" +import { AsyncOptionalAs } from "./AsyncOptionalAs.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/RequiredAs.js"!RequiredAs}. Throws if `value` is + * `undefined`; otherwise delegates to {@link AsyncOptionalAs.object}. + */ +export namespace AsyncRequiredAs { + export function object( + anchor: AsyncTermWrapper, + p: string, + value: T, + termFrom: IAsyncTermFromValueMapping, + ): Promise { + if (value === undefined) { + throw new Error("value cannot be undefined") + } + return AsyncOptionalAs.object(anchor, p, value, termFrom) + } +} diff --git a/src/async/mapping/AsyncRequiredFrom.ts b/src/async/mapping/AsyncRequiredFrom.ts new file mode 100644 index 0000000..69e000d --- /dev/null +++ b/src/async/mapping/AsyncRequiredFrom.ts @@ -0,0 +1,43 @@ +import type { Quad_Subject } from "@rdfjs/types" +import type { IAsyncTermAsValueMapping } from "../type/IAsyncTermAsValueMapping.js" +import { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/RequiredFrom.js"!RequiredFrom}. + * + * Reads exactly one matching quad from the wrapped dataset and returns + * the value produced by `termAs`. Throws if zero or more than one quad + * matches the supplied subject + predicate. + */ +export namespace AsyncRequiredFrom { + export async function subjectPredicate( + anchor: AsyncTermWrapper, + p: string, + termAs: IAsyncTermAsValueMapping, + ): Promise { + if (termAs === undefined) { + throw new Error("termAs is required") + } + + const predicate = anchor.factory.namedNode(p) + const matches = anchor.dataset.match( + anchor as unknown as Quad_Subject, + predicate, + undefined, + anchor.factory.defaultGraph(), + ) + + const iterator = matches[Symbol.asyncIterator]() + const first = await iterator.next() + if (first.done) { + throw new Error(`No value found for predicate ${p} on term ${anchor.value}`) + } + const second = await iterator.next() + if (!second.done) { + throw new Error(`More than one value for predicate ${p} on term ${anchor.value}`) + } + + return termAs(new AsyncTermWrapper(first.value.object, anchor.dataset, anchor.factory)) + } +} diff --git a/src/async/mapping/AsyncSetFrom.ts b/src/async/mapping/AsyncSetFrom.ts new file mode 100644 index 0000000..a87c866 --- /dev/null +++ b/src/async/mapping/AsyncSetFrom.ts @@ -0,0 +1,26 @@ +import type { IAsyncTermAsValueMapping } from "../type/IAsyncTermAsValueMapping.js" +import type { IAsyncTermFromValueMapping } from "../type/IAsyncTermFromValueMapping.js" +import type { AsyncTermWrapper } from "../AsyncTermWrapper.js" +import { AsyncWrappingSet } from "../AsyncWrappingSet.js" + +/** + * Asynchronous counterpart of + * {@link "../../mapping/SetFrom.js"!SetFrom}. Returns an + * {@link AsyncWrappingSet} bound to the supplied subject + predicate. + */ +export namespace AsyncSetFrom { + export function subjectPredicate( + anchor: AsyncTermWrapper, + p: string, + termAs: IAsyncTermAsValueMapping, + termFrom: IAsyncTermFromValueMapping, + ): AsyncWrappingSet { + if (termAs === undefined) { + throw new Error("termAs is required") + } + if (termFrom === undefined) { + throw new Error("termFrom is required") + } + return new AsyncWrappingSet(anchor, p, termAs, termFrom) + } +} diff --git a/src/async/mapping/AsyncTermAs.ts b/src/async/mapping/AsyncTermAs.ts new file mode 100644 index 0000000..74d7955 --- /dev/null +++ b/src/async/mapping/AsyncTermAs.ts @@ -0,0 +1,34 @@ +import type { Term } from "@rdfjs/types" +import type { IAsyncTermAsValueMapping } from "../type/IAsyncTermAsValueMapping.js" +import type { IAsyncTermWrapperConstructor } from "../type/IAsyncTermWrapperConstructor.js" +import { AsyncTermWrapper } from "../AsyncTermWrapper.js" +import { ensureIs, ensurePresent } from "../../ensure.js" + +/** + * Asynchronous counterpart of {@link "../../mapping/TermAs.js"!TermAs}. + * Provides mappers from RDF terms to user types built around + * {@link AsyncTermWrapper}. + */ +export namespace AsyncTermAs { + /** + * Mapper that constructs a wrapper of `constructor` from the term. + * The new wrapper inherits the dataset and factory of the source. + */ + export function instance(constructor: IAsyncTermWrapperConstructor): IAsyncTermAsValueMapping { + return (term: AsyncTermWrapper) => { + ensurePresent(term) + ensureIs(term, AsyncTermWrapper) + return new constructor(term as unknown as Term, term.dataset, term.factory) + } + } + + /** Identity mapper - returns the supplied wrapper unchanged. */ + export function is(term: T): T { + return term + } + + /** Maps to the underlying {@link Term}. */ + export function term(term: AsyncTermWrapper): Term { + return term as unknown as Term + } +} diff --git a/src/async/type/IAsyncTermAsValueMapping.ts b/src/async/type/IAsyncTermAsValueMapping.ts new file mode 100644 index 0000000..5d442fd --- /dev/null +++ b/src/async/type/IAsyncTermAsValueMapping.ts @@ -0,0 +1,9 @@ +import type { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Maps an {@link AsyncTermWrapper} to a JavaScript value. The mapping + * may be synchronous (returning the value directly) or asynchronous + * (returning a {@link Promise}); consumers `await` the result either + * way. + */ +export type IAsyncTermAsValueMapping = (term: AsyncTermWrapper) => T | Promise diff --git a/src/async/type/IAsyncTermFromValueMapping.ts b/src/async/type/IAsyncTermFromValueMapping.ts new file mode 100644 index 0000000..b366092 --- /dev/null +++ b/src/async/type/IAsyncTermFromValueMapping.ts @@ -0,0 +1,13 @@ +import type { DataFactory, Term } from "@rdfjs/types" +import type { Triple } from "../../type/ITriple.js" + +/** + * Maps a JavaScript value to an RDF/JS {@link Term}. Term creation is + * pure and never needs to touch the dataset, so this signature is + * synchronous - identical to its sync counterpart, but re-exported here + * for symmetry with {@link IAsyncTermAsValueMapping}. + */ +export type IAsyncTermFromValueMapping = ( + value: T, + factory: DataFactory, +) => Term diff --git a/src/async/type/IAsyncTermWrapperConstructor.ts b/src/async/type/IAsyncTermWrapperConstructor.ts new file mode 100644 index 0000000..69abe42 --- /dev/null +++ b/src/async/type/IAsyncTermWrapperConstructor.ts @@ -0,0 +1,15 @@ +import type { DataFactory, Term } from "@rdfjs/types" +import type { AsyncDefaultDatasetCore } from "../AsyncNotifyingDatasetCore.js" +import type { Triple } from "../../type/ITriple.js" +import { AsyncTermWrapper } from "../AsyncTermWrapper.js" + +/** + * Constructor signature for an {@link AsyncTermWrapper} subclass. Mirrors + * {@link "../../type/ITermWrapperConstructor.js"!ITermWrapperConstructor} + * for the asynchronous surface. + */ +export type IAsyncTermWrapperConstructor = new ( + term: Term, + dataset: AsyncDefaultDatasetCore, + factory: DataFactory, +) => T diff --git a/src/mod.ts b/src/mod.ts index d3d793f..02ae727 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -36,3 +36,22 @@ export * from "./errors/LiteralDatatypeError.js" export * from "./errors/ListRootError.js" export * from "./errors/QuadError.js" export * from "./errors/NamedGraphError.js" + +// Async surface +export type * from "./async/type/IAsyncTermAsValueMapping.js" +export type * from "./async/type/IAsyncTermFromValueMapping.js" +export type * from "./async/type/IAsyncTermWrapperConstructor.js" + +export * from "./async/AsyncDatasetCore.js" +export * from "./async/AsyncNotifyingDatasetCore.js" +export * from "./async/AsyncTermWrapper.js" +export * from "./async/AsyncDatasetWrapper.js" +export * from "./async/AsyncWrappingSet.js" + +export * from "./async/mapping/AsyncTermAs.js" +export * from "./async/mapping/AsyncLiteralAs.js" +export * from "./async/mapping/AsyncRequiredFrom.js" +export * from "./async/mapping/AsyncRequiredAs.js" +export * from "./async/mapping/AsyncOptionalFrom.js" +export * from "./async/mapping/AsyncOptionalAs.js" +export * from "./async/mapping/AsyncSetFrom.js" diff --git a/test/unit/async_dataset_wrapper.test.ts b/test/unit/async_dataset_wrapper.test.ts new file mode 100644 index 0000000..898ea80 --- /dev/null +++ b/test/unit/async_dataset_wrapper.test.ts @@ -0,0 +1,94 @@ +import { dataFactory } from "./util/dataFactory.js" +import assert from "node:assert" +import { describe, it } from "node:test" +import { AsyncParentDataset } from "./model/AsyncParentDataset.js" +import { asyncDatasetFromRdf } from "./util/asyncDatasetFromRdf.js" +import { asyncN3StoreFactory } from "./util/asyncN3StoreFactory.js" + +const rdf = ` +prefix : + + + a :Parent ; + :hasString "o1" ; + :hasChild [ + :hasString "child string 1" ; + ] ; + :hasChildSet [ + :hasString "child string 2" ; + ], [ + :hasString "child string 3" ; + ] ; +. + + :hasString "o2" ; + :hasChild ; +. + + :hasString "child string 4" ; +. +` + +async function collect(it: AsyncIterable): Promise { + const arr: T[] = [] + for await (const x of it) { + arr.push(x) + } + return arr +} + +await describe("Async Dataset Wrappers", async () => { + const parentDataset = new AsyncParentDataset(asyncDatasetFromRdf(rdf), dataFactory, asyncN3StoreFactory) + + await it("get instances of Parent as AsyncParent", async () => { + const parents = await collect(parentDataset.instancesOfParent) + assert.equal(parents.length, 1) + for (const parent of parents) { + assert.equal(await parent.hasString, "o1") + } + }) + + await it("get subjects of hasChild as AsyncParent instances", async () => { + const parents = await collect(parentDataset.subjectsOfHasChild) + assert.equal(parents.length, 2) + for (const parent of parents) { + assert.equal(["o1", "o2"].includes((await parent.hasString)!), true) + } + }) + + await it("get objects of hasChild as AsyncChild instances", async () => { + const children = await collect(parentDataset.objectsOfHasChild) + assert.equal(children.length, 2) + for (const child of children) { + assert.equal(["child string 1", "child string 4"].includes((await child.hasString)!), true) + } + }) + + await it("get matching subjects of `?s ?p :Parent ?g` as AsyncParent instances", async () => { + const parents = await collect(parentDataset.matchSubjectsOfPropertyanyObjectparentGraphany) + assert.equal(parents.length, 1) + for (const parent of parents) { + assert.equal(await parent.hasString, "o1") + } + }) + + await it("get matching objects of ` :hasChild ?o ?g` as AsyncChild instances", async () => { + const children = await collect(parentDataset.matchObjectsOfSubjectxPropertyhaschildGraphany) + assert.equal(children.length, 1) + for (const child of children) { + assert.equal(await child.hasString, "child string 1") + } + }) + + await it("iterates asynchronously", async () => { + const all = await collect(parentDataset) + assert.equal(all.length, 11) + for (const x of all) { + assert.equal(x.equals(x), true) + } + }) + + await it("size resolves to triple count", async () => { + assert.equal(await parentDataset.size, 11) + }) +}) diff --git a/test/unit/async_term_wrapper.test.ts b/test/unit/async_term_wrapper.test.ts new file mode 100644 index 0000000..2375cde --- /dev/null +++ b/test/unit/async_term_wrapper.test.ts @@ -0,0 +1,138 @@ +import { dataFactory } from "./util/dataFactory.js" +import assert from "node:assert" +import { describe, it } from "node:test" +import { AsyncParent } from "./model/AsyncParent.js" +import { AsyncChild } from "./model/AsyncChild.js" +import { AsyncDatasetWrapper, type ChangeEvent } from "@rdfjs/wrapper" +import { asyncDatasetFromRdf } from "./util/asyncDatasetFromRdf.js" +import { asyncN3StoreFactory } from "./util/asyncN3StoreFactory.js" + +const rdf = ` +prefix : + + + a :Parent ; + :hasString "o1" ; + :hasNumber "42"^^ ; + :hasBoolean "true"^^ ; + :hasLangString "hello"@en ; + :hasNullableString "maybe" ; + :hasChild ; + :hasChildSet , ; +. + :hasString "child 1" . + :hasString "child 2" . + :hasString "child 3" . +` + +await describe("Async Term Wrappers", async () => { + const buildParent = () => { + const dataset = asyncDatasetFromRdf(rdf, "https://example.org/") + const wrapped = new AsyncDatasetWrapper(dataset, dataFactory, asyncN3StoreFactory) + return new AsyncParent("https://example.org/x", wrapped, dataFactory) + } + + await it("reads required value mappings as promises", async () => { + const parent = buildParent() + assert.equal(await parent.hasString, "o1") + assert.equal(await parent.hasNumber, 42) + assert.equal(await parent.hasBoolean, true) + const lang = await parent.hasLangString + assert.deepEqual(lang, { lang: "en", string: "hello" }) + }) + + await it("reads optional value mappings as promises", async () => { + const parent = buildParent() + assert.equal(await parent.hasNullableString, "maybe") + }) + + await it("returns undefined for missing optional values", async () => { + const dataset = asyncDatasetFromRdf("") + const wrapped = new AsyncDatasetWrapper(dataset, dataFactory, asyncN3StoreFactory) + const child = new AsyncChild("https://example.org/missing", wrapped, dataFactory) + assert.equal(await child.hasString, undefined) + }) + + await it("writes through optional setters", async () => { + const parent = buildParent() + await parent.setHasNullableString("changed") + assert.equal(await parent.hasNullableString, "changed") + + await parent.setHasNullableString(undefined) + assert.equal(await parent.hasNullableString, undefined) + }) + + await it("writes through required setters", async () => { + const parent = buildParent() + await parent.setHasString("new value") + assert.equal(await parent.hasString, "new value") + }) + + await it("traverses object mappings asynchronously", async () => { + const parent = buildParent() + const child = await parent.hasChild + assert.ok(child instanceof AsyncChild) + assert.equal(await child.hasString, "child 1") + }) + + await it("iterates async wrapping sets", async () => { + const parent = buildParent() + const seen: string[] = [] + for await (const child of parent.hasChildSet) { + seen.push((await child.hasString)!) + } + assert.equal(seen.length, 2) + assert.deepEqual(seen.sort(), ["child 2", "child 3"]) + }) + + await it("size and has on async wrapping sets", async () => { + const parent = buildParent() + const set = parent.hasChildSet + assert.equal(await set.size, 2) + const c2 = new AsyncChild("https://example.org/c2", parent.dataset, dataFactory) + assert.equal(await set.has(c2), true) + }) + + await it("add/delete on async wrapping sets emits events", async () => { + const parent = buildParent() + const set = parent.hasChildSet + const events: Array<[ChangeEvent, string]> = [] + const listener = async (event: ChangeEvent, value: AsyncChild) => { + events.push([event, value.value]) + } + set.on(listener) + + const c4 = new AsyncChild("https://example.org/c4", parent.dataset, dataFactory) + await set.add(c4) + assert.equal(await set.size, 3) + + await set.delete(c4) + assert.equal(await set.size, 2) + + set.off(listener) + + assert.deepEqual(events, [ + ["add", "https://example.org/c4"], + ["delete", "https://example.org/c4"], + ]) + }) + + await it("dataset emits events on add and delete", async () => { + const dataset = asyncDatasetFromRdf("") + const wrapped = new AsyncDatasetWrapper(dataset, dataFactory, asyncN3StoreFactory) + const events: ChangeEvent[] = [] + wrapped.on(async (event, _q) => { + events.push(event) + }) + + const s = dataFactory.namedNode("https://example.org/s") + const p = dataFactory.namedNode("https://example.org/p") + const o = dataFactory.literal("v") + const q = dataFactory.quad(s, p, o) + + await wrapped.add(q) + await wrapped.delete(q) + + assert.deepEqual(events, ["add", "delete"]) + }) +}) diff --git a/test/unit/model/AsyncChild.ts b/test/unit/model/AsyncChild.ts new file mode 100644 index 0000000..626fe5d --- /dev/null +++ b/test/unit/model/AsyncChild.ts @@ -0,0 +1,22 @@ +import { + AsyncOptionalAs, + AsyncOptionalFrom, + AsyncLiteralAs, + AsyncTermWrapper, + LiteralFrom, +} from "@rdfjs/wrapper" +import { Example } from "../vocabulary/Example.js" + +/** + * Async counterpart of `Child`. Properties return promises and + * mutations are awaited. + */ +export class AsyncChild extends AsyncTermWrapper { + public get hasString(): Promise { + return AsyncOptionalFrom.subjectPredicate(this, Example.hasString, AsyncLiteralAs.string) + } + + public setHasString(value: string | undefined): Promise { + return AsyncOptionalAs.object(this, Example.hasString, value, LiteralFrom.string) + } +} diff --git a/test/unit/model/AsyncParent.ts b/test/unit/model/AsyncParent.ts new file mode 100644 index 0000000..c5dfc85 --- /dev/null +++ b/test/unit/model/AsyncParent.ts @@ -0,0 +1,111 @@ +import type { ILangString, AsyncWrappingSet } from "@rdfjs/wrapper" +import { + AsyncLiteralAs, + AsyncOptionalAs, + AsyncOptionalFrom, + AsyncRequiredAs, + AsyncRequiredFrom, + AsyncSetFrom, + AsyncTermAs, + AsyncTermWrapper, + BlankNodeFrom, + LiteralFrom, + NamedNodeFrom, + TermFrom, +} from "@rdfjs/wrapper" +import { AsyncChild } from "./AsyncChild.js" +import { Example } from "../vocabulary/Example.js" + +/** + * Async counterpart of `Parent`, demonstrating every shape of mapping + * across the async surface. + * + * Property getters return promises (for read mappings) or + * {@link AsyncWrappingSet} instances (for set mappings). Mutations are + * exposed as `setX(value)` methods that return promises, since + * JavaScript property setters cannot be `async` themselves. + */ +export class AsyncParent extends AsyncTermWrapper { + /* Value Mapping */ + public get hasBlankNode(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasBlankNode, AsyncLiteralAs.string) + } + public setHasBlankNode(value: string): Promise { + return AsyncRequiredAs.object(this, Example.hasBlankNode, value, BlankNodeFrom.string) + } + + public get hasDate(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasDate, AsyncLiteralAs.date) + } + public setHasDate(value: Date): Promise { + return AsyncRequiredAs.object(this, Example.hasDate, value, LiteralFrom.date) + } + + public get hasLangString(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasLangString, AsyncLiteralAs.langString) + } + public setHasLangString(value: ILangString): Promise { + return AsyncRequiredAs.object(this, Example.hasLangString, value, LiteralFrom.langString) + } + + public get hasNumber(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasNumber, AsyncLiteralAs.number) + } + public setHasNumber(value: number): Promise { + return AsyncRequiredAs.object(this, Example.hasNumber, value, LiteralFrom.double) + } + + public get hasBoolean(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasBoolean, AsyncLiteralAs.boolean) + } + public setHasBoolean(value: boolean): Promise { + return AsyncRequiredAs.object(this, Example.hasBoolean, value, LiteralFrom.boolean) + } + + public get hasString(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasString, AsyncLiteralAs.string) + } + public setHasString(value: string): Promise { + return AsyncRequiredAs.object(this, Example.hasString, value, LiteralFrom.string) + } + + public get hasIri(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasIri, AsyncLiteralAs.string) + } + public setHasIri(value: string): Promise { + return AsyncRequiredAs.object(this, Example.hasIri, value, NamedNodeFrom.string) + } + + /* Object Mapping */ + public get hasChild(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasChild, AsyncTermAs.instance(AsyncChild)) + } + public setHasChild(value: AsyncChild): Promise { + return AsyncOptionalAs.object(this, Example.hasChild, value, TermFrom.instance) + } + + /* Arity Mapping */ + public get hasNullableString(): Promise { + return AsyncOptionalFrom.subjectPredicate(this, Example.hasNullableString, AsyncLiteralAs.string) + } + public setHasNullableString(value: string | undefined): Promise { + return AsyncOptionalAs.object(this, Example.hasNullableString, value, LiteralFrom.string) + } + + /* Set Mapping */ + public get hasChildSet(): AsyncWrappingSet { + return AsyncSetFrom.subjectPredicate(this, Example.hasChildSet, AsyncTermAs.instance(AsyncChild), TermFrom.instance) + } + + public get hasLangStringSet(): AsyncWrappingSet { + return AsyncSetFrom.subjectPredicate(this, Example.hasLangStringSet, AsyncLiteralAs.langString, LiteralFrom.langString) + } + + /* Recursion Mapping */ + public get hasRecursive(): Promise { + return AsyncRequiredFrom.subjectPredicate(this, Example.hasRecursive, AsyncTermAs.instance(AsyncParent)) + } + public setHasRecursive(value: AsyncParent | undefined): Promise { + return AsyncOptionalAs.object(this, Example.hasRecursive, value, TermFrom.instance) + } +} diff --git a/test/unit/model/AsyncParentDataset.ts b/test/unit/model/AsyncParentDataset.ts new file mode 100644 index 0000000..15b09ce --- /dev/null +++ b/test/unit/model/AsyncParentDataset.ts @@ -0,0 +1,26 @@ +import { AsyncDatasetWrapper } from "@rdfjs/wrapper" +import { AsyncParent } from "./AsyncParent.js" +import { AsyncChild } from "./AsyncChild.js" +import { Example } from "../vocabulary/Example.js" + +export class AsyncParentDataset extends AsyncDatasetWrapper { + public get instancesOfParent(): AsyncIterable { + return this.instancesOf(Example.Parent, AsyncParent) + } + + public get subjectsOfHasChild(): AsyncIterable { + return this.subjectsOf(Example.hasChild, AsyncParent) + } + + public get objectsOfHasChild(): AsyncIterable { + return this.objectsOf(Example.hasChild, AsyncChild) + } + + public get matchSubjectsOfPropertyanyObjectparentGraphany(): AsyncIterable { + return this.matchSubjectsOf(AsyncParent, undefined, this.factory.namedNode(Example.Parent)) + } + + public get matchObjectsOfSubjectxPropertyhaschildGraphany(): AsyncIterable { + return this.matchObjectsOf(AsyncChild, this.factory.namedNode("x"), this.factory.namedNode(Example.hasChild)) + } +} diff --git a/test/unit/util/asyncDatasetFromRdf.ts b/test/unit/util/asyncDatasetFromRdf.ts new file mode 100644 index 0000000..c41c07b --- /dev/null +++ b/test/unit/util/asyncDatasetFromRdf.ts @@ -0,0 +1,13 @@ +import { AsyncNotifyingDatasetCoreWrapper, type AsyncDefaultNotifyingDatasetCore } from "@rdfjs/wrapper" +import { Parser, Store } from "n3" + +/** + * Parses the Turtle in `rdf` into an n3 {@link Store} and returns it + * exposed through the async surface as an {@link AsyncDefaultNotifyingDatasetCore}. + * Counterpart of `datasetFromRdf`. + */ +export function asyncDatasetFromRdf(rdf: string, baseIRI?: string): AsyncDefaultNotifyingDatasetCore { + const store = new Store() + store.addQuads(new Parser({ baseIRI }).parse(rdf)) + return new AsyncNotifyingDatasetCoreWrapper(store) as unknown as AsyncDefaultNotifyingDatasetCore +} diff --git a/test/unit/util/asyncN3StoreFactory.ts b/test/unit/util/asyncN3StoreFactory.ts new file mode 100644 index 0000000..69f0ef0 --- /dev/null +++ b/test/unit/util/asyncN3StoreFactory.ts @@ -0,0 +1,34 @@ +import type { Quad } from "@rdfjs/types" +import { Store } from "n3" +import { + AsyncNotifyingDatasetCoreWrapper, + type AsyncNotifyingDatasetCore, + type AsyncNotifyingDatasetCoreFactory, +} from "@rdfjs/wrapper" + +/** + * Test-only {@link AsyncNotifyingDatasetCoreFactory} that produces + * datasets backed by an n3 {@link Store} but exposed through the async + * surface. The store itself is synchronous; the wrapper bridges it into + * the async pipeline by resolving every operation on the microtask + * queue, which is enough to exercise the async code paths. + */ +export class AsyncN3StoreFactory implements AsyncNotifyingDatasetCoreFactory { + public async dataset(quads?: AsyncIterable | Iterable): Promise> { + const store = new Store() + if (quads !== undefined) { + if (Symbol.asyncIterator in quads) { + for await (const q of quads as AsyncIterable) { + store.addQuad(q) + } + } else { + for (const q of quads as Iterable) { + store.addQuad(q) + } + } + } + return new AsyncNotifyingDatasetCoreWrapper(store) + } +} + +export const asyncN3StoreFactory: any = new AsyncN3StoreFactory() From fa8174c53444f72935ec0be0d6b7c529c6628259 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:37:10 +0100 Subject: [PATCH 13/19] Update src/EventEmitter.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/EventEmitter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index e4ad8b7..f2be4d9 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -1,6 +1,6 @@ -import { BaseQuad, Quad, Term } from "@rdfjs/types"; -import { IPattern } from "./dataset/LazyMaterialize.js"; -import { ChangeEvent } from "./dataset/NotifyingDatasetCore.js"; +import type { BaseQuad, Quad, Term } from "@rdfjs/types"; +import type { IPattern } from "./dataset/LazyMaterialize.js"; +import type { ChangeEvent } from "./dataset/NotifyingDatasetCore.js"; /** * A minimal multi-cast event emitter generic over the listener argument From 63f628355ebd989fea6d616ee08b319c2cb3bfd0 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:01:04 +0100 Subject: [PATCH 14/19] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- test/unit/dataset_module.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/dataset_module.test.ts b/test/unit/dataset_module.test.ts index edca1a2..436b13e 100644 --- a/test/unit/dataset_module.test.ts +++ b/test/unit/dataset_module.test.ts @@ -15,7 +15,6 @@ const s2 = DataFactory.namedNode("https://example.org/s2") const p = DataFactory.namedNode("https://example.org/p") const o1 = DataFactory.literal("o1") const o2 = DataFactory.literal("o2") -const g = DataFactory.namedNode("https://example.org/g") await describe("defaultGraph", async () => { await it("has the correct shape", () => { From 361cc12f32e3036ec2a0aebd507434b28637d960 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:01:15 +0100 Subject: [PATCH 15/19] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/DatasetWrapper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DatasetWrapper.ts b/src/DatasetWrapper.ts index 48ee791..454fa3f 100644 --- a/src/DatasetWrapper.ts +++ b/src/DatasetWrapper.ts @@ -8,7 +8,6 @@ import { ensureDefaultGraph, ensureTermType } from "./ensure.js" import { ensureNotifyingDatasetCore, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./dataset/NotifyingDatasetCore.js" import { Triple } from "./type/ITriple.js" import { defaultGraph } from "./dataset/terms.js" -import { TermTypeError } from "./errors/TermTypeError.js" /** * The view of an underlying RDF/JS dataset that {@link DatasetWrapper} From a40b3f0d8a57071fc83cb5c8b9031ea3dda53c3f Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:01:50 +0100 Subject: [PATCH 16/19] Update src/dataset/terms.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/dataset/terms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataset/terms.ts b/src/dataset/terms.ts index ec30643..4cdc14b 100644 --- a/src/dataset/terms.ts +++ b/src/dataset/terms.ts @@ -1,4 +1,4 @@ -import { DefaultGraph, Term } from "@rdfjs/types"; +import type { DefaultGraph, Term } from "@rdfjs/types"; /** * Frozen, shared singleton {@link DefaultGraph} term used internally to From e897fd81105bf96b372a8440d86c24eae28a1739 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:03:29 +0100 Subject: [PATCH 17/19] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/async/type/IAsyncTermWrapperConstructor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/async/type/IAsyncTermWrapperConstructor.ts b/src/async/type/IAsyncTermWrapperConstructor.ts index 69abe42..afbf38b 100644 --- a/src/async/type/IAsyncTermWrapperConstructor.ts +++ b/src/async/type/IAsyncTermWrapperConstructor.ts @@ -1,7 +1,6 @@ import type { DataFactory, Term } from "@rdfjs/types" import type { AsyncDefaultDatasetCore } from "../AsyncNotifyingDatasetCore.js" import type { Triple } from "../../type/ITriple.js" -import { AsyncTermWrapper } from "../AsyncTermWrapper.js" /** * Constructor signature for an {@link AsyncTermWrapper} subclass. Mirrors From b39f6949ca3498ea53763ad478368f4dad9d3258 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:03:38 +0100 Subject: [PATCH 18/19] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/dataset/ProjectedDataset.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dataset/ProjectedDataset.ts b/src/dataset/ProjectedDataset.ts index 4a2686d..447cb5a 100644 --- a/src/dataset/ProjectedDataset.ts +++ b/src/dataset/ProjectedDataset.ts @@ -5,7 +5,6 @@ import { EventEmitter } from "../EventEmitter.js"; import { LazyMatchNotifyingDatasetCore } from "./LazyMaterialize.js"; import { ChangeEvent, Listener, NotifyingDatasetCore, NotifyingDatasetCoreFactory } from "./NotifyingDatasetCore.js"; import { Triple, BaseTriple } from "../type/ITriple.js"; -import { TermTypeError } from "../errors/TermTypeError.js"; /** * A {@link NotifyingDatasetCore} whose quads are always exposed in the From 40f3ad1534de39c1fd7ab31446f396854e44c4c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:06:59 +0000 Subject: [PATCH 19/19] docs: update NamedGraphError JSDoc links to scoped APIs Agent-Logs-Url: https://github.com/rdfjs/wrapper/sessions/1a12314a-54af-47f5-a01e-20bdadb5a9fb Co-authored-by: jeswr <63333554+jeswr@users.noreply.github.com> --- src/errors/NamedGraphError.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/errors/NamedGraphError.ts b/src/errors/NamedGraphError.ts index 2d750ac..33eb0d1 100644 --- a/src/errors/NamedGraphError.ts +++ b/src/errors/NamedGraphError.ts @@ -4,7 +4,8 @@ import { QuadError } from "./QuadError.js" /** * Thrown when a named graph is used on a dataset view that only supports the default graph. * - * @see {@link namedGraph} + * @see {@link DatasetWrapper.scoped} + * @see {@link GraphScopedDataset} */ export class NamedGraphError extends QuadError { constructor(quad: BaseQuad, cause?: any) {