diff --git a/README.md b/README.md index 7817ffd..cba05c5 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ const dataset_x = new N3.Store(new N3.Parser().parse(rdf)) // rdf holds the turt Class usage: ```javascript -const person1 = new Person("https://example.org/person1", dataset_x, DataFactory) +const person1 = Person.from("https://example.org/person1", dataset_x, DataFactory) // Get property console.log(person1.name) @@ -154,7 +154,7 @@ ex:person2 Class usage: ```javascript -const person2 = new Person("https://example.org/person2", dataset_z, DataFactory) +const person2 = Person.from("https://example.org/person2", dataset_z, DataFactory) // Get property console.log(person2.name) @@ -165,7 +165,7 @@ console.log(person2.mum.name) // outputs "Alice" // Set class properties -const person3 = new Person("https://example.org/person3", dataset_z, DataFactory) +const person3 = Person.from("https://example.org/person3", dataset_z, DataFactory) person3.name = "Joanne" person1.mum = person3 console.log(person1.mum.name) diff --git a/src/TermWrapper.ts b/src/TermWrapper.ts index 192274f..b48bce6 100644 --- a/src/TermWrapper.ts +++ b/src/TermWrapper.ts @@ -98,6 +98,111 @@ export class TermWrapper implements IRdfJsTerm { this._factory = factory } + //#region Static factory + + /** + * Creates a new instance of this class (or subclass) from an IRI, typed as both the wrapper and the {@link NamedNode} it wraps. + * + * @remarks + * This factory is equivalent to invoking the constructor directly, differing only in the return type: the constructor returns the declared instance type, whereas this factory returns the intersection of the (sub)class instance type and {@link NamedNode}. Because the result _is_ a {@link NamedNode} at the type level, it can be passed directly anywhere an RDF/JS {@link Term} is expected — for example when creating quads with a {@link DataFactory} or when matching with {@link DatasetCore.match} — without casts. + * + * @example Creating an instance of a subclass + * Assume the following RDF data: + * ```turtle + * BASE + * + * "some value" . + * ``` + * We can wrap the subject and use the wrapper directly as a named node: + * ```ts + * class SomeClass extends TermWrapper { + * get someProperty(): string { + * return RequiredFrom.subjectPredicate(this, "http://example.com/someProperty", LiteralAs.string) + * } + * } + * + * const instance = SomeClass.from("http://example.com/someSubject", dataset, factory) + * // instance is typed as SomeClass & NamedNode + * + * const value = instance.someProperty // contains "some value" + * ``` + * + * @example Using created instances directly as RDF/JS terms + * In contrast to instances created with the constructor, no casts are required to use the result as a term: + * ```ts + * const instance = SomeClass.from("http://example.com/someSubject", dataset, factory) + * + * // Used as subject when creating a quad + * factory.quad(instance, predicate, object) + * + * // Used as subject when matching statements in a dataset + * dataset.match(instance) + * ``` + * + * @param term The IRI of a named node that is the original term being wrapped. + * @param dataset The dataset that contains the term being wrapped. + * @param factory A collection of methods for creating terms. + * @returns A new instance of the class this method was invoked on, typed additionally as the {@link NamedNode} it wraps. + * + * @see + * - [Named nodes in the RDF/JS Data model specification](https://rdf.js.org/data-model-spec/#namednode-interface) + */ + public static from any>( + this: This, + term: string, + dataset: DatasetCore, + factory: DataFactory, + ): InstanceType & NamedNode + + /** + * Creates a new instance of this class (or subclass), typed as both the wrapper and the type of the {@link Term} it wraps. + * + * @remarks + * This factory is equivalent to invoking the constructor directly, differing only in the return type: the constructor returns the declared instance type, whereas this factory returns the intersection of the (sub)class instance type and the type of the `term` argument. Because the result _is_ a {@link Term} at the type level, it can be passed directly anywhere an RDF/JS term is expected — for example when creating quads with a {@link DataFactory} or when matching with {@link DatasetCore.match} — without casts. + * + * @example Wrapping a literal + * The term type of the argument is preserved in the return type, so term-type-specific members are available without casts: + * ```ts + * const literal = factory.literal("some value", "en") + * const instance = SomeClass.from(literal, dataset, factory) + * // instance is typed as SomeClass & Literal + * + * const language = instance.language // contains "en" + * ``` + * + * @example Using created instances directly as RDF/JS terms + * In contrast to instances created with the constructor, no casts are required to use the result as a term: + * ```ts + * const instance = SomeClass.from(factory.blankNode(), dataset, factory) + * + * // Used as subject when creating a quad + * factory.quad(instance, predicate, object) + * + * // Used as subject when matching statements in a dataset + * dataset.match(instance) + * ``` + * + * @param term The original term being wrapped. + * @param dataset The dataset that contains the term being wrapped. + * @param factory A collection of methods for creating terms. + * @returns A new instance of the class this method was invoked on, typed additionally as the {@link Term} it wraps. + * + * @see + * - [Terms in the RDF/JS Data model specification](https://rdf.js.org/data-model-spec/#term-interface) + */ + public static from any>( + this: This, + term: T, + dataset: DatasetCore, + factory: DataFactory, + ): InstanceType & T + + public static from(this: any, term: string | Term, dataset: DatasetCore, factory: DataFactory): any { + return new this(term, dataset, factory) + } + + //#endregion + /** * The dataset that contains this term. * diff --git a/test/unit/term_wrapper_from.test.ts b/test/unit/term_wrapper_from.test.ts new file mode 100644 index 0000000..fe55eb3 --- /dev/null +++ b/test/unit/term_wrapper_from.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory } from "n3" +import { TermWrapper } from "@rdfjs/wrapper" +import { Child } from "./model/Child.js" +import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { Example } from "./vocabulary/Example.js" +import type { NamedNode, Quad_Subject, Term } from "@rdfjs/types" + +const rdf = ` +prefix : + + :hasString "string 1" . +` + +await describe("Term Wrapper from", async () => { + const dataset = datasetFromRdf(rdf) + + await describe("Runtime behaviour", async () => { + await it("creates an instance of the subclass it is invoked on", async () => { + const child = Child.from("x", dataset, DataFactory) + + assert.equal(child instanceof Child, true) + assert.equal(child instanceof TermWrapper, true) + }) + + await it("creates an instance of the base class when invoked on it", async () => { + const wrapper = TermWrapper.from("x", dataset, DataFactory) + + assert.equal(wrapper instanceof TermWrapper, true) + }) + + await it("wraps a string as a named node, like the constructor", async () => { + const child = Child.from("x", dataset, DataFactory) + const constructed = new Child("x", dataset, DataFactory) + + assert.equal(child.termType, "NamedNode") + assert.equal(child.value, "x") + assert.equal(child.equals(constructed as Term), true) + assert.equal(child.equals(DataFactory.namedNode("x")), true) + }) + + await it("wraps a given term instance, like the constructor", async () => { + const child = Child.from(DataFactory.blankNode("b1"), dataset, DataFactory) + + assert.equal(child.termType, "BlankNode") + assert.equal(child.value, "b1") + }) + + await it("exposes accessors of the subclass", async () => { + const child = Child.from("x", dataset, DataFactory) + + assert.equal(child.hasString, "string 1") + }) + + await it("keeps references to the dataset and factory", async () => { + const child = Child.from("x", dataset, DataFactory) + + assert.equal(child.dataset, dataset) + assert.equal(child.factory, DataFactory) + }) + }) + + await describe("Intersection return type", async () => { + await it("is assignable to term types without casts", async () => { + const child = Child.from("x", dataset, DataFactory) + + // Compile-time assertions: no casts in any of the assignments below + const node: NamedNode = child + const subject: Quad_Subject = child + + assert.equal(node.equals(subject), true) + }) + + await it("can be used to create quads without casts", async () => { + const child = Child.from("x", dataset, DataFactory) + const quad = DataFactory.quad(child, DataFactory.namedNode(Example.hasString), DataFactory.literal("string 2")) + + assert.equal(quad.subject.equals(child), true) + }) + + await it("can be used to match quads in a dataset without casts", async () => { + const child = Child.from("x", dataset, DataFactory) + const matches = dataset.match(child) + + assert.equal(matches.size, 1) + }) + + await it("preserves the term type of the wrapped term", async () => { + const literal = DataFactory.literal("some value", "en") + const wrapped = Child.from(literal, dataset, DataFactory) + + // Compile-time assertion: literal members are available without casts + assert.equal(wrapped.language, "en") + assert.equal(wrapped.datatype.value, "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString") + }) + }) +})