Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/errors/CardinalityError.ts
Original file line number Diff line number Diff line change
@@ -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
* <s> <q> "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
* <s> <p> "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)
}
}
68 changes: 68 additions & 0 deletions src/errors/MappingArgumentError.ts
Original file line number Diff line number Diff line change
@@ -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
* <s> <p> "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)
}
}
16 changes: 14 additions & 2 deletions src/mapping/Mapping.ts
Original file line number Diff line number Diff line change
@@ -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<TKey, TValue>(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping<[TKey, TValue]>, termFrom: ITermFromValueMapping<[TKey, TValue]>): Map<TKey, TValue> {
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)
Expand Down
13 changes: 12 additions & 1 deletion src/mapping/OptionalAs.ts
Original file line number Diff line number Diff line change
@@ -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<T>(anchor: TermWrapper, p: string, value: T | undefined, termFrom: ITermFromValueMapping<T>) {
if (termFrom === undefined) {
throw new Error
throw new MappingArgumentError("termFrom")
}

const predicate = anchor.factory.namedNode(p)
Expand Down
13 changes: 12 additions & 1 deletion src/mapping/OptionalFrom.ts
Original file line number Diff line number Diff line change
@@ -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<T>(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping<T>): T | undefined {
if (termAs === undefined) {
throw new Error // TODO: Describe
throw new MappingArgumentError("termAs")
}

const predicate = anchor.factory.namedNode(p)
Expand Down
20 changes: 16 additions & 4 deletions src/mapping/RequiredFrom.ts
Original file line number Diff line number Diff line change
@@ -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<T>(anchor1: TermWrapper, p: string, termAs: ITermAsValueMapping<T>): 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))
Expand Down
16 changes: 14 additions & 2 deletions src/mapping/SetFrom.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<T>(anchor: TermWrapper, p: string, termAs: ITermAsValueMapping<T>, termFrom: ITermFromValueMapping<T>): Set<T> {
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)
Expand Down
2 changes: 2 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
74 changes: 74 additions & 0 deletions test/unit/mapping_errors.test.ts
Original file line number Diff line number Diff line change
@@ -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 : <https://example.org/>

<x> :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"))
})
})
})
Loading