From 23664af17e0efa5e1252b9be1628f65f7d73b0e8 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:01:02 +0000 Subject: [PATCH] feat: typed, descriptive errors for mapping misuse and cardinality violations Adds two error classes rooted at WrapperError and exports them from mod.ts: - MappingArgumentError: thrown when a mapping function is invoked with an undefined termAs/termFrom mapper, carrying the offending argument name. Replaces the bare 'throw new Error // TODO: Describe' in RequiredFrom, OptionalFrom, OptionalAs, SetFrom and Mapping. - CardinalityError: thrown by RequiredFrom when a singular mapping finds no value or more than one value, carrying the subject term, the predicate IRI and a 'none' | 'multiple' discriminator. Replaces the two untyped string Errors in RequiredFrom (messages unchanged) and resolves its 'TODO: Expose standard errors'. Documents the new errors with @throws tags on the throwing mapping functions and upgrades the existing assert.throws tests to assert the specific error classes and properties. Co-Authored-By: Claude Fable 5 --- src/errors/CardinalityError.ts | 65 ++++++++++++++++++++++++++ src/errors/MappingArgumentError.ts | 68 +++++++++++++++++++++++++++ src/mapping/Mapping.ts | 16 ++++++- src/mapping/OptionalAs.ts | 13 +++++- src/mapping/OptionalFrom.ts | 13 +++++- src/mapping/RequiredFrom.ts | 20 ++++++-- src/mapping/SetFrom.ts | 16 ++++++- src/mod.ts | 2 + test/unit/mapping_errors.test.ts | 74 ++++++++++++++++++++++++++++++ test/unit/term_wrapper.test.ts | 23 +++++++--- 10 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 src/errors/CardinalityError.ts create mode 100644 src/errors/MappingArgumentError.ts create mode 100644 test/unit/mapping_errors.test.ts diff --git a/src/errors/CardinalityError.ts b/src/errors/CardinalityError.ts new file mode 100644 index 0000000..db55114 --- /dev/null +++ b/src/errors/CardinalityError.ts @@ -0,0 +1,65 @@ +import { TermError } from "./TermError.js" +import type { Term } from "@rdfjs/types" + +/** + * Error thrown when the number of values found for a predicate violates an expected cardinality of exactly one. + * + * Code working with RDF might expect that a [subject](https://www.w3.org/TR/rdf11-concepts/#dfn-subject) has exactly one value for a given [predicate](https://www.w3.org/TR/rdf11-concepts/#dfn-predicate) (e.g. a person having exactly one date of birth). RDF itself imposes no such constraint, so when the data has no value or several values where a singular mapping expects one, code might be strict and throw an error representing the failed expectation. + * + * @remarks + * The {@link TermError.term | underlying error's `term`} is the subject whose values were counted, {@link CardinalityError.predicate | `predicate`} is the IRI of the predicate that was matched and {@link CardinalityError.found | `found`} discriminates between the two possible violations: `"none"` (no value found) and `"multiple"` (more than one value found). + * + * @example No value found + * Consider the following mapping class, which expects exactly one value: + * ```ts + * class Class extends TermWrapper { + * public get property(): string { + * return RequiredFrom.subjectPredicate(this, "p", LiteralAs.string) + * } + * } + * ``` + * + * Given the following RDF, which has no value for the predicate: + * ```turtle + * "o" . + * ``` + * + * invoking the mapping code in the following manner: + * ```ts + * new Class("s", dataset, factory).property + * ``` + * + * will result in this error being thrown with a `found` of `"none"`. + * + * @example More than one value found + * Consider the same mapping class, given the following RDF, which has two values for the predicate: + * ```turtle + *

"o1", "o2" . + * ``` + * + * invoking the mapping code in the following manner: + * ```ts + * new Class("s", dataset, factory).property + * ``` + * + * will result in this error being thrown with a `found` of `"multiple"`. + * + * @see + * - [Triples in RDF 1.1 Concepts and Abstract Syntax](https://www.w3.org/TR/rdf11-concepts/#section-triples) + * - [DatasetCore.match](https://rdf.js.org/dataset-spec/#dom-datasetcore-match) + */ +export class CardinalityError extends TermError { + /** + * Creates a new instance of {@link CardinalityError}. + * + * @param term - The subject term whose values for the predicate violated the expected cardinality. + * @param predicate - The IRI of the predicate that was matched. + * @param found - The violating cardinality that was found: `"none"` for no value, `"multiple"` for more than one value. + * @param cause - The specific original cause of the error. + */ + constructor(term: Term, public readonly predicate: string, public readonly found: "none" | "multiple", cause?: any) { + super(term, found === "none" + ? `No value found for predicate ${predicate} on term ${term.value}` + : `More than one value for predicate ${predicate} on term ${term.value}`, cause) + } +} diff --git a/src/errors/MappingArgumentError.ts b/src/errors/MappingArgumentError.ts new file mode 100644 index 0000000..6e047ce --- /dev/null +++ b/src/errors/MappingArgumentError.ts @@ -0,0 +1,68 @@ +import { WrapperError } from "./WrapperError.js" +import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" +import type { ITermFromValueMapping } from "../type/ITermFromValueMapping.js" + +/** + * Error thrown when a mapping function is invoked without a required mapping argument. + * + * The mapping functions of this library (e.g. `RequiredFrom.subjectPredicate`) delegate conversion between RDF terms and JavaScript values to mapper arguments: {@link ITermAsValueMapping | `termAs` mappers} read (RDF to JavaScript) and {@link ITermFromValueMapping | `termFrom` mappers} write (JavaScript to RDF). Passing `undefined` instead of a mapper is a mistake in the mapping class, which this error reports eagerly and by name, instead of letting the mapping fail later with an unrelated error. + * + * @remarks + * The {@link MappingArgumentError.argument | `argument`} property is the name of the parameter that was `undefined` (e.g. `"termAs"` or `"termFrom"`). + * + * A common way to hit this error from JavaScript (where there is no compiler to catch it) is referencing a misspelt or nonexistent member of a mapper collection like `LiteralAs`, which evaluates to `undefined`. + * + * @example Passing an undefined value mapper + * Consider the following mapping class, which references a nonexistent member of `LiteralAs`: + * ```ts + * class Class extends TermWrapper { + * public get property(): string { + * return RequiredFrom.subjectPredicate(this, "p", LiteralAs.strin) // typo: not a member of LiteralAs + * } + * } + * ``` + * + * Given any RDF, for example: + * ```turtle + *

"o" . + * ``` + * + * invoking the mapping code in the following manner: + * ```ts + * new Class("s", dataset, factory).property + * ``` + * + * will result in this error being thrown with an `argument` of `"termAs"`. + * + * @example Passing an undefined term mapper + * Consider the following mapping class, which passes `undefined` where a `termFrom` mapper is expected: + * ```ts + * class Class extends TermWrapper { + * public set property(value: string) { + * OptionalAs.object(this, "p", value, undefined) + * } + * } + * ``` + * + * Invoking the mapping code in the following manner: + * ```ts + * new Class("s", dataset, factory).property = "o" + * ``` + * + * will result in this error being thrown with an `argument` of `"termFrom"`. + * + * @see + * - {@link ITermAsValueMapping} + * - {@link ITermFromValueMapping} + */ +export class MappingArgumentError extends WrapperError { + /** + * Creates a new instance of {@link MappingArgumentError}. + * + * @param argument - The name of the mapping parameter that was `undefined`. + * @param cause - The specific original cause of the error. + */ + constructor(public readonly argument: string, cause?: any) { + super(`Argument ${argument} must be a mapping function but was undefined`, cause) + } +} diff --git a/src/mapping/Mapping.ts b/src/mapping/Mapping.ts index fc77a27..10ef0e4 100644 --- a/src/mapping/Mapping.ts +++ b/src/mapping/Mapping.ts @@ -1,16 +1,28 @@ import { TermWrapper } from "../TermWrapper.js" import { WrappingMap } from "../WrappingMap.js" +import { MappingArgumentError } from "../errors/MappingArgumentError.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "../type/ITermFromValueMapping.js" export namespace Mapping { + /** + * {@link ITermAsValueMapping | Maps} the objects of statements with the anchor term as subject and the given predicate to a mutable map of JavaScript keys and values. + * + * @param anchor - The wrapped term that is the subject of the matched statements. + * @param p - The IRI of the predicate of the matched statements. + * @param termAs - The mapper that converts object terms to JavaScript key-value pairs. + * @param termFrom - The mapper that converts JavaScript key-value pairs to object terms. + * @returns A mutable map backed by the underlying dataset. + * + * @throws {@link MappingArgumentError} If `termAs` or `termFrom` is `undefined`. + */ export function languageDictionary(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping<[TKey, TValue]>, termFrom: ITermFromValueMapping<[TKey, TValue]>): Map { if (termAs === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termAs") } if (termFrom === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termFrom") } return new WrappingMap(anchor, p, termAs, termFrom) diff --git a/src/mapping/OptionalAs.ts b/src/mapping/OptionalAs.ts index 9b19648..a2ee250 100644 --- a/src/mapping/OptionalAs.ts +++ b/src/mapping/OptionalAs.ts @@ -1,11 +1,22 @@ import { TermWrapper } from "../TermWrapper.js" +import { MappingArgumentError } from "../errors/MappingArgumentError.js" import type { ITermFromValueMapping } from "../type/ITermFromValueMapping.js" import type { Quad_Object, Quad_Subject, Term } from "@rdfjs/types" export namespace OptionalAs { + /** + * {@link ITermFromValueMapping | Maps} a JavaScript value to the object of a statement with the anchor term as subject and the given predicate, replacing any previous values. + * + * @param anchor - The wrapped term that is the subject of the affected statements. + * @param p - The IRI of the predicate of the affected statements. + * @param value - The JavaScript value to map to an object term, or `undefined` to only delete previous values. + * @param termFrom - The mapper that converts the JavaScript value to an object term. + * + * @throws {@link MappingArgumentError} If `termFrom` is `undefined`. + */ export function object(anchor: TermWrapper, p: string, value: T | undefined, termFrom: ITermFromValueMapping) { if (termFrom === undefined) { - throw new Error + throw new MappingArgumentError("termFrom") } const predicate = anchor.factory.namedNode(p) diff --git a/src/mapping/OptionalFrom.ts b/src/mapping/OptionalFrom.ts index a6af485..e7dce93 100644 --- a/src/mapping/OptionalFrom.ts +++ b/src/mapping/OptionalFrom.ts @@ -1,11 +1,22 @@ import { TermWrapper } from "../TermWrapper.js" +import { MappingArgumentError } from "../errors/MappingArgumentError.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" import type { Term } from "@rdfjs/types" export namespace OptionalFrom { + /** + * {@link ITermAsValueMapping | Maps} the object of the first statement with the anchor term as subject and the given predicate, if any. + * + * @param anchor - The wrapped term that is the subject of the matched statements. + * @param p - The IRI of the predicate of the matched statements. + * @param termAs - The mapper that converts the object term to a JavaScript value. + * @returns The JavaScript value the first object term maps to, or `undefined` if there is no value for the predicate on the anchor term. + * + * @throws {@link MappingArgumentError} If `termAs` is `undefined`. + */ export function subjectPredicate(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping): T | undefined { if (termAs === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termAs") } const predicate = anchor.factory.namedNode(p) diff --git a/src/mapping/RequiredFrom.ts b/src/mapping/RequiredFrom.ts index 58f4b39..bc8e7c5 100644 --- a/src/mapping/RequiredFrom.ts +++ b/src/mapping/RequiredFrom.ts @@ -1,25 +1,37 @@ import { TermWrapper } from "../TermWrapper.js" +import { CardinalityError } from "../errors/CardinalityError.js" +import { MappingArgumentError } from "../errors/MappingArgumentError.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" import type { Term } from "@rdfjs/types" export namespace RequiredFrom { + /** + * {@link ITermAsValueMapping | Maps} the single object of statements with the anchor term as subject and the given predicate. + * + * @param anchor1 - The wrapped term that is the subject of the matched statements. + * @param p - The IRI of the predicate of the matched statements. + * @param termAs - The mapper that converts the object term to a JavaScript value. + * @returns The JavaScript value the single object term maps to. + * + * @throws {@link MappingArgumentError} If `termAs` is `undefined`. + * @throws {@link CardinalityError} If there is no value or more than one value for the predicate on the anchor term. + */ export function subjectPredicate(anchor1: TermWrapper, p: string, termAs: ITermAsValueMapping): T { if (termAs === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termAs") } const anchor2 = anchor1.factory.namedNode(p) const matches = anchor1.dataset.match(anchor1 as Term, anchor2)[Symbol.iterator]() - // TODO: Expose standard errors const {value: first, done: none} = matches.next() if (none) { - throw new Error(`No value found for predicate ${p} on term ${anchor1.value}`) + throw new CardinalityError(anchor1 as Term, p, "none") } if (!matches.next().done) { - throw new Error(`More than one value for predicate ${p} on term ${anchor1.value}`) + throw new CardinalityError(anchor1 as Term, p, "multiple") } return termAs(new TermWrapper(first.object, anchor1.dataset, anchor1.factory)) diff --git a/src/mapping/SetFrom.ts b/src/mapping/SetFrom.ts index 9ba0e08..92cfeaf 100644 --- a/src/mapping/SetFrom.ts +++ b/src/mapping/SetFrom.ts @@ -1,4 +1,5 @@ import { TermWrapper } from "../TermWrapper.js" +import { MappingArgumentError } from "../errors/MappingArgumentError.js" import type { ITermAsValueMapping } from "../type/ITermAsValueMapping.js" import type { ITermFromValueMapping } from "../type/ITermFromValueMapping.js" import { WrappingSet } from "../WrappingSet.js" @@ -7,13 +8,24 @@ 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 { + /** + * {@link ITermAsValueMapping | Maps} the objects of statements with the anchor term as subject and the given predicate to a mutable set of JavaScript values. + * + * @param anchor - The wrapped term that is the subject of the matched statements. + * @param p - The IRI of the predicate of the matched statements. + * @param termAs - The mapper that converts object terms to JavaScript values. + * @param termFrom - The mapper that converts JavaScript values to object terms. + * @returns A mutable set backed by the underlying dataset. + * + * @throws {@link MappingArgumentError} If `termAs` or `termFrom` is `undefined`. + */ export function subjectPredicate(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping, termFrom: ITermFromValueMapping): Set { if (termAs === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termAs") } if (termFrom === undefined) { - throw new Error // TODO: Describe + throw new MappingArgumentError("termFrom") } return new WrappingSet(anchor, p, termAs, termFrom) diff --git a/src/mod.ts b/src/mod.ts index 016c51d..cbf0375 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -26,6 +26,8 @@ export * from "./errors/WrapperError.js" export * from "./errors/TermError.js" export * from "./errors/TermTypeError.js" export * from "./errors/LiteralDatatypeError.js" +export * from "./errors/CardinalityError.js" +export * from "./errors/MappingArgumentError.js" export * from "./errors/ListRootError.js" export * from "./errors/QuadError.js" export * from "./errors/NamedGraphError.js" diff --git a/test/unit/mapping_errors.test.ts b/test/unit/mapping_errors.test.ts new file mode 100644 index 0000000..6e78dfb --- /dev/null +++ b/test/unit/mapping_errors.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert" +import { describe, it } from "node:test" +import { DataFactory } from "n3" +import { + LiteralAs, + LiteralFrom, + Mapping, + MappingArgumentError, + OptionalAs, + OptionalFrom, + RequiredFrom, + SetFrom, + TermWrapper +} from "@rdfjs/wrapper" +import { datasetFromRdf } from "./util/datasetFromRdf.js" +import { Example } from "./vocabulary/Example.js" + +const rdf = ` +prefix : + + :hasString "string 1" . +` + +await describe("Mapping Errors", async () => { + const dataset = datasetFromRdf(rdf) + const wrapper = new TermWrapper("x", dataset, DataFactory) + const undefinedMapper = undefined as any + + function assertMappingArgumentError(argument: string) { + return (error: unknown) => { + assert.ok(error instanceof MappingArgumentError) + assert.equal(error.argument, argument) + return true + } + } + + await describe("RequiredFrom", async () => { + await it("throws if termAs is undefined", async () => { + assert.throws(() => RequiredFrom.subjectPredicate(wrapper, Example.hasString, undefinedMapper), assertMappingArgumentError("termAs")) + }) + }) + + await describe("OptionalFrom", async () => { + await it("throws if termAs is undefined", async () => { + assert.throws(() => OptionalFrom.subjectPredicate(wrapper, Example.hasString, undefinedMapper), assertMappingArgumentError("termAs")) + }) + }) + + await describe("OptionalAs", async () => { + await it("throws if termFrom is undefined", async () => { + assert.throws(() => OptionalAs.object(wrapper, Example.hasString, "value", undefinedMapper), assertMappingArgumentError("termFrom")) + }) + }) + + await describe("SetFrom", async () => { + await it("throws if termAs is undefined", async () => { + assert.throws(() => SetFrom.subjectPredicate(wrapper, Example.hasString, undefinedMapper, LiteralFrom.string), assertMappingArgumentError("termAs")) + }) + + await it("throws if termFrom is undefined", async () => { + assert.throws(() => SetFrom.subjectPredicate(wrapper, Example.hasString, LiteralAs.string, undefinedMapper), assertMappingArgumentError("termFrom")) + }) + }) + + await describe("Mapping", async () => { + await it("throws if termAs is undefined", async () => { + assert.throws(() => Mapping.languageDictionary(wrapper, Example.hasString, undefinedMapper, undefinedMapper), assertMappingArgumentError("termAs")) + }) + + await it("throws if termFrom is undefined", async () => { + assert.throws(() => Mapping.languageDictionary(wrapper, Example.hasString, LiteralAs.langString as any, undefinedMapper), assertMappingArgumentError("termFrom")) + }) + }) +}) diff --git a/test/unit/term_wrapper.test.ts b/test/unit/term_wrapper.test.ts index bc67f5c..cf42386 100644 --- a/test/unit/term_wrapper.test.ts +++ b/test/unit/term_wrapper.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert" import { describe, it } from "node:test" import { DataFactory } from "n3" +import { CardinalityError } from "@rdfjs/wrapper" import { Child } from "./model/Child.js" import { datasetFromRdf } from "./util/datasetFromRdf.js" import { Parent } from "./model/Parent.js" +import { Example } from "./vocabulary/Example.js" import type { Term } from "@rdfjs/types" const rdf = ` @@ -124,13 +126,23 @@ await describe("Term Wrapper", async () => { await describe("Arity Mapping", async () => { await describe("Singular", async () => { await it("get singular throws if more than 1", async () => { - // TODO: Test for specific errors - assert.throws(() => parent.hasTooManySingularString) + assert.throws(() => parent.hasTooManySingularString, (error: unknown) => { + assert.ok(error instanceof CardinalityError) + assert.equal(error.found, "multiple") + assert.equal(error.predicate, Example.hasTooManySingularString) + assert.equal(parent.equals(error.term), true) + return true + }) }) await it("get singular throws if no value", async () => { - // TODO: Test for specific errors - assert.throws(() => parent.hasNoSingularString) + assert.throws(() => parent.hasNoSingularString, (error: unknown) => { + assert.ok(error instanceof CardinalityError) + assert.equal(error.found, "none") + assert.equal(error.predicate, Example.hasNoSingularString) + assert.equal(parent.equals(error.term), true) + return true + }) }) await it("set singular to undefined throws", async () => { @@ -199,8 +211,7 @@ await describe("Term Wrapper", async () => { assert.equal(parent.dataset.size, 22) parent.hasRecursive = undefined assert.equal(parent.dataset.size, 21) - // TODO: check for typed error singular no value - assert.throws(() => parent.hasRecursive) + assert.throws(() => parent.hasRecursive, CardinalityError) parent.hasRecursive = parent assert.equal(parent.hasRecursive.hasRecursive.hasRecursive.value, "x") })