From 61a6609a714e5dc3d8dfaa55430d6b9a1fb6ccc8 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Tue, 18 Aug 2026 11:21:15 -0400 Subject: [PATCH] fix(sparql): keep grammar roles distinct and preserve RDF terms --- docs/sparql-mapping.md | 30 +- packages/comunica/README.md | 4 +- packages/comunica/deno.json | 11 +- packages/comunica/mod.ts | 56 ++- packages/comunica/mod_test.ts | 22 +- packages/oxigraph/README.md | 4 +- packages/oxigraph/deno.json | 11 +- packages/oxigraph/mod.ts | 50 +- packages/oxigraph/mod_test.ts | 15 +- packages/sparql/README.md | 14 +- packages/sparql/builder.ts | 160 +++--- packages/sparql/builder_test.ts | 11 +- packages/sparql/client.ts | 23 +- packages/sparql/composition_test.ts | 2 +- packages/sparql/deno.json | 10 +- packages/sparql/graph-store/mod.ts | 213 ++++++++ packages/sparql/graph-store/mod_test.ts | 63 +++ packages/sparql/http/error.ts | 25 +- packages/sparql/http/mod.ts | 360 +++++++++++--- packages/sparql/http/mod_test.ts | 156 +++++- packages/sparql/package.json | 3 +- packages/sparql/patterns/cypher.ts | 14 +- packages/sparql/patterns/objects.ts | 206 +++++--- packages/sparql/patterns/objects_test.ts | 2 +- packages/sparql/patterns/triples.ts | 87 ++-- packages/sparql/patterns/triples_test.ts | 4 +- packages/sparql/result/binding_test.ts | 6 +- packages/sparql/result/json.ts | 106 ++-- packages/sparql/result/json_test.ts | 45 +- packages/sparql/sparql.ts | 293 ++++++----- packages/sparql/sparql_test.ts | 4 +- packages/sparql/syntax/mod_test.ts | 16 +- packages/sparql/syntax/scan.ts | 204 +++++--- packages/sparql/syntax/scanner.ts | 341 ++++++++++--- packages/sparql/syntax/source.ts | 11 +- packages/sparql/syntax/types.ts | 38 +- packages/sparql/update.ts | 190 +++++--- packages/sparql/update_test.ts | 1 - packages/sparql/utils.ts | 595 ++++++++++++----------- packages/sparql/utils_test.ts | 27 +- 40 files changed, 2397 insertions(+), 1036 deletions(-) create mode 100644 packages/sparql/graph-store/mod.ts create mode 100644 packages/sparql/graph-store/mod_test.ts diff --git a/docs/sparql-mapping.md b/docs/sparql-mapping.md index 0b4f14b..27476a4 100644 --- a/docs/sparql-mapping.md +++ b/docs/sparql-mapping.md @@ -6,15 +6,15 @@ This guide maps the current `@okikio/sparql` API to SPARQL syntax and to the pac The package distinguishes the major grammar roles instead of treating every fragment as one branded string. -| Type | Meaning | Typical producers | -| --- | --- | --- | -| `SparqlTerm` | one term or legal predicate-path syntax | `v()`, `iri()`, `tripleTerm()`, property-path helpers | -| `SparqlExpr` | one expression | comparison/arithmetic/functions, `exists()` | -| `PatternValue` | one graph-pattern fragment | `triple()`, `filter()`, `optional()`, `graph()`, `service()`, `values()` | -| `SparqlQuery` | one complete query document | `QueryBuilder.build()` | -| `SparqlUpdate` | one complete Update document | update builders | +| Type | Meaning | Typical producers | +| ------------------ | --------------------------------------- | ------------------------------------------------------------------------ | +| `SparqlTermType` | one term or legal predicate-path syntax | `v()`, `iri()`, `tripleTerm()`, property-path helpers | +| `SparqlExprType` | one expression | comparison/arithmetic/functions, `exists()` | +| `PatternValueType` | one graph-pattern fragment | `triple()`, `filter()`, `optional()`, `graph()`, `service()`, `values()` | +| `SparqlQueryType` | one complete query document | `QueryBuilder.build()` | +| `SparqlUpdateType` | one complete Update document | update builders | -Complete query/update documents are intentionally not embeddable `SparqlValue` fragments. Use `subquery()` when a complete query must become a graph pattern. +Complete query/update documents are intentionally not embeddable `SparqlValueType` fragments. Use `subquery()` when a complete query must become a graph pattern. ## RDF terms @@ -23,7 +23,7 @@ Native `@okikio/rdf` named nodes are accepted in IRI-bearing grammar positions. ```ts import * as rdf from '@okikio/rdf' import * as sparql from '@okikio/sparql' -import { Product, name } from '@okikio/vocab/schema' +import { name, Product } from '@okikio/vocab/schema' const query = sparql.select(['?product', '?name']).where( sparql.triple('?product', rdf.namedNode(rdf.RDF.type), Product), @@ -44,7 +44,7 @@ sparql.typed('42', rdf.namedNode(rdf.XSD.integer)) sparql.update().clear(rdf.namedNode('urn:graph:old')) ``` -Strict graph positions validate `SparqlTerm` syntax and reject variables or literals. `GRAPH` and `SERVICE` keep their separate `VarOrIriRef` behavior because variables are legal there. +Strict graph positions validate `SparqlTermType` syntax and reject variables or literals. `GRAPH` and `SERVICE` keep their separate `VarOrIriRef` behavior because variables are legal there. ## SELECT @@ -211,7 +211,7 @@ sparql.sequence('schema:address', 'schema:addressLocality') sparql.alternative('foaf:name', 'schema:name') ``` -Property paths return `SparqlTerm` because that syntax is legal in the predicate position of a triple path. +Property paths return `SparqlTermType` because that syntax is legal in the predicate position of a triple path. ## SPARQL 1.2 triple terms @@ -297,9 +297,9 @@ The public update method is `update()`. ## HTTP client ```ts -import { createClient } from '@okikio/sparql/http' +import * as http from '@okikio/sparql/http' -const client = createClient({ endpoint: 'https://example.com/sparql' }) +const client = http.create({ endpoint: 'https://example.com/sparql' }) const rows = await client.queryBindings(query) ``` @@ -308,8 +308,8 @@ The HTTP client owns endpoint transport, accepted media types, bounded response ## Oxigraph and Comunica ```ts -import { createClient as createOxigraphClient } from '@okikio/oxigraph' -import { createClient as createComunicaClient } from '@okikio/comunica' +import * as oxigraph from '@okikio/oxigraph' +import * as comunica from '@okikio/comunica' ``` Both adapters implement the same `Queryable` contract. diff --git a/packages/comunica/README.md b/packages/comunica/README.md index 4340bcc..ea73237 100644 --- a/packages/comunica/README.md +++ b/packages/comunica/README.md @@ -4,10 +4,10 @@ Adapter from a caller-owned Comunica `QueryEngine` to `@okikio/sparql`'s result- ```ts import { QueryEngine } from '@comunica/query-sparql' -import { createClient } from '@okikio/comunica' +import { create } from '@okikio/comunica' const engine = new QueryEngine() -const client = createClient(engine, { +const client = create(engine, { context: () => ({ sources: [/* caller-owned sources */] }), }) diff --git a/packages/comunica/deno.json b/packages/comunica/deno.json index 093b641..bba497c 100644 --- a/packages/comunica/deno.json +++ b/packages/comunica/deno.json @@ -2,5 +2,14 @@ "name": "@okikio/comunica", "version": "0.1.0", "license": "MIT", - "exports": { ".": "./mod.ts" } + "exports": { + ".": "./mod.ts" + }, + "publish": { + "exclude": [ + "**/*_test.ts", + "**/*_bench.ts", + "**/*_property_test.ts" + ] + } } diff --git a/packages/comunica/mod.ts b/packages/comunica/mod.ts index ebd2c17..4e962b5 100644 --- a/packages/comunica/mod.ts +++ b/packages/comunica/mod.ts @@ -5,28 +5,33 @@ import { getQueryText, getUpdateText, type Queryable, type QueryOptionsType } fr import type { BindingType } from '@okikio/sparql' /** Async result stream shape returned by Comunica query methods. */ -export interface ResultStreamType extends AsyncIterable { +export interface ResultStream extends AsyncIterable { /** Node-style streams expose `destroy`; the adapter uses it for early cancellation when available. */ destroy?(error?: Error): void } /** Minimal Comunica QueryEngine surface used by this adapter. */ -export interface QueryEngineType { - queryBindings(query: string, context?: unknown): Promise> - queryQuads(query: string, context?: unknown): Promise> +export interface QueryEngine { + /** Executes a bindings-producing SPARQL query through the adapted engine. */ + queryBindings(query: string, context?: unknown): Promise> + /** Executes a quad-producing SPARQL query through the adapted engine. */ + queryQuads(query: string, context?: unknown): Promise> + /** Executes a SPARQL ASK query through the adapted engine. */ queryBoolean(query: string, context?: unknown): Promise + /** Executes a SPARQL Update request and resolves when the adapted engine finishes. */ queryVoid(query: string, context?: unknown): Promise } /** Comunica integration configuration. */ -export interface ClientOptionsType { +export interface ComunicaOptionsType { /** Creates the engine-specific query context for each operation. */ readonly context?: (options: QueryOptionsType) => unknown } /** Comunica-backed query interface. The supplied QueryEngine remains caller-owned. */ export interface Client extends Queryable { - readonly engine: QueryEngineType + /** Caller-owned Comunica engine. Creating or closing this client does not transfer engine ownership. */ + readonly engine: QueryEngine } /** @@ -36,21 +41,33 @@ export interface Client extends Queryable { * early or the supplied signal aborts. Boolean/void cancellation still depends * on the query context provided to Comunica because those methods return one * promise rather than a cancellable stream. + * + * @example + * ```ts + * import * as comunica from '@okikio/comunica' + * + * // `engine` is a Comunica QueryEngine created and owned by the application. + * const client = comunica.create(engine) + * const exists = await client.queryBoolean('ASK { ?s ?p ?o }') + * ``` */ -export function createClient(engine: QueryEngineType, options: ClientOptionsType = {}): Client { +export function create(engine: QueryEngine, options: ComunicaOptionsType = {}): Client { return { engine, /** Query bindings through the wrapped engine without transferring engine ownership. */ async queryBindings(query, queryOptions = {}) { abort(queryOptions.signal) - const stream = await engine.queryBindings(getQueryText(query), options.context?.(queryOptions)) - return mapStream(stream, queryOptions.signal, readBinding) + const stream = await engine.queryBindings( + getQueryText(query), + options.context?.(queryOptions), + ) + return mapStream(stream, queryOptions.signal, decodeBinding) }, /** Query quads through the wrapped engine without transferring engine ownership. */ async queryQuads(query, queryOptions = {}) { abort(queryOptions.signal) const stream = await engine.queryQuads(getQueryText(query), options.context?.(queryOptions)) - return mapStream(stream, queryOptions.signal, readQuad) + return mapStream(stream, queryOptions.signal, decodeQuad) }, /** Query boolean through the wrapped engine without transferring engine ownership. */ async queryBoolean(query, queryOptions = {}) { @@ -70,7 +87,7 @@ export function createClient(engine: QueryEngineType, options: ClientOptionsType /** Maps one upstream engine stream and destroys unfinished work when consumption stops early. */ async function* mapStream( - stream: ResultStreamType, + stream: ResultStream, signal: AbortSignal | undefined, map: (value: Input) => Output, ): AsyncGenerator { @@ -90,8 +107,11 @@ async function* mapStream( } /** Converts one engine-specific binding row into the engine-neutral RDF binding map. */ -function readBinding(value: unknown): BindingType { - if (typeof value !== 'object' || value === null || !('entries' in value) || typeof value.entries !== 'function') { +function decodeBinding(value: unknown): BindingType { + if ( + typeof value !== 'object' || value === null || !('entries' in value) || + typeof value.entries !== 'function' + ) { throw new TypeError('Comunica binding row does not expose entries().') } const result = new Map>() @@ -105,8 +125,11 @@ function readBinding(value: unknown): BindingType { } /** Converts one engine-specific graph result into a native RDF quad. */ -function readQuad(value: unknown): Quad { - if (!isTerm(value) || value.termType !== 'Quad' || !('subject' in value) || !('predicate' in value) || !('object' in value) || !('graph' in value)) { +function decodeQuad(value: unknown): Quad { + if ( + !isTerm(value) || value.termType !== 'Quad' || !('subject' in value) || + !('predicate' in value) || !('object' in value) || !('graph' in value) + ) { throw new TypeError('Comunica graph result contains a non-quad value.') } return fromQuad(value as Quad) @@ -123,7 +146,8 @@ function variableName(value: unknown): string { function isTerm(value: unknown): value is Term { if (typeof value !== 'object' || value === null) return false const term = value as Partial - return typeof term.termType === 'string' && typeof term.value === 'string' && typeof term.equals === 'function' + return typeof term.termType === 'string' && typeof term.value === 'string' && + typeof term.equals === 'function' } /** Throws the caller supplied abort reason when cancellation has been requested. */ diff --git a/packages/comunica/mod_test.ts b/packages/comunica/mod_test.ts index 9aa7395..7bbd59b 100644 --- a/packages/comunica/mod_test.ts +++ b/packages/comunica/mod_test.ts @@ -2,16 +2,22 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { literal } from '@okikio/rdf' import { select, triple, update } from '@okikio/sparql' -import { createClient, type ResultStreamType } from './mod.ts' +import { create, type ResultStream } from './mod.ts' describe('@okikio/comunica', () => { it('forwards caller-owned query context and structured documents', async () => { let seen: unknown let queryText = '' let updateText = '' - const client = createClient({ - queryBoolean: async (query: string, context?: unknown) => { queryText = query; seen = context; return true }, - queryVoid: async (query: string) => { updateText = query }, + const client = create({ + queryBoolean: async (query: string, context?: unknown) => { + queryText = query + seen = context + return true + }, + queryVoid: async (query: string) => { + updateText = query + }, queryBindings: async () => ({ [Symbol.asyncIterator]: async function* () {} }), queryQuads: async () => ({ [Symbol.asyncIterator]: async function* () {} }), }, { context: () => ({ source: 'memory' }) }) @@ -26,14 +32,16 @@ describe('@okikio/comunica', () => { it('destroys caller-owned result work when the consumer returns early', async () => { let destroyed = false - const stream: ResultStreamType>> = { + const stream: ResultStream>> = { async *[Symbol.asyncIterator]() { yield new Map([['name', literal('Alice')]]) yield new Map([['name', literal('Bob')]]) }, - destroy() { destroyed = true }, + destroy() { + destroyed = true + }, } - const client = createClient({ + const client = create({ queryBindings: async () => stream, queryQuads: async () => ({ [Symbol.asyncIterator]: async function* () {} }), queryBoolean: async () => true, diff --git a/packages/oxigraph/README.md b/packages/oxigraph/README.md index c5508dd..bdf9ba2 100644 --- a/packages/oxigraph/README.md +++ b/packages/oxigraph/README.md @@ -4,10 +4,10 @@ Adapter from a caller-owned Oxigraph `Store` to `@okikio/sparql`'s result-mode-s ```ts import { Store } from 'oxigraph' -import { createClient } from '@okikio/oxigraph' +import { create } from '@okikio/oxigraph' const store = new Store() -const client = createClient(store) +const client = create(store) const rows = await client.queryBindings(query) await client.update(update) diff --git a/packages/oxigraph/deno.json b/packages/oxigraph/deno.json index bebc0af..30f06c7 100644 --- a/packages/oxigraph/deno.json +++ b/packages/oxigraph/deno.json @@ -2,5 +2,14 @@ "name": "@okikio/oxigraph", "version": "0.1.0", "license": "MIT", - "exports": { ".": "./mod.ts" } + "exports": { + ".": "./mod.ts" + }, + "publish": { + "exclude": [ + "**/*_test.ts", + "**/*_bench.ts", + "**/*_property_test.ts" + ] + } } diff --git a/packages/oxigraph/mod.ts b/packages/oxigraph/mod.ts index 794f569..0507ebb 100644 --- a/packages/oxigraph/mod.ts +++ b/packages/oxigraph/mod.ts @@ -5,13 +5,15 @@ import { getQueryText, getUpdateText, type Queryable, type QueryOptionsType } fr import type { BindingType } from '@okikio/sparql' /** Minimal Oxigraph Store surface used by this adapter. */ -export interface StoreType { +export interface Store { + /** Executes the supplied SPARQL query without taking ownership of the adapted store. */ query(query: string, options?: Readonly>): unknown + /** Executes the supplied SPARQL update without taking ownership of the adapted store. */ update(update: string, options?: Readonly>): void } /** Oxigraph adapter options. */ -export interface ClientOptionsType { +export interface OxigraphOptionsType { /** Static query options forwarded to `Store.query()`. */ readonly query?: Readonly> /** Static update options forwarded to `Store.update()`. */ @@ -20,7 +22,8 @@ export interface ClientOptionsType { /** Oxigraph-backed query interface. The supplied store remains caller-owned. */ export interface Client extends Queryable { - readonly store: StoreType + /** Caller-owned Oxigraph store. The adapter borrows it and never creates hidden global store state. */ + readonly store: Store } /** @@ -29,29 +32,44 @@ export interface Client extends Queryable { * Oxigraph's current JavaScript Store API is synchronous. Abort signals are * therefore checked before execution, and timeout requests are rejected rather * than pretending a synchronous Wasm call can be interrupted. + * + * @example + * ```ts + * import * as oxigraph from '@okikio/oxigraph' + * + * // `store` is an Oxigraph Store created and owned by the application. + * const client = oxigraph.create(store) + * const exists = await client.queryBoolean('ASK { ?s ?p ?o }') + * ``` */ -export function createClient(store: StoreType, options: ClientOptionsType = {}): Client { +export function create(store: Store, options: OxigraphOptionsType = {}): Client { return { store, /** Query bindings through the wrapped engine without transferring engine ownership. */ async queryBindings(query, queryOptions = {}) { prepare(queryOptions) const result = store.query(getQueryText(query), options.query) - if (!isIterable(result)) throw new TypeError('Oxigraph SELECT query did not return an iterable of bindings.') + if (!isIterable(result)) { + throw new TypeError('Oxigraph SELECT query did not return an iterable of bindings.') + } return bindings(result) }, /** Query quads through the wrapped engine without transferring engine ownership. */ async queryQuads(query, queryOptions = {}) { prepare(queryOptions) const result = store.query(getQueryText(query), options.query) - if (!isIterable(result)) throw new TypeError('Oxigraph graph query did not return an iterable of quads.') + if (!isIterable(result)) { + throw new TypeError('Oxigraph graph query did not return an iterable of quads.') + } return quads(result) }, /** Query boolean through the wrapped engine without transferring engine ownership. */ async queryBoolean(query, queryOptions = {}) { prepare(queryOptions) const result = store.query(getQueryText(query), options.query) - if (typeof result !== 'boolean') throw new TypeError('Oxigraph ASK query did not return a boolean.') + if (typeof result !== 'boolean') { + throw new TypeError('Oxigraph ASK query did not return a boolean.') + } return result }, /** Submits one complete SPARQL Update document through the wrapped engine. */ @@ -64,9 +82,13 @@ export function createClient(store: StoreType, options: ClientOptionsType = {}): /** Validates operation controls that the synchronous engine can actually honor. */ function prepare(options: QueryOptionsType): void { - if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + } if (options.timeoutMs !== undefined && options.timeoutMs !== null && options.timeoutMs > 0) { - throw new TypeError('Oxigraph synchronous Store queries cannot honor timeoutMs. Run the store in an owned Worker when interruptibility is required.') + throw new TypeError( + 'Oxigraph synchronous Store queries cannot honor timeoutMs. Run the store in an owned Worker when interruptibility is required.', + ) } } @@ -76,7 +98,9 @@ async function* bindings(values: Iterable): AsyncGenerator if (!(value instanceof Map)) throw new TypeError('Oxigraph SELECT row is not a Map.') const binding = new Map>() for (const [name, term] of value) { - if (typeof name !== 'string') throw new TypeError('Oxigraph binding variable name is not a string.') + if (typeof name !== 'string') { + throw new TypeError('Oxigraph binding variable name is not a string.') + } if (!isTerm(term)) throw new TypeError(`Oxigraph binding '${name}' is not an RDF term.`) binding.set(name.replace(/^[?$]/, ''), fromTerm(term)) } @@ -101,10 +125,12 @@ function isIterable(value: unknown): value is Iterable { function isTerm(value: unknown): value is Term { if (typeof value !== 'object' || value === null) return false const record = value as Partial - return typeof record.termType === 'string' && typeof record.value === 'string' && typeof record.equals === 'function' + return typeof record.termType === 'string' && typeof record.value === 'string' && + typeof record.equals === 'function' } /** Returns whether the supplied value satisfies the quad contract. */ function isQuad(value: unknown): value is Quad { - return isTerm(value) && value.termType === 'Quad' && 'subject' in value && 'predicate' in value && 'object' in value && 'graph' in value + return isTerm(value) && value.termType === 'Quad' && 'subject' in value && 'predicate' in value && + 'object' in value && 'graph' in value } diff --git a/packages/oxigraph/mod_test.ts b/packages/oxigraph/mod_test.ts index 5edc3b7..3c52741 100644 --- a/packages/oxigraph/mod_test.ts +++ b/packages/oxigraph/mod_test.ts @@ -1,11 +1,11 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { select, triple, update } from '@okikio/sparql' -import { createClient } from './mod.ts' +import { create } from './mod.ts' describe('@okikio/oxigraph', () => { it('does not pretend a synchronous Store supports timeout cancellation', async () => { - const client = createClient({ query: () => true, update: () => undefined }) + const client = create({ query: () => true, update: () => undefined }) let kind = '' try { await client.queryBoolean('ASK {}', { timeoutMs: 1 }) @@ -18,9 +18,14 @@ describe('@okikio/oxigraph', () => { it('accepts structured query and update documents without taking store ownership', async () => { let queryText = '' let updateText = '' - const client = createClient({ - query(query: string) { queryText = query; return true }, - update(value: string) { updateText = value }, + const client = create({ + query(query: string) { + queryText = query + return true + }, + update(value: string) { + updateText = value + }, }) const query = select('*').where(triple('?s', '?p', '?o')) expect(await client.queryBoolean(query)).toBe(true) diff --git a/packages/sparql/README.md b/packages/sparql/README.md index f1fbcf9..b3cc1c3 100644 --- a/packages/sparql/README.md +++ b/packages/sparql/README.md @@ -15,11 +15,11 @@ const query = sparql.select(['?name']) The API keeps complete documents separate from embeddable syntax: ```text -SparqlTerm term or legal property-path position -SparqlExpr expression -PatternValue graph-pattern fragment -SparqlQuery complete query document -SparqlUpdate complete Update document +SparqlTermType term or legal property-path position +SparqlExprType expression +PatternValueType graph-pattern fragment +SparqlQueryType complete query document +SparqlUpdateType complete Update document ``` This prevents a complete query/update from being accepted where the SPARQL grammar requires a term, expression, or WHERE fragment. @@ -31,7 +31,7 @@ IRI-bearing positions accept native RDF named nodes. Generated vocabulary values ```ts import * as rdf from '@okikio/rdf' import * as sparql from '@okikio/sparql' -import { Product, name, offers, price } from '@okikio/vocab/schema' +import { name, offers, price, Product } from '@okikio/vocab/schema' const query = sparql.select(['?product', '?name']).where( sparql.triple('?product', rdf.namedNode(rdf.RDF.type), Product), @@ -47,7 +47,7 @@ const text = sparql.typed('42', rdf.namedNode(rdf.XSD.integer)) const update = sparql.update().clear(rdf.namedNode('urn:graph:old')).build() ``` -Strict IRI positions reject variable/literal `SparqlTerm` values at runtime instead of trusting any branded term as an IRI. +Strict IRI positions reject variable/literal `SparqlTermType` values at runtime instead of trusting any branded term as an IRI. ## Execution diff --git a/packages/sparql/builder.ts b/packages/sparql/builder.ts index 4efab6d..1d47e1e 100644 --- a/packages/sparql/builder.ts +++ b/packages/sparql/builder.ts @@ -10,43 +10,45 @@ import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode, type Namespace } from '@okikio/rdf' import { + type IriInputType, + type PatternValueType, queryDocument, rawPattern, + type SparqlExprType, + type SparqlQueryType, + type SparqlTermType, toGraphRef, toRawString, toVarOrIriRef, toVarToken, validateIRI, validatePrefixName, - type IriInput, - type PatternValue, - type SparqlExpr, - type SparqlQuery, - type SparqlTerm, - type VariableName, + type VariableNameType, } from './sparql.ts' import { bind, filter, optional } from './utils.ts' /** SELECT projection item accepted by the fluent builder. */ -export type ProjectionItem = string | SparqlTerm | SparqlExpr +export type ProjectionItemType = string | SparqlTermType | SparqlExprType /** SELECT projection or wildcard. */ -export type Projection = readonly ProjectionItem[] | '*' +export type ProjectionType = readonly ProjectionItemType[] | '*' /** DESCRIBE target accepted by the fluent builder. */ -export type DescribeItem = string | SparqlTerm | RdfNamedNode +export type DescribeItemType = string | SparqlTermType | RdfNamedNode /** Sort order for ORDER BY clauses. */ -export type SortDirection = 'ASC' | 'DESC' +export type SortDirectionType = 'ASC' | 'DESC' /** One ORDER BY variable and optional direction. */ -export interface SortSpec { +export interface SortSpecType { + /** Variable or expression used as the sort key. */ readonly variable: string - readonly direction?: SortDirection + /** RDF 1.2 base text direction associated with this language value. */ + readonly direction?: SortDirectionType } /** SELECT duplicate modifier. */ -export type SelectModifier = 'none' | 'distinct' | 'reduced' +export type SelectModifierType = 'none' | 'distinct' | 'reduced' /** * Immutable query-builder state. @@ -54,30 +56,49 @@ export type SelectModifier = 'none' | 'distinct' | 'reduced' * `construct` is deliberately separate from `where`. A CONSTRUCT template is * output data syntax, while WHERE is the graph pattern evaluated by the query. */ -interface QueryState { +interface QueryStateType { + /** SPARQL query form currently represented by the builder state. */ readonly type: 'SELECT' | 'ASK' | 'CONSTRUCT' | 'DESCRIBE' - readonly projection: Projection - readonly describe: readonly DescribeItem[] - readonly construct?: PatternValue + /** SELECT projection requested by the current query builder state. */ + readonly projection: ProjectionType + /** Resources requested by a DESCRIBE query. */ + readonly describe: readonly DescribeItemType[] + /** Triple templates emitted by a CONSTRUCT query. */ + readonly construct?: PatternValueType + /** Prefix declarations available while serializing the current RDF or SPARQL document. */ readonly prefixes: ReadonlyMap + /** Default graph IRIs included in the query dataset through `FROM`. */ readonly from: readonly string[] + /** Named graph IRIs included in the query dataset. */ readonly fromNamed: readonly string[] - readonly where: readonly PatternValue[] - readonly filters: readonly PatternValue[] - readonly optional: readonly PatternValue[] - readonly bindings: readonly PatternValue[] - readonly unions: readonly (readonly PatternValue[])[] - readonly sorts: readonly SortSpec[] + /** Graph patterns that form the query or update WHERE clause. */ + readonly where: readonly PatternValueType[] + /** FILTER expressions appended to the current graph pattern. */ + readonly filters: readonly PatternValueType[] + /** OPTIONAL graph-pattern groups appended to the current query. */ + readonly optional: readonly PatternValueType[] + /** VALUES or binding records attached to the current query state. */ + readonly bindings: readonly PatternValueType[] + /** UNION graph-pattern branches attached to the current query state. */ + readonly unions: readonly (readonly PatternValueType[])[] + /** ORDER BY specifications applied in source order. */ + readonly sorts: readonly SortSpecType[] + /** GROUP BY expressions applied before aggregate projection. */ readonly groupBy: readonly string[] - readonly having: readonly SparqlExpr[] - readonly values: ReadonlyMap + /** HAVING expressions applied after grouping. */ + readonly having: readonly SparqlExprType[] + /** Value expressions retained from source metadata. */ + readonly values: ReadonlyMap + /** Maximum result rows requested by the current query. */ readonly limit?: number + /** Result rows skipped before query results are returned. */ readonly offset?: number - readonly modifier: SelectModifier + /** SELECT duplicate-handling mode: none, DISTINCT, or REDUCED. */ + readonly modifier: SelectModifierType } /** Shared empty state copied by each query-form constructor. */ -const initialState: QueryState = { +const initialState: QueryStateType = { type: 'SELECT', projection: '*', describe: [], @@ -97,20 +118,20 @@ const initialState: QueryState = { } /** Serializes one SELECT projection item without turning arbitrary IRIs into variables. */ -function projectionText(item: ProjectionItem): string { +function projectionText(item: ProjectionItemType): string { if (typeof item !== 'string') return item.value return toVarToken(item) } /** Serializes one DESCRIBE target according to `VarOrIriRef`. */ -function describeText(item: DescribeItem): string { +function describeText(item: DescribeItemType): string { if (isRdfTerm(item)) return toVarOrIriRef(item) if (typeof item !== 'string') return item.value return toVarOrIriRef(item) } /** Resolves a namespace-like prefix input to its validated absolute IRI. */ -function namespaceText(value: string | SparqlTerm | RdfNamedNode | Namespace): string { +function namespaceText(value: string | SparqlTermType | RdfNamedNode | Namespace): string { if (typeof value === 'function') { validateIRI(value.iri) return value.iri @@ -131,22 +152,23 @@ function namespaceText(value: string | SparqlTerm | RdfNamedNode | Namespace): s } /** Emits one indented pattern while preserving intentional internal newlines. */ -function pushPattern(parts: string[], pattern: PatternValue, depth = 1): void { +function pushPattern(parts: string[], pattern: PatternValueType, depth = 1): void { const indent = ' '.repeat(depth) for (const line of pattern.value.split('\n')) parts.push(`${indent}${line}`) } /** Immutable builder for SELECT, ASK, CONSTRUCT, and DESCRIBE query documents. */ export class QueryBuilder { - readonly #state: QueryState + /** Mutable builder state owned by this builder instance and copied when an immutable output is produced. */ + readonly #state: QueryStateType /** Creates one immutable builder from already-normalized state. */ - private constructor(state: QueryState) { + private constructor(state: QueryStateType) { this.#state = state } /** Starts a SELECT query. */ - static select(projection: Projection = '*'): QueryBuilder { + static select(projection: ProjectionType = '*'): QueryBuilder { return new QueryBuilder({ ...initialState, type: 'SELECT', projection }) } @@ -161,7 +183,7 @@ export class QueryBuilder { * Omit `template` for the SPARQL `CONSTRUCT WHERE { ... }` shorthand. Supply * it to keep the result template separate from the WHERE graph pattern. */ - static construct(template?: PatternValue): QueryBuilder { + static construct(template?: PatternValueType): QueryBuilder { return new QueryBuilder({ ...initialState, type: 'CONSTRUCT', @@ -171,19 +193,27 @@ export class QueryBuilder { } /** Starts a DESCRIBE query over variables and/or explicit RDF named nodes. */ - static describe(resources: readonly DescribeItem[]): QueryBuilder { + static describe(resources: readonly DescribeItemType[]): QueryBuilder { if (resources.length === 0) throw new TypeError('DESCRIBE requires at least one target.') - return new QueryBuilder({ ...initialState, type: 'DESCRIBE', projection: [], describe: [...resources] }) + return new QueryBuilder({ + ...initialState, + type: 'DESCRIBE', + projection: [], + describe: [...resources], + }) } /** Adds a FROM graph IRI. */ - from(graph: IriInput): QueryBuilder { + from(graph: IriInputType): QueryBuilder { return new QueryBuilder({ ...this.#state, from: [...this.#state.from, toGraphRef(graph)] }) } /** Adds a FROM NAMED graph IRI. */ - fromNamed(graph: IriInput): QueryBuilder { - return new QueryBuilder({ ...this.#state, fromNamed: [...this.#state.fromNamed, toGraphRef(graph)] }) + fromNamed(graph: IriInputType): QueryBuilder { + return new QueryBuilder({ + ...this.#state, + fromNamed: [...this.#state.fromNamed, toGraphRef(graph)], + }) } /** @@ -193,7 +223,7 @@ export class QueryBuilder { * `@okikio/rdf` namespace function. Namespace functions therefore compose * directly with SPARQL without flattening them into application strings. */ - prefix(name: string, iri: string | SparqlTerm | RdfNamedNode | Namespace): QueryBuilder { + prefix(name: string, iri: string | SparqlTermType | RdfNamedNode | Namespace): QueryBuilder { validatePrefixName(name) const prefixes = new Map(this.#state.prefixes) prefixes.set(name, namespaceText(iri)) @@ -201,12 +231,12 @@ export class QueryBuilder { } /** Adds graph patterns to WHERE. */ - where(...patterns: readonly PatternValue[]): QueryBuilder { + where(...patterns: readonly PatternValueType[]): QueryBuilder { return new QueryBuilder({ ...this.#state, where: [...this.#state.where, ...patterns] }) } /** Adds FILTER graph-pattern clauses from expressions. */ - filter(...conditions: readonly SparqlExpr[]): QueryBuilder { + filter(...conditions: readonly SparqlExprType[]): QueryBuilder { return new QueryBuilder({ ...this.#state, filters: [...this.#state.filters, ...conditions.map((value) => filter(value))], @@ -214,7 +244,7 @@ export class QueryBuilder { } /** Adds OPTIONAL graph-pattern clauses. */ - optional(...patterns: readonly PatternValue[]): QueryBuilder { + optional(...patterns: readonly PatternValueType[]): QueryBuilder { return new QueryBuilder({ ...this.#state, optional: [...this.#state.optional, ...patterns.map((value) => optional(value))], @@ -222,7 +252,7 @@ export class QueryBuilder { } /** Adds a BIND clause with an explicit output variable. */ - bind(expression: SparqlExpr | SparqlTerm, variable: VariableName): QueryBuilder { + bind(expression: SparqlExprType | SparqlTermType, variable: VariableNameType): QueryBuilder { return new QueryBuilder({ ...this.#state, bindings: [...this.#state.bindings, bind(expression, variable)], @@ -235,13 +265,15 @@ export class QueryBuilder { * One call represents one disjunction. Each branch is emitted in its own * group so `union(a, b)` means `{ a } UNION { b }`, not `{ a b }`. */ - union(...branches: readonly PatternValue[]): QueryBuilder { - if (branches.length < 2) throw new TypeError('UNION requires at least two graph-pattern branches.') + union(...branches: readonly PatternValueType[]): QueryBuilder { + if (branches.length < 2) { + throw new TypeError('UNION requires at least two graph-pattern branches.') + } return new QueryBuilder({ ...this.#state, unions: [...this.#state.unions, [...branches]] }) } /** Adds GROUP BY variables. */ - groupBy(...variables: readonly VariableName[]): QueryBuilder { + groupBy(...variables: readonly VariableNameType[]): QueryBuilder { return new QueryBuilder({ ...this.#state, groupBy: [...this.#state.groupBy, ...variables.map((value) => toVarToken(value))], @@ -249,12 +281,12 @@ export class QueryBuilder { } /** Adds HAVING expressions. */ - having(...conditions: readonly SparqlExpr[]): QueryBuilder { + having(...conditions: readonly SparqlExprType[]): QueryBuilder { return new QueryBuilder({ ...this.#state, having: [...this.#state.having, ...conditions] }) } /** Adds one ORDER BY variable. */ - orderBy(variable: VariableName, direction?: SortDirection): QueryBuilder { + orderBy(variable: VariableNameType, direction?: SortDirectionType): QueryBuilder { const sort = direction === undefined ? { variable: toVarToken(variable) } : { variable: toVarToken(variable), direction } @@ -263,13 +295,17 @@ export class QueryBuilder { /** Sets LIMIT after validating the non-negative integer grammar. */ limit(count: number): QueryBuilder { - if (!Number.isInteger(count) || count < 0) throw new TypeError(`LIMIT must be a non-negative integer, got ${count}.`) + if (!Number.isInteger(count) || count < 0) { + throw new TypeError(`LIMIT must be a non-negative integer, got ${count}.`) + } return new QueryBuilder({ ...this.#state, limit: count }) } /** Sets OFFSET after validating the non-negative integer grammar. */ offset(count: number): QueryBuilder { - if (!Number.isInteger(count) || count < 0) throw new TypeError(`OFFSET must be a non-negative integer, got ${count}.`) + if (!Number.isInteger(count) || count < 0) { + throw new TypeError(`OFFSET must be a non-negative integer, got ${count}.`) + } return new QueryBuilder({ ...this.#state, offset: count }) } @@ -284,26 +320,28 @@ export class QueryBuilder { } /** Adds one single-variable VALUES data block. */ - values(variable: VariableName, values: readonly SparqlTerm[]): QueryBuilder { + values(variable: VariableNameType, values: readonly SparqlTermType[]): QueryBuilder { const blocks = new Map(this.#state.values) blocks.set(toVarToken(variable), [...values]) return new QueryBuilder({ ...this.#state, values: blocks }) } /** Explicitly converts this complete query into a subquery graph pattern. */ - asSubquery(): PatternValue { + asSubquery(): PatternValueType { return rawPattern(`{ ${this.build().value} }`) } /** Builds one complete query document. */ - build(): SparqlQuery { + build(): SparqlQueryType { const parts: string[] = [] for (const [name, iri] of this.#state.prefixes) parts.push(`PREFIX ${name}: <${iri}>`) if (this.#state.prefixes.size > 0) parts.push('') if (this.#state.type === 'SELECT') { - const modifier = this.#state.modifier === 'none' ? '' : `${this.#state.modifier.toUpperCase()} ` + const modifier = this.#state.modifier === 'none' + ? '' + : `${this.#state.modifier.toUpperCase()} ` const projection = this.#state.projection === '*' ? '*' : this.#state.projection.map(projectionText).join(' ') @@ -357,7 +395,13 @@ export class QueryBuilder { parts.push(`HAVING(${this.#state.having.map((value) => value.value).join(' && ')})`) } if (this.#state.sorts.length > 0) { - parts.push(`ORDER BY ${this.#state.sorts.map((sort) => sort.direction ? `${sort.direction}(${sort.variable})` : sort.variable).join(' ')}`) + parts.push( + `ORDER BY ${ + this.#state.sorts.map((sort) => + sort.direction ? `${sort.direction}(${sort.variable})` : sort.variable + ).join(' ') + }`, + ) } if (this.#state.limit !== undefined) parts.push(`LIMIT ${this.#state.limit}`) if (this.#state.offset !== undefined) parts.push(`OFFSET ${this.#state.offset}`) @@ -376,6 +420,6 @@ export const construct = QueryBuilder.construct export const describe = QueryBuilder.describe /** Explicitly wraps a built query as a subquery graph pattern. */ -export function subquery(builder: QueryBuilder): PatternValue { +export function subquery(builder: QueryBuilder): PatternValueType { return builder.asSubquery() } diff --git a/packages/sparql/builder_test.ts b/packages/sparql/builder_test.ts index f84971f..8dc510f 100644 --- a/packages/sparql/builder_test.ts +++ b/packages/sparql/builder_test.ts @@ -2,11 +2,11 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { namedNode, namespace } from '@okikio/rdf' import { - SPARQL_PATTERN_BRAND, - SPARQL_QUERY_BRAND, construct, describe as describeQuery, select, + SPARQL_PATTERN_BRAND, + SPARQL_QUERY_BRAND, strlit, subquery, triple, @@ -40,7 +40,11 @@ describe('@okikio/sparql query builder', () => { triple('?s', 'schema:name', '?name'), triple('?s', 'schema:sku', '?sku'), ).build() - expect(query.value.includes('{\n ?s schema:name ?name .\n }\n UNION\n {\n ?s schema:sku ?sku .\n }')).toBe(true) + expect( + query.value.includes( + '{\n ?s schema:name ?name .\n }\n UNION\n {\n ?s schema:sku ?sku .\n }', + ), + ).toBe(true) }) it('accepts RDF namespace functions and named nodes directly', () => { @@ -57,7 +61,6 @@ describe('@okikio/sparql query builder', () => { expect(query.value.includes('?product ?name .')).toBe(true) }) - it('rejects non-IRI terms from dataset graph clauses', () => { expect(() => select('*').from(strlit('not a graph'))).toThrow() }) diff --git a/packages/sparql/client.ts b/packages/sparql/client.ts index 2cce6ea..ac39284 100644 --- a/packages/sparql/client.ts +++ b/packages/sparql/client.ts @@ -1,18 +1,26 @@ /** Engine-neutral SPARQL query and update contracts. @module */ import type { Quad } from '@okikio/rdf' -import type { SparqlQuery, SparqlUpdate } from './sparql.ts' +import type { SparqlQueryType, SparqlUpdateType } from './sparql.ts' import type { BindingType } from './result/binding.ts' /** Query text accepted by engines and protocol clients. */ -export type QueryInputType = string | SparqlQuery | { readonly build: () => SparqlQuery } +export type QueryInputType = string | SparqlQueryType | { + /** Builds the final SPARQL request text from the deferred input object. */ + readonly build: () => SparqlQueryType +} /** Update text accepted by engines and protocol clients. */ -export type UpdateInputType = string | SparqlUpdate | { readonly build: () => SparqlUpdate } +export type UpdateInputType = string | SparqlUpdateType | { + /** Builds the final SPARQL request text from the deferred input object. */ + readonly build: () => SparqlUpdateType +} /** Per-operation cancellation and implementation-defined timing controls. */ export interface QueryOptionsType { + /** Caller-owned abort signal checked before expensive work and between long-running steps. */ readonly signal?: AbortSignal + /** Maximum request duration in milliseconds before the operation aborts its internal request. */ readonly timeoutMs?: number | null } @@ -24,9 +32,16 @@ export interface QueryOptionsType { * from being submitted as an update by structural accident. */ export interface Queryable { - queryBindings(query: QueryInputType, options?: QueryOptionsType): Promise> + /** Executes a SELECT-style query and returns solution bindings. */ + queryBindings( + query: QueryInputType, + options?: QueryOptionsType, + ): Promise> + /** Executes a CONSTRUCT or DESCRIBE query and returns RDF quads. */ queryQuads(query: QueryInputType, options?: QueryOptionsType): Promise> + /** Executes an ASK query and returns its Boolean result. */ queryBoolean(query: QueryInputType, options?: QueryOptionsType): Promise + /** Executes a SPARQL Update request. */ update(update: UpdateInputType, options?: QueryOptionsType): Promise } diff --git a/packages/sparql/composition_test.ts b/packages/sparql/composition_test.ts index 3d2b35d..2b9b116 100644 --- a/packages/sparql/composition_test.ts +++ b/packages/sparql/composition_test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import * as rdf from '@okikio/rdf' -import { Product, ProductSchema, name, offers, type ProductType } from '@okikio/vocab/schema' +import { name, offers, Product, ProductSchema, type ProductType } from '@okikio/vocab/schema' import { select, triple, variable } from './mod.ts' describe('RDF, vocabulary, and SPARQL composition', () => { diff --git a/packages/sparql/deno.json b/packages/sparql/deno.json index 96c0910..8d6ecd1 100644 --- a/packages/sparql/deno.json +++ b/packages/sparql/deno.json @@ -5,6 +5,14 @@ "exports": { ".": "./mod.ts", "./http": "./http/mod.ts", - "./syntax": "./syntax/mod.ts" + "./syntax": "./syntax/mod.ts", + "./graph-store": "./graph-store/mod.ts" + }, + "publish": { + "exclude": [ + "**/*_test.ts", + "**/*_bench.ts", + "**/*_property_test.ts" + ] } } diff --git a/packages/sparql/graph-store/mod.ts b/packages/sparql/graph-store/mod.ts new file mode 100644 index 0000000..697fb55 --- /dev/null +++ b/packages/sparql/graph-store/mod.ts @@ -0,0 +1,213 @@ +/** SPARQL 1.1 Graph Store HTTP Protocol client. @module */ + +import { defaultGraph, type GraphTermType, namedNode, type Quad, quad } from '@okikio/rdf' +import { parse as parseNTriples, write as writeNTriples } from '@okikio/rdf/ntriples' +import { QueryError } from '../http/error.ts' + +/** Graph selected by a Graph Store HTTP Protocol request. */ +export type GraphTargetType = { + /** Selects the protocol default graph when true. */ + readonly default: true +} | { + /** RDF graph that receives quads produced by Graph Store work. */ + readonly graph: string | URL +} + +/** Graph Store client options. */ +export interface GraphStoreOptionsType { + /** SPARQL protocol endpoint used for this client operation. */ + readonly endpoint: string | URL + /** Caller-supplied Fetch-compatible function used for remote HTTP requests. */ + readonly fetch?: typeof fetch + /** HTTP headers merged into protocol requests without mutating the caller-supplied Headers object. */ + readonly headers?: HeadersInit + /** Maximum response body size accepted before the protocol client aborts decoding. */ + readonly maxResponseBytes?: number +} + +/** Per-request Graph Store controls. */ +export interface GraphStoreRequestOptionsType { + /** Caller-owned abort signal checked before expensive work and between long-running steps. */ + readonly signal?: AbortSignal + /** HTTP headers merged into protocol requests without mutating the caller-supplied Headers object. */ + readonly headers?: HeadersInit +} + +/** Graph Store HTTP Protocol client. */ +export interface Client { + /** SPARQL protocol endpoint used for this client operation. */ + readonly endpoint: URL + /** Gets all quads from the addressed default or named graph. */ + get(target: GraphTargetType, options?: GraphStoreRequestOptionsType): Promise + /** Replaces the addressed graph or resource with the supplied representation. */ + put( + target: GraphTargetType, + source: Iterable, + options?: GraphStoreRequestOptionsType, + ): Promise + /** Appends or submits the supplied graph representation according to SPARQL Graph Store HTTP semantics. */ + post( + target: GraphTargetType, + source: Iterable, + options?: GraphStoreRequestOptionsType, + ): Promise + /** Removes the addressed default or named graph from the remote Graph Store. */ + delete(target: GraphTargetType, options?: GraphStoreRequestOptionsType): Promise +} + +/** Default response-size limit used when the caller does not provide an override. */ +const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 + +/** + * Creates an import-safe Graph Store HTTP client. + * + * The client does not contact the endpoint until `get`, `put`, `post`, or + * `delete` is called. Response materialization is limited by + * `maxResponseBytes` so a remote graph cannot grow JavaScript memory without a + * configured limit. + * + * @example + * ```ts + * import * as graphStore from '@okikio/sparql/graph-store' + * + * const client = graphStore.create({ endpoint: 'https://example.test/data' }) + * const quads = await client.get({ default: true }) + * ``` + */ +export function create(options: GraphStoreOptionsType): Client { + const endpoint = new URL(options.endpoint) + const fetchImpl = options.fetch ?? fetch + return { + endpoint, + /** Returns the previously issued or cached value without changing ordering state. */ + async get(target, requestOptions = {}) { + const response = await send( + fetchImpl, + endpoint, + target, + 'GET', + undefined, + options, + requestOptions, + ) + const text = await limitedText( + response, + options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + ) + const graph = graphFor(target) + const values: Quad[] = [] + for await ( + const value of parseNTriples( + text, + requestOptions.signal ? { signal: requestOptions.signal } : {}, + ) + ) { + values.push(quad(value.subject, value.predicate, value.object, graph)) + } + return values + }, + /** Replaces the selected Graph Store graph with the supplied RDF payload. */ + async put(target, source, requestOptions = {}) { + await discard( + await send(fetchImpl, endpoint, target, 'PUT', body(source), options, requestOptions), + ) + }, + /** Merges the supplied RDF payload into the selected Graph Store graph. */ + async post(target, source, requestOptions = {}) { + await discard( + await send(fetchImpl, endpoint, target, 'POST', body(source), options, requestOptions), + ) + }, + /** Removes the selected graph through the SPARQL Graph Store Protocol. */ + async delete(target, requestOptions = {}) { + await discard( + await send(fetchImpl, endpoint, target, 'DELETE', undefined, options, requestOptions), + ) + }, + } +} + +/** Encoded HTTP request body produced for the selected SPARQL Protocol method. */ +function body(source: Iterable): string { + const values = Array.from( + source, + (value) => quad(value.subject, value.predicate, value.object, defaultGraph()), + ) + return writeNTriples(values) +} + +/** Sends the prepared SPARQL Protocol request and returns the validated HTTP response. */ +async function send( + fetchImpl: typeof fetch, + endpoint: URL, + target: GraphTargetType, + method: 'GET' | 'PUT' | 'POST' | 'DELETE', + payload: string | undefined, + client: GraphStoreOptionsType, + options: GraphStoreRequestOptionsType, +): Promise { + const url = select(endpoint, target) + const headers = new Headers(client.headers) + for (const [key, value] of new Headers(options.headers)) headers.set(key, value) + headers.set('accept', 'application/n-triples') + if (payload !== undefined) headers.set('content-type', 'application/n-triples; charset=utf-8') + let response: Response + try { + response = await fetchImpl(url, { + method, + headers, + ...(payload === undefined ? {} : { body: payload }), + ...(options.signal ? { signal: options.signal } : {}), + }) + } catch (cause) { + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + } + throw new QueryError('network', 'Graph Store request failed before a response was received.', { + cause, + }) + } + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new QueryError( + 'http', + `Graph Store endpoint returned HTTP ${response.status} ${response.statusText}.`, + { + status: response.status, + response: detail.slice(0, 16 * 1024), + }, + ) + } + return response +} + +/** Returns the endpoint URL with the Graph Store default-graph or named-graph selector. */ +function select(endpoint: URL, target: GraphTargetType): URL { + const url = new URL(endpoint) + if ('default' in target) { + const prefix = url.search ? `${url.search.slice(1)}&` : '' + url.search = `${prefix}default` + } else { + url.searchParams.append('graph', String(target.graph)) + } + return url +} + +/** Returns the graph target encoded by the current Graph Store request options. */ +function graphFor(target: GraphTargetType): GraphTermType { + return 'default' in target ? defaultGraph() : namedNode(String(target.graph)) +} + +/** Reads a bounded response preview for diagnostics without materializing an unbounded body. */ +async function limitedText(response: Response, maxBytes: number): Promise { + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.byteLength > maxBytes) { + throw new QueryError('limit', `Graph Store response exceeded ${maxBytes} bytes.`) + } + return new TextDecoder().decode(bytes) +} + +/** Cancels and drains no further response data after the caller no longer needs the body. */ +async function discard(response: Response): Promise { + if (response.body) await response.body.cancel().catch(() => undefined) +} diff --git a/packages/sparql/graph-store/mod_test.ts b/packages/sparql/graph-store/mod_test.ts new file mode 100644 index 0000000..b0a8a76 --- /dev/null +++ b/packages/sparql/graph-store/mod_test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode, quad } from '@okikio/rdf' +import { create } from './mod.ts' + +const source = quad( + namedNode('urn:s'), + namedNode('urn:p'), + namedNode('urn:o'), + namedNode('urn:source'), +) + +describe('@okikio/sparql/graph-store', () => { + it('emits the exact default selector used by Graph Store HTTP Protocol', async () => { + let url = '' + const client = create({ + endpoint: 'https://example.com/data', + fetch: async (input) => { + url = String(input) + return new Response(null, { status: 204 }) + }, + }) + await client.delete({ default: true }) + expect(url).toBe('https://example.com/data?default') + }) + + it('percent-encodes named graph selectors without losing endpoint query parameters', async () => { + let url = '' + const client = create({ + endpoint: 'https://example.com/data?tenant=a', + fetch: async (input) => { + url = String(input) + return new Response(null, { status: 204 }) + }, + }) + await client.delete({ graph: 'https://example.com/graph?a=1&b=2' }) + const target = new URL(url) + expect(target.searchParams.get('tenant')).toBe('a') + expect(target.searchParams.get('graph')).toBe('https://example.com/graph?a=1&b=2') + }) + + it('writes graph payloads as RDF graphs and reattaches the selected graph on reads', async () => { + const bodies: string[] = [] + const client = create({ + endpoint: 'https://example.com/data', + fetch: async (_input, init) => { + if (init?.body) bodies.push(String(init.body)) + if (init?.method === 'GET') { + return new Response(' .\n', { + headers: { 'content-type': 'application/n-triples' }, + }) + } + return new Response(null, { status: 204 }) + }, + }) + + await client.put({ graph: 'urn:target' }, [source]) + expect(bodies[0]).toBe(' .\n') + const values = await client.get({ graph: 'urn:target' }) + expect(values).toHaveLength(1) + expect(values[0]?.graph.value).toBe('urn:target') + }) +}) diff --git a/packages/sparql/http/error.ts b/packages/sparql/http/error.ts index a726428..bb3eb8a 100644 --- a/packages/sparql/http/error.ts +++ b/packages/sparql/http/error.ts @@ -1,28 +1,47 @@ /** Normalized failures from SPARQL HTTP protocol operations. @module */ /** Stable high-level HTTP query failure category. */ -export type QueryErrorKind = 'abort' | 'timeout' | 'network' | 'http' | 'media' | 'protocol' | 'limit' +export type QueryErrorKindType = + | 'abort' + | 'timeout' + | 'network' + | 'http' + | 'media' + | 'protocol' + | 'limit' /** Error with bounded diagnostics suitable for application logging. */ export class QueryError extends Error { - readonly kind: QueryErrorKind + /** Stable protocol failure category that callers can branch on without parsing the message. */ + readonly kind: QueryErrorKindType + /** Structured protocol details retained on this query error for diagnostics. */ readonly details: { + /** HTTP status code associated with this SPARQL protocol failure. */ readonly status?: number + /** Response media type observed when the SPARQL protocol request failed. */ readonly mediaType?: string + /** SPARQL query or update text associated with this protocol failure. */ readonly query?: string + /** Bounded response excerpt retained to diagnose the SPARQL protocol failure. */ readonly response?: string + /** Original runtime or network failure retained for diagnostics. */ readonly cause?: unknown } /** Creates one stable protocol failure while preserving bounded details and the original cause. */ constructor( - kind: QueryErrorKind, + kind: QueryErrorKindType, message: string, details: { + /** HTTP status code associated with this SPARQL protocol failure. */ readonly status?: number + /** Response media type observed when the SPARQL protocol request failed. */ readonly mediaType?: string + /** SPARQL query or update text associated with this protocol failure. */ readonly query?: string + /** Bounded response excerpt retained to diagnose the SPARQL protocol failure. */ readonly response?: string + /** Original runtime or network failure retained for diagnostics. */ readonly cause?: unknown } = {}, ) { diff --git a/packages/sparql/http/mod.ts b/packages/sparql/http/mod.ts index 169f1b9..5c0aaf7 100644 --- a/packages/sparql/http/mod.ts +++ b/packages/sparql/http/mod.ts @@ -4,32 +4,117 @@ import { parse as parseNQuads } from '@okikio/rdf/nquads' import { parse as parseNTriples } from '@okikio/rdf/ntriples' import type { Quad } from '@okikio/rdf' import { getQueryText, getUpdateText, type Queryable, type QueryOptionsType } from '../client.ts' -import { readBindings, readBoolean } from '../result/json.ts' +import { decodeBindings, decodeBoolean } from '../result/json.ts' import { QueryError } from './error.ts' +/** SPARQL Query request transfer mode. */ +export type QueryMethodType = 'get' | 'post-form' | 'post-direct' +/** SPARQL Update request transfer mode. */ +export type UpdateMethodType = 'post-form' | 'post-direct' + +/** Dataset parameters defined by the SPARQL Protocol for query operations. */ +export interface QueryDatasetType { + /** Default graph IRIs sent using the SPARQL Protocol query dataset parameters. */ + readonly defaultGraphUris?: readonly string[] + /** Named graph IRIs sent using the SPARQL Protocol query dataset parameters. */ + readonly namedGraphUris?: readonly string[] +} + +/** Dataset parameters defined by the SPARQL Protocol for update operations. */ +export interface UpdateDatasetType { + /** Default USING graph IRIs sent with a SPARQL Update request. */ + readonly usingGraphUris?: readonly string[] + /** Named USING graph IRIs sent with a SPARQL Update request. */ + readonly usingNamedGraphUris?: readonly string[] +} + +/** Per-request HTTP controls layered on top of the engine-neutral query controls. */ +export interface HttpRequestOptionsType extends QueryOptionsType { + /** HTTP encoding used for SPARQL Query requests. */ + readonly queryMethod?: QueryMethodType + /** HTTP encoding used for SPARQL Update requests. */ + readonly updateMethod?: UpdateMethodType + /** Default and named graph parameters attached to SPARQL Query requests. */ + readonly queryDataset?: QueryDatasetType + /** Using-graph parameters attached to SPARQL Update requests. */ + readonly updateDataset?: UpdateDatasetType + /** HTTP headers merged into protocol requests without mutating the caller-supplied Headers object. */ + readonly headers?: HeadersInit +} + /** SPARQL endpoint client configuration. */ -export interface ClientOptionsType { +export interface HttpOptionsType { + /** SPARQL protocol endpoint used for this client operation. */ readonly endpoint: string | URL + /** Optional distinct endpoint used for SPARQL Update requests. */ readonly updateEndpoint?: string | URL + /** Caller-supplied Fetch-compatible function used for remote HTTP requests. */ readonly fetch?: typeof fetch + /** HTTP headers merged into protocol requests without mutating the caller-supplied Headers object. */ readonly headers?: HeadersInit + /** Maximum request duration in milliseconds before the operation aborts its internal request. */ readonly timeoutMs?: number + /** Maximum response body size accepted before the protocol client aborts decoding. */ readonly maxResponseBytes?: number + /** HTTP encoding used for SPARQL Query requests. */ + readonly queryMethod?: QueryMethodType + /** HTTP encoding used for SPARQL Update requests. */ + readonly updateMethod?: UpdateMethodType + /** Default and named graph parameters attached to SPARQL Query requests. */ + readonly queryDataset?: QueryDatasetType + /** Using-graph parameters attached to SPARQL Update requests. */ + readonly updateDataset?: UpdateDatasetType } /** SPARQL HTTP client with explicit result-mode methods. */ export interface Client extends Queryable { + /** SPARQL protocol endpoint used for this client operation. */ readonly endpoint: URL + /** Optional distinct endpoint used for SPARQL Update requests. */ readonly updateEndpoint: URL + /** Sends a SELECT-style query and returns decoded solution bindings. */ + queryBindings( + query: Parameters[0], + options?: HttpRequestOptionsType, + ): ReturnType + /** Sends a CONSTRUCT or DESCRIBE query and returns decoded RDF quads. */ + queryQuads( + query: Parameters[0], + options?: HttpRequestOptionsType, + ): ReturnType + /** Sends an ASK query and returns its Boolean result. */ + queryBoolean( + query: Parameters[0], + options?: HttpRequestOptionsType, + ): ReturnType + /** Sends a SPARQL Update request and resolves after the endpoint accepts it. */ + update( + update: Parameters[0], + options?: HttpRequestOptionsType, + ): ReturnType } -/** Default max response bytes used when the caller does not provide an override. */ +/** Default response-size limit used when the caller does not provide an override. */ const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 -/** Maximum SPARQL source characters retained in normalized HTTP error details. */ +/** Maximum query characters retained in protocol diagnostics. */ const QUERY_PREVIEW_LENGTH = 512 -/** Creates an import-safe SPARQL HTTP protocol client. No request is made until a method is called. */ -export function createClient(options: ClientOptionsType): Client { +/** + * Creates an import-safe SPARQL HTTP protocol client. + * + * Creation only validates and stores endpoint configuration. Network work starts + * when a query or update method is called. The caller owns any supplied `fetch` + * implementation and abort signals. + * + * @example + * ```ts + * import * as http from '@okikio/sparql/http' + * + * const client = http.create({ endpoint: 'https://example.test/sparql' }) + * const exists = await client.queryBoolean('ASK { ?s ?p ?o }') + * ``` + */ +export function create(options: HttpOptionsType): Client { const endpoint = new URL(options.endpoint) const updateEndpoint = new URL(options.updateEndpoint ?? options.endpoint) const fetchImpl = options.fetch ?? fetch @@ -38,96 +123,240 @@ export function createClient(options: ClientOptionsType): Client { return { endpoint, updateEndpoint, - /** Query bindings through the wrapped engine without transferring engine ownership. */ + /** Executes a SPARQL tuple query and decodes the JSON bindings result. */ async queryBindings(query, queryOptions = {}) { const text = getQueryText(query) - const response = await request(fetchImpl, endpoint, text, 'query', options, queryOptions, - 'application/sparql-results+json; version=1.2, application/sparql-results+json') - const json = await readJson(response, maxResponseBytes, queryOptions.signal, text) - const bindings = readBindings(json) + const response = await request( + fetchImpl, + endpoint, + text, + 'query', + options, + queryOptions, + 'application/sparql-results+json; version=1.2, application/sparql-results+json', + ) + const bindings = decodeBindings( + await readJson(response, maxResponseBytes, queryOptions.signal, text), + ) return array(bindings) }, - /** Query boolean through the wrapped engine without transferring engine ownership. */ + /** Executes a SPARQL ASK query and decodes the boolean result. */ async queryBoolean(query, queryOptions = {}) { const text = getQueryText(query) - const response = await request(fetchImpl, endpoint, text, 'query', options, queryOptions, - 'application/sparql-results+json; version=1.2, application/sparql-results+json') - return readBoolean(await readJson(response, maxResponseBytes, queryOptions.signal, text)) + const response = await request( + fetchImpl, + endpoint, + text, + 'query', + options, + queryOptions, + 'application/sparql-results+json; version=1.2, application/sparql-results+json', + ) + return decodeBoolean(await readJson(response, maxResponseBytes, queryOptions.signal, text)) }, - /** Query quads through the wrapped engine without transferring engine ownership. */ + /** Executes a graph-producing SPARQL query and returns its RDF quad stream. */ async queryQuads(query, queryOptions = {}) { const text = getQueryText(query) - const response = await request(fetchImpl, endpoint, text, 'query', options, queryOptions, - 'application/n-quads; version=1.2, application/n-triples; version=1.2, application/n-quads, application/n-triples') + const response = await request( + fetchImpl, + endpoint, + text, + 'query', + options, + queryOptions, + 'application/n-quads; version=1.2, application/n-triples; version=1.2, application/n-quads, application/n-triples', + ) const mediaType = getMediaType(response.headers.get('content-type')) if (!response.body) return array([]) - if (mediaType === 'application/n-quads') return parseNQuads(response.body, queryOptions.signal ? { signal: queryOptions.signal } : {}) + if (mediaType === 'application/n-quads') { + return parseNQuads( + response.body, + queryOptions.signal ? { signal: queryOptions.signal } : {}, + ) + } if (mediaType === 'application/n-triples' || mediaType === 'text/plain') { - return parseNTriples(response.body, queryOptions.signal ? { signal: queryOptions.signal } : {}) + return parseNTriples( + response.body, + queryOptions.signal ? { signal: queryOptions.signal } : {}, + ) } await response.body.cancel().catch(() => undefined) - throw new QueryError('media', `Unsupported RDF graph result media type '${mediaType || 'unknown'}'.`, { - mediaType, - query: preview(text), - }) + throw new QueryError( + 'media', + `Unsupported RDF graph result media type '${mediaType || 'unknown'}'.`, + { + mediaType, + query: preview(text), + }, + ) }, - /** Submits one complete SPARQL Update document through the wrapped engine. */ + /** Sends one SPARQL Update request using the configured protocol mode. */ async update(update, queryOptions = {}) { const text = getUpdateText(update) - const response = await request(fetchImpl, updateEndpoint, text, 'update', options, queryOptions, '*/*') + const response = await request( + fetchImpl, + updateEndpoint, + text, + 'update', + options, + queryOptions, + '*/*', + ) if (response.body) await response.body.cancel().catch(() => undefined) }, } } -/** Sends one SPARQL protocol POST and normalizes abort, timeout, network, and HTTP failures. */ +/** Sends one SPARQL protocol request and normalizes abort, timeout, network, and HTTP failures. */ async function request( fetchImpl: typeof fetch, endpoint: URL, text: string, operation: 'query' | 'update', - client: ClientOptionsType, - options: QueryOptionsType, + client: HttpOptionsType, + options: HttpRequestOptionsType, accept: string, ): Promise { const timeout = options.timeoutMs === null ? 0 : options.timeoutMs ?? client.timeoutMs ?? 0 const signal = getSignal(options.signal, timeout) const headers = new Headers(client.headers) - headers.set('content-type', operation === 'query' ? 'application/sparql-query; charset=utf-8' : 'application/sparql-update; charset=utf-8') + for (const [key, value] of new Headers(options.headers)) headers.set(key, value) headers.set('accept', accept) + const target = new URL(endpoint) + const init = operation === 'query' + ? queryRequest( + target, + text, + options.queryMethod ?? client.queryMethod ?? 'post-direct', + options.queryDataset ?? client.queryDataset, + headers, + ) + : updateRequest( + target, + text, + options.updateMethod ?? client.updateMethod ?? 'post-direct', + options.updateDataset ?? client.updateDataset, + headers, + ) let response: Response try { - response = await fetchImpl(endpoint, { method: 'POST', headers, body: text, ...(signal ? { signal } : {}) }) + response = await fetchImpl(target, { ...init, ...(signal ? { signal } : {}) }) } catch (error) { if (signal?.aborted) { const timedOut = timeout > 0 && !options.signal?.aborted - throw new QueryError(timedOut ? 'timeout' : 'abort', timedOut ? `SPARQL request timed out after ${timeout}ms.` : 'SPARQL request was aborted.', { + throw new QueryError( + timedOut ? 'timeout' : 'abort', + timedOut ? `SPARQL request timed out after ${timeout}ms.` : 'SPARQL request was aborted.', + { + query: preview(text), + cause: error, + }, + ) + } + throw new QueryError( + 'network', + 'SPARQL endpoint request failed before a response was received.', + { query: preview(text), cause: error, - }) - } - throw new QueryError('network', 'SPARQL endpoint request failed before a response was received.', { - query: preview(text), - cause: error, - }) + }, + ) } if (!response.ok) { - const responseText = await readText(response, Math.min(client.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, 16 * 1024), signal) - throw new QueryError('http', `SPARQL endpoint returned HTTP ${response.status} ${response.statusText}.`, { - status: response.status, - query: preview(text), - response: responseText, - }) + const responseText = await readText( + response, + Math.min(client.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, 16 * 1024), + signal, + ) + throw new QueryError( + 'http', + `SPARQL endpoint returned HTTP ${response.status} ${response.statusText}.`, + { + status: response.status, + query: preview(text), + response: responseText, + }, + ) } return response } -/** Read json from the supplied source while preserving caller ownership. */ -async function readJson(response: Response, limit: number, signal: AbortSignal | undefined, query: string): Promise { +/** Builds one SPARQL Query request using a standards-defined transfer mode. */ +function queryRequest( + url: URL, + text: string, + method: QueryMethodType, + dataset: QueryDatasetType | undefined, + headers: Headers, +): RequestInit { + const params = new URLSearchParams() + addQueryDataset(params, dataset) + if (method === 'get') { + params.append('query', text) + append(url, params) + return { method: 'GET', headers } + } + if (method === 'post-form') { + params.append('query', text) + headers.set('content-type', 'application/x-www-form-urlencoded; charset=utf-8') + return { method: 'POST', headers, body: params } + } + addQueryDataset(url.searchParams, dataset) + headers.set('content-type', 'application/sparql-query; charset=utf-8') + return { method: 'POST', headers, body: text } +} + +/** Builds one SPARQL Update request using a standards-defined transfer mode. */ +function updateRequest( + url: URL, + text: string, + method: UpdateMethodType, + dataset: UpdateDatasetType | undefined, + headers: Headers, +): RequestInit { + const params = new URLSearchParams() + addUpdateDataset(params, dataset) + if (method === 'post-form') { + params.append('update', text) + headers.set('content-type', 'application/x-www-form-urlencoded; charset=utf-8') + return { method: 'POST', headers, body: params } + } + addUpdateDataset(url.searchParams, dataset) + headers.set('content-type', 'application/sparql-update; charset=utf-8') + return { method: 'POST', headers, body: text } +} + +/** Adds SPARQL Query dataset parameters to the request URL or form body. */ +function addQueryDataset(params: URLSearchParams, value?: QueryDatasetType): void { + for (const iri of value?.defaultGraphUris ?? []) params.append('default-graph-uri', iri) + for (const iri of value?.namedGraphUris ?? []) params.append('named-graph-uri', iri) +} + +/** Adds SPARQL Update USING dataset parameters to the request form body. */ +function addUpdateDataset(params: URLSearchParams, value?: UpdateDatasetType): void { + for (const iri of value?.usingGraphUris ?? []) params.append('using-graph-uri', iri) + for (const iri of value?.usingNamedGraphUris ?? []) params.append('using-named-graph-uri', iri) +} + +/** Appends one encoded protocol field without replacing earlier repeated values. */ +function append(url: URL, params: URLSearchParams): void { + for (const [key, value] of params) url.searchParams.append(key, value) +} + +/** Reads a bounded HTTP response body and decodes it as JSON. */ +async function readJson( + response: Response, + limit: number, + signal: AbortSignal | undefined, + query: string, +): Promise { const mediaType = getMediaType(response.headers.get('content-type')) - if (mediaType !== 'application/sparql-results+json' && mediaType !== 'application/json' && mediaType !== '') { + if ( + mediaType !== 'application/sparql-results+json' && mediaType !== 'application/json' && + mediaType !== '' + ) { if (response.body) await response.body.cancel().catch(() => undefined) throw new QueryError('media', `Expected SPARQL JSON results but received '${mediaType}'.`, { mediaType, @@ -146,7 +375,7 @@ async function readJson(response: Response, limit: number, signal: AbortSignal | } } -/** Read text from the supplied source while preserving caller ownership. */ +/** Reads a bounded HTTP response body as UTF-8 text. */ async function readText(response: Response, limit: number, signal?: AbortSignal): Promise { if (!response.body) return '' const reader = response.body.getReader() @@ -170,12 +399,16 @@ async function readText(response: Response, limit: number, signal?: AbortSignal) } return text + decoder.decode() } finally { - if (!complete) await reader.cancel('SPARQL response consumption stopped before completion').catch(() => undefined) + if (!complete) { + await reader.cancel('SPARQL response consumption stopped before completion').catch(() => + undefined + ) + } reader.releaseLock() } } -/** Reads one response chunk while allowing an already-pending read to be aborted. */ +/** Reads one chunk from the response stream while preserving cancellation. */ function readBody( reader: ReadableStreamDefaultReader, signal?: AbortSignal, @@ -185,7 +418,6 @@ function readBody( void reader.cancel(signal.reason).catch(() => undefined) return Promise.reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) } - return new Promise((resolve, reject) => { let settled = false const finish = (): void => signal.removeEventListener('abort', onAbort) @@ -196,46 +428,44 @@ function readBody( void reader.cancel(signal.reason).catch(() => undefined) reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) } - signal.addEventListener('abort', onAbort, { once: true }) - reader.read().then( - (value) => { - if (settled) return + reader.read().then((value) => { + if (!settled) { settled = true finish() resolve(value) - }, - (error) => { - if (settled) return + } + }, (error) => { + if (!settled) { settled = true finish() reject(error) - }, - ) + } + }) }) } -/** Combines caller cancellation with the configured timeout without inventing a timeout when disabled. */ +/** Returns the caller signal or a timeout-linked signal for the current request. */ function getSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined { const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined if (signal && timeout) return AbortSignal.any([signal, timeout]) return signal ?? timeout } -/** Normalizes a Content-Type header to its lowercase media type without parameters. */ +/** Returns the normalized response media type without parameters. */ function getMediaType(value: string | null): string { return (value ?? '').split(';', 1)[0]!.trim().toLowerCase() } -/** Bounds query text retained in errors so diagnostics cannot capture an unbounded request body. */ +/** Returns a bounded query preview suitable for diagnostics. */ function preview(query: string): string { return query.length <= QUERY_PREVIEW_LENGTH ? query : `${query.slice(0, QUERY_PREVIEW_LENGTH)}…` } -/** Adapts an already-materialized result array to the asynchronous Queryable stream contract. */ +/** Normalizes a scalar-or-array input into a readonly array. */ async function* array(values: readonly T[]): AsyncGenerator { yield* values } export { QueryError } from './error.ts' -export type { QueryErrorKind } from './error.ts' +export type { QueryErrorKindType } from './error.ts' diff --git a/packages/sparql/http/mod_test.ts b/packages/sparql/http/mod_test.ts index 7e61e0e..304f728 100644 --- a/packages/sparql/http/mod_test.ts +++ b/packages/sparql/http/mod_test.ts @@ -1,16 +1,22 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { strlit, triple, update } from '../mod.ts' -import { createClient, QueryError } from './mod.ts' +import { create, QueryError } from './mod.ts' describe('@okikio/sparql/http', () => { it('keeps SELECT bindings as RDF terms', async () => { - const client = createClient({ + const client = create({ endpoint: 'https://example.com/sparql', - fetch: async () => new Response(JSON.stringify({ - head: { vars: ['name'] }, - results: { bindings: [{ name: { type: 'literal', value: 'Alice', 'xml:lang': 'en' } }] }, - }), { headers: { 'content-type': 'application/sparql-results+json' } }), + fetch: async () => + new Response( + JSON.stringify({ + head: { vars: ['name'] }, + results: { + bindings: [{ name: { type: 'literal', value: 'Alice', 'xml:lang': 'en' } }], + }, + }), + { headers: { 'content-type': 'application/sparql-results+json' } }, + ), }) const rows = [] for await (const row of await client.queryBindings('SELECT ?name WHERE {}')) rows.push(row) @@ -18,37 +24,47 @@ describe('@okikio/sparql/http', () => { }) it('parses graph result media types without converting RDF terms to bindings', async () => { - const client = createClient({ + const client = create({ endpoint: 'https://example.com/sparql', - fetch: async () => new Response(' "o" .\n', { - headers: { 'content-type': 'application/n-quads; version=1.2' }, - }), + fetch: async () => + new Response(' "o" .\n', { + headers: { 'content-type': 'application/n-quads; version=1.2' }, + }), }) const values = [] - for await (const value of await client.queryQuads('CONSTRUCT WHERE { ?s ?p ?o }')) values.push(value) + for await (const value of await client.queryQuads('CONSTRUCT WHERE { ?s ?p ?o }')) { + values.push(value) + } expect(values).toHaveLength(1) expect(values[0]?.graph.value).toBe('urn:g') }) it('rejects graph and binding media types that do not match the requested result mode', async () => { - const bindingClient = createClient({ + const bindingClient = create({ endpoint: 'https://example.com/sparql', fetch: async () => new Response('plain', { headers: { 'content-type': 'text/plain' } }), }) - await expect(bindingClient.queryBindings('SELECT * WHERE {}')).rejects.toThrow('Expected SPARQL JSON') + await expect(bindingClient.queryBindings('SELECT * WHERE {}')).rejects.toThrow( + 'Expected SPARQL JSON', + ) - const graphClient = createClient({ + const graphClient = create({ endpoint: 'https://example.com/sparql', fetch: async () => new Response('{}', { headers: { 'content-type': 'application/json' } }), }) - await expect(graphClient.queryQuads('CONSTRUCT WHERE { ?s ?p ?o }')).rejects.toThrow('Unsupported RDF graph') + await expect(graphClient.queryQuads('CONSTRUCT WHERE { ?s ?p ?o }')).rejects.toThrow( + 'Unsupported RDF graph', + ) }) it('enforces response byte limits before JSON decoding', async () => { - const client = createClient({ + const client = create({ endpoint: 'https://example.com/sparql', maxResponseBytes: 8, - fetch: async () => new Response('{"boolean":true}', { headers: { 'content-type': 'application/sparql-results+json' } }), + fetch: async () => + new Response('{"boolean":true}', { + headers: { 'content-type': 'application/sparql-results+json' }, + }), }) try { await client.queryBoolean('ASK {}') @@ -66,9 +82,10 @@ describe('@okikio/sparql/http', () => { cancelled = true }, }) - const client = createClient({ + const client = create({ endpoint: 'https://example.com/sparql', - fetch: async () => new Response(body, { headers: { 'content-type': 'application/sparql-results+json' } }), + fetch: async () => + new Response(body, { headers: { 'content-type': 'application/sparql-results+json' } }), }) const controller = new AbortController() const pending = client.queryBoolean('ASK {}', { signal: controller.signal }) @@ -82,7 +99,7 @@ describe('@okikio/sparql/http', () => { let requestUrl = '' let contentType = '' let body = '' - const client = createClient({ + const client = create({ endpoint: 'https://example.com/query', updateEndpoint: 'https://example.com/update', fetch: async (input, init) => { @@ -101,9 +118,10 @@ describe('@okikio/sparql/http', () => { }) it('normalizes non-success responses into bounded HTTP errors', async () => { - const client = createClient({ + const client = create({ endpoint: 'https://example.com/sparql', - fetch: async () => new Response('temporarily unavailable', { status: 503, statusText: 'Unavailable' }), + fetch: async () => + new Response('temporarily unavailable', { status: 503, statusText: 'Unavailable' }), }) try { await client.queryBoolean('ASK {}') @@ -117,3 +135,97 @@ describe('@okikio/sparql/http', () => { } }) }) + +it('supports SPARQL Protocol GET query dataset parameters without moving them into the body', async () => { + let method = '' + let url = '' + let body: BodyInit | null | undefined + const client = create({ + endpoint: 'https://example.com/sparql?existing=1', + fetch: async (input, init) => { + method = init?.method ?? '' + url = String(input) + body = init?.body + return new Response(JSON.stringify({ boolean: true }), { + headers: { 'content-type': 'application/sparql-results+json' }, + }) + }, + }) + + expect( + await client.queryBoolean('ASK {}', { + queryMethod: 'get', + queryDataset: { + defaultGraphUris: ['https://example.com/default'], + namedGraphUris: ['https://example.com/named'], + }, + }), + ).toBe(true) + + const target = new URL(url) + expect(method).toBe('GET') + expect(body).toBeUndefined() + expect(target.searchParams.get('existing')).toBe('1') + expect(target.searchParams.get('query')).toBe('ASK {}') + expect(target.searchParams.getAll('default-graph-uri')).toEqual(['https://example.com/default']) + expect(target.searchParams.getAll('named-graph-uri')).toEqual(['https://example.com/named']) +}) + +it('supports form-encoded SPARQL query and update protocol requests', async () => { + const requests: Array<{ url: string; contentType: string; body: string }> = [] + const client = create({ + endpoint: 'https://example.com/sparql', + fetch: async (input, init) => { + requests.push({ + url: String(input), + contentType: new Headers(init?.headers).get('content-type') ?? '', + body: String(init?.body ?? ''), + }) + return requests.length === 1 + ? new Response(JSON.stringify({ boolean: true }), { + headers: { 'content-type': 'application/sparql-results+json' }, + }) + : new Response(null, { status: 204 }) + }, + }) + + await client.queryBoolean('ASK {}', { + queryMethod: 'post-form', + queryDataset: { defaultGraphUris: ['https://example.com/default'] }, + }) + await client.update('CLEAR DEFAULT', { + updateMethod: 'post-form', + updateDataset: { usingNamedGraphUris: ['https://example.com/named'] }, + }) + + expect(requests[0]?.url).toBe('https://example.com/sparql') + expect(requests[0]?.contentType).toBe('application/x-www-form-urlencoded; charset=utf-8') + const query = new URLSearchParams(requests[0]?.body) + expect(query.get('query')).toBe('ASK {}') + expect(query.getAll('default-graph-uri')).toEqual(['https://example.com/default']) + + expect(requests[1]?.contentType).toBe('application/x-www-form-urlencoded; charset=utf-8') + const update = new URLSearchParams(requests[1]?.body) + expect(update.get('update')).toBe('CLEAR DEFAULT') + expect(update.getAll('using-named-graph-uri')).toEqual(['https://example.com/named']) +}) + +it('keeps direct POST dataset parameters in the request URL', async () => { + let url = '' + let body = '' + const client = create({ + endpoint: 'https://example.com/sparql', + fetch: async (input, init) => { + url = String(input) + body = String(init?.body ?? '') + return new Response(JSON.stringify({ boolean: true }), { + headers: { 'content-type': 'application/sparql-results+json' }, + }) + }, + }) + await client.queryBoolean('ASK {}', { + queryDataset: { namedGraphUris: ['https://example.com/named'] }, + }) + expect(new URL(url).searchParams.getAll('named-graph-uri')).toEqual(['https://example.com/named']) + expect(body).toBe('ASK {}') +}) diff --git a/packages/sparql/package.json b/packages/sparql/package.json index a5bce01..cb07318 100644 --- a/packages/sparql/package.json +++ b/packages/sparql/package.json @@ -9,7 +9,8 @@ "exports": { ".": "./mod.ts", "./http": "./http/mod.ts", - "./syntax": "./syntax/mod.ts" + "./syntax": "./syntax/mod.ts", + "./graph-store": "./graph-store/mod.ts" }, "description": "SPARQL construction, syntax inspection, and query contracts for TypeScript.", "license": "MIT", diff --git a/packages/sparql/patterns/cypher.ts b/packages/sparql/patterns/cypher.ts index e6f8a30..6d4263d 100644 --- a/packages/sparql/patterns/cypher.ts +++ b/packages/sparql/patterns/cypher.ts @@ -8,22 +8,28 @@ */ import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode } from '@okikio/rdf' -import { rdfTerm, rawPattern, toPredicateName, type PatternValue, type SparqlTerm } from '../sparql.ts' +import { + type PatternValueType, + rawPattern, + rdfTerm, + type SparqlTermType, + toPredicateName, +} from '../sparql.ts' import { Node } from './objects.ts' /** Predicate values accepted inside a cypher relationship placeholder. */ -type CypherTermType = SparqlTerm | RdfNamedNode +type CypherTermType = SparqlTermType | RdfNamedNode /** * Builds graph patterns from `node-[predicate]->node` visual relationships. * - * Direction is semantic: `a-[p]->b` emits `a p b`, while `a<-[p]-b` + * DirectionType is semantic: `a-[p]->b` emits `a p b`, while `a<-[p]-b` * emits `b p a`. Interpolated RDF NamedNodes are preserved as full IRIs. */ export function cypher( strings: TemplateStringsArray, ...values: Array -): PatternValue { +): PatternValueType { let source = strings[0] ?? '' const nodes: Node[] = [] const terms = new Map() diff --git a/packages/sparql/patterns/objects.ts b/packages/sparql/patterns/objects.ts index 4841123..e8ade70 100644 --- a/packages/sparql/patterns/objects.ts +++ b/packages/sparql/patterns/objects.ts @@ -1,5 +1,5 @@ /** - * Graph pattern matching inspired by Cypher. + * GraphTermType pattern matching inspired by Cypher. * * SPARQL's verbose syntax makes queries hard to read, especially when you're describing * complex graph structures. These pattern helpers let you think in terms of nodes and @@ -16,32 +16,32 @@ * @module */ -import { RDF, isTerm as isRdfTerm, namedNode } from '@okikio/rdf' +import { isTerm as isRdfTerm, namedNode, RDF } from '@okikio/rdf' import { - toVarToken, - toPredicateName, - toRawString, - variable, + type PatternValueType, raw, - rdfTerm, - rawTerm, rawPattern, - SPARQL_VALUE_BRAND, + rawTerm, + rdfTerm, SPARQL_PATTERN_BRAND, - type SparqlValue, - type SparqlTerm, - type PatternValue, + SPARQL_VALUE_BRAND, + type SparqlTermType, + type SparqlValueType, + toPredicateName, + toRawString, + toVarToken, + variable, } from '../sparql.ts' import { exprTermString } from '../utils.ts' import { - triples, + type PredicateObjectListType, triple, - type TriplePredicate, - type TripleObject, - type PredicateObjectList, -} from "./triples.ts" + type TripleObjectType, + type TriplePredicateType, + triples, +} from './triples.ts' // ============================================================================ // Design Philosophy @@ -71,25 +71,28 @@ import { * Can be a simple triple object (literal, IRI, variable) or another Node for * nested structures. Arrays let you specify multiple values for one property. */ -export type PropertyAtomic = TripleObject | Node +export type PropertyAtomicType = TripleObjectType | Node /** One object-pattern property value or a repeated set of property values. */ -export type PropertyValue = PropertyAtomic | PropertyAtomic[] +export type PropertyValueType = PropertyAtomicType | PropertyAtomicType[] /** * Map of property names to values. */ -export interface NodePropertyMap { - [predicate: string]: PropertyValue +export interface NodePropertyMapType { + /** Additional keyed values accepted by this standards-compatible structural record. */ + [predicate: string]: PropertyValueType } /** Predicate-preserving property entry used internally by node and edge builders. */ interface PropertyEntryType { - readonly predicate: TriplePredicate - value: PropertyValue + /** RDF predicate IRI represented by this statement, pattern, or index entry. */ + readonly predicate: TriplePredicateType + /** Object value emitted for this predicate when the property pattern is serialized. */ + value: PropertyValueType } /** Creates a stable comparison key without changing the predicate's lexical form. */ -function predicateKey(predicate: TriplePredicate): string { +function predicateKey(predicate: TriplePredicateType): string { if (typeof predicate === 'string') return `string:${predicate}` if (isRdfTerm(predicate)) return `iri:${predicate.value}` return `term:${predicate.value}` @@ -206,29 +209,49 @@ function predicateKey(predicate: TriplePredicate): string { * ) * ``` */ -export class Node implements PatternValue { +export class Node implements PatternValueType { + /** Compile-time brand that prevents unrelated values from satisfying the SPARQL value contract structurally. */ readonly [SPARQL_VALUE_BRAND] = true as const + /** Compile-time brand that marks values that can be emitted as SPARQL graph patterns. */ readonly [SPARQL_PATTERN_BRAND] = true as const - readonly subjectTerm: SparqlTerm + /** Converts the supplied subject input into a validated SPARQL subject term. */ + readonly subjectTerm: SparqlTermType + /** Returns the variable name without its optional leading question mark. */ private readonly varName: string - private readonly typesTerm: TriplePredicate[] = [] + /** Builds the RDF type term or expression used by the object-pattern helpers. */ + private readonly typesTerm: TriplePredicateType[] = [] + /** Property records or property definitions owned by this model. */ private readonly properties: PropertyEntryType[] = [] // Fluent getters for natural chaining /** Fluent no-op alias that keeps natural-language Node chains on the same immutable pattern object. */ - get is(): this { return this } + get is(): this { + return this + } /** Fluent no-op alias used to continue Node property/type chains without changing semantics. */ - get with(): this { return this } + get with(): this { + return this + } /** Fluent no-op alias used to join consecutive Node clauses without allocating another wrapper. */ - get and(): this { return this } + get and(): this { + return this + } /** Fluent no-op alias used by natural-language Node chains before a following predicate operation. */ - get that(): this { return this } + get that(): this { + return this + } /** Fluent no-op alias used by natural-language Node chains before adding another property. */ - get has(): this { return this } + get has(): this { + return this + } /** Creates a variable-backed graph-pattern node and normalizes optional type/property seeds into predicate-preserving entries. */ - constructor(subject: string | SparqlTerm, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap) { + constructor( + subject: string | SparqlTermType, + type?: TriplePredicateType | TriplePredicateType[], + options?: NodePropertyMapType, + ) { const subjectString = toVarToken(subject) this.varName = subjectString @@ -250,7 +273,11 @@ export class Node implements PatternValue { } /** Creates a variable-backed node using the fluent object-pattern API. */ - static create(name: string, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap): Node { + static create( + name: string, + type?: TriplePredicateType | TriplePredicateType[], + options?: NodePropertyMapType, + ): Node { return new Node(name, type, options) } @@ -260,7 +287,7 @@ export class Node implements PatternValue { * Use this when you need to reference the node as an object in another triple. * For example, when connecting two nodes with a relationship. */ - term(): SparqlTerm { + term(): SparqlTermType { return this.subjectTerm } @@ -275,13 +302,13 @@ export class Node implements PatternValue { * node('person').a('foaf:Person').a('schema:Author') * ``` */ - a(typeIri: TriplePredicate): this { + a(typeIri: TriplePredicateType): this { this.typesTerm.push(typeIri) return this } /** Alias for {@link a} with more explicit naming. */ - type(typeIri: TriplePredicate): this { + type(typeIri: TriplePredicateType): this { this.a(typeIri) return this } @@ -294,9 +321,10 @@ export class Node implements PatternValue { * node('item').types(['schema:Product', 'schema:CreativeWork']) * ``` */ - types(typesIri: TriplePredicate[]): this { - for (const typeIri of typesIri) - this.a(typeIri); + types(typesIri: TriplePredicateType[]): this { + for (const typeIri of typesIri) { + this.a(typeIri) + } return this } @@ -325,7 +353,7 @@ export class Node implements PatternValue { * node('product').prop('schema:publisher', node('publisher', 'schema:Organization')) * ``` */ - prop(predicate: TriplePredicate, value: PropertyValue): this { + prop(predicate: TriplePredicateType, value: PropertyValueType): this { const key = predicateKey(predicate) const entry = this.properties.find((item) => predicateKey(item.predicate) === key) @@ -358,7 +386,7 @@ export class Node implements PatternValue { * }) * ``` */ - props(map: NodePropertyMap): this { + props(map: NodePropertyMapType): this { for (const [key, value] of Object.entries(map)) { this.prop(key, value) } @@ -376,7 +404,7 @@ export class Node implements PatternValue { if (visited.has(this)) return '' visited.add(this) - const pairs: PredicateObjectList = [] + const pairs: PredicateObjectListType = [] const nested: string[] = [] for (const type of this.typesTerm) { @@ -384,8 +412,8 @@ export class Node implements PatternValue { pairs.push([namedNode(RDF.type), object]) } - const push = (predicate: TriplePredicate, atomic: PropertyAtomic): void => { - let object: TripleObject + const push = (predicate: TriplePredicateType, atomic: PropertyAtomicType): void => { + let object: TripleObjectType if (atomic instanceof Node) { object = atomic.term() const value = atomic.buildPatternInternal(visited) @@ -406,11 +434,11 @@ export class Node implements PatternValue { } /** - * Get the full SPARQL pattern as a SparqlValue. + * Get the full SPARQL pattern as a SparqlValueType. * * Call this to get the complete pattern including all nested nodes. */ - pattern(): PatternValue { + pattern(): PatternValueType { const visited = new Set() const text = this.buildPatternInternal(visited) return rawPattern(text) @@ -419,8 +447,8 @@ export class Node implements PatternValue { /** * Get the SPARQL pattern string. * - * This implements SparqlValue.value, which means you can pass Node objects - * directly to query builder methods that expect SparqlValue. + * This implements SparqlValueType.value, which means you can pass Node objects + * directly to query builder methods that expect SparqlValueType. * * ⚠️ Warning: This returns the full pattern, not just the variable. If you * want to use this node as an object in a triple, call term() instead. @@ -445,8 +473,9 @@ export class Node implements PatternValue { * Like nodes, relationships can have properties too. This is called reification * in RDF - treating the edge itself as a resource with facts about it. */ -export interface RelationshipPropertyMap { - [predicate: string]: PropertyValue +export interface RelationshipPropertyMapType { + /** Additional keyed values accepted by this standards-compatible structural record. */ + [predicate: string]: PropertyValueType } /** @@ -561,29 +590,43 @@ export interface RelationshipPropertyMap { * .where(friend2Rel) * ``` */ -export class Relationship implements PatternValue { +export class Relationship implements PatternValueType { + /** Compile-time brand that prevents unrelated values from satisfying the SPARQL value contract structurally. */ readonly [SPARQL_VALUE_BRAND] = true as const + /** Compile-time brand that marks values that can be emitted as SPARQL graph patterns. */ readonly [SPARQL_PATTERN_BRAND] = true as const - private readonly fromTerm: SparqlTerm - private readonly toTerm: SparqlTerm - private readonly predicate: TriplePredicate + /** Converts an RDF/JS term into the corresponding SPARQL value wrapper. */ + private readonly fromTerm: SparqlTermType + /** Converts a SPARQL term wrapper back to an RDF/JS term when representable. */ + private readonly toTerm: SparqlTermType + /** RDF predicate IRI represented by this statement, pattern, or index entry. */ + private readonly predicate: TriplePredicateType + /** Property records or property definitions owned by this model. */ private readonly properties: PropertyEntryType[] = [] /** Fluent no-op alias used to continue relationship metadata chains on the same pattern. */ - get with(): this { return this } + get with(): this { + return this + } /** Fluent no-op alias used to join relationship metadata clauses without changing the RDF statement. */ - get and(): this { return this } + get and(): this { + return this + } /** Fluent no-op alias retained for natural-language relationship chaining. */ - get that(): this { return this } + get that(): this { + return this + } /** Fluent no-op alias used before attaching another reified relationship property. */ - get has(): this { return this } + get has(): this { + return this + } /** Captures caller endpoints as SPARQL terms and preserves the predicate term without flattening RDF IRIs to strings. */ constructor( - from: Node | string | SparqlTerm, - predicate: TriplePredicate, - to: Node | string | SparqlTerm, + from: Node | string | SparqlTermType, + predicate: TriplePredicateType, + to: Node | string | SparqlTermType, ) { if (from instanceof Node) { this.fromTerm = from.term() @@ -603,7 +646,7 @@ export class Relationship implements PatternValue { /** Creates a relationship between two variable-backed nodes using the supplied predicate term. */ static create( fromVar: string, - predicate: TriplePredicate, + predicate: TriplePredicateType, toVar: string, ): Relationship { return new Relationship(fromVar, predicate, toVar) @@ -621,7 +664,7 @@ export class Relationship implements PatternValue { * rel('person', 'knows', 'friend').prop('timestamp', dateTime(new Date())) * ``` */ - prop(predicate: TriplePredicate, value: PropertyValue): this { + prop(predicate: TriplePredicateType, value: PropertyValueType): this { const key = predicateKey(predicate) const entry = this.properties.find((item) => predicateKey(item.predicate) === key) if (!entry) { @@ -641,7 +684,7 @@ export class Relationship implements PatternValue { * Convenient when you have several properties to set. Just pass an object * where keys are predicates and values are objects. */ - props(map: RelationshipPropertyMap): this { + props(map: RelationshipPropertyMapType): this { for (const [key, value] of Object.entries(map)) { this.prop(key, value) } @@ -655,8 +698,12 @@ export class Relationship implements PatternValue { * blank node identifier. Same relationship always gets the same ID. */ private getEdgeId(): string { - const predicate = isRdfTerm(this.predicate) ? rdfTerm(this.predicate) : toPredicateName(toRawString(this.predicate)) - const hash = simpleHash(`${toVarToken(this.fromTerm)}|${predicate}|${exprTermString(this.toTerm)}`) + const predicate = isRdfTerm(this.predicate) + ? rdfTerm(this.predicate) + : toPredicateName(toRawString(this.predicate)) + const hash = simpleHash( + `${toVarToken(this.fromTerm)}|${predicate}|${exprTermString(this.toTerm)}`, + ) return `_:edge_${hash}` } @@ -666,7 +713,7 @@ export class Relationship implements PatternValue { * If there are no properties, just generates the basic triple. If there are * properties, generates the triple plus a reification structure. */ - private buildTriples(): SparqlValue { + private buildTriples(): SparqlValueType { const base = triple(this.fromTerm, this.predicate, this.toTerm) if (this.properties.length === 0) { @@ -675,16 +722,21 @@ export class Relationship implements PatternValue { // Reify with properties const edgeId = this.getEdgeId() - const poList: PredicateObjectList = [ + const poList: PredicateObjectListType = [ [namedNode(RDF.type), namedNode(RDF.statement)], [namedNode(RDF.subject), this.fromTerm], - [namedNode(RDF.predicate), typeof this.predicate === 'string' ? rawTerm(this.predicate) : this.predicate], + [ + namedNode(RDF.predicate), + typeof this.predicate === 'string' ? rawTerm(this.predicate) : this.predicate, + ], [namedNode(RDF.object), this.toTerm], ] for (const entry of this.properties) { const values = Array.isArray(entry.value) ? entry.value : [entry.value] for (const value of values) { - if (value instanceof Node) throw new TypeError('Relationship metadata cannot contain a nested Node value.') + if (value instanceof Node) { + throw new TypeError('Relationship metadata cannot contain a nested Node value.') + } poList.push([entry.predicate, value]) } } @@ -721,7 +773,11 @@ export class Relationship implements PatternValue { * .prop('foaf:age', v('age')) * ``` */ -export function node(name: string, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap): Node { +export function node( + name: string, + type?: TriplePredicateType | TriplePredicateType[], + options?: NodePropertyMapType, +): Node { return Node.create(name, type, options) } @@ -742,7 +798,7 @@ export function node(name: string, type?: TriplePredicate | TriplePredicate[], o */ export function rel( fromVar: string, - predicate: TriplePredicate, + predicate: TriplePredicateType, toVar: string, ): Relationship { return Relationship.create(fromVar, predicate, toVar) @@ -764,8 +820,8 @@ export function rel( * ``` */ export function match( - ...patterns: Array -): SparqlValue { + ...patterns: Array +): SparqlValueType { const built = patterns.map((p) => p.value) return raw(`${built.join('\n ')}`) } diff --git a/packages/sparql/patterns/objects_test.ts b/packages/sparql/patterns/objects_test.ts index 757c99d..2ae9e6f 100644 --- a/packages/sparql/patterns/objects_test.ts +++ b/packages/sparql/patterns/objects_test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { RDF } from '@okikio/rdf' -import { Product, name, offers } from '@okikio/vocab/schema' +import { name, offers, Product } from '@okikio/vocab/schema' import { node, rel, variable } from '../mod.ts' describe('@okikio/sparql object patterns', () => { diff --git a/packages/sparql/patterns/triples.ts b/packages/sparql/patterns/triples.ts index 70f520d..ab54c62 100644 --- a/packages/sparql/patterns/triples.ts +++ b/packages/sparql/patterns/triples.ts @@ -12,9 +12,16 @@ */ import { isTerm as isRdfTerm, type Term as RdfTerm } from '@okikio/rdf' -import type { PatternValue, PredicateInput, SparqlTerm } from '../sparql.ts' -import { rawPattern, rawTerm, rdfTerm, toPredicateName, toPredicateToken, toVarToken } from '../sparql.ts' -import { termString, type ExpressionPrimitive } from '../utils.ts' +import type { PatternValueType, PredicateInputType, SparqlTermType } from '../sparql.ts' +import { + rawPattern, + rawTerm, + rdfTerm, + toPredicateName, + toPredicateToken, + toVarToken, +} from '../sparql.ts' +import { type ExpressionPrimitiveType, termString } from '../utils.ts' // ============================================================================ // Triple Component Types @@ -26,7 +33,7 @@ import { termString, type ExpressionPrimitive } from '../utils.ts' * Can be a variable (?person), an IRI (), or a blank node. * Most often you'll use variables to match multiple resources. */ -export type TripleSubject = string | SparqlTerm | RdfTerm +export type TripleSubjectType = string | SparqlTermType | RdfTerm /** * Predicate of a triple pattern. @@ -34,7 +41,7 @@ export type TripleSubject = string | SparqlTerm | RdfTerm * Can be a prefixed name (foaf:name), full IRI, or variable. Predicates * describe relationships or properties. */ -export type TriplePredicate = PredicateInput +export type TriplePredicateType = PredicateInputType /** * Values that are allowed in the object position of a triple, per SPARQL. @@ -44,19 +51,18 @@ export type TriplePredicate = PredicateInput * - an IRI or prefixed name * - a literal * - a blank node - * */ -export type TripleObject = - | SparqlTerm +export type TripleObjectType = + | SparqlTermType | RdfTerm - | ExpressionPrimitive + | ExpressionPrimitiveType /** * Convert subject to string form. * - * Handles both raw strings and SparqlValue objects. + * Handles both raw strings and SparqlValueType objects. */ -export function tripleSubjectString(subject: TripleSubject): string { +export function tripleSubjectString(subject: TripleSubjectType): string { if (typeof subject === 'string') { const value = subject.trim() if (/^[?$]/.test(value) || !value.includes(':')) return toVarToken(value) @@ -69,17 +75,16 @@ export function tripleSubjectString(subject: TripleSubject): string { /** * Convert predicate to string form. */ -export function tripleObjectString(object: TripleObject): string { +export function tripleObjectString(object: TripleObjectType): string { if (isRdfTerm(object)) return rdfTerm(object) if (typeof object === 'string' && /^[?$][A-Za-z_][A-Za-z0-9_]*$/.test(object.trim())) { return toVarToken(object) } - return termString(object as SparqlTerm | ExpressionPrimitive, 'object') + return termString(object as SparqlTermType | ExpressionPrimitiveType, 'object') } - /** Converts a predicate input without flattening RDF named nodes to strings. */ -function predicateString(predicate: TriplePredicate): string { +function predicateString(predicate: TriplePredicateType): string { return toPredicateToken(predicate) } @@ -116,10 +121,10 @@ function predicateString(predicate: TriplePredicate): string { * ``` */ export function triple( - subject: TripleSubject, - predicate: TriplePredicate, - object: TripleObject, -): PatternValue { + subject: TripleSubjectType, + predicate: TriplePredicateType, + object: TripleObjectType, +): PatternValueType { const s = tripleSubjectString(subject) const p = predicateString(predicate) const o = tripleObjectString(object) @@ -128,7 +133,7 @@ export function triple( } // ============================================================================ -// Multiple Triples with Shared Subject +// Multiple triples with a shared subject // ============================================================================ /** @@ -137,7 +142,7 @@ export function triple( * Each entry is [predicate, object]. Use this when you want explicit control * over the order of properties. */ -export type PredicateObjectList = Array<[TriplePredicate, TripleObject]> +export type PredicateObjectListType = Array<[TriplePredicateType, TripleObjectType]> /** * Object format for predicate-object pairs. @@ -145,9 +150,9 @@ export type PredicateObjectList = Array<[TriplePredicate, TripleObject]> * Keys are predicates, values are objects. Values can be single items or arrays * for properties with multiple values. */ -export type PredicateObjectMap = Record< +export type PredicateObjectMapType = Record< string, - TripleObject | TripleObject[] + TripleObjectType | TripleObjectType[] > /** @@ -191,25 +196,25 @@ export type PredicateObjectMap = Record< * same predicate (one for each value). */ export function triples( - subject: TripleSubject, - predicateObjects: PredicateObjectList | PredicateObjectMap, -): PatternValue { + subject: TripleSubjectType, + predicateObjects: PredicateObjectListType | PredicateObjectMapType, +): PatternValueType { const subjectTerm = tripleSubjectString(subject) // 4 spaces; 2 (block) + 2 (extra) - const CONTINUATION_INDENT = ' '; + const CONTINUATION_INDENT = ' ' // Normalize to list format - const list: PredicateObjectList = Array.isArray(predicateObjects) + const list: PredicateObjectListType = Array.isArray(predicateObjects) ? predicateObjects : Object.entries(predicateObjects).flatMap(([pred, value]) => { - if (Array.isArray(value)) { - // Multiple values for same predicate → multiple pairs - return value.map( - (v): [TriplePredicate, TripleObject] => [pred, v], - ) - } - return [[pred, value]] + if (Array.isArray(value)) { + // Multiple values for same predicate → multiple pairs + return value.map( + (v): [TriplePredicateType, TripleObjectType] => [pred, v], + ) + } + return [[pred, value]] }) // Build semicolon-separated list @@ -225,7 +230,9 @@ export function triples( }) const [first, ...rest] = lines - if (first === undefined) throw new TypeError('triples() requires at least one predicate-object pair.') + if (first === undefined) { + throw new TypeError('triples() requires at least one predicate-object pair.') + } if (rest.length === 0) { // Single predicate-object: everything on a single line // `first` currently has leading spaces; strip them on the left. @@ -283,10 +290,10 @@ export function triples( * ``` */ export function tripleTerm( - subject: TripleSubject, - predicate: TriplePredicate, - object: TripleObject, -): SparqlTerm { + subject: TripleSubjectType, + predicate: TriplePredicateType, + object: TripleObjectType, +): SparqlTermType { const s = tripleSubjectString(subject) const p = predicateString(predicate) const o = tripleObjectString(object) diff --git a/packages/sparql/patterns/triples_test.ts b/packages/sparql/patterns/triples_test.ts index f482738..a4b21e4 100644 --- a/packages/sparql/patterns/triples_test.ts +++ b/packages/sparql/patterns/triples_test.ts @@ -9,7 +9,9 @@ describe('@okikio/sparql triple patterns', () => { }) it('accepts RDF named nodes in every RDF IRI-bearing position', () => { - expect(triple(namedNode('urn:s'), namedNode('urn:p'), namedNode('urn:o')).value).toBe(' .') + expect(triple(namedNode('urn:s'), namedNode('urn:p'), namedNode('urn:o')).value).toBe( + ' .', + ) }) it('rejects empty grouped predicate-object lists', () => { diff --git a/packages/sparql/result/binding_test.ts b/packages/sparql/result/binding_test.ts index ffc1e9a..996bff8 100644 --- a/packages/sparql/result/binding_test.ts +++ b/packages/sparql/result/binding_test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { literal } from '@okikio/rdf' -import { mapBindings, type BindingType } from './binding.ts' +import { type BindingType, mapBindings } from './binding.ts' /** Yields two immutable-by-contract binding rows. */ async function* rows(): AsyncGenerator { @@ -12,7 +12,9 @@ async function* rows(): AsyncGenerator { describe('@okikio/sparql binding mapping', () => { it('maps an async binding stream without coercing the source RDF terms', async () => { const values: string[] = [] - for await (const value of mapBindings(rows(), (row) => row.get('name')?.value ?? '')) values.push(value) + for await (const value of mapBindings(rows(), (row) => row.get('name')?.value ?? '')) { + values.push(value) + } expect(values).toEqual(['A', 'B']) }) }) diff --git a/packages/sparql/result/json.ts b/packages/sparql/result/json.ts index 2c0f2b5..1b4a077 100644 --- a/packages/sparql/result/json.ts +++ b/packages/sparql/result/json.ts @@ -1,48 +1,90 @@ /** SPARQL 1.1/1.2 Query Results JSON decoding. @module */ -import { blankNode, literal, namedNode, quad, type ObjectTerm, type Predicate, type Subject, type TermType } from '@okikio/rdf' +import { + blankNode, + literal, + namedNode, + type ObjectTermType, + type PredicateTermType, + quad, + type SubjectTermType, + type TermType, +} from '@okikio/rdf' import type { BindingType } from './binding.ts' /** Raw SPARQL JSON term, including SPARQL 1.2 triple terms and text direction. */ export type JsonTermType = - | { readonly type: 'uri'; readonly value: string } - | { readonly type: 'bnode'; readonly value: string } | { - readonly type: 'literal' - readonly value: string - readonly datatype?: string - readonly 'xml:lang'?: string - readonly 'its:dir'?: 'ltr' | 'rtl' - } + /** SPARQL Results JSON term discriminator. */ + readonly type: 'uri' + /** Lexical RDF term value supplied by SPARQL Results JSON. */ + readonly value: string + } | { - readonly type: 'triple' - readonly value: { - readonly subject: JsonTermType - readonly predicate: JsonTermType - readonly object: JsonTermType - } + /** SPARQL Results JSON term discriminator. */ + readonly type: 'bnode' + /** Lexical RDF term value supplied by SPARQL Results JSON. */ + readonly value: string + } + | { + /** SPARQL Results JSON term discriminator. */ + readonly type: 'literal' + /** Lexical RDF term value supplied by SPARQL Results JSON. */ + readonly value: string + /** Datatype IRI associated with this RDF literal value. */ + readonly datatype?: string + /** BCP 47 language tag supplied by SPARQL Results JSON. */ + readonly 'xml:lang'?: string + /** RDF 1.2 base text direction supplied by SPARQL Results JSON. */ + readonly 'its:dir'?: 'ltr' | 'rtl' + } + | { + /** SPARQL Results JSON term discriminator. */ + readonly type: 'triple' + /** Lexical RDF term value supplied by SPARQL Results JSON. */ + readonly value: { + /** RDF subject term represented by this statement or operation filter. */ + readonly subject: JsonTermType + /** RDF predicate IRI represented by this statement or operation filter. */ + readonly predicate: JsonTermType + /** RDF object term represented by this statement or operation filter. */ + readonly object: JsonTermType } + } /** Raw SELECT results object. */ export interface JsonBindingsType { + /** Requests Graph Store metadata without downloading a graph response body. */ readonly head: { + /** Ordered SELECT variable names declared by the SPARQL Results JSON header. */ readonly vars: readonly string[] + /** Version marker retained by this syntax record. */ readonly version?: string + /** Hypermedia links reported by the SPARQL Results JSON header. */ readonly link?: readonly string[] } + /** SPARQL JSON bindings result rows. */ readonly results: { + /** Raw SPARQL Results JSON binding rows before RDF term decoding. */ readonly bindings: readonly Readonly>[] } } /** Raw ASK results object. */ export interface JsonBooleanType { - readonly head?: { readonly version?: string; readonly link?: readonly string[] } + /** Requests Graph Store metadata without downloading a graph response body. */ + readonly head?: { + /** Version marker retained by this syntax record. */ + readonly version?: string + /** Hypermedia links reported by the SPARQL Results JSON header. */ + readonly link?: readonly string[] + } + /** SPARQL ASK boolean result. */ readonly boolean: boolean } /** Decodes one SPARQL JSON RDF term without JavaScript datatype coercion. */ -export function readTerm(value: JsonTermType): TermType { +export function decodeTerm(value: JsonTermType): TermType { switch (value.type) { case 'uri': return namedNode(value.value) @@ -55,33 +97,37 @@ export function readTerm(value: JsonTermType): TermType { return literal(value.value, value.datatype ? namedNode(value.datatype) : undefined) } case 'triple': { - const subject = readTerm(value.value.subject) - const predicate = readTerm(value.value.predicate) - const object = readTerm(value.value.object) + const subject = decodeTerm(value.value.subject) + const predicate = decodeTerm(value.value.predicate) + const object = decodeTerm(value.value.object) if (subject.termType !== 'NamedNode' && subject.termType !== 'BlankNode') { throw new TypeError(`SPARQL JSON triple subject cannot be ${subject.termType}.`) } if (predicate.termType !== 'NamedNode') { throw new TypeError(`SPARQL JSON triple predicate cannot be ${predicate.termType}.`) } - if (!isObjectTerm(object)) throw new TypeError(`SPARQL JSON triple object cannot be ${object.termType}.`) - return quad(subject as Subject, predicate as Predicate, object) + if (!isObjectTerm(object)) { + throw new TypeError(`SPARQL JSON triple object cannot be ${object.termType}.`) + } + return quad(subject as SubjectTermType, predicate as PredicateTermType, object) } } } /** Decodes a complete SELECT result into immutable-by-contract Maps. */ -export function readBindings(value: unknown): readonly BindingType[] { - if (!isBindingsResult(value)) throw new TypeError('Response is not a SPARQL bindings JSON result.') +export function decodeBindings(value: unknown): readonly BindingType[] { + if (!isBindingsResult(value)) { + throw new TypeError('Response is not a SPARQL bindings JSON result.') + } return value.results.bindings.map((row) => { const binding = new Map() - for (const [name, term] of Object.entries(row)) binding.set(name, readTerm(term)) + for (const [name, term] of Object.entries(row)) binding.set(name, decodeTerm(term)) return binding }) } /** Decodes an ASK result. */ -export function readBoolean(value: unknown): boolean { +export function decodeBoolean(value: unknown): boolean { if (!isBooleanResult(value)) throw new TypeError('Response is not a SPARQL boolean JSON result.') return value.boolean } @@ -98,10 +144,12 @@ function isBindingsResult(value: unknown): value is JsonBindingsType { /** Returns whether the supplied value satisfies the boolean result contract. */ function isBooleanResult(value: unknown): value is JsonBooleanType { - return typeof value === 'object' && value !== null && typeof (value as Record).boolean === 'boolean' + return typeof value === 'object' && value !== null && + typeof (value as Record).boolean === 'boolean' } /** Returns whether the supplied value satisfies the object term contract. */ -function isObjectTerm(term: TermType): term is ObjectTerm { - return term.termType === 'NamedNode' || term.termType === 'BlankNode' || term.termType === 'Literal' || term.termType === 'Quad' +function isObjectTerm(term: TermType): term is ObjectTermType { + return term.termType === 'NamedNode' || term.termType === 'BlankNode' || + term.termType === 'Literal' || term.termType === 'Quad' } diff --git a/packages/sparql/result/json_test.ts b/packages/sparql/result/json_test.ts index 0f097a1..1bc86fd 100644 --- a/packages/sparql/result/json_test.ts +++ b/packages/sparql/result/json_test.ts @@ -1,11 +1,16 @@ import { describe, it } from 'node:test' import { expect } from '@std/expect' import { RDF } from '@okikio/rdf' -import { readBindings, readBoolean, readTerm } from './json.ts' +import { decodeBindings, decodeBoolean, decodeTerm } from './json.ts' describe('@okikio/sparql JSON results', () => { it('preserves RDF literal datatype, language, and RDF 1.2 direction', () => { - const value = readTerm({ type: 'literal', value: 'bonjour', 'xml:lang': 'fr', 'its:dir': 'ltr' }) + const value = decodeTerm({ + type: 'literal', + value: 'bonjour', + 'xml:lang': 'fr', + 'its:dir': 'ltr', + }) expect(value.termType).toBe('Literal') if (value.termType === 'Literal') { expect(value.direction).toBe('ltr') @@ -14,7 +19,7 @@ describe('@okikio/sparql JSON results', () => { }) it('decodes SPARQL 1.2 triple terms recursively', () => { - const value = readTerm({ + const value = decodeTerm({ type: 'triple', value: { subject: { type: 'uri', value: 'urn:s' }, @@ -26,24 +31,34 @@ describe('@okikio/sparql JSON results', () => { }) it('rejects illegal triple predicates instead of coercing them', () => { - expect(() => readTerm({ - type: 'triple', - value: { - subject: { type: 'uri', value: 'urn:s' }, - predicate: { type: 'literal', value: 'not-an-iri' }, - object: { type: 'literal', value: 'o' }, - }, - })).toThrow('predicate') + expect(() => + decodeTerm({ + type: 'triple', + value: { + subject: { type: 'uri', value: 'urn:s' }, + predicate: { type: 'literal', value: 'not-an-iri' }, + object: { type: 'literal', value: 'o' }, + }, + }) + ).toThrow('predicate') }) it('decodes SELECT and ASK result modes without JavaScript datatype coercion', () => { - const rows = readBindings({ + const rows = decodeBindings({ head: { vars: ['price'] }, - results: { bindings: [{ price: { type: 'literal', value: '12.50', datatype: 'http://www.w3.org/2001/XMLSchema#decimal' } }] }, + results: { + bindings: [{ + price: { + type: 'literal', + value: '12.50', + datatype: 'http://www.w3.org/2001/XMLSchema#decimal', + }, + }], + }, }) expect(rows).toHaveLength(1) expect(rows[0]?.get('price')?.termType).toBe('Literal') - expect(readBoolean({ boolean: true })).toBe(true) - expect(() => readBoolean({ boolean: 'true' })).toThrow() + expect(decodeBoolean({ boolean: true })).toBe(true) + expect(() => decodeBoolean({ boolean: 'true' })).toThrow() }) }) diff --git a/packages/sparql/sparql.ts b/packages/sparql/sparql.ts index e5081c4..5578abd 100644 --- a/packages/sparql/sparql.ts +++ b/packages/sparql/sparql.ts @@ -38,7 +38,14 @@ * @module */ -import { XSD, isTerm as isRdfTerm, type Literal as RdfLiteral, type NamedNode as RdfNamedNode, type Quad as RdfQuad, type Term as RdfTerm } from '@okikio/rdf' +import { + isTerm as isRdfTerm, + type Literal as RdfLiteral, + type NamedNode as RdfNamedNode, + type Quad as RdfQuad, + type Term as RdfTerm, + XSD, +} from '@okikio/rdf' // ============================================================================ // Core Types @@ -58,16 +65,22 @@ export const SPARQL_QUERY_BRAND = Symbol('SparqlQueryBrand') export const SPARQL_UPDATE_BRAND = Symbol('SparqlUpdateBrand') /** One already-serialized SPARQL term. */ -export interface SparqlTerm { +export interface SparqlTermType { + /** Compile-time brand that prevents unrelated values from satisfying the SPARQL value contract structurally. */ readonly [SPARQL_VALUE_BRAND]: true + /** Compile-time brand that marks values that serialize as SPARQL terms. */ readonly [SPARQL_TERM_BRAND]: true + /** Serialized SPARQL term fragment safe to embed where the type permits a term. */ readonly value: string } /** One already-serialized SPARQL expression. */ -export interface SparqlExpr { +export interface SparqlExprType { + /** Compile-time brand that prevents unrelated values from satisfying the SPARQL value contract structurally. */ readonly [SPARQL_VALUE_BRAND]: true + /** Compile-time brand that marks values that serialize as SPARQL expressions. */ readonly [SPARQL_EXPR_BRAND]: true + /** Serialized SPARQL expression fragment safe to embed where the type permits an expression. */ readonly value: string } @@ -75,35 +88,42 @@ export interface SparqlExpr { * A graph pattern snippet – e.g. a block of triples to drop into WHERE {}. * Node, Relationship, and other pattern builders should be this. */ -export interface PatternValue { +export interface PatternValueType { + /** Compile-time brand that prevents unrelated values from satisfying the SPARQL value contract structurally. */ readonly [SPARQL_VALUE_BRAND]: true + /** Compile-time brand that marks values that can be emitted as SPARQL graph patterns. */ readonly [SPARQL_PATTERN_BRAND]: true + /** Serialized SPARQL graph-pattern fragment. */ readonly value: string } /** A complete SPARQL query document, not an embeddable expression or pattern. */ -export interface SparqlQuery { +export interface SparqlQueryType { + /** Compile-time brand that marks values that serialize as complete SPARQL queries. */ readonly [SPARQL_QUERY_BRAND]: true + /** Complete serialized SPARQL query text. */ readonly value: string } /** A complete SPARQL Update document, not an embeddable expression or pattern. */ -export interface SparqlUpdate { +export interface SparqlUpdateType { + /** Compile-time brand that marks values that serialize as complete SPARQL updates. */ readonly [SPARQL_UPDATE_BRAND]: true + /** Complete serialized SPARQL Update text. */ readonly value: string } /** Complete SPARQL documents accepted by engines and protocol clients. */ -export type SparqlDocument = SparqlQuery | SparqlUpdate +export type SparqlDocumentType = SparqlQueryType | SparqlUpdateType /** Any library-owned SPARQL syntax fragment accepted by shared helpers. */ -export type SparqlValue = SparqlTerm | SparqlExpr | PatternValue +export type SparqlValueType = SparqlTermType | SparqlExprType | PatternValueType /** IRI-bearing input accepted by SPARQL grammar positions that require an IRI. */ -export type IriInput = string | SparqlTerm | RdfNamedNode +export type IriInputType = string | SparqlTermType | RdfNamedNode /** Predicate syntax accepted by triple and property-path constructors. */ -export type PredicateInput = string | SparqlTerm | RdfNamedNode +export type PredicateInputType = string | SparqlTermType | RdfNamedNode /** * Values that can be safely interpolated into the `sparql` tag *as a single @@ -113,8 +133,8 @@ export type PredicateInput = string | SparqlTerm | RdfNamedNode * - `valuesList(...)`, `exprList(...)`, `rdfList(...)` * - `bnodePattern(...)` */ -export type SparqlInterpolatable = - | SparqlValue +export type SparqlInterpolatableType = + | SparqlValueType | string | number | boolean @@ -129,60 +149,60 @@ export type SparqlInterpolatable = * In SPARQL, variables can be written as ?name or $name. We normalize these * internally to just store the name part, then add the ? when generating queries. */ -export type VariableName = string | `?${string}` | SparqlTerm +export type VariableNameType = string | `?${string}` | SparqlTermType /** * Namespace prefix for abbreviated IRIs (e.g., "foaf" in foaf:name). */ -export type PrefixName = string +export type PrefixNameType = string /** * Full IRI for a datatype (e.g., http://www.w3.org/2001/XMLSchema#integer). */ -export type DatatypeIRI = string | RdfNamedNode +export type DatatypeIriType = string | RdfNamedNode /** * Language tag for multilingual literals (e.g., "en", "fr", "ja-JP"). */ -export type LanguageTag = string +export type LanguageTagType = string // ============================================================================ // Internal Helpers // ============================================================================ /** - * Type guard for `SparqlValue`. + * Type guard for `SparqlValueType`. */ -export function isSparqlValue(value: unknown): value is SparqlValue { +export function isSparqlValue(value: unknown): value is SparqlValueType { return ( typeof value === 'object' && value !== null && - (value as SparqlValue)[SPARQL_VALUE_BRAND] === true + (value as SparqlValueType)[SPARQL_VALUE_BRAND] === true ) } /** Returns whether a library SPARQL value is a term fragment. */ -export function isSparqlTerm(v: SparqlValue): v is SparqlTerm { - return (v as SparqlTerm)[SPARQL_TERM_BRAND] === true +export function isSparqlTerm(v: SparqlValueType): v is SparqlTermType { + return (v as SparqlTermType)[SPARQL_TERM_BRAND] === true } /** Returns whether a library SPARQL value is an expression fragment. */ -export function isSparqlExpr(v: SparqlValue): v is SparqlExpr { - return (v as SparqlExpr)[SPARQL_EXPR_BRAND] === true +export function isSparqlExpr(v: SparqlValueType): v is SparqlExprType { + return (v as SparqlExprType)[SPARQL_EXPR_BRAND] === true } /** Returns whether a library SPARQL value is a graph-pattern fragment. */ -export function isPatternValue(v: SparqlValue): v is PatternValue { - return (v as PatternValue)[SPARQL_PATTERN_BRAND] === true +export function isPatternValue(v: SparqlValueType): v is PatternValueType { + return (v as PatternValueType)[SPARQL_PATTERN_BRAND] === true } /** - * Extract the raw string from a SparqlValue or return the string as-is. + * Extract the raw string from a SparqlValueType or return the string as-is. * * Use this when you need the underlying string value without any conversion. * This is for SYNTAX elements that should pass through unchanged. */ -export function toRawString(value: string | SparqlValue): string { +export function toRawString(value: string | SparqlValueType): string { return isSparqlValue(value) ? value.value : value } @@ -209,11 +229,11 @@ export function isVariableToken(value: string): boolean { * - `"name"` * - `"?name"` * - `"$name"` - * - `SparqlValue` that wraps a variable token + * - `SparqlValueType` that wraps a variable token * * Enforces your existing variable naming rules via validateVariableName(). */ -export function toVarToken(name: VariableName): string { +export function toVarToken(name: VariableNameType): string { const normalized = normalizeVariableName(name) validateVariableName(normalized) return `?${normalized}` @@ -269,7 +289,7 @@ export function toIriLikeToken(value: string | RdfNamedNode): string { } /** Serializes a predicate/path atom without flattening RDF named nodes to strings. */ -export function toPredicateToken(input: PredicateInput): string { +export function toPredicateToken(input: PredicateInputType): string { if (isRdfTerm(input)) return rdfTerm(input) if (isSparqlValue(input)) return input.value if (isVariableToken(input.trim())) return toVarToken(input) @@ -286,14 +306,14 @@ export function toPredicateToken(input: PredicateInput): string { * SERVICE VarOrIriRef { ... } (depending on implementation) * * Semantics: - * - If you pass a SparqlValue, we assume it's already a correct token and + * - If you pass a SparqlValueType, we assume it's already a correct token and * just return `.value`. * - If you pass a string: * - `?name` / `$name` → normalised to `?name` * - `name` with no colon → treated as variable name → `?name` * - anything else → treated as IRI/prefixed name via toIriLikeToken() */ -export function toVarOrIriRef(input: string | SparqlTerm | RdfNamedNode): string { +export function toVarOrIriRef(input: string | SparqlTermType | RdfNamedNode): string { if (isRdfTerm(input)) return rdfTerm(input) if (isSparqlValue(input)) { const token = input.value.trim() @@ -330,7 +350,7 @@ export function toVarOrIriRef(input: string | SparqlTerm | RdfNamedNode): string * - Reject obvious variable tokens (`?name` / `$name`) * - Normalise to `` or `prefix:local` */ -export function toGraphRef(input: IriInput): string { +export function toGraphRef(input: IriInputType): string { if (isRdfTerm(input)) return rdfTerm(input) const token = isSparqlValue(input) ? input.value.trim() : input.trim() @@ -356,19 +376,19 @@ export function toGraphRef(input: IriInput): string { * - Accepts 'default' | 'named' | 'all' in any case and normalises them. * - Otherwise, falls back to a strict GraphRef (IRI) via toGraphRef(). */ -export type GraphRefAllKeyword = 'DEFAULT' | 'NAMED' | 'ALL' +export type GraphRefAllKeywordType = 'DEFAULT' | 'NAMED' | 'ALL' /** Graph IRI or the DEFAULT graph accepted by COPY, MOVE, and ADD. */ -export type GraphOrDefaultInput = IriInput | 'DEFAULT' | 'default' +export type GraphOrDefaultInputType = IriInputType | 'DEFAULT' | 'default' /** Normalizes the SPARQL Update `GraphOrDefault` production. */ -export function toGraphOrDefault(input: GraphOrDefaultInput): string { +export function toGraphOrDefault(input: GraphOrDefaultInputType): string { if (typeof input === 'string' && input.trim().toUpperCase() === 'DEFAULT') return 'DEFAULT' - return toGraphRef(input as IriInput) + return toGraphRef(input as IriInputType) } /** Normalizes a SPARQL Update graph reference or DEFAULT/NAMED/ALL keyword. */ -export function toGraphRefAll(input: IriInput): string { +export function toGraphRefAll(input: IriInputType): string { if (typeof input === 'string') { const upper = input.trim().toUpperCase() if (upper === 'DEFAULT' || upper === 'NAMED' || upper === 'ALL') return upper @@ -382,28 +402,33 @@ export function toGraphRefAll(input: IriInput): string { * This is useful when you need to *inspect* what you got back from user * input or higher-level code, rather than just drop it into the query string. */ -export type ParsedVarOrIriRef = +export type ParsedVarOrIriRefType = | { - kind: 'var' - /** Name without the leading '?' */ - name: string - /** Canonical variable token (`?name`) */ - token: string - } + /** Selects the `var` variant of ParsedVarOrIriRefType. */ + kind: 'var' + /** Name without the leading '?' */ + name: string + /** Canonical variable token (`?name`) */ + token: string + } | { - kind: 'iri' - /** The IRI *without* angle brackets */ - iri: string - /** Lexical token, usually `` */ - token: string - } + /** Selects the `iri` variant of ParsedVarOrIriRefType. */ + kind: 'iri' + /** The IRI *without* angle brackets */ + iri: string + /** Lexical token, usually `` */ + token: string + } | { - kind: 'prefixed' - prefix: string - local: string - /** Lexical token like `prefix:local` */ - token: string - } + /** Selects the `prefixed` variant of ParsedVarOrIriRefType. */ + kind: 'prefixed' + /** Prefix label associated with this syntax or RDF name. */ + prefix: string + /** Local-name component of this parsed prefixed SPARQL name. */ + local: string + /** Lexical token like `prefix:local` */ + token: string + } /** * Parse a VarOrIriRef into a structured representation. @@ -412,8 +437,8 @@ export type ParsedVarOrIriRef = * the same semantics as the rest of the builder. */ export function parseVarOrIriRef( - input: string | SparqlTerm | RdfNamedNode, -): ParsedVarOrIriRef { + input: string | SparqlTermType | RdfNamedNode, +): ParsedVarOrIriRefType { const token = toVarOrIriRef(input) if (isVariableToken(token)) { @@ -453,7 +478,7 @@ export function parseVarOrIriRef( } /** Wraps trusted syntax as one raw SPARQL term without escaping it. */ -export function rawTerm(value: string): SparqlTerm { +export function rawTerm(value: string): SparqlTermType { return { [SPARQL_VALUE_BRAND]: true, [SPARQL_TERM_BRAND]: true, @@ -462,7 +487,7 @@ export function rawTerm(value: string): SparqlTerm { } /** Wraps trusted syntax as one raw SPARQL expression without escaping it. */ -export function rawExpr(value: string): SparqlExpr { +export function rawExpr(value: string): SparqlExprType { return { [SPARQL_VALUE_BRAND]: true, [SPARQL_EXPR_BRAND]: true, @@ -471,7 +496,7 @@ export function rawExpr(value: string): SparqlExpr { } /** Wraps trusted syntax as a raw graph-pattern fragment without escaping it. */ -export function rawPattern(text: string): PatternValue { +export function rawPattern(text: string): PatternValueType { return { [SPARQL_VALUE_BRAND]: true, [SPARQL_PATTERN_BRAND]: true, @@ -480,17 +505,17 @@ export function rawPattern(text: string): PatternValue { } /** Wraps already-serialized text as one complete SPARQL query document. */ -export function queryDocument(value: string): SparqlQuery { +export function queryDocument(value: string): SparqlQueryType { return { [SPARQL_QUERY_BRAND]: true, value } as const } /** Wraps already-serialized text as one complete SPARQL Update document. */ -export function updateDocument(value: string): SparqlUpdate { +export function updateDocument(value: string): SparqlUpdateType { return { [SPARQL_UPDATE_BRAND]: true, value } as const } /** - * Wrap a raw SPARQL snippet as a `SparqlValue`. + * Wrap a raw SPARQL snippet as a `SparqlValueType`. * * Use this when you *know* the string is already valid SPARQL syntax and you * do not want any further escaping or conversion. @@ -509,7 +534,7 @@ export function updateDocument(value: string): SparqlUpdate { * raw('BNODE()') // Built-in function * raw('ex:customFunc(?x, ?y)') // Custom function */ -export function raw(value: string): SparqlExpr { +export function raw(value: string): SparqlExprType { return rawExpr(value) } @@ -669,11 +694,16 @@ export function escapeString( // Control characters with explicit SPARQL-style escapes switch (ch) { - case '\n': return '\\n' - case '\r': return '\\r' - case '\t': return '\\t' - case '\b': return '\\b' - case '\f': return '\\f' + case '\n': + return '\\n' + case '\r': + return '\\r' + case '\t': + return '\\t' + case '\b': + return '\\b' + case '\f': + return '\\f' default: { // Any remaining control char U+0000–U+001F gets a \u00XX escape const code = ch.charCodeAt(0) @@ -689,7 +719,7 @@ export function escapeString( */ export function needsLongQuotes(str: string): boolean { return str.includes('\n') || str.includes('\r') || - str.includes('"') || str.includes("'") + str.includes('"') || str.includes("'") } // ============================================================================ @@ -810,8 +840,8 @@ export function validateLanguageTag(tag: string): void { * internal representation. This function strips the prefix if present, so both * "foo" and "?foo" become "foo" internally. */ -export function normalizeVariableName(name: VariableName): string { - const n = isSparqlValue(name) ? name?.value : name; +export function normalizeVariableName(name: VariableNameType): string { + const n = isSparqlValue(name) ? name?.value : name // Strip ? or $ prefix if present if (n.startsWith('?') || n.startsWith('$')) { return n.slice(1) @@ -833,7 +863,7 @@ export function normalizeVariableName(name: VariableName): string { * variable('name') // → ?name * variable('?name') // → ?name */ -export function variable(name: VariableName): SparqlTerm { +export function variable(name: VariableNameType): SparqlTermType { const n = normalizeVariableName(name) validateVariableName(n) return rawTerm(`?${n}`) @@ -848,9 +878,8 @@ export function variable(name: VariableName): SparqlTerm { * @example * uri('http://example.org/resource') // → * uri('urn:isbn:0451450523') // → - */ -export function uri(iri: string | RdfNamedNode): SparqlTerm { +export function uri(iri: string | RdfNamedNode): SparqlTermType { if (typeof iri !== 'string') return rawTerm(rdfTerm(iri)) validateIRI(iri) return rawTerm(`<${iri}>`) @@ -864,7 +893,7 @@ export function uri(iri: string | RdfNamedNode): SparqlTerm { * @example * prefixed('foaf', 'name') // → foaf:name */ -export function prefixed(prefix: PrefixName, localName: string): SparqlTerm { +export function prefixed(prefix: PrefixNameType, localName: string): SparqlTermType { validatePrefixName(prefix) // Local names have complex rules; block obvious injection if (INJECTION_CHARS.test(localName)) { @@ -876,7 +905,7 @@ export function prefixed(prefix: PrefixName, localName: string): SparqlTerm { /** * Alias for {@link prefixed} with more explicit naming. */ -export function prefix(namespace: PrefixName, local: string): SparqlTerm { +export function prefix(namespace: PrefixNameType, local: string): SparqlTermType { return prefixed(namespace, local) } @@ -893,7 +922,7 @@ export function prefix(namespace: PrefixName, local: string): SparqlTerm { * strlit('Hello') // → "Hello" * strlit('Line 1\nLine 2') // → """Line 1\nLine 2""" */ -export function strlit(value: string): SparqlTerm { +export function strlit(value: string): SparqlTermType { const escaped = escapeString(value) if (needsLongQuotes(value)) { @@ -910,12 +939,12 @@ export function strlit(value: string): SparqlTerm { * typed('42', 'http://www.w3.org/2001/XMLSchema#integer') * // → "42"^^ */ -export function typed(value: string, datatype: DatatypeIRI): SparqlTerm { +export function typed(value: string, datatype: DatatypeIriType): SparqlTermType { const datatypeToken = typeof datatype === 'string' ? (() => { - validateIRI(datatype) - return `<${datatype}>` - })() + validateIRI(datatype) + return `<${datatype}>` + })() : rdfTerm(datatype) const escaped = escapeString(value) @@ -935,7 +964,7 @@ export function typed(value: string, datatype: DatatypeIRI): SparqlTerm { * @example lang('Hello', 'en') → "Hello"@en * @example lang('Bonjour', 'fr') → "Bonjour"@fr */ -export function lang(value: string, tag: LanguageTag): SparqlTerm { +export function lang(value: string, tag: LanguageTagType): SparqlTermType { validateLanguageTag(tag) const escaped = escapeString(value) @@ -956,7 +985,7 @@ export function lang(value: string, tag: LanguageTag): SparqlTerm { * * @throws {Error} If value is not an integer */ -export function integer(value: number): SparqlTerm { +export function integer(value: number): SparqlTermType { if (!Number.isInteger(value)) { throw new Error(`Expected integer, got: ${value}`) } @@ -976,7 +1005,7 @@ export function integer(value: number): SparqlTerm { * * @throws {Error} If value is not finite (NaN or Infinity) */ -export function decimal(value: number): SparqlTerm { +export function decimal(value: number): SparqlTermType { if (!Number.isFinite(value)) { throw new Error(`Expected finite number, got: ${value}`) } @@ -1000,7 +1029,7 @@ export function decimal(value: number): SparqlTerm { * * @throws {Error} If value is not an double */ -export function double(value: number): SparqlTerm { +export function double(value: number): SparqlTermType { if (!Number.isFinite(value)) { throw new Error(`Expected finite number, got: ${value}`) } @@ -1018,7 +1047,7 @@ export function double(value: number): SparqlTerm { * num(42) // → 42 * num(3.14) // → 3.14 */ -export function num(value: number): SparqlTerm { +export function num(value: number): SparqlTermType { if (Number.isInteger(value)) { return integer(value) } @@ -1031,14 +1060,14 @@ export function num(value: number): SparqlTerm { * * Boolean values in SPARQL are written as bare keywords, not quoted strings. */ -export function boolean(value: boolean): SparqlTerm { +export function boolean(value: boolean): SparqlTermType { return rawTerm(value ? 'true' : 'false') } /** * Short alias for {@link boolean}. */ -export function bool(value: boolean): SparqlTerm { +export function bool(value: boolean): SparqlTermType { return boolean(value) } @@ -1048,7 +1077,7 @@ export function bool(value: boolean): SparqlTerm { * @example * date(new Date('2024-01-15')) // → "2024-01-15"^^ */ -export function date(value: Date | string): SparqlTerm { +export function date(value: Date | string): SparqlTermType { const dateObj = value instanceof Date ? value : new Date(value) const yyyy = dateObj.getFullYear() const mm = String(dateObj.getMonth() + 1).padStart(2, '0') @@ -1062,7 +1091,7 @@ export function date(value: Date | string): SparqlTerm { * @example * dateTime(new Date()) // → "2024-01-15T10:30:00.000Z"^^ */ -export function dateTime(value: Date | string): SparqlTerm { +export function dateTime(value: Date | string): SparqlTermType { const dateObj = value instanceof Date ? value : new Date(value) return rawTerm(`"${dateObj.toISOString()}"^^<${XSD.dateTime}>`) } @@ -1113,7 +1142,7 @@ export function dateTime(value: Date | string): SparqlTerm { * convertValue(null) // throws * ``` */ -export function convertValue(value: SparqlInterpolatable, strict = true): string { +export function convertValue(value: SparqlInterpolatableType, strict = true): string { // Already a SPARQL value – pass straight through. if (isSparqlValue(value)) return value.value if (isRdfTerm(value)) return rdfTerm(value) @@ -1122,7 +1151,7 @@ export function convertValue(value: SparqlInterpolatable, strict = true): string if (value === null || value === undefined) { if (strict) { throw new Error( - 'Cannot convert null/undefined to a SPARQL term. Use OPTIONAL/BOUND or pass strict=false if you explicitly want an empty string literal.' + 'Cannot convert null/undefined to a SPARQL term. Use OPTIONAL/BOUND or pass strict=false if you explicitly want an empty string literal.', ) } return strlit('').value @@ -1148,18 +1177,18 @@ export function convertValue(value: SparqlInterpolatable, strict = true): string // Anything else (arrays, plain objects, etc.) is not a single term. if (Array.isArray(value)) { throw new Error( - 'Cannot convert an array directly to a SPARQL term. Use valuesList(), exprList(), or rdfList() to control how the list appears in your query.' + 'Cannot convert an array directly to a SPARQL term. Use valuesList(), exprList(), or rdfList() to control how the list appears in your query.', ) } if (typeof value === 'object') { throw new Error( - 'Cannot convert a plain object directly to a SPARQL term. Use bnodePattern() to create [ ... ] blank nodes, or pre-wrap it as a SparqlValue using raw().' + 'Cannot convert a plain object directly to a SPARQL term. Use bnodePattern() to create [ ... ] blank nodes, or pre-wrap it as a SparqlValueType using raw().', ) } throw new Error( - `Cannot convert value of type "${typeof value}" to a SPARQL term` + `Cannot convert value of type "${typeof value}" to a SPARQL term`, ) } @@ -1176,7 +1205,10 @@ export function isNonStringIterable(value: unknown): value is Iterable value !== undefined && typeof value !== 'string' && (typeof value === 'object' || typeof value === 'function') && - typeof (value as { readonly [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' + typeof (value as { + /** Iterable hook used to distinguish SPARQL iterable inputs from strings. */ + readonly [Symbol.iterator]?: unknown + })[Symbol.iterator] === 'function' ) } @@ -1204,8 +1236,8 @@ export function isNonStringIterable(value: unknown): value is Iterable * ``` */ export function valuesList( - items: Iterable -): SparqlValue { + items: Iterable, +): SparqlValueType { const parts: string[] = [] for (const item of items) { @@ -1241,8 +1273,8 @@ export function valuesList( * ``` */ export function exprList( - items: Iterable -): SparqlValue { + items: Iterable, +): SparqlValueType { const parts: string[] = [] for (const item of items) { @@ -1275,8 +1307,8 @@ export function exprList( * ``` */ export function rdfList( - items: Iterable -): SparqlValue { + items: Iterable, +): SparqlValueType { const parts: string[] = [] for (const item of items) { @@ -1325,7 +1357,7 @@ export function rdfList( * // Fresh blank nodes for each result row. * ``` */ -export function bnode(id?: string): SparqlTerm { +export function bnode(id?: string): SparqlTermType { if (id) { return rawTerm(`_:${id}`) } @@ -1335,22 +1367,22 @@ export function bnode(id?: string): SparqlTerm { /** * Property map for `bnodePattern`. */ -export type BnodeProps = Record +export type BnodePropsType = Record /** * Allowed values for blank node properties: * - * - Single scalar term (string/number/boolean/Date/SparqlValue/null/undefined). + * - Single scalar term (string/number/boolean/Date/SparqlValueType/null/undefined). * - Arrays or other iterables → become **object lists**: * `predicate v1 , v2 , v3`. * - Nested property objects → become nested `[ ... ]` blank nodes. */ -export type BnodePropValue = - | SparqlInterpolatable - | Iterable +export type BnodePropValueType = + | SparqlInterpolatableType + | Iterable /** - * Internal: check for a "plain" object (not Date, not SparqlValue, etc.). + * Internal: check for a "plain" object (not Date, not SparqlValueType, etc.). */ export function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== 'object') { @@ -1385,7 +1417,7 @@ export function toPredicateName(key: string): string { } // `a` = `rdf:type` its a common shortcut in SPARQL - if (key === "a") return key + if (key === 'a') return key // Fallback: assume a default ":" prefix is bound. return `:${key}` @@ -1454,7 +1486,7 @@ export function toPredicateName(key: string): string { * // ] * ``` */ -export function bnodePattern(props: BnodeProps): SparqlTerm { +export function bnodePattern(props: BnodePropsType): SparqlTermType { const entries = Object.entries(props) if (entries.length === 0) { @@ -1468,7 +1500,7 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { if (rawVal === null || rawVal === undefined) { throw new Error( - `Property "${rawKey}" is null/undefined in bnodePattern(). Omit it or model absence with OPTIONAL patterns instead.` + `Property "${rawKey}" is null/undefined in bnodePattern(). Omit it or model absence with OPTIONAL patterns instead.`, ) } @@ -1478,7 +1510,7 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { !isSparqlValue(rawVal) && !(rawVal instanceof Date) ) { - const nested = bnodePattern(rawVal as BnodeProps) + const nested = bnodePattern(rawVal as BnodePropsType) propertyFragments.push(`${predicate} ${nested.value}`) continue } @@ -1487,13 +1519,13 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { if (Array.isArray(rawVal) || isNonStringIterable(rawVal)) { const objects: string[] = [] - for (const item of rawVal as Iterable) { + for (const item of rawVal as Iterable) { objects.push(convertValue(item)) } if (objects.length === 0) { throw new Error( - `Property "${rawKey}" has an empty iterable in bnodePattern().` + `Property "${rawKey}" has an empty iterable in bnodePattern().`, ) } @@ -1503,7 +1535,7 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { // Single scalar value. propertyFragments.push( - `${predicate} ${convertValue(rawVal as SparqlInterpolatable)}` + `${predicate} ${convertValue(rawVal as SparqlInterpolatableType)}`, ) } @@ -1519,10 +1551,10 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { * * It: * - Interpolates values using `convertValue` (scalars) or lets you insert - * richer fragments using `SparqlValue` helpers (`raw`, `valuesList`, etc.). + * richer fragments using `SparqlValueType` helpers (`raw`, `valuesList`, etc.). * - Normalizes only the common indentation introduced by the template call site. * - * Because `SparqlInterpolatable` deliberately excludes arrays/objects, you are + * Because `SparqlInterpolatableType` deliberately excludes arrays/objects, you are * guided towards the explicit helpers for composite structures. * * @example Basic query with scalars @@ -1570,14 +1602,14 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { */ export function sparql( strings: TemplateStringsArray, - ...values: SparqlInterpolatable[] -): SparqlValue { + ...values: SparqlInterpolatableType[] +): SparqlValueType { let result = strings[0] ?? '' for (let i = 0; i < values.length; i++) { const value = values[i] - // SparqlValue fragments are injected as-is. + // SparqlValueType fragments are injected as-is. if (isSparqlValue(value)) { result += value.value } else { @@ -1591,7 +1623,6 @@ export function sparql( return raw(normalizeTemplate(result)) } - /** Serializes an RDF/JS term as SPARQL syntax without losing RDF semantics. */ export function rdfTerm(term: RdfTerm): string { switch (term.termType) { @@ -1606,7 +1637,9 @@ export function rdfTerm(term: RdfTerm): string { case 'Literal': { const literal = term as RdfLiteral const lexical = `"${escapeString(literal.value, '"')}"` - if (literal.language) return `${lexical}@${literal.language}${literal.direction ? `--${literal.direction}` : ''}` + if (literal.language) { + return `${lexical}@${literal.language}${literal.direction ? `--${literal.direction}` : ''}` + } if (literal.datatype.value === XSD.string) return lexical return `${lexical}^^<${escapeIriForQuery(literal.datatype.value)}>` } @@ -1615,7 +1648,9 @@ export function rdfTerm(term: RdfTerm): string { if (triple.graph.termType !== 'DefaultGraph') { throw new TypeError('A SPARQL triple-term expression cannot contain a named graph.') } - return `<<( ${rdfTerm(triple.subject)} ${rdfTerm(triple.predicate)} ${rdfTerm(triple.object)} )>>` + return `<<( ${rdfTerm(triple.subject)} ${rdfTerm(triple.predicate)} ${ + rdfTerm(triple.object) + } )>>` } } } @@ -1626,7 +1661,9 @@ export function rdfTerm(term: RdfTerm): string { */ function normalizeTemplate(value: string): string { const lines = value.replace(/^\n/, '').replace(/\n\s*$/, '').split('\n') - const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^\s*/)?.[0].length ?? 0) + const indents = lines.filter((line) => line.trim()).map((line) => + line.match(/^\s*/)?.[0].length ?? 0 + ) const common = indents.length === 0 ? 0 : Math.min(...indents) return lines.map((line) => line.slice(common)).join('\n') } diff --git a/packages/sparql/sparql_test.ts b/packages/sparql/sparql_test.ts index 63036d6..380a8a0 100644 --- a/packages/sparql/sparql_test.ts +++ b/packages/sparql/sparql_test.ts @@ -25,7 +25,9 @@ describe('@okikio/sparql term and pattern roles', () => { }) it('treats prefixed subject strings as graph terms and predicate variables as variables', () => { - expect(triple('schema:Product', 'schema:name', '?name').value).toBe('schema:Product schema:name ?name .') + expect(triple('schema:Product', 'schema:name', '?name').value).toBe( + 'schema:Product schema:name ?name .', + ) expect(triple('?subject', '?predicate', '?object').value).toBe('?subject ?predicate ?object .') }) diff --git a/packages/sparql/syntax/mod_test.ts b/packages/sparql/syntax/mod_test.ts index a662224..5133955 100644 --- a/packages/sparql/syntax/mod_test.ts +++ b/packages/sparql/syntax/mod_test.ts @@ -10,21 +10,29 @@ async function collect(source: AsyncIterable): Promise { describe('@okikio/sparql/syntax', () => { it('emits source-ranged version and SPARQL 1.2 feature events without building an AST', async () => { - const document = await inspect('VERSION "1.2"\nSELECT ?s WHERE { BIND( <<( ?s :p :o )>> AS ?t ) }') + const document = await inspect( + 'VERSION "1.2"\nSELECT ?s WHERE { BIND( <<( ?s :p :o )>> AS ?t ) }', + ) expect(document.version).toBe('1.2') expect(document.features.some((value) => value.feature === 'triple-term')).toBe(true) expect(document.tokens.find((value) => value.kind === 'variable')?.range.line).toBe(2) }) it('reports triple terms against the 1.2-basic compatibility profile', async () => { - const document = await inspect('VERSION "1.2-basic" SELECT * WHERE { BIND( <<( :s :p :o )>> AS ?t ) }') + const document = await inspect( + 'VERSION "1.2-basic" SELECT * WHERE { BIND( <<( :s :p :o )>> AS ?t ) }', + ) expect(document.diagnostics.some((value) => value.code === 'sparql-version-feature')).toBe(true) }) it('distinguishes relational less-than from an IRI reference without whitespace', async () => { - const values = await collect(tokens('SELECT * WHERE { FILTER(?x<5) BIND( AS ?iri) }')) + const values = await collect( + tokens('SELECT * WHERE { FILTER(?x<5) BIND( AS ?iri) }'), + ) expect(values.some((value) => value.kind === 'operator' && value.raw === '<')).toBe(true) - expect(values.some((value) => value.kind === 'iri' && value.value === 'https://example/')).toBe(true) + expect(values.some((value) => value.kind === 'iri' && value.value === 'https://example/')).toBe( + true, + ) }) it('keeps long literals across hostile chunk splits', async () => { diff --git a/packages/sparql/syntax/scan.ts b/packages/sparql/syntax/scan.ts index 33aa5ec..8361db0 100644 --- a/packages/sparql/syntax/scan.ts +++ b/packages/sparql/syntax/scan.ts @@ -1,6 +1,6 @@ /** Version-aware semantic events over the data-oriented SPARQL scanner. @module */ -import { Kind, Scanner, SyntaxScanError } from './scanner.ts' +import { KindType, Scanner, SyntaxScanError } from './scanner.ts' import type { DiagnosticType, DocumentType, @@ -27,7 +27,10 @@ const VERSIONS = new Set(['1.1', '1.2-basic', '1.2']) export { SyntaxScanError } from './scanner.ts' /** Emits source-ranged lexical tokens, version announcements, features, and diagnostics. */ -export async function* events(source: SourceType, options: OptionsType = {}): AsyncGenerator { +export async function* events( + source: SourceType, + options: OptionsType = {}, +): AsyncGenerator { const scanner = new Scanner(source, options) const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS let tokenCount = 0 @@ -38,87 +41,115 @@ export async function* events(source: SourceType, options: OptionsType = {}): As try { while (true) { - let token: TokenType - try { - await scanner.next() - if (scanner.kind === Kind.Eof) break - token = scanner.token() - } catch (error) { - if (!(error instanceof SyntaxScanError) || !options.tolerant) throw error - yield { kind: 'diagnostic', diagnostic: diagnostic(error.code, error.message, 'error', error.range) } - break - } - - if (scanner.kind === Kind.Unknown) { - const issue = diagnostic('sparql-token', `Unrecognized SPARQL token ${JSON.stringify(token.raw)}.`, 'error', token.range) - if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) - yield { kind: 'diagnostic', diagnostic: issue } - continue - } - - if (token.kind !== 'whitespace' && token.kind !== 'comment') { - tokenCount++ - if (tokenCount > maxTokens) { - const issue = diagnostic('sparql-token-count', `SPARQL source exceeds ${maxTokens} tokens.`, 'error', token.range) - if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) - yield { kind: 'diagnostic', diagnostic: issue } - return + let token: TokenType + try { + await scanner.next() + if (scanner.kind === KindType.Eof) break + token = scanner.token() + } catch (error) { + if (!(error instanceof SyntaxScanError) || !options.tolerant) throw error + yield { + kind: 'diagnostic', + diagnostic: diagnostic(error.code, error.message, 'error', error.range), + } + break } - } - - yield { kind: 'token', token } - if (pendingVersion && token.kind !== 'whitespace' && token.kind !== 'comment') { - hasVersionDirective = true - externalVersion = undefined - if (token.kind !== 'string' || isLongString(token.raw)) { + if (scanner.kind === KindType.Unknown) { const issue = diagnostic( - 'sparql-version-value', - 'VERSION must be followed by a short quoted version string.', + 'sparql-token', + `Unrecognized SPARQL token ${JSON.stringify(token.raw)}.`, 'error', - merge(pendingVersion.range, token.range), + token.range, ) + if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) yield { kind: 'diagnostic', diagnostic: issue } - effectiveVersion = undefined - } else { - const recognized = VERSIONS.has(token.value as VersionType) ? token.value as VersionType : undefined - const versionEvent = version(token.value, recognized, merge(pendingVersion.range, token.range)) - yield versionEvent - if (!recognized) { - yield { - kind: 'diagnostic', - diagnostic: diagnostic( - 'sparql-version-unknown', - `Unrecognized SPARQL version label ${JSON.stringify(token.value)}.`, - 'warning', - token.range, - ), - } + continue + } + + if (token.kind !== 'whitespace' && token.kind !== 'comment') { + tokenCount++ + if (tokenCount > maxTokens) { + const issue = diagnostic( + 'sparql-token-count', + `SPARQL source exceeds ${maxTokens} tokens.`, + 'error', + token.range, + ) + if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) + yield { kind: 'diagnostic', diagnostic: issue } + return + } + } + + yield { kind: 'token', token } + + if (pendingVersion && token.kind !== 'whitespace' && token.kind !== 'comment') { + hasVersionDirective = true + externalVersion = undefined + if (token.kind !== 'string' || isLongString(token.raw)) { + const issue = diagnostic( + 'sparql-version-value', + 'VERSION must be followed by a short quoted version string.', + 'error', + merge(pendingVersion.range, token.range), + ) + yield { kind: 'diagnostic', diagnostic: issue } effectiveVersion = undefined } else { - effectiveVersion = recognized + const recognized = VERSIONS.has(token.value as VersionType) + ? token.value as VersionType + : undefined + const versionEvent = version( + token.value, + recognized, + merge(pendingVersion.range, token.range), + ) + yield versionEvent + if (!recognized) { + yield { + kind: 'diagnostic', + diagnostic: diagnostic( + 'sparql-version-unknown', + `Unrecognized SPARQL version label ${JSON.stringify(token.value)}.`, + 'warning', + token.range, + ), + } + effectiveVersion = undefined + } else { + effectiveVersion = recognized + } } + pendingVersion = undefined + continue } - pendingVersion = undefined - continue - } - if (token.kind === 'keyword' && token.value === 'VERSION') { - pendingVersion = token - continue - } + if (token.kind === 'keyword' && token.value === 'VERSION') { + pendingVersion = token + continue + } - const feature = getFeature(token) - if (!feature) continue - const featureEvent: FeatureEventType = { kind: 'feature', feature, range: token.range } - yield featureEvent + const feature = getFeature(token) + if (!feature) continue + const featureEvent: FeatureEventType = { kind: 'feature', feature, range: token.range } + yield featureEvent - const issue = getCompatibilityDiagnostic(feature, hasVersionDirective ? effectiveVersion : externalVersion, token.range) - if (issue) yield { kind: 'diagnostic', diagnostic: issue } - } + const issue = getCompatibilityDiagnostic( + feature, + hasVersionDirective ? effectiveVersion : externalVersion, + token.range, + ) + if (issue) yield { kind: 'diagnostic', diagnostic: issue } + } if (pendingVersion) { - const issue = diagnostic('sparql-version-value', 'VERSION is missing its quoted version label.', 'error', pendingVersion.range) + const issue = diagnostic( + 'sparql-version-value', + 'VERSION is missing its quoted version label.', + 'error', + pendingVersion.range, + ) if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) yield { kind: 'diagnostic', diagnostic: issue } } @@ -128,14 +159,20 @@ export async function* events(source: SourceType, options: OptionsType = {}): As } /** Emits only lexical tokens while preserving the same scanner and cancellation behavior. */ -export async function* tokens(source: SourceType, options: OptionsType = {}): AsyncGenerator { +export async function* tokens( + source: SourceType, + options: OptionsType = {}, +): AsyncGenerator { for await (const event of events(source, options)) { if (event.kind === 'token') yield event.token } } /** Materializes the event stream without claiming to produce a full SPARQL AST. */ -export async function inspect(source: SourceType, options: OptionsType = {}): Promise { +export async function inspect( + source: SourceType, + options: OptionsType = {}, +): Promise { const foundTokens: TokenType[] = [] const diagnostics: DiagnosticType[] = [] const versions: VersionEventType[] = [] @@ -143,10 +180,18 @@ export async function inspect(source: SourceType, options: OptionsType = {}): Pr for await (const event of events(source, options)) { switch (event.kind) { - case 'token': foundTokens.push(event.token); break - case 'diagnostic': diagnostics.push(event.diagnostic); break - case 'version': versions.push(event); break - case 'feature': features.push(event); break + case 'token': + foundTokens.push(event.token) + break + case 'diagnostic': + diagnostics.push(event.diagnostic) + break + case 'version': + versions.push(event) + break + case 'feature': + features.push(event) + break } } @@ -179,7 +224,9 @@ function getCompatibilityDiagnostic( if (!version || version === '1.2') return undefined if (version === '1.2-basic') { - if (feature !== 'triple-term' && feature !== 'reified-triple' && feature !== 'triple-function') return undefined + if ( + feature !== 'triple-term' && feature !== 'reified-triple' && feature !== 'triple-function' + ) return undefined return diagnostic( 'sparql-version-feature', `SPARQL ${version} does not permit the observed ${feature} syntax.`, @@ -197,7 +244,11 @@ function getCompatibilityDiagnostic( } /** Maps a VERSION token to the supported syntax profile used by feature diagnostics. */ -function version(label: string, value: VersionType | undefined, range: RangeType): VersionEventType { +function version( + label: string, + value: VersionType | undefined, + range: RangeType, +): VersionEventType { return value === undefined ? { kind: 'version', label, range } : { kind: 'version', label, version: value, range } @@ -229,4 +280,3 @@ function merge(start: RangeType, end: RangeType): RangeType { function isLongString(raw: string): boolean { return raw.startsWith("'''") || raw.startsWith('"""') } - diff --git a/packages/sparql/syntax/scanner.ts b/packages/sparql/syntax/scanner.ts index ef50c88..5785bb1 100644 --- a/packages/sparql/syntax/scanner.ts +++ b/packages/sparql/syntax/scanner.ts @@ -11,7 +11,7 @@ const COMPACT_THRESHOLD = 64 * 1024 const REFILL_WINDOW = 16 * 1024 /** Numeric token kinds keep hot scanner state compact. This is not a public API. */ -export const Kind = { +export const KindType = { Eof: 0, Keyword: 1, Variable: 2, @@ -34,28 +34,133 @@ export const Kind = { } as const /** Stable lexical token-kind value emitted by the SPARQL scanner. */ -export type Kind = (typeof Kind)[keyof typeof Kind] +export type KindType = (typeof KindType)[keyof typeof KindType] /** Case-insensitive SPARQL keywords recognized separately from identifiers and prefixed names. */ const KEYWORDS = new Set([ - 'ABS', 'ADD', 'ALL', 'AS', 'ASC', 'ASK', 'AVG', 'BASE', 'BIND', 'BNODE', 'BOUND', - 'BY', 'CEIL', 'CLEAR', 'COALESCE', 'CONCAT', 'CONSTRUCT', 'CONTAINS', 'COPY', 'COUNT', - 'CREATE', 'DATATYPE', 'DAY', 'DEFAULT', 'DELETE', 'DESC', 'DESCRIBE', 'DISTINCT', 'DROP', - 'ENCODE_FOR_URI', 'EXISTS', 'FILTER', 'FLOOR', 'FROM', 'GRAPH', 'GROUP', 'GROUP_CONCAT', - 'HAVING', 'HOURS', 'IF', 'IN', 'INSERT', 'INTO', 'IRI', 'ISBLANK', 'ISIRI', 'ISLITERAL', - 'ISNUMERIC', 'ISTRIPLE', 'ISURI', 'LCASE', 'LIMIT', 'LOAD', 'MAX', 'MD5', 'MIN', 'MINUS', - 'MINUTES', 'MONTH', 'MOVE', 'NAMED', 'NOT', 'NOW', 'OBJECT', 'OFFSET', 'OPTIONAL', 'ORDER', - 'PREDICATE', 'PREFIX', 'RAND', 'REDUCED', 'REGEX', 'REPLACE', 'SAMPLE', 'SELECT', 'SEPARATOR', - 'SERVICE', 'SHA1', 'SHA256', 'SHA384', 'SHA512', 'SILENT', 'STR', 'STRAFTER', 'STRBEFORE', - 'STRDT', 'STRENDS', 'STRLANG', 'STRLANGDIR', 'STRLEN', 'STRSTARTS', 'SUBJECT', 'SUBSTR', 'SUM', - 'TIMEZONE', 'TO', 'TRIPLE', 'TRUE', 'TZ', 'UCASE', 'UNDEF', 'UNION', 'URI', 'USING', 'UUID', - 'VALUES', 'VERSION', 'WHERE', 'WITH', 'YEAR', 'LANG', 'LANGDIR', 'LANGMATCHES', 'HASLANG', - 'HASLANGDIR', 'FALSE', + 'ABS', + 'ADD', + 'ALL', + 'AS', + 'ASC', + 'ASK', + 'AVG', + 'BASE', + 'BIND', + 'BNODE', + 'BOUND', + 'BY', + 'CEIL', + 'CLEAR', + 'COALESCE', + 'CONCAT', + 'CONSTRUCT', + 'CONTAINS', + 'COPY', + 'COUNT', + 'CREATE', + 'DATATYPE', + 'DAY', + 'DEFAULT', + 'DELETE', + 'DESC', + 'DESCRIBE', + 'DISTINCT', + 'DROP', + 'ENCODE_FOR_URI', + 'EXISTS', + 'FILTER', + 'FLOOR', + 'FROM', + 'GRAPH', + 'GROUP', + 'GROUP_CONCAT', + 'HAVING', + 'HOURS', + 'IF', + 'IN', + 'INSERT', + 'INTO', + 'IRI', + 'ISBLANK', + 'ISIRI', + 'ISLITERAL', + 'ISNUMERIC', + 'ISTRIPLE', + 'ISURI', + 'LCASE', + 'LIMIT', + 'LOAD', + 'MAX', + 'MD5', + 'MIN', + 'MINUS', + 'MINUTES', + 'MONTH', + 'MOVE', + 'NAMED', + 'NOT', + 'NOW', + 'OBJECT', + 'OFFSET', + 'OPTIONAL', + 'ORDER', + 'PREDICATE', + 'PREFIX', + 'RAND', + 'REDUCED', + 'REGEX', + 'REPLACE', + 'SAMPLE', + 'SELECT', + 'SEPARATOR', + 'SERVICE', + 'SHA1', + 'SHA256', + 'SHA384', + 'SHA512', + 'SILENT', + 'STR', + 'STRAFTER', + 'STRBEFORE', + 'STRDT', + 'STRENDS', + 'STRLANG', + 'STRLANGDIR', + 'STRLEN', + 'STRSTARTS', + 'SUBJECT', + 'SUBSTR', + 'SUM', + 'TIMEZONE', + 'TO', + 'TRIPLE', + 'TRUE', + 'TZ', + 'UCASE', + 'UNDEF', + 'UNION', + 'URI', + 'USING', + 'UUID', + 'VALUES', + 'VERSION', + 'WHERE', + 'WITH', + 'YEAR', + 'LANG', + 'LANGDIR', + 'LANGMATCHES', + 'HASLANG', + 'HASLANGDIR', + 'FALSE', ]) /** Position-aware lexical failure used by strict mode and converted in tolerant mode. */ export class SyntaxScanError extends SyntaxError { + /** Stable machine-readable code used to classify this diagnostic or failure. */ readonly code: string + /** Source range that locates the related token, statement, feature, or diagnostic. */ readonly range: RangeType /** Creates a source-ranged lexical failure that the event layer can surface as a diagnostic. */ @@ -76,27 +181,47 @@ export class SyntaxScanError extends SyntaxError { * character. Consumed source is compacted to cap retained text. */ export class Scanner { - kind: Kind = Kind.Eof + /** Current lexical token class. `Eof` means no token is currently available. */ + kind: KindType = KindType.Eof + /** Decoded token value used by syntax inspection; `raw` preserves the exact source spelling. */ value = '' + /** Exact source text consumed for this token before semantic decoding. */ raw = '' + /** Zero-based source offset where this record starts. */ start = 0 + /** Exclusive zero-based source offset where this record ends. */ end = 0 + /** One-based source line containing the start of this record. */ line = 1 + /** One-based source column containing the start of this record. */ column = 1 + /** One-based source line at the exclusive end of the current token. */ endLine = 1 + /** One-based source column at the exclusive end of the current token. */ endColumn = 1 + /** Caller-owned abort signal checked before expensive work and between long-running steps. */ readonly signal: AbortSignal | undefined + /** Whether this token is whitespace or a comment that does not affect SPARQL grammar. */ readonly trivia: boolean + /** Maximum token length accepted before the scanner reports a configured limit. */ readonly maxTokenLength: number + /** Input source currently owned by this parser or scanner until it is consumed or canceled. */ #source: AsyncGenerator + /** Streaming text decoder that preserves partial UTF-8 sequences between source chunks. */ #decoder = new TextDecoder('utf-8', { fatal: true }) + /** Retained unread source text. Compaction removes consumed prefixes to keep memory bounded. */ #buffer = '' + /** Current lookup or cursor index used to avoid rescanning already consumed state. */ #index = 0 + /** Absolute source offset corresponding to the start of the retained scanner buffer. */ #absolute = 0 + /** Current one-based source line maintained as the scanner consumes characters. */ #line = 1 + /** Current one-based source column maintained as the scanner consumes characters. */ #column = 1 + /** Whether the underlying source has reached its terminal end state. */ #done = false /** Creates a buffered scanner whose hot character loop stays synchronous until a source refill is needed. */ @@ -122,7 +247,7 @@ export class Scanner { this.#mark() const first = this.#peek() if (first === undefined) { - this.kind = Kind.Eof + this.kind = KindType.Eof this.#finish() return } @@ -130,22 +255,26 @@ export class Scanner { const three = `${first}${this.#peek(1) ?? ''}${this.#peek(2) ?? ''}` const two = three.slice(0, 2) - if (three === '<<(') return this.#fixed(Kind.Marker, 3) - if (three === ')>>') return this.#fixed(Kind.Marker, 3) - if (two === '<<' || two === '>>' || two === '{|' || two === '|}') return this.#fixed(Kind.Marker, 2) - if (two === '^^' || two === '!=' || two === '<=' || two === '>=' || two === '||' || two === '&&') { - return this.#fixed(Kind.Operator, 2) + if (three === '<<(') return this.#fixed(KindType.Marker, 3) + if (three === ')>>') return this.#fixed(KindType.Marker, 3) + if (two === '<<' || two === '>>' || two === '{|' || two === '|}') { + return this.#fixed(KindType.Marker, 2) + } + if ( + two === '^^' || two === '!=' || two === '<=' || two === '>=' || two === '||' || two === '&&' + ) { + return this.#fixed(KindType.Operator, 2) } if (first === '?' || first === '$') { const second = this.#peek(1) if (second !== undefined && isVarStart(second)) return await this.#variable() - return this.#fixed(Kind.Operator, 1) + return this.#fixed(KindType.Operator, 1) } if (first === '<') { if (await this.#looksLikeIri()) return await this.#iri() - return this.#fixed(Kind.Operator, 1) + return this.#fixed(KindType.Operator, 1) } if (first === '"' || first === "'") return await this.#string(first) @@ -157,12 +286,12 @@ export class Scanner { if (await this.#number()) return } - if ('{}()[];,'.includes(first) || first === '.') return this.#fixed(Kind.Punctuation, 1) - if ('=<>+-*/!|^'.includes(first)) return this.#fixed(Kind.Operator, 1) - if (first === '~') return this.#fixed(Kind.Marker, 1) + if ('{}()[];,'.includes(first) || first === '.') return this.#fixed(KindType.Punctuation, 1) + if ('=<>+-*/!|^'.includes(first)) return this.#fixed(KindType.Operator, 1) + if (first === '~') return this.#fixed(KindType.Marker, 1) this.#take() - this.kind = Kind.Unknown + this.kind = KindType.Unknown this.value = first this.raw = first this.#finish() @@ -216,7 +345,7 @@ export class Scanner { if (char === undefined || !isWhitespace(char)) break raw += this.#take() ?? '' } - this.kind = Kind.Whitespace + this.kind = KindType.Whitespace this.value = raw this.raw = raw this.#finish() @@ -234,7 +363,7 @@ export class Scanner { if (char === undefined || char === '\n' || char === '\r') break raw += this.#take() ?? '' } - this.kind = Kind.Comment + this.kind = KindType.Comment this.value = raw.slice(1) this.raw = raw this.#finish() @@ -274,7 +403,7 @@ export class Scanner { } /** Fixed as one isolated step of the Scanner state machine. */ - #fixed(kind: Kind, width: number): void { + #fixed(kind: KindType, width: number): void { let raw = '' for (let i = 0; i < width; i++) raw += this.#take() ?? '' this.kind = kind @@ -299,7 +428,7 @@ export class Scanner { value += char this.#guard(mark) } - this.kind = Kind.Variable + this.kind = KindType.Variable this.value = value this.raw = raw this.#finish() @@ -337,7 +466,9 @@ export class Scanner { await this.#refill() char = this.#peek() } - if (char === undefined) throw this.error('sparql-iri-end', 'Unterminated SPARQL IRI reference.') + if (char === undefined) { + throw this.error('sparql-iri-end', 'Unterminated SPARQL IRI reference.') + } if (char === '>') { raw += this.#take() ?? '' break @@ -353,7 +484,7 @@ export class Scanner { value += char this.#guard(mark) } - this.kind = Kind.Iri + this.kind = KindType.Iri this.value = value this.raw = raw this.#finish() @@ -375,7 +506,9 @@ export class Scanner { await this.#refill(3) char = this.#peek() } - if (char === undefined) throw this.error('sparql-string-end', 'Unterminated SPARQL string literal.') + if (char === undefined) { + throw this.error('sparql-string-end', 'Unterminated SPARQL string literal.') + } if (char === quote) { if (long) { if (this.#peek(2) === undefined && !this.#done) await this.#refill(3) @@ -389,7 +522,10 @@ export class Scanner { } } if (!long && (char === '\n' || char === '\r')) { - throw this.error('sparql-string-line', 'Short SPARQL string literals cannot contain line breaks.') + throw this.error( + 'sparql-string-line', + 'Short SPARQL string literals cannot contain line breaks.', + ) } if (char === '\\') { raw += this.#take() ?? '' @@ -413,7 +549,7 @@ export class Scanner { this.#guard(mark) } - this.kind = Kind.String + this.kind = KindType.String this.value = value this.raw = raw this.#finish() @@ -440,8 +576,8 @@ export class Scanner { } this.kind = sawLetter && /^[A-Za-z]+(?:-[A-Za-z0-9]+)*(?:--[A-Za-z]+)?$/.test(value) - ? Kind.LangDir - : Kind.Unknown + ? KindType.LangDir + : KindType.Unknown this.value = value this.raw = raw this.#finish() @@ -461,7 +597,7 @@ export class Scanner { raw += this.#take() ?? '' this.#guard(mark) } - this.kind = raw.length > 2 ? Kind.Blank : Kind.Unknown + this.kind = raw.length > 2 ? KindType.Blank : KindType.Unknown this.value = raw.slice(2) this.raw = raw this.#finish() @@ -483,7 +619,7 @@ export class Scanner { if (char === '\\') { if (this.#peek(1) === undefined && !this.#done) await this.#refill(2) const next = this.#peek(1) - if (next === undefined || !'_~.-!$&\'()*+,;=/?#@%'.includes(next)) break + if (next === undefined || !"_~.-!$&'()*+,;=/?#@%".includes(next)) break raw += `${this.#take() ?? ''}${this.#take() ?? ''}` escapedLocal = true this.#guard(mark) @@ -504,18 +640,18 @@ export class Scanner { } if (raw.includes(':')) { - this.kind = Kind.Prefixed + this.kind = KindType.Prefixed this.value = raw } else if (!escapedLocal && raw === 'a') { - this.kind = Kind.Keyword + this.kind = KindType.Keyword this.value = raw } else { const upper = raw.toUpperCase() if (KEYWORDS.has(upper)) { - this.kind = upper === 'TRUE' || upper === 'FALSE' ? Kind.Boolean : Kind.Keyword + this.kind = upper === 'TRUE' || upper === 'FALSE' ? KindType.Boolean : KindType.Keyword this.value = upper === 'TRUE' || upper === 'FALSE' ? raw.toLowerCase() : upper } else { - this.kind = Kind.Identifier + this.kind = KindType.Identifier this.value = raw } } @@ -533,15 +669,26 @@ export class Scanner { candidate += char } - const matches: Array<{ kind: Kind; match: string }> = [] - const double = candidate.match(/^[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))[eE][+-]?[0-9]+/)?.[0] + const matches: Array<{ + /** Discriminates the concrete matches variant. */ + kind: KindType + /** Source text matched by the candidate scanner token. */ + match: string + }> = [] + const double = candidate.match(/^[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))[eE][+-]?[0-9]+/) + ?.[0] const decimal = candidate.match(/^[+-]?[0-9]*\.[0-9]+/)?.[0] const integer = candidate.match(/^[+-]?[0-9]+/)?.[0] - if (double) matches.push({ kind: Kind.Double, match: double }) - if (decimal) matches.push({ kind: Kind.Decimal, match: decimal }) - if (integer) matches.push({ kind: Kind.Integer, match: integer }) - - let chosen: { kind: Kind; match: string } | undefined + if (double) matches.push({ kind: KindType.Double, match: double }) + if (decimal) matches.push({ kind: KindType.Decimal, match: decimal }) + if (integer) matches.push({ kind: KindType.Integer, match: integer }) + + let chosen: { + /** Discriminates the concrete chosen variant. */ + kind: KindType + /** Source text matched by the candidate scanner token. */ + match: string + } | undefined for (const entry of matches) { if (!chosen || entry.match.length > chosen.match.length) chosen = entry } @@ -558,10 +705,17 @@ export class Scanner { } /** Unicode as one isolated step of the Scanner state machine. */ - async #unicode(): Promise<{ raw: string; value: string }> { + async #unicode(): Promise<{ + /** Original escaped source spelling before decoding or normalization. */ + raw: string + /** Unicode scalar decoded from the SPARQL escape sequence. */ + value: string + }> { await this.#refill(9) const marker = this.#take() - if (marker !== 'u' && marker !== 'U') throw this.error('sparql-unicode', 'Expected a Unicode escape.') + if (marker !== 'u' && marker !== 'U') { + throw this.error('sparql-unicode', 'Expected a Unicode escape.') + } const width = marker === 'u' ? 4 : 8 let hex = '' for (let i = 0; i < width; i++) { @@ -601,7 +755,10 @@ export class Scanner { #guard(mark: number): void { if (this.#absolute - mark > this.maxTokenLength) { this.#finish() - throw this.error('sparql-token-limit', `SPARQL token exceeds ${this.maxTokenLength} code units.`) + throw this.error( + 'sparql-token-limit', + `SPARQL token exceeds ${this.maxTokenLength} code units.`, + ) } } @@ -650,38 +807,62 @@ export class Scanner { } /** Converts one internal numeric scanner kind to the public lexical token class. */ -function kindName(kind: Kind): TokenKindType { +function kindName(kind: KindType): TokenKindType { switch (kind) { - case Kind.Keyword: return 'keyword' - case Kind.Variable: return 'variable' - case Kind.Iri: return 'iri' - case Kind.Prefixed: return 'prefixed' - case Kind.Blank: return 'blank' - case Kind.String: return 'string' - case Kind.LangDir: return 'langDir' - case Kind.Integer: return 'integer' - case Kind.Decimal: return 'decimal' - case Kind.Double: return 'double' - case Kind.Boolean: return 'boolean' - case Kind.Punctuation: return 'punctuation' - case Kind.Operator: return 'operator' - case Kind.Marker: return 'marker' - case Kind.Identifier: return 'identifier' - case Kind.Whitespace: return 'whitespace' - case Kind.Comment: return 'comment' - default: return 'identifier' + case KindType.Keyword: + return 'keyword' + case KindType.Variable: + return 'variable' + case KindType.Iri: + return 'iri' + case KindType.Prefixed: + return 'prefixed' + case KindType.Blank: + return 'blank' + case KindType.String: + return 'string' + case KindType.LangDir: + return 'langDir' + case KindType.Integer: + return 'integer' + case KindType.Decimal: + return 'decimal' + case KindType.Double: + return 'double' + case KindType.Boolean: + return 'boolean' + case KindType.Punctuation: + return 'punctuation' + case KindType.Operator: + return 'operator' + case KindType.Marker: + return 'marker' + case KindType.Identifier: + return 'identifier' + case KindType.Whitespace: + return 'whitespace' + case KindType.Comment: + return 'comment' + default: + return 'identifier' } } /** Decodes a SPARQL Unicode escape and rejects invalid scalar values before token emission. */ function escaped(char: string): string { switch (char) { - case 't': return '\t' - case 'b': return '\b' - case 'n': return '\n' - case 'r': return '\r' - case 'f': return '\f' - default: return char + case 't': + return '\t' + case 'b': + return '\b' + case 'n': + return '\n' + case 'r': + return '\r' + case 'f': + return '\f' + default: + return char } } diff --git a/packages/sparql/syntax/source.ts b/packages/sparql/syntax/source.ts index ba424a6..71d256e 100644 --- a/packages/sparql/syntax/source.ts +++ b/packages/sparql/syntax/source.ts @@ -11,7 +11,10 @@ const DIRECT_CHUNK_SIZE = 16 * 1024 * A Web stream reader is cancelled when the syntax consumer stops early. A * pending read is also cancelled when the operation signal aborts. */ -export async function* chunks(source: SourceType, signal?: AbortSignal): AsyncGenerator { +export async function* chunks( + source: SourceType, + signal?: AbortSignal, +): AsyncGenerator { if (typeof source === 'string') { for (let offset = 0; offset < source.length; offset += DIRECT_CHUNK_SIZE) { throwIfAborted(signal) @@ -42,7 +45,11 @@ export async function* chunks(source: SourceType, signal?: AbortSignal): AsyncGe yield item.value } } finally { - if (!complete) await reader.cancel('SPARQL syntax consumer stopped before source completion').catch(() => undefined) + if (!complete) { + await reader.cancel('SPARQL syntax consumer stopped before source completion').catch(() => + undefined + ) + } reader.releaseLock() } } diff --git a/packages/sparql/syntax/types.ts b/packages/sparql/syntax/types.ts index dc520d8..eb639b0 100644 --- a/packages/sparql/syntax/types.ts +++ b/packages/sparql/syntax/types.ts @@ -5,11 +5,17 @@ export type VersionType = '1.1' | '1.2-basic' | '1.2' /** Source range using UTF-16 code-unit offsets and one-based line/column positions. */ export interface RangeType { + /** Zero-based source offset where this record starts. */ readonly start: number + /** Exclusive zero-based source offset where this record ends. */ readonly end: number + /** One-based source line containing the start of this record. */ readonly line: number + /** One-based source column containing the start of this record. */ readonly column: number + /** One-based source line containing the exclusive range end. */ readonly endLine: number + /** One-based source column at the exclusive range end. */ readonly endColumn: number } @@ -35,11 +41,13 @@ export type TokenKindType = /** One lexical SPARQL token. */ export interface TokenType { + /** Lexical class selected by the scanner for this source token. */ readonly kind: TokenKindType /** Decoded semantic value where decoding is meaningful, otherwise the token text. */ readonly value: string /** Exact source spelling. */ readonly raw: string + /** Source range that locates the related token, statement, feature, or diagnostic. */ readonly range: RangeType } @@ -48,9 +56,13 @@ export type SeverityType = 'warning' | 'error' /** Recoverable lexical or version/feature diagnostic. */ export interface DiagnosticType { + /** Stable machine-readable code used to classify this diagnostic or failure. */ readonly code: string + /** Human-readable explanation of the diagnostic or failure. */ readonly message: string + /** Whether the syntax condition is recoverable (`warning`) or prevents valid interpretation (`error`). */ readonly severity: SeverityType + /** Source range that locates the related token, statement, feature, or diagnostic. */ readonly range: RangeType } @@ -66,25 +78,42 @@ export type FeatureType = /** One observed feature occurrence. */ export interface FeatureEventType { + /** Identifies this event as an observed SPARQL feature occurrence. */ readonly kind: 'feature' + /** SPARQL feature identified by this syntax event. */ readonly feature: FeatureType + /** Source range that locates the related token, statement, feature, or diagnostic. */ readonly range: RangeType } /** One VERSION announcement. `version` is absent for unrecognized labels. */ export interface VersionEventType { + /** Identifies this event as a SPARQL VERSION announcement. */ readonly kind: 'version' + /** Human-readable syntax feature label used in diagnostics and reports. */ readonly label: string + /** Normalized SPARQL version when the announced label is recognized. */ readonly version?: VersionType + /** Source range that locates the related token, statement, feature, or diagnostic. */ readonly range: RangeType } /** Incremental syntax event. */ export type EventType = - | { readonly kind: 'token'; readonly token: TokenType } + | { + /** Selects the `token` variant of EventType. */ + readonly kind: 'token' + /** Token emitted by this SPARQL syntax event. */ + readonly token: TokenType + } | VersionEventType | FeatureEventType - | { readonly kind: 'diagnostic'; readonly diagnostic: DiagnosticType } + | { + /** Selects the `diagnostic` variant of EventType. */ + readonly kind: 'diagnostic' + /** Structured syntax diagnostic emitted by this parser event. */ + readonly diagnostic: DiagnosticType + } /** Byte/text input accepted by SPARQL syntax inspection. */ export type SourceType = @@ -106,14 +135,19 @@ export interface OptionsType { readonly maxTokenLength?: number /** Maximum number of emitted non-trivia tokens. */ readonly maxTokens?: number + /** Caller-owned abort signal checked before expensive work and between long-running steps. */ readonly signal?: AbortSignal } /** Materialized view over the event stream. This is intentionally not a SPARQL AST. */ export interface DocumentType { + /** Lexical tokens emitted during SPARQL syntax inspection. */ readonly tokens: readonly TokenType[] + /** Structured diagnostics retained so recoverable source information is not silently discarded. */ readonly diagnostics: readonly DiagnosticType[] + /** Version announcements discovered in the inspected SPARQL source. */ readonly versions: readonly VersionEventType[] + /** SPARQL 1.2 feature uses discovered in the inspected source. */ readonly features: readonly FeatureEventType[] /** Effective recognized version after applying VERSION-over-protocol precedence. */ readonly version?: VersionType diff --git a/packages/sparql/update.ts b/packages/sparql/update.ts index c006516..cd79b9f 100644 --- a/packages/sparql/update.ts +++ b/packages/sparql/update.ts @@ -34,7 +34,17 @@ * @module */ -import { rawPattern, toGraphOrDefault, toGraphRef, toGraphRefAll, updateDocument, type GraphOrDefaultInput, type IriInput, type PatternValue, type SparqlUpdate } from './sparql.ts' +import { + type GraphOrDefaultInputType, + type IriInputType, + type PatternValueType, + rawPattern, + type SparqlUpdateType, + toGraphOrDefault, + toGraphRef, + toGraphRefAll, + updateDocument, +} from './sparql.ts' // ============================================================================ // Update Operation Types @@ -46,30 +56,51 @@ import { rawPattern, toGraphOrDefault, toGraphRef, toGraphRefAll, updateDocument * This is immutable - each method creates a new state object rather than * modifying the existing one. */ -export interface UpdateState { - readonly operations: UpdateOperation[] +export interface UpdateStateType { + /** Update operations accumulated in source order. */ + readonly operations: UpdateOperationType[] } /** * Individual update operation. */ -export interface UpdateOperation { - readonly type: 'INSERT_DATA' | 'DELETE_DATA' | 'DELETE_WHERE' | 'DELETE_INSERT' | 'LOAD' | 'CLEAR' | 'DROP' | 'CREATE' | 'COPY' | 'MOVE' | 'ADD' - readonly data?: PatternValue - readonly where?: PatternValue +export interface UpdateOperationType { + /** SPARQL Update operation keyword represented by this builder operation. */ + readonly type: + | 'INSERT_DATA' + | 'DELETE_DATA' + | 'DELETE_WHERE' + | 'DELETE_INSERT' + | 'LOAD' + | 'CLEAR' + | 'DROP' + | 'CREATE' + | 'COPY' + | 'MOVE' + | 'ADD' + /** RDF data block attached to this update operation. */ + readonly data?: PatternValueType + /** Graph patterns that form the query or update WHERE clause. */ + readonly where?: PatternValueType + /** RDF graph name represented by this quad, statement, or query target. */ readonly graph?: string - readonly deleteTemplate?: PatternValue - readonly insertTemplate?: PatternValue + /** DELETE template patterns emitted before the MODIFY WHERE clause. */ + readonly deleteTemplate?: PatternValueType + /** INSERT template patterns emitted before the MODIFY WHERE clause. */ + readonly insertTemplate?: PatternValueType + /** Whether the SPARQL update operation requests SILENT failure handling. */ readonly silent?: boolean + /** Source graph IRI used by LOAD, COPY, MOVE, or ADD operations. */ readonly source?: string + /** Destination graph or endpoint resource used by this update operation. */ readonly dest?: string } /** * Initial empty state for updates. */ -const initialUpdateState: UpdateState = { - operations: [] +const initialUpdateState: UpdateStateType = { + operations: [], } // ============================================================================ @@ -93,10 +124,11 @@ const initialUpdateState: UpdateState = { * ``` */ export class UpdateBuilder { - private readonly state: UpdateState + /** Current immutable snapshot of the outer update builder state. */ + private readonly state: UpdateStateType /** Stores one immutable update-operation sequence; every builder method returns a new sequence instead of mutating this instance. */ - constructor(state: UpdateState) { + constructor(state: UpdateStateType) { this.state = state } @@ -145,12 +177,12 @@ export class UpdateBuilder { * ) * ``` */ - insertData(data: PatternValue, graph?: IriInput): UpdateBuilder { + insertData(data: PatternValueType, graph?: IriInputType): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'INSERT_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) } - ] + { type: 'INSERT_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) }, + ], }) } @@ -177,12 +209,12 @@ export class UpdateBuilder { * ])) * ``` */ - deleteData(data: PatternValue, graph?: IriInput): UpdateBuilder { + deleteData(data: PatternValueType, graph?: IriInputType): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'DELETE_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) } - ] + { type: 'DELETE_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) }, + ], }) } @@ -216,12 +248,12 @@ export class UpdateBuilder { * // Deletes invalid ages * ``` */ - deleteWhere(pattern: PatternValue): UpdateBuilder { + deleteWhere(pattern: PatternValueType): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'DELETE_WHERE', where: pattern } - ] + { type: 'DELETE_WHERE', where: pattern }, + ], }) } @@ -289,12 +321,17 @@ export class UpdateBuilder { * // Continues even if URL is unreachable * ``` */ - load(url: IriInput, graph?: IriInput, silent = false): UpdateBuilder { + load(url: IriInputType, graph?: IriInputType, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'LOAD', source: toGraphRef(url), ...(graph ? { graph: toGraphRef(graph) } : {}), silent } - ] + { + type: 'LOAD', + source: toGraphRef(url), + ...(graph ? { graph: toGraphRef(graph) } : {}), + silent, + }, + ], }) } @@ -323,12 +360,12 @@ export class UpdateBuilder { * // Doesn't error if graph doesn't exist * ``` */ - clear(graph: IriInput, silent = false): UpdateBuilder { + clear(graph: IriInputType, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'CLEAR', graph: toGraphRefAll(graph), silent } - ] + { type: 'CLEAR', graph: toGraphRefAll(graph), silent }, + ], }) } @@ -352,12 +389,12 @@ export class UpdateBuilder { * // Succeeds even if graph doesn't exist * ``` */ - drop(graph: IriInput, silent = false): UpdateBuilder { + drop(graph: IriInputType, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'DROP', graph: toGraphRefAll(graph), silent } - ] + { type: 'DROP', graph: toGraphRefAll(graph), silent }, + ], }) } @@ -381,14 +418,14 @@ export class UpdateBuilder { * // Succeeds even if graph already exists * ``` */ - create(graph: IriInput, silent = false): UpdateBuilder { + create(graph: IriInputType, silent = false): UpdateBuilder { const graphRef = toGraphRef(graph) return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'CREATE', graph: graphRef, silent } - ] + { type: 'CREATE', graph: graphRef, silent }, + ], }) } @@ -434,12 +471,16 @@ export class UpdateBuilder { * // COPY SILENT TO * ``` */ - copy(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { + copy( + source: GraphOrDefaultInputType, + dest: GraphOrDefaultInputType, + silent = false, + ): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'COPY', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } - ] + { type: 'COPY', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent }, + ], }) } @@ -485,12 +526,16 @@ export class UpdateBuilder { * // MOVE SILENT TO * ``` */ - move(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { + move( + source: GraphOrDefaultInputType, + dest: GraphOrDefaultInputType, + silent = false, + ): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'MOVE', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } - ] + { type: 'MOVE', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent }, + ], }) } @@ -538,12 +583,16 @@ export class UpdateBuilder { * // ADD SILENT TO * ``` */ - add(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { + add( + source: GraphOrDefaultInputType, + dest: GraphOrDefaultInputType, + silent = false, + ): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'ADD', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } - ] + { type: 'ADD', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent }, + ], }) } @@ -553,7 +602,7 @@ export class UpdateBuilder { * Converts all operations into a SPARQL Update string. Multiple operations * are separated by semicolons. * - * @returns SPARQL Update string wrapped in SparqlValue + * @returns SPARQL Update string wrapped in SparqlValueType * * @example * ```ts @@ -565,7 +614,7 @@ export class UpdateBuilder { * // INSERT DATA { ex:person1 foaf:name "Alice" . } * ``` */ - build(): SparqlUpdate { + build(): SparqlUpdateType { const operations: string[] = [] for (const op of this.state.operations) { @@ -611,13 +660,17 @@ export class UpdateBuilder { } case 'CLEAR': { - const target = op.graph === 'DEFAULT' || op.graph === 'NAMED' || op.graph === 'ALL' ? op.graph : `GRAPH ${op.graph}` + const target = op.graph === 'DEFAULT' || op.graph === 'NAMED' || op.graph === 'ALL' + ? op.graph + : `GRAPH ${op.graph}` operations.push(`CLEAR ${silent}${target}`) break } case 'DROP': { - const target = op.graph === 'DEFAULT' || op.graph === 'NAMED' || op.graph === 'ALL' ? op.graph : `GRAPH ${op.graph}` + const target = op.graph === 'DEFAULT' || op.graph === 'NAMED' || op.graph === 'ALL' + ? op.graph + : `GRAPH ${op.graph}` operations.push(`DROP ${silent}${target}`) break } @@ -652,7 +705,6 @@ export class UpdateBuilder { return updateDocument(operations.join(';\n')) } - } // ============================================================================ @@ -667,17 +719,21 @@ export class UpdateBuilder { * to return to the main UpdateBuilder. */ class ModifyBuilder { - private readonly updateState: UpdateState - private readonly deleteTemplate: PatternValue | undefined - private readonly insertTemplate: PatternValue | undefined - private readonly wherePatterns: PatternValue[] + /** Shared update-document state used while constructing a MODIFY operation. */ + private readonly updateState: UpdateStateType + /** DELETE template patterns accumulated for the current MODIFY builder. */ + private readonly deleteTemplate: PatternValueType | undefined + /** INSERT template patterns accumulated for the current MODIFY builder. */ + private readonly insertTemplate: PatternValueType | undefined + /** WHERE graph patterns accumulated for the current MODIFY operation. */ + private readonly wherePatterns: PatternValueType[] /** Creates one DELETE/INSERT/WHERE sub-builder tied to the immutable parent update sequence. */ constructor( - updateState: UpdateState, - deleteTemplate?: PatternValue, - insertTemplate?: PatternValue, - wherePatterns: PatternValue[] = [], + updateState: UpdateStateType, + deleteTemplate?: PatternValueType, + insertTemplate?: PatternValueType, + wherePatterns: PatternValueType[] = [], ) { this.updateState = updateState this.deleteTemplate = deleteTemplate @@ -701,12 +757,12 @@ class ModifyBuilder { * .done() * ``` */ - delete(template: PatternValue): ModifyBuilder { + delete(template: PatternValueType): ModifyBuilder { return new ModifyBuilder( this.updateState, template, this.insertTemplate, - this.wherePatterns + this.wherePatterns, ) } @@ -727,12 +783,12 @@ class ModifyBuilder { * .done() * ``` */ - insert(template: PatternValue): ModifyBuilder { + insert(template: PatternValueType): ModifyBuilder { return new ModifyBuilder( this.updateState, this.deleteTemplate, template, - this.wherePatterns + this.wherePatterns, ) } @@ -755,12 +811,12 @@ class ModifyBuilder { * .done() * ``` */ - where(pattern: PatternValue): ModifyBuilder { + where(pattern: PatternValueType): ModifyBuilder { return new ModifyBuilder( this.updateState, this.deleteTemplate, this.insertTemplate, - [...this.wherePatterns, pattern] + [...this.wherePatterns, pattern], ) } @@ -786,7 +842,7 @@ class ModifyBuilder { */ done(): UpdateBuilder { const whereValue = this.wherePatterns.length > 0 - ? rawPattern(this.wherePatterns.map(p => p.value).join('\n ')) + ? rawPattern(this.wherePatterns.map((p) => p.value).join('\n ')) : undefined return new UpdateBuilder({ @@ -797,8 +853,8 @@ class ModifyBuilder { ...(this.deleteTemplate ? { deleteTemplate: this.deleteTemplate } : {}), ...(this.insertTemplate ? { insertTemplate: this.insertTemplate } : {}), ...(whereValue ? { where: whereValue } : {}), - } - ] + }, + ], }) } } @@ -840,7 +896,7 @@ export const update = UpdateBuilder.create * ])).build() * ``` */ -export function insert(data: PatternValue, graph?: IriInput): UpdateBuilder { +export function insert(data: PatternValueType, graph?: IriInputType): UpdateBuilder { return UpdateBuilder.create().insertData(data, graph) } @@ -858,7 +914,7 @@ export function insert(data: PatternValue, graph?: IriInput): UpdateBuilder { * deleteOp(triple('ex:person1', 'foaf:age', num(30))).build() * ``` */ -export function deleteOp(data: PatternValue, graph?: IriInput): UpdateBuilder { +export function deleteOp(data: PatternValueType, graph?: IriInputType): UpdateBuilder { return UpdateBuilder.create().deleteData(data, graph) } diff --git a/packages/sparql/update_test.ts b/packages/sparql/update_test.ts index aa17900..e3226d1 100644 --- a/packages/sparql/update_test.ts +++ b/packages/sparql/update_test.ts @@ -38,7 +38,6 @@ describe('@okikio/sparql update builder', () => { ].join(';\n')) }) - it('accepts RDF named nodes for CLEAR/DROP and rejects variable terms in strict graph positions', () => { const graph = namedNode('urn:graph:products') expect(update().clear(graph).drop(graph, true).build().value).toBe([ diff --git a/packages/sparql/utils.ts b/packages/sparql/utils.ts index e96c2d5..ae00c2f 100644 --- a/packages/sparql/utils.ts +++ b/packages/sparql/utils.ts @@ -19,37 +19,41 @@ * @module */ -import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode, type Term as RdfTerm } from '@okikio/rdf' +import { + isTerm as isRdfTerm, + type NamedNode as RdfNamedNode, + type Term as RdfTerm, +} from '@okikio/rdf' import { convertValue, + type IriInputType, + isIRIRefToken, isSparqlValue, normalizeVariableName, + type PatternValueType, + type PredicateInputType, + type PrefixNameType, raw, rawPattern, rawTerm, + SPARQL_EXPR_BRAND, + SPARQL_PATTERN_BRAND, + SPARQL_TERM_BRAND, + SPARQL_VALUE_BRAND, + type SparqlExprType, + type SparqlInterpolatableType, + type SparqlTermType, + type SparqlValueType, strlit, - validateVariableName, - variable, toPredicateToken, toVarOrIriRef, toVarToken, - validatePrefixName, validateIRI, - isIRIRefToken, - SPARQL_VALUE_BRAND, - SPARQL_EXPR_BRAND, - SPARQL_TERM_BRAND, - SPARQL_PATTERN_BRAND, - type PrefixName, - type VariableName, - type SparqlValue, - type SparqlInterpolatable, - type SparqlExpr, - type SparqlTerm, - type PatternValue, - type IriInput, - type PredicateInput, + validatePrefixName, + validateVariableName, + variable, + type VariableNameType, } from './sparql.ts' // ============================================================================ @@ -76,9 +80,9 @@ import { * ``` */ export function values( - varName: VariableName, - items: SparqlInterpolatable[] -): PatternValue { + varName: VariableNameType, + items: SparqlInterpolatableType[], +): PatternValueType { const _var = toVarToken(varName) const converted = items.map((item) => convertValue(item)).join(' ') return rawPattern(`VALUES ${_var} { ${converted} }`) @@ -106,7 +110,7 @@ export function values( * // FILTER(?age >= 18 && REGEX(?name, "^Spider")) * ``` */ -export function filter(expression: SparqlExpr): PatternValue { +export function filter(expression: SparqlExprType): PatternValueType { return rawPattern(`FILTER(${expression.value})`) } @@ -131,7 +135,7 @@ export function filter(expression: SparqlExpr): PatternValue { * ])) * ``` */ -export function optional(pattern: PatternValue): PatternValue { +export function optional(pattern: PatternValueType): PatternValueType { return rawPattern(`OPTIONAL { ${pattern.value} }`) } @@ -154,7 +158,10 @@ export function optional(pattern: PatternValue): PatternValue { * // BIND(2024 - ?birthYear AS ?age) * ``` */ -export function bind(expression: SparqlExpr | SparqlTerm, varName: VariableName): PatternValue { +export function bind( + expression: SparqlExprType | SparqlTermType, + varName: VariableNameType, +): PatternValueType { const normalized = toVarToken(varName) return rawPattern(`BIND(${expression.value} AS ${normalized})`) } @@ -171,7 +178,7 @@ export function bind(expression: SparqlExpr | SparqlTerm, varName: VariableName) * // EXISTS { ?person foaf:email ?anyEmail } * ``` */ -export function exists(pattern: PatternValue): SparqlExpr { +export function exists(pattern: PatternValueType): SparqlExprType { return raw(`EXISTS { ${pattern.value} }`) } @@ -186,11 +193,10 @@ export function exists(pattern: PatternValue): SparqlExpr { * // NOT EXISTS { ?person foaf:email ?email } * ``` */ -export function notExists(pattern: PatternValue): SparqlExpr { +export function notExists(pattern: PatternValueType): SparqlExprType { return raw(`NOT EXISTS { ${pattern.value} }`) } - // ============================================================================ // Expression Helpers // ============================================================================ @@ -199,9 +205,9 @@ export function notExists(pattern: PatternValue): SparqlExpr { * Values that can be used in SPARQL expressions. * * These are the building blocks: literals, numbers, dates, and already-constructed - * SparqlValue objects. Most expression helpers accept these types. + * SparqlValueType objects. Most expression helpers accept these types. */ -export type ExpressionPrimitive = +export type ExpressionPrimitiveType = | string | number | boolean @@ -213,15 +219,15 @@ export type ExpressionPrimitive = /** * Convert a value to SPARQL for use in expressions. * - * - SparqlValue objects pass through unchanged + * - SparqlValueType objects pass through unchanged * - Primitives are converted using convertValue (escaped and typed) * * This is the key function that ensures data values are properly escaped - * while syntax elements (already wrapped as SparqlValue) pass through. + * while syntax elements (already wrapped as SparqlValueType) pass through. */ export function exprTerm( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { + value: SparqlValueType | ExpressionPrimitiveType, +): SparqlValueType { if (isSparqlValue(value)) { return value } @@ -232,7 +238,7 @@ export function exprTerm( * Get the raw SPARQL string for a value. */ export function exprTermString( - value: SparqlValue | ExpressionPrimitive, + value: SparqlValueType | ExpressionPrimitiveType, ): string { return exprTerm(value).value } @@ -247,14 +253,14 @@ export function exprTermString( * For now we focus on triple positions; you can extend this later if * you want to validate GRAPH names etc. */ -export type TermPosition = 'subject' | 'object' | 'graph' +export type TermPositionType = 'subject' | 'object' | 'graph' /** * Very small SPARQL-style validator for GraphNode/VarOrTerm lexicals. * * We lean on the fact that `exprTerm()` has already: * - turned primitives into valid literals/IRIs - * - left SparqlValue.value as-is when it represents syntax + * - left SparqlValueType.value as-is when it represents syntax * * So here we just check that the lexical form looks like: * - variable (?x, $x) @@ -363,17 +369,17 @@ export function isGraphNodeLexical(lex: string): boolean { * a valid SPARQL term (e.g. STR(...), CONCAT(...), BNODE()). */ export function termString( - value: SparqlTerm | ExpressionPrimitive, - position: TermPosition = 'object', + value: SparqlTermType | ExpressionPrimitiveType, + position: TermPositionType = 'object', ): string { const lex = exprTermString(value) if (!isGraphNodeLexical(lex)) { throw new Error( `Invalid ${position} term "${lex}". Triple ${position}s must be variables, ` + - `IRIs, blank node labels, literals, prefixed names, or SPARQL 1.2 triple forms. ` + - `Use BIND(...) / FILTER(...) to compute a value (e.g. STR(), CONCAT(), ` + - `BNODE()) and then use the bound variable in the triple.`, + `IRIs, blank node labels, literals, prefixed names, or SPARQL 1.2 triple forms. ` + + `Use BIND(...) / FILTER(...) to compute a value (e.g. STR(), CONCAT(), ` + + `BNODE()) and then use the bound variable in the triple.`, ) } @@ -398,13 +404,13 @@ export function termString( * ``` */ export function concat( - ...args: Array -): FluentExpr { + ...args: Array +): FluentExprType { if (args.length === 0) { return fluent(strlit('')) } - const inner = args.map(a => exprTermString(a)).join(', ') + const inner = args.map((a) => exprTermString(a)).join(', ') return fluent(raw(`CONCAT(${inner})`)) } @@ -414,7 +420,7 @@ export function concat( * Forces conversion to string representation. Useful when you need to ensure * a value is treated as a string for comparison or manipulation. */ -export function str(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function str(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`STR(${exprTermString(value)})`)) } @@ -424,22 +430,22 @@ export function str(value: SparqlValue | ExpressionPrimitive): FluentExpr { * Returns the character count. Note that this counts Unicode characters, not bytes. */ export function strlen( - value: SparqlValue | ExpressionPrimitive, -): FluentExpr { + value: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`STRLEN(${exprTermString(value)})`)) } /** * Convert string to uppercase. */ -export function ucase(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function ucase(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`UCASE(${exprTermString(value)})`)) } /** * Convert string to lowercase. */ -export function lcase(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function lcase(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`LCASE(${exprTermString(value)})`)) } @@ -456,9 +462,9 @@ export function lcase(value: SparqlValue | ExpressionPrimitive): FluentExpr { * ``` */ export function contains( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw( `CONTAINS(${exprTermString(text)}, ${exprTermString(pattern)})`, ) @@ -470,9 +476,9 @@ export function contains( * Case-sensitive prefix check. */ export function startsWith( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw( `STRSTARTS(${exprTermString(text)}, ${exprTermString(pattern)})`, ) @@ -480,9 +486,9 @@ export function startsWith( /** Alias for {@link startsWith} (matches SPARQL function name). */ export function strstarts( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return startsWith(text, pattern) } @@ -492,9 +498,9 @@ export function strstarts( * Case-sensitive suffix check. */ export function endsWith( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw( `STRENDS(${exprTermString(text)}, ${exprTermString(pattern)})`, ) @@ -502,9 +508,9 @@ export function endsWith( /** Alias for {@link endsWith} (matches SPARQL function name). */ export function strends( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return endsWith(text, pattern) } @@ -526,10 +532,10 @@ export function strends( * ``` */ export function regex( - text: SparqlValue | ExpressionPrimitive, + text: SparqlValueType | ExpressionPrimitiveType, pattern: string, flags?: string, -): SparqlExpr { +): SparqlExprType { const textStr = exprTermString(text) const patternStr = exprTermString(pattern) @@ -560,10 +566,10 @@ export function regex( * ``` */ export function substr( - text: SparqlValue | ExpressionPrimitive, - start: SparqlValue | ExpressionPrimitive, - length?: SparqlValue | ExpressionPrimitive, -): FluentExpr { + text: SparqlValueType | ExpressionPrimitiveType, + start: SparqlValueType | ExpressionPrimitiveType, + length?: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { const textStr = exprTermString(text) const startStr = exprTermString(start) @@ -594,11 +600,11 @@ export function substr( * ``` */ export function replaceStr( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, - replacement: SparqlValue | ExpressionPrimitive, + text: SparqlValueType | ExpressionPrimitiveType, + pattern: SparqlValueType | ExpressionPrimitiveType, + replacement: SparqlValueType | ExpressionPrimitiveType, flags?: string, -): FluentExpr { +): FluentExprType { const textStr = exprTermString(text) const patternStr = exprTermString(pattern) const replacementStr = exprTermString(replacement) @@ -630,9 +636,9 @@ export function replaceStr( * ``` */ export function strBefore( - text: SparqlValue | ExpressionPrimitive, - match: SparqlValue | ExpressionPrimitive, -): FluentExpr { + text: SparqlValueType | ExpressionPrimitiveType, + match: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { const textTerm = exprTermString(text) const matchTerm = exprTermString(match) return fluent(raw(`STRBEFORE(${textTerm}, ${matchTerm})`)) @@ -657,9 +663,9 @@ export function strBefore( * ``` */ export function strAfter( - text: SparqlValue | ExpressionPrimitive, - match: SparqlValue | ExpressionPrimitive, -): FluentExpr { + text: SparqlValueType | ExpressionPrimitiveType, + match: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { const textTerm = exprTermString(text) const matchTerm = exprTermString(match) return fluent(raw(`STRAFTER(${textTerm}, ${matchTerm})`)) @@ -678,11 +684,11 @@ export function strAfter( * ``` */ export function ifElse( - condition: SparqlValue, - whenTrue: SparqlValue | ExpressionPrimitive, - whenFalse: SparqlValue | ExpressionPrimitive, -): FluentExpr { - const trueTerm = exprTermString(whenTrue); + condition: SparqlValueType, + whenTrue: SparqlValueType | ExpressionPrimitiveType, + whenFalse: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { + const trueTerm = exprTermString(whenTrue) const falseTerm = exprTermString(whenFalse) return fluent(raw( `IF(${condition.value}, ${trueTerm}, ${falseTerm})`, @@ -695,69 +701,69 @@ export function ifElse( /** Add two numbers. */ export function add( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): FluentExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`${exprTermString(left)} + ${exprTermString(right)}`)) } /** Subtract two numbers. */ export function sub( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): FluentExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`${exprTermString(left)} - ${exprTermString(right)}`)) } /** Multiply two numbers. */ export function mul( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): FluentExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`${exprTermString(left)} * ${exprTermString(right)}`)) } /** Divide two numbers. */ export function div( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): FluentExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`${exprTermString(left)} / ${exprTermString(right)}`)) } /** Modulo operation (remainder after division). */ export function mod( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): FluentExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`(${exprTermString(left)} % ${exprTermString(right)})`)) } /** Absolute value. */ export function abs( - value: SparqlValue | ExpressionPrimitive, -): FluentExpr { + value: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`ABS(${exprTermString(value)})`)) } /** Round to nearest integer. */ export function round( - value: SparqlValue | ExpressionPrimitive, -): FluentExpr { + value: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`ROUND(${exprTermString(value)})`)) } /** Round up to next integer. */ export function ceil( - value: SparqlValue | ExpressionPrimitive, -): FluentExpr { + value: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`CEIL(${exprTermString(value)})`)) } /** Round down to previous integer. */ export function floor( - value: SparqlValue | ExpressionPrimitive, -): FluentExpr { + value: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`FLOOR(${exprTermString(value)})`)) } @@ -767,49 +773,49 @@ export function floor( /** Equal to. */ export function eq( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} = ${exprTermString(right)}`) } /** Not equal to. */ export function neq( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} != ${exprTermString(right)}`) } /** Greater than. */ export function gt( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} > ${exprTermString(right)}`) } /** Greater than or equal to. */ export function gte( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} >= ${exprTermString(right)}`) } /** Less than. */ export function lt( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} < ${exprTermString(right)}`) } /** Less than or equal to. */ export function lte( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + left: SparqlValueType | ExpressionPrimitiveType, + right: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { return raw(`${exprTermString(left)} <= ${exprTermString(right)}`) } @@ -830,8 +836,8 @@ export function lte( * ``` */ export function isNull( - value: SparqlValue, -): SparqlExpr { + value: SparqlValueType, +): SparqlExprType { return raw(`!BOUND(${value.value})`) } @@ -841,36 +847,36 @@ export function isNull( * Opposite of isNull - checks if a variable has a value. */ export function isNotNull( - value: SparqlValue, -): SparqlExpr { + value: SparqlValueType, +): SparqlExprType { return raw(`BOUND(${value.value})`) } /** Check if a variable is bound. Basically the same thing as {@link isNotNull} */ export function bound( - variable: SparqlValue, -): SparqlExpr { + variable: SparqlValueType, +): SparqlExprType { return raw(`BOUND(${variable.value})`) } /** Check if a term is an IRI. */ export function isIri( - term: SparqlValue, -): SparqlExpr { + term: SparqlValueType, +): SparqlExprType { return raw(`isIRI(${term.value})`) } /** Check if a term is a blank node. */ export function isBlank( - term: SparqlValue, -): SparqlExpr { + term: SparqlValueType, +): SparqlExprType { return raw(`isBlank(${term.value})`) } /** Check if a term is a literal. */ export function isLiteral( - term: SparqlValue, -): SparqlExpr { + term: SparqlValueType, +): SparqlExprType { return raw(`isLiteral(${term.value})`) } @@ -895,12 +901,12 @@ export function isLiteral( * ``` */ export function and( - ...conditions: SparqlValue[] -): SparqlExpr { + ...conditions: SparqlValueType[] +): SparqlExprType { const filtered = conditions.filter(Boolean) if (filtered.length === 0) throw new Error('and() requires at least one condition') - if (filtered.length === 1) return filtered[0] as SparqlExpr + if (filtered.length === 1) return filtered[0] as SparqlExprType return raw(filtered.map((c) => `(${c.value})`).join(' && ')) } @@ -921,12 +927,12 @@ export function and( * ``` */ export function or( - ...conditions: SparqlValue[] -): SparqlExpr { + ...conditions: SparqlValueType[] +): SparqlExprType { const filtered = conditions.filter(Boolean) if (filtered.length === 0) throw new Error('or() requires at least one condition') - if (filtered.length === 1) return filtered[0] as SparqlExpr + if (filtered.length === 1) return filtered[0] as SparqlExprType return raw(conditions.map((c) => `(${c.value})`).join(' || ')) } @@ -936,7 +942,7 @@ export function or( * * Flips true to false and false to true. */ -export function not(condition: SparqlValue): SparqlExpr { +export function not(condition: SparqlValueType): SparqlExprType { return raw(`!(${condition.value})`) } @@ -956,9 +962,9 @@ export function not(condition: SparqlValue): SparqlExpr { * ``` */ export function inList( - expr: SparqlValue | ExpressionPrimitive, - values: Array, -): SparqlExpr { + expr: SparqlValueType | ExpressionPrimitiveType, + values: Array, +): SparqlExprType { if (values.length === 0) { return raw('false') } @@ -972,9 +978,9 @@ export function inList( * Opposite of inList - returns true if the value doesn't match any list item. */ export function notInList( - expr: SparqlValue | ExpressionPrimitive, - values: Array, -): SparqlExpr { + expr: SparqlValueType | ExpressionPrimitiveType, + values: Array, +): SparqlExprType { if (values.length === 0) { return raw('true') } @@ -994,10 +1000,10 @@ export function notInList( * ``` */ export function between( - expr: SparqlValue | ExpressionPrimitive, - low: SparqlValue | ExpressionPrimitive, - high: SparqlValue | ExpressionPrimitive, -): SparqlExpr { + expr: SparqlValueType | ExpressionPrimitiveType, + low: SparqlValueType | ExpressionPrimitiveType, + high: SparqlValueType | ExpressionPrimitiveType, +): SparqlExprType { const exprTerm = exprTermString(expr) const lowTerm = exprTermString(low) const highTerm = exprTermString(high) @@ -1017,8 +1023,8 @@ export function between( * ``` */ export function coalesce( - ...values: Array -): FluentExpr { + ...values: Array +): FluentExprType { if (values.length === 0) { return fluent(strlit('')) } @@ -1032,7 +1038,7 @@ export function coalesce( * - Represents the SPARQL `BNODE()` function, which creates * a fresh blank node per evaluation. */ -export function bnodeFn(): SparqlExpr { +export function bnodeFn(): SparqlExprType { return raw('BNODE()') } @@ -1071,66 +1077,110 @@ export function bnodeFn(): SparqlExpr { * ) * ``` */ -export interface FluentExpr extends SparqlExpr { +export interface FluentExprType extends SparqlExprType { // Comparison operators - eq(other: SparqlValue | ExpressionPrimitive): SparqlExpr - neq(other: SparqlValue | ExpressionPrimitive): SparqlExpr - lt(other: SparqlValue | ExpressionPrimitive): SparqlExpr - lte(other: SparqlValue | ExpressionPrimitive): SparqlExpr - gt(other: SparqlValue | ExpressionPrimitive): SparqlExpr - gte(other: SparqlValue | ExpressionPrimitive): SparqlExpr + /** Builds an equality expression between this expression and the supplied value. */ + eq(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds an inequality expression between this expression and the supplied value. */ + neq(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds a less-than comparison expression. */ + lt(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds a less-than-or-equal comparison expression. */ + lte(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds a greater-than comparison expression. */ + gt(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds a greater-than-or-equal comparison expression. */ + gte(other: SparqlValueType | ExpressionPrimitiveType): SparqlExprType // Arithmetic operators - add(other: SparqlValue | ExpressionPrimitive): FluentExpr - sub(other: SparqlValue | ExpressionPrimitive): FluentExpr - mul(other: SparqlValue | ExpressionPrimitive): FluentExpr - div(other: SparqlValue | ExpressionPrimitive): FluentExpr - mod(other: SparqlValue | ExpressionPrimitive): FluentExpr + /** Adds to FluentExprType while maintaining its indexes and semantic invariants. */ + add(other: SparqlValueType | ExpressionPrimitiveType): FluentExprType + /** Builds numeric subtraction with this expression on the left. */ + sub(other: SparqlValueType | ExpressionPrimitiveType): FluentExprType + /** Builds numeric multiplication with this expression on the left. */ + mul(other: SparqlValueType | ExpressionPrimitiveType): FluentExprType + /** Builds numeric division with this expression on the left. */ + div(other: SparqlValueType | ExpressionPrimitiveType): FluentExprType + /** Builds the SPARQL remainder expression for this value. */ + mod(other: SparqlValueType | ExpressionPrimitiveType): FluentExprType // String functions - concat(...others: Array): FluentExpr - contains(substring: SparqlValue | ExpressionPrimitive): SparqlExpr - startsWith(prefix: SparqlValue | ExpressionPrimitive): SparqlExpr - endsWith(suffix: SparqlValue | ExpressionPrimitive): SparqlExpr - regex(pattern: string, flags?: string): SparqlExpr - strlen(): FluentExpr - ucase(): FluentExpr - lcase(): FluentExpr - substr(start: SparqlValue | ExpressionPrimitive, length?: SparqlValue | ExpressionPrimitive): FluentExpr - replace(pattern: SparqlValue | ExpressionPrimitive, replacement: SparqlValue | ExpressionPrimitive, flags?: string): FluentExpr - strBefore(match: SparqlValue | ExpressionPrimitive): FluentExpr - strAfter(match: SparqlValue | ExpressionPrimitive): FluentExpr + /** Builds CONCAT with this expression as the first argument. */ + concat(...others: Array): FluentExprType + /** Builds a CONTAINS string predicate for this expression. */ + contains(substring: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds STRSTARTS for this expression and the supplied prefix. */ + startsWith(prefix: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds STRENDS for this expression and the supplied suffix. */ + endsWith(suffix: SparqlValueType | ExpressionPrimitiveType): SparqlExprType + /** Builds a REGEX predicate with optional SPARQL regular-expression flags. */ + regex(pattern: string, flags?: string): SparqlExprType + /** Builds STRLEN for this expression. */ + strlen(): FluentExprType + /** Builds UCASE for this expression. */ + ucase(): FluentExprType + /** Builds LCASE for this expression. */ + lcase(): FluentExprType + /** Builds SUBSTR using SPARQL one-based start and optional length arguments. */ + substr( + start: SparqlValueType | ExpressionPrimitiveType, + length?: SparqlValueType | ExpressionPrimitiveType, + ): FluentExprType + /** Builds REPLACE with the supplied pattern, replacement, and optional flags. */ + replace( + pattern: SparqlValueType | ExpressionPrimitiveType, + replacement: SparqlValueType | ExpressionPrimitiveType, + flags?: string, + ): FluentExprType + /** Builds STRBEFORE for this expression and the supplied delimiter. */ + strBefore(match: SparqlValueType | ExpressionPrimitiveType): FluentExprType + /** Builds STRAFTER for this expression and the supplied delimiter. */ + strAfter(match: SparqlValueType | ExpressionPrimitiveType): FluentExprType // Type checking - isNull(): SparqlExpr - isNotNull(): SparqlExpr - isIri(): SparqlExpr - isBlank(): SparqlExpr - isLiteral(): SparqlExpr - bound(): SparqlExpr + /** Builds the project null-test expression used by object-pattern helpers. */ + isNull(): SparqlExprType + /** Builds the negated project null-test expression used by object-pattern helpers. */ + isNotNull(): SparqlExprType + /** Builds ISIRI for this expression. */ + isIri(): SparqlExprType + /** Builds ISBLANK for this expression. */ + isBlank(): SparqlExprType + /** Builds ISLITERAL for this expression. */ + isLiteral(): SparqlExprType + /** Builds BOUND for this variable-compatible expression. */ + bound(): SparqlExprType // Logical operators - and(other: SparqlValue): SparqlExpr - or(other: SparqlValue): SparqlExpr - not(): SparqlExpr + /** Builds logical conjunction with this expression on the left. */ + and(other: SparqlValueType): SparqlExprType + /** Builds logical disjunction with this expression on the left. */ + or(other: SparqlValueType): SparqlExprType + /** Builds logical negation of this expression. */ + not(): SparqlExprType // Math functions - abs(): FluentExpr - round(): FluentExpr - ceil(): FluentExpr - floor(): FluentExpr + /** Builds ABS for this numeric expression. */ + abs(): FluentExprType + /** Builds ROUND for this numeric expression. */ + round(): FluentExprType + /** Builds CEIL for this numeric expression. */ + ceil(): FluentExprType + /** Builds FLOOR for this numeric expression. */ + floor(): FluentExprType // Utility - as(variable: VariableName): SparqlExpr + /** Aliases this expression to the supplied SPARQL variable for projection. */ + as(variable: VariableNameType): SparqlExprType } /** * Create a fluent value with chainable methods. * - * Wraps any SparqlValue to add method chaining. This lets you write expressions + * Wraps any SparqlValueType to add method chaining. This lets you write expressions * more naturally with dot notation instead of nested function calls. * - * @param value SparqlValue to enhance + * @param value SparqlValueType to enhance * @returns FluentValue with chainable methods * * @example @@ -1144,11 +1194,12 @@ export interface FluentExpr extends SparqlExpr { * fluent(v('price')).mul(1.1).add(5) * ``` */ -export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { - if (SPARQL_PATTERN_BRAND in value && value[SPARQL_PATTERN_BRAND]) - throw new Error(`Cannot convert pattern value "${value}" to a fluent expression`); +export function fluent(value: SparqlTermType | SparqlExprType): FluentExprType { + if (SPARQL_PATTERN_BRAND in value && value[SPARQL_PATTERN_BRAND]) { + throw new Error(`Cannot convert pattern value "${value}" to a fluent expression`) + } - const result: FluentExpr = { + const result: FluentExprType = { ...Object.assign(value, { [SPARQL_TERM_BRAND]: false }), [SPARQL_EXPR_BRAND]: true, @@ -1178,7 +1229,8 @@ export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { ucase: () => fluent(ucase(result)), lcase: () => fluent(lcase(result)), substr: (start, length) => fluent(substr(result, start, length)), - replace: (pattern, replacement, flags) => fluent(replaceStr(result, pattern, replacement, flags)), + replace: (pattern, replacement, flags) => + fluent(replaceStr(result, pattern, replacement, flags)), strBefore: (match) => fluent(strBefore(result, match)), strAfter: (match) => fluent(strAfter(result, match)), @@ -1207,7 +1259,7 @@ export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { validateVariableName(varName) // Use the *current* expression and wrap as required by SPARQL return raw(`(${result.value} AS ?${varName})`) - } + }, } return result @@ -1250,21 +1302,21 @@ export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { * ) * ``` */ -export function v(name: string): FluentExpr { +export function v(name: string): FluentExprType { return fluent(variable(name)) } /** Get the language tag of a literal. */ export function getlang( - literal: SparqlValue | ExpressionPrimitive, -): FluentExpr { + literal: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`LANG(${exprTermString(literal)})`)) } /** Get the datatype IRI of a literal. */ export function datatype( - literal: SparqlValue | ExpressionPrimitive, -): FluentExpr { + literal: SparqlValueType | ExpressionPrimitiveType, +): FluentExprType { return fluent(raw(`DATATYPE(${exprTermString(literal)})`)) } @@ -1279,27 +1331,31 @@ export function datatype( * used with GROUP BY clauses. The `.as()` method lets you assign the result * to a variable. */ -export interface AggregationExpression extends SparqlExpr { - as(variable: string): SparqlExpr +export interface AggregationExpressionType extends SparqlExprType { + /** Aliases this aggregate expression to the supplied SPARQL variable for projection. */ + as(variable: string): SparqlExprType } /** * Internal helper to create aggregation expressions. */ -function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPrimitive): AggregationExpression { +function createAggregation( + sparqlFunc: string, + expr?: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { const exprStr = expr ? exprTermString(expr) : '*' const baseValue = `${sparqlFunc}(${exprStr})` - const result: AggregationExpression = { + const result: AggregationExpressionType = { [SPARQL_VALUE_BRAND]: true, [SPARQL_EXPR_BRAND]: true, value: baseValue, /** Wraps this aggregation as `(expression AS ?variable)` for SELECT projection grammar. */ - as(variable: string): SparqlExpr { + as(variable: string): SparqlExprType { const varName = toVarToken(variable) // SPARQL 1.1 requires (Expression AS ?var) in SELECT return raw(`(${baseValue} AS ${varName})`) - } + }, } return result @@ -1324,8 +1380,8 @@ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPr * ``` */ export function count( - expr?: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr?: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('COUNT', expr) } @@ -1341,8 +1397,8 @@ export function count( * ``` */ export function countDistinct( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { const exprStr = exprTermString(expr) // Treat DISTINCT … as raw SPARQL, not a literal return createAggregation('COUNT', raw(`DISTINCT ${exprStr}`)) @@ -1360,29 +1416,29 @@ export function countDistinct( * ``` */ export function sum( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('SUM', expr) } /** Calculate average of numeric values. */ export function avg( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('AVG', expr) } /** Find minimum value. */ export function min( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('MIN', expr) } /** Find maximum value. */ export function max( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('MAX', expr) } @@ -1393,8 +1449,8 @@ export function max( * Useful for properties that should be the same across a group. */ export function sample( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { + expr: SparqlValueType | ExpressionPrimitiveType, +): AggregationExpressionType { return createAggregation('SAMPLE', expr) } @@ -1411,13 +1467,11 @@ export function sample( * ``` */ export function groupConcat( - expr: SparqlValue | ExpressionPrimitive, + expr: SparqlValueType | ExpressionPrimitiveType, separator?: string, -): AggregationExpression { +): AggregationExpressionType { const exprStr = exprTermString(expr) - const baseValue = separator - ? `${exprStr}; SEPARATOR=${exprTermString(separator)}` - : exprStr + const baseValue = separator ? `${exprStr}; SEPARATOR=${exprTermString(separator)}` : exprStr return createAggregation('GROUP_CONCAT', raw(baseValue)) } @@ -1426,7 +1480,6 @@ export function groupConcat( // GRAPH Patterns // ============================================================================ - // ============================================================================ // GRAPH Patterns // ============================================================================ @@ -1441,7 +1494,7 @@ export function groupConcat( * That means the graph identifier can be: * - A variable: `"g"`, `"?g"`, or `$g` → normalised to `?g` * - An IRI: `"http://example.org/data"` → `` - * - A prefixed name: `"ex:Graph"` + * - A prefixed name: `"ex:GraphTermType"` * * It will *not* accept GraphRefAll keywords like DEFAULT/NAMED/ALL here, * because those belong to the update grammar (`GraphRefAll`), not to @@ -1476,9 +1529,9 @@ export function groupConcat( * ``` */ export function graph( - graphIri: IriInput, - pattern: PatternValue, -): PatternValue { + graphIri: IriInputType, + pattern: PatternValueType, +): PatternValueType { const graphRef = toVarOrIriRef(graphIri) return rawPattern(`GRAPH ${graphRef} { ${pattern.value} }`) } @@ -1493,7 +1546,7 @@ export function graph( * `UNDEF` is valid in VALUES data blocks. It is not a general expression value * and must not be rewritten as a variable such as `?UNDEF`. */ -export function undef(): SparqlTerm { +export function undef(): SparqlTermType { return rawTerm('UNDEF') } @@ -1523,7 +1576,7 @@ export function undef(): SparqlTerm { * // Finds everyone in the org (including CEO themselves due to zero matches) * ``` */ -export function zeroOrMore(property: PredicateInput): SparqlTerm { +export function zeroOrMore(property: PredicateInputType): SparqlTermType { const prop = toPredicateToken(property) return rawTerm(`${prop}*`) } @@ -1532,7 +1585,7 @@ export function zeroOrMore(property: PredicateInput): SparqlTerm { * One or more path. * * Matches the property one or more times. Like + in regular expressions. - * Subject and object must be different (at least one hop required). + * SubjectTermType and object must be different (at least one hop required). * * @param property Property IRI * @@ -1549,7 +1602,7 @@ export function zeroOrMore(property: PredicateInput): SparqlTerm { * // Finds parents, grandparents, great-grandparents, etc. * ``` */ -export function oneOrMore(property: PredicateInput): SparqlTerm { +export function oneOrMore(property: PredicateInputType): SparqlTermType { const prop = toPredicateToken(property) return rawTerm(`${prop}+`) } @@ -1570,7 +1623,7 @@ export function oneOrMore(property: PredicateInput): SparqlTerm { * // Matches married and unmarried people * ``` */ -export function zeroOrOne(property: PredicateInput): SparqlTerm { +export function zeroOrOne(property: PredicateInputType): SparqlTermType { const prop = toPredicateToken(property) return rawTerm(`${prop}?`) } @@ -1596,7 +1649,7 @@ export function zeroOrOne(property: PredicateInput): SparqlTerm { * // Navigate: product → manufacturer → location → city * ``` */ -export function sequence(...properties: PredicateInput[]): SparqlTerm { +export function sequence(...properties: PredicateInputType[]): SparqlTermType { const props = properties.map(toPredicateToken) return rawTerm(props.join('/')) } @@ -1622,7 +1675,7 @@ export function sequence(...properties: PredicateInput[]): SparqlTerm { * // Gets name from any of these properties * ``` */ -export function alternative(...properties: PredicateInput[]): SparqlTerm { +export function alternative(...properties: PredicateInputType[]): SparqlTermType { const props = properties.map(toPredicateToken) return rawTerm(`(${props.join('|')})`) } @@ -1647,7 +1700,7 @@ export function alternative(...properties: PredicateInput[]): SparqlTerm { * // Reverse of: ?author schema:author ?book * ``` */ -export function inverse(property: PredicateInput): SparqlTerm { +export function inverse(property: PredicateInputType): SparqlTermType { const prop = toPredicateToken(property) return rawTerm(`^${prop}`) } @@ -1673,7 +1726,7 @@ export function inverse(property: PredicateInput): SparqlTerm { * // Gets data properties, not metadata * ``` */ -export function negatedPropertySet(...properties: PredicateInput[]): SparqlTerm { +export function negatedPropertySet(...properties: PredicateInputType[]): SparqlTermType { const props = properties.map(toPredicateToken) return rawTerm(`!(${props.join('|')})`) } @@ -1726,10 +1779,10 @@ export function negatedPropertySet(...properties: PredicateInput[]): SparqlTerm * ``` */ export function service( - endpoint: IriInput, - pattern: PatternValue, - silent = false -): PatternValue { + endpoint: IriInputType, + pattern: PatternValueType, + silent = false, +): PatternValueType { const endpointRef = toVarOrIriRef(endpoint) const silentModifier = silent ? 'SILENT ' : '' return rawPattern(`SERVICE ${silentModifier}${endpointRef} { ${pattern.value} }`) @@ -1779,7 +1832,7 @@ export function service( * const fullQuery = raw(`${prefixBlock}\n\n${query.build().value}`) * ``` */ -export function definePrefix(prefix: PrefixName, iri: string | RdfNamedNode): SparqlValue { +export function definePrefix(prefix: PrefixNameType, iri: string | RdfNamedNode): SparqlValueType { validatePrefixName(prefix) if (isRdfTerm(iri)) { @@ -1832,7 +1885,7 @@ export function definePrefix(prefix: PrefixName, iri: string | RdfNamedNode): Sp * // BIND(MD5(CONCAT(?firstName, ?lastName, ?birthDate)) AS ?personKey) * ``` */ -export function md5(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function md5(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`MD5(${exprTermString(value)})`)) } @@ -1855,7 +1908,7 @@ export function md5(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // BIND(SHA1(?documentText) AS ?contentHash) * ``` */ -export function sha1(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function sha1(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`SHA1(${exprTermString(value)})`)) } @@ -1881,7 +1934,7 @@ export function sha1(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // WHERE { ?user ex:password ?password } * ``` */ -export function sha256(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function sha256(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`SHA256(${exprTermString(value)})`)) } @@ -1903,7 +1956,7 @@ export function sha256(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // SHA384(?data) * ``` */ -export function sha384(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function sha384(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`SHA384(${exprTermString(value)})`)) } @@ -1927,7 +1980,7 @@ export function sha384(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // BIND(SHA512(?sensitiveData) AS ?secureHash) * ``` */ -export function sha512(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function sha512(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`SHA512(${exprTermString(value)})`)) } @@ -1972,7 +2025,7 @@ export function sha512(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // WHERE { ?person foaf:name ?name } * ``` */ -export function now(): SparqlValue { +export function now(): SparqlValueType { return raw('NOW()') } @@ -2008,7 +2061,7 @@ export function now(): SparqlValue { * // WHERE { ?subject ex:property ?value } * ``` */ -export function uuid(): SparqlValue { +export function uuid(): SparqlValueType { return raw('UUID()') } @@ -2042,7 +2095,7 @@ export function uuid(): SparqlValue { * // WHERE { ?user ex:loginTime NOW() } * ``` */ -export function struuid(): FluentExpr { +export function struuid(): FluentExprType { return fluent(raw('STRUUID()')) } @@ -2080,7 +2133,7 @@ export function struuid(): FluentExpr { * // ORDER BY (RAND() AS ?random) * ``` */ -export function rand(): FluentExpr { +export function rand(): FluentExprType { return fluent(raw('RAND()')) } @@ -2093,17 +2146,17 @@ export function rand(): FluentExpr { * * @example strdt(strlit('custom value'), 'http://example.org/datatype') */ -export function strdt(lexical: SparqlValue, datatype: SparqlValue): SparqlValue { +export function strdt(lexical: SparqlValueType, datatype: SparqlValueType): SparqlValueType { return raw(`STRDT(${lexical.value}, ${datatype.value})`) } /** Creates a SPARQL STRLANG expression from lexical text and a language tag. */ -export function strlang(lexical: SparqlValue, lang: string): SparqlValue { +export function strlang(lexical: SparqlValueType, lang: string): SparqlValueType { return raw(`STRLANG(${lexical.value}, ${exprTermString(lang)})`) } /** Creates a SPARQL sameTerm expression without JavaScript value coercion. */ -export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { +export function sameTerm(a: SparqlValueType, b: SparqlValueType): SparqlValueType { return raw(`sameTerm(${a.value}, ${b.value})`) } @@ -2141,7 +2194,7 @@ export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { * // BIND(IRI(CONCAT("http://example.org/person/", ENCODE_FOR_URI(?name))) AS ?personIri) * ``` */ -export function encodeForUri(value: SparqlValue | ExpressionPrimitive): FluentExpr { +export function encodeForUri(value: SparqlValueType | ExpressionPrimitiveType): FluentExprType { return fluent(raw(`ENCODE_FOR_URI(${exprTermString(value)})`)) } @@ -2180,9 +2233,9 @@ export function encodeForUri(value: SparqlValue | ExpressionPrimitive): FluentEx * ``` */ export function langMatches( - lang: SparqlValue | ExpressionPrimitive, - range: string -): SparqlValue { + lang: SparqlValueType | ExpressionPrimitiveType, + range: string, +): SparqlValueType { return raw(`langMatches(${exprTermString(lang)}, ${exprTermString(range)})`) } @@ -2230,7 +2283,7 @@ export function langMatches( * // } * ``` */ -export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { +export function iri(value: SparqlValueType | ExpressionPrimitiveType): SparqlValueType { return raw(`IRI(${exprTermString(value)})`) } @@ -2300,6 +2353,6 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * // } * ``` */ -export function minus(pattern: PatternValue): PatternValue { +export function minus(pattern: PatternValueType): PatternValueType { return rawPattern(`MINUS { ${pattern.value} }`) } diff --git a/packages/sparql/utils_test.ts b/packages/sparql/utils_test.ts index 18d1e17..22ad047 100644 --- a/packages/sparql/utils_test.ts +++ b/packages/sparql/utils_test.ts @@ -3,20 +3,20 @@ import { expect } from '@std/expect' import * as rdf from '@okikio/rdf' import { name, offers, price } from '@okikio/vocab/schema' import { - SPARQL_EXPR_BRAND, - SPARQL_PATTERN_BRAND, - SPARQL_TERM_BRAND, + definePrefix, exists, filter, - definePrefix, inverse, optional, prefixed, sequence, + SPARQL_EXPR_BRAND, + SPARQL_PATTERN_BRAND, + SPARQL_TERM_BRAND, triple, typed, - uri, undef, + uri, v, values, zeroOrMore, @@ -39,13 +39,21 @@ describe('@okikio/sparql grammar-role helpers', () => { }) it('returns term syntax for property paths', () => { - for (const path of [zeroOrMore('schema:parent'), inverse('schema:child'), sequence('schema:a', 'schema:b')]) { + for ( + const path of [ + zeroOrMore('schema:parent'), + inverse('schema:child'), + sequence('schema:a', 'schema:b'), + ] + ) { expect(path[SPARQL_TERM_BRAND]).toBe(true) } }) it('uses RDF named nodes directly in property paths', () => { expect(zeroOrMore(name).value).toBe('*') - expect(sequence(offers, price).value).toBe('/') + expect(sequence(offers, price).value).toBe( + '/', + ) expect(inverse(name).value).toBe('^') }) @@ -53,10 +61,11 @@ describe('@okikio/sparql grammar-role helpers', () => { const stringDatatype = rdf.namedNode(rdf.XSD.string) const schema = rdf.namedNode('https://schema.org/') - expect(typed('Widget', stringDatatype).value).toBe('"Widget"^^') + expect(typed('Widget', stringDatatype).value).toBe( + '"Widget"^^', + ) expect(uri(name).value).toBe('') expect(definePrefix('schema', schema).value).toBe('PREFIX schema: ') expect(prefixed('schema', 'name')[SPARQL_TERM_BRAND]).toBe(true) }) - }) -- 2.51.2