From 4481672034bddeb86f22159de18da40e6f0ae5cc Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Sun, 16 Aug 2026 18:51:49 -0400 Subject: [PATCH] refactor: refactor code structure for improved readability and maintainability Signed-off-by: Okiki Ojo --- builder.ts | 617 -------- deno.json | 54 + deno.lock | 16 - executor.ts | 1012 ------------ mise.toml | 28 +- mod.ts | 243 --- namespaces.ts | 1382 ----------------- package.json | 16 + packages/sparql/.npmignore | 4 + packages/sparql/README.md | 67 + packages/sparql/builder.ts | 381 +++++ packages/sparql/builder_bench.ts | 31 + packages/sparql/builder_test.ts | 81 + packages/sparql/client.ts | 45 + packages/sparql/client_test.ts | 14 + packages/sparql/composition_test.ts | 28 + packages/sparql/deno.json | 10 + packages/sparql/http/error.ts | 34 + packages/sparql/http/error_test.ts | 20 + packages/sparql/http/mod.ts | 241 +++ packages/sparql/http/mod_test.ts | 119 ++ packages/sparql/mod.ts | 21 + packages/sparql/package.json | 24 + packages/sparql/patterns/cypher.ts | 78 + packages/sparql/patterns/cypher_test.ts | 25 + .../sparql/patterns}/objects.ts | 338 ++-- packages/sparql/patterns/objects_test.ts | 25 + .../sparql/patterns}/triples.ts | 138 +- packages/sparql/patterns/triples_test.ts | 22 + packages/sparql/result/binding.ts | 14 + packages/sparql/result/binding_test.ts | 18 + packages/sparql/result/json.ts | 107 ++ packages/sparql/result/json_test.ts | 49 + sparql.ts => packages/sparql/sparql.ts | 337 ++-- packages/sparql/sparql_test.ts | 42 + packages/sparql/syntax/mod.ts | 40 + packages/sparql/syntax/mod_test.ts | 73 + packages/sparql/syntax/scan.ts | 232 +++ packages/sparql/syntax/scan_bench.ts | 20 + packages/sparql/syntax/scanner.ts | 721 +++++++++ packages/sparql/syntax/source.ts | 107 ++ packages/sparql/syntax/types.ts | 120 ++ update.ts => packages/sparql/update.ts | 433 +++--- packages/sparql/update_test.ts | 65 + utils.ts => packages/sparql/utils.ts | 622 ++++---- packages/sparql/utils_test.ts | 62 + patterns/cypher.ts | 123 -- pnpm-workspace.yaml | 2 + scripts/ttl-to-ts.ts | 448 ------ 49 files changed, 3965 insertions(+), 4784 deletions(-) delete mode 100644 builder.ts create mode 100644 deno.json delete mode 100644 deno.lock delete mode 100644 executor.ts delete mode 100644 mod.ts delete mode 100644 namespaces.ts create mode 100644 package.json create mode 100644 packages/sparql/.npmignore create mode 100644 packages/sparql/README.md create mode 100644 packages/sparql/builder.ts create mode 100644 packages/sparql/builder_bench.ts create mode 100644 packages/sparql/builder_test.ts create mode 100644 packages/sparql/client.ts create mode 100644 packages/sparql/client_test.ts create mode 100644 packages/sparql/composition_test.ts create mode 100644 packages/sparql/deno.json create mode 100644 packages/sparql/http/error.ts create mode 100644 packages/sparql/http/error_test.ts create mode 100644 packages/sparql/http/mod.ts create mode 100644 packages/sparql/http/mod_test.ts create mode 100644 packages/sparql/mod.ts create mode 100644 packages/sparql/package.json create mode 100644 packages/sparql/patterns/cypher.ts create mode 100644 packages/sparql/patterns/cypher_test.ts rename {patterns => packages/sparql/patterns}/objects.ts (77%) create mode 100644 packages/sparql/patterns/objects_test.ts rename {patterns => packages/sparql/patterns}/triples.ts (75%) create mode 100644 packages/sparql/patterns/triples_test.ts create mode 100644 packages/sparql/result/binding.ts create mode 100644 packages/sparql/result/binding_test.ts create mode 100644 packages/sparql/result/json.ts create mode 100644 packages/sparql/result/json_test.ts rename sparql.ts => packages/sparql/sparql.ts (84%) create mode 100644 packages/sparql/sparql_test.ts create mode 100644 packages/sparql/syntax/mod.ts create mode 100644 packages/sparql/syntax/mod_test.ts create mode 100644 packages/sparql/syntax/scan.ts create mode 100644 packages/sparql/syntax/scan_bench.ts create mode 100644 packages/sparql/syntax/scanner.ts create mode 100644 packages/sparql/syntax/source.ts create mode 100644 packages/sparql/syntax/types.ts rename update.ts => packages/sparql/update.ts (74%) create mode 100644 packages/sparql/update_test.ts rename utils.ts => packages/sparql/utils.ts (92%) create mode 100644 packages/sparql/utils_test.ts delete mode 100644 patterns/cypher.ts create mode 100644 pnpm-workspace.yaml delete mode 100644 scripts/ttl-to-ts.ts diff --git a/builder.ts b/builder.ts deleted file mode 100644 index 25b5630..0000000 --- a/builder.ts +++ /dev/null @@ -1,617 +0,0 @@ -/** - * Fluent query builder for SPARQL. - * - * Building SPARQL queries by concatenating strings gets messy fast. You lose type - * safety, formatting becomes inconsistent, and it's easy to make syntax errors. - * This builder gives you a chainable API inspired by Drizzle ORM. - * - * ## Security Model - * - * The builder distinguishes between SYNTAX and DATA VALUES: - * - * **Syntax elements** (validated, not escaped): - * - Variable names: `?name`, `?age` - * - Prefix names: `foaf`, `schema` - * - IRIs: `http://xmlns.com/foaf/0.1/` - * - Prefixed names: `foaf:name`, `rdf:type` - * - * **Data values** (escaped, type-annotated): - * - Strings passed to filter expressions - * - Values in BIND expressions - * - Literal values in patterns - * - * @module - */ - -import { - rawPattern, - validatePrefixName, - validateIRI, - isSparqlValue, - toRawString, - toVarToken, - toVarOrIriRef, - toGraphRef, - type SparqlValue, - type VariableName, - type PatternValue, - type SparqlTerm, - type SparqlExpr, -} from './sparql.ts' -import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult } from './executor.ts' -import { bind, filter, optional } from './utils.ts' - -// ============================================================================ -// Core Query Types -// ============================================================================ - -/** - * Pattern-like input for WHERE/OPTIONAL/UNION clauses. - */ -export type PatternLike = string | SparqlExpr | SparqlTerm - -/** - * Variables to select in query results. - */ -export type Projection = PatternLike[] | '*' - -/** - * Sort order for ORDER BY clauses. - */ -export type SortDirection = 'ASC' | 'DESC' - -/** - * Sort specification combining variable and direction. - */ -export interface SortSpec { - readonly variable: string - readonly direction?: SortDirection -} - -/** - * SELECT query modifiers. - */ -export type SelectModifier = 'none' | 'distinct' | 'reduced' - -// ============================================================================ -// Internal Helpers for Syntax vs Value Handling -// ============================================================================ - -/** - * Process a projection variable (for SELECT clause). - * - * Projection items can be: - * - Variable strings: "?name" or "name" → ?name - * - SparqlValue objects: passed through - * - Expressions with AS: already wrapped - */ -function processProjectionItem(item: PatternLike): string { - if (isSparqlValue(item)) return item.value - - // Plain string - treat as variable name - const str = item.trim() - - // Already looks like a variable - return toVarToken(str) -} - -/** - * Process an DESCRIBE statement. - */ -function processDescribeItem(item: PatternLike): string { - if (isSparqlValue(item)) return item.value - return toVarOrIriRef(item) // <- grammar helper -} - -// ============================================================================ -// Query Builder State -// ============================================================================ - -interface QueryState { - readonly type: 'SELECT' | 'ASK' | 'CONSTRUCT' | 'DESCRIBE' - readonly projection: Projection - readonly prefixes?: Map - readonly from?: string[] - readonly fromNamed?: string[] - readonly where: SparqlValue[] - readonly filters: SparqlValue[] - readonly optional: SparqlValue[] - readonly bindings: SparqlValue[] - readonly unions: SparqlValue[][] - readonly sorts: SortSpec[] - readonly limit?: number - readonly offset?: number - readonly modifier: SelectModifier - readonly groupBy?: string[] - readonly having?: SparqlValue[] - readonly values?: Map -} - -const initialState: QueryState = { - type: 'SELECT', - projection: '*', - where: [], - filters: [], - optional: [], - bindings: [], - unions: [], - sorts: [], - modifier: 'none', -} - -// ============================================================================ -// Query Builder -// ============================================================================ - -export class QueryBuilder { - private constructor(private readonly state: QueryState) { } - - /** - * Start a SELECT query. - */ - static select(projection: Projection = '*'): QueryBuilder { - return new QueryBuilder({ - ...initialState, - type: 'SELECT', - projection, - }) - } - - /** - * Start an ASK query. - */ - static ask(): QueryBuilder { - return new QueryBuilder({ - ...initialState, - type: 'ASK', - projection: [], - }) - } - - /** - * Start a CONSTRUCT query. - */ - static construct(template: SparqlValue): QueryBuilder { - return new QueryBuilder({ - ...initialState, - type: 'CONSTRUCT', - projection: [], - where: [template], - }) - } - - /** - * Start a DESCRIBE query. - */ - static describe(resources: PatternLike[]): QueryBuilder { - return new QueryBuilder({ - ...initialState, - type: 'DESCRIBE', - projection: resources, - }) - } - - // -------------------------------------------------------------------------- - // Clause builders - // -------------------------------------------------------------------------- - - /** - * Add a FROM clause to specify a named graph. - * - * @param graphIRI - Full IRI of the graph (validated) - */ - from(graphIRI: string | SparqlValue): QueryBuilder { - const graphRef = toGraphRef(graphIRI) - - return new QueryBuilder({ - ...this.state, - from: [...(this.state.from || []), graphRef], - }) - } - - /** - * Add FROM NAMED clause for named graph queries. - * - * @param graphIRI - Full IRI of the named graph (validated) - */ - fromNamed(graphIRI: string | SparqlValue): QueryBuilder { - const graphRef = toGraphRef(graphIRI) - - return new QueryBuilder({ - ...this.state, - fromNamed: [...(this.state.fromNamed || []), graphRef], - }) - } - - /** - * Declare a namespace prefix for abbreviated IRIs. - * - * Both the prefix name and IRI are validated to prevent injection attacks. - * - * @param name - Prefix name (e.g., "foaf", "schema") - validated - * @param iri - Full namespace IRI (e.g., "http://xmlns.com/foaf/0.1/") - validated - * - * @throws {Error} If prefix name contains invalid characters - * @throws {Error} If IRI is malformed or contains injection characters - * - * @example - * ```ts - * select(['?name']) - * .prefix('foaf', 'http://xmlns.com/foaf/0.1/') - * .where(triple('?person', 'foaf:name', '?name')) - * ``` - */ - prefix(name: string | SparqlValue, iri: string | SparqlValue): QueryBuilder { - const prefixName = toRawString(name) - const namespaceIRI = toRawString(iri) - - // Validate both to prevent injection - validatePrefixName(prefixName) - validateIRI(namespaceIRI) - - const prefixes = new Map(this.state.prefixes || []) - prefixes.set(prefixName, namespaceIRI) - - return new QueryBuilder({ - ...this.state, - prefixes, - }) - } - - /** - * Add a WHERE pattern. - * - * Patterns should be created using triple(), triples(), node(), or other - * pattern helpers that return SparqlValue objects. - */ - where(...patterns: SparqlValue[]): QueryBuilder { - return new QueryBuilder({ - ...this.state, - where: [...this.state.where, ...patterns], - }) - } - - /** - * Add a FILTER constraint. - * - * Conditions should be created using expression helpers (eq, gte, regex, etc.) - * that properly handle escaping for data values. - */ - filter(...conditions: SparqlValue[]): QueryBuilder { - const filterValues = conditions.map(c => filter(c)) - - return new QueryBuilder({ - ...this.state, - filters: [...this.state.filters, ...filterValues], - }) - } - - /** - * Add an OPTIONAL pattern. - */ - optional(...patterns: SparqlValue[]): QueryBuilder { - const optionalPatterns = patterns.map(p => optional(p)) - - return new QueryBuilder({ - ...this.state, - optional: [...this.state.optional, ...optionalPatterns], - }) - } - - /** - * Add a BIND expression to create computed variables. - * - * @param expression - Expression to compute (SparqlValue) - * @param asVariable - Variable name for the result (validated) - */ - bind(expression: SparqlValue, asVariable?: VariableName): QueryBuilder { - const bindValue = asVariable - ? bind(expression, asVariable) - : bind(expression) - - return new QueryBuilder({ - ...this.state, - bindings: [...this.state.bindings, bindValue], - }) - } - - /** - * Add a UNION of alternative patterns. - */ - union(...branches: SparqlValue[]): QueryBuilder { - return new QueryBuilder({ - ...this.state, - unions: [...this.state.unions, branches], - }) - } - - /** - * Add GROUP BY clause for aggregation. - * - * @param variables - Variable names to group by (validated) - */ - groupBy(...variables: VariableName[]): QueryBuilder { - const normalized = variables.map(v => toVarToken(v)) - - return new QueryBuilder({ - ...this.state, - groupBy: [...(this.state.groupBy || []), ...normalized], - }) - } - - /** - * Add HAVING clause to filter grouped results. - */ - having(...conditions: SparqlValue[]): QueryBuilder { - return new QueryBuilder({ - ...this.state, - having: [...(this.state.having || []), ...conditions], - }) - } - - /** - * Add ORDER BY clause. - * - * @param variable - Variable name to sort by (validated) - * @param direction - Sort direction (ASC or DESC) - */ - orderBy(variable: VariableName, direction?: SortDirection): QueryBuilder { - const varStr = toVarToken(variable) - - return new QueryBuilder({ - ...this.state, - sorts: [...this.state.sorts, { variable: varStr, direction }], - }) - } - - /** - * Add LIMIT clause. - */ - limit(count: number): QueryBuilder { - if (!Number.isInteger(count) || count < 0) { - throw new Error(`LIMIT must be a non-negative integer, got: ${count}`) - } - - return new QueryBuilder({ - ...this.state, - limit: count, - }) - } - - /** - * Add OFFSET clause. - */ - offset(count: number): QueryBuilder { - if (!Number.isInteger(count) || count < 0) { - throw new Error(`OFFSET must be a non-negative integer, got: ${count}`) - } - - return new QueryBuilder({ - ...this.state, - offset: count, - }) - } - - /** - * Use DISTINCT modifier to remove duplicate rows. - */ - distinct(): QueryBuilder { - return new QueryBuilder({ - ...this.state, - modifier: 'distinct', - }) - } - - /** - * Use REDUCED modifier as optimization hint. - */ - reduced(): QueryBuilder { - return new QueryBuilder({ - ...this.state, - modifier: 'reduced', - }) - } - - /** - * Add a VALUES clause for inline data. - * - * @param varName - Variable name (validated) - * @param vals - Values to match against (should be SparqlValue objects) - */ - values(varName: VariableName, vals: SparqlTerm[]): QueryBuilder { - const name = toVarToken(varName) - const valuesMap = new Map(this.state.values || []) - valuesMap.set(name, vals) - - return new QueryBuilder({ - ...this.state, - values: valuesMap, - }) - } - - /** - * Wrap this query as a subquery for nesting. - */ - asSubquery(): PatternValue { - return rawPattern(`{ ${this.build().value} }`) - } - - // -------------------------------------------------------------------------- - // Build & execute - // -------------------------------------------------------------------------- - - /** - * Build the final SPARQL query string. - */ - build(): PatternValue { - const parts: string[] = [] - - // PREFIX declarations - if (this.state.prefixes && this.state.prefixes.size > 0) { - for (const [name, iri] of this.state.prefixes) { - // name and iri are already validated in prefix() - parts.push(`PREFIX ${name}: <${iri}>`) - } - parts.push('') - } - - // Query type and projection - if (this.state.type === 'SELECT') { - let modifier = '' - if (this.state.modifier === 'distinct') { - modifier = 'DISTINCT ' - } else if (this.state.modifier === 'reduced') { - modifier = 'REDUCED ' - } - - const proj = this.state.projection === '*' - ? '*' - : (this.state.projection as PatternLike[]) - .map(x => processProjectionItem(x)) - .join(' ') - - parts.push(`SELECT ${modifier}${proj}`) - } else if (this.state.type === 'ASK') { - parts.push('ASK') - } else if (this.state.type === 'CONSTRUCT') { - parts.push('CONSTRUCT') - } else if (this.state.type === 'DESCRIBE') { - const projection = Array.isArray(this.state.projection) - ? (this.state.projection as PatternLike[]) - .map(x => processDescribeItem(x)) - .join(' ') - : processDescribeItem(this.state.projection as PatternLike) - parts.push(`DESCRIBE ${projection}`) - } - - // FROM clauses (IRIs already validated) - if (this.state.from) { - for (const graph of this.state.from) { - parts.push(`FROM ${graph}`) - } - } - - // FROM NAMED clauses (IRIs already validated) - if (this.state.fromNamed) { - for (const graph of this.state.fromNamed) { - parts.push(`FROM NAMED ${graph}`) - } - } - - // WHERE clause - if ( - this.state.where.length > 0 || - this.state.filters.length > 0 || - this.state.optional.length > 0 || - this.state.bindings.length > 0 || - this.state.unions.length > 0 || - this.state.values - ) { - parts.push('WHERE {') - - // VALUES clauses - if (this.state.values) { - for (const [varName, vals] of this.state.values.entries()) { - const valueStrs = vals.map(v => v.value).join(' ') - parts.push(` VALUES ${varName} { ${valueStrs} }`) - } - } - - // WHERE patterns - for (const pattern of this.state.where) { - parts.push(` ${pattern.value}`) - } - - // FILTER expressions - for (const f of this.state.filters) { - parts.push(` ${f.value}`) - } - - // OPTIONAL blocks - for (const opt of this.state.optional) { - parts.push(` ${opt.value}`) - } - - // BIND expressions - for (const b of this.state.bindings) { - parts.push(` ${b.value}`) - } - - // UNION blocks - for (let i = 0; i < this.state.unions.length; i++) { - if (i > 0) parts.push(' UNION') - parts.push(' {') - for (const pattern of this.state.unions[i]) { - parts.push(` ${pattern.value}`) - } - parts.push(' }') - } - - parts.push('}') - } - - // GROUP BY clause - if (this.state.groupBy && this.state.groupBy.length > 0) { - parts.push(`GROUP BY ${this.state.groupBy.join(' ')}`) - } - - // HAVING clause - if (this.state.having && this.state.having.length > 0) { - const havingClauses = this.state.having - .map(h => h.value) - .join(' && ') - parts.push(`HAVING(${havingClauses})`) - } - - // ORDER BY clause - if (this.state.sorts.length > 0) { - const sorts = this.state.sorts.map((sort) => { - if (sort.direction) { - return `${sort.direction}(${sort.variable})` - } - return sort.variable - }) - parts.push(`ORDER BY ${sorts.join(' ')}`) - } - - // LIMIT clause - if (this.state.limit !== undefined) { - parts.push(`LIMIT ${this.state.limit}`) - } - - // OFFSET clause - if (this.state.offset !== undefined) { - parts.push(`OFFSET ${this.state.offset}`) - } - - return rawPattern(parts.join('\n')) - } - - /** - * Execute the query against a SPARQL endpoint. - */ - execute(config: ExecutionConfig): Promise> { - const executor = createExecutor(config) - return executor.execute(this.build()) - } -} - -// ============================================================================ -// Convenience Exports -// ============================================================================ - -export const select = QueryBuilder.select -export const ask = QueryBuilder.ask -export const construct = QueryBuilder.construct -export const describe = QueryBuilder.describe - -export function subquery(builder: QueryBuilder): SparqlValue { - return builder.asSubquery() -} - -export function execute( - builder: QueryBuilder, - config: ExecutionConfig -): Promise> { - return builder.execute(config) -} \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..8befe56 --- /dev/null +++ b/deno.json @@ -0,0 +1,54 @@ +{ + "workspace": [ + "./packages/rdf", + "./packages/sparql", + "./packages/vocab", + "./packages/triplestore", + "./packages/oxigraph", + "./packages/comunica" + ], + "tasks": { + "fmt": "deno fmt packages bench examples .mise/tasks docs README.md VALIDATION.md", + "lint": "deno lint packages bench examples .mise/tasks", + "check": "deno check packages/*/mod.ts packages/**/*.ts bench/**/*.ts examples/**/*.ts .mise/tasks/**/*.ts", + "test": "deno test packages --allow-read", + "bench": "deno run --allow-read=packages --allow-run .mise/tasks/bench.ts", + "verify": "deno task fmt --check && deno task lint && deno task check && deno task test", + "vocab": "deno run --allow-read --allow-write .mise/tasks/vocab.ts", + "vocab:schema": "deno run --allow-net=raw.githubusercontent.com --allow-write .mise/tasks/schema.ts", + "bench:types": "deno run --allow-read --allow-write --allow-env --allow-run bench/vocab/types.ts", + "bench:all": "deno task bench && deno task bench:types" + }, + "compilerOptions": { + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": false, + "lib": [ + "deno.window", + "dom", + "dom.iterable", + "esnext" + ] + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": false, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ] + } + }, + "imports": { + "@std/expect": "jsr:@std/expect@^1.0.20", + "mitata": "npm:mitata@1.0.34", + "@standard-schema/spec": "jsr:@standard-schema/spec@1.1.0" + } +} diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 159b885..0000000 --- a/deno.lock +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@cspotcode/outdent@0.8": "0.8.0" - }, - "jsr": { - "@cspotcode/outdent@0.8.0": { - "integrity": "bbb3dea1443b4191a091644dfeaf3f182cca83e9003d211c50786c21792695f6" - } - }, - "workspace": { - "dependencies": [ - "jsr:@cspotcode/outdent@0.8" - ] - } -} diff --git a/executor.ts b/executor.ts deleted file mode 100644 index bb6eb0d..0000000 --- a/executor.ts +++ /dev/null @@ -1,1012 +0,0 @@ -// executor.ts - -import { - sparql, - raw, - uri, - valuesList, - type SparqlValue, -} from './sparql.ts' -import { - XSD, - RDF, - RDFS, - FOAF, - SCHEMA, -} from './namespaces.ts' - -/** - * Raw SPARQL JSON binding value. - * - * Mirrors the SPARQL 1.1 JSON Results format. - */ -export interface BindingValue { - type: 'uri' | 'literal' | 'bnode' - value: string - 'xml:lang'?: string - datatype?: string -} - -/** - * Map of variable name → binding. - * - * Keys are variable names **without** the leading `?`. - */ -export type BindingMap = Record - -/** - * Standard SPARQL JSON result shape. - * - * - `head.vars` lists variable names. - * - `results.bindings` holds rows. - * - `boolean` is present for ASK queries. - */ -export interface QueryResult { - head: { - vars: string[] - } - results: { - bindings: TBind[] - } - /** - * Present for ASK queries. - */ - boolean?: boolean -} - -/* ============================================================================ - * Error types - * ========================================================================== */ - -export type QueryErrorKind = - | 'http' // non-2xx HTTP status - | 'timeout' // request hit a client-side timeout - | 'abort' // caller-provided signal aborted the request - | 'network' // fetch never got a response (DNS, connection reset, etc.) - | 'protocol' // endpoint returned non-JSON / wrong shape when JSON expected - | 'unknown' // anything else unexpected - -/** - * Rich error type for SPARQL execution. - * - * Everything in here is designed to be safe to log and inspect. - */ -export class QueryError extends Error { - readonly kind: QueryErrorKind - readonly status?: number - readonly responseBody?: unknown - readonly query: string - - constructor(init: { - message: string - kind: QueryErrorKind - query: string | SparqlValue - status?: number - responseBody?: unknown - cause?: unknown - }) { - super(init.message) - this.name = 'QueryError' - this.kind = init.kind - this.status = init.status - this.responseBody = init.responseBody - this.query = typeof init.query === 'string' ? init.query : init.query.value - this.cause = init.cause - } -} - -/** Type guard for handling timeouts specifically. */ -export function isTimeoutError(error: unknown): error is QueryError & { kind: 'timeout' } { - return error instanceof QueryError && error.kind === 'timeout' -} - -/** Type guard for user aborts (external AbortSignal). */ -export function isAbortError(error: unknown): error is QueryError & { kind: 'abort' } { - return error instanceof QueryError && error.kind === 'abort' -} - -/* ============================================================================ - * Config + low-level execution - * ========================================================================== */ - -/** - * Shared configuration for an executor. - */ -export interface ExecutionConfig { - /** SPARQL endpoint URL. */ - endpoint: string - /** - * Optional fetch implementation (Deno, browser, node-fetch, etc.). - * Defaults to the global `fetch`. - */ - fetch?: typeof fetch - /** Extra headers for every request (auth, tenant, etc.). */ - headers?: HeadersInit - /** - * Default timeout in milliseconds for all requests from this executor. - * - `undefined` / `0` = no default timeout - */ - timeoutMs?: number -} - -/** - * Per-call options for executing a query. - * - * You can override the default timeout, and/or pass an `AbortSignal`. - */ -export interface RequestOptions { - /** - * Timeout in milliseconds. - * - `undefined` → falls back to `ExecutionConfig.timeoutMs` - * - `0` or `null` → disable timeout for this call - */ - timeoutMs?: number | null - - /** - * Optional AbortSignal to cancel the request from the outside. - * - * Example: - * ```ts - * const controller = new AbortController() - * const promise = executor.query(query, { signal: controller.signal }) - * controller.abort() - * ``` - */ - signal?: AbortSignal -} - -/** - * Execute a SPARQL query (SELECT / CONSTRUCT / ASK / UPDATE) against an endpoint. - * - * This is the lowest-level building block. Higher-level helpers (builder, update, - * executor) all use this under the hood. - * - * @param config Endpoint + HTTP settings - * @param query Text SPARQL or a `SparqlValue` (tagged template / builder output) - */ -export async function executeSparql( - config: ExecutionConfig, - query: string | SparqlValue, - options: RequestOptions = {}, -): Promise { - const { - endpoint, - fetch: fetchImpl = fetch, - headers, - timeoutMs: defaultTimeout, - } = config - - const queryText = typeof query === 'string' ? query : query.value - const effectiveTimeout = - options.timeoutMs === null - ? 0 - : options.timeoutMs ?? defaultTimeout ?? 0 - - let controller: AbortController | undefined - let signal: AbortSignal | undefined - let timeoutId: ReturnType | undefined - let timedOut = false - let abortedByUser = false - - // Combine timeout + user AbortSignal into a single signal for fetch. - if (effectiveTimeout > 0 || options.signal) { - controller = new AbortController() - signal = controller.signal - - if (effectiveTimeout > 0) { - timeoutId = setTimeout(() => { - timedOut = true - controller!.abort() - }, effectiveTimeout) - } - - if (options.signal) { - if (options.signal.aborted) { - abortedByUser = true - controller.abort(options.signal.reason) - } else { - options.signal.addEventListener( - 'abort', - () => { - abortedByUser = true - controller!.abort(options.signal?.reason) - }, - { once: true }, - ) - } - } - } else { - signal = options.signal - } - - let response: Response - - try { - response = await fetchImpl(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/sparql-query; charset=utf-8', - Accept: 'application/sparql-results+json, application/json', - ...headers, - }, - body: queryText, - signal, - }) - } catch (err) { - if (timeoutId) clearTimeout(timeoutId) - - // Abort / timeout cases – runtime-dependent, so we detect conservatively. - if ( - err instanceof DOMException || - (err instanceof Error && err.name === 'AbortError') - ) { - if (timedOut) { - throw new QueryError({ - kind: 'timeout', - message: `SPARQL request timed out after ${effectiveTimeout}ms`, - query, - cause: err, - }) - } - if (abortedByUser) { - throw new QueryError({ - kind: 'abort', - message: 'SPARQL request was aborted by caller', - query, - cause: err, - }) - } - throw new QueryError({ - kind: 'network', - message: 'SPARQL request was aborted or failed before a response was received', - query, - cause: err, - }) - } - - // Typical "cannot fetch" in many environments. - if (err instanceof TypeError) { - throw new QueryError({ - kind: 'network', - message: `Network error while calling SPARQL endpoint: ${err.message}`, - query, - cause: err, - }) - } - - throw new QueryError({ - kind: 'unknown', - message: 'Unexpected error while calling SPARQL endpoint', - query, - cause: err, - }) - } finally { - if (timeoutId) clearTimeout(timeoutId) - } - - const text = await response.text() - - if (!response.ok) { - let body: unknown = text - try { - body = text ? JSON.parse(text) : undefined - } catch { - // ignore – keep raw text - } - - // Treat all non-2xx as HTTP-level failures; the caller can inspect status. - throw new QueryError({ - kind: 'http', - message: `SPARQL HTTP ${response.status} ${response.statusText}`, - status: response.status, - responseBody: body, - query, - }) - } - - // Successful (2xx) with no body – quite common for UPDATEs. - if (!text) { - return undefined as T - } - - // Many endpoints return JSON for SELECT/ASK and raw RDF for CONSTRUCT/DESCRIBE. - // We try JSON first; if that fails, return raw text. - try { - return JSON.parse(text) as T - } catch (err) { - throw new QueryError({ - kind: 'protocol', - message: 'Expected SPARQL JSON results but endpoint returned non-JSON response', - status: response.status, - responseBody: text, - query, - cause: err, - }) - } -} - -/* ============================================================================ - * High-level Executor - * ========================================================================== */ - -/** - * High-level executor interface returned by {@link createExecutor}. - * - * This is the "one stop shop" you hand to your application code: - * - * - `execute` → raw JSON result - * - `query` → SELECT/DESCRIBE/CONSTRUCT with typed bindings - * - `ask` → boolean ASK queries - * - `update` → SPARQL UPDATE (INSERT / DELETE / LOAD / CLEAR / etc.) - * - `resolveLabels` → best-effort human label for URIs - * - `fetchProperties` → property map for URIs - * - `expand` → fuse labels + properties into a single enriched view - */ -export interface Executor { - /** Low-level "give me the raw SPARQL JSON result" call. */ - execute( - query: string | SparqlValue, - options?: RequestOptions, - ): Promise> - - /** SELECT / DESCRIBE / CONSTRUCT helper returning coerced rows. */ - query( - query: string | SparqlValue, - options?: RequestOptions, - ): Promise> - - /** ASK helper – returns the boolean. */ - ask( - query: string | SparqlValue, - options?: RequestOptions, - ): Promise - - /** SPARQL UPDATE – throws on error, otherwise resolves to void. */ - update( - query: string | SparqlValue, - options?: RequestOptions, - ): Promise - - /** Resolve human-readable labels for URIs. */ - resolveLabels( - uris: string[], - config?: LabelResolutionConfig, - ): Promise> - - /** Fetch selected properties for URIs. */ - fetchProperties( - uris: string[], - config: PropertyFetchConfig, - ): Promise>> - - /** Merge labels + properties into a richer representation. */ - expand( - uris: string[], - config?: ExpandConfig, - ): Promise -} - -/** Internal helper type for passing `execute` into helpers. */ -type ExecuteFn = ( - query: string | SparqlValue, - options?: RequestOptions, -) => Promise> - -/** - * Create an {@link Executor} bound to a specific SPARQL endpoint. - * - * @example Simple usage - * ```ts - * const executor = createExecutor({ endpoint: 'https://dbpedia.org/sparql' }) - * - * const rows = await executor.query<{ name: BindingValue }>( - * `SELECT ?name WHERE { dbr:Toronto foaf:name ?name } LIMIT 10` - * ) - * - * console.log(rows[0].name) // "Toronto" (after coercion) - * ``` - */ -export function createExecutor(config: ExecutionConfig): Executor { - /** - * Core execution that always returns SPARQL JSON shape. This is the one - * everything else builds upon. - */ - async function execute( - query: string | SparqlValue, - options?: RequestOptions, - ): Promise> { - const json = await executeSparql>(config, query, options) - - // For ASK, some endpoints still embed results in this structure, others - // return { boolean }. We try to normalize here. - - // When the endpoint is properly configured, we should get JSON with - // { head, results } for SELECT and { boolean } for ASK. - if ( - !json || - typeof json !== 'object' || - !('results' in json) || - !('head' in json) - ) { - throw new QueryError({ - kind: 'protocol', - message: 'SPARQL endpoint returned JSON that does not match SPARQL Results format', - query, - responseBody: json, - }) - } - - return json - } - - /** - * SELECT / DESCRIBE / CONSTRUCT helper that: - * - runs the query - * - coerces literals into JS types - * - returns an array of rows - */ - async function query( - q: string | SparqlValue, - options?: RequestOptions, - ): Promise> { - const result = await execute(q, options) - return transformResults(result) - } - - /** - * ASK helper – returns the boolean or `false` if the server didn't provide it. - */ - async function ask( - q: string | SparqlValue, - options?: RequestOptions, - ): Promise { - const result = await execute(q, options) - return Boolean(result.boolean) - } - - /** - * SPARQL UPDATE helper – we treat it as fire-and-forget. - * Any HTTP/protocol error will throw via `executeSparql`. - */ - async function update( - q: string | SparqlValue, - options?: RequestOptions, - ): Promise { - // For UPDATE, we just care that it doesn't throw. - await executeSparql(config, q, options) - } - - const execFn: ExecuteFn = execute - - return { - execute, - query, - ask, - update, - resolveLabels: (uris, cfg) => resolveLabels(uris, cfg, execFn), - fetchProperties: (uris, cfg) => fetchProperties(uris, cfg, execFn), - expand: (uris, cfg) => expand(uris, cfg, execFn), - } -} - -/* ============================================================================ - * Result transformation + coercion - * ========================================================================== */ - -/** - * Transform SPARQL JSON into a more ergonomic JS representation. - * - * - Literals are coerced using {@link coerceValue} - * - URIs stay as strings - * - Blank nodes stay as strings (bnode IDs) - */ -export function transformResults( - result: QueryResult, -): Array<{ [K in keyof TBind]: unknown }> { - return result.results.bindings.map((binding) => parseBinding(binding)) -} - -/** - * Parse a single binding map into a JS row. - * - * Keys are variable names (without `?`) and values are coerced via - * {@link coerceValue}. - */ -export function parseBinding( - binding: TBind, -): { [K in keyof TBind]: unknown } { - const row: Record = {} - - for (const [key, value] of Object.entries(binding)) { - row[key] = coerceValue(value) - } - - return row as { [K in keyof TBind]: unknown } -} - -/* ============================================================================ - * Datatype Coercion - * ========================================================================== */ - -/** - * Expanded set of integer-like XSD datatypes. - * - * All of these are represented as `number` in JS. - */ -const INTEGER_DATATYPES = new Set([ - XSD.integer, - XSD.long, - XSD.int, - XSD.short, - XSD.byte, - XSD.nonNegativeInteger, - XSD.nonPositiveInteger, - XSD.positiveInteger, - XSD.negativeInteger, - XSD.unsignedLong, - XSD.unsignedInt, - XSD.unsignedShort, - XSD.unsignedByte, -]) - -/** - * Decimal / floating-point XSD datatypes. - * - * All of these are represented as `number` in JS. - */ -const DECIMAL_DATATYPES = new Set([ - XSD.decimal, - XSD.float, - XSD.double, -]) - -/** - * Datetime-like XSD datatypes that should be parsed as JS `Date`. - * - * We attempt `Date.parse` and fall back to the original string on failure. - */ -const DATETIME_DATATYPES = new Set([ - XSD.dateTime, - XSD.dateTimeStamp, -]) - -/** - * Date-only XSD datatypes. These are still parsed as full JS `Date` - * (midnight UTC or local, depending on environment). - */ -const DATE_DATATYPES = new Set([ - XSD.date, -]) - -/** - * Time-only datatypes (stored as `Date` as well, for now). - */ -const TIME_DATATYPES = new Set([ - XSD.time, -]) - -/** - * Coerce a SPARQL JSON binding value into a more natural JS type. - * - * - URIs → string - * - `xsd:boolean` → boolean - * - integer-like → number - * - decimal / float / double → number - * - date / dateTime / dateTimeStamp / time → Date - * - `rdf:JSON` → parsed JSON (with safe fallback) - * - everything else → string (original literal value) - * - * We deliberately stay conservative for exotic datatypes: if we don't have - * a clear, widely-understood JS representation, we keep the raw string. - */ -export function coerceValue(bindingValue: BindingValue): unknown { - const { type, value, datatype } = bindingValue - - // URIs are already natural strings. - if (type === 'uri') { - return value - } - - // Typed literals: try to interpret known datatypes. - if (datatype) { - // Integers - if (datatype === XSD.boolean) { - return value === 'true' || value === '1' - } - - // Integers - if (INTEGER_DATATYPES.has(datatype)) { - const n = Number.parseInt(value, 10) - return Number.isNaN(n) ? value : n - } - - // Decimals / floats / doubles - if (DECIMAL_DATATYPES.has(datatype)) { - const n = Number.parseFloat(value) - return Number.isNaN(n) ? value : n - } - - // Dates / times - if ( - DATETIME_DATATYPES.has(datatype) || - DATE_DATATYPES.has(datatype) || - TIME_DATATYPES.has(datatype) - ) { - const ts = Date.parse(value) - return Number.isNaN(ts) ? value : new Date(ts) - } - - // RDF JSON literal – use the constant if present in your RDF namespace. - if ((RDF as Record).JSON && datatype === (RDF as Record).JSON) { - try { - return JSON.parse(value) - } catch { - return value - } - } - - // AnyURI is already a string representation; keep as-is. - if (datatype === XSD.anyURI) { - return value - } - - // For everything else (HTML, XMLLiteral, gYear, durations, etc.) - // we fall through and return the raw string. - - } - - // Untyped literals or unsupported datatypes: keep as string. - return value -} - -/* ============================================================================ - * Convenience helpers - * ========================================================================== */ - -/** - * Pluck a single column from result rows. - * - * @example Get list of names - * ```ts - * const rows = await executor.query<{ name: BindingValue }>(...) - * const names = pluck(rows, 'name') // string[] - * ``` - */ -export function pluck, K extends keyof T>( - rows: T[], - key: K, -): Array { - return rows.map((row) => row[key]) -} - -/** - * Get the first row (or `undefined`). - * - * @example - * ```ts - * const rows = await executor.query(...) - * const firstRow = first(rows) - * ``` - */ -export function first(rows: T[]): T | undefined { - return rows[0] -} - -/* ============================================================================ - * Label resolution - * ========================================================================== */ - -/** - * Default label predicates when resolving human-friendly names. - * - * Order matters – we stop at the first non-empty value: - * 1. `rdfs:label` - * 2. `foaf:name` - * 3. `schema:name` - * 4. `schema:alternateName` - */ -const DEFAULT_LABEL_PREDICATES: readonly string[] = [ - RDFS.label, - FOAF.name, - SCHEMA.name, - SCHEMA.alternateName, -] - -/** - * Configuration for {@link resolveLabels}. - */ -export interface LabelResolutionConfig { - /** - * Predicates to try (in order of preference). - * - * Defaults to {@link DEFAULT_LABEL_PREDICATES}. - */ - labelPredicates?: string[] - - /** - * Batch size for VALUES clause when resolving many URIs. - * - * Defaults to 50. - */ - batchSize?: number -} - -/** - * Resolve best-effort labels for a list of URIs. - * - * This is intentionally: - * - **best-effort** (if no label exists, URI won't be in the result) - * - **batch-friendly** (uses VALUES to resolve many URIs at once) - * - * Uses the `sparql\`...\`` tag internally for safer query construction. - */ -export async function resolveLabels( - uris: string[], - config: LabelResolutionConfig | undefined, - execute: ExecuteFn, -): Promise> { - if (uris.length === 0) return {} - - const labelPredicates = - config?.labelPredicates && config.labelPredicates.length > 0 - ? config.labelPredicates - : [...DEFAULT_LABEL_PREDICATES] - - const batchSize = config?.batchSize ?? 50 - const result: Record = {} - - const labelVarNames = labelPredicates.map((_, index) => `label${index}`) - const labelVarList = labelVarNames.map((v) => `?${v}`).join(' ') - - for (let i = 0; i < uris.length; i += batchSize) { - const batch = uris.slice(i, i + batchSize) - - const optionalPatterns = labelPredicates - .map((predicate, index) => { - const varName = labelVarNames[index] - return `OPTIONAL { ?uri <${predicate}> ?${varName} }` - }) - .join('\n') - - const query = sparql` - SELECT ?uri ${raw(labelVarList)} WHERE { - VALUES ?uri { ${valuesList(batch.map((u) => uri(u)))} } - ${raw(optionalPatterns)} - } - ` - - const response = await execute(query) - - for (const binding of response.results.bindings) { - const uriValue = binding.uri?.value - if (!uriValue) continue - - const label = - labelVarNames - .map((v) => binding[v]?.value) - .find((v) => v !== undefined && v !== '') - - if (label) { - result[uriValue] = label - } - } - } - - return result -} - -/* ============================================================================ - * Property fetch - * ========================================================================== */ - - -/** - * Configuration for {@link fetchProperties}. - */ -export interface PropertyFetchConfig { - /** - * List of properties to fetch. - * - * `property` can be: - * - full IRI (`http://example.org/name`) - * - prefixed name (`schema:name`) - * - variable (`?property`) – in which case you control the pattern in your query - * - * `propertyName` controls the key used in the returned object. Defaults to - * the property string itself. - */ - properties: Array<{ - property: string - propertyName?: string - }> - - /** - * Batch size for VALUES clause when fetching many URIs. - * - * Defaults to 50. - */ - batchSize?: number -} - -/** - * Fetch selected properties for each URI. - * - * Returns a nested map: - * - * ```ts - * { - * "http://example.org/resource/1": { - * name: "Example", - * createdAt: Date, - * }, - * ... - * } - * ``` - */ -export async function fetchProperties( - uris: string[], - config: PropertyFetchConfig, - execute: ExecuteFn, -): Promise>> { - if (uris.length === 0) return {} - const requestedProperties = config.properties ?? [] - if (requestedProperties.length === 0) return {} - - const batchSize = config.batchSize ?? 50 - const result: Record> = {} - - const propertyVarNames = requestedProperties.map((_, index) => `p${index}`) - const propertyVarList = propertyVarNames.map((v) => `?${v}`).join(' ') - - for (let i = 0; i < uris.length; i += batchSize) { - const batch = uris.slice(i, i + batchSize) - - const optionalPatterns = requestedProperties - .map((prop, index) => { - let propertyExpr = prop.property - - if (!propertyExpr.startsWith('?')) { - if (!propertyExpr.includes(':') && !propertyExpr.startsWith('<')) { - propertyExpr = `<${propertyExpr}>` - } - } - - const varName = propertyVarNames[index] - return `OPTIONAL { ?uri ${propertyExpr} ?${varName} }` - }) - .join('\n') - - const query = sparql` - SELECT ?uri ${raw(propertyVarList)} WHERE { - VALUES ?uri { ${valuesList(batch.map((u) => uri(u)))} } - ${raw(optionalPatterns)} - } - ` - - const response = await execute(query) - - for (const binding of response.results.bindings) { - const uriValue = binding.uri?.value - if (!uriValue) continue - - // biome-ignore lint/suspicious/noAssignInExpressions: - const entry = (result[uriValue] ??= {}) - - requestedProperties.forEach((prop, index) => { - const varName = propertyVarNames[index] - const bindingValue = binding[varName] - if (!bindingValue) return - - const key = prop.propertyName ?? prop.property - entry[key] = coerceValue(bindingValue) - }) - } - } - - return result -} - -/* ============================================================================ - * Expand (labels + properties) - * ========================================================================== */ - -/** - * Result of {@link expand}. - * - * Each expanded item includes: - * - `uri` – the original resource URI - * - `label` – resolved human label (if any) - * - `properties` – fetched properties keyed by name - */ -export interface ExpandResult { - uri: string - label?: string - properties: Record -} - -/** - * How to handle the base URI list when some URIs have no data. - */ -export type ExpandMode = - | 'all' // include all URIs passed in - | 'withData' // only URIs that have label or properties - -/** - * Configuration for {@link expand}. - */ -export interface ExpandConfig { - labels?: LabelResolutionConfig - properties?: PropertyFetchConfig - /** - * Whether to return entries for URIs that have no label and no properties. - * - * - `'all'` (default) – keep them with empty properties and no label - * - `'withData'` – filter out completely - */ - mode?: ExpandMode -} - -/** - * Expand URIs into richer objects with: - * - * - a human-friendly `label` (using {@link resolveLabels}) - * - selected `properties` (using {@link fetchProperties}) - * - * @example Basic expansion - * ```ts - * const expanded = await executor.expand( - * [ - * 'http://knowledge.graph/narrative#Product/123', - * 'http://knowledge.graph/narrative#Product/456', - * ], - * { - * properties: { - * properties: [ - * { property: 'schema:releaseDate', propertyName: 'releaseDate' }, - * { property: 'schema:isbn', propertyName: 'isbn' }, - * ], - * }, - * }, - * ) - * - * // → [{ uri, label, properties: { releaseDate: Date, isbn: string } }, ...] - * ``` - */ -export async function expand( - uris: string[], - config: ExpandConfig | undefined, - execute: ExecuteFn, -): Promise { - if (uris.length === 0) return [] - - const mode: ExpandMode = config?.mode ?? 'all' - - const [labels, props] = await Promise.all([ - resolveLabels(uris, config?.labels, execute), - config?.properties - ? fetchProperties(uris, config.properties, execute) - : Promise.resolve>>({}), - ]) - - const results: ExpandResult[] = [] - - for (const uriValue of uris) { - const label = labels[uriValue] - const properties = props[uriValue] ?? {} - - const hasData = - typeof label === 'string' || Object.keys(properties).length > 0 - - if (mode === 'withData' && !hasData) { - continue - } - - results.push({ - uri: uriValue, - label, - properties, - }) - } - - return results -} diff --git a/mise.toml b/mise.toml index 666a523..3c77d7b 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,25 @@ [tools] -deno = "latest" -kubectl = "latest" -talosctl = "latest" -k9s = "latest" +deno = "2" +node = "26" +pnpm = "11" + +[tasks.verify] +depends = ["fmt", "lint", "check", "test"] + +[tasks.fmt] +run = "deno fmt --check packages bench examples .mise/tasks docs README.md VALIDATION.md" + +[tasks.lint] +run = "deno lint packages bench examples .mise/tasks" + +[tasks.check] +run = "deno check packages/*/mod.ts packages/**/*.ts bench/**/*.ts examples/**/*.ts .mise/tasks/**/*.ts" + +[tasks.test] +run = "deno test packages --allow-read --allow-write" + +[tasks.bench] +run = "deno bench bench packages --allow-read --allow-write" + +[tasks.vocab-schema] +run = "deno run --allow-net=raw.githubusercontent.com --allow-write .mise/tasks/schema.ts" diff --git a/mod.ts b/mod.ts deleted file mode 100644 index 2c9ddc9..0000000 --- a/mod.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Type-safe SPARQL query builder with fluent API - * - * Writing SPARQL queries by hand means string concatenation, manual escaping, and hunting through - * parentheses when something breaks. You lose autocomplete, type checking, and the ability to compose - * queries from reusable pieces. This library gives you a modern query builder with type safety and a - * fluent interface that reads naturally. Write `v('age').gte(18)` instead of `FILTER(?age >= 18)`, chain - * operations like `v('price').mul(1.2).round()`, and let TypeScript catch errors at compile time. - * - * The library works in three layers. Core types handle value conversion - strings become escaped - * literals, numbers stay as numbers, dates format correctly. Pattern builders let you describe graphs - * using triples, nested object notation, or ASCII art syntax. The query builder provides the - * chainable interface for complete queries with full SPARQL 1.1 support. You can write basic triple - * patterns like `triple('?person', 'foaf:name', '?name')`, build complex nested structures with - * `node('product', Product).prop('publisher', node('pub', Organization))`, or use ASCII art paths - * with `cypher`${product}-[schema:publisher]->${publisher}``. These patterns compose - use triples - * for simple cases, nested nodes for complex graphs, ASCII art for visual clarity, and mix them in - * the same query. Every pattern compiles to standard SPARQL triples. - * - * The fluent API makes expressions readable. Instead of `filter(and(gte(v('age'), 18), lt(v('age'), 65)))`, - * you write `filter(v('age').gte(18).and(v('age').lt(65)))`. For computed values, chain operations - * left to right: `v('price').mul(0.9).round().add(5).as('discount')`. Conditional logic stays clear with - * `ifElse(v('inStock').eq(true), v('price').mul(0.9), v('price').add(10))`. String operations chain - * naturally: `v('name').ucase().concat('...')` for fluent methods, or use standalone functions like - * `substr(v('name').ucase(), 1, 10)` when needed. Twenty-one functions return FluentValue for - * seamless chaining - arithmetic (add, sub, mul, div, mod), math (abs, round, ceil, floor), string - * operations (concat, strlen, ucase, lcase, contains, startsWith, endsWith, regex), conditionals - * (ifElse, coalesce), and type checks (isNull, isNotNull, isIri, isBlank, isLiteral, bound). - * - * @example Quick start with triple patterns - * ```ts - * const adults = select(['?name', '?age']) - * .where(triple('?person', 'foaf:name', '?name')) - * .where(triple('?person', 'foaf:age', '?age')) - * .filter(v('age').gte(18)) - * .orderBy('?name') - * - * const sparql = adults.build() - * const results = await adults.execute({ endpoint: 'http://localhost:3030/dataset/sparql' }) - * ``` - * - * @example Nested object patterns - * ```ts - * const products = select(['?title', '?publisherName', '?city']) - * .where( - * node('product', 'schema:Product', { - * 'schema:name': v('title'), - * 'schema:publisher': node('publisher', 'schema:Organization', { - * 'schema:name': v('publisherName'), - * 'schema:location': node('location', 'schema:Place', { - * 'schema:city': v('city') - * }) - * }) - * }) - * ) - * ``` - * - * @example ASCII art patterns with cypher template tag - * ```ts - * const product = node('product', 'schema:Product', { - * 'schema:name': v('title') - * }) - * - * const publisher = node('publisher', 'schema:Organization', { - * 'schema:name': v('pubName') - * }) - * - * const query = select(['?title', '?pubName']) - * .where(cypher`${product}-[schema:publisher]->${publisher}`) - * ``` - * - * @example Combining patterns with match() - * ```ts - * const pattern = match( - * node('person', 'foaf:Person', { 'foaf:name': v('name') }), - * rel('person', 'foaf:knows', 'friend'), - * node('friend', 'foaf:Person', { 'foaf:name': v('friendName') }) - * ) - * - * select(['?name', '?friendName']).where(pattern) - * ``` - * - * @example Fluent operations with aggregations - * ```ts - * const analytics = select([ - * v('city'), - * count().as('users'), - * avg(v('age')).as('avgAge') - * ]) - * .where(triple('?user', 'schema:city', '?city')) - * .where(triple('?user', 'foaf:age', '?age')) - * .groupBy('?city') - * .having(count().gte(10)) - * .orderBy('?users', 'DESC') - * ``` - * - * @example Complex computed values - * ```ts - * const pricing = select(['?product', '?finalPrice', '?displayName']) - * .where(triple('?product', 'schema:name', '?name')) - * .where(triple('?product', 'schema:price', '?basePrice')) - * .where(triple('?product', 'schema:inStock', '?inStock')) - * .bind( - * ifElse( - * v('inStock').eq(true), - * v('basePrice').mul(0.9).round(), - * v('basePrice').add(10) - * ).as('finalPrice') - * ) - * .bind( - * v('name').ucase().concat('...').as('displayName') - * ) - * .filter(v('finalPrice').gte(10)) - * ``` - * - * @example Nested subqueries - * ```ts - * const topSellers = select([v('product'), count().as('sales')]) - * .where(triple('?order', 'schema:product', '?product')) - * .groupBy('?product') - * .orderBy('?sales', 'DESC') - * .limit(10) - * - * const enriched = select(['?product', '?name', '?sales']) - * .where(subquery(topSellers)) - * .where(triple('?product', 'schema:name', '?name')) - * ``` - * - * @example Property paths for transitive relationships - * ```ts - * // Find all contacts through any number of "knows" hops - * const network = select(['?person', '?contact']) - * .where(triple('?person', zeroOrMore('foaf:knows'), '?contact')) - * .filter(v('person').neq(v('contact'))) - * - * // Navigate nested properties - * const cities = select(['?person', '?city']) - * .where(triple('?person', sequence('schema:address', 'schema:city'), '?city')) - * - * // Alternative predicates - * const names = select(['?person', '?name']) - * .where(triple('?person', alternative('foaf:name', 'schema:name'), '?name')) - * ``` - * - * @example Update operations with fluent API - * ```ts - * // Increment ages - * const incrementAge = modify() - * .delete(triple('?person', 'foaf:age', '?oldAge')) - * .insert(triple('?person', 'foaf:age', v('oldAge').add(1))) - * .where(triple('?person', 'foaf:age', '?oldAge')) - * .where(filter(v('oldAge').gte(0))) - * .done() - * - * await incrementAge.execute({ endpoint: 'http://localhost:3030/dataset/update' }) - * ``` - * - * @example Chain-style node building with nested relationships - * ```ts - * const pattern = node('person', 'foaf:Person') - * .prop('foaf:name', v('personName')) - * .prop('foaf:knows', node('friend', 'foaf:Person', { - * 'foaf:name': v('friendName'), - * 'schema:city': v('friendCity') - * })) - * - * select(['?personName', '?friendName', '?friendCity']).where(pattern) - * ``` - * - * @example Named graphs and federation - * ```ts - * // Query specific graph - * const graphData = select(['?s', '?p', '?o']) - * .fromNamed('http://example.org/graph1') - * .where(graph('?g', triple('?s', '?p', '?o'))) - * - * // Federated query - * const federated = select(['?person', '?birthPlace']) - * .where(triple('?person', 'foaf:name', '?name')) - * .where( - * service( - * 'http://dbpedia.org/sparql', - * triple('?person', 'dbo:birthPlace', '?birthPlace') - * ) - * ) - * ``` - * - * @example Complete e-commerce scenario - * ```ts - * const productQuery = select([ - * v('name'), - * v('finalPrice'), - * v('stockStatus'), - * v('categoryName') - * ]) - * .where( - * node('product', 'schema:Product', { - * 'schema:name': v('name'), - * 'schema:price': v('basePrice'), - * 'schema:inventory': v('stock'), - * 'schema:category': node('category', 'schema:Category', { - * 'schema:name': v('categoryName') - * }) - * }) - * ) - * .bind( - * ifElse( - * v('stock').gt(0), - * v('basePrice').mul(0.9).round(), - * v('basePrice').add(10) - * ).as('finalPrice') - * ) - * .bind( - * ifElse( - * v('stock').gt(10), - * 'In Stock', - * ifElse(v('stock').gt(0), 'Low Stock', 'Out of Stock') - * ).as('stockStatus') - * ) - * .filter(v('finalPrice').gte(10)) - * .orderBy('?finalPrice') - * .limit(50) - * ``` - * - * @module - */ - -// Core types and utilities -export * from './sparql.ts' -export * from './utils.ts' - -// Pattern builders - choose your style -export * from './patterns/triples.ts' -export * from './patterns/objects.ts' -export * from './patterns/cypher.ts' - -// Query builder and execution -export * from './builder.ts' -export * from './update.ts' -export * from './executor.ts' - -// Namespace constants -export * from './namespaces.ts' \ No newline at end of file diff --git a/namespaces.ts b/namespaces.ts deleted file mode 100644 index 9cf0e4d..0000000 --- a/namespaces.ts +++ /dev/null @@ -1,1382 +0,0 @@ -/** - * Core RDF / SPARQL namespace constants with intent, datatypes, and usage examples. - * - * Design goals: - * - Give you **strongly-typed, auto-complete friendly IRIs** for the vocabularies - * most relevant to SPARQL 1.1 / 1.2 and RDF 1.1 / RDF-star. - * - Focus on **datatypes** and high-value terms from: - * - XSD (XML Schema) - * - RDF / RDFS - * - OWL - * - FOAF - * - Schema.org - * - SPARQL Service Description (SD) - * - SHACL - * - SKOS - * - PROV, VoID, Dublin Core Terms - * - WGS84 (`geo:`) and GeoSPARQL (`geosparql:`, `geof:`) - * - SPARQL Results vocabulary - * - * All namespaces use the `http://` form of the IRI, which is still the most widely - * deployed and interoperable in RDF/SPARQL systems. - * - * These are **just string constants** – zero runtime overhead. They help you avoid - * subtle typos and keep your query builder readable. - */ - -/** - * Common structural shape shared by all namespace objects. - * - * You rarely need this directly; it’s mainly here so helpers like `getNamespaceIRI` - * can accept any of the exported namespaces. - */ -export interface NamespaceLike { - /** Base namespace IRI (typically used for PREFIX declarations). */ - readonly _namespace: string; - - /** Every other property is a full IRI for a class, datatype, or property. */ - readonly [term: string]: string; -} - -/* ======================================================================= */ -/* XSD – XML Schema Datatypes */ -/* ======================================================================= */ - -/** - * XML Schema Datatypes (XSD) – the backbone of SPARQL literal typing. - * - * **Intent** - * XSD defines scalar types (numbers, dates, strings, URIs, etc.) used to type RDF - * literals like `"42"^^xsd:int` or `"2024-01-01"^^xsd:date`. SPARQL has explicit - * comparison and casting rules for these types. - * - * **Typical uses** - * - Creating typed literals in your DSL (e.g. `typed('18', XSD.int)`). - * - Writing filters that rely on numeric or date semantics. - * - Defining SHACL constraints or schema ranges for properties. - * - * @example Numeric filter - * ```ts - * import { XSD } from './namespaces.ts' - * - * // ?age > 18 using xsd:int semantics - * query.filter(v('age').gt(typed('18', XSD.int))) - * ``` - * - * @example Date comparison - * ```ts - * query.filter( - * v('createdAt').gt(typed('2024-01-01T00:00:00Z', XSD.dateTime)) - * ) - * ``` - */ -export const XSD: NamespaceLike = /* @__PURE__ */ { - /** Base namespace for all XML Schema datatypes. */ - _namespace: 'http://www.w3.org/2001/XMLSchema#', - - // Core string & language - - /** Free-form Unicode string (default for plain literals). */ - string: 'http://www.w3.org/2001/XMLSchema#string', - - /** Whitespace-normalized string. */ - normalizedString: 'http://www.w3.org/2001/XMLSchema#normalizedString', - - /** Tokenized string (no leading/trailing/extra internal spaces). */ - token: 'http://www.w3.org/2001/XMLSchema#token', - - /** Language tag (e.g. "en", "en-CA"). */ - language: 'http://www.w3.org/2001/XMLSchema#language', - - // Numeric hierarchy - - /** Arbitrary-precision decimal (great for currency/precise amounts). */ - decimal: 'http://www.w3.org/2001/XMLSchema#decimal', - - /** Arbitrary-precision integer. */ - integer: 'http://www.w3.org/2001/XMLSchema#integer', - - /** Integer ≤ 0. */ - nonPositiveInteger: 'http://www.w3.org/2001/XMLSchema#nonPositiveInteger', - - /** Integer < 0. */ - negativeInteger: 'http://www.w3.org/2001/XMLSchema#negativeInteger', - - /** Integer ≥ 0. */ - nonNegativeInteger: 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger', - - /** Integer > 0. */ - positiveInteger: 'http://www.w3.org/2001/XMLSchema#positiveInteger', - - /** 64-bit signed integer. */ - long: 'http://www.w3.org/2001/XMLSchema#long', - - /** 32-bit signed integer. */ - int: 'http://www.w3.org/2001/XMLSchema#int', - - /** 16-bit signed integer. */ - short: 'http://www.w3.org/2001/XMLSchema#short', - - /** 8-bit signed integer. */ - byte: 'http://www.w3.org/2001/XMLSchema#byte', - - /** Unsigned 64-bit integer. */ - unsignedLong: 'http://www.w3.org/2001/XMLSchema#unsignedLong', - - /** Unsigned 32-bit integer. */ - unsignedInt: 'http://www.w3.org/2001/XMLSchema#unsignedInt', - - /** Unsigned 16-bit integer. */ - unsignedShort: 'http://www.w3.org/2001/XMLSchema#unsignedShort', - - /** Unsigned 8-bit integer. */ - unsignedByte: 'http://www.w3.org/2001/XMLSchema#unsignedByte', - - // Floating-point - - /** 32-bit IEEE 754 floating point. */ - float: 'http://www.w3.org/2001/XMLSchema#float', - - /** 64-bit IEEE 754 floating point. */ - double: 'http://www.w3.org/2001/XMLSchema#double', - - // Boolean - - /** Boolean value: "true"/"false"/"1"/"0". */ - boolean: 'http://www.w3.org/2001/XMLSchema#boolean', - - // Date / time - - /** Calendar date without time (YYYY-MM-DD). */ - date: 'http://www.w3.org/2001/XMLSchema#date', - - /** Time without date (hh:mm:ss[.fraction][timezone]). */ - time: 'http://www.w3.org/2001/XMLSchema#time', - - /** Date and time (YYYY-MM-DDThh:mm:ss[.fraction][timezone]). */ - dateTime: 'http://www.w3.org/2001/XMLSchema#dateTime', - - /** - * Date and time with required timezone. - * Useful when you need fully-qualified timestamps. - */ - dateTimeStamp: 'http://www.w3.org/2001/XMLSchema#dateTimeStamp', - - /** Duration of time (PnYnMnDTnHnMnS). */ - duration: 'http://www.w3.org/2001/XMLSchema#duration', - - /** Year and month duration (PnYnM). */ - yearMonthDuration: 'http://www.w3.org/2001/XMLSchema#yearMonthDuration', - - /** Day and time duration (PnDTnHnMnS). */ - dayTimeDuration: 'http://www.w3.org/2001/XMLSchema#dayTimeDuration', - - // Calendar fragments (useful in some vocabularies) - - /** Gregorian year (YYYY). */ - gYear: 'http://www.w3.org/2001/XMLSchema#gYear', - - /** Gregorian year-month (YYYY-MM). */ - gYearMonth: 'http://www.w3.org/2001/XMLSchema#gYearMonth', - - /** Gregorian month (--MM). */ - gMonth: 'http://www.w3.org/2001/XMLSchema#gMonth', - - /** Gregorian month-day (--MM-DD). */ - gMonthDay: 'http://www.w3.org/2001/XMLSchema#gMonthDay', - - /** Gregorian day of month (---DD). */ - gDay: 'http://www.w3.org/2001/XMLSchema#gDay', - - // Binary & misc - - /** Any type – root of the type hierarchy. */ - anyType: 'http://www.w3.org/2001/XMLSchema#anyType', - - /** URI/IRI represented as a string literal. */ - anyURI: 'http://www.w3.org/2001/XMLSchema#anyURI', - - /** Base64-encoded binary data. */ - base64Binary: 'http://www.w3.org/2001/XMLSchema#base64Binary', - - /** Hex-encoded binary data. */ - hexBinary: 'http://www.w3.org/2001/XMLSchema#hexBinary', - - /** Qualified name (prefix:local form). */ - QName: 'http://www.w3.org/2001/XMLSchema#QName', - - /** NOTATION type (legacy XML feature, rarely used in RDF). */ - NOTATION: 'http://www.w3.org/2001/XMLSchema#NOTATION', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* RDF – Core RDF vocabulary */ -/* ======================================================================= */ - -/** - * RDF – Core RDF vocabulary. - * - * **Intent** - * RDF defines the basic building blocks of RDF graphs: statements, lists, - * containers, and special literal datatypes. - * - * **Typical uses** - * - Using `RDF.type` to declare class membership. - * - Working with RDF lists via `RDF.first`, `RDF.rest`, `RDF.nil`. - * - Handling RDF-specific literal types like `RDF.HTML` or `RDF.JSON`. - * - * @example Declaring types - * ```ts - * import { RDF, RDFS } from './namespaces.ts' - * - * where(triple('?c', RDF.type, RDFS.Class)) - * ``` - * - * @example RDF list traversal - * ```ts - * select(['?item']) - * .where(triple('?list', RDF.first, '?item')) - * .where(triple('?list', RDF.rest, RDF.nil)) - * ``` - */ -export const RDF: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - - /** Assigns a class to a resource: `?s rdf:type ?class`. */ - type: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', - - /** Class of RDF properties. */ - Property: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Property', - - /** Reified statement (rarely used in modern data). */ - Statement: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Statement', - - /** Subject of a reified statement. */ - subject: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#subject', - - /** Predicate of a reified statement. */ - predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate', - - /** Object of a reified statement. */ - object: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#object', - - /** Generic value property (used in some vocabularies). */ - value: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#value', - - // Collections & containers - - /** Class of RDF lists. */ - List: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#List', - - /** Ordered container. */ - Seq: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Seq', - - /** Unordered bag (multiset). */ - Bag: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Bag', - - /** Container of alternatives. */ - Alt: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Alt', - - /** First element in an RDF list. */ - first: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first', - - /** Rest of an RDF list (another list or rdf:nil). */ - rest: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest', - - /** Marker for the empty RDF list. */ - nil: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil', - - // Literal datatypes - - /** XML literal datatype. */ - XMLLiteral: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral', - - /** HTML literal datatype. */ - HTML: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML', - - /** JSON literal datatype (RDF 1.1+ extension). */ - JSON: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON', - - /** Language-tagged string datatype. */ - langString: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', - - /** Directional language-tagged string datatype. */ - dirLangString: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* RDFS – RDF Schema */ -/* ======================================================================= */ - -/** - * RDFS – RDF Schema. - * - * **Intent** - * RDFS gives you minimal schema language for RDF: - * - Class hierarchies (`rdfs:subClassOf`) - * - Property hierarchies (`rdfs:subPropertyOf`) - * - Domain and range constraints - * - Human-readable labels and comments - * - * **Typical uses** - * - Attaching human-friendly labels to resources. - * - Expressing lightweight type hierarchies. - * - Building small ontologies without full OWL. - * - * @example Labels for display - * ```ts - * import { RDF, RDFS } from './namespaces.ts' - * - * select(['?resource', '?label']) - * .where(triple('?resource', RDF.type, RDFS.Class)) - * .where(triple('?resource', RDFS.label, '?label')) - * ``` - * - * @example Discovering subclasses - * ```ts - * select(['?sub']) - * .where(triple('?sub', RDFS.subClassOf, 'narrative:Product')) - * ``` - */ -export const RDFS: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/2000/01/rdf-schema#', - - /** Human-readable label. */ - label: 'http://www.w3.org/2000/01/rdf-schema#label', - - /** Human-readable description / documentation. */ - comment: 'http://www.w3.org/2000/01/rdf-schema#comment', - - /** Link to related resources. */ - seeAlso: 'http://www.w3.org/2000/01/rdf-schema#seeAlso', - - /** Link to the defining resource of a term. */ - isDefinedBy: 'http://www.w3.org/2000/01/rdf-schema#isDefinedBy', - - /** Class of all RDFS classes. */ - Class: 'http://www.w3.org/2000/01/rdf-schema#Class', - - /** Class of all resources that can be named. */ - Resource: 'http://www.w3.org/2000/01/rdf-schema#Resource', - - /** Class of literal values. */ - Literal: 'http://www.w3.org/2000/01/rdf-schema#Literal', - - /** Class of datatypes. */ - Datatype: 'http://www.w3.org/2000/01/rdf-schema#Datatype', - - /** Class of containers (rdf:Bag, rdf:Seq, rdf:Alt). */ - Container: 'http://www.w3.org/2000/01/rdf-schema#Container', - - /** Class of container membership properties (rdf:_1, rdf:_2, …). */ - ContainerMembershipProperty: - 'http://www.w3.org/2000/01/rdf-schema#ContainerMembershipProperty', - - /** Relates a class to its superclass. */ - subClassOf: 'http://www.w3.org/2000/01/rdf-schema#subClassOf', - - /** Relates a property to its super-property. */ - subPropertyOf: 'http://www.w3.org/2000/01/rdf-schema#subPropertyOf', - - /** Domain constraint for a property. */ - domain: 'http://www.w3.org/2000/01/rdf-schema#domain', - - /** Range constraint for a property. */ - range: 'http://www.w3.org/2000/01/rdf-schema#range', - - /** Membership relation between containers and members. */ - member: 'http://www.w3.org/2000/01/rdf-schema#member', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* OWL – Web Ontology Language */ -/* ======================================================================= */ - -/** - * OWL – Web Ontology Language (OWL 2 core). - * - * **Intent** - * OWL lets you define more expressive ontologies than RDFS: - * - Equivalence and disjointness between classes and properties. - * - Property characteristics (functional, inverse functional, symmetric, etc.). - * - Complex class expressions (restrictions, intersections, unions). - * - * **Typical uses** - * - Reasoner-backed knowledge graphs. - * - Entity resolution (owl:sameAs). - * - Complex domain models and validation. - * - * @example sameAs for entity resolution - * ```ts - * import { OWL } from './namespaces.ts' - * - * where(triple('?comic', OWL.sameAs, '?externalComic')) - * ``` - */ -export const OWL: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/2002/07/owl#', - - // Core classes - - /** An ontology (document-level resource). */ - Ontology: 'http://www.w3.org/2002/07/owl#Ontology', - - /** Class of OWL classes. */ - Class: 'http://www.w3.org/2002/07/owl#Class', - - /** Top of the class hierarchy (everything is an owl:Thing). */ - Thing: 'http://www.w3.org/2002/07/owl#Thing', - - /** Bottom of the class hierarchy (no instances). */ - Nothing: 'http://www.w3.org/2002/07/owl#Nothing', - - // Properties - - /** Object property (links individuals to individuals). */ - ObjectProperty: 'http://www.w3.org/2002/07/owl#ObjectProperty', - - /** Datatype property (links individuals to literals). */ - DatatypeProperty: 'http://www.w3.org/2002/07/owl#DatatypeProperty', - - /** Annotation property (labels, comments, etc.). */ - AnnotationProperty: 'http://www.w3.org/2002/07/owl#AnnotationProperty', - - /** Functional property (at most one value). */ - FunctionalProperty: 'http://www.w3.org/2002/07/owl#FunctionalProperty', - - /** Inverse functional property (inverse has at most one value). */ - InverseFunctionalProperty: - 'http://www.w3.org/2002/07/owl#InverseFunctionalProperty', - - /** Symmetric property (A R B ⇒ B R A). */ - SymmetricProperty: 'http://www.w3.org/2002/07/owl#SymmetricProperty', - - /** Transitive property (A R B ∧ B R C ⇒ A R C). */ - TransitiveProperty: 'http://www.w3.org/2002/07/owl#TransitiveProperty', - - /** Asymmetric property. */ - AsymmetricProperty: 'http://www.w3.org/2002/07/owl#AsymmetricProperty', - - /** Reflexive property (every individual relates to itself). */ - ReflexiveProperty: 'http://www.w3.org/2002/07/owl#ReflexiveProperty', - - /** Irreflexive property (no individual relates to itself). */ - IrreflexiveProperty: 'http://www.w3.org/2002/07/owl#IrreflexiveProperty', - - /** Declares two properties as inverses. */ - inverseOf: 'http://www.w3.org/2002/07/owl#inverseOf', - - // Equivalence & disjointness - - /** Two resources refer to the same real-world entity. */ - sameAs: 'http://www.w3.org/2002/07/owl#sameAs', - - /** Two individuals are explicitly different. */ - differentFrom: 'http://www.w3.org/2002/07/owl#differentFrom', - - /** Classes with identical instances. */ - equivalentClass: 'http://www.w3.org/2002/07/owl#equivalentClass', - - /** Properties with identical extension. */ - equivalentProperty: - 'http://www.w3.org/2002/07/owl#equivalentProperty', - - /** Disjoint classes (no shared instances). */ - disjointWith: 'http://www.w3.org/2002/07/owl#disjointWith', - - // Class constructors - - /** Class of restrictions. */ - Restriction: 'http://www.w3.org/2002/07/owl#Restriction', - - /** Property being restricted. */ - onProperty: 'http://www.w3.org/2002/07/owl#onProperty', - - /** All values must be from this class. */ - allValuesFrom: 'http://www.w3.org/2002/07/owl#allValuesFrom', - - /** At least one value must be from this class. */ - someValuesFrom: 'http://www.w3.org/2002/07/owl#someValuesFrom', - - /** Property must have the given value. */ - hasValue: 'http://www.w3.org/2002/07/owl#hasValue', - - /** Exact cardinality restriction. */ - cardinality: 'http://www.w3.org/2002/07/owl#cardinality', - - /** Minimum cardinality restriction. */ - minCardinality: 'http://www.w3.org/2002/07/owl#minCardinality', - - /** Maximum cardinality restriction. */ - maxCardinality: 'http://www.w3.org/2002/07/owl#maxCardinality', - - /** Intersection of multiple classes. */ - intersectionOf: 'http://www.w3.org/2002/07/owl#intersectionOf', - - /** Union of multiple classes. */ - unionOf: 'http://www.w3.org/2002/07/owl#unionOf', - - /** Complement of a class. */ - complementOf: 'http://www.w3.org/2002/07/owl#complementOf', - - /** Enumeration of individuals forming a class. */ - oneOf: 'http://www.w3.org/2002/07/owl#oneOf', - - // Individuals - - /** Named individual (explicitly named resource). */ - NamedIndividual: - 'http://www.w3.org/2002/07/owl#NamedIndividual', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* FOAF – Friend of a Friend */ -/* ======================================================================= */ - -/** - * FOAF – Friend of a Friend. - * - * **Intent** - * FOAF is a classic vocabulary for modeling: - * - People (names, accounts, profiles) - * - Organizations and groups - * - Social relationships (`foaf:knows`) - * - * **Typical uses** - * - Person profiles and social graphs. - * - Linking users to pages, images, and accounts. - * - * @example Basic person query - * ```ts - * import { RDF, FOAF } from './namespaces.ts' - * - * select(['?name', '?email']) - * .where(triple('?person', RDF.type, FOAF.Person)) - * .where(triple('?person', FOAF.name, '?name')) - * .optional(triple('?person', FOAF.mbox, '?email')) - * ``` - */ -export const FOAF: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://xmlns.com/foaf/0.1/', - - // Core classes - - /** Generic agent (person, organization, software, etc.). */ - Agent: 'http://xmlns.com/foaf/0.1/Agent', - - /** A person. */ - Person: 'http://xmlns.com/foaf/0.1/Person', - - /** An organization. */ - Organization: 'http://xmlns.com/foaf/0.1/Organization', - - /** A group of agents. */ - Group: 'http://xmlns.com/foaf/0.1/Group', - - /** A document (web page, file, etc.). */ - Document: 'http://xmlns.com/foaf/0.1/Document', - - /** An image (photo, avatar, etc.). */ - Image: 'http://xmlns.com/foaf/0.1/Image', - - /** A project. */ - Project: 'http://xmlns.com/foaf/0.1/Project', - - /** An online account. */ - OnlineAccount: 'http://xmlns.com/foaf/0.1/OnlineAccount', - - // Descriptive properties - - /** Name of a person or thing (often full name). */ - name: 'http://xmlns.com/foaf/0.1/name', - - /** Given / first name. */ - givenName: 'http://xmlns.com/foaf/0.1/givenName', - - /** Family / last name. */ - familyName: 'http://xmlns.com/foaf/0.1/familyName', - - /** Nickname or handle. */ - nick: 'http://xmlns.com/foaf/0.1/nick', - - /** Title (Mr, Ms, Dr, etc.). */ - title: 'http://xmlns.com/foaf/0.1/title', - - /** Gender string (not standardized, but commonly used). */ - gender: 'http://xmlns.com/foaf/0.1/gender', - - /** Age in years. */ - age: 'http://xmlns.com/foaf/0.1/age', - - /** Birthday (often xsd:date). */ - birthday: 'http://xmlns.com/foaf/0.1/birthday', - - // Contact & web presence - - /** Email address (usually as mailto: IRI). */ - mbox: 'http://xmlns.com/foaf/0.1/mbox', - - /** SHA1 hash of email (privacy-friendly ID). */ - mbox_sha1sum: 'http://xmlns.com/foaf/0.1/mbox_sha1sum', - - /** Phone number. */ - phone: 'http://xmlns.com/foaf/0.1/phone', - - /** Homepage of a person or thing. */ - homepage: 'http://xmlns.com/foaf/0.1/homepage', - - /** Weblog/blog. */ - weblog: 'http://xmlns.com/foaf/0.1/weblog', - - /** Generic page about the thing. */ - page: 'http://xmlns.com/foaf/0.1/page', - - // Social graph - - /** Person knows another person. */ - knows: 'http://xmlns.com/foaf/0.1/knows', - - /** Membership of a group. */ - member: 'http://xmlns.com/foaf/0.1/member', - - // Images / depictions - - /** An image representing the thing. */ - img: 'http://xmlns.com/foaf/0.1/img', - - /** An image that depicts the resource. */ - depiction: 'http://xmlns.com/foaf/0.1/depiction', - - /** Resource depicted in an image. */ - depicts: 'http://xmlns.com/foaf/0.1/depicts', - - // Accounts - - /** Online account belonging to the agent. */ - account: 'http://xmlns.com/foaf/0.1/account', - - /** Username of an online account. */ - accountName: 'http://xmlns.com/foaf/0.1/accountName', - - /** Service homepage of an online account (e.g., https://twitter.com). */ - accountServiceHomepage: - 'http://xmlns.com/foaf/0.1/accountServiceHomepage', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* Schema.org – curated subset */ -/* ======================================================================= */ - -/** - * Schema.org – structured data for the web. - * - * **Intent** - * Schema.org is a large vocabulary used for SEO, rich snippets, and web-structured - * data (products, places, events, articles, organizations, etc.). - * - * **Typical uses** - * - Product catalogs, prices, availability. - * - Organizations and locations. - * - Articles, events, and creative works. - * - * This is a **curated subset**, not the full Schema.org universe. - * - * @example Product query - * ```ts - * import { RDF, SCHEMA } from './namespaces.ts' - * - * select(['?name', '?price']) - * .where(triple('?p', RDF.type, SCHEMA.Product)) - * .where(triple('?p', SCHEMA.name, '?name')) - * .where(triple('?p', SCHEMA.price, '?price')) - * ``` - */ -export const SCHEMA: NamespaceLike = /* @__PURE__ */ { - // Note: schema.org now often uses https:// in docs, but http:// IRIs are widely used. - _namespace: 'http://schema.org/', - - // Generic properties - - /** Name of the thing. */ - name: 'http://schema.org/name', - - /** An alias for the item. */ - alternateName: 'http://schema.org/alternateName', - - /** Description of the thing. */ - description: 'http://schema.org/description', - - /** URL of the thing. */ - url: 'http://schema.org/url', - - /** Representative image. */ - image: 'http://schema.org/image', - - /** Identifier (could be SKU, ISBN, etc.). */ - identifier: 'http://schema.org/identifier', - - /** Link to an unambiguous reference (e.g. Wikidata). */ - sameAs: 'http://schema.org/sameAs', - - // Types - - /** A person. */ - Person: 'http://schema.org/Person', - - /** An organization. */ - Organization: 'http://schema.org/Organization', - - /** A product. */ - Product: 'http://schema.org/Product', - - /** A place. */ - Place: 'http://schema.org/Place', - - /** An event. */ - Event: 'http://schema.org/Event', - - /** A creative work. */ - CreativeWork: 'http://schema.org/CreativeWork', - - /** An article (blog post, news, etc.). */ - Article: 'http://schema.org/Article', - - /** An offer to sell or lease something. */ - Offer: 'http://schema.org/Offer', - - /** Aggregate offer (min/max prices, etc.). */ - AggregateOffer: 'http://schema.org/AggregateOffer', - - // Product / commerce - - /** Price of an offer or product. */ - price: 'http://schema.org/price', - - /** Price currency (ISO 4217). */ - priceCurrency: 'http://schema.org/priceCurrency', - - /** Availability status (InStock, OutOfStock, etc.). */ - availability: 'http://schema.org/availability', - - /** Stock keeping unit. */ - sku: 'http://schema.org/sku', - - /** Brand associated with the product. */ - brand: 'http://schema.org/brand', - - /** Item condition (e.g., NewCondition). */ - itemCondition: 'http://schema.org/itemCondition', - - /** Offers associated with a product. */ - offers: 'http://schema.org/offers', - - // Ratings & reviews - - /** Aggregate rating node. */ - AggregateRating: 'http://schema.org/AggregateRating', - - /** Property linking a thing to its aggregate rating. */ - aggregateRating: 'http://schema.org/aggregateRating', - - /** Rating value (numeric). */ - ratingValue: 'http://schema.org/ratingValue', - - /** Count of reviews. */ - reviewCount: 'http://schema.org/reviewCount', - - /** Review type. */ - Review: 'http://schema.org/Review', - - /** Property linking a thing to its reviews. */ - review: 'http://schema.org/review', - - // Person / contact - - /** Email address. */ - email: 'http://schema.org/email', - - /** Telephone number. */ - telephone: 'http://schema.org/telephone', - - /** Job title. */ - jobTitle: 'http://schema.org/jobTitle', - - /** Organization a person works for. */ - worksFor: 'http://schema.org/worksFor', - - /** Postal address. */ - address: 'http://schema.org/address', - - // Postal address fields - - /** Postal address type. */ - PostalAddress: 'http://schema.org/PostalAddress', - - /** Street address. */ - streetAddress: 'http://schema.org/streetAddress', - - /** City or locality. */ - addressLocality: 'http://schema.org/addressLocality', - - /** Region or state. */ - addressRegion: 'http://schema.org/addressRegion', - - /** Postal code. */ - postalCode: 'http://schema.org/postalCode', - - /** Country. */ - addressCountry: 'http://schema.org/addressCountry', - - // Authorship / publication - - /** Author of content. */ - author: 'http://schema.org/author', - - /** Creator (alias/related to author). */ - creator: 'http://schema.org/creator', - - /** Publisher of a creative work. */ - publisher: 'http://schema.org/publisher', - - /** Publication date. */ - datePublished: 'http://schema.org/datePublished', - - /** Last modification date. */ - dateModified: 'http://schema.org/dateModified', - - // Events / temporal - - /** Start date/time of an event. */ - startDate: 'http://schema.org/startDate', - - /** End date/time of an event. */ - endDate: 'http://schema.org/endDate', - - /** Location of an event or organization. */ - location: 'http://schema.org/location', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* SD – SPARQL Service Description */ -/* ======================================================================= */ - -/** - * SD – SPARQL Service Description vocabulary. - * - * **Intent** - * This vocabulary describes SPARQL endpoints and their capabilities, usually - * exposed at the service URL as RDF. It tells you: - * - What datasets and graphs exist. - * - Which features, result formats, and languages are supported. - * - * **Typical uses** - * - Inspecting an endpoint’s capabilities before deciding which features to use. - */ -export const SD: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/ns/sparql-service-description#', - - Service: 'http://www.w3.org/ns/sparql-service-description#Service', - Dataset: 'http://www.w3.org/ns/sparql-service-description#Dataset', - Graph: 'http://www.w3.org/ns/sparql-service-description#Graph', - - endpoint: 'http://www.w3.org/ns/sparql-service-description#endpoint', - url: 'http://www.w3.org/ns/sparql-service-description#url', - - defaultDataset: - 'http://www.w3.org/ns/sparql-service-description#defaultDataset', - namedGraph: - 'http://www.w3.org/ns/sparql-service-description#namedGraph', - name: 'http://www.w3.org/ns/sparql-service-description#name', - graph: 'http://www.w3.org/ns/sparql-service-description#graph', - - feature: 'http://www.w3.org/ns/sparql-service-description#feature', - supportedLanguage: - 'http://www.w3.org/ns/sparql-service-description#supportedLanguage', - languageExtension: - 'http://www.w3.org/ns/sparql-service-description#languageExtension', - - defaultEntailmentRegime: - 'http://www.w3.org/ns/sparql-service-description#defaultEntailmentRegime', - entailmentRegime: - 'http://www.w3.org/ns/sparql-service-description#entailmentRegime', - - extensionFunction: - 'http://www.w3.org/ns/sparql-service-description#extensionFunction', - extensionAggregate: - 'http://www.w3.org/ns/sparql-service-description#extensionAggregate', - - resultFormat: - 'http://www.w3.org/ns/sparql-service-description#resultFormat', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* SHACL – Shapes Constraint Language */ -/* ======================================================================= */ - -/** - * SHACL – Shapes Constraint Language. - * - * **Intent** - * SHACL lets you define validation rules ("shapes") for RDF graphs: - * - Cardinality constraints (`sh:minCount`, `sh:maxCount`) - * - Datatype and class constraints - * - Complex logical combinations of constraints - * - * **Typical uses** - * - Validating datasets before loading them into a KG. - * - Encoding business rules and invariants in RDF form. - */ -export const SHACL: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/ns/shacl#', - - // Core classes - Shape: 'http://www.w3.org/ns/shacl#Shape', - NodeShape: 'http://www.w3.org/ns/shacl#NodeShape', - PropertyShape: 'http://www.w3.org/ns/shacl#PropertyShape', - - // Targeting - targetClass: 'http://www.w3.org/ns/shacl#targetClass', - targetNode: 'http://www.w3.org/ns/shacl#targetNode', - targetSubjectsOf: 'http://www.w3.org/ns/shacl#targetSubjectsOf', - targetObjectsOf: 'http://www.w3.org/ns/shacl#targetObjectsOf', - - // Structure - path: 'http://www.w3.org/ns/shacl#path', - property: 'http://www.w3.org/ns/shacl#property', - node: 'http://www.w3.org/ns/shacl#node', - class: 'http://www.w3.org/ns/shacl#class', - datatype: 'http://www.w3.org/ns/shacl#datatype', - nodeKind: 'http://www.w3.org/ns/shacl#nodeKind', - - // Cardinality - minCount: 'http://www.w3.org/ns/shacl#minCount', - maxCount: 'http://www.w3.org/ns/shacl#maxCount', - - // Value ranges - minInclusive: 'http://www.w3.org/ns/shacl#minInclusive', - maxInclusive: 'http://www.w3.org/ns/shacl#maxInclusive', - minExclusive: 'http://www.w3.org/ns/shacl#minExclusive', - maxExclusive: 'http://www.w3.org/ns/shacl#maxExclusive', - - // Patterns and enumeration - pattern: 'http://www.w3.org/ns/shacl#pattern', - flags: 'http://www.w3.org/ns/shacl#flags', - in: 'http://www.w3.org/ns/shacl#in', - hasValue: 'http://www.w3.org/ns/shacl#hasValue', - - // Logical combinations - and: 'http://www.w3.org/ns/shacl#and', - or: 'http://www.w3.org/ns/shacl#or', - not: 'http://www.w3.org/ns/shacl#not', - xone: 'http://www.w3.org/ns/shacl#xone', - - // Qualified value shapes - qualifiedValueShape: - 'http://www.w3.org/ns/shacl#qualifiedValueShape', - qualifiedMinCount: - 'http://www.w3.org/ns/shacl#qualifiedMinCount', - qualifiedMaxCount: - 'http://www.w3.org/ns/shacl#qualifiedMaxCount', - - // Closed shapes - closed: 'http://www.w3.org/ns/shacl#closed', - ignoredProperties: - 'http://www.w3.org/ns/shacl#ignoredProperties', - - // Validation results - ValidationReport: - 'http://www.w3.org/ns/shacl#ValidationReport', - ValidationResult: - 'http://www.w3.org/ns/shacl#ValidationResult', - conforms: 'http://www.w3.org/ns/shacl#conforms', - result: 'http://www.w3.org/ns/shacl#result', - focusNode: 'http://www.w3.org/ns/shacl#focusNode', - resultPath: 'http://www.w3.org/ns/shacl#resultPath', - value: 'http://www.w3.org/ns/shacl#value', - resultMessage: - 'http://www.w3.org/ns/shacl#resultMessage', - resultSeverity: - 'http://www.w3.org/ns/shacl#resultSeverity', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* SKOS – Simple Knowledge Organization System */ -/* ======================================================================= */ - -/** - * SKOS – Simple Knowledge Organization System. - * - * **Intent** - * SKOS is used for thesauri, taxonomies, classification schemes, and controlled - * vocabularies (concepts, labels, broader/narrower relations). - * - * **Typical uses** - * - Modeling genres, subject headings, tag vocabularies. - * - Multi-level classification systems for products, story arcs, etc. - */ -export const SKOS: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/2004/02/skos/core#', - - // Core classes - Concept: 'http://www.w3.org/2004/02/skos/core#Concept', - ConceptScheme: - 'http://www.w3.org/2004/02/skos/core#ConceptScheme', - Collection: 'http://www.w3.org/2004/02/skos/core#Collection', - OrderedCollection: - 'http://www.w3.org/2004/02/skos/core#OrderedCollection', - - // Labelling - prefLabel: 'http://www.w3.org/2004/02/skos/core#prefLabel', - altLabel: 'http://www.w3.org/2004/02/skos/core#altLabel', - hiddenLabel: - 'http://www.w3.org/2004/02/skos/core#hiddenLabel', - notation: 'http://www.w3.org/2004/02/skos/core#notation', - - // Documentation - note: 'http://www.w3.org/2004/02/skos/core#note', - definition: - 'http://www.w3.org/2004/02/skos/core#definition', - scopeNote: 'http://www.w3.org/2004/02/skos/core#scopeNote', - example: 'http://www.w3.org/2004/02/skos/core#example', - - // Hierarchies - broader: 'http://www.w3.org/2004/02/skos/core#broader', - narrower: 'http://www.w3.org/2004/02/skos/core#narrower', - broaderTransitive: - 'http://www.w3.org/2004/02/skos/core#broaderTransitive', - narrowerTransitive: - 'http://www.w3.org/2004/02/skos/core#narrowerTransitive', - related: 'http://www.w3.org/2004/02/skos/core#related', - - // Schemes - inScheme: 'http://www.w3.org/2004/02/skos/core#inScheme', - hasTopConcept: - 'http://www.w3.org/2004/02/skos/core#hasTopConcept', - topConceptOf: - 'http://www.w3.org/2004/02/skos/core#topConceptOf', - - // Collections - member: 'http://www.w3.org/2004/02/skos/core#member', - memberList: - 'http://www.w3.org/2004/02/skos/core#memberList', - - // Mappings - semanticRelation: - 'http://www.w3.org/2004/02/skos/core#semanticRelation', - mappingRelation: - 'http://www.w3.org/2004/02/skos/core#mappingRelation', - exactMatch: 'http://www.w3.org/2004/02/skos/core#exactMatch', - closeMatch: 'http://www.w3.org/2004/02/skos/core#closeMatch', - broadMatch: 'http://www.w3.org/2004/02/skos/core#broadMatch', - narrowMatch: - 'http://www.w3.org/2004/02/skos/core#narrowMatch', - relatedMatch: - 'http://www.w3.org/2004/02/skos/core#relatedMatch', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* PROV-O – Provenance ontology */ -/* ======================================================================= */ - -/** - * PROV – W3C Provenance Ontology. - * - * **Intent** - * PROV describes how data was produced: - * - Which activities generated which entities. - * - Which agents were responsible. - * - When those activities happened. - * - * **Typical uses** - * - Tracking data lineage. - * - Recording who asserted which statements and when. - */ -export const PROV: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/ns/prov#', - - // Core classes - Entity: 'http://www.w3.org/ns/prov#Entity', - Activity: 'http://www.w3.org/ns/prov#Activity', - Agent: 'http://www.w3.org/ns/prov#Agent', - - // Core relations - wasGeneratedBy: - 'http://www.w3.org/ns/prov#wasGeneratedBy', - used: 'http://www.w3.org/ns/prov#used', - wasDerivedFrom: - 'http://www.w3.org/ns/prov#wasDerivedFrom', - wasAttributedTo: - 'http://www.w3.org/ns/prov#wasAttributedTo', - wasAssociatedWith: - 'http://www.w3.org/ns/prov#wasAssociatedWith', - actedOnBehalfOf: - 'http://www.w3.org/ns/prov#actedOnBehalfOf', - wasInformedBy: - 'http://www.w3.org/ns/prov#wasInformedBy', - wasInfluencedBy: - 'http://www.w3.org/ns/prov#wasInfluencedBy', - - // Qualifiers - startedAtTime: - 'http://www.w3.org/ns/prov#startedAtTime', - endedAtTime: 'http://www.w3.org/ns/prov#endedAtTime', - atLocation: 'http://www.w3.org/ns/prov#atLocation', - hadPrimarySource: - 'http://www.w3.org/ns/prov#hadPrimarySource', - specializationOf: - 'http://www.w3.org/ns/prov#specializationOf', - alternateOf: 'http://www.w3.org/ns/prov#alternateOf', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* VoID – Vocabulary of Interlinked Datasets */ -/* ======================================================================= */ - -/** - * VoID – Vocabulary of Interlinked Datasets. - * - * **Intent** - * VoID is used to describe dataset-level metadata: - * - Size, links, partitions, example resources. - * - SPARQL endpoints and data dumps. - */ -export const VOID: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://rdfs.org/ns/void#', - - Dataset: 'http://rdfs.org/ns/void#Dataset', - Linkset: 'http://rdfs.org/ns/void#Linkset', - - subset: 'http://rdfs.org/ns/void#subset', - target: 'http://rdfs.org/ns/void#target', - linkPredicate: 'http://rdfs.org/ns/void#linkPredicate', - - triples: 'http://rdfs.org/ns/void#triples', - distinctSubjects: - 'http://rdfs.org/ns/void#distinctSubjects', - distinctObjects: - 'http://rdfs.org/ns/void#distinctObjects', - - classPartition: - 'http://rdfs.org/ns/void#classPartition', - propertyPartition: - 'http://rdfs.org/ns/void#propertyPartition', - - vocabulary: 'http://rdfs.org/ns/void#vocabulary', - dataDump: 'http://rdfs.org/ns/void#dataDump', - sparqlEndpoint: - 'http://rdfs.org/ns/void#sparqlEndpoint', - uriSpace: 'http://rdfs.org/ns/void#uriSpace', - exampleResource: - 'http://rdfs.org/ns/void#exampleResource', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* DCTERMS – Dublin Core Terms */ -/* ======================================================================= */ - -/** - * DCTERMS – Dublin Core Metadata Terms. - * - * **Intent** - * Dublin Core is a generic metadata vocabulary used all over the web - * and in many RDF datasets for titles, creators, dates, rights, etc. - */ -export const DCTERMS: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://purl.org/dc/terms/', - - // Core DC elements (terms flavor) - title: 'http://purl.org/dc/terms/title', - creator: 'http://purl.org/dc/terms/creator', - subject: 'http://purl.org/dc/terms/subject', - description: 'http://purl.org/dc/terms/description', - publisher: 'http://purl.org/dc/terms/publisher', - contributor: 'http://purl.org/dc/terms/contributor', - date: 'http://purl.org/dc/terms/date', - type: 'http://purl.org/dc/terms/type', - format: 'http://purl.org/dc/terms/format', - identifier: 'http://purl.org/dc/terms/identifier', - source: 'http://purl.org/dc/terms/source', - language: 'http://purl.org/dc/terms/language', - relation: 'http://purl.org/dc/terms/relation', - coverage: 'http://purl.org/dc/terms/coverage', - rights: 'http://purl.org/dc/terms/rights', - - // Common refinements - created: 'http://purl.org/dc/terms/created', - modified: 'http://purl.org/dc/terms/modified', - issued: 'http://purl.org/dc/terms/issued', - license: 'http://purl.org/dc/terms/license', - rightsHolder: - 'http://purl.org/dc/terms/rightsHolder', - spatial: 'http://purl.org/dc/terms/spatial', - temporal: 'http://purl.org/dc/terms/temporal', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* GEO – WGS84 Geo Position */ -/* ======================================================================= */ - -/** - * GEO – WGS84 basic geo vocabulary. - * - * **Intent** - * Simple latitude/longitude/altitude vocabulary for expressing points - * on Earth (WGS84). - */ -export const GEO: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/2003/01/geo/wgs84_pos#', - - SpatialThing: - 'http://www.w3.org/2003/01/geo/wgs84_pos#SpatialThing', - Point: 'http://www.w3.org/2003/01/geo/wgs84_pos#Point', - - lat: 'http://www.w3.org/2003/01/geo/wgs84_pos#lat', - long: 'http://www.w3.org/2003/01/geo/wgs84_pos#long', - alt: 'http://www.w3.org/2003/01/geo/wgs84_pos#alt', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* GeoSPARQL – ontology and functions */ -/* ======================================================================= */ - -/** - * GEOSPARQL – ontology for geospatial data. - * - * **Intent** - * GeoSPARQL defines: - * - Feature / geometry classes - * - Datatypes for geometries (WKT, GML) - * - Topological relations (within, contains, etc.) - */ -export const GEOSPARQL: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.opengis.net/ont/geosparql#', - - // Core classes - Feature: 'http://www.opengis.net/ont/geosparql#Feature', - Geometry: - 'http://www.opengis.net/ont/geosparql#Geometry', - - // Feature-geometry relations - hasGeometry: - 'http://www.opengis.net/ont/geosparql#hasGeometry', - hasDefaultGeometry: - 'http://www.opengis.net/ont/geosparql#hasDefaultGeometry', - - // Geometry literal datatypes - wktLiteral: - 'http://www.opengis.net/ont/geosparql#wktLiteral', - gmlLiteral: - 'http://www.opengis.net/ont/geosparql#gmlLiteral', - - // Common topological relations - sfWithin: - 'http://www.opengis.net/ont/geosparql#sfWithin', - sfContains: - 'http://www.opengis.net/ont/geosparql#sfContains', - sfOverlaps: - 'http://www.opengis.net/ont/geosparql#sfOverlaps', - sfIntersects: - 'http://www.opengis.net/ont/geosparql#sfIntersects', -} as const satisfies NamespaceLike; - -/** - * GEOF – GeoSPARQL functions namespace. - * - * **Intent** - * Defines IRI identifiers for spatial functions like distance, buffer, etc., - * used in SPARQL `FILTER` expressions. - */ -export const GEOF: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.opengis.net/def/function/geosparql/', - - distance: - 'http://www.opengis.net/def/function/geosparql/distance', - buffer: - 'http://www.opengis.net/def/function/geosparql/buffer', - envelope: - 'http://www.opengis.net/def/function/geosparql/envelope', - intersection: - 'http://www.opengis.net/def/function/geosparql/intersection', - union: - 'http://www.opengis.net/def/function/geosparql/union', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* SPARQL Results vocabulary */ -/* ======================================================================= */ - -/** - * SPARQL_RESULTS – SPARQL Results vocabulary. - * - * **Intent** - * Used mostly in RDF encodings of SPARQL result sets (e.g., XML/JSON - * structured into RDF). You’ll see these IRIs if you round-trip results - * via RDF form. - */ -export const SPARQL_RESULTS: NamespaceLike = /* @__PURE__ */ { - _namespace: 'http://www.w3.org/2005/sparql-results#', - - ResultSet: - 'http://www.w3.org/2005/sparql-results#ResultSet', - resultVariable: - 'http://www.w3.org/2005/sparql-results#resultVariable', - solution: - 'http://www.w3.org/2005/sparql-results#solution', - binding: 'http://www.w3.org/2005/sparql-results#binding', - variable: - 'http://www.w3.org/2005/sparql-results#variable', - value: 'http://www.w3.org/2005/sparql-results#value', - boolean: - 'http://www.w3.org/2005/sparql-results#boolean', -} as const satisfies NamespaceLike; - -/* ======================================================================= */ -/* Helper: getNamespaceIRI */ -/* ======================================================================= */ - -/** - * Union of all known namespace objects, for convenience. - */ -export type KnownNamespace = - | typeof XSD - | typeof RDF - | typeof RDFS - | typeof OWL - | typeof FOAF - | typeof SCHEMA - | typeof SD - | typeof SHACL - | typeof SKOS - | typeof PROV - | typeof VOID - | typeof DCTERMS - | typeof GEO - | typeof GEOSPARQL - | typeof GEOF - | typeof SPARQL_RESULTS; - -/** - * Get the base namespace IRI for a given vocabulary object. - * - * **Intent** - * Avoid hardcoding namespace IRIs when building `PREFIX` declarations or - * when you need to expose vocabulary metadata in your APIs. - * - * @example Generate PREFIX declarations - * ```ts - * import { RDF, RDFS, SCHEMA, getNamespaceIRI } from './namespaces.ts' - * - * const prefixes = [ - * ['rdf', getNamespaceIRI(RDF)], - * ['rdfs', getNamespaceIRI(RDFS)], - * ['schema', getNamespaceIRI(SCHEMA)], - * ] - * - * const query = select(['?s']) - * .prefixes(prefixes) - * .where(triple('?s', RDF.type, SCHEMA.Product)) - * ``` - */ -export function getNamespaceIRI(ns: KnownNamespace): string { - return ns._namespace; -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5d76757 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "root", + "private": true, + "type": "module", + "workspaces": [ + "packages/*" + ], + "scripts": { + "fmt": "deno task fmt", + "lint": "deno task lint", + "check": "deno task check", + "test": "deno task test", + "bench": "deno task bench", + "verify": "deno task verify" + } +} diff --git a/packages/sparql/.npmignore b/packages/sparql/.npmignore new file mode 100644 index 0000000..db25063 --- /dev/null +++ b/packages/sparql/.npmignore @@ -0,0 +1,4 @@ +*_test.ts +*_bench.ts +_memory_test.ts +*.map diff --git a/packages/sparql/README.md b/packages/sparql/README.md new file mode 100644 index 0000000..f1fbcf9 --- /dev/null +++ b/packages/sparql/README.md @@ -0,0 +1,67 @@ +# `@okikio/sparql` + +SPARQL construction, syntax inspection, and engine-neutral query contracts. + +```ts +import * as sparql from '@okikio/sparql' + +const query = sparql.select(['?name']) + .prefix('schema', 'https://schema.org/') + .where(sparql.triple('?thing', 'schema:name', '?name')) +``` + +## Syntax roles + +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 +``` + +This prevents a complete query/update from being accepted where the SPARQL grammar requires a term, expression, or WHERE fragment. + +## RDF and generated vocabulary terms + +IRI-bearing positions accept native RDF named nodes. Generated vocabulary values therefore compose directly without making this package depend on `@okikio/vocab`: + +```ts +import * as rdf from '@okikio/rdf' +import * as sparql from '@okikio/sparql' +import { Product, name, offers, price } from '@okikio/vocab/schema' + +const query = sparql.select(['?product', '?name']).where( + sparql.triple('?product', rdf.namedNode(rdf.RDF.type), Product), + sparql.triple('?product', name, '?name'), +) +``` + +The same RDF terms work in property paths, graph/update IRIs, datatypes, and prefix declarations: + +```ts +const path = sparql.sequence(offers, price) +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. + +## Execution + +Execution is separate. Use `@okikio/sparql/http`, `@okikio/oxigraph`, `@okikio/comunica`, or another implementation of `Queryable`. + +```ts +interface Queryable { + queryBindings(...): Promise> + queryQuads(...): Promise> + queryBoolean(...): Promise + update(...): Promise +} +``` + +`update()` is the public update operation. A concrete upstream engine can use a different internal method name; for example, Comunica currently exposes `queryVoid()`, which its adapter translates to `update()`. + +See [`../../docs/sparql-mapping.md`](../../docs/sparql-mapping.md). diff --git a/packages/sparql/builder.ts b/packages/sparql/builder.ts new file mode 100644 index 0000000..4efab6d --- /dev/null +++ b/packages/sparql/builder.ts @@ -0,0 +1,381 @@ +/** + * Immutable fluent builders for complete SPARQL query documents. + * + * The builder keeps grammar roles separate. Terms and expressions are not graph + * patterns, graph patterns are not complete queries, and a complete query only + * becomes embeddable through the explicit `asSubquery()` operation. + * + * @module + */ + +import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode, type Namespace } from '@okikio/rdf' +import { + queryDocument, + rawPattern, + toGraphRef, + toRawString, + toVarOrIriRef, + toVarToken, + validateIRI, + validatePrefixName, + type IriInput, + type PatternValue, + type SparqlExpr, + type SparqlQuery, + type SparqlTerm, + type VariableName, +} from './sparql.ts' +import { bind, filter, optional } from './utils.ts' + +/** SELECT projection item accepted by the fluent builder. */ +export type ProjectionItem = string | SparqlTerm | SparqlExpr + +/** SELECT projection or wildcard. */ +export type Projection = readonly ProjectionItem[] | '*' + +/** DESCRIBE target accepted by the fluent builder. */ +export type DescribeItem = string | SparqlTerm | RdfNamedNode + +/** Sort order for ORDER BY clauses. */ +export type SortDirection = 'ASC' | 'DESC' + +/** One ORDER BY variable and optional direction. */ +export interface SortSpec { + readonly variable: string + readonly direction?: SortDirection +} + +/** SELECT duplicate modifier. */ +export type SelectModifier = 'none' | 'distinct' | 'reduced' + +/** + * Immutable query-builder state. + * + * `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 { + readonly type: 'SELECT' | 'ASK' | 'CONSTRUCT' | 'DESCRIBE' + readonly projection: Projection + readonly describe: readonly DescribeItem[] + readonly construct?: PatternValue + readonly prefixes: ReadonlyMap + readonly from: readonly string[] + 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[] + readonly groupBy: readonly string[] + readonly having: readonly SparqlExpr[] + readonly values: ReadonlyMap + readonly limit?: number + readonly offset?: number + readonly modifier: SelectModifier +} + +/** Shared empty state copied by each query-form constructor. */ +const initialState: QueryState = { + type: 'SELECT', + projection: '*', + describe: [], + prefixes: new Map(), + from: [], + fromNamed: [], + where: [], + filters: [], + optional: [], + bindings: [], + unions: [], + sorts: [], + groupBy: [], + having: [], + values: new Map(), + modifier: 'none', +} + +/** Serializes one SELECT projection item without turning arbitrary IRIs into variables. */ +function projectionText(item: ProjectionItem): string { + if (typeof item !== 'string') return item.value + return toVarToken(item) +} + +/** Serializes one DESCRIBE target according to `VarOrIriRef`. */ +function describeText(item: DescribeItem): 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 { + if (typeof value === 'function') { + validateIRI(value.iri) + return value.iri + } + if (isRdfTerm(value)) { + validateIRI(value.value) + return value.value + } + if (typeof value !== 'string') { + const token = value.value.trim() + const iri = token.startsWith('<') && token.endsWith('>') ? token.slice(1, -1) : token + validateIRI(iri) + return iri + } + const iri = toRawString(value) + validateIRI(iri) + return iri +} + +/** Emits one indented pattern while preserving intentional internal newlines. */ +function pushPattern(parts: string[], pattern: PatternValue, 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 + + /** Creates one immutable builder from already-normalized state. */ + private constructor(state: QueryState) { + this.#state = state + } + + /** Starts a SELECT query. */ + static select(projection: Projection = '*'): QueryBuilder { + return new QueryBuilder({ ...initialState, type: 'SELECT', projection }) + } + + /** Starts an ASK query. */ + static ask(): QueryBuilder { + return new QueryBuilder({ ...initialState, type: 'ASK', projection: [] }) + } + + /** + * Starts a CONSTRUCT query. + * + * 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 { + return new QueryBuilder({ + ...initialState, + type: 'CONSTRUCT', + projection: [], + ...(template === undefined ? {} : { construct: template }), + }) + } + + /** Starts a DESCRIBE query over variables and/or explicit RDF named nodes. */ + static describe(resources: readonly DescribeItem[]): QueryBuilder { + if (resources.length === 0) throw new TypeError('DESCRIBE requires at least one target.') + return new QueryBuilder({ ...initialState, type: 'DESCRIBE', projection: [], describe: [...resources] }) + } + + /** Adds a FROM graph IRI. */ + from(graph: IriInput): 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)] }) + } + + /** + * Declares one prefix. + * + * The namespace can be a string, RDF named node, SPARQL IRI term, or an + * `@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 { + validatePrefixName(name) + const prefixes = new Map(this.#state.prefixes) + prefixes.set(name, namespaceText(iri)) + return new QueryBuilder({ ...this.#state, prefixes }) + } + + /** Adds graph patterns to WHERE. */ + where(...patterns: readonly PatternValue[]): QueryBuilder { + return new QueryBuilder({ ...this.#state, where: [...this.#state.where, ...patterns] }) + } + + /** Adds FILTER graph-pattern clauses from expressions. */ + filter(...conditions: readonly SparqlExpr[]): QueryBuilder { + return new QueryBuilder({ + ...this.#state, + filters: [...this.#state.filters, ...conditions.map((value) => filter(value))], + }) + } + + /** Adds OPTIONAL graph-pattern clauses. */ + optional(...patterns: readonly PatternValue[]): QueryBuilder { + return new QueryBuilder({ + ...this.#state, + optional: [...this.#state.optional, ...patterns.map((value) => optional(value))], + }) + } + + /** Adds a BIND clause with an explicit output variable. */ + bind(expression: SparqlExpr | SparqlTerm, variable: VariableName): QueryBuilder { + return new QueryBuilder({ + ...this.#state, + bindings: [...this.#state.bindings, bind(expression, variable)], + }) + } + + /** + * Adds one UNION expression containing two or more graph-pattern branches. + * + * 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.') + return new QueryBuilder({ ...this.#state, unions: [...this.#state.unions, [...branches]] }) + } + + /** Adds GROUP BY variables. */ + groupBy(...variables: readonly VariableName[]): QueryBuilder { + return new QueryBuilder({ + ...this.#state, + groupBy: [...this.#state.groupBy, ...variables.map((value) => toVarToken(value))], + }) + } + + /** Adds HAVING expressions. */ + having(...conditions: readonly SparqlExpr[]): QueryBuilder { + return new QueryBuilder({ ...this.#state, having: [...this.#state.having, ...conditions] }) + } + + /** Adds one ORDER BY variable. */ + orderBy(variable: VariableName, direction?: SortDirection): QueryBuilder { + const sort = direction === undefined + ? { variable: toVarToken(variable) } + : { variable: toVarToken(variable), direction } + return new QueryBuilder({ ...this.#state, sorts: [...this.#state.sorts, sort] }) + } + + /** 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}.`) + 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}.`) + return new QueryBuilder({ ...this.#state, offset: count }) + } + + /** Returns a builder with the SELECT `DISTINCT` solution modifier without mutating this builder. */ + distinct(): QueryBuilder { + return new QueryBuilder({ ...this.#state, modifier: 'distinct' }) + } + + /** Returns a builder with the SELECT `REDUCED` solution modifier without mutating this builder. */ + reduced(): QueryBuilder { + return new QueryBuilder({ ...this.#state, modifier: 'reduced' }) + } + + /** Adds one single-variable VALUES data block. */ + values(variable: VariableName, values: readonly SparqlTerm[]): 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 { + return rawPattern(`{ ${this.build().value} }`) + } + + /** Builds one complete query document. */ + build(): SparqlQuery { + 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 projection = this.#state.projection === '*' + ? '*' + : this.#state.projection.map(projectionText).join(' ') + parts.push(`SELECT ${modifier}${projection}`) + } else if (this.#state.type === 'ASK') { + parts.push('ASK') + } else if (this.#state.type === 'DESCRIBE') { + parts.push(`DESCRIBE ${this.#state.describe.map(describeText).join(' ')}`) + } else if (this.#state.construct) { + parts.push('CONSTRUCT {') + pushPattern(parts, this.#state.construct) + parts.push('}') + } else { + parts.push('CONSTRUCT') + } + + for (const graph of this.#state.from) parts.push(`FROM ${graph}`) + for (const graph of this.#state.fromNamed) parts.push(`FROM NAMED ${graph}`) + + const hasPattern = this.#state.where.length > 0 || this.#state.filters.length > 0 || + this.#state.optional.length > 0 || this.#state.bindings.length > 0 || + this.#state.unions.length > 0 || this.#state.values.size > 0 + + if (this.#state.type === 'CONSTRUCT' && !this.#state.construct) { + parts.push('WHERE {') + } else if (hasPattern || this.#state.type === 'ASK' || this.#state.type === 'CONSTRUCT') { + parts.push('WHERE {') + } + + if (parts.at(-1) === 'WHERE {') { + for (const [variable, values] of this.#state.values) { + parts.push(` VALUES ${variable} { ${values.map((value) => value.value).join(' ')} }`) + } + for (const pattern of this.#state.where) pushPattern(parts, pattern) + for (const pattern of this.#state.filters) pushPattern(parts, pattern) + for (const pattern of this.#state.optional) pushPattern(parts, pattern) + for (const pattern of this.#state.bindings) pushPattern(parts, pattern) + for (const union of this.#state.unions) { + union.forEach((branch, index) => { + if (index > 0) parts.push(' UNION') + parts.push(' {') + pushPattern(parts, branch, 2) + parts.push(' }') + }) + } + parts.push('}') + } + + if (this.#state.groupBy.length > 0) parts.push(`GROUP BY ${this.#state.groupBy.join(' ')}`) + if (this.#state.having.length > 0) { + 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(' ')}`) + } + if (this.#state.limit !== undefined) parts.push(`LIMIT ${this.#state.limit}`) + if (this.#state.offset !== undefined) parts.push(`OFFSET ${this.#state.offset}`) + + return queryDocument(parts.join('\n')) + } +} + +/** Starts a SELECT query builder. */ +export const select = QueryBuilder.select +/** Starts an ASK query builder. */ +export const ask = QueryBuilder.ask +/** Starts a CONSTRUCT query builder. */ +export const construct = QueryBuilder.construct +/** Starts a DESCRIBE query builder. */ +export const describe = QueryBuilder.describe + +/** Explicitly wraps a built query as a subquery graph pattern. */ +export function subquery(builder: QueryBuilder): PatternValue { + return builder.asSubquery() +} diff --git a/packages/sparql/builder_bench.ts b/packages/sparql/builder_bench.ts new file mode 100644 index 0000000..6f87ffe --- /dev/null +++ b/packages/sparql/builder_bench.ts @@ -0,0 +1,31 @@ +/** Decision benchmark for structured SPARQL construction overhead. @module */ + +import { bench, do_not_optimize, group, run } from 'mitata' +import { select } from './builder.ts' +import { triple } from './patterns/triples.ts' +import { namedNode } from '@okikio/rdf' + +const ROWS = 1_000 +const name = namedNode('https://schema.org/name') +const patterns = Array.from( + { length: ROWS }, + (_, index) => triple(`product${index}`, name, `?name${index}`), +) +const directPatterns = patterns.map((pattern) => pattern.value) + +const structured = (): string => select('*').where(...patterns).build().value +const direct = (): string => `SELECT *\nWHERE {\n${directPatterns.map((value) => ` ${value}`).join('\n')}\n}` + +if (structured() !== direct()) throw new Error('SPARQL builder benchmark oracle failed.') + +group('sparql structured construction: 1k triple patterns', () => { + bench('direct string assembly baseline', () => { + do_not_optimize(direct()) + }) + + bench('immutable QueryBuilder', () => { + do_not_optimize(structured()) + }) +}) + +await run() diff --git a/packages/sparql/builder_test.ts b/packages/sparql/builder_test.ts new file mode 100644 index 0000000..f84971f --- /dev/null +++ b/packages/sparql/builder_test.ts @@ -0,0 +1,81 @@ +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, + strlit, + subquery, + triple, + v, +} from './mod.ts' + +describe('@okikio/sparql query builder', () => { + it('is immutable when clauses are added', () => { + const base = select(['name']) + const filtered = base.where(triple('?thing', 'schema:name', '?name')) + expect(base.build().value).toBe('SELECT ?name') + expect(filtered.build().value.includes('?thing schema:name ?name .')).toBe(true) + }) + + it('keeps CONSTRUCT templates separate from WHERE patterns', () => { + const template = triple('?copy', 'schema:name', '?name') + const where = triple('?source', 'schema:name', '?name') + const query = construct(template).where(where).build() + + expect(query.value.includes('CONSTRUCT {\n ?copy schema:name ?name .\n}')).toBe(true) + expect(query.value.includes('WHERE {\n ?source schema:name ?name .\n}')).toBe(true) + }) + + it('supports the CONSTRUCT WHERE shorthand without duplicating the pattern', () => { + const query = construct().where(triple('?s', '?p', '?o')).build() + expect(query.value).toBe('CONSTRUCT\nWHERE {\n ?s ?p ?o .\n}') + }) + + it('serializes UNION as disjunctions rather than one conjunction', () => { + const query = select('*').union( + 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) + }) + + it('accepts RDF namespace functions and named nodes directly', () => { + const schema = namespace('https://schema.org/') + const graph = namedNode('urn:graph:products') + const query = select('*') + .prefix('schema', schema) + .from(graph) + .where(triple('?product', schema('name'), '?name')) + .build() + + expect(query.value.includes('PREFIX schema: ')).toBe(true) + expect(query.value.includes('FROM ')).toBe(true) + 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() + }) + + it('accepts RDF named nodes in DESCRIBE and keeps full queries distinct from patterns', () => { + const query = describeQuery([namedNode('urn:product:1')]).build() + expect(query.value).toBe('DESCRIBE ') + expect(query[SPARQL_QUERY_BRAND]).toBe(true) + + const pattern = subquery(select('*').where(triple('?s', '?p', '?o'))) + expect(pattern[SPARQL_PATTERN_BRAND]).toBe(true) + }) + + it('keeps FILTER expressions separate from graph patterns', () => { + const query = select(['name']) + .where(triple('?product', 'schema:name', '?name')) + .filter(v('name').neq('')) + .build() + expect(query.value.includes('FILTER(?name != "")')).toBe(true) + }) +}) diff --git a/packages/sparql/client.ts b/packages/sparql/client.ts new file mode 100644 index 0000000..2cce6ea --- /dev/null +++ b/packages/sparql/client.ts @@ -0,0 +1,45 @@ +/** Engine-neutral SPARQL query and update contracts. @module */ + +import type { Quad } from '@okikio/rdf' +import type { SparqlQuery, SparqlUpdate } 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 } + +/** Update text accepted by engines and protocol clients. */ +export type UpdateInputType = string | SparqlUpdate | { readonly build: () => SparqlUpdate } + +/** Per-operation cancellation and implementation-defined timing controls. */ +export interface QueryOptionsType { + readonly signal?: AbortSignal + readonly timeoutMs?: number | null +} + +/** + * Result-mode-specific SPARQL engine contract. + * + * Query and update document types are intentionally distinct. This prevents a + * complete update from reaching a SELECT/ASK path and prevents a query builder + * from being submitted as an update by structural accident. + */ +export interface Queryable { + queryBindings(query: QueryInputType, options?: QueryOptionsType): Promise> + queryQuads(query: QueryInputType, options?: QueryOptionsType): Promise> + queryBoolean(query: QueryInputType, options?: QueryOptionsType): Promise + update(update: UpdateInputType, options?: QueryOptionsType): Promise +} + +/** Resolves a complete query builder/document or raw string to protocol text. */ +export function getQueryText(input: QueryInputType): string { + if (typeof input === 'string') return input + if ('build' in input) return input.build().value + return input.value +} + +/** Resolves a complete update builder/document or raw string to protocol text. */ +export function getUpdateText(input: UpdateInputType): string { + if (typeof input === 'string') return input + if ('build' in input) return input.build().value + return input.value +} diff --git a/packages/sparql/client_test.ts b/packages/sparql/client_test.ts new file mode 100644 index 0000000..6192a56 --- /dev/null +++ b/packages/sparql/client_test.ts @@ -0,0 +1,14 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { getQueryText, getUpdateText, select, triple, update } from './mod.ts' + +describe('@okikio/sparql client document inputs', () => { + it('resolves strings, documents, and builders without conflating query and update syntax', () => { + const query = select('*').where(triple('?s', '?p', '?o')) + const change = update().deleteWhere(triple('?s', '?p', '?o')) + + expect(getQueryText('ASK {}')).toBe('ASK {}') + expect(getQueryText(query)).toBe(query.build().value) + expect(getUpdateText(change)).toBe(change.build().value) + }) +}) diff --git a/packages/sparql/composition_test.ts b/packages/sparql/composition_test.ts new file mode 100644 index 0000000..3d2b35d --- /dev/null +++ b/packages/sparql/composition_test.ts @@ -0,0 +1,28 @@ +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 { select, triple, variable } from './mod.ts' + +describe('RDF, vocabulary, and SPARQL composition', () => { + it('uses generated vocabulary values directly as native RDF terms in SPARQL', () => { + expect(rdf.isTerm(Product)).toBe(true) + expect(rdf.isTerm(name)).toBe(true) + + const query = select(['product', 'name']) + .where( + triple('?product', rdf.namedNode(rdf.RDF.type), Product), + triple('?product', name, '?name'), + triple('?product', offers, variable('offer')), + ) + .build() + + expect(query.value.includes('?product ?name .')).toBe(true) + expect(query.value.includes('?product ?offer .')).toBe(true) + }) + + it('validates the same vocabulary-shaped data through Standard Schema', async () => { + const value: ProductType = { '@type': 'Product', name: 'Widget', sku: 'SKU-1' } + expect(await ProductSchema['~standard'].validate(value)).toEqual({ value }) + }) +}) diff --git a/packages/sparql/deno.json b/packages/sparql/deno.json new file mode 100644 index 0000000..96c0910 --- /dev/null +++ b/packages/sparql/deno.json @@ -0,0 +1,10 @@ +{ + "name": "@okikio/sparql", + "version": "0.1.0", + "license": "MIT", + "exports": { + ".": "./mod.ts", + "./http": "./http/mod.ts", + "./syntax": "./syntax/mod.ts" + } +} diff --git a/packages/sparql/http/error.ts b/packages/sparql/http/error.ts new file mode 100644 index 0000000..a726428 --- /dev/null +++ b/packages/sparql/http/error.ts @@ -0,0 +1,34 @@ +/** 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' + +/** Error with bounded diagnostics suitable for application logging. */ +export class QueryError extends Error { + readonly kind: QueryErrorKind + readonly details: { + readonly status?: number + readonly mediaType?: string + readonly query?: string + readonly response?: string + readonly cause?: unknown + } + + /** Creates one stable protocol failure while preserving bounded details and the original cause. */ + constructor( + kind: QueryErrorKind, + message: string, + details: { + readonly status?: number + readonly mediaType?: string + readonly query?: string + readonly response?: string + readonly cause?: unknown + } = {}, + ) { + super(message, details.cause === undefined ? undefined : { cause: details.cause }) + this.name = 'QueryError' + this.kind = kind + this.details = details + } +} diff --git a/packages/sparql/http/error_test.ts b/packages/sparql/http/error_test.ts new file mode 100644 index 0000000..c245eae --- /dev/null +++ b/packages/sparql/http/error_test.ts @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { QueryError } from './error.ts' + +describe('@okikio/sparql HTTP errors', () => { + it('preserves stable error kind, bounded details, and cause', () => { + const cause = new Error('socket closed') + const error = new QueryError('network', 'SPARQL request failed.', { + status: 503, + mediaType: 'text/plain', + query: 'ASK {}', + response: 'unavailable', + cause, + }) + expect(error.name).toBe('QueryError') + expect(error.kind).toBe('network') + expect(error.details.status).toBe(503) + expect(error.cause).toBe(cause) + }) +}) diff --git a/packages/sparql/http/mod.ts b/packages/sparql/http/mod.ts new file mode 100644 index 0000000..169f1b9 --- /dev/null +++ b/packages/sparql/http/mod.ts @@ -0,0 +1,241 @@ +/** SPARQL Query/Update HTTP protocol client. @module */ + +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 { QueryError } from './error.ts' + +/** SPARQL endpoint client configuration. */ +export interface ClientOptionsType { + readonly endpoint: string | URL + readonly updateEndpoint?: string | URL + readonly fetch?: typeof fetch + readonly headers?: HeadersInit + readonly timeoutMs?: number + readonly maxResponseBytes?: number +} + +/** SPARQL HTTP client with explicit result-mode methods. */ +export interface Client extends Queryable { + readonly endpoint: URL + readonly updateEndpoint: URL +} + +/** Default max response bytes 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. */ +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 { + const endpoint = new URL(options.endpoint) + const updateEndpoint = new URL(options.updateEndpoint ?? options.endpoint) + const fetchImpl = options.fetch ?? fetch + const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES + + return { + endpoint, + updateEndpoint, + /** Query bindings through the wrapped engine without transferring engine ownership. */ + 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) + return array(bindings) + }, + /** Query boolean through the wrapped engine without transferring engine ownership. */ + 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)) + }, + /** Query quads through the wrapped engine without transferring engine ownership. */ + 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 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-triples' || mediaType === 'text/plain') { + 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), + }) + }, + /** Submits one complete SPARQL Update document through the wrapped engine. */ + async update(update, queryOptions = {}) { + const text = getUpdateText(update) + 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. */ +async function request( + fetchImpl: typeof fetch, + endpoint: URL, + text: string, + operation: 'query' | 'update', + client: ClientOptionsType, + options: QueryOptionsType, + 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') + headers.set('accept', accept) + + let response: Response + try { + response = await fetchImpl(endpoint, { method: 'POST', headers, body: text, ...(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.', { + 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, + }) + } + 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 { + const mediaType = getMediaType(response.headers.get('content-type')) + 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, + query: preview(query), + }) + } + const text = await readText(response, limit, signal) + try { + return JSON.parse(text) + } catch (error) { + throw new QueryError('protocol', 'SPARQL endpoint returned malformed JSON results.', { + query: preview(query), + response: text.slice(0, 1024), + cause: error, + }) + } +} + +/** Read text from the supplied source while preserving caller ownership. */ +async function readText(response: Response, limit: number, signal?: AbortSignal): Promise { + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let bytes = 0 + let text = '' + let complete = false + try { + while (true) { + const item = await readBody(reader, signal) + if (item.done) { + complete = true + break + } + bytes += item.value.byteLength + if (bytes > limit) { + await reader.cancel('SPARQL response size limit exceeded').catch(() => undefined) + throw new QueryError('limit', `SPARQL response exceeded ${limit} bytes.`) + } + text += decoder.decode(item.value, { stream: true }) + } + return text + decoder.decode() + } finally { + 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. */ +function readBody( + reader: ReadableStreamDefaultReader, + signal?: AbortSignal, +): Promise> { + if (!signal) return reader.read() + if (signal.aborted) { + 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) + const onAbort = (): void => { + if (settled) return + settled = true + finish() + 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 + settled = true + finish() + resolve(value) + }, + (error) => { + if (settled) return + settled = true + finish() + reject(error) + }, + ) + }) +} + +/** Combines caller cancellation with the configured timeout without inventing a timeout when disabled. */ +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. */ +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. */ +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. */ +async function* array(values: readonly T[]): AsyncGenerator { + yield* values +} + +export { QueryError } from './error.ts' +export type { QueryErrorKind } from './error.ts' diff --git a/packages/sparql/http/mod_test.ts b/packages/sparql/http/mod_test.ts new file mode 100644 index 0000000..7e61e0e --- /dev/null +++ b/packages/sparql/http/mod_test.ts @@ -0,0 +1,119 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { strlit, triple, update } from '../mod.ts' +import { createClient, QueryError } from './mod.ts' + +describe('@okikio/sparql/http', () => { + it('keeps SELECT bindings as RDF terms', async () => { + const client = createClient({ + 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' } }), + }) + const rows = [] + for await (const row of await client.queryBindings('SELECT ?name WHERE {}')) rows.push(row) + expect(rows[0]?.get('name')?.termType).toBe('Literal') + }) + + it('parses graph result media types without converting RDF terms to bindings', async () => { + const client = createClient({ + endpoint: 'https://example.com/sparql', + 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) + 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({ + 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') + + const graphClient = createClient({ + 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') + }) + + it('enforces response byte limits before JSON decoding', async () => { + const client = createClient({ + endpoint: 'https://example.com/sparql', + maxResponseBytes: 8, + fetch: async () => new Response('{"boolean":true}', { headers: { 'content-type': 'application/sparql-results+json' } }), + }) + try { + await client.queryBoolean('ASK {}') + throw new Error('Expected response limit failure.') + } catch (error) { + expect(error instanceof QueryError).toBe(true) + if (error instanceof QueryError) expect(error.kind).toBe('limit') + } + }) + + it('cancels a response body whose read is already pending', async () => { + let cancelled = false + const body = new ReadableStream({ + cancel() { + cancelled = true + }, + }) + const client = createClient({ + endpoint: 'https://example.com/sparql', + fetch: async () => new Response(body, { headers: { 'content-type': 'application/sparql-results+json' } }), + }) + const controller = new AbortController() + const pending = client.queryBoolean('ASK {}', { signal: controller.signal }) + await Promise.resolve() + controller.abort(new Error('stop-http')) + await expect(pending).rejects.toThrow('stop-http') + expect(cancelled).toBe(true) + }) + + it('uses the configured update endpoint and SPARQL Update media type', async () => { + let requestUrl = '' + let contentType = '' + let body = '' + const client = createClient({ + endpoint: 'https://example.com/query', + updateEndpoint: 'https://example.com/update', + fetch: async (input, init) => { + requestUrl = String(input) + const headers = new Headers(init?.headers) + contentType = headers.get('content-type') ?? '' + body = String(init?.body ?? '') + return new Response(null, { status: 204 }) + }, + }) + const document = update().insertData(triple('urn:s', 'urn:p', strlit('o'))).build() + await client.update(document) + expect(requestUrl).toBe('https://example.com/update') + expect(contentType).toBe('application/sparql-update; charset=utf-8') + expect(body.includes('INSERT DATA')).toBe(true) + }) + + it('normalizes non-success responses into bounded HTTP errors', async () => { + const client = createClient({ + endpoint: 'https://example.com/sparql', + fetch: async () => new Response('temporarily unavailable', { status: 503, statusText: 'Unavailable' }), + }) + try { + await client.queryBoolean('ASK {}') + throw new Error('Expected HTTP failure.') + } catch (error) { + expect(error instanceof QueryError).toBe(true) + if (error instanceof QueryError) { + expect(error.kind).toBe('http') + expect(error.details.status).toBe(503) + } + } + }) +}) diff --git a/packages/sparql/mod.ts b/packages/sparql/mod.ts new file mode 100644 index 0000000..1e35c0d --- /dev/null +++ b/packages/sparql/mod.ts @@ -0,0 +1,21 @@ +/** + * SPARQL construction, query contracts, and protocol-neutral values. + * + * Endpoint transport is available from `@okikio/sparql/http`. Query engines + * implement the result-mode-specific `Queryable` contract instead of being + * imported by this package. + * + * @module + */ + +export * from './sparql.ts' +export * from './utils.ts' +export * from './builder.ts' +export * from './update.ts' +export * from './patterns/triples.ts' +export * from './patterns/objects.ts' +export * from './patterns/cypher.ts' +export { getQueryText, getUpdateText } from './client.ts' +export type { Queryable, QueryInputType, QueryOptionsType, UpdateInputType } from './client.ts' +export { mapBindings } from './result/binding.ts' +export type { BindingType } from './result/binding.ts' diff --git a/packages/sparql/package.json b/packages/sparql/package.json new file mode 100644 index 0000000..a5bce01 --- /dev/null +++ b/packages/sparql/package.json @@ -0,0 +1,24 @@ +{ + "name": "@okikio/sparql", + "version": "0.1.0", + "type": "module", + "sideEffects": false, + "dependencies": { + "@okikio/rdf": "0.1.0" + }, + "exports": { + ".": "./mod.ts", + "./http": "./http/mod.ts", + "./syntax": "./syntax/mod.ts" + }, + "description": "SPARQL construction, syntax inspection, and query contracts for TypeScript.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/okikio/sparql-client.git", + "directory": "packages/sparql" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sparql/patterns/cypher.ts b/packages/sparql/patterns/cypher.ts new file mode 100644 index 0000000..e6f8a30 --- /dev/null +++ b/packages/sparql/patterns/cypher.ts @@ -0,0 +1,78 @@ +/** + * Cypher-like visual graph-pattern syntax that compiles to ordinary SPARQL. + * + * This is only syntax sugar. Predicates remain RDF/SPARQL terms and the output + * is a normal graph-pattern fragment. + * + * @module + */ + +import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode } from '@okikio/rdf' +import { rdfTerm, rawPattern, toPredicateName, type PatternValue, type SparqlTerm } from '../sparql.ts' +import { Node } from './objects.ts' + +/** Predicate values accepted inside a cypher relationship placeholder. */ +type CypherTermType = SparqlTerm | RdfNamedNode + +/** + * Builds graph patterns from `node-[predicate]->node` visual relationships. + * + * Direction 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 { + let source = strings[0] ?? '' + const nodes: Node[] = [] + const terms = new Map() + + for (let index = 0; index < values.length; index++) { + const value = values[index]! + if (value instanceof Node) { + const placeholder = `NODE_${nodes.length}` + nodes.push(value) + source += placeholder + } else { + const placeholder = `TERM_${index}` + terms.set(placeholder, value) + source += placeholder + } + source += strings[index + 1] ?? '' + } + + const triples: string[] = [] + for (const node of nodes) { + if (node.value.trim()) triples.push(node.value) + } + + // Keep connector recognition explicit. This prevents a reverse arrow from + // being normalized to the same edge direction as a forward arrow. + const edge = /NODE_(\d+)\s*(<-|-)\[([^\]]+)\](->|-)\s*NODE_(\d+)/g + for (const match of source.matchAll(edge)) { + const left = nodes[Number(match[1])] + const leftConnector = match[2] + const predicateSource = match[3]?.trim() + const right = nodes[Number(match[5])] + if (!left || !right || !predicateSource) { + throw new SyntaxError('Cypher pattern references a missing node or predicate.') + } + + const predicate = predicateText(predicateSource, terms) + const reverse = leftConnector === '<-' + const subject = reverse ? right.getVarName() : left.getVarName() + const object = reverse ? left.getVarName() : right.getVarName() + triples.push(`${subject} ${predicate} ${object} .`) + } + + return rawPattern(triples.join('\n')) +} + +/** Serializes a visual-edge predicate without flattening RDF named nodes. */ +function predicateText(source: string, terms: ReadonlyMap): string { + const term = terms.get(source) + if (!term) return toPredicateName(source) + if (isRdfTerm(term)) return rdfTerm(term) + return term.value +} diff --git a/packages/sparql/patterns/cypher_test.ts b/packages/sparql/patterns/cypher_test.ts new file mode 100644 index 0000000..c8deb2a --- /dev/null +++ b/packages/sparql/patterns/cypher_test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { name, offers } from '@okikio/vocab/schema' +import { cypher, node } from '../mod.ts' + +describe('@okikio/sparql cypher pattern helper', () => { + it('preserves forward and reverse edge direction', () => { + const person = node('person') + const friend = node('friend') + expect(cypher`${person}-[${name}]->${friend}`.value.includes( + '?person ?friend .', + )).toBe(true) + expect(cypher`${friend}<-[${name}]-${person}`.value.includes( + '?person ?friend .', + )).toBe(true) + }) + + it('accepts generated vocabulary predicates without flattening them to strings', () => { + const product = node('product') + const maker = node('maker') + expect(cypher`${product}-[${offers}]->${maker}`.value.includes( + '?product ?maker .', + )).toBe(true) + }) +}) diff --git a/patterns/objects.ts b/packages/sparql/patterns/objects.ts similarity index 77% rename from patterns/objects.ts rename to packages/sparql/patterns/objects.ts index 9b73fec..4841123 100644 --- a/patterns/objects.ts +++ b/packages/sparql/patterns/objects.ts @@ -1,27 +1,30 @@ /** * Graph 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 * relationships instead of raw triples. - * + * * The core idea comes from Cypher (Neo4j's query language). Instead of repeating * `?person foaf:name ?name ; foaf:age ?age`, you describe the node once with all its * properties. Relationships work similarly - you define how nodes connect without * manually writing every triple. - * + * * This is syntactic sugar that generates standard SPARQL triples under the hood. * The benefit is readability - your queries look more like the graph you're querying. - * + * * @module */ +import { RDF, isTerm as isRdfTerm, namedNode } from '@okikio/rdf' + import { toVarToken, toPredicateName, toRawString, variable, raw, + rdfTerm, rawTerm, rawPattern, SPARQL_VALUE_BRAND, @@ -32,13 +35,12 @@ import { } from '../sparql.ts' import { exprTermString } from '../utils.ts' -import { +import { triples, triple, - type TripleSubject, type TriplePredicate, type TripleObject, - type PredicateObjectMap, + type PredicateObjectList, } from "./triples.ts" // ============================================================================ @@ -47,15 +49,15 @@ import { /** * Best practice: Use explicit value constructors. - * + * * When building patterns, always use str(), num(), v() etc. for values. * Don't rely on implicit conversion. This makes your intent clear and avoids * ambiguity about whether something is a literal value or a variable name. - * + * * Good: node('person', Person).prop('name', str('Alice')) * Good: node('person', Person).prop('age', num(30)) * Bad: node('person', Person).prop('name', 'Alice') // Unclear intent - * + * * The explicit style makes it obvious what's a value vs. a variable vs. an IRI. */ @@ -65,38 +67,52 @@ import { /** * Property value for a node. - * + * * 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 +/** One object-pattern property value or a repeated set of property values. */ export type PropertyValue = PropertyAtomic | PropertyAtomic[] /** * Map of property names to values. */ -export interface NodePropertyMap { +export interface NodePropertyMap { [predicate: string]: PropertyValue } +/** Predicate-preserving property entry used internally by node and edge builders. */ +interface PropertyEntryType { + readonly predicate: TriplePredicate + value: PropertyValue +} + +/** Creates a stable comparison key without changing the predicate's lexical form. */ +function predicateKey(predicate: TriplePredicate): string { + if (typeof predicate === 'string') return `string:${predicate}` + if (isRdfTerm(predicate)) return `iri:${predicate.value}` + return `term:${predicate.value}` +} + /** * A node in your graph pattern. - * + * * Nodes represent resources - people, places, things. Each node has a variable that will bind to * matching resources in your data. You can specify the node's type (what kind of resource it is) * and properties (facts about it). Properties can be simple values, variables, or other nodes for * nested structures. When you nest nodes, the library generates all necessary triples automatically. - * + * * The pattern gets compiled to SPARQL triples, but you write it in a more intuitive nested structure. * This handles the bookkeeping of variable names and relationships between nodes. You can also nest * relationships within properties to create patterns that combine node and edge metadata. - * + * * @example Basic node * ```ts * const person = node('person', 'foaf:Person') * // Generates: ?person a foaf:Person . * ``` - * + * * @example Node with properties * ```ts * const person = node('person', 'foaf:Person', { @@ -108,7 +124,7 @@ export interface NodePropertyMap { * // ?person foaf:name ?name . * // ?person foaf:age ?age . * ``` - * + * * @example Nested nodes (one level) * ```ts * const product = node('product', 'schema:Product', { @@ -124,7 +140,7 @@ export interface NodePropertyMap { * // ?publisher a schema:Organization . * // ?publisher rdfs:label "Marvel Comics" . * ``` - * + * * @example Deeply nested nodes (multiple levels) * ```ts * const product = node('product', 'schema:Product', { @@ -143,7 +159,7 @@ export interface NodePropertyMap { * }) * // Generates all triples for product → publisher → location → geo * ``` - * + * * @example Nesting relationships within properties * ```ts * const person = node('person', 'foaf:Person', { @@ -155,12 +171,12 @@ export interface NodePropertyMap { * // You can also use rel() for relationships with metadata: * const personWithRel = node('person', 'foaf:Person') * .prop('foaf:name', v('name')) - * .prop('foaf:knows', + * .prop('foaf:knows', * rel('person', 'foaf:knows', node('friend', 'foaf:Person')) * .prop('ex:since', date(new Date('2020-01-01'))) * ) * ``` - * + * * @example Multiple nested nodes of the same type * ```ts * const book = node('book', 'schema:Book', { @@ -172,14 +188,14 @@ export interface NodePropertyMap { * }) * // Generates triples for book with both authors * ``` - * + * * @example Chain-style building with nested structures * ```ts * const query = select(['?productName', '?publisherName', '?city']) * .where( * node('product', 'schema:Product') * .prop('schema:name', v('productName')) - * .prop('schema:publisher', + * .prop('schema:publisher', * node('publisher', 'schema:Organization') * .prop('schema:name', v('publisherName')) * .prop('schema:location', @@ -193,22 +209,28 @@ export interface NodePropertyMap { export class Node implements PatternValue { readonly [SPARQL_VALUE_BRAND] = true as const readonly [SPARQL_PATTERN_BRAND] = true as const - + readonly subjectTerm: SparqlTerm private readonly varName: string private readonly typesTerm: TriplePredicate[] = [] - private readonly properties: NodePropertyMap = {} + 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 } + /** Fluent no-op alias used to continue Node property/type chains without changing semantics. */ get with(): this { return this } + /** Fluent no-op alias used to join consecutive Node clauses without allocating another wrapper. */ 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 } + /** Fluent no-op alias used by natural-language Node chains before adding another property. */ get has(): this { return this } - constructor(subject: TripleSubject, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap) { + /** 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) { const subjectString = toVarToken(subject) - + this.varName = subjectString this.subjectTerm = variable(subjectString) @@ -227,13 +249,14 @@ 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 { return new Node(name, type, options) } /** * Get the variable term for this node. - * + * * Use this when you need to reference the node as an object in another triple. * For example, when connecting two nodes with a relationship. */ @@ -243,10 +266,10 @@ export class Node implements PatternValue { /** * Add an rdf:type to this node. - * + * * Types indicate what kind of resource this is. A node can have multiple types * (someone can be both a Person and an Author). - * + * * @example * ```ts * node('person').a('foaf:Person').a('schema:Author') @@ -265,13 +288,13 @@ export class Node implements PatternValue { /** * Add multiple types at once. - * + * * @example * ```ts * node('item').types(['schema:Product', 'schema:CreativeWork']) * ``` */ - types(typesIri: TriplePredicate[]): this { + types(typesIri: TriplePredicate[]): this { for (const typeIri of typesIri) this.a(typeIri); return this @@ -279,57 +302,53 @@ export class Node implements PatternValue { /** * Add a property to this node. - * + * * Properties describe facts about the resource. The value can be a literal, * variable, IRI, or even another node for nested structures. Arrays let you * specify multiple values for one property. - * + * * If you call prop() multiple times with the same predicate, the values * accumulate - you'll get multiple triples with that predicate. - * + * * @example Single value * ```ts * node('person').prop('foaf:name', v('name')) * ``` - * + * * @example Multiple values * ```ts * node('person').prop('foaf:nick', ['Spidey', 'Web-Head']) * ``` - * + * * @example Nested node * ```ts * node('product').prop('schema:publisher', node('publisher', 'schema:Organization')) * ``` */ prop(predicate: TriplePredicate, value: PropertyValue): this { - const key = typeof predicate === 'string' ? predicate : predicate.value - const existing = this.properties[key] - - if (existing === undefined) { - this.properties[key] = value - } else if (Array.isArray(existing)) { - if (Array.isArray(value)) { - existing.push(...value) - } else { - existing.push(value) - } + const key = predicateKey(predicate) + const entry = this.properties.find((item) => predicateKey(item.predicate) === key) + + if (!entry) { + this.properties.push({ predicate, value }) + return this + } + + const existing = entry.value + if (Array.isArray(existing)) { + entry.value = Array.isArray(value) ? [...existing, ...value] : [...existing, value] } else { - if (Array.isArray(value)) { - this.properties[key] = [existing, ...value] - } else { - this.properties[key] = [existing, value] - } + entry.value = Array.isArray(value) ? [existing, ...value] : [existing, value] } return this } /** * Add multiple properties at once. - * + * * Convenient when you have several properties to set. Just pass an object * where keys are predicates and values are objects. - * + * * @example * ```ts * node('person').props({ @@ -348,91 +367,47 @@ export class Node implements PatternValue { /** * Build the SPARQL pattern for this node. - * + * * Recursively processes this node and any nested nodes, generating all the * necessary triples. The visited set prevents infinite recursion if there * are circular references. */ private buildPatternInternal(visited: Set): string { - if (visited.has(this)) { - return '' - } + if (visited.has(this)) return '' visited.add(this) - const poNormalized: PredicateObjectMap = {} - const nestedChunks: string[] = [] - - // Add rdf:type triples - if (this.typesTerm.length > 0) { - const typeObjs: TripleObject[] = this.typesTerm.map((t) => - typeof t === 'string' ? rawTerm(t) : t.value, - ) - - const existing = - poNormalized['a'] || - poNormalized['rdf:type'] || - poNormalized['http://www.w3.org/1999/02/22-rdf-syntax-ns#type'] || - poNormalized['']; - - if (existing === undefined) { - poNormalized['a'] = typeObjs - } else if (Array.isArray(existing)) { - poNormalized['a'] = [...existing, ...typeObjs] - } else { - poNormalized['a'] = [existing, ...typeObjs] - } + const pairs: PredicateObjectList = [] + const nested: string[] = [] + + for (const type of this.typesTerm) { + const object = typeof type === 'string' ? rawTerm(type) : type + pairs.push([namedNode(RDF.type), object]) } - // Process properties, handling nested nodes - const pushAtomic = (key: string, atomic: PropertyAtomic): void => { + const push = (predicate: TriplePredicate, atomic: PropertyAtomic): void => { let object: TripleObject - if (atomic instanceof Node) { - // Use the nested node's variable as the object object = atomic.term() - // Also generate the nested node's pattern - const nested = atomic.buildPatternInternal(visited) - if (nested.trim().length > 0) { - nestedChunks.push(nested) - } + const value = atomic.buildPatternInternal(visited) + if (value.trim()) nested.push(value) } else { object = atomic } - - const existing = poNormalized[key] - if (existing === undefined) { - poNormalized[key] = object - } else if (Array.isArray(existing)) { - existing.push(object) - } else { - poNormalized[key] = [existing, object] - } + pairs.push([predicate, object]) } - for (const [key, value] of Object.entries(this.properties)) { - if (Array.isArray(value)) { - for (const atomic of value) { - pushAtomic(key, atomic) - } - } else { - pushAtomic(key, value) - } + for (const entry of this.properties) { + const values = Array.isArray(entry.value) ? entry.value : [entry.value] + for (const value of values) push(entry.predicate, value) } - // Build this node's triples - const selfPattern = triples(this.subjectTerm, poNormalized).value - - // Combine with nested patterns - const allChunks = [selfPattern, ...nestedChunks].filter( - (chunk) => chunk.trim().length > 0, - ) - - return allChunks.join('\n') + const self = pairs.length === 0 ? '' : triples(this.subjectTerm, pairs).value + return [self, ...nested].filter((value) => value.trim()).join('\n') } /** * Get the full SPARQL pattern as a SparqlValue. - * + * * Call this to get the complete pattern including all nested nodes. */ pattern(): PatternValue { @@ -443,10 +418,10 @@ 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. - * + * * ⚠️ 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. */ @@ -454,6 +429,7 @@ export class Node implements PatternValue { return this.pattern().value } + /** Returns the canonical `?name` token used to reference this node in legacy/helper integrations. */ getVarName(): string { return this.varName } @@ -465,7 +441,7 @@ export class Node implements PatternValue { /** * Properties on a relationship. - * + * * Like nodes, relationships can have properties too. This is called reification * in RDF - treating the edge itself as a resource with facts about it. */ @@ -475,24 +451,24 @@ export interface RelationshipPropertyMap { /** * A relationship between two nodes. - * + * * Relationships describe how nodes connect. In the simplest case, a relationship is just an edge * between two nodes with a predicate. But you can also add properties to the relationship itself * (metadata about the connection) and nest relationships with full node structures. When you pass * Node objects as the from/to arguments, the library automatically generates all necessary triples * for those nodes plus the connecting edge. - * + * * When you add properties to a relationship, it uses RDF reification to represent the edge as a * resource. This lets you attach information like timestamps, confidence scores, or provenance data * to connections. You can also nest nodes within relationships to create patterns where both the * nodes and their connection have detailed structures. - * + * * @example Simple relationship * ```ts * rel('person', 'foaf:knows', 'friend') * // Generates: ?person foaf:knows ?friend . * ``` - * + * * @example Relationship with metadata * ```ts * rel('person', 'foaf:knows', 'friend') @@ -507,7 +483,7 @@ export interface RelationshipPropertyMap { * // rel:since "2020-01-01"^^xsd:date ; * // rel:confidence 0.95 . * ``` - * + * * @example Relationship between nested nodes * ```ts * rel( @@ -530,7 +506,7 @@ export interface RelationshipPropertyMap { * // ?friend foaf:age ?friendAge . * // ?person foaf:knows ?friend . * ``` - * + * * @example Relationship with nested nodes and metadata * ```ts * rel( @@ -548,7 +524,7 @@ export interface RelationshipPropertyMap { * .prop('org:directReport', bool(true)) * // Generates all node triples, the relationship triple, and reification with metadata * ``` - * + * * @example Chain-style relationship building * ```ts * const pattern = select(['?person', '?friend', '?friendCity']) @@ -566,19 +542,19 @@ export interface RelationshipPropertyMap { * ) * .filter(v('score').gte(0.8)) * ``` - * + * * @example Multiple relationships from one node * ```ts * const person = node('person', 'foaf:Person', { * 'foaf:name': v('name') * }) - * + * * const friend1Rel = rel(person, 'foaf:knows', node('friend1', 'foaf:Person')) * .prop('ex:closeness', num(0.9)) - * + * * const friend2Rel = rel(person, 'foaf:knows', node('friend2', 'foaf:Person')) * .prop('ex:closeness', num(0.7)) - * + * * select(['?name', '?friend1', '?friend2']) * .where(person) * .where(friend1Rel) @@ -589,32 +565,33 @@ export class Relationship implements PatternValue { readonly [SPARQL_VALUE_BRAND] = true as const readonly [SPARQL_PATTERN_BRAND] = true as const - private readonly fromNode?: Node - private readonly toNode?: Node - private readonly fromTerm: TripleSubject - private readonly toTerm: TripleSubject + private readonly fromTerm: SparqlTerm + private readonly toTerm: SparqlTerm private readonly predicate: TriplePredicate - private readonly properties: RelationshipPropertyMap = {} + private readonly properties: PropertyEntryType[] = [] + /** Fluent no-op alias used to continue relationship metadata chains on the same pattern. */ 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 } + /** Fluent no-op alias retained for natural-language relationship chaining. */ get that(): this { return this } + /** Fluent no-op alias used before attaching another reified relationship property. */ 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 | TripleSubject, + from: Node | string | SparqlTerm, predicate: TriplePredicate, to: Node | string | SparqlTerm, ) { if (from instanceof Node) { - this.fromNode = from this.fromTerm = from.term() } else { this.fromTerm = variable(toVarToken(from)) } if (to instanceof Node) { - this.toNode = to this.toTerm = to.term() } else { this.toTerm = variable(toVarToken(to)) @@ -623,6 +600,7 @@ export class Relationship implements PatternValue { this.predicate = predicate } + /** Creates a relationship between two variable-backed nodes using the supplied predicate term. */ static create( fromVar: string, predicate: TriplePredicate, @@ -633,37 +611,33 @@ export class Relationship implements PatternValue { /** * Add a property to this relationship. - * + * * When you add properties, the relationship gets reified (represented as a * blank node with rdf:Statement type). This lets you attach metadata to * the connection itself. - * + * * @example Timestamp on relationship * ```ts * rel('person', 'knows', 'friend').prop('timestamp', dateTime(new Date())) * ``` */ prop(predicate: TriplePredicate, value: PropertyValue): this { - const key = typeof predicate === 'string' ? predicate : predicate.value - const existing = this.properties[key] - - if (existing === undefined) { - this.properties[key] = value - } else if (Array.isArray(existing)) { - if (Array.isArray(value)) { - existing.push(...value) - } else { - existing.push(value) - } - } else { - this.properties[key] = Array.isArray(value) ? [existing, ...value] : [existing, value] + const key = predicateKey(predicate) + const entry = this.properties.find((item) => predicateKey(item.predicate) === key) + if (!entry) { + this.properties.push({ predicate, value }) + return this } + const existing = entry.value + entry.value = Array.isArray(existing) + ? (Array.isArray(value) ? [...existing, ...value] : [...existing, value]) + : (Array.isArray(value) ? [existing, ...value] : [existing, value]) return this } /** * Add multiple properties at once. - * + * * Convenient when you have several properties to set. Just pass an object * where keys are predicates and values are objects. */ @@ -676,47 +650,51 @@ export class Relationship implements PatternValue { /** * Generate deterministic ID for reified edge. - * + * * Uses a simple hash of the subject-predicate-object to create a stable * blank node identifier. Same relationship always gets the same ID. */ private getEdgeId(): string { - const hash = simpleHash(`${toVarToken(this.fromTerm)}|${toPredicateName(toRawString(this.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}` } /** * Build the triples for this relationship. - * + * * If there are no properties, just generates the basic triple. If there are * properties, generates the triple plus a reification structure. */ private buildTriples(): SparqlValue { const base = triple(this.fromTerm, this.predicate, this.toTerm) - const keys = Object.keys(this.properties) - if (keys.length === 0) { + if (this.properties.length === 0) { return base } // Reify with properties const edgeId = this.getEdgeId() - const poMap: PredicateObjectMap = { - // `a` = `rdf:type` - 'a': rawTerm('rdf:Statement'), - 'rdf:subject': this.fromTerm, - 'rdf:predicate': typeof this.predicate === 'string' - ? rawTerm(this.predicate) - : this.predicate, - 'rdf:object': this.toTerm, - ...this.properties, + const poList: PredicateObjectList = [ + [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.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.') + poList.push([entry.predicate, value]) + } } - const edgeTriples = triples(rawTerm(edgeId), poMap) + const edgeTriples = triples(rawTerm(edgeId), poList) return rawPattern(`${base.value}\n ${edgeTriples.value}`) } + /** Serializes this relationship pattern using the same value contract consumed by SPARQL builders. */ get value(): string { return this.buildTriples().value } @@ -728,14 +706,14 @@ export class Relationship implements PatternValue { /** * Create a node pattern. - * + * * Convenience function for creating Node instances. Lets you quickly define * graph patterns without the `new` keyword. - * + * * @param name Variable name for this node (without ? prefix) * @param type Optional RDF type(s) for the node * @param options Optional property map - * + * * @example * ```ts * const person = node('person', 'foaf:Person') @@ -749,14 +727,14 @@ export function node(name: string, type?: TriplePredicate | TriplePredicate[], o /** * Create a relationship pattern. - * + * * Convenience function for creating Relationship instances. Describes how * two nodes connect. - * + * * @param fromVar Source node variable name * @param predicate Relationship type/predicate * @param toVar Target node variable name - * + * * @example * ```ts * const knows = rel('person', 'foaf:knows', 'friend') @@ -772,10 +750,10 @@ export function rel( /** * Combine multiple patterns into one. - * + * * Takes several patterns (nodes, relationships, or raw SPARQL) and combines * them into a single pattern. Useful for building complex graph structures. - * + * * @example * ```ts * const pattern = match( @@ -794,7 +772,7 @@ export function match( /** * Simple string hash for generating IDs. - * + * * Uses a basic hash algorithm to create deterministic IDs from strings. * Not cryptographically secure, but fine for generating blank node identifiers. */ @@ -806,4 +784,4 @@ export function simpleHash(str: string): string { hash = hash & hash } return Math.abs(hash).toString(36) -} \ No newline at end of file +} diff --git a/packages/sparql/patterns/objects_test.ts b/packages/sparql/patterns/objects_test.ts new file mode 100644 index 0000000..757c99d --- /dev/null +++ b/packages/sparql/patterns/objects_test.ts @@ -0,0 +1,25 @@ +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 { node, rel, variable } from '../mod.ts' + +describe('@okikio/sparql object patterns', () => { + it('preserves generated RDF class and property terms', () => { + const product = node('product', Product) + .prop(name, variable('name')) + .prop(offers, variable('offer')) + + expect(product.value.includes(`<${RDF.type}> `)).toBe(true) + expect(product.value.includes(' ?name')).toBe(true) + expect(product.value.includes(' ?offer')).toBe(true) + }) + + it('uses full RDF reification IRIs without requiring an rdf prefix', () => { + const pattern = rel('product', offers, 'maker').prop(name, variable('label')).value + expect(pattern.includes(`<${RDF.type}> <${RDF.statement}>`)).toBe(true) + expect(pattern.includes(`<${RDF.subject}> ?product`)).toBe(true) + expect(pattern.includes(`<${RDF.predicate}> `)).toBe(true) + expect(pattern.includes(`<${RDF.object}> ?maker`)).toBe(true) + }) +}) diff --git a/patterns/triples.ts b/packages/sparql/patterns/triples.ts similarity index 75% rename from patterns/triples.ts rename to packages/sparql/patterns/triples.ts index 958ebe9..70f520d 100644 --- a/patterns/triples.ts +++ b/packages/sparql/patterns/triples.ts @@ -1,18 +1,19 @@ /** * Basic triple pattern construction. - * + * * SPARQL queries are built from triple patterns (subject-predicate-object). Writing * these by hand means lots of repetitive code. These helpers let you construct * triples programmatically with less boilerplate. - * + * * Think of triples as the sentences of your graph query. Each triple makes a statement * about a resource. The functions here help you write those statements concisely. - * + * * @module */ -import type { SparqlExpr, SparqlTerm } from '../sparql.ts' -import { raw, toPredicateName, toRawString, isSparqlValue, toVarToken, } from '../sparql.ts' +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' // ============================================================================ @@ -21,19 +22,19 @@ import { termString, type ExpressionPrimitive } from '../utils.ts' /** * Subject of a triple pattern. - * + * * 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 +export type TripleSubject = string | SparqlTerm | RdfTerm /** * Predicate of a triple pattern. - * + * * Can be a prefixed name (foaf:name), full IRI, or variable. Predicates * describe relationships or properties. */ -export type TriplePredicate = string | SparqlTerm +export type TriplePredicate = PredicateInput /** * Values that are allowed in the object position of a triple, per SPARQL. @@ -43,34 +44,43 @@ export type TriplePredicate = string | SparqlTerm * - an IRI or prefixed name * - a literal * - a blank node - * - * (We can later extend this to collections `( ... )` and blank-node property - * lists `[ ... ]` via additional SparqlValue kinds.) + * */ export type TripleObject = - | SparqlTerm // but only certain `kind`s, enforced at runtime + | SparqlTerm + | RdfTerm | ExpressionPrimitive /** * Convert subject to string form. - * + * * Handles both raw strings and SparqlValue objects. */ export function tripleSubjectString(subject: TripleSubject): string { - // If it's already a SparqlValue (iri, bnode, literal, raw, etc.) - if (isSparqlValue(subject)) { - return subject.value + if (typeof subject === 'string') { + const value = subject.trim() + if (/^[?$]/.test(value) || !value.includes(':')) return toVarToken(value) + return toPredicateName(value) } - - // Otherwise, it’s a variable name like "person" or "?person" - return toVarToken(subject) + if (isRdfTerm(subject)) return rdfTerm(subject) + return subject.value } /** * Convert predicate to string form. */ export function tripleObjectString(object: TripleObject): string { - return termString(object, 'object') + 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') +} + + +/** Converts a predicate input without flattening RDF named nodes to strings. */ +function predicateString(predicate: TriplePredicate): string { + return toPredicateToken(predicate) } // ============================================================================ @@ -79,26 +89,26 @@ export function tripleObjectString(object: TripleObject): string { /** * Create a single triple pattern. - * + * * This is the basic building block of SPARQL queries. A triple makes a statement * about a resource - who they are, what properties they have, how they relate * to other resources. - * + * * The pattern will match any data in your graph that fits this structure. * Variables (like ?person) will bind to whatever values make the pattern true. - * + * * @example Match by name * ```ts * triple('?person', 'foaf:name', '?name') * // ?person foaf:name ?name . * ``` - * + * * @example Match specific value * ```ts * triple('?person', 'foaf:age', 30) * // ?person foaf:age 30 . * ``` - * + * * @example With full IRI * ```ts * triple(uri('http://example.org/person/1'), 'foaf:name', 'Alice') @@ -109,12 +119,12 @@ export function triple( subject: TripleSubject, predicate: TriplePredicate, object: TripleObject, -): SparqlExpr { +): PatternValue { const s = tripleSubjectString(subject) - const p = toPredicateName(toRawString(predicate)) + const p = predicateString(predicate) const o = tripleObjectString(object) - return raw(`${s} ${p} ${o} .`) + return rawPattern(`${s} ${p} ${o} .`) } // ============================================================================ @@ -123,7 +133,7 @@ export function triple( /** * Array format for predicate-object pairs. - * + * * Each entry is [predicate, object]. Use this when you want explicit control * over the order of properties. */ @@ -131,7 +141,7 @@ export type PredicateObjectList = Array<[TriplePredicate, TripleObject]> /** * Object format for predicate-object pairs. - * + * * Keys are predicates, values are objects. Values can be single items or arrays * for properties with multiple values. */ @@ -142,15 +152,15 @@ export type PredicateObjectMap = Record< /** * Create multiple triples with the same subject. - * + * * When you have several facts about one resource, you don't want to repeat the * subject for each triple. This helper uses SPARQL's semicolon syntax to share * the subject across multiple predicate-object pairs. - * + * * You can pass properties as an array of [predicate, object] pairs, or as an * object where keys are predicates. The object format is more convenient, but * the array format gives you control over ordering. - * + * * @example Array format * ```ts * triples('?person', [ @@ -159,7 +169,7 @@ export type PredicateObjectMap = Record< * ['foaf:nick', 'Spidey'] * ]) * ``` - * + * * Generates: * ```sparql * ?person @@ -167,7 +177,7 @@ export type PredicateObjectMap = Record< * foaf:age 18 ; * foaf:nick "Spidey" . * ``` - * + * * @example Object format * ```ts * triples('?person', { @@ -176,14 +186,14 @@ export type PredicateObjectMap = Record< * 'foaf:nick': ['Spidey', 'Spider-Man'] * }) * ``` - * + * * When a property has an array value, it creates multiple triples with the * same predicate (one for each value). */ export function triples( subject: TripleSubject, predicateObjects: PredicateObjectList | PredicateObjectMap, -): SparqlExpr { +): PatternValue { const subjectTerm = tripleSubjectString(subject) // 4 spaces; 2 (block) + 2 (extra) @@ -204,7 +214,7 @@ export function triples( // Build semicolon-separated list const lines: string[] = list.map(([p, o], idx) => { - const pred = toPredicateName(toRawString(p)) + const pred = predicateString(p) const obj = tripleObjectString(o) const suffix = idx < list.length - 1 ? ' ;' : ' .' @@ -215,10 +225,11 @@ export function triples( }) const [first, ...rest] = lines + 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. - return raw(`${subjectTerm} ${first.trimStart()}`) + return rawPattern(`${subjectTerm} ${first.trimStart()}`) } // Multiple: first predicate shares the line with the subject, @@ -226,60 +237,59 @@ export function triples( const firstLine = `${subjectTerm} ${first.trimStart()}` const restLines = rest.join('\n') - return raw(`${firstLine}\n${restLines}`) + return rawPattern(`${firstLine}\n${restLines}`) } // ============================================================================ -// SPARQL* (RDF-star) +// SPARQL 1.2 triple-term expressions // ============================================================================ /** - * Quoted triple for SPARQL* (RDF-star). - * - * SPARQL* extends SPARQL to work with quoted triples - statements about statements. - * This lets you add metadata to edges in your graph (like confidence scores, - * sources, or timestamps on relationships). - * - * @param subject Subject of quoted triple - * @param predicate Predicate of quoted triple - * @param object Object of quoted triple - * + * RDF 1.2 triple-term expression for SPARQL 1.2. + * + * The `<<( ... )>>` expression denotes an RDF triple term. It is distinct from + * SPARQL 1.2 reified-triple syntax `<< ... >>`. + * + * @param subject Subject of the triple term + * @param predicate Predicate of the triple term + * @param object Object of the triple term + * * @example Statement about a relationship * ```ts - * const claim = quotedTriple('?person', 'foaf:knows', '?friend') + * const claim = tripleTerm('?person', 'foaf:knows', '?friend') * select(['?person', '?friend', '?source']) * .where(triple(claim, 'dc:source', '?source')) - * // << ?person foaf:knows ?friend >> dc:source ?source + * // <<( ?person foaf:knows ?friend )>> dc:source ?source * ``` - * + * * @example Add confidence to statements * ```ts * construct(triple( - * quotedTriple('?person', 'foaf:knows', '?friend'), + * tripleTerm('?person', 'foaf:knows', '?friend'), * 'ex:confidence', * num(0.95) * )) * .where(triple('?person', 'foaf:knows', '?friend')) * // Annotates each friendship with a confidence score * ``` - * + * * @example Query metadata on relationships * ```ts - * const statement = quotedTriple('?s', '?p', '?o') + * const statement = tripleTerm('?s', '?p', '?o') * select(['?s', '?p', '?o', '?timestamp']) * .where(triple(statement, 'prov:generatedAtTime', '?timestamp')) * .filter(gte(v('timestamp'), date('2024-01-01'))) * // Finds recent statements * ``` */ -export function quotedTriple( +export function tripleTerm( subject: TripleSubject, predicate: TriplePredicate, object: TripleObject, -): SparqlExpr { +): SparqlTerm { const s = tripleSubjectString(subject) - const p = toPredicateName(toRawString(predicate)) + const p = predicateString(predicate) const o = tripleObjectString(object) - - return raw(`<< ${s} ${p} ${o} >>`) -} \ No newline at end of file + + return rawTerm(`<<( ${s} ${p} ${o} )>>`) +} diff --git a/packages/sparql/patterns/triples_test.ts b/packages/sparql/patterns/triples_test.ts new file mode 100644 index 0000000..f482738 --- /dev/null +++ b/packages/sparql/patterns/triples_test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode } from '@okikio/rdf' +import { triple, triples, tripleTerm } from './triples.ts' + +describe('@okikio/sparql triple patterns', () => { + it('preserves predicate variables instead of turning them into prefixed names', () => { + expect(triple('?s', '?p', '?o').value).toBe('?s ?p ?o .') + }) + + it('accepts RDF named nodes in every RDF IRI-bearing position', () => { + expect(triple(namedNode('urn:s'), namedNode('urn:p'), namedNode('urn:o')).value).toBe(' .') + }) + + it('rejects empty grouped predicate-object lists', () => { + expect(() => triples('?s', [])).toThrow('at least one') + }) + + it('builds RDF 1.2 triple-term syntax as a term', () => { + expect(tripleTerm('?s', namedNode('urn:p'), '?o').value).toBe('<<( ?s ?o )>>') + }) +}) diff --git a/packages/sparql/result/binding.ts b/packages/sparql/result/binding.ts new file mode 100644 index 0000000..603ead1 --- /dev/null +++ b/packages/sparql/result/binding.ts @@ -0,0 +1,14 @@ +/** SPARQL result bindings that preserve RDF terms. @module */ + +import type { TermType } from '@okikio/rdf' + +/** One SPARQL solution mapping. Variable names do not include `?` or `$`. */ +export type BindingType = ReadonlyMap + +/** Maps a stream of RDF-term bindings into application values without mutating the original rows. */ +export async function* mapBindings( + bindings: AsyncIterable, + map: (binding: BindingType) => T, +): AsyncGenerator { + for await (const binding of bindings) yield map(binding) +} diff --git a/packages/sparql/result/binding_test.ts b/packages/sparql/result/binding_test.ts new file mode 100644 index 0000000..ffc1e9a --- /dev/null +++ b/packages/sparql/result/binding_test.ts @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { literal } from '@okikio/rdf' +import { mapBindings, type BindingType } from './binding.ts' + +/** Yields two immutable-by-contract binding rows. */ +async function* rows(): AsyncGenerator { + yield new Map([['name', literal('A')]]) + yield new Map([['name', literal('B')]]) +} + +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) + expect(values).toEqual(['A', 'B']) + }) +}) diff --git a/packages/sparql/result/json.ts b/packages/sparql/result/json.ts new file mode 100644 index 0000000..2c0f2b5 --- /dev/null +++ b/packages/sparql/result/json.ts @@ -0,0 +1,107 @@ +/** 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 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' + } + | { + readonly type: 'triple' + readonly value: { + readonly subject: JsonTermType + readonly predicate: JsonTermType + readonly object: JsonTermType + } + } + +/** Raw SELECT results object. */ +export interface JsonBindingsType { + readonly head: { + readonly vars: readonly string[] + readonly version?: string + readonly link?: readonly string[] + } + readonly results: { + readonly bindings: readonly Readonly>[] + } +} + +/** Raw ASK results object. */ +export interface JsonBooleanType { + readonly head?: { readonly version?: string; readonly link?: readonly string[] } + readonly boolean: boolean +} + +/** Decodes one SPARQL JSON RDF term without JavaScript datatype coercion. */ +export function readTerm(value: JsonTermType): TermType { + switch (value.type) { + case 'uri': + return namedNode(value.value) + case 'bnode': + return blankNode(value.value) + case 'literal': { + const language = value['xml:lang'] + const direction = value['its:dir'] + if (language) return literal(value.value, direction ? { language, direction } : language) + 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) + 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) + } + } +} + +/** 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.') + return value.results.bindings.map((row) => { + const binding = new Map() + for (const [name, term] of Object.entries(row)) binding.set(name, readTerm(term)) + return binding + }) +} + +/** Decodes an ASK result. */ +export function readBoolean(value: unknown): boolean { + if (!isBooleanResult(value)) throw new TypeError('Response is not a SPARQL boolean JSON result.') + return value.boolean +} + +/** Returns whether the supplied value satisfies the bindings result contract. */ +function isBindingsResult(value: unknown): value is JsonBindingsType { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (typeof record.head !== 'object' || record.head === null) return false + if (typeof record.results !== 'object' || record.results === null) return false + return Array.isArray((record.head as Record).vars) && + Array.isArray((record.results as Record).bindings) +} + +/** 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' +} + +/** 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' +} diff --git a/packages/sparql/result/json_test.ts b/packages/sparql/result/json_test.ts new file mode 100644 index 0000000..0f097a1 --- /dev/null +++ b/packages/sparql/result/json_test.ts @@ -0,0 +1,49 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { RDF } from '@okikio/rdf' +import { readBindings, readBoolean, readTerm } 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' }) + expect(value.termType).toBe('Literal') + if (value.termType === 'Literal') { + expect(value.direction).toBe('ltr') + expect(value.datatype.value).toBe(RDF.dirLangString) + } + }) + + it('decodes SPARQL 1.2 triple terms recursively', () => { + const value = readTerm({ + type: 'triple', + value: { + subject: { type: 'uri', value: 'urn:s' }, + predicate: { type: 'uri', value: 'urn:p' }, + object: { type: 'literal', value: 'o' }, + }, + }) + expect(value.termType).toBe('Quad') + }) + + 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') + }) + + it('decodes SELECT and ASK result modes without JavaScript datatype coercion', () => { + const rows = readBindings({ + head: { vars: ['price'] }, + 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() + }) +}) diff --git a/sparql.ts b/packages/sparql/sparql.ts similarity index 84% rename from sparql.ts rename to packages/sparql/sparql.ts index ac0e980..e5081c4 100644 --- a/sparql.ts +++ b/packages/sparql/sparql.ts @@ -1,19 +1,19 @@ /** * Type-safe SPARQL construction using template literals. - * + * * Writing SPARQL by hand gets messy fast. String concatenation leads to injection * vulnerabilities, and manually escaping values is error-prone. This module lets * you write queries with automatic type conversion and proper escaping. - * + * * The core idea is simple: use template literals with automatic value conversion. * Strings become properly escaped literals, numbers stay as numbers, dates get * formatted correctly, and complex values are handled intelligently. - * + * * @example Basic query construction * ```ts * const name = "Peter Parker"; * const age = 30; - * + * * const query = sparql` * SELECT * WHERE { * ?person foaf:name ${name} ; @@ -21,11 +21,11 @@ * } * `; * ``` - * + * * @example Working with arrays * ```ts * const cities = ["London", "Paris", "Tokyo"]; - * + * * // Arrays become space-separated values for VALUES clauses * const query = sparql` * SELECT * WHERE { @@ -34,30 +34,37 @@ * } * `; * ``` - * + * * @module */ -import { outdent } from "outdent" +import { XSD, isTerm as isRdfTerm, type Literal as RdfLiteral, type NamedNode as RdfNamedNode, type Quad as RdfQuad, type Term as RdfTerm } from '@okikio/rdf' // ============================================================================ // Core Types // ============================================================================ -/** - * Internal brand used to distinguish SPARQL values from plain strings. - */ +/** Brand shared by every library-owned SPARQL syntax value. */ export const SPARQL_VALUE_BRAND = Symbol('SparqlValueBrand') +/** Brand for values that serialize as one SPARQL term. */ export const SPARQL_TERM_BRAND = Symbol('SparqlTermBrand') +/** Brand for values that serialize as one SPARQL expression. */ export const SPARQL_EXPR_BRAND = Symbol('SparqlExprBrand') +/** Brand for values that serialize as a graph-pattern fragment. */ export const SPARQL_PATTERN_BRAND = Symbol('SparqlPatternBrand') +/** Brand for a complete SPARQL query document. */ +export const SPARQL_QUERY_BRAND = Symbol('SparqlQueryBrand') +/** Brand for a complete SPARQL Update document. */ +export const SPARQL_UPDATE_BRAND = Symbol('SparqlUpdateBrand') +/** One already-serialized SPARQL term. */ export interface SparqlTerm { readonly [SPARQL_VALUE_BRAND]: true readonly [SPARQL_TERM_BRAND]: true readonly value: string } +/** One already-serialized SPARQL expression. */ export interface SparqlExpr { readonly [SPARQL_VALUE_BRAND]: true readonly [SPARQL_EXPR_BRAND]: true @@ -74,8 +81,30 @@ export interface PatternValue { readonly value: string } +/** A complete SPARQL query document, not an embeddable expression or pattern. */ +export interface SparqlQuery { + readonly [SPARQL_QUERY_BRAND]: true + readonly value: string +} + +/** A complete SPARQL Update document, not an embeddable expression or pattern. */ +export interface SparqlUpdate { + readonly [SPARQL_UPDATE_BRAND]: true + readonly value: string +} + +/** Complete SPARQL documents accepted by engines and protocol clients. */ +export type SparqlDocument = SparqlQuery | SparqlUpdate + +/** Any library-owned SPARQL syntax fragment accepted by shared helpers. */ export type SparqlValue = SparqlTerm | SparqlExpr | PatternValue +/** IRI-bearing input accepted by SPARQL grammar positions that require an IRI. */ +export type IriInput = string | SparqlTerm | RdfNamedNode + +/** Predicate syntax accepted by triple and property-path constructors. */ +export type PredicateInput = string | SparqlTerm | RdfNamedNode + /** * Values that can be safely interpolated into the `sparql` tag *as a single * RDF term*. This deliberately does NOT include arrays or plain objects. @@ -92,10 +121,11 @@ export type SparqlInterpolatable = | Date | null | undefined + | RdfTerm /** * Variable name without the leading ? or $ sigil. - * + * * 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. */ @@ -109,7 +139,7 @@ export type PrefixName = string /** * Full IRI for a datatype (e.g., http://www.w3.org/2001/XMLSchema#integer). */ -export type DatatypeIRI = string +export type DatatypeIRI = string | RdfNamedNode /** * Language tag for multilingual literals (e.g., "en", "fr", "ja-JP"). @@ -131,21 +161,24 @@ export function isSparqlValue(value: unknown): value is SparqlValue { ) } +/** 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 } +/** 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 } +/** 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 } /** * Extract the raw string from a SparqlValue 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. */ @@ -211,7 +244,8 @@ export function isIRIRefToken(text: string): boolean { * * This gives you a clean lexical token suitable for query text. */ -export function toIriLikeToken(value: string): string { +export function toIriLikeToken(value: string | RdfNamedNode): string { + if (typeof value !== 'string') return rdfTerm(value) const trimmed = value.trim() // Already → validate inner and return. @@ -234,6 +268,14 @@ export function toIriLikeToken(value: string): string { return trimmed } +/** Serializes a predicate/path atom without flattening RDF named nodes to strings. */ +export function toPredicateToken(input: PredicateInput): string { + if (isRdfTerm(input)) return rdfTerm(input) + if (isSparqlValue(input)) return input.value + if (isVariableToken(input.trim())) return toVarToken(input) + return toPredicateName(input) +} + /** * SPARQL 1.1 `VarOrIriRef`: * @@ -251,10 +293,12 @@ export function toIriLikeToken(value: string): string { * - `name` with no colon → treated as variable name → `?name` * - anything else → treated as IRI/prefixed name via toIriLikeToken() */ -export function toVarOrIriRef(input: string | SparqlValue): string { +export function toVarOrIriRef(input: string | SparqlTerm | RdfNamedNode): string { + if (isRdfTerm(input)) return rdfTerm(input) if (isSparqlValue(input)) { - // Caller is responsible for providing a syntactically correct token. - return input.value + const token = input.value.trim() + if (isVariableToken(token)) return toVarToken(token) + return toIriLikeToken(token) } const trimmed = input.trim() @@ -286,21 +330,15 @@ export function toVarOrIriRef(input: string | SparqlValue): string { * - Reject obvious variable tokens (`?name` / `$name`) * - Normalise to `` or `prefix:local` */ -export function toGraphRef(input: string | SparqlValue): string { - if (isSparqlValue(input)) { - // Assume the caller built a correct IRI/prefixed token. - return input.value - } +export function toGraphRef(input: IriInput): string { + if (isRdfTerm(input)) return rdfTerm(input) - const trimmed = input.trim() - - if (isVariableToken(trimmed)) { - throw new Error( - `GraphRef must be an IRI, not a variable: ${trimmed}`, - ) + const token = isSparqlValue(input) ? input.value.trim() : input.trim() + if (isVariableToken(token)) { + throw new Error(`GraphRef must be an IRI, not a variable: ${token}`) } - return toIriLikeToken(trimmed) + return toIriLikeToken(token) } /** @@ -320,19 +358,22 @@ export function toGraphRef(input: string | SparqlValue): string { */ export type GraphRefAllKeyword = 'DEFAULT' | 'NAMED' | 'ALL' -export function toGraphRefAll(input: string | SparqlValue): string { - if (isSparqlValue(input)) { - return input.value - } +/** Graph IRI or the DEFAULT graph accepted by COPY, MOVE, and ADD. */ +export type GraphOrDefaultInput = IriInput | 'DEFAULT' | 'default' - const trimmed = input.trim() - const upper = trimmed.toUpperCase() +/** Normalizes the SPARQL Update `GraphOrDefault` production. */ +export function toGraphOrDefault(input: GraphOrDefaultInput): string { + if (typeof input === 'string' && input.trim().toUpperCase() === 'DEFAULT') return 'DEFAULT' + return toGraphRef(input as IriInput) +} - if (upper === 'DEFAULT' || upper === 'NAMED' || upper === 'ALL') { - return upper +/** Normalizes a SPARQL Update graph reference or DEFAULT/NAMED/ALL keyword. */ +export function toGraphRefAll(input: IriInput): string { + if (typeof input === 'string') { + const upper = input.trim().toUpperCase() + if (upper === 'DEFAULT' || upper === 'NAMED' || upper === 'ALL') return upper } - - return toGraphRef(trimmed) + return toGraphRef(input) } /** @@ -371,7 +412,7 @@ export type ParsedVarOrIriRef = * the same semantics as the rest of the builder. */ export function parseVarOrIriRef( - input: string | SparqlValue, + input: string | SparqlTerm | RdfNamedNode, ): ParsedVarOrIriRef { const token = toVarOrIriRef(input) @@ -411,6 +452,7 @@ export function parseVarOrIriRef( } } +/** Wraps trusted syntax as one raw SPARQL term without escaping it. */ export function rawTerm(value: string): SparqlTerm { return { [SPARQL_VALUE_BRAND]: true, @@ -419,6 +461,7 @@ export function rawTerm(value: string): SparqlTerm { } as const } +/** Wraps trusted syntax as one raw SPARQL expression without escaping it. */ export function rawExpr(value: string): SparqlExpr { return { [SPARQL_VALUE_BRAND]: true, @@ -427,6 +470,7 @@ export function rawExpr(value: string): SparqlExpr { } as const } +/** Wraps trusted syntax as a raw graph-pattern fragment without escaping it. */ export function rawPattern(text: string): PatternValue { return { [SPARQL_VALUE_BRAND]: true, @@ -435,21 +479,31 @@ export function rawPattern(text: string): PatternValue { } as const } +/** Wraps already-serialized text as one complete SPARQL query document. */ +export function queryDocument(value: string): SparqlQuery { + return { [SPARQL_QUERY_BRAND]: true, value } as const +} + +/** Wraps already-serialized text as one complete SPARQL Update document. */ +export function updateDocument(value: string): SparqlUpdate { + return { [SPARQL_UPDATE_BRAND]: true, value } as const +} + /** * Wrap a raw SPARQL snippet as a `SparqlValue`. * * Use this when you *know* the string is already valid SPARQL syntax and you * do not want any further escaping or conversion. - * + * * Inserts raw SPARQL without any processing. - * + * * You can use this as an escape hatch when the builder doesn't support your syntax: * - Property paths * - Custom functions * - Complex expressions - * + * * ⚠️ WARNING: No escaping or validation. Ensure input is safe, before use. - * + * * @example * raw('foaf:knows+') // Property path * raw('BNODE()') // Built-in function @@ -634,7 +688,7 @@ export function escapeString( * Check if a string needs triple-quoting (contains newlines or quotes). */ export function needsLongQuotes(str: string): boolean { - return str.includes('\n') || str.includes('\r') || + return str.includes('\n') || str.includes('\r') || str.includes('"') || str.includes("'") } @@ -649,10 +703,10 @@ export const INJECTION_CHARS = /[<>"'\n\r\t{}:]/ /** * Validate an IRI for use in SPARQL. - * + * * Allows any valid URI scheme (not just http/https). * Blocks characters that could break SPARQL syntax or enable injection. - * + * * @throws {Error} If the IRI is invalid or contains forbidden characters */ export function validateIRI(iri: string): void { @@ -660,7 +714,7 @@ export function validateIRI(iri: string): void { if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(iri)) { throw new Error(`IRI must have a valid scheme (e.g., http:, urn:, file:), got: ${iri}`) } - + // Block characters that break IRI syntax in SPARQL const forbidden = ['<', '>', '"', ' ', '\n', '\r', '\t', '{', '}'] for (const char of forbidden) { @@ -672,21 +726,21 @@ export function validateIRI(iri: string): void { /** * Validate a SPARQL variable name. - * + * * SPARQL allows Unicode in variable names, but we block injection chars. - * + * * @throws {Error} If the variable name is invalid */ export function validateVariableName(name: string): void { if (!name || name.length === 0) { throw new Error('Variable name cannot be empty') } - + // Block characters that could enable injection if (INJECTION_CHARS.test(name)) { throw new Error(`Variable name contains forbidden characters: ${name}`) } - + // Must start with letter or underscore (simplified check) if (!/^[A-Za-z_\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D]/.test(name)) { throw new Error(`Variable name must start with a letter or underscore: ${name}`) @@ -695,21 +749,21 @@ export function validateVariableName(name: string): void { /** * Validate a namespace prefix name. - * + * * Prefixes follow similar rules to variable names - they're identifiers that * get expanded to full IRIs during query execution. - * + * * @throws {Error} If the prefix name is invalid */ export function validatePrefixName(name: string): void { // Empty prefix (default namespace) is always valid if (name === '') return - + // Block injection characters if (INJECTION_CHARS.test(name) || name.includes(':')) { throw new Error(`Prefix name contains forbidden characters: ${name}`) } - + // Must start with letter or underscore if (!/^[A-Za-z_\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF]/.test(name)) { throw new Error(`Prefix name must start with a letter or underscore: ${name}`) @@ -718,7 +772,7 @@ export function validatePrefixName(name: string): void { /** * Validate a prefixed name (prefix:localPart). - * + * * @throws {Error} If the prefixed name is malformed */ export function validatePrefixedName(prefixedName: string): void { @@ -726,12 +780,12 @@ export function validatePrefixedName(prefixedName: string): void { if (colonIndex === -1) { throw new Error(`Prefixed name must contain a colon: ${prefixedName}`) } - + const prefix = prefixedName.slice(0, colonIndex) const local = prefixedName.slice(colonIndex + 1) - + validatePrefixName(prefix) - + // Local part can be empty or must be a valid local name // This is a simplified check - full PN_LOCAL is more complex if (local !== '' && !/^[A-Za-z0-9_.-]*$/.test(local)) { @@ -751,7 +805,7 @@ export function validateLanguageTag(tag: string): void { /** * Normalize variable names to handle both ?foo and foo formats. - * + * * SPARQL lets you write variables with ? or $ prefixes, but we want consistent * internal representation. This function strips the prefix if present, so both * "foo" and "?foo" become "foo" internally. @@ -771,10 +825,10 @@ export function normalizeVariableName(name: VariableName): string { /** * Create a SPARQL variable reference. - * + * * Variables are placeholders that get bound to values during query execution. * The ? prefix is added automatically, so you can write either "name" or "?name". - * + * * @example * variable('name') // → ?name * variable('?name') // → ?name @@ -787,60 +841,61 @@ export function variable(name: VariableName): SparqlTerm { /** * Create an IRI reference wrapped in angle brackets. - * + * * IRIs are how you reference resources in RDF. This function validates the IRI * format and wraps it in the required angle brackets. - * + * * @example * uri('http://example.org/resource') // → * uri('urn:isbn:0451450523') // → */ -export function uri(iri: string): SparqlTerm { +export function uri(iri: string | RdfNamedNode): SparqlTerm { + if (typeof iri !== 'string') return rawTerm(rdfTerm(iri)) validateIRI(iri) return rawTerm(`<${iri}>`) } /** * Create a prefixed name (namespace:local format). - * + * * Prefixes let you abbreviate long IRIs. - * + * * @example * prefixed('foaf', 'name') // → foaf:name */ -export function prefixed(prefix: PrefixName, localName: string): SparqlExpr { +export function prefixed(prefix: PrefixName, localName: string): SparqlTerm { validatePrefixName(prefix) // Local names have complex rules; block obvious injection if (INJECTION_CHARS.test(localName)) { throw new Error(`Local name contains forbidden characters: ${localName}`) } - return raw(`${prefix}:${localName}`) + return rawTerm(`${prefix}:${localName}`) } /** * Alias for {@link prefixed} with more explicit naming. */ -export function prefix(namespace: PrefixName, local: string): SparqlExpr { +export function prefix(namespace: PrefixName, local: string): SparqlTerm { return prefixed(namespace, local) } /** * Create a simple string literal. - * + * * Uses the most concise valid syntax: * - Simple strings: "value" * - Strings with special chars: """value""" - * + * * Note: In RDF 1.1, simple string literals implicitly have type xsd:string. - * + * * @example * strlit('Hello') // → "Hello" * strlit('Line 1\nLine 2') // → """Line 1\nLine 2""" */ export function strlit(value: string): SparqlTerm { const escaped = escapeString(value) - + if (needsLongQuotes(value)) { return rawTerm(`"""${escaped}"""`) } @@ -850,35 +905,40 @@ export function strlit(value: string): SparqlTerm { /** * Create a typed literal with explicit datatype. - * + * * @example * typed('42', 'http://www.w3.org/2001/XMLSchema#integer') * // → "42"^^ */ export function typed(value: string, datatype: DatatypeIRI): SparqlTerm { - validateIRI(datatype) + const datatypeToken = typeof datatype === 'string' + ? (() => { + validateIRI(datatype) + return `<${datatype}>` + })() + : rdfTerm(datatype) const escaped = escapeString(value) - + if (needsLongQuotes(value)) { - return rawTerm(`"""${escaped}"""^^<${datatype}>`) + return rawTerm(`"""${escaped}"""^^${datatypeToken}`) } - return rawTerm(`"${escaped}"^^<${datatype}>`) + return rawTerm(`"${escaped}"^^${datatypeToken}`) } /** * Create a language-tagged literal. - * + * * Use this for multilingual text. The language tag indicates which language the * text is in, following BCP 47 conventions (en, fr, ja-JP, etc.). - * + * * @example lang('Hello', 'en') → "Hello"@en * @example lang('Bonjour', 'fr') → "Bonjour"@fr */ export function lang(value: string, tag: LanguageTag): SparqlTerm { validateLanguageTag(tag) const escaped = escapeString(value) - + if (needsLongQuotes(value)) { return rawTerm(`"""${escaped}"""@${tag.toLowerCase()}`) } @@ -888,12 +948,12 @@ export function lang(value: string, tag: LanguageTag): SparqlTerm { /** * Create an integer literal using native SPARQL syntax. - * + * * SPARQL treats bare integers like `42` as xsd:integer. - * + * * @example * integer(42) // → 42 - * + * * @throws {Error} If value is not an integer */ export function integer(value: number): SparqlTerm { @@ -907,13 +967,13 @@ export function integer(value: number): SparqlTerm { /** * Create a decimal literal using native SPARQL syntax. - * + * * SPARQL treats numbers with decimal points like `3.14` as xsd:decimal. - * + * * @example * decimal(3.14) // → 3.14 * decimal(42) // → 42.0 (ensures decimal interpretation) - * + * * @throws {Error} If value is not finite (NaN or Infinity) */ export function decimal(value: number): SparqlTerm { @@ -931,13 +991,13 @@ export function decimal(value: number): SparqlTerm { /** * Create a double literal using scientific notation. - * + * * SPARQL treats numbers in scientific notation as xsd:double. - * + * * @example * double(42) // → 4.2e1 * double(3.14e10) // → 3.14e10 - * + * * @throws {Error} If value is not an double */ export function double(value: number): SparqlTerm { @@ -950,10 +1010,10 @@ export function double(value: number): SparqlTerm { /** * Create a numeric literal, choosing appropriate type. - * + * * - Integers → native integer syntax (xsd:integer) * - Decimals → native decimal syntax (xsd:decimal) - * + * * @example * num(42) // → 42 * num(3.14) // → 3.14 @@ -968,7 +1028,7 @@ export function num(value: number): SparqlTerm { /** * Create a boolean literal (true or false). - * + * * Boolean values in SPARQL are written as bare keywords, not quoted strings. */ export function boolean(value: boolean): SparqlTerm { @@ -984,7 +1044,7 @@ export function bool(value: boolean): SparqlTerm { /** * Create an xsd:date literal. - * + * * @example * date(new Date('2024-01-15')) // → "2024-01-15"^^ */ @@ -993,18 +1053,18 @@ export function date(value: Date | string): SparqlTerm { const yyyy = dateObj.getFullYear() const mm = String(dateObj.getMonth() + 1).padStart(2, '0') const dd = String(dateObj.getDate()).padStart(2, '0') - return rawTerm(`"${yyyy}-${mm}-${dd}"^^`) + return rawTerm(`"${yyyy}-${mm}-${dd}"^^<${XSD.date}>`) } /** * Create an xsd:dateTime literal. - * + * * @example * dateTime(new Date()) // → "2024-01-15T10:30:00.000Z"^^ */ export function dateTime(value: Date | string): SparqlTerm { const dateObj = value instanceof Date ? value : new Date(value) - return rawTerm(`"${dateObj.toISOString()}"^^`) + return rawTerm(`"${dateObj.toISOString()}"^^<${XSD.dateTime}>`) } // ============================================================================ @@ -1013,7 +1073,7 @@ export function dateTime(value: Date | string): SparqlTerm { /** * Convert a single JavaScript value into a SPARQL *term* representation. - * + * * This is the workhorse function that handles all type conversions. It's called * automatically by the sparql template tag, so you rarely need to call it directly. * The conversion rules match what developers expect - strings become literals, @@ -1024,7 +1084,7 @@ export function dateTime(value: Date | string): SparqlTerm { * * - Use `valuesList(...)`, `exprList(...)`, `rdfList(...)` for lists. * - Use `bnodePattern(...)` for `[ ... ]` blank node property lists. - * + * * ⚠️ IMPORTANT: This function is for DATA VALUES only, not SPARQL syntax. * Do not use this for variables, prefixes, IRIs, or other syntax elements. * @@ -1053,10 +1113,10 @@ export function dateTime(value: Date | string): SparqlTerm { * convertValue(null) // throws * ``` */ -export function convertValue(value: SparqlInterpolatable, strict = true): string { // Already a SPARQL value – pass straight through. - if (isSparqlValue(value)) { - return value.value - } +export function convertValue(value: SparqlInterpolatable, strict = true): string { + // Already a SPARQL value – pass straight through. + if (isSparqlValue(value)) return value.value + if (isRdfTerm(value)) return rdfTerm(value) // Null/undefined cannot represent "unbound" – force caller to decide. if (value === null || value === undefined) { @@ -1115,7 +1175,8 @@ export function isNonStringIterable(value: unknown): value is Iterable value !== null && value !== undefined && typeof value !== 'string' && - typeof (value as any)[Symbol.iterator] === 'function' + (typeof value === 'object' || typeof value === 'function') && + typeof (value as { readonly [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' ) } @@ -1309,6 +1370,8 @@ export function isPlainObject(value: unknown): value is Record * - Everything else → treated as `:${localName}` (assumes a default `:` prefix). */ export function toPredicateName(key: string): string { + if (isVariableToken(key)) return toVarToken(key) + if (key.startsWith('<') && key.endsWith('>')) { return key } @@ -1457,7 +1520,7 @@ export function bnodePattern(props: BnodeProps): SparqlTerm { * It: * - Interpolates values using `convertValue` (scalars) or lets you insert * richer fragments using `SparqlValue` helpers (`raw`, `valuesList`, etc.). - * - Dedents/normalises indentation using `outdent`. + * - Normalizes only the common indentation introduced by the template call site. * * Because `SparqlInterpolatable` deliberately excludes arrays/objects, you are * guided towards the explicit helpers for composite structures. @@ -1509,7 +1572,7 @@ export function sparql( strings: TemplateStringsArray, ...values: SparqlInterpolatable[] ): SparqlValue { - let result = strings[0] + let result = strings[0] ?? '' for (let i = 0; i < values.length; i++) { const value = values[i] @@ -1522,10 +1585,60 @@ export function sparql( result += convertValue(value) } - result += strings[i + 1] + result += strings[i + 1] ?? '' + } + + 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) { + case 'NamedNode': + return `<${escapeIriForQuery(term.value)}>` + case 'BlankNode': + return `_:${term.value}` + case 'Variable': + return `?${term.value}` + case 'DefaultGraph': + throw new TypeError('The default graph is not a standalone SPARQL term.') + 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.datatype.value === XSD.string) return lexical + return `${lexical}^^<${escapeIriForQuery(literal.datatype.value)}>` + } + case 'Quad': { + const triple = term as RdfQuad + 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)} )>>` + } } +} + +/** + * Removes indentation introduced by a template call site without implementing a + * general-purpose text dedent library. SPARQL syntax remains otherwise untouched. + */ +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 common = indents.length === 0 ? 0 : Math.min(...indents) + return lines.map((line) => line.slice(common)).join('\n') +} - return raw(outdent.string(result)) +/** Escapes code points that cannot appear literally inside a SPARQL IRIREF. */ +function escapeIriForQuery(value: string): string { + return value.replace(/[<>"{}|^`\\\u0000-\u0020]/g, (char) => { + const point = char.codePointAt(0)! + return point <= 0xffff + ? `\\u${point.toString(16).padStart(4, '0').toUpperCase()}` + : `\\U${point.toString(16).padStart(8, '0').toUpperCase()}` + }) } -export default sparql \ No newline at end of file +export default sparql diff --git a/packages/sparql/sparql_test.ts b/packages/sparql/sparql_test.ts new file mode 100644 index 0000000..63036d6 --- /dev/null +++ b/packages/sparql/sparql_test.ts @@ -0,0 +1,42 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode } from '@okikio/rdf' +import { + SPARQL_PATTERN_BRAND, + SPARQL_QUERY_BRAND, + SPARQL_TERM_BRAND, + triple, + tripleTerm, +} from './mod.ts' + +describe('@okikio/sparql term and pattern roles', () => { + it('preserves RDF named nodes in subject, predicate, and object positions', () => { + const subject = namedNode('urn:product:1') + const predicate = namedNode('urn:example:name') + const object = namedNode('urn:value:1') + const pattern = triple(subject, predicate, object) + + expect(pattern.value).toBe(' .') + expect(pattern[SPARQL_PATTERN_BRAND]).toBe(true) + }) + + it('keeps explicit object variables as variables rather than string literals', () => { + expect(triple('?product', 'schema:name', '?name').value).toBe('?product schema:name ?name .') + }) + + 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('?subject', '?predicate', '?object').value).toBe('?subject ?predicate ?object .') + }) + + it('creates RDF 1.2 triple-term syntax as a term rather than a graph pattern', () => { + const value = tripleTerm( + namedNode('urn:s'), + namedNode('urn:p'), + namedNode('urn:o'), + ) + expect(value.value).toBe('<<( )>>') + expect(value[SPARQL_TERM_BRAND]).toBe(true) + expect(SPARQL_QUERY_BRAND in value).toBe(false) + }) +}) diff --git a/packages/sparql/syntax/mod.ts b/packages/sparql/syntax/mod.ts new file mode 100644 index 0000000..79fa5cc --- /dev/null +++ b/packages/sparql/syntax/mod.ts @@ -0,0 +1,40 @@ +/** + * Incremental SPARQL syntax inspection. + * + * This subpath exposes a source-ranged token and event stream. It deliberately + * does not claim to be a complete query AST: SPARQL 1.2 is still evolving, and + * callers such as formatters, diagnostics, editors, and future grammar parsers + * can consume the stable lexical/event layer without forcing tree materialization. + * + * @example Inspect version-sensitive syntax + * ```ts + * import * as syntax from '@okikio/sparql/syntax' + * + * const result = await syntax.inspect(` + * VERSION "1.2" + * SELECT ?s WHERE { BIND( <<( ?s :p :o )>> AS ?triple ) } + * `) + * + * console.log(result.version) // "1.2" + * console.log(result.features[0]?.feature) // "triple-term" + * ``` + * + * @module + */ + +export { events, inspect, SyntaxScanError, tokens } from './scan.ts' +export type { + DiagnosticType, + DocumentType, + EventType, + FeatureEventType, + FeatureType, + OptionsType, + RangeType, + SeverityType, + SourceType, + TokenKindType, + TokenType, + VersionEventType, + VersionType, +} from './types.ts' diff --git a/packages/sparql/syntax/mod_test.ts b/packages/sparql/syntax/mod_test.ts new file mode 100644 index 0000000..a662224 --- /dev/null +++ b/packages/sparql/syntax/mod_test.ts @@ -0,0 +1,73 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { events, inspect, tokens } from './mod.ts' + +async function collect(source: AsyncIterable): Promise { + const values: T[] = [] + for await (const value of source) values.push(value) + return values +} + +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 ) }') + 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 ) }') + 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) }')) + expect(values.some((value) => value.kind === 'operator' && value.raw === '<')).toBe(true) + expect(values.some((value) => value.kind === 'iri' && value.value === 'https://example/')).toBe(true) + }) + + it('keeps long literals across hostile chunk splits', async () => { + const source = (async function* () { + yield 'SELECT * WHERE { BIND(""' + yield '"hello\\nworld"' + yield '"" AS ?value) }' + })() + const values = await collect(tokens(source)) + expect(values.find((value) => value.kind === 'string')?.value).toBe('hello\nworld') + }) + + it('emits directional language tags and 1.2-only diagnostics under 1.1', async () => { + const document = await inspect('VERSION "1.1" SELECT * WHERE { ?s :p "hello"@en--ltr }') + expect(document.features.some((value) => value.feature === 'directional-literal')).toBe(true) + expect(document.diagnostics.some((value) => value.code === 'sparql-version-feature')).toBe(true) + }) + + it('can retain comments and whitespace without changing semantic token limits', async () => { + const values = await collect(tokens('# comment\nSELECT\t?s {}', { trivia: true, maxTokens: 4 })) + expect(values.some((value) => value.kind === 'comment')).toBe(true) + expect(values.some((value) => value.kind === 'whitespace')).toBe(true) + }) + + it('cancels a pending Web Stream read when the consumer returns early', async () => { + let cancelled = false + const source = new ReadableStream({ + pull(controller) { + controller.enqueue(new TextEncoder().encode('SELECT ?s ')) + return new Promise(() => undefined) + }, + cancel() { + cancelled = true + }, + }) + + for await (const _token of tokens(source)) break + await Promise.resolve() + expect(cancelled).toBe(true) + }) + + it('keeps malformed tokens observable in tolerant mode', async () => { + const values = await collect(events('SELECT * WHERE { ?s :p @-- }', { tolerant: true })) + expect(values.some((value) => value.kind === 'diagnostic')).toBe(true) + }) +}) diff --git a/packages/sparql/syntax/scan.ts b/packages/sparql/syntax/scan.ts new file mode 100644 index 0000000..33aa5ec --- /dev/null +++ b/packages/sparql/syntax/scan.ts @@ -0,0 +1,232 @@ +/** Version-aware semantic events over the data-oriented SPARQL scanner. @module */ + +import { Kind, Scanner, SyntaxScanError } from './scanner.ts' +import type { + DiagnosticType, + DocumentType, + EventType, + FeatureEventType, + FeatureType, + OptionsType, + RangeType, + SourceType, + TokenType, + VersionEventType, + VersionType, +} from './types.ts' + +/** Default max tokens used when the caller does not provide an override. */ +const DEFAULT_MAX_TOKENS = 10_000_000 +/** SPARQL 1.2 functions that imply the triple-term feature when encountered during syntax inspection. */ +const TRIPLE_FUNCTIONS = new Set(['TRIPLE', 'ISTRIPLE', 'SUBJECT', 'PREDICATE', 'OBJECT']) +/** SPARQL 1.2 functions that imply directional-language support during syntax inspection. */ +const DIRECTION_FUNCTIONS = new Set(['LANGDIR', 'HASLANG', 'HASLANGDIR', 'STRLANGDIR']) +/** Version labels accepted by the current syntax inspection contract. */ +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 { + const scanner = new Scanner(source, options) + const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS + let tokenCount = 0 + let externalVersion = options.version + let effectiveVersion = externalVersion + let hasVersionDirective = false + let pendingVersion: TokenType | undefined + + 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 + } + } + + 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 { + 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 + } + + 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 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) + if (!options.tolerant) throw new SyntaxScanError(issue.code, issue.message, issue.range) + yield { kind: 'diagnostic', diagnostic: issue } + } + } finally { + await scanner.close() + } +} + +/** Emits only lexical tokens while preserving the same scanner and cancellation behavior. */ +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 { + const foundTokens: TokenType[] = [] + const diagnostics: DiagnosticType[] = [] + const versions: VersionEventType[] = [] + const features: FeatureEventType[] = [] + + 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 + } + } + + const version = versions.length > 0 ? versions.at(-1)?.version : options.version + return version === undefined + ? { tokens: foundTokens, diagnostics, versions, features } + : { tokens: foundTokens, diagnostics, versions, features, version } +} + +/** Maps one token to the compatibility feature it directly introduces. */ +function getFeature(token: TokenType): FeatureType | undefined { + if (token.kind === 'langDir' && token.value.includes('--')) return 'directional-literal' + if (token.kind === 'marker') { + if (token.raw === '<<(') return 'triple-term' + if (token.raw === '<<') return 'reified-triple' + if (token.raw === '{|') return 'annotation' + if (token.raw === '~') return 'reifier' + } + if (token.kind === 'keyword' && TRIPLE_FUNCTIONS.has(token.value)) return 'triple-function' + if (token.kind === 'keyword' && DIRECTION_FUNCTIONS.has(token.value)) return 'direction-function' + return undefined +} + +/** Returns a version compatibility diagnostic only when an effective version is known. */ +function getCompatibilityDiagnostic( + feature: FeatureType, + version: VersionType | undefined, + range: RangeType, +): DiagnosticType | undefined { + if (!version || version === '1.2') return undefined + + if (version === '1.2-basic') { + 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.`, + 'error', + range, + ) + } + + return diagnostic( + 'sparql-version-feature', + `SPARQL 1.1 does not permit the observed ${feature} syntax.`, + 'error', + range, + ) +} + +/** Maps a VERSION token to the supported syntax profile used by feature diagnostics. */ +function version(label: string, value: VersionType | undefined, range: RangeType): VersionEventType { + return value === undefined + ? { kind: 'version', label, range } + : { kind: 'version', label, version: value, range } +} + +/** Creates one source-ranged syntax diagnostic without throwing from tolerant inspection. */ +function diagnostic( + code: string, + message: string, + severity: DiagnosticType['severity'], + range: RangeType, +): DiagnosticType { + return { code, message, severity, range } +} + +/** Merges adjacent token ranges when one syntax event spans multiple lexical tokens. */ +function merge(start: RangeType, end: RangeType): RangeType { + return { + start: start.start, + end: end.end, + line: start.line, + column: start.column, + endLine: end.endLine, + endColumn: end.endColumn, + } +} + +/** Returns whether the supplied value satisfies the long string contract. */ +function isLongString(raw: string): boolean { + return raw.startsWith("'''") || raw.startsWith('"""') +} + diff --git a/packages/sparql/syntax/scan_bench.ts b/packages/sparql/syntax/scan_bench.ts new file mode 100644 index 0000000..bd62142 --- /dev/null +++ b/packages/sparql/syntax/scan_bench.ts @@ -0,0 +1,20 @@ +import { bench, run } from 'mitata' +import { inspect, tokens } from './mod.ts' + +const rows = Array.from( + { length: 10_000 }, + (_, index) => `?s${index} "value-${index}" .`, +).join('\n') +const query = `VERSION "1.2"\nSELECT * WHERE {\n${rows}\n}` + +bench('sparql syntax tokens: 10k triple patterns', async () => { + let count = 0 + for await (const _token of tokens(query)) count++ + return count +}) + +bench('sparql syntax inspect: 10k triple patterns', async () => { + return (await inspect(query)).tokens.length +}) + +await run() diff --git a/packages/sparql/syntax/scanner.ts b/packages/sparql/syntax/scanner.ts new file mode 100644 index 0000000..ef50c88 --- /dev/null +++ b/packages/sparql/syntax/scanner.ts @@ -0,0 +1,721 @@ +/** Data-oriented lexical scanner used by SPARQL syntax inspection. @module */ + +import { chunks, throwIfAborted } from './source.ts' +import type { OptionsType, RangeType, SourceType, TokenKindType, TokenType } from './types.ts' + +/** Default max token length used when the caller does not provide an override. */ +const DEFAULT_MAX_TOKEN_LENGTH = 8 * 1024 * 1024 +/** Consumed UTF-16 units required before slicing the scanner buffer to bound retained source text. */ +const COMPACT_THRESHOLD = 64 * 1024 +/** Target buffered lookahead window that keeps the hot lexical loop synchronous across ordinary tokens. */ +const REFILL_WINDOW = 16 * 1024 + +/** Numeric token kinds keep hot scanner state compact. This is not a public API. */ +export const Kind = { + Eof: 0, + Keyword: 1, + Variable: 2, + Iri: 3, + Prefixed: 4, + Blank: 5, + String: 6, + LangDir: 7, + Integer: 8, + Decimal: 9, + Double: 10, + Boolean: 11, + Punctuation: 12, + Operator: 13, + Marker: 14, + Identifier: 15, + Whitespace: 16, + Comment: 17, + Unknown: 18, +} as const + +/** Stable lexical token-kind value emitted by the SPARQL scanner. */ +export type Kind = (typeof Kind)[keyof typeof Kind] + +/** 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', +]) + +/** Position-aware lexical failure used by strict mode and converted in tolerant mode. */ +export class SyntaxScanError extends SyntaxError { + readonly code: string + readonly range: RangeType + + /** Creates a source-ranged lexical failure that the event layer can surface as a diagnostic. */ + constructor(code: string, message: string, range: RangeType) { + super(message) + this.name = 'SparqlSyntaxScanError' + this.code = code + this.range = range + } +} + +/** + * Incremental UTF-8 scanner with one mutable token record. + * + * Character classification is synchronous while bytes are already buffered. + * The scanner awaits only when it must refill the current source window. This + * preserves streaming and hostile chunk-split behavior without a Promise per + * character. Consumed source is compacted to cap retained text. + */ +export class Scanner { + kind: Kind = Kind.Eof + value = '' + raw = '' + start = 0 + end = 0 + line = 1 + column = 1 + endLine = 1 + endColumn = 1 + + readonly signal: AbortSignal | undefined + readonly trivia: boolean + readonly maxTokenLength: number + + #source: AsyncGenerator + #decoder = new TextDecoder('utf-8', { fatal: true }) + #buffer = '' + #index = 0 + #absolute = 0 + #line = 1 + #column = 1 + #done = false + + /** Creates a buffered scanner whose hot character loop stays synchronous until a source refill is needed. */ + constructor(source: SourceType, options: OptionsType) { + this.signal = options.signal + this.trivia = options.trivia ?? false + this.maxTokenLength = options.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH + this.#source = chunks(source, options.signal) + } + + /** Advances to the next lexical token. */ + async next(): Promise { + throwIfAborted(this.signal) + await this.#refill(3) + + if (this.trivia) { + if (await this.#trivia()) return + } else { + await this.#skipTrivia() + } + + await this.#refill(3) + this.#mark() + const first = this.#peek() + if (first === undefined) { + this.kind = Kind.Eof + this.#finish() + return + } + + 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 (first === '?' || first === '$') { + const second = this.#peek(1) + if (second !== undefined && isVarStart(second)) return await this.#variable() + return this.#fixed(Kind.Operator, 1) + } + + if (first === '<') { + if (await this.#looksLikeIri()) return await this.#iri() + return this.#fixed(Kind.Operator, 1) + } + + if (first === '"' || first === "'") return await this.#string(first) + if (first === '@') return await this.#langDir() + if (two === '_:') return await this.#blank() + if (first === ':' || isPnStart(first)) return await this.#word() + + if (/[0-9]/.test(first) || first === '.' || first === '+' || first === '-') { + 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) + + this.#take() + this.kind = Kind.Unknown + this.value = first + this.raw = first + this.#finish() + } + + /** Copies mutable scanner state into one stable public token. */ + token(): TokenType { + return { kind: kindName(this.kind), value: this.value, raw: this.raw, range: this.range() } + } + + /** Returns the absolute and line/column range for the current token record. */ + range(): RangeType { + return { + start: this.start, + end: this.end, + line: this.line, + column: this.column, + endLine: this.endLine, + endColumn: this.endColumn, + } + } + + /** Creates a lexical error anchored at the current scanner token. */ + error(code: string, message: string): SyntaxScanError { + return new SyntaxScanError(code, message, this.range()) + } + + /** Releases the upstream source when syntax iteration stops before EOF. */ + async close(): Promise { + await this.#source.return(undefined) + } + + /** Trivia as one isolated step of the Scanner state machine. */ + async #trivia(): Promise { + this.#mark() + let first = this.#peek() + if (first === undefined && !this.#done) { + await this.#refill() + first = this.#peek() + } + if (first === undefined) return false + + if (isWhitespace(first)) { + let raw = '' + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined || !isWhitespace(char)) break + raw += this.#take() ?? '' + } + this.kind = Kind.Whitespace + this.value = raw + this.raw = raw + this.#finish() + return true + } + + if (first === '#') { + let raw = '' + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined || char === '\n' || char === '\r') break + raw += this.#take() ?? '' + } + this.kind = Kind.Comment + this.value = raw.slice(1) + this.raw = raw + this.#finish() + return true + } + + return false + } + + /** Skips trivia in the current parser or scanner state. */ + async #skipTrivia(): Promise { + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined) return + if (isWhitespace(char)) { + this.#take() + continue + } + if (char === '#') { + while (true) { + let item = this.#peek() + if (item === undefined && !this.#done) { + await this.#refill() + item = this.#peek() + } + if (item === undefined || item === '\n' || item === '\r') break + this.#take() + } + continue + } + return + } + } + + /** Fixed as one isolated step of the Scanner state machine. */ + #fixed(kind: Kind, width: number): void { + let raw = '' + for (let i = 0; i < width; i++) raw += this.#take() ?? '' + this.kind = kind + this.value = raw + this.raw = raw + this.#finish() + } + + /** Variable as one isolated step of the Scanner state machine. */ + async #variable(): Promise { + const mark = this.#absolute + let raw = this.#take() ?? '' + let value = '' + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined || !isVarContinue(char)) break + raw += this.#take() ?? '' + value += char + this.#guard(mark) + } + this.kind = Kind.Variable + this.value = value + this.raw = raw + this.#finish() + } + + /** Looks like iri as one isolated step of the Scanner state machine. */ + async #looksLikeIri(): Promise { + let offset = 1 + while (offset <= this.maxTokenLength) { + if (this.#peek(offset) === undefined && !this.#done) await this.#refill(offset + 1) + const char = this.#peek(offset) + if (char === undefined) return false + if (char === '>') return true + if (char === '\\') { + if (this.#peek(offset + 1) === undefined && !this.#done) await this.#refill(offset + 10) + const marker = this.#peek(offset + 1) + if (marker !== 'u' && marker !== 'U') return false + offset += marker === 'u' ? 6 : 10 + continue + } + if (char <= ' ' || /[<>"{}|^`]/.test(char)) return false + offset++ + } + throw this.error('sparql-token-limit', `IRI token exceeds ${this.maxTokenLength} code units.`) + } + + /** Iri as one isolated step of the Scanner state machine. */ + async #iri(): Promise { + const mark = this.#absolute + let raw = this.#take() ?? '' + let value = '' + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined) throw this.error('sparql-iri-end', 'Unterminated SPARQL IRI reference.') + if (char === '>') { + raw += this.#take() ?? '' + break + } + if (char === '\\') { + raw += this.#take() ?? '' + const escape = await this.#unicode() + raw += escape.raw + value += escape.value + continue + } + raw += this.#take() ?? '' + value += char + this.#guard(mark) + } + this.kind = Kind.Iri + this.value = value + this.raw = raw + this.#finish() + } + + /** String as one isolated step of the Scanner state machine. */ + async #string(quote: string): Promise { + await this.#refill(3) + const mark = this.#absolute + const long = this.#peek(1) === quote && this.#peek(2) === quote + const width = long ? 3 : 1 + let raw = '' + for (let i = 0; i < width; i++) raw += this.#take() ?? '' + let value = '' + + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill(3) + char = this.#peek() + } + 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) + if (this.#peek(1) === quote && this.#peek(2) === quote) { + for (let i = 0; i < 3; i++) raw += this.#take() ?? '' + break + } + } else { + raw += this.#take() ?? '' + break + } + } + if (!long && (char === '\n' || char === '\r')) { + throw this.error('sparql-string-line', 'Short SPARQL string literals cannot contain line breaks.') + } + if (char === '\\') { + raw += this.#take() ?? '' + if (this.#peek() === undefined && !this.#done) await this.#refill(9) + const next = this.#peek() + if (next === 'u' || next === 'U') { + const escape = await this.#unicode() + raw += escape.raw + value += escape.value + continue + } + if (next === undefined || !'tbnrf"\'\\'.includes(next)) { + throw this.error('sparql-string-escape', 'Invalid SPARQL string escape.') + } + raw += this.#take() ?? '' + value += escaped(next) + continue + } + raw += this.#take() ?? '' + value += char + this.#guard(mark) + } + + this.kind = Kind.String + this.value = value + this.raw = raw + this.#finish() + } + + /** Lang dir as one isolated step of the Scanner state machine. */ + async #langDir(): Promise { + const mark = this.#absolute + let raw = this.#take() ?? '' + let value = '' + let sawLetter = false + + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined || !/[A-Za-z0-9-]/.test(char)) break + raw += this.#take() ?? '' + value += char + if (/[A-Za-z]/.test(char)) sawLetter = true + this.#guard(mark) + } + + this.kind = sawLetter && /^[A-Za-z]+(?:-[A-Za-z0-9]+)*(?:--[A-Za-z]+)?$/.test(value) + ? Kind.LangDir + : Kind.Unknown + this.value = value + this.raw = raw + this.#finish() + } + + /** Blank as one isolated step of the Scanner state machine. */ + async #blank(): Promise { + const mark = this.#absolute + let raw = `${this.#take() ?? ''}${this.#take() ?? ''}` + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill() + char = this.#peek() + } + if (char === undefined || isDelimiter(char)) break + raw += this.#take() ?? '' + this.#guard(mark) + } + this.kind = raw.length > 2 ? Kind.Blank : Kind.Unknown + this.value = raw.slice(2) + this.raw = raw + this.#finish() + } + + /** Word as one isolated step of the Scanner state machine. */ + async #word(): Promise { + const mark = this.#absolute + let raw = '' + let escapedLocal = false + + while (true) { + let char = this.#peek() + if (char === undefined && !this.#done) { + await this.#refill(3) + char = this.#peek() + } + if (char === undefined || isDelimiter(char)) break + if (char === '\\') { + if (this.#peek(1) === undefined && !this.#done) await this.#refill(2) + const next = this.#peek(1) + if (next === undefined || !'_~.-!$&\'()*+,;=/?#@%'.includes(next)) break + raw += `${this.#take() ?? ''}${this.#take() ?? ''}` + escapedLocal = true + this.#guard(mark) + continue + } + if (char === '%') { + if (this.#peek(2) === undefined && !this.#done) await this.#refill(3) + if (isHex(this.#peek(1)) && isHex(this.#peek(2))) { + raw += `${this.#take() ?? ''}${this.#take() ?? ''}${this.#take() ?? ''}` + escapedLocal = true + this.#guard(mark) + continue + } + } + if (!isPnContinue(char)) break + raw += this.#take() ?? '' + this.#guard(mark) + } + + if (raw.includes(':')) { + this.kind = Kind.Prefixed + this.value = raw + } else if (!escapedLocal && raw === 'a') { + this.kind = Kind.Keyword + this.value = raw + } else { + const upper = raw.toUpperCase() + if (KEYWORDS.has(upper)) { + this.kind = upper === 'TRUE' || upper === 'FALSE' ? Kind.Boolean : Kind.Keyword + this.value = upper === 'TRUE' || upper === 'FALSE' ? raw.toLowerCase() : upper + } else { + this.kind = Kind.Identifier + this.value = raw + } + } + this.raw = raw + this.#finish() + } + + /** Attempts numeric maximal munch without consuming adjacent arithmetic operators. */ + async #number(): Promise { + await this.#refill(128) + let candidate = '' + for (let offset = 0; offset < 128; offset++) { + const char = this.#peek(offset) + if (char === undefined || !/[0-9eE+.-]/.test(char)) break + candidate += char + } + + const matches: Array<{ kind: Kind; 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 + for (const entry of matches) { + if (!chosen || entry.match.length > chosen.match.length) chosen = entry + } + if (!chosen) return false + + this.#mark() + let raw = '' + for (let i = 0; i < chosen.match.length; i++) raw += this.#take() ?? '' + this.kind = chosen.kind + this.value = raw + this.raw = raw + this.#finish() + return true + } + + /** Unicode as one isolated step of the Scanner state machine. */ + async #unicode(): Promise<{ raw: string; value: string }> { + await this.#refill(9) + const marker = this.#take() + 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++) { + const char = this.#take() + if (char === undefined || !/[0-9A-Fa-f]/.test(char)) { + throw this.error('sparql-unicode', 'Invalid SPARQL Unicode escape.') + } + hex += char + } + const point = Number.parseInt(hex, 16) + if (point > 0x10FFFF || (point >= 0xD800 && point <= 0xDFFF)) { + throw this.error('sparql-unicode', 'SPARQL Unicode escape is not a Unicode scalar value.') + } + return { raw: `${marker}${hex}`, value: String.fromCodePoint(point) } + } + + /** Mark as one isolated step of the Scanner state machine. */ + #mark(): void { + this.start = this.#absolute + this.end = this.#absolute + this.line = this.#line + this.column = this.#column + this.endLine = this.#line + this.endColumn = this.#column + this.value = '' + this.raw = '' + } + + /** Finish as one isolated step of the Scanner state machine. */ + #finish(): void { + this.end = this.#absolute + this.endLine = this.#line + this.endColumn = this.#column + } + + /** Applies configured parser resource limits before accepting more input. */ + #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.`) + } + } + + /** Reads the next buffered source value without consuming it. */ + #peek(offset = 0): string | undefined { + return this.#buffer[this.#index + offset] + } + + /** Consumes and returns the next buffered source value. */ + #take(): string | undefined { + const char = this.#buffer[this.#index] + if (char === undefined) return undefined + this.#index++ + this.#absolute++ + if (char === '\n') { + this.#line++ + this.#column = 1 + } else { + this.#column++ + } + this.#compact() + return char + } + + /** Ensures at least `minimum` code units are buffered from the current cursor when possible. */ + async #refill(minimum = REFILL_WINDOW): Promise { + while (!this.#done && this.#buffer.length - this.#index < minimum) { + const item = await this.#source.next() + if (item.done) { + this.#buffer += this.#decoder.decode() + this.#done = true + break + } + this.#buffer += typeof item.value === 'string' + ? item.value + : this.#decoder.decode(item.value, { stream: true }) + } + } + + /** Compacts consumed source data while preserving every unread token byte. */ + #compact(): void { + if (this.#index < COMPACT_THRESHOLD) return + this.#buffer = this.#buffer.slice(this.#index) + this.#index = 0 + } +} + +/** Converts one internal numeric scanner kind to the public lexical token class. */ +function kindName(kind: Kind): 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' + } +} + +/** 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 + } +} + +/** Returns whether the supplied value satisfies the whitespace contract. */ +function isWhitespace(char: string): boolean { + return char === ' ' || char === '\t' || char === '\n' || char === '\r' +} + +/** Returns whether the supplied value satisfies the var start contract. */ +function isVarStart(char: string): boolean { + return /[\p{L}\p{N}_]/u.test(char) +} + +/** Returns whether the supplied value satisfies the var continue contract. */ +function isVarContinue(char: string): boolean { + return /[\p{L}\p{N}\p{M}\p{Pc}_\u00B7]/u.test(char) +} + +/** Returns whether the supplied value satisfies the pn start contract. */ +function isPnStart(char: string): boolean { + return /[\p{L}_]/u.test(char) +} + +/** Returns whether the supplied value satisfies the pn continue contract. */ +function isPnContinue(char: string): boolean { + return /[\p{L}\p{N}\p{M}\p{Pc}_:.-]/u.test(char) +} + +/** Returns whether the supplied value satisfies the delimiter contract. */ +function isDelimiter(char: string): boolean { + return isWhitespace(char) || '{}()[];,<>"\'~^|=!*/?'.includes(char) +} + +/** Returns whether the supplied value satisfies the hex contract. */ +function isHex(char: string | undefined): boolean { + return char !== undefined && /[0-9A-Fa-f]/.test(char) +} diff --git a/packages/sparql/syntax/source.ts b/packages/sparql/syntax/source.ts new file mode 100644 index 0000000..ba424a6 --- /dev/null +++ b/packages/sparql/syntax/source.ts @@ -0,0 +1,107 @@ +/** Incremental source adapter used by the SPARQL lexical scanner. @module */ + +import type { SourceType } from './types.ts' + +/** Window used to expose direct string/byte inputs through the same bounded streaming path as chunked sources. */ +const DIRECT_CHUNK_SIZE = 16 * 1024 + +/** + * Iterates source chunks without taking ownership of ordinary iterables. + * + * 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 { + if (typeof source === 'string') { + for (let offset = 0; offset < source.length; offset += DIRECT_CHUNK_SIZE) { + throwIfAborted(signal) + yield source.slice(offset, offset + DIRECT_CHUNK_SIZE) + } + return + } + + if (source instanceof Uint8Array) { + for (let offset = 0; offset < source.byteLength; offset += DIRECT_CHUNK_SIZE) { + throwIfAborted(signal) + yield source.subarray(offset, offset + DIRECT_CHUNK_SIZE) + } + return + } + + if (source instanceof ReadableStream) { + const reader = source.getReader() + let complete = false + try { + while (true) { + throwIfAborted(signal) + const item = await read(reader, signal) + if (item.done) { + complete = true + return + } + yield item.value + } + } finally { + if (!complete) await reader.cancel('SPARQL syntax consumer stopped before source completion').catch(() => undefined) + reader.releaseLock() + } + } + + if (Symbol.asyncIterator in Object(source)) { + for await (const chunk of source as AsyncIterable) { + throwIfAborted(signal) + yield chunk + } + return + } + + for (const chunk of source as Iterable) { + throwIfAborted(signal) + yield chunk + } +} + +/** Throws the original abort reason before more input is accepted. */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError') +} + +/** Cancels a Web Stream read that is already pending when the operation aborts. */ +function read( + reader: ReadableStreamDefaultReader, + signal?: AbortSignal, +): Promise> { + if (!signal) return reader.read() + if (signal.aborted) { + 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 = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + if (settled) return + settled = true + finish() + 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 + settled = true + finish() + resolve(value) + }, + (error) => { + if (settled) return + settled = true + finish() + reject(error) + }, + ) + }) +} diff --git a/packages/sparql/syntax/types.ts b/packages/sparql/syntax/types.ts new file mode 100644 index 0000000..dc520d8 --- /dev/null +++ b/packages/sparql/syntax/types.ts @@ -0,0 +1,120 @@ +/** Public data contracts for SPARQL syntax inspection. @module */ + +/** SPARQL version labels defined by the SPARQL 1.2 query/update specifications. */ +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 { + readonly start: number + readonly end: number + readonly line: number + readonly column: number + readonly endLine: number + readonly endColumn: number +} + +/** Public lexical token classes. */ +export type TokenKindType = + | 'keyword' + | 'variable' + | 'iri' + | 'prefixed' + | 'blank' + | 'string' + | 'langDir' + | 'integer' + | 'decimal' + | 'double' + | 'boolean' + | 'punctuation' + | 'operator' + | 'marker' + | 'identifier' + | 'whitespace' + | 'comment' + +/** One lexical SPARQL token. */ +export interface TokenType { + readonly kind: TokenKindType + /** Decoded semantic value where decoding is meaningful, otherwise the token text. */ + readonly value: string + /** Exact source spelling. */ + readonly raw: string + readonly range: RangeType +} + +/** Stable diagnostic severity used by syntax inspection. */ +export type SeverityType = 'warning' | 'error' + +/** Recoverable lexical or version/feature diagnostic. */ +export interface DiagnosticType { + readonly code: string + readonly message: string + readonly severity: SeverityType + readonly range: RangeType +} + +/** SPARQL syntax features that need explicit compatibility awareness. */ +export type FeatureType = + | 'directional-literal' + | 'triple-term' + | 'reified-triple' + | 'annotation' + | 'reifier' + | 'triple-function' + | 'direction-function' + +/** One observed feature occurrence. */ +export interface FeatureEventType { + readonly kind: 'feature' + readonly feature: FeatureType + readonly range: RangeType +} + +/** One VERSION announcement. `version` is absent for unrecognized labels. */ +export interface VersionEventType { + readonly kind: 'version' + readonly label: string + readonly version?: VersionType + readonly range: RangeType +} + +/** Incremental syntax event. */ +export type EventType = + | { readonly kind: 'token'; readonly token: TokenType } + | VersionEventType + | FeatureEventType + | { readonly kind: 'diagnostic'; readonly diagnostic: DiagnosticType } + +/** Byte/text input accepted by SPARQL syntax inspection. */ +export type SourceType = + | string + | Uint8Array + | Iterable + | AsyncIterable + | ReadableStream + +/** Controls for incremental SPARQL syntax inspection. */ +export interface OptionsType { + /** External protocol/media-type version used only when no VERSION directive is present. */ + readonly version?: VersionType + /** Keep whitespace and comments as tokens. They are skipped by default. */ + readonly trivia?: boolean + /** Emit diagnostics and continue where lexical recovery is safe. */ + readonly tolerant?: boolean + /** Maximum decoded token length. */ + readonly maxTokenLength?: number + /** Maximum number of emitted non-trivia tokens. */ + readonly maxTokens?: number + readonly signal?: AbortSignal +} + +/** Materialized view over the event stream. This is intentionally not a SPARQL AST. */ +export interface DocumentType { + readonly tokens: readonly TokenType[] + readonly diagnostics: readonly DiagnosticType[] + readonly versions: readonly VersionEventType[] + readonly features: readonly FeatureEventType[] + /** Effective recognized version after applying VERSION-over-protocol precedence. */ + readonly version?: VersionType +} diff --git a/update.ts b/packages/sparql/update.ts similarity index 74% rename from update.ts rename to packages/sparql/update.ts index cd1a48b..c006516 100644 --- a/update.ts +++ b/packages/sparql/update.ts @@ -1,25 +1,25 @@ /** * SPARQL 1.1 Update operations. - * + * * SPARQL Update provides operations for modifying RDF data. These complement * queries (SELECT/ASK/CONSTRUCT) by letting you insert, delete, and manage * graph data. Updates execute against an update endpoint (often different * from the query endpoint). - * + * * The pattern mirrors the query builder: start with an operation type (insert, - * delete, modify), add details, then build or execute. Each operation is - * immutable - methods return new builders rather than modifying existing ones. - * + * delete, modify), add details, then build. Execution belongs to an explicit + * SPARQL client. Each operation is immutable. Methods return new builders rather + * than modifying existing ones. + * * @example Insert data * ```ts - * insert(triples('ex:person1', [ + * const text = insert(triples('ex:person1', [ * ['rdf:type', 'foaf:Person'], * ['foaf:name', str('Alice')], * ['foaf:age', num(30)] - * ])) - * .execute(config) + * ])).build() * ``` - * + * * @example Conditional update * ```ts * modify() @@ -28,14 +28,13 @@ * .where(triple('?person', 'foaf:age', '?oldAge')) * .where(bind(add(v('oldAge'), 1), 'newAge')) * .done() - * .execute(config) + * .build() * ``` - * + * * @module */ -import { raw, sparql, SPARQL_EXPR_BRAND, SPARQL_VALUE_BRAND, toGraphRef, toGraphRefAll, toVarOrIriRef, type SparqlValue } from './sparql.ts' -import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult } from './executor.ts' +import { rawPattern, toGraphOrDefault, toGraphRef, toGraphRefAll, updateDocument, type GraphOrDefaultInput, type IriInput, type PatternValue, type SparqlUpdate } from './sparql.ts' // ============================================================================ // Update Operation Types @@ -43,7 +42,7 @@ import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult /** * Internal state for update operations. - * + * * This is immutable - each method creates a new state object rather than * modifying the existing one. */ @@ -56,11 +55,11 @@ export interface UpdateState { */ export interface UpdateOperation { readonly type: 'INSERT_DATA' | 'DELETE_DATA' | 'DELETE_WHERE' | 'DELETE_INSERT' | 'LOAD' | 'CLEAR' | 'DROP' | 'CREATE' | 'COPY' | 'MOVE' | 'ADD' - readonly data?: SparqlValue - readonly where?: SparqlValue + readonly data?: PatternValue + readonly where?: PatternValue readonly graph?: string - readonly deleteTemplate?: SparqlValue - readonly insertTemplate?: SparqlValue + readonly deleteTemplate?: PatternValue + readonly insertTemplate?: PatternValue readonly silent?: boolean readonly source?: string readonly dest?: string @@ -79,35 +78,40 @@ const initialUpdateState: UpdateState = { /** * Builder for SPARQL Update operations. - * + * * Each method returns a new UpdateBuilder with updated state. This immutability * means you can safely store intermediate builders and branch from them. - * + * * @example Building incrementally * ```ts * const baseUpdate = update() * .insertData(triple('ex:person1', 'rdf:type', 'foaf:Person')) - * + * * // Add more operations * const fullUpdate = baseUpdate * .insertData(triple('ex:person1', 'foaf:name', str('Alice'))) * ``` */ export class UpdateBuilder { - constructor(private readonly state: UpdateState) {} + private readonly state: UpdateState + + /** Stores one immutable update-operation sequence; every builder method returns a new sequence instead of mutating this instance. */ + constructor(state: UpdateState) { + this.state = state + } /** * Start building an update operation. - * + * * Returns an empty builder you can add operations to. Operations are executed * in the order you add them. - * + * * @example Multiple operations * ```ts * update() * .insertData(triple('ex:person1', 'foaf:name', str('Alice'))) * .insertData(triple('ex:person2', 'foaf:name', str('Bob'))) - * .execute(config) + * .build() * ``` */ static create(): UpdateBuilder { @@ -116,14 +120,14 @@ export class UpdateBuilder { /** * Insert RDF triples (INSERT DATA). - * + * * Adds triples directly to the dataset. The triples must be ground (no variables) - * all subjects, predicates, and objects must be concrete values, not variables. * For conditional inserts based on patterns, use modify() instead. - * + * * @param data Triples to insert (must be ground) * @param graph Optional named graph to insert into - * + * * @example Insert person data * ```ts * insertData(triples('ex:person1', [ @@ -132,7 +136,7 @@ export class UpdateBuilder { * ['foaf:age', num(30)] * ])) * ``` - * + * * @example Insert into named graph * ```ts * insertData( @@ -141,30 +145,30 @@ export class UpdateBuilder { * ) * ``` */ - insertData(data: SparqlValue, graph?: string): UpdateBuilder { + insertData(data: PatternValue, graph?: IriInput): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'INSERT_DATA', data, graph: graph ? toVarOrIriRef(graph) : graph } + { type: 'INSERT_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) } ] }) } /** * Delete RDF triples (DELETE DATA). - * + * * Removes triples from the dataset. The triples must be ground (no variables) - * you must specify exact triples to delete. For pattern-based deletion, * use deleteWhere() or modify() instead. - * + * * @param data Triples to delete (must be ground) * @param graph Optional named graph to delete from - * + * * @example Delete specific triple * ```ts * deleteData(triple('ex:person1', 'foaf:age', num(30))) * ``` - * + * * @example Delete multiple triples * ```ts * deleteData(triples('ex:person1', [ @@ -173,36 +177,36 @@ export class UpdateBuilder { * ])) * ``` */ - deleteData(data: SparqlValue, graph?: string): UpdateBuilder { + deleteData(data: PatternValue, graph?: IriInput): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'DELETE_DATA', data, graph: graph ? toVarOrIriRef(graph) : graph } + { type: 'DELETE_DATA', data, ...(graph ? { graph: toGraphRef(graph) } : {}) } ] }) } /** * Delete triples matching a pattern (DELETE WHERE). - * + * * Finds all triples matching the pattern and deletes them. The pattern can * contain variables - anything that matches gets deleted. This is shorthand * for DELETE/INSERT where the delete and where templates are the same. - * + * * @param pattern Pattern of triples to delete - * + * * @example Delete all ages * ```ts * deleteWhere(triple('?person', 'foaf:age', '?age')) * // Deletes age for all people * ``` - * + * * @example Delete specific person's data * ```ts * deleteWhere(triple('ex:person1', '?property', '?value')) * // Deletes all triples with ex:person1 as subject * ``` - * + * * @example Complex pattern * ```ts * deleteWhere(raw(` @@ -212,7 +216,7 @@ export class UpdateBuilder { * // Deletes invalid ages * ``` */ - deleteWhere(pattern: SparqlValue): UpdateBuilder { + deleteWhere(pattern: PatternValue): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, @@ -223,15 +227,15 @@ export class UpdateBuilder { /** * Start a DELETE/INSERT operation. - * + * * Combines deletion and insertion in one operation. Finds matches with WHERE, * deletes according to DELETE template, inserts according to INSERT template. * This is the most powerful update operation - use it when you need to transform * data based on patterns. - * + * * Chain with .delete(), .insert(), and .where() to build the operation. * Call .done() when finished to return to the main UpdateBuilder. - * + * * @example Update ages * ```ts * modify() @@ -241,7 +245,7 @@ export class UpdateBuilder { * .where(bind(add(v('oldAge'), 1), 'newAge')) * .done() * ``` - * + * * @example Conditional insert * ```ts * modify() @@ -258,19 +262,19 @@ export class UpdateBuilder { /** * Load RDF from a URL. - * + * * Fetches RDF from the specified URL and adds it to the dataset. The URL * must return RDF in a format the endpoint understands (Turtle, RDF/XML, etc.). - * + * * @param url URL to load from * @param graph Optional target graph (default: default graph) * @param silent Don't fail if URL unreachable (default: false) - * + * * @example Load Turtle file * ```ts * load('http://example.org/data.ttl') * ``` - * + * * @example Load into named graph * ```ts * load( @@ -278,48 +282,48 @@ export class UpdateBuilder { * 'http://example.org/graph1' * ) * ``` - * + * * @example Silent load * ```ts * load('http://example.org/data.ttl', undefined, true) * // Continues even if URL is unreachable * ``` */ - load(url: string, graph?: string, silent = false): UpdateBuilder { + load(url: IriInput, graph?: IriInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'LOAD', data: { [SPARQL_VALUE_BRAND]: true, [SPARQL_EXPR_BRAND]: true, value: toGraphRef(url) }, graph: graph ? toVarOrIriRef(graph) : graph, silent } + { type: 'LOAD', source: toGraphRef(url), ...(graph ? { graph: toGraphRef(graph) } : {}), silent } ] }) } /** * Clear a graph (remove all triples). - * + * * Removes all triples from the specified graph but keeps the graph itself. * Use 'DEFAULT' to clear the default graph. - * + * * @param graph Graph IRI or 'DEFAULT' * @param silent Don't fail if graph doesn't exist (default: false) - * + * * @example Clear default graph * ```ts * clear('DEFAULT') * ``` - * + * * @example Clear named graph * ```ts * clear('http://example.org/graph1') * ``` - * + * * @example Silent clear * ```ts * clear('http://example.org/graph1', true) * // Doesn't error if graph doesn't exist * ``` */ - clear(graph: string, silent = false): UpdateBuilder { + clear(graph: IriInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, @@ -330,25 +334,25 @@ export class UpdateBuilder { /** * Drop a graph (delete it entirely). - * + * * Completely removes a graph and all its triples. Unlike clear(), which * empties the graph but keeps it, drop() removes the graph entirely. - * + * * @param graph Graph IRI to drop * @param silent Don't fail if graph doesn't exist (default: false) - * + * * @example Drop named graph * ```ts * drop('http://example.org/graph1') * ``` - * + * * @example Silent drop * ```ts * drop('http://example.org/graph1', true) * // Succeeds even if graph doesn't exist * ``` */ - drop(graph: string, silent = false): UpdateBuilder { + drop(graph: IriInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, @@ -359,33 +363,27 @@ export class UpdateBuilder { /** * Create a new empty graph. - * + * * Creates a new named graph. The graph starts empty - use insertData() * to add triples to it. - * + * * @param graph Graph IRI to create * @param silent Don't fail if graph already exists (default: false) - * + * * @example Create graph * ```ts * create('http://example.org/graph1') * ``` - * + * * @example Silent create * ```ts * create('http://example.org/graph1', true) * // Succeeds even if graph already exists * ``` */ - create(graph: string, silent = false): UpdateBuilder { - const trimmed = graph.trim() - const upper = trimmed.toUpperCase() + create(graph: IriInput, silent = false): UpdateBuilder { + const graphRef = toGraphRef(graph) - const graphRef = toGraphRef(graph); - if (upper === 'NAMED' || upper === 'ALL') { - throw new Error("Graph Ref in create() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - return new UpdateBuilder({ operations: [ ...this.state.operations, @@ -396,227 +394,178 @@ export class UpdateBuilder { /** * Copy all triples from one graph to another. - * + * * Copies the content of the source graph to the destination graph. The destination * graph is overwritten - any existing content in it is replaced. The source graph * remains unchanged. - * + * * Use 'DEFAULT' as the graph name to refer to the default graph. - * + * * @param source Source graph IRI (or 'DEFAULT') * @param dest Destination graph IRI (or 'DEFAULT') * @param silent Don't fail if source doesn't exist (default: false) - * + * * @sparql `COPY [SILENT] TO ` - * + * * @example Copy to backup * ```ts * // Library * copy('http://example.org/graph1', 'http://example.org/backup1') - * + * * // SPARQL ↓ * // COPY TO * ``` - * + * * @example Copy from default graph * ```ts * // Library * copy('DEFAULT', 'http://example.org/snapshot') - * + * * // SPARQL ↓ * // COPY DEFAULT TO * ``` - * + * * @example Silent copy * ```ts * // Library * copy('http://example.org/source', 'http://example.org/dest', true) - * + * * // SPARQL ↓ * // COPY SILENT TO * ``` */ - copy(source: string, dest: string, silent = false): UpdateBuilder { - const src = source.trim() - const srcUpper = src.toUpperCase() - - const destination = dest.trim() - const destUpper = destination.toUpperCase() - - if (srcUpper === 'NAMED' || srcUpper === 'ALL') { - throw new Error("Source graph ref in add() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - if (destUpper === 'NAMED' || destUpper === 'ALL') { - throw new Error("Destination graph ref in add() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - const srcGraphRef = toGraphRef(source); - const destGraphRef = toGraphRef(dest); + copy(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'COPY', source: srcGraphRef, dest: destGraphRef, silent } + { type: 'COPY', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } ] }) } /** * Move all triples from one graph to another. - * + * * Moves the content of the source graph to the destination graph. The destination * graph is overwritten, and the source graph is cleared. This is equivalent to * COPY followed by DROP of the source. - * + * * Use 'DEFAULT' as the graph name to refer to the default graph. - * + * * @param source Source graph IRI (or 'DEFAULT') * @param dest Destination graph IRI (or 'DEFAULT') * @param silent Don't fail if source doesn't exist (default: false) - * + * * @sparql `MOVE [SILENT] TO ` - * + * * @example Rename graph * ```ts * // Library * move('http://example.org/temp', 'http://example.org/final') - * + * * // SPARQL ↓ * // MOVE TO * ``` - * + * * @example Archive to default * ```ts * // Library * move('http://example.org/staging', 'DEFAULT') - * + * * // SPARQL ↓ * // MOVE TO DEFAULT * ``` - * + * * @example Silent move * ```ts * // Library * move('http://example.org/source', 'http://example.org/dest', true) - * + * * // SPARQL ↓ * // MOVE SILENT TO * ``` */ - move(source: string, dest: string, silent = false): UpdateBuilder { - const src = source.trim() - const srcUpper = src.toUpperCase() - - const destination = dest.trim() - const destUpper = destination.toUpperCase() - - if (srcUpper === 'NAMED' || srcUpper === 'ALL') { - throw new Error("Source graph ref in move() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - if (destUpper === 'NAMED' || destUpper === 'ALL') { - throw new Error("Destination graph ref in move() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - const srcGraphRef = toGraphRef(source); - const destGraphRef = toGraphRef(dest); + move(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'MOVE', source: srcGraphRef, dest: destGraphRef, silent } + { type: 'MOVE', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } ] }) } /** * Add all triples from one graph to another. - * + * * Adds the content of the source graph to the destination graph. Unlike COPY, * existing triples in the destination are preserved. The source graph remains * unchanged. This is like a merge operation. - * + * * Use 'DEFAULT' as the graph name to refer to the default graph. - * + * * @param source Source graph IRI (or 'DEFAULT') * @param dest Destination graph IRI (or 'DEFAULT') * @param silent Don't fail if source doesn't exist (default: false) - * + * * @sparql `ADD [SILENT] TO ` - * + * * @example Merge graphs * ```ts * // Library * add('http://example.org/updates', 'http://example.org/main') - * + * * // SPARQL ↓ * // ADD TO * ``` - * + * * @example Combine into default * ```ts * // Library * add('http://example.org/graph1', 'DEFAULT') * add('http://example.org/graph2', 'DEFAULT') - * + * * // SPARQL ↓ * // ADD TO DEFAULT * // ADD TO DEFAULT * ``` - * + * * @example Silent add * ```ts * // Library * add('http://example.org/optional', 'http://example.org/main', true) - * + * * // SPARQL ↓ * // ADD SILENT TO * ``` */ - add(source: string, dest: string, silent = false): UpdateBuilder { - const src = source.trim() - const srcUpper = src.toUpperCase() - - const destination = dest.trim() - const destUpper = destination.toUpperCase() - - if (srcUpper === 'NAMED' || srcUpper === 'ALL') { - throw new Error("Source graph ref in copy() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - if (destUpper === 'NAMED' || destUpper === 'ALL') { - throw new Error("Destination graph ref in copy() doesn't support either 'NAMED' nor 'ALL' in create statements") - } - - const srcGraphRef = toGraphRef(source); - const destGraphRef = toGraphRef(dest); - + add(source: GraphOrDefaultInput, dest: GraphOrDefaultInput, silent = false): UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'ADD', source: srcGraphRef, dest: destGraphRef, silent } + { type: 'ADD', source: toGraphOrDefault(source), dest: toGraphOrDefault(dest), silent } ] }) } /** * Build the SPARQL Update request. - * + * * Converts all operations into a SPARQL Update string. Multiple operations * are separated by semicolons. - * + * * @returns SPARQL Update string wrapped in SparqlValue - * + * * @example * ```ts * const updateStr = update() * .insertData(triple('ex:person1', 'foaf:name', str('Alice'))) * .build() - * + * * console.log(updateStr.value) * // INSERT DATA { ex:person1 foaf:name "Alice" . } * ``` */ - build(): SparqlValue { + build(): SparqlUpdate { const operations: string[] = [] for (const op of this.state.operations) { @@ -657,18 +606,18 @@ export class UpdateBuilder { case 'LOAD': { const into = op.graph ? ` INTO GRAPH ${op.graph}` : '' - operations.push(`LOAD ${silent}${op.data!.value}${into}`) + operations.push(`LOAD ${silent}${op.source!}${into}`) break } case 'CLEAR': { - const target = op.graph === 'DEFAULT' ? 'DEFAULT' : `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' ? 'DEFAULT' : `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 } @@ -679,60 +628,31 @@ export class UpdateBuilder { } case 'COPY': { - const sourceRef = op.source === 'DEFAULT' ? 'DEFAULT' : `<${op.source}` - const destRef = op.dest === 'DEFAULT' ? 'DEFAULT' : `<${op.dest}` + const sourceRef = op.source! + const destRef = op.dest! operations.push(`COPY ${silent}${sourceRef} TO ${destRef}`) break } case 'MOVE': { - const sourceRef = op.source === 'DEFAULT' ? 'DEFAULT' : `${op.source}` - const destRef = op.dest === 'DEFAULT' ? 'DEFAULT' : `${op.dest}` + const sourceRef = op.source! + const destRef = op.dest! operations.push(`MOVE ${silent}${sourceRef} TO ${destRef}`) break } case 'ADD': { - const sourceRef = op.source === 'DEFAULT' ? 'DEFAULT' : `${op.source}` - const destRef = op.dest === 'DEFAULT' ? 'DEFAULT' : `${op.dest}` + const sourceRef = op.source! + const destRef = op.dest! operations.push(`ADD ${silent}${sourceRef} TO ${destRef}`) break } } } - return sparql`${operations.join(';\n')}` + return updateDocument(operations.join(';\n')) } - /** - * Execute the update against an endpoint. - * - * Builds the update and sends it to the endpoint's update endpoint. - * Returns a result object indicating success or failure. - * - * @param config Endpoint configuration - * @returns Promise of update result - * - * @example - * ```ts - * const result = await update() - * .insertData(triple('ex:person1', 'foaf:name', str('Alice'))) - * .execute({ - * endpoint: 'http://localhost:9999/sparql', - * updateEndpoint: 'http://localhost:9999/update' - * }) - * - * if (result.success) { - * console.log('Update succeeded') - * } else { - * console.error(result.error.message) - * } - * ``` - */ - execute(config: ExecutionConfig): Promise> { - const executor = createExecutor(config) - return executor.execute(this.build()) - } } // ============================================================================ @@ -741,27 +661,38 @@ export class UpdateBuilder { /** * Builder for DELETE/INSERT operations. - * + * * Created by calling modify() on an UpdateBuilder. Lets you specify delete * templates, insert templates, and where patterns. Call done() when finished * 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[] + + /** Creates one DELETE/INSERT/WHERE sub-builder tied to the immutable parent update sequence. */ constructor( - private readonly updateState: UpdateState, - private readonly deleteTemplate?: SparqlValue, - private readonly insertTemplate?: SparqlValue, - private readonly wherePatterns: SparqlValue[] = [] - ) {} + updateState: UpdateState, + deleteTemplate?: PatternValue, + insertTemplate?: PatternValue, + wherePatterns: PatternValue[] = [], + ) { + this.updateState = updateState + this.deleteTemplate = deleteTemplate + this.insertTemplate = insertTemplate + this.wherePatterns = wherePatterns + } /** * Add DELETE template. - * + * * Specifies which triples to delete. Variables in the template are bound * by the WHERE clause, then those matched triples are deleted. - * + * * @param template Pattern of triples to delete - * + * * @example * ```ts * modify() @@ -770,7 +701,7 @@ class ModifyBuilder { * .done() * ``` */ - delete(template: SparqlValue): ModifyBuilder { + delete(template: PatternValue): ModifyBuilder { return new ModifyBuilder( this.updateState, template, @@ -781,12 +712,12 @@ class ModifyBuilder { /** * Add INSERT template. - * + * * Specifies which triples to insert. Variables in the template are bound * by the WHERE clause, then those new triples are inserted. - * + * * @param template Pattern of triples to insert - * + * * @example * ```ts * modify() @@ -796,7 +727,7 @@ class ModifyBuilder { * .done() * ``` */ - insert(template: SparqlValue): ModifyBuilder { + insert(template: PatternValue): ModifyBuilder { return new ModifyBuilder( this.updateState, this.deleteTemplate, @@ -807,12 +738,12 @@ class ModifyBuilder { /** * Add WHERE pattern. - * + * * Patterns that bind variables used in DELETE and INSERT templates. * Multiple where() calls are ANDed together. - * + * * @param pattern Pattern to match - * + * * @example * ```ts * modify() @@ -824,7 +755,7 @@ class ModifyBuilder { * .done() * ``` */ - where(pattern: SparqlValue): ModifyBuilder { + where(pattern: PatternValue): ModifyBuilder { return new ModifyBuilder( this.updateState, this.deleteTemplate, @@ -835,12 +766,12 @@ class ModifyBuilder { /** * Finalize and return to UpdateBuilder. - * + * * Completes the DELETE/INSERT operation and returns to the main UpdateBuilder - * so you can add more operations or execute. - * + * so you can add more operations or build the final update. + * * @returns UpdateBuilder with this operation added - * + * * @example * ```ts * update() @@ -850,12 +781,12 @@ class ModifyBuilder { * .where(triple('?person', 'foaf:age', '?oldAge')) * .where(bind(add(v('oldAge'), 1), 'newAge')) * .done() // Returns to UpdateBuilder - * .execute(config) + * .build() * ``` */ done(): UpdateBuilder { const whereValue = this.wherePatterns.length > 0 - ? raw(this.wherePatterns.map(p => p.value).join('\n ')) + ? rawPattern(this.wherePatterns.map(p => p.value).join('\n ')) : undefined return new UpdateBuilder({ @@ -863,9 +794,9 @@ class ModifyBuilder { ...this.updateState.operations, { type: 'DELETE_INSERT', - deleteTemplate: this.deleteTemplate, - insertTemplate: this.insertTemplate, - where: whereValue + ...(this.deleteTemplate ? { deleteTemplate: this.deleteTemplate } : {}), + ...(this.insertTemplate ? { insertTemplate: this.insertTemplate } : {}), + ...(whereValue ? { where: whereValue } : {}), } ] }) @@ -878,67 +809,65 @@ class ModifyBuilder { /** * Start building an update operation. - * + * * Creates an empty UpdateBuilder you can add operations to. This is the * general entry point when you want to combine multiple operations. - * + * * @example * ```ts * update() * .insertData(triple('ex:person1', 'foaf:name', str('Alice'))) * .insertData(triple('ex:person2', 'foaf:name', str('Bob'))) - * .execute(config) + * .build() * ``` */ export const update = UpdateBuilder.create /** * Start with INSERT DATA operation. - * + * * Convenience function for inserting triples. Equivalent to * update().insertData(...). - * + * * @param data Triples to insert * @param graph Optional named graph - * + * * @example * ```ts * insert(triples('ex:person1', [ * ['rdf:type', 'foaf:Person'], * ['foaf:name', str('Alice')] - * ])) - * .execute(config) + * ])).build() * ``` */ -export function insert(data: SparqlValue, graph?: string): UpdateBuilder { +export function insert(data: PatternValue, graph?: IriInput): UpdateBuilder { return UpdateBuilder.create().insertData(data, graph) } /** * Start with DELETE DATA operation. - * + * * Convenience function for deleting triples. Equivalent to * update().deleteData(...). - * + * * @param data Triples to delete * @param graph Optional named graph - * + * * @example * ```ts - * deleteOp(triple('ex:person1', 'foaf:age', num(30))) - * .execute(config) + * deleteOp(triple('ex:person1', 'foaf:age', num(30))).build() * ``` */ -export function deleteOp(data: SparqlValue, graph?: string): UpdateBuilder { +export function deleteOp(data: PatternValue, graph?: IriInput): UpdateBuilder { return UpdateBuilder.create().deleteData(data, graph) } /** * Start with DELETE/INSERT operation. - * + * * Convenience function for conditional updates. Equivalent to * update().modify(). - * + * * @example * ```ts * modify() @@ -947,9 +876,9 @@ export function deleteOp(data: SparqlValue, graph?: string): UpdateBuilder { * .where(triple('?person', 'foaf:age', '?oldAge')) * .where(bind(add(v('oldAge'), 1), 'newAge')) * .done() - * .execute(config) + * .build() * ``` */ export function modify(): ModifyBuilder { return UpdateBuilder.create().modify() -} \ No newline at end of file +} diff --git a/packages/sparql/update_test.ts b/packages/sparql/update_test.ts new file mode 100644 index 0000000..aa17900 --- /dev/null +++ b/packages/sparql/update_test.ts @@ -0,0 +1,65 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode } from '@okikio/rdf' +import { SPARQL_UPDATE_BRAND, strlit, triple, update, variable } from './mod.ts' + +describe('@okikio/sparql update builder', () => { + const statement = triple(namedNode('urn:s'), namedNode('urn:p'), strlit('value')) + + it('builds a distinct complete update document', () => { + const document = update().insertData(statement).build() + expect(document[SPARQL_UPDATE_BRAND]).toBe(true) + expect(document.value).toBe('INSERT DATA { "value" . }') + }) + + it('rejects variables where INSERT DATA requires a graph IRI', () => { + expect(() => update().insertData(statement, '?graph')).toThrow() + }) + + it('serializes CLEAR and DROP graph keywords without a GRAPH prefix', () => { + expect(update().clear('DEFAULT').build().value).toBe('CLEAR DEFAULT') + expect(update().drop('NAMED').build().value).toBe('DROP NAMED') + expect(update().drop('ALL', true).build().value).toBe('DROP SILENT ALL') + }) + + it('serializes COPY, MOVE, and ADD with DEFAULT and named graph operands', () => { + const source = namedNode('urn:graph:source') + const target = namedNode('urn:graph:target') + const document = update() + .copy(source, 'DEFAULT') + .move('DEFAULT', target) + .add(source, target, true) + .build() + + expect(document.value).toBe([ + 'COPY TO DEFAULT', + 'MOVE DEFAULT TO ', + 'ADD SILENT TO ', + ].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([ + 'CLEAR GRAPH ', + 'DROP SILENT GRAPH ', + ].join(';\n')) + expect(() => update().clear(variable('graph'))).toThrow() + expect(() => update().create(variable('graph'))).toThrow() + }) + + it('keeps DELETE/INSERT templates separate from WHERE', () => { + const document = update() + .modify() + .delete(triple('?s', 'schema:old', '?old')) + .insert(triple('?s', 'schema:new', '?new')) + .where(triple('?s', 'schema:old', '?old')) + .done() + .build() + + expect(document.value.includes('DELETE { ?s schema:old ?old . }')).toBe(true) + expect(document.value.includes('INSERT { ?s schema:new ?new . }')).toBe(true) + expect(document.value.includes('WHERE { ?s schema:old ?old . }')).toBe(true) + }) +}) diff --git a/utils.ts b/packages/sparql/utils.ts similarity index 92% rename from utils.ts rename to packages/sparql/utils.ts index fe438da..e96c2d5 100644 --- a/utils.ts +++ b/packages/sparql/utils.ts @@ -1,37 +1,39 @@ /** * SPARQL expression helpers and query utilities. - * + * * These helpers build SPARQL expressions programmatically with proper escaping * for data values and validation for syntax elements. - * + * * ## Key Distinction - * + * * **Syntax elements** (passed through raw after validation): * - Variables created with `v()` or `variable()` * - Prefixed names like `foaf:name` * - IRIs - * + * * **Data values** (escaped and type-annotated): * - String literals passed to comparisons: `eq(v('name'), 'Alice')` * - Numbers: `gte(v('age'), 18)` * - Values in `concat()`, `contains()`, etc. - * + * * @module */ +import { isTerm as isRdfTerm, type NamedNode as RdfNamedNode, type Term as RdfTerm } from '@okikio/rdf' + import { convertValue, isSparqlValue, normalizeVariableName, raw, + rawPattern, + rawTerm, strlit, validateVariableName, variable, - toPredicateName, - toRawString, + toPredicateToken, toVarOrIriRef, toVarToken, - PrefixName, validatePrefixName, validateIRI, isIRIRefToken, @@ -39,11 +41,15 @@ import { 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, } from './sparql.ts' // ============================================================================ @@ -52,17 +58,17 @@ import { /** * Create a VALUES clause for filtering by a list of values. - * + * * VALUES clauses let you provide a set of possible values for a variable. * Think of it like an IN clause in SQL. The query engine will try each value * and return results that match any of them. - * + * * @example Simple list * ```ts * values('city', ['London', 'Paris', 'Tokyo']) * // VALUES ?city { "London" "Paris" "Tokyo" } * ``` - * + * * @example With numbers * ```ts * values('age', [18, 21, 25]) @@ -72,25 +78,25 @@ import { export function values( varName: VariableName, items: SparqlInterpolatable[] -): SparqlValue { +): PatternValue { const _var = toVarToken(varName) const converted = items.map((item) => convertValue(item)).join(' ') - return raw(`VALUES ${_var} { ${converted} }`) + return rawPattern(`VALUES ${_var} { ${converted} }`) } /** * Wrap an expression in a FILTER clause. - * + * * Filters restrict results based on boolean conditions. The expression you pass * should evaluate to true or false. Use this with comparison operators, regex * checks, or any other boolean expression. - * + * * @example Age filter * ```ts * filter(gte(v('age'), 18)) * // FILTER(?age >= 18) * ``` - * + * * @example Multiple conditions * ```ts * filter(and( @@ -100,23 +106,23 @@ export function values( * // FILTER(?age >= 18 && REGEX(?name, "^Spider")) * ``` */ -export function filter(expression: SparqlValue): SparqlValue { - return raw(`FILTER(${expression.value})`) +export function filter(expression: SparqlExpr): PatternValue { + return rawPattern(`FILTER(${expression.value})`) } /** * Wrap a pattern in an OPTIONAL block. - * + * * Optional patterns don't fail the whole query if they don't match - they just * leave variables unbound. This is like a LEFT JOIN in SQL. Use it for properties * that might not exist on all results. - * + * * @example Email might not exist * ```ts * optional(triple('?person', 'foaf:email', '?email')) * // OPTIONAL { ?person foaf:email ?email } * ``` - * + * * @example Multiple optional triples * ```ts * optional(triples('?person', [ @@ -125,64 +131,62 @@ export function filter(expression: SparqlValue): SparqlValue { * ])) * ``` */ -export function optional(pattern: SparqlValue): SparqlValue { - return raw(`OPTIONAL { ${pattern.value} }`) +export function optional(pattern: PatternValue): PatternValue { + return rawPattern(`OPTIONAL { ${pattern.value} }`) } /** * Create a BIND expression to compute new variables. - * + * * BIND lets you create new variables from expressions. Think of it like a computed * column - you're deriving a new value from existing data. The variable will be * available in the rest of the query. - * + * * @example Full name from parts * ```ts * bind(concat(v('firstName'), ' ', v('lastName')), 'fullName') * // BIND(CONCAT(?firstName, " ", ?lastName) AS ?fullName) * ``` - * + * * @example Age calculation * ```ts * bind(sub(2024, v('birthYear')), 'age') * // BIND(2024 - ?birthYear AS ?age) * ``` */ -export function bind(expression: SparqlValue, varName?: VariableName): SparqlValue { - if (!varName) return raw(`BIND(${expression.value})`); - +export function bind(expression: SparqlExpr | SparqlTerm, varName: VariableName): PatternValue { const normalized = toVarToken(varName) - return raw(`BIND(${expression.value} AS ${normalized})`) + return rawPattern(`BIND(${expression.value} AS ${normalized})`) } /** * Check if a pattern exists in the data. - * + * * EXISTS tests whether a graph pattern has any matches. The pattern you pass * is evaluated but doesn't affect variable bindings in the main query. - * + * * @example Has any email * ```ts * exists(triple('?person', 'foaf:email', '?anyEmail')) * // EXISTS { ?person foaf:email ?anyEmail } * ``` */ -export function exists(pattern: SparqlValue): SparqlValue { +export function exists(pattern: PatternValue): SparqlExpr { return raw(`EXISTS { ${pattern.value} }`) } /** * Check if a pattern does not exist in the data. - * + * * Opposite of EXISTS - returns true if the pattern has no matches. - * + * * @example No email address * ```ts * notExists(triple('?person', 'foaf:email', '?email')) * // NOT EXISTS { ?person foaf:email ?email } * ``` */ -export function notExists(pattern: SparqlValue): SparqlValue { +export function notExists(pattern: PatternValue): SparqlExpr { return raw(`NOT EXISTS { ${pattern.value} }`) } @@ -193,7 +197,7 @@ export function notExists(pattern: SparqlValue): SparqlValue { /** * 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. */ @@ -204,13 +208,14 @@ export type ExpressionPrimitive = | Date | null | undefined + | RdfTerm /** * Convert a value to SPARQL for use in expressions. - * + * * - SparqlValue 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. */ @@ -258,7 +263,7 @@ export type TermPosition = 'subject' | 'object' | 'graph' * - blank node label (_:b1, _:foo-123, etc.) * - blank node property list ([] or [ ... ]) * - literal ("...", 42, true, "..."@en, "..."^^<...>) - * - RDF* quoted triple (<< ... >>) + * - SPARQL 1.2 triple-term or reified-triple syntax * * Anything that looks like a function call or complex expression * (STR(...), CONCAT(...), BNODE(), etc.) is rejected. @@ -309,7 +314,7 @@ export function isGraphNodeLexical(lex: string): boolean { if (/^\[\s*[\s\S]*\s*\]$/.test(t)) return true // --------------------------------------------------------------------------- - // RDF* quoted triple: << ... >> + // SPARQL 1.2 triple-term / reified-triple syntax. // --------------------------------------------------------------------------- if (/^<<[\s\S]*>>$/.test(t)) return true @@ -366,7 +371,7 @@ export function termString( if (!isGraphNodeLexical(lex)) { throw new Error( `Invalid ${position} term "${lex}". Triple ${position}s must be variables, ` + - `IRIs, blank node labels, literals, prefixed names, or RDF* quoted triples. ` + + `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.`, ) @@ -381,11 +386,11 @@ export function termString( /** * Concatenate strings or values. - * + * * CONCAT joins multiple values into a single string. All arguments are converted * to strings first. This is your go-to for building full names, labels, or any * composite string field. - * + * * @example Full name * ```ts * concat(v('firstName'), ' ', v('lastName')) @@ -405,7 +410,7 @@ export function concat( /** * Convert a value to a string. - * + * * Forces conversion to string representation. Useful when you need to ensure * a value is treated as a string for comparison or manipulation. */ @@ -415,7 +420,7 @@ export function str(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Get the length of a string. - * + * * Returns the character count. Note that this counts Unicode characters, not bytes. */ export function strlen( @@ -440,10 +445,10 @@ export function lcase(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Check if a string contains a substring. - * + * * Case-sensitive substring search. Returns true if pattern appears anywhere * in the text. - * + * * @example * ```ts * contains(v('title'), 'Spider') @@ -461,7 +466,7 @@ export function contains( /** * Check if string starts with a prefix. - * + * * Case-sensitive prefix check. */ export function startsWith( @@ -483,7 +488,7 @@ export function strstarts( /** * Check if string ends with a suffix. - * + * * Case-sensitive suffix check. */ export function endsWith( @@ -505,16 +510,16 @@ export function strends( /** * Pattern matching with regular expressions. - * + * * Supports standard regex patterns. The flags parameter lets you control * matching behavior (i for case-insensitive, m for multiline, etc.). - * + * * @example Case-insensitive match * ```ts * regex(v('name'), '^Spider', 'i') * // REGEX(?name, "^Spider", "i") * ``` - * + * * @example Match email pattern * ```ts * regex(v('email'), '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$', 'i') @@ -527,27 +532,27 @@ export function regex( ): SparqlExpr { const textStr = exprTermString(text) const patternStr = exprTermString(pattern) - + if (flags) { const flagsStr = exprTermString(flags) return raw(`REGEX(${textStr}, ${patternStr}, ${flagsStr})`) } - + return raw(`REGEX(${textStr}, ${patternStr})`) } /** * Extract substring from a string. - * + * * Starting position is 1-indexed (SPARQL convention). If length is omitted, * extracts to the end of the string. - * + * * @example First 5 characters * ```ts * substr(v('title'), 1, 5) * // SUBSTR(?title, 1, 5) * ``` - * + * * @example Everything after position 10 * ```ts * substr(v('description'), 10) @@ -561,27 +566,27 @@ export function substr( ): FluentExpr { const textStr = exprTermString(text) const startStr = exprTermString(start) - + if (length !== undefined) { const lengthStr = exprTermString(length) return fluent(raw(`SUBSTR(${textStr}, ${startStr}, ${lengthStr})`)) } - + return fluent(raw(`SUBSTR(${textStr}, ${startStr})`)) } /** * Replace occurrences of a pattern in text. - * + * * Replaces all occurrences of pattern with replacement string. * Optional flags parameter for case-insensitive matching (i), etc. - * + * * @example Remove dashes * ```ts * replaceStr(v('isbn'), '-', '') * // REPLACE(?isbn, "-", "") * ``` - * + * * @example Case-insensitive replacement * ```ts * replaceStr(v('text'), 'hello', 'hi', 'i') @@ -597,27 +602,27 @@ export function replaceStr( const textStr = exprTermString(text) const patternStr = exprTermString(pattern) const replacementStr = exprTermString(replacement) - + if (flags) { const flagsStr = exprTermString(flags) return fluent(raw(`REPLACE(${textStr}, ${patternStr}, ${replacementStr}, ${flagsStr})`)) } - + return fluent(raw(`REPLACE(${textStr}, ${patternStr}, ${replacementStr})`)) } /** * Get substring before first occurrence of match string. - * + * * Returns the part of the text that appears before the first occurrence * of the match string. If match is not found, returns empty string. - * + * * @example Extract username from email * ```ts * strBefore(v('email'), '@') * // STRBEFORE(?email, "@") * ``` - * + * * @example Extract domain before subdomain * ```ts * strBefore(v('domain'), '.') @@ -635,16 +640,16 @@ export function strBefore( /** * Get substring after first occurrence of match string. - * + * * Returns the part of the text that appears after the first occurrence * of the match string. If match is not found, returns empty string. - * + * * @example Extract domain from email * ```ts * strAfter(v('email'), '@') * // STRAFTER(?email, "@") * ``` - * + * * @example Extract file extension * ```ts * strAfter(v('filename'), '.') @@ -662,10 +667,10 @@ export function strAfter( /** * Conditional expression (ternary operator). - * + * * Like JavaScript's `condition ? whenTrue : whenFalse`. Evaluates the condition * and returns one of two values based on the result. - * + * * @example Adult vs minor * ```ts * ifElse(gte(v('age'), 18), strlit('Adult'), strlit('Minor')) @@ -814,10 +819,10 @@ export function lte( /** * Check if a variable is unbound (null). - * + * * In SPARQL, variables can be unbound if an OPTIONAL pattern didn't match. * This lets you check for that condition. - * + * * @example * ```ts * filter(isNull(v('email'))) @@ -832,7 +837,7 @@ export function isNull( /** * Check if a variable is bound (not null). - * + * * Opposite of isNull - checks if a variable has a value. */ export function isNotNull( @@ -875,10 +880,10 @@ export function isLiteral( /** * Combine conditions with AND. - * + * * All conditions must be true for the result to be true. Short-circuits on * the first false condition. - * + * * @example Multiple filters * ```ts * and( @@ -902,10 +907,10 @@ export function and( /** * Combine conditions with OR. - * + * * Any condition being true makes the result true. Short-circuits on the * first true condition. - * + * * @example Alternative publishers * ```ts * or( @@ -928,7 +933,7 @@ export function or( /** * Negate a condition. - * + * * Flips true to false and false to true. */ export function not(condition: SparqlValue): SparqlExpr { @@ -941,9 +946,9 @@ export function not(condition: SparqlValue): SparqlExpr { /** * Check if a value is in a list. - * + * * Like SQL's IN operator. Checks if the expression matches any value in the list. - * + * * @example Check publisher * ```ts * inList(v('publisher'), ['Marvel', 'DC Comics', 'Image']) @@ -963,7 +968,7 @@ export function inList( /** * Check if a value is not in a list. - * + * * Opposite of inList - returns true if the value doesn't match any list item. */ export function notInList( @@ -979,9 +984,9 @@ export function notInList( /** * Check if a value is in a range. - * + * * Shorthand for value >= low AND value <= high. Both bounds are inclusive. - * + * * @example Age range * ```ts * between(v('age'), 18, 65) @@ -1001,10 +1006,10 @@ export function between( /** * Return first non-null value from a list. - * + * * Like SQL's COALESCE. Evaluates arguments left-to-right and returns the first * one that's bound. Useful for providing fallback values. - * + * * @example Fallback label * ```ts * coalesce(v('preferredLabel'), v('commonLabel'), strlit('Unnamed')) @@ -1037,26 +1042,26 @@ export function bnodeFn(): SparqlExpr { /** * Fluent interface for SPARQL values with chainable methods. - * + * * Instead of wrapping values in functions, you can call methods directly on values. * This makes complex expressions more readable and natural. - * + * * @example Comparison operators * ```ts * v('age').gte(18) // instead of gte(v('age'), 18) * v('name').eq('Alice') // instead of eq(v('name'), 'Alice') * ``` - * + * * @example Arithmetic * ```ts * v('price').mul(1.1).add(5) // instead of add(mul(v('price'), 1.1), 5) * ``` - * + * * @example String operations * ```ts * v('name').ucase().contains('SPIDER') // instead of contains(ucase(v('name')), 'SPIDER') * ``` - * + * * @example Combining styles * ```ts * // Both functional and method styles work together @@ -1121,19 +1126,19 @@ export interface FluentExpr extends SparqlExpr { /** * Create a fluent value with chainable methods. - * + * * Wraps any SparqlValue to add method chaining. This lets you write expressions * more naturally with dot notation instead of nested function calls. - * + * * @param value SparqlValue to enhance * @returns FluentValue with chainable methods - * + * * @example * ```ts * const age = fluent(v('age')) * age.gte(18).and(age.lt(65)) * ``` - * + * * @example Direct with variables * ```ts * fluent(v('price')).mul(1.1).add(5) @@ -1210,26 +1215,26 @@ export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { /** * Create a fluent variable reference. - * + * * Variables are placeholders for values that get bound during query execution. * This enhanced version returns a FluentValue with chainable methods for * natural, readable query construction. - * + * * @param name Variable name (with or without ? prefix) * @returns FluentValue with comparison, arithmetic, and other methods - * + * * @example Chainable comparisons * ```ts * v('age').gte(18) * // Instead of: gte(v('age'), 18) * ``` - * + * * @example Arithmetic chains * ```ts * v('price').mul(1.1).add(5) * // Instead of: add(mul(v('price'), 1.1), 5) * ``` - * + * * @example Complex expressions * ```ts * select(['?name', '?total']) @@ -1237,7 +1242,7 @@ export function fluent(value: SparqlTerm | SparqlExpr): FluentExpr { * .where(triple('?person', 'schema:price', '?price')) * .bind(v('price').mul(1.2).round(), 'total') * ``` - * + * * @example Combining with logical operators * ```ts * filter( @@ -1269,7 +1274,7 @@ export function datatype( /** * Aggregation expression that can be aliased with AS. - * + * * Aggregations reduce a group of values to a single result. They're typically * used with GROUP BY clauses. The `.as()` method lets you assign the result * to a variable. @@ -1289,6 +1294,7 @@ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPr [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 { const varName = toVarToken(variable) // SPARQL 1.1 requires (Expression AS ?var) in SELECT @@ -1301,16 +1307,16 @@ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPr /** * Count the number of rows. - * + * * Without arguments, counts all rows (COUNT(*)). With an expression, counts * non-null values of that expression. - * + * * @example Count all * ```ts * select([count().as('total')]) * // SELECT COUNT(*) AS ?total * ``` - * + * * @example Count specific values * ```ts * select([count(v('email')).as('emailCount')]) @@ -1325,9 +1331,9 @@ export function count( /** * Count distinct values. - * + * * Like COUNT but only counts unique values. - * + * * @example Unique publishers * ```ts * select([countDistinct(v('publisher')).as('publisherCount')]) @@ -1344,9 +1350,9 @@ export function countDistinct( /** * Sum numeric values. - * + * * Adds up all values in the group. - * + * * @example Total price * ```ts * select([sum(v('price')).as('totalPrice')]) @@ -1382,7 +1388,7 @@ export function max( /** * Return an arbitrary value from the group. - * + * * When you just need one value from each group but don't care which one. * Useful for properties that should be the same across a group. */ @@ -1394,10 +1400,10 @@ export function sample( /** * Concatenate values into a single string. - * + * * Joins multiple values with an optional separator. Useful for creating * comma-separated lists or similar aggregations. - * + * * @example Author list * ```ts * select([groupConcat(v('author'), ', ').as('authors')]) @@ -1420,22 +1426,6 @@ export function groupConcat( // GRAPH Patterns // ============================================================================ -export function toGraphRefName(name: string): string { - // Variable: ?g - if (name.startsWith('?')) return name - - // Already an IRI ref: - if (name.startsWith('<') && name.endsWith('>')) return name - - // Full IRI with scheme: http://, https://, etc. - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(name)) { - return `<${name}>` - } - - // Otherwise treat as prefixed name or bare identifier - // (assumes appropriate PREFIX is declared) - return name -} // ============================================================================ // GRAPH Patterns @@ -1486,11 +1476,11 @@ export function toGraphRefName(name: string): string { * ``` */ export function graph( - graphIri: string | SparqlValue, - pattern: SparqlValue, -): SparqlValue { + graphIri: IriInput, + pattern: PatternValue, +): PatternValue { const graphRef = toVarOrIriRef(graphIri) - return raw(`GRAPH ${graphRef} { ${pattern.value} }`) + return rawPattern(`GRAPH ${graphRef} { ${pattern.value} }`) } // ============================================================================ @@ -1498,36 +1488,13 @@ export function graph( // ============================================================================ /** - * Unbound variable placeholder. - * - * Used in IF expressions to leave variables unbound. When ?UNDEF is used - * as a binding result, it doesn't bind the variable at all - the variable - * stays unbound. - * - * @example Conditional binding - * ```ts - * bind( - * ifElse(eq(v('x'), 1), v('x'), undef()), - * 'result' - * ) - * // BIND(IF(?x = 1, ?x, ?UNDEF) AS ?result) - * // If ?x = 1, ?result gets bound to ?x's value - * // If ?x != 1, ?result stays unbound - * ``` - * - * @example Inferring functional properties - * ```ts - * select([ - * v('property'), - * ifElse(eq(v('maxCardinality'), 1), v('maxCardinality'), undef()).as('isFunctional') - * ]) - * .where(...) - * .groupBy('?property') - * // ?isFunctional only gets bound for properties with max cardinality 1 - * ``` + * Returns the SPARQL `UNDEF` data-block token. + * + * `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(): SparqlValue { - return raw('?UNDEF') +export function undef(): SparqlTerm { + return rawTerm('UNDEF') } // ============================================================================ @@ -1536,66 +1503,66 @@ export function undef(): SparqlValue { /** * Zero or more path (transitive closure). - * + * * Matches the property zero or more times. Like * in regular expressions. * Use this to traverse relationship chains of any length, including zero * (which means subject and object can be the same). - * + * * @param property Property IRI - * + * * @example Find all connected people * ```ts * triple('?person', zeroOrMore('foaf:knows'), '?contact') * // ?person foaf:knows* ?contact * // Matches: direct friends, friends of friends, etc. * ``` - * + * * @example Organizational hierarchy * ```ts * triple('?ceo', zeroOrMore('org:manages'), '?employee') * // Finds everyone in the org (including CEO themselves due to zero matches) * ``` */ -export function zeroOrMore(property: string | SparqlValue): SparqlValue { - const prop = toPredicateName(toRawString(property)) - return raw(`${prop}*`) +export function zeroOrMore(property: PredicateInput): SparqlTerm { + const prop = toPredicateToken(property) + return rawTerm(`${prop}*`) } /** * 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). - * + * * @param property Property IRI - * + * * @example Find direct and indirect reports * ```ts * triple('?manager', oneOrMore('org:manages'), '?employee') * // ?manager org:manages+ ?employee * // Matches all reports at any level, but not the manager themselves * ``` - * + * * @example Ancestor relationships * ```ts * triple('?ancestor', oneOrMore('bio:parent'), '?descendant') * // Finds parents, grandparents, great-grandparents, etc. * ``` */ -export function oneOrMore(property: string | SparqlValue): SparqlValue { - const prop = toPredicateName(toRawString(property)) - return raw(`${prop}+`) +export function oneOrMore(property: PredicateInput): SparqlTerm { + const prop = toPredicateToken(property) + return rawTerm(`${prop}+`) } /** * Zero or one path (optional property). - * + * * Matches the property zero or one time. Like ? in regular expressions. * Use for optional properties where you want both entities with and without * the property. - * + * * @param property Property IRI - * + * * @example Person with optional spouse * ```ts * triple('?person', zeroOrOne('schema:spouse'), '?maybeSpouse') @@ -1603,112 +1570,112 @@ export function oneOrMore(property: string | SparqlValue): SparqlValue { * // Matches married and unmarried people * ``` */ -export function zeroOrOne(property: string | SparqlValue): SparqlValue { - const prop = toPredicateName(toRawString(property)) - return raw(`${prop}?`) +export function zeroOrOne(property: PredicateInput): SparqlTerm { + const prop = toPredicateToken(property) + return rawTerm(`${prop}?`) } /** * Sequence path. - * + * * Matches properties in sequence (path1 followed by path2). Use to navigate * multi-hop relationships as if they were single properties. - * + * * @param properties Properties to traverse in order - * + * * @example Person's city through address * ```ts * triple('?person', sequence('schema:address', 'schema:city'), '?city') * // ?person schema:address/schema:city ?city * // Equivalent to: ?person schema:address ?addr . ?addr schema:city ?city * ``` - * + * * @example Complex navigation * ```ts * triple('?product', sequence('schema:manufacturer', 'schema:location', 'schema:city'), '?city') * // Navigate: product → manufacturer → location → city * ``` */ -export function sequence(...properties: Array): SparqlValue { - const props = properties.map(p => toPredicateName(toRawString(p))) - return raw(props.join('/')) +export function sequence(...properties: PredicateInput[]): SparqlTerm { + const props = properties.map(toPredicateToken) + return rawTerm(props.join('/')) } /** * Alternative path. - * + * * Matches either path1 or path2. Use when multiple properties lead to the * same kind of information. - * + * * @param properties Properties to try (any match) - * + * * @example Contact info * ```ts * triple('?person', alternative('foaf:phone', 'foaf:email'), '?contact') * // ?person foaf:phone|foaf:email ?contact * // Matches either phone numbers or email addresses * ``` - * + * * @example Multiple name properties * ```ts * triple('?entity', alternative('rdfs:label', 'foaf:name', 'schema:name'), '?name') * // Gets name from any of these properties * ``` */ -export function alternative(...properties: Array): SparqlValue { - const props = properties.map(p => toPredicateName(toRawString(p))) - return raw(`(${props.join('|')})`) +export function alternative(...properties: PredicateInput[]): SparqlTerm { + const props = properties.map(toPredicateToken) + return rawTerm(`(${props.join('|')})`) } /** * Inverse path. - * + * * Traverses the property in reverse direction. Swaps subject and object positions. - * + * * @param property Property IRI - * + * * @example Find who manages this person * ```ts * triple('?employee', inverse('org:manages'), '?manager') * // ?employee ^org:manages ?manager * // Equivalent to: ?manager org:manages ?employee * ``` - * + * * @example Find authors of book * ```ts * triple('?book', inverse('schema:author'), '?author') * // Reverse of: ?author schema:author ?book * ``` */ -export function inverse(property: string | SparqlValue): SparqlValue { - const prop = toPredicateName(toRawString(property)) - return raw(`^${prop}`) +export function inverse(property: PredicateInput): SparqlTerm { + const prop = toPredicateToken(property) + return rawTerm(`^${prop}`) } /** * Negated property set. - * + * * Matches any property except those listed. Use to exclude specific * relationships when you want "everything else". - * + * * @param properties Properties to exclude - * + * * @example Any property except rdf:type * ```ts * triple('?s', negatedPropertySet('rdf:type'), '?o') * // ?s !(rdf:type) ?o * // Matches all triples except type declarations * ``` - * + * * @example Non-metadata properties * ```ts * triple('?s', negatedPropertySet('rdf:type', 'rdfs:label', 'rdfs:comment'), '?o') * // Gets data properties, not metadata * ``` */ -export function negatedPropertySet(...properties: Array): SparqlValue { - const props = properties.map(p => toPredicateName(toRawString(p))) - return raw(`!(${props.join('|')})`) +export function negatedPropertySet(...properties: PredicateInput[]): SparqlTerm { + const props = properties.map(toPredicateToken) + return rawTerm(`!(${props.join('|')})`) } // ============================================================================ @@ -1717,15 +1684,15 @@ export function negatedPropertySet(...properties: Array): /** * Query a remote SPARQL endpoint (federation). - * + * * SERVICE lets you include data from other SPARQL endpoints in your query. * The pattern is sent to the remote endpoint and results are integrated with * your local query. This is powerful for combining data from multiple sources. - * + * * @param endpoint Remote SPARQL endpoint URL * @param pattern Pattern to execute remotely * @param silent If true, continue if service unavailable (default: false) - * + * * @example Query DBpedia for birth places * ```ts * select(['?person', '?name', '?birthPlace']) @@ -1736,7 +1703,7 @@ export function negatedPropertySet(...properties: Array): * )) * // Combines local names with DBpedia birth places * ``` - * + * * @example Silent service (don't fail) * ```ts * service( @@ -1746,7 +1713,7 @@ export function negatedPropertySet(...properties: Array): * ) * // SERVICE SILENT - continues even if endpoint is down * ``` - * + * * @example Complex federated query * ```ts * select(['?company', '?revenue', '?stockPrice']) @@ -1759,13 +1726,13 @@ export function negatedPropertySet(...properties: Array): * ``` */ export function service( - endpoint: string | SparqlValue, - pattern: SparqlValue, + endpoint: IriInput, + pattern: PatternValue, silent = false -): SparqlValue { +): PatternValue { const endpointRef = toVarOrIriRef(endpoint) const silentModifier = silent ? 'SILENT ' : '' - return raw(`SERVICE ${silentModifier}${endpointRef} { ${pattern.value} }`) + return rawPattern(`SERVICE ${silentModifier}${endpointRef} { ${pattern.value} }`) } // ============================================================================ @@ -1774,13 +1741,13 @@ export function service( /** * Define a PREFIX for abbreviated IRIs. - * + * * Prefixes let you write short names instead of full IRIs. They're declared * at the top of queries and expand to full IRIs everywhere they're used. - * + * * @param prefix Prefix name * @param iri Full IRI for the namespace - * + * * @example Define common prefixes * ```ts * const prefixes = [ @@ -1788,55 +1755,46 @@ export function service( * definePrefix('schema', 'http://schema.org/'), * definePrefix('ex', 'http://example.org/') * ] - * + * * const query = raw(` * ${prefixes.map(p => p.value).join('\n')} - * + * * SELECT ?name WHERE { * ?person foaf:name ?name . * ?person schema:email ?email . * } * `) * ``` - * + * * @example With builder * ```ts * const prefixBlock = [ * definePrefix('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'), * definePrefix('rdfs', 'http://www.w3.org/2000/01/rdf-schema#') * ].map(p => p.value).join('\n') - * + * * const query = select(['?class']) * .where(triple('?instance', 'rdf:type', '?class')) - * + * * const fullQuery = raw(`${prefixBlock}\n\n${query.build().value}`) * ``` */ -export function definePrefix(prefix: PrefixName, iri: string): SparqlValue { +export function definePrefix(prefix: PrefixName, iri: string | RdfNamedNode): SparqlValue { validatePrefixName(prefix) - let endpoint: string | null = null; - const trimmed = iri.trim() + if (isRdfTerm(iri)) { + return raw(`PREFIX ${prefix}: ${toPredicateToken(iri)}`) + } - // Already → validate inner and return. + const trimmed = iri.trim() if (isIRIRefToken(trimmed)) { const inner = trimmed.slice(1, -1) validateIRI(inner) - endpoint = trimmed - } - - // Try as absolute IRI first (scheme:...) - try { - validateIRI(trimmed) - endpoint = `<${trimmed}>` - } catch { - // Not a valid absolute IRI → fall through to prefixed + return raw(`PREFIX ${prefix}: ${trimmed}`) } - if (!endpoint) - throw new Error(`Prefix endpoint for \`PREFIX ${prefix}\: ${endpoint}\` in definePrefix() must be an IRI.`) - - return raw(`PREFIX ${prefix}: ${endpoint}`) + validateIRI(trimmed) + return raw(`PREFIX ${prefix}: <${trimmed}>`) } // ============================================================================ @@ -1845,31 +1803,31 @@ export function definePrefix(prefix: PrefixName, iri: string): SparqlValue { /** * Compute MD5 hash of a value. - * + * * Returns the MD5 hash as a hex string. MD5 is a cryptographic hash function * that produces a 128-bit (16-byte) hash value, typically rendered as a * 32-character hexadecimal number. - * + * * @param value Value to hash - * + * * @sparql `MD5(value)` - * + * * @example Hash a string * ```ts * // Library * select([md5(v('email')).as('emailHash')]) * .where(triple('?person', 'foaf:mbox', '?email')) - * + * * // SPARQL ↓ * // SELECT (MD5(?email) AS ?emailHash) * // WHERE { ?person foaf:mbox ?email } * ``` - * + * * @example Deduplication key * ```ts * // Library * bind(md5(concat(v('firstName'), v('lastName'), v('birthDate'))), 'personKey') - * + * * // SPARQL ↓ * // BIND(MD5(CONCAT(?firstName, ?lastName, ?birthDate)) AS ?personKey) * ``` @@ -1880,19 +1838,19 @@ export function md5(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Compute SHA1 hash of a value. - * + * * Returns the SHA-1 hash as a hex string. SHA-1 produces a 160-bit (20-byte) * hash value, typically rendered as a 40-character hexadecimal number. - * + * * @param value Value to hash - * + * * @sparql `SHA1(value)` - * + * * @example Content-based identifier * ```ts * // Library * bind(sha1(v('documentText')), 'contentHash') - * + * * // SPARQL ↓ * // BIND(SHA1(?documentText) AS ?contentHash) * ``` @@ -1903,21 +1861,21 @@ export function sha1(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Compute SHA256 hash of a value. - * + * * Returns the SHA-256 hash as a hex string. SHA-256 produces a 256-bit (32-byte) * hash value, typically rendered as a 64-character hexadecimal number. This is * more secure than MD5 or SHA-1. - * + * * @param value Value to hash - * + * * @sparql `SHA256(value)` - * + * * @example Secure hash * ```ts * // Library * select([sha256(v('password')).as('passwordHash')]) * .where(triple('?user', 'ex:password', '?password')) - * + * * // SPARQL ↓ * // SELECT (SHA256(?password) AS ?passwordHash) * // WHERE { ?user ex:password ?password } @@ -1929,18 +1887,18 @@ export function sha256(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Compute SHA384 hash of a value. - * + * * Returns the SHA-384 hash as a hex string. SHA-384 produces a 384-bit hash value. - * + * * @param value Value to hash - * + * * @sparql `SHA384(value)` - * + * * @example * ```ts * // Library * sha384(v('data')) - * + * * // SPARQL ↓ * // SHA384(?data) * ``` @@ -1951,20 +1909,20 @@ export function sha384(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Compute SHA512 hash of a value. - * + * * Returns the SHA-512 hash as a hex string. SHA-512 produces a 512-bit (64-byte) * hash value, typically rendered as a 128-character hexadecimal number. This * provides the highest security of the standard SHA-2 family. - * + * * @param value Value to hash - * + * * @sparql `SHA512(value)` - * + * * @example High-security hash * ```ts * // Library * bind(sha512(v('sensitiveData')), 'secureHash') - * + * * // SPARQL ↓ * // BIND(SHA512(?sensitiveData) AS ?secureHash) * ``` @@ -1979,20 +1937,20 @@ export function sha512(value: SparqlValue | ExpressionPrimitive): FluentExpr { /** * Get the current date and time. - * + * * Returns the current dateTime when the query is executed. The value is fixed * for the entire query execution - all calls to NOW() in the same query return * the same value. - * + * * @sparql `NOW()` - * + * * @example Timestamp queries * ```ts * // Library * select(['?event', '?time']) * .where(triple('?event', 'ex:timestamp', '?time')) * .filter(v('time').lt(now())) - * + * * // SPARQL ↓ * // SELECT ?event ?time * // WHERE { @@ -2000,7 +1958,7 @@ export function sha512(value: SparqlValue | ExpressionPrimitive): FluentExpr { * // FILTER(?time < NOW()) * // } * ``` - * + * * @example Add timestamp to data * ```ts * // Library @@ -2008,7 +1966,7 @@ export function sha512(value: SparqlValue | ExpressionPrimitive): FluentExpr { * .insert(triple('?person', 'ex:lastModified', now())) * .where(triple('?person', 'foaf:name', '?name')) * .done() - * + * * // SPARQL ↓ * // INSERT { ?person ex:lastModified NOW() } * // WHERE { ?person foaf:name ?name } @@ -2020,23 +1978,23 @@ export function now(): SparqlValue { /** * Generate a fresh UUID as an IRI. - * + * * Creates a new UUID (Universally Unique Identifier) and returns it as an IRI * in the urn:uuid: namespace. Each call generates a different UUID. - * + * * @sparql `UUID()` - * + * * @example Generate unique IRIs * ```ts * // Library * construct(triple(uuid(), 'rdf:type', 'ex:Event')) * .where(triple('?input', 'ex:data', '?data')) - * + * * // SPARQL ↓ * // CONSTRUCT { UUID() rdf:type ex:Event } * // WHERE { ?input ex:data ?data } * ``` - * + * * @example Stable blank node replacement * ```ts * // Library @@ -2044,7 +2002,7 @@ export function now(): SparqlValue { * .insert(triple(uuid(), 'ex:property', '?value')) * .where(triple('?subject', 'ex:property', '?value')) * .done() - * + * * // SPARQL ↓ * // INSERT { UUID() ex:property ?value } * // WHERE { ?subject ex:property ?value } @@ -2056,21 +2014,21 @@ export function uuid(): SparqlValue { /** * Generate a fresh UUID as a string literal. - * + * * Like UUID() but returns a plain string instead of an IRI. Useful when you * need a unique identifier as a literal value rather than an IRI. - * + * * @sparql `STRUUID()` - * + * * @example Unique string identifiers * ```ts * // Library * bind(struuid(), 'transactionId') - * + * * // SPARQL ↓ * // BIND(STRUUID() AS ?transactionId) * ``` - * + * * @example Session tracking * ```ts * // Library @@ -2078,7 +2036,7 @@ export function uuid(): SparqlValue { * .insert(triple('?user', 'ex:sessionId', struuid())) * .where(triple('?user', 'ex:loginTime', now())) * .done() - * + * * // SPARQL ↓ * // INSERT { ?user ex:sessionId STRUUID() } * // WHERE { ?user ex:loginTime NOW() } @@ -2090,32 +2048,32 @@ export function struuid(): FluentExpr { /** * Generate a random number between 0 and 1. - * + * * Returns a pseudo-random number in the range [0, 1). Different calls may * return different values, even within the same query execution. - * + * * @sparql `RAND()` - * + * * @example Random sampling * ```ts * // Library * select(['?item']) * .where(triple('?item', 'rdf:type', 'ex:Product')) * .filter(rand().lt(0.1)) - * + * * // SPARQL ↓ * // SELECT ?item * // WHERE { ?item rdf:type ex:Product } * // FILTER(RAND() < 0.1) * ``` - * + * * @example Randomize order * ```ts * // Library * select(['?person', '?name']) * .where(triple('?person', 'foaf:name', '?name')) * .orderBy(rand().as('random')) - * + * * // SPARQL ↓ * // SELECT ?person ?name * // WHERE { ?person foaf:name ?name } @@ -2132,31 +2090,33 @@ export function rand(): FluentExpr { /** * Create a typed literal from a string. - * + * * @example strdt(strlit('custom value'), 'http://example.org/datatype') */ export function strdt(lexical: SparqlValue, datatype: SparqlValue): SparqlValue { 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 { return raw(`STRLANG(${lexical.value}, ${exprTermString(lang)})`) } +/** Creates a SPARQL sameTerm expression without JavaScript value coercion. */ export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { return raw(`sameTerm(${a.value}, ${b.value})`) } /** * Encode a string for use in a URI. - * + * * Percent-encodes characters that have special meaning in URIs. This follows * the encoding rules of RFC 3986 for creating valid URI components. - * + * * @param value String to encode - * + * * @sparql `ENCODE_FOR_URI(value)` - * + * * @example Build query parameters * ```ts * // Library @@ -2164,11 +2124,11 @@ export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { * concat('http://example.org/search?q=', encodeForUri(v('searchTerm'))), * 'searchUrl' * ) - * + * * // SPARQL ↓ * // BIND(CONCAT("http://example.org/search?q=", ENCODE_FOR_URI(?searchTerm)) AS ?searchUrl) * ``` - * + * * @example Create URIs from names * ```ts * // Library @@ -2176,7 +2136,7 @@ export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { * iri(concat('http://example.org/person/', encodeForUri(v('name')))), * 'personIri' * ) - * + * * // SPARQL ↓ * // BIND(IRI(CONCAT("http://example.org/person/", ENCODE_FOR_URI(?name))) AS ?personIri) * ``` @@ -2187,34 +2147,34 @@ export function encodeForUri(value: SparqlValue | ExpressionPrimitive): FluentEx /** * Check if a language tag matches a language range. - * + * * Tests whether a language tag (like "en-US") matches a language range * (like "en" or "*"). This implements RFC 4647 basic filtering. - * + * * @param lang Language tag to test * @param range Language range pattern - * + * * @sparql `langMatches(lang, range)` - * + * * @example Match English variants * ```ts * // Library * select(['?label']) * .where(triple('?resource', 'rdfs:label', '?label')) * .filter(langMatches(getlang(v('label')), 'en')) - * + * * // SPARQL ↓ * // SELECT ?label * // WHERE { ?resource rdfs:label ?label } * // FILTER(langMatches(LANG(?label), "en")) * // Matches "en", "en-US", "en-GB", etc. * ``` - * + * * @example Match any language * ```ts * // Library * filter(langMatches(getlang(v('label')), '*')) - * + * * // SPARQL ↓ * // FILTER(langMatches(LANG(?label), "*")) * ``` @@ -2232,14 +2192,14 @@ export function langMatches( /** * Construct an IRI from a string. - * + * * Converts a string value to an IRI. This is useful for dynamically creating * IRIs from string components. The input must be a valid absolute IRI. - * + * * @param value String value to convert to IRI - * + * * @sparql `IRI(value)` - * + * * @example Dynamic IRI creation * ```ts * // Library @@ -2247,11 +2207,11 @@ export function langMatches( * iri(concat('http://example.org/id/', v('personId'))), * 'personIri' * ) - * + * * // SPARQL ↓ * // BIND(IRI(CONCAT("http://example.org/id/", ?personId)) AS ?personIri) * ``` - * + * * @example Namespace-based IRIs * ```ts * // Library @@ -2261,7 +2221,7 @@ export function langMatches( * iri(concat('http://data.example.org/item/', encodeForUri(v('id')))), * 'newIri' * ) - * + * * // SPARQL ↓ * // SELECT ?newIri * // WHERE { @@ -2280,18 +2240,18 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { /** * Exclude solutions that match a pattern (MINUS). - * + * * MINUS removes solutions from the query results. It's different from NOT EXISTS: * - MINUS removes entire solutions if the pattern matches * - NOT EXISTS tests for pattern absence but keeps solutions - * + * * Use MINUS when you want to subtract one set of results from another. Use * NOT EXISTS when you want to filter based on absence of a pattern. - * + * * @param pattern Pattern to subtract from results - * + * * @sparql `MINUS { pattern }` - * + * * @example Exclude patterns * ```ts * // Library @@ -2300,7 +2260,7 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * .where(minus( * triple('?person', 'ex:blocked', true) * )) - * + * * // SPARQL ↓ * // SELECT ?person ?name * // WHERE { @@ -2308,7 +2268,7 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * // MINUS { ?person ex:blocked true } * // } * ``` - * + * * @example MINUS vs NOT EXISTS * ```ts * // Library - MINUS: Removes entire solution @@ -2316,7 +2276,7 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * .where(triple('?person', 'foaf:name', '?name')) * .where(optional(triple('?person', 'foaf:age', '?age'))) * .where(minus(triple('?person', 'ex:status', 'inactive'))) - * + * * // SPARQL ↓ * // SELECT ?person ?name ?age * // WHERE { @@ -2324,13 +2284,13 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * // OPTIONAL { ?person foaf:age ?age } * // MINUS { ?person ex:status "inactive" } * // } - * + * * // Library - NOT EXISTS: Filters but keeps solution structure * select(['?person', '?name', '?age']) * .where(triple('?person', 'foaf:name', '?name')) * .where(optional(triple('?person', 'foaf:age', '?age'))) * .filter(notExists(triple('?person', 'ex:status', 'inactive'))) - * + * * // SPARQL ↓ * // SELECT ?person ?name ?age * // WHERE { @@ -2340,6 +2300,6 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * // } * ``` */ -export function minus(pattern: SparqlValue): SparqlValue { - return raw(`MINUS { ${pattern.value} }`) -} \ No newline at end of file +export function minus(pattern: PatternValue): PatternValue { + return rawPattern(`MINUS { ${pattern.value} }`) +} diff --git a/packages/sparql/utils_test.ts b/packages/sparql/utils_test.ts new file mode 100644 index 0000000..18d1e17 --- /dev/null +++ b/packages/sparql/utils_test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test' +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, + exists, + filter, + definePrefix, + inverse, + optional, + prefixed, + sequence, + triple, + typed, + uri, + undef, + v, + values, + zeroOrMore, +} from './mod.ts' + +describe('@okikio/sparql grammar-role helpers', () => { + it('returns graph-pattern values for graph-pattern clauses', () => { + const pattern = triple('?s', '?p', '?o') + expect(filter(v('s').eq(v('o')))[SPARQL_PATTERN_BRAND]).toBe(true) + expect(optional(pattern)[SPARQL_PATTERN_BRAND]).toBe(true) + expect(values('s', [undef()])[SPARQL_PATTERN_BRAND]).toBe(true) + }) + + it('returns expressions for EXISTS', () => { + expect(exists(triple('?s', '?p', '?o'))[SPARQL_EXPR_BRAND]).toBe(true) + }) + + it('uses the SPARQL UNDEF token inside VALUES data blocks', () => { + expect(undef().value).toBe('UNDEF') + }) + + it('returns term syntax for property paths', () => { + 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(inverse(name).value).toBe('^') + }) + + it('uses RDF named nodes in datatype, IRI, and prefix constructors', () => { + const stringDatatype = rdf.namedNode(rdf.XSD.string) + const schema = rdf.namedNode('https://schema.org/') + + 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) + }) + +}) diff --git a/patterns/cypher.ts b/patterns/cypher.ts deleted file mode 100644 index 5afae27..0000000 --- a/patterns/cypher.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Neo4J like Cypher syntax for graph patterns. - * - * Visual representation of relationships makes queries more intuitive. Instead of - * writing separate node and relationship definitions, you can draw the connections - * with ASCII art arrows. This is inspired by Cypher's visual syntax. - * - * The cypher template tag parses patterns like `node1-[predicate]->node2` and - * generates the appropriate SPARQL triples. It's syntactic sugar - the RDF semantics - * are unchanged, but the code reads more like a diagram of your graph. - * - * ⚠️ Note: This generates standard SPARQL triples. The arrows are just a visual - * aid for writing patterns - they get compiled to subject-predicate-object triples. - * - * @module - */ - -import { rawPattern, toPredicateName, type SparqlTerm, type PatternValue, } from '../sparql.ts' -import { Node } from './objects.ts' - -/** - * Create graph patterns using ASCII art syntax. - * - * Draw your graph with arrows and brackets. The cypher template tag parses this - * visual representation and generates SPARQL triples. Node objects get substituted - * in and connected according to the arrows. - * - * The syntax supports: - * - `node1-[predicate]->node2` - directed edge from node1 to node2 - * - `node1<-[predicate]-node2` - directed edge from node2 to node1 - * - `node1-[predicate]-node2` - undirected (generates forward direction) - * - * Under the hood, this extracts the node patterns and creates additional triples - * for the relationships. It's a more readable way to write what would otherwise - * be multiple triple() or rel() calls. - * - * @example Simple connection - * ```ts - * const product = node('product', 'schema:Product', { - * 'schema:name': v('title') - * }) - * - * const publisher = node('publisher', 'schema:Organization', { - * 'rdfs:label': str('Marvel Comics') - * }) - * - * const pattern = cypher`${product}-[schema:publisher]->${publisher}` - * ``` - * - * Generates: - * ```sparql - * ?product a schema:Product . - * ?product schema:name ?title . - * ?publisher a schema:Organization . - * ?publisher rdfs:label "Marvel Comics" . - * ?product schema:publisher ?publisher . - * ``` - * - * @example Multiple connections - * ```ts - * const person = node('person', 'foaf:Person') - * const friend = node('friend', 'foaf:Person') - * const group = node('group', 'foaf:Group') - * - * const pattern = cypher` - * ${person}-[foaf:knows]->${friend} - * ${person}-[foaf:member]->${group} - * ` - * ``` - * - * @example Reverse direction - * ```ts - * // These are equivalent: - * cypher`${person}-[foaf:knows]->${friend}` - * cypher`${friend}<-[foaf:knows]-${person}` - * ``` - */ -export function cypher( - strings: TemplateStringsArray, - ...values: Array -): PatternValue { - let result = strings[0] - const nodes: Node[] = [] - - // Substitute node placeholders - for (let i = 0; i < values.length; i++) { - const value = values[i] - - if (value instanceof Node) { - nodes.push(value) - result += `NODE_${nodes.length - 1}` - } else { - result += String(value) - } - - result += strings[i + 1] - } - - // Parse ASCII art patterns - const edgePattern = /NODE_(\d+)\s*?\s*NODE_(\d+)/g - const triples: string[] = [] - - // First, add all node patterns - for (const node of nodes) { - triples.push(...node.value.split('\n')) - } - - // Then parse and add edge patterns - let match: RegExpExecArray | null = null; - // biome-ignore lint/suspicious/noAssignInExpressions: - while ((match = edgePattern.exec(result)) !== null) { - const fromIdx = parseInt(match[1]) - const predicate = toPredicateName(match[2]) - const toIdx = parseInt(match[3]) - - const fromVar = nodes[fromIdx].getVarName() - const toVar = nodes[toIdx].getVarName() - - triples.push(`${fromVar} ${predicate} ${toVar} .`) - } - - return rawPattern(`${triples.join('\n')}`) -} \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/scripts/ttl-to-ts.ts b/scripts/ttl-to-ts.ts deleted file mode 100644 index 87d1bf3..0000000 --- a/scripts/ttl-to-ts.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * TTL to TypeScript Type Generator - * - * Starter implementation that parses TTL ontologies and generates TypeScript types. - * - * Usage: - * ```bash - * deno run --allow-read --allow-write scripts/ttl-to-ts.ts \ - * --input infra/blazegraph/data/narrative.ttl \ - * --output types/narrative - * ``` - */ - -// @deno-types=npm:@types/n3@^1.26.0 -import { Parser, Store, DataFactory, Quad } from 'npm:n3@^1.26.0' -const { namedNode } = DataFactory - -// ============================================================================ -// Type Definitions -// ============================================================================ - -interface ClassInfo { - uri: string - label: string - comment?: string - properties: PropertyInfo[] - superClasses: string[] -} - -interface PropertyInfo { - uri: string - label: string - comment?: string - domain: string[] - range: string[] - minCardinality?: number - maxCardinality?: number - functional: boolean // OWL functional property -} - -interface OntologyInfo { - classes: ClassInfo[] - properties: PropertyInfo[] - prefixes: Map -} - -// ============================================================================ -// TTL Parser -// ============================================================================ - -async function parseTTL(ttlContent: string): Promise { - const parser = new Parser() - const store = new Store() - - // Parse TTL into RDF quads - const quads = parser.parse(ttlContent) - quads.forEach(quad => store.addQuad(quad)) - - // Extract prefixes - const prefixes = extractPrefixes(ttlContent) - - // Extract classes - const classUris = extractClassURIs(store) - const classes = classUris.map(uri => extractClassInfo(store, uri)) - - // Extract properties - const propertyUris = extractPropertyURIs(store) - const properties = propertyUris.map(uri => extractPropertyInfo(store, uri)) - - // Attach properties to classes - for (const cls of classes) { - cls.properties = properties.filter(prop => - prop.domain.includes(cls.uri) - ) - } - - return { classes, properties, prefixes } -} - -function extractPrefixes(ttlContent: string): Map { - const prefixes = new Map() - const lines = ttlContent.split('\n') - - for (const line of lines) { - const match = line.match(/@prefix\s+(\w+):\s+<([^>]+)>/) - if (match) { - prefixes.set(match[1], match[2]) - } - } - - return prefixes -} - -function extractClassURIs(store: Store): string[] { - const classes = new Set() - - // RDFS classes - store.getQuads(null, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode('http://www.w3.org/2000/01/rdf-schema#Class'), null) - .forEach(quad => classes.add(quad.subject.value)) - - // OWL classes - store.getQuads(null, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode('http://www.w3.org/2002/07/owl#Class'), null) - .forEach(quad => classes.add(quad.subject.value)) - - return Array.from(classes) -} - -function extractPropertyURIs(store: Store): string[] { - const properties = new Set() - - // RDF properties - store.getQuads(null, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#Property'), null) - .forEach(quad => properties.add(quad.subject.value)) - - // OWL object properties - store.getQuads(null, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode('http://www.w3.org/2002/07/owl#ObjectProperty'), null) - .forEach(quad => properties.add(quad.subject.value)) - - // OWL datatype properties - store.getQuads(null, namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), namedNode('http://www.w3.org/2002/07/owl#DatatypeProperty'), null) - .forEach(quad => properties.add(quad.subject.value)) - - return Array.from(properties) -} - -function extractClassInfo(store: Store, classUri: string): ClassInfo { - return { - uri: classUri, - label: getLiteral(store, classUri, 'http://www.w3.org/2000/01/rdf-schema#label') || extractLocalName(classUri), - comment: getLiteral(store, classUri, 'http://www.w3.org/2000/01/rdf-schema#comment'), - properties: [], // Filled later - superClasses: getSuperClasses(store, classUri) - } -} - -function extractPropertyInfo(store: Store, propUri: string): PropertyInfo { - return { - uri: propUri, - label: getLiteral(store, propUri, 'http://www.w3.org/2000/01/rdf-schema#label') || extractLocalName(propUri), - comment: getLiteral(store, propUri, 'http://www.w3.org/2000/01/rdf-schema#comment'), - domain: getURIs(store, propUri, 'http://www.w3.org/2000/01/rdf-schema#domain'), - range: getURIs(store, propUri, 'http://www.w3.org/2000/01/rdf-schema#range'), - functional: isFunctionalProperty(store, propUri), - minCardinality: undefined, // Could be extracted from OWL/SHACL - maxCardinality: undefined - } -} - -function getSuperClasses(store: Store, classUri: string): string[] { - return store.getQuads(namedNode(classUri), namedNode('http://www.w3.org/2000/01/rdf-schema#subClassOf'), null, null) - .map(quad => quad.object.value) -} - -function getLiteral(store: Store, subject: string, predicate: string): string | undefined { - const quad = store.getQuads(namedNode(subject), namedNode(predicate), null, null)[0] - return quad?.object.value -} - -function getURIs(store: Store, subject: string, predicate: string): string[] { - return store.getQuads(namedNode(subject), namedNode(predicate), null, null) - .map(quad => quad.object.value) -} - -function isFunctionalProperty(store: Store, propUri: string): boolean { - return store.getQuads( - namedNode(propUri), - namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), - namedNode('http://www.w3.org/2002/07/owl#FunctionalProperty'), - null - ).length > 0 -} - -function extractLocalName(uri: string): string { - const match = uri.match(/[#/]([^#/]+)$/) - return match ? match[1] : uri -} - -// ============================================================================ -// Type Mapping -// ============================================================================ - -function mapXSDToTS(xsdType: string): string { - const typeMap: Record = { - 'http://www.w3.org/2001/XMLSchema#string': 'string', - 'http://www.w3.org/2001/XMLSchema#integer': 'number', - 'http://www.w3.org/2001/XMLSchema#int': 'number', - 'http://www.w3.org/2001/XMLSchema#long': 'number', - 'http://www.w3.org/2001/XMLSchema#decimal': 'number', - 'http://www.w3.org/2001/XMLSchema#float': 'number', - 'http://www.w3.org/2001/XMLSchema#double': 'number', - 'http://www.w3.org/2001/XMLSchema#boolean': 'boolean', - 'http://www.w3.org/2001/XMLSchema#date': 'Date', - 'http://www.w3.org/2001/XMLSchema#dateTime': 'Date', - 'http://www.w3.org/2001/XMLSchema#time': 'Date', - 'http://www.w3.org/2001/XMLSchema#anyURI': 'string', - } - - return typeMap[xsdType] || 'string' -} - -function mapRangeToTS(range: string[], prefixes: Map): string { - if (range.length === 0) { - return 'string' - } - - if (range.length === 1) { - const r = range[0] - - // XSD datatype - if (r.includes('XMLSchema#')) { - return mapXSDToTS(r) - } - - // Object property (reference to another class) - const shortUri = shortenURI(r, prefixes) - return `IRI<'${shortUri}'>` - } - - // Union type - return range.map(r => mapRangeToTS([r], prefixes)).join(' | ') -} - -function shortenURI(uri: string, prefixes: Map): string { - for (const [prefix, namespace] of prefixes) { - if (uri.startsWith(namespace)) { - return `${prefix}:${uri.substring(namespace.length)}` - } - } - return uri -} - -// ============================================================================ -// TypeScript Generator -// ============================================================================ - -function generateTypeScript(ontology: OntologyInfo): Map { - const files = new Map() - - // Generate type for each class - for (const cls of ontology.classes) { - const fileName = `${cls.label}.ts` - const content = generateClassInterface(cls, ontology.prefixes) - files.set(fileName, content) - } - - // Generate index file - const indexContent = generateIndex(ontology.classes) - files.set('index.ts', indexContent) - - return files -} - -function generateClassInterface(cls: ClassInfo, prefixes: Map): string { - const shortUri = shortenURI(cls.uri, prefixes) - - // Generate JSDoc comment - const jsdoc = cls.comment - ? `/**\n * ${cls.comment}\n */\n` - : '' - - // Generate properties - const properties = cls.properties.map(prop => { - const propShortUri = shortenURI(prop.uri, prefixes) - const tsType = mapRangeToTS(prop.range, prefixes) - const isArray = !prop.functional && (prop.maxCardinality === undefined || prop.maxCardinality > 1) - const optional = prop.minCardinality === 0 || prop.minCardinality === undefined - - const propJsdoc = prop.comment ? ` /**\n * ${prop.comment}\n */\n` : '' - - return `${propJsdoc} '${propShortUri}': ${tsType}${isArray ? '[]' : ''}${optional ? ' | undefined' : ''}` - }).join('\n\n') - - const superClassTypes = cls.superClasses.length > 0 - ? ` extends ${cls.superClasses.map(sc => shortenURI(sc, prefixes).replace(':', '_')).join(', ')}` - : '' - - return `/** - * Generated from TTL ontology - * Do not edit manually - */ - -// Brand type for IRI safety -export type IRI = string & { __iri: T } - -${jsdoc}export interface ${cls.label}${superClassTypes} { - '@id': IRI<'${shortUri}'> - '@type': '${shortUri}' - -${properties} -} - -/** - * Valid properties for ${cls.label} class - */ -export type ${cls.label}Property = ${cls.properties.map(p => `'${shortenURI(p.uri, prefixes)}'`).join(' | ')} - -/** - * Type guard for ${cls.label} properties - */ -export function is${cls.label}Property(property: string): property is ${cls.label}Property { - const validProperties: ${cls.label}Property[] = [ -${cls.properties.map(p => ` '${shortenURI(p.uri, prefixes)}'`).join(',\n')} - ] - return validProperties.includes(property as ${cls.label}Property) -} -` -} - -function generateIndex(classes: ClassInfo[]): string { - const exports = classes.map(cls => `export * from './${cls.label}'`).join('\n') - - const classConstants = classes.map(cls => - ` ${cls.label}: '${cls.uri}'` - ).join(',\n') - - const typeUnion = classes.map(cls => cls.label).join(' | ') - - return `/** - * Generated from TTL ontology - * Do not edit manually - */ - -${exports} - -/** - * All class URIs - */ -export const Classes = { -${classConstants} -} as const - -/** - * Union of all class types - */ -export type AnyClass = ${typeUnion} - -/** - * Type guard for valid class URIs - */ -export function isValidClass(uri: string): uri is typeof Classes[keyof typeof Classes] { - return Object.values(Classes).includes(uri as any) -} -` -} - -// ============================================================================ -// File Writer -// ============================================================================ - -async function writeGeneratedFiles( - files: Map, - outputDir: string -): Promise { - // Create output directory - await Deno.mkdir(outputDir, { recursive: true }) - - // Write each file - for (const [fileName, content] of files) { - const filePath = `${outputDir}/${fileName}` - await Deno.writeTextFile(filePath, content) - console.log(`✅ Generated ${filePath}`) - } -} - -// ============================================================================ -// CLI -// ============================================================================ - -async function main() { - const args = Deno.args - - // Parse arguments - let inputFile = '' - let outputDir = './generated' - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--input' || args[i] === '-i') { - inputFile = args[++i] - } else if (args[i] === '--output' || args[i] === '-o') { - outputDir = args[++i] - } else if (args[i] === '--help' || args[i] === '-h') { - console.log(` -TTL to TypeScript Type Generator - -Usage: - deno run --allow-read --allow-write ttl-to-ts.ts [options] - -Options: - -i, --input Input TTL file (required) - -o, --output Output directory (default: ./generated) - -h, --help Show this help message - -Example: - deno run --allow-read --allow-write ttl-to-ts.ts \\ - --input ontology/narrative.ttl \\ - --output src/types/narrative - `) - Deno.exit(0) - } - } - - if (!inputFile) { - console.error('❌ Error: --input is required') - Deno.exit(1) - } - - try { - console.log(`📖 Reading ${inputFile}...`) - const ttlContent = await Deno.readTextFile(inputFile) - - console.log(`🔍 Parsing ontology...`) - const ontology = await parseTTL(ttlContent) - - console.log(`📝 Found ${ontology.classes.length} classes, ${ontology.properties.length} properties`) - - console.log(`🔨 Generating TypeScript...`) - const files = generateTypeScript(ontology) - - console.log(`💾 Writing files to ${outputDir}...`) - await writeGeneratedFiles(files, outputDir) - - console.log(`\n✨ Done! Generated ${files.size} files`) - console.log(`\nImport generated types:`) - console.log(` import { Product, Publisher } from './${outputDir}'`) - - } catch (error) { - console.error(`❌ Error: ${(error as Error)?.message}`) - Deno.exit(1) - } -} - -// Run CLI if executed directly -if (import.meta.main) { - main() -} - -// ============================================================================ -// Exports for programmatic use -// ============================================================================ - -export { - parseTTL, - generateTypeScript, - writeGeneratedFiles, - type OntologyInfo, - type ClassInfo, - type PropertyInfo -} \ No newline at end of file -- 2.51.2