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