diff --git a/builder.ts b/builder.ts index a7bdb09..6fd8e6e 100644 --- a/builder.ts +++ b/builder.ts @@ -3,57 +3,37 @@ * * 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 - each method - * adds a clause to your query and returns a new builder. + * This builder gives you a chainable API inspired by Drizzle ORM. * - * The pattern is simple: start with a query type (select, ask, construct), add - * clauses (where, filter, optional), then build or execute. Each step is type-safe - * and the final query is properly formatted. + * ## Security Model * - * Think of it like building a sentence. You start with the verb (SELECT), add the - * details (WHERE patterns, FILTER conditions), and finish with modifiers (ORDER BY, - * LIMIT). The builder handles all the SPARQL syntax so you can focus on expressing - * your query logic. + * The builder distinguishes between SYNTAX and DATA VALUES: * - * @example Basic SELECT query - * ```ts - * const query = select(['?name', '?age']) - * .where(triple('?person', 'foaf:name', '?name')) - * .where(triple('?person', 'foaf:age', '?age')) - * .filter(gte(v('age'), 18)) - * .orderBy('?name') - * .limit(10) + * **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` * - * const result = await query.execute(config) - * ``` - * - * @example Using with node patterns - * ```ts - * const person = node('person', 'foaf:Person') - * .prop('foaf:name', v('name')) - * .prop('foaf:age', v('age')) - * - * const query = select(['?name', '?age']) - * .where(person) - * .filter(gte(v('age'), 21)) - * .distinct() - * ``` + * **Data values** (escaped, type-annotated): + * - Strings passed to filter expressions + * - Values in BIND expressions + * - Literal values in patterns * * @module */ import { - sparql, + raw, normalizeVariableName, + validateVariableName, + validatePrefixName, + validateIRI, + toRawString, + isSparqlValue, type SparqlValue, type VariableName, } from './sparql.ts' -import { - bind as bindExpr, - exprTermString, - filter as filterExpr, - optional as optionalExpr, -} from './utils.ts' import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult } from './executor.ts' // ============================================================================ @@ -62,17 +42,11 @@ import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult /** * Pattern-like input for WHERE/OPTIONAL/UNION clauses. - * - * Most helpers (triple, triples, node, rel, match) return SparqlValue. You can - * also pass raw strings if needed, though the type-safe helpers are preferred. */ export type PatternLike = string | SparqlValue /** * Variables to select in query results. - * - * Can be an array of variable names (with or without ? prefix), or the wildcard - * '*' to select all variables. */ export type Projection = PatternLike[] | '*' @@ -91,25 +65,99 @@ export interface SortSpec { /** * SELECT query modifiers. - * - * These are mutually exclusive - you can only have one per query: - * - none: No modifier (default) - * - distinct: Remove duplicate rows - * - reduced: Allow implementation to remove some duplicates (optimization hint) */ export type SelectModifier = 'none' | 'distinct' | 'reduced' // ============================================================================ -// Query Builder State +// Internal Helpers for Syntax vs Value Handling // ============================================================================ /** - * Internal state for the query builder. + * Process a projection variable (for SELECT clause). * - * This is immutable - each builder method creates a new state object rather - * than modifying the existing one. This makes the builder safe to reuse and - * compose. + * 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 + if (str.startsWith('?') || str.startsWith('$')) { + const name = str.slice(1) + validateVariableName(name) + return `?${name}` + } + + // Check if it's an expression (contains spaces, parens, AS keyword) + // These should be passed through as-is (user responsibility) + if (str.includes(' ') || str.includes('(')) { + // This is risky - but we warn in docs to use SparqlValue for complex expressions + return str + } + + // Simple identifier - treat as variable + validateVariableName(str) + return `?${str}` +} + +/** + * Process an ORDER BY variable. + */ +function processOrderByVariable(varName: string): string { + const str = varName.trim() + + if (str.startsWith('?') || str.startsWith('$')) { + const name = str.slice(1) + validateVariableName(name) + return `?${name}` + } + + validateVariableName(str) + return `?${str}` +} + +// ============================================================================ +// FILTER, OPTIONAL, BIND helpers +// ============================================================================ + +/** + * Wrap an expression in a FILTER clause. + */ +export function filter(expression: SparqlValue): SparqlValue { + return raw(`FILTER(${expression.value})`) +} + +/** + * Wrap a pattern in an OPTIONAL block. + */ +export function optional(pattern: SparqlValue): SparqlValue { + return raw(`OPTIONAL { ${pattern.value} }`) +} + +/** + * Create a BIND expression. */ +export function bind(expression: SparqlValue, varName?: string): SparqlValue { + if (!varName) { + return raw(`BIND(${expression.value})`) + } + + const normalized = normalizeVariableName(varName) + validateVariableName(normalized) + return raw(`BIND(${expression.value} AS ?${normalized})`) +} + +// ============================================================================ +// Query Builder State +// ============================================================================ + interface QueryState { readonly type: 'SELECT' | 'ASK' | 'CONSTRUCT' | 'DESCRIBE' readonly projection: Projection @@ -130,9 +178,6 @@ interface QueryState { readonly values?: Map } -/** - * Initial empty state for new queries. - */ const initialState: QueryState = { type: 'SELECT', projection: '*', @@ -149,55 +194,11 @@ const initialState: QueryState = { // Query Builder // ============================================================================ -/** - * Fluent query builder for SPARQL. - * - * Each method returns a new QueryBuilder with updated state. This immutability - * means you can safely store intermediate builders and branch from them without - * worrying about shared state. - * - * The builder compiles to standard SPARQL 1.1 queries. All the syntax details - * (clause ordering, indentation, punctuation) are handled automatically. - * - * @example Building incrementally - * ```ts - * const baseQuery = select(['?name', '?age']) - * .where(triple('?person', 'foaf:name', '?name')) - * .where(triple('?person', 'foaf:age', '?age')) - * - * // Branch for adults - * const adults = baseQuery - * .filter(gte(v('age'), 18)) - * .orderBy('?age', 'DESC') - * - * // Branch for children (baseQuery unchanged) - * const children = baseQuery - * .filter(lt(v('age'), 18)) - * .orderBy('?age', 'ASC') - * ``` - */ export class QueryBuilder { private constructor(private readonly state: QueryState) { } /** * Start a SELECT query. - * - * SELECT queries retrieve data from your graph. Specify which variables you - * want in the results, or use '*' to get all variables that appear in your - * WHERE patterns. - * - * @param projection Variables to select, or '*' for all - * - * @example Select specific variables - * ```ts - * select(['?name', '?age']) - * ``` - * - * @example Select all - * ```ts - * select('*') - * select() // defaults to '*' - * ``` */ static select(projection: Projection = '*'): QueryBuilder { return new QueryBuilder({ @@ -209,16 +210,6 @@ export class QueryBuilder { /** * Start an ASK query. - * - * ASK queries return a boolean - does the pattern exist in the data? Use this - * when you just need to check for the presence of certain patterns without - * retrieving actual data. - * - * @example Check if person exists - * ```ts - * ask() - * .where(triple('?person', 'foaf:name', 'Peter Parker')) - * ``` */ static ask(): QueryBuilder { return new QueryBuilder({ @@ -230,24 +221,6 @@ export class QueryBuilder { /** * Start a CONSTRUCT query. - * - * CONSTRUCT queries create new RDF triples based on your pattern matches. - * The template you provide defines what triples to output. Variables from - * your WHERE patterns get filled in to create the constructed triples. - * - * @param template Triple pattern to construct - * - * @example Transform data shape - * ```ts - * construct(triples('?person', [ - * ['schema:name', '?name'], - * ['schema:age', '?age'] - * ])) - * .where(triples('?person', [ - * ['foaf:name', '?name'], - * ['foaf:age', '?age'] - * ])) - * ``` */ static construct(template: SparqlValue): QueryBuilder { return new QueryBuilder({ @@ -260,17 +233,6 @@ export class QueryBuilder { /** * Start a DESCRIBE query. - * - * DESCRIBE queries return all triples about specified resources. It's like - * asking "tell me everything you know about these things." The server decides - * which triples are relevant. - * - * @param resources IRIs or variables to describe - * - * @example Describe resources - * ```ts - * describe(['', '']) - * ``` */ static describe(resources: (string | SparqlValue)[]): QueryBuilder { return new QueryBuilder({ @@ -287,118 +249,62 @@ export class QueryBuilder { /** * Add a FROM clause to specify a named graph. * - * FROM restricts the query to only look in specific named graphs. Without - * FROM clauses, queries search the default graph. You can add multiple FROM - * clauses to query across several graphs. - * - * @param graphIRI IRI of the named graph - * - * @example Query specific graph - * ```ts - * select(['?s', '?p', '?o']) - * .from('http://example.org/graph/data') - * .where(triple('?s', '?p', '?o')) - * ``` + * @param graphIRI - Full IRI of the graph (validated) */ from(graphIRI: string | SparqlValue): QueryBuilder { + const iri = toRawString(graphIRI) + validateIRI(iri) + return new QueryBuilder({ ...this.state, - from: [...(this.state.from || []), exprTermString(graphIRI)], + from: [...(this.state.from || []), iri], }) } /** * Add FROM NAMED clause for named graph queries. * - * FROM NAMED declares which named graphs are available for GRAPH patterns. - * Without FROM NAMED, GRAPH patterns can access any named graph. Use this - * to restrict which graphs your query can access. - * - * @param graphIRI Named graph IRI - * - * @example Restrict to specific graph - * ```ts - * select(['?s', '?p', '?o']) - * .fromNamed('http://example.org/graph1') - * .where(graph('?g', triple('?s', '?p', '?o'))) - * ``` + * @param graphIRI - Full IRI of the named graph (validated) */ fromNamed(graphIRI: string | SparqlValue): QueryBuilder { + const iri = toRawString(graphIRI) + validateIRI(iri) + return new QueryBuilder({ ...this.state, - fromNamed: [...(this.state.fromNamed || []), exprTermString(graphIRI)], + fromNamed: [...(this.state.fromNamed || []), iri], }) } /** * Declare a namespace prefix for abbreviated IRIs. * - * **Common use case:** Cleaning up queries by using short prefixes instead of typing - * full IRIs everywhere. Makes queries more readable and less error-prone. - * - * **How it works:** Prefix declarations appear at the top of the generated SPARQL query. - * Once declared, you can use the short form (like `foaf:name`) anywhere in your patterns - * instead of the full IRI (``). + * Both the prefix name and IRI are validated to prevent injection attacks. * - * **Best practice:** Declare all your prefixes upfront before adding patterns. This keeps - * the query structure clear and ensures prefixes are available for all subsequent patterns. + * @param name - Prefix name (e.g., "foaf", "schema") - validated + * @param iri - Full namespace IRI (e.g., "http://xmlns.com/foaf/0.1/") - validated * - * @param name - Prefix name (without the colon) - * @param iri - Full namespace IRI - * @returns QueryBuilder for chaining + * @throws {Error} If prefix name contains invalid characters + * @throws {Error} If IRI is malformed or contains injection characters * - * @example Basic prefix usage + * @example * ```ts * select(['?name']) * .prefix('foaf', 'http://xmlns.com/foaf/0.1/') * .where(triple('?person', 'foaf:name', '?name')) - * - * // Generates: - * // PREFIX foaf: - * // SELECT ?name WHERE { ?person foaf:name ?name } - * ``` - * - * @example Multiple prefixes - * ```ts - * select(['?name', '?email']) - * .prefix('foaf', 'http://xmlns.com/foaf/0.1/') - * .prefix('schema', 'https://schema.org/') - * .where(triple('?person', 'foaf:name', '?name')) - * .where(triple('?person', 'schema:email', '?email')) - * - * // Generates: - * // PREFIX foaf: - * // PREFIX schema: - * // SELECT ?name ?email WHERE { ... } - * ``` - * - * @example Using namespace constants - * ```ts - * import { RDF, FOAF, getNamespaceIRI } from '@okikio/sparql' - * - * select(['?person']) - * .prefix('rdf', getNamespaceIRI(RDF)) - * .prefix('foaf', getNamespaceIRI(FOAF)) - * .where(triple('?person', RDF.type, uri(FOAF.Person))) - * ``` - * - * @example Overriding prefixes (last one wins) - * ```ts - * select(['?name']) - * .prefix('ex', 'http://example.org/old/') - * .prefix('ex', 'http://example.org/new/') // Replaces previous - * .where(triple('?person', 'ex:name', '?name')) - * // Uses http://example.org/new/ * ``` - * - * @see getNamespaceIRI - Extract namespace IRI from constants */ 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( - exprTermString(name), - exprTermString(iri) - ) + prefixes.set(prefixName, namespaceIRI) + return new QueryBuilder({ ...this.state, prefixes, @@ -408,26 +314,8 @@ export class QueryBuilder { /** * Add a WHERE pattern. * - * WHERE patterns define what you're looking for in the graph. Each pattern - * is typically created with triple(), triples(), node(), rel(), or match(). - * Multiple where() calls add patterns that must all match (they're ANDed together). - * - * @param [...patterns] Patterns to match - * - * @example Basic triples - * ```ts - * query - * .where(triple('?person', 'foaf:name', '?name')) - * .where(triple('?person', 'foaf:age', '?age')) - * ``` - * - * @example Node pattern - * ```ts - * const person = node('person', 'foaf:Person') - * .prop('foaf:name', v('name')) - * - * query.where(person) - * ``` + * Patterns should be created using triple(), triples(), node(), or other + * pattern helpers that return SparqlValue objects. */ where(...patterns: SparqlValue[]): QueryBuilder { return new QueryBuilder({ @@ -439,27 +327,11 @@ export class QueryBuilder { /** * Add a FILTER constraint. * - * Filters restrict results based on conditions. The condition should be a - * boolean expression built with comparison operators, functions, or logical - * operators. Filters are applied after pattern matching. - * - * @param [...conditions] Boolean expression - * - * @example Age filter - * ```ts - * query.filter(gte(v('age'), 18)) - * ``` - * - * @example Multiple conditions - * ```ts - * query.filter(and( - * gte(v('age'), 18), - * regex(v('name'), '^Spider', 'i') - * )) - * ``` + * 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 => filterExpr(c)) + const filterValues = conditions.map(c => filter(c)) return new QueryBuilder({ ...this.state, @@ -469,24 +341,9 @@ export class QueryBuilder { /** * Add an OPTIONAL pattern. - * - * Optional patterns don't fail the query if they don't match - they just - * leave variables unbound. This is like LEFT JOIN in SQL. Use it for data - * that might not exist for all results. - * - * @param [...patterns] Pattern to optionally match - * - * @example Optional email - * ```ts - * query - * .where(triple('?person', 'foaf:name', '?name')) - * .optional(triple('?person', 'foaf:email', '?email')) - * ``` - * - * Email will be bound if it exists, unbound otherwise. */ optional(...patterns: SparqlValue[]): QueryBuilder { - const optionalPatterns = patterns.map(p => optionalExpr(p)) + const optionalPatterns = patterns.map(p => optional(p)) return new QueryBuilder({ ...this.state, @@ -497,33 +354,13 @@ export class QueryBuilder { /** * Add a BIND expression to create computed variables. * - * BIND lets you create new variables from expressions. The variable will - * be available in the rest of the query and in results. This is useful for - * deriving values, formatting strings, or doing calculations. - * - * @param expression Expression to compute - * @param asVariable Variable name for the result - * - * @example Full name - * ```ts - * query.bind( - * concat(v('firstName'), ' ', v('lastName')), - * 'fullName' - * ) - * ``` - * - * @example Age calculation - * ```ts - * query.bind( - * sub(2024, v('birthYear')), - * 'age' - * ) - * ``` + * @param expression - Expression to compute (SparqlValue) + * @param asVariable - Variable name for the result (validated) */ bind(expression: SparqlValue, asVariable?: VariableName): QueryBuilder { const bindValue = asVariable - ? bindExpr(expression, normalizeVariableName(asVariable)) - : bindExpr(expression) + ? bind(expression, normalizeVariableName(asVariable)) + : bind(expression) return new QueryBuilder({ ...this.state, @@ -533,22 +370,6 @@ export class QueryBuilder { /** * Add a UNION of alternative patterns. - * - * UNION means "match any of these patterns." It's like OR for patterns - if - * any branch matches, you get results. Each argument is a complete pattern - * that could stand alone. - * - * @param branches Alternative patterns - * - * @example Either type - * ```ts - * query.union( - * triple('?item', 'rdf:type', 'schema:Book'), - * triple('?item', 'rdf:type', 'schema:Movie') - * ) - * ``` - * - * This matches items that are either books or movies. */ union(...branches: SparqlValue[]): QueryBuilder { return new QueryBuilder({ @@ -560,32 +381,15 @@ export class QueryBuilder { /** * Add GROUP BY clause for aggregation. * - * GROUP BY groups results by specified variables before applying aggregation - * functions like COUNT, SUM, MAX. All non-aggregated variables in your SELECT - * must appear in GROUP BY. - * - * @param variables Variables to group by (with or without ? prefix) - * - * @example Count products per publisher - * ```ts - * select([v('publisher'), count(v('product')).as('total')]) - * .where(triple('?product', 'schema:publisher', '?publisher')) - * .groupBy('?publisher') - * ``` - * - * @example Multiple grouping variables - * ```ts - * select([v('publisher'), v('year'), count().as('total')]) - * .where(triple('?product', 'schema:publisher', '?publisher')) - * .where(triple('?product', 'schema:datePublished', '?date')) - * .bind(year(v('date')), 'year') - * .groupBy('?publisher', '?year') - * ``` + * @param variables - Variable names to group by (validated) */ groupBy(...variables: VariableName[]): QueryBuilder { - const normalized = variables.map(v => - `?${normalizeVariableName(v)}` - ) + const normalized = variables.map(v => { + const name = normalizeVariableName(v) + validateVariableName(name) + return `?${name}` + }) + return new QueryBuilder({ ...this.state, groupBy: [...(this.state.groupBy || []), ...normalized], @@ -594,163 +398,39 @@ export class QueryBuilder { /** * Add HAVING clause to filter grouped results. - * - * HAVING is like FILTER but operates on grouped/aggregated data. Use it to - * filter based on aggregation results (like "groups with COUNT > 10"). - * Multiple having() calls are ANDed together. - * - * @param condition Boolean expression on aggregated values - * - * @example Publishers with many products - * ```ts - * select([v('publisher'), count().as('total')]) - * .where(triple('?product', 'schema:publisher', '?publisher')) - * .groupBy('?publisher') - * .having(gt(count(), 10)) - * ``` - * - * @example Multiple conditions - * ```ts - * select([v('publisher'), sum(v('price')).as('revenue')]) - * .where(triple('?product', 'schema:publisher', '?publisher')) - * .where(triple('?product', 'schema:price', '?price')) - * .groupBy('?publisher') - * .having(and( - * gt(count(), 5), - * gt(sum(v('price')), 1000) - * )) - * ``` - */ - having(condition: SparqlValue): QueryBuilder { - return new QueryBuilder({ - ...this.state, - having: [...(this.state.having || []), condition], - }) - } - - /** - * Add VALUES clause for inline data. - * - * VALUES provides a list of possible bindings for a variable. Like a small - * in-memory table that gets joined with your query patterns. Useful for - * filtering by specific values or providing test data. - * - * @param variable Variable name (with or without ?) - * @param vals Array of values - * - * @example Filter by specific cities - * ```ts - * select(['?person', '?city']) - * .values('city', [str('London'), str('Paris'), str('Tokyo')]) - * .where(triple('?person', 'schema:address', '?address')) - * .where(triple('?address', 'schema:city', '?city')) - * ``` - * - * @example Provide test data - * ```ts - * select(['?city', '?population']) - * .values('city', [str('NYC'), str('LA'), str('Chicago')]) - * .where(triple('?city', 'schema:population', '?population')) - * ``` */ - values(variable: VariableName, vals: SparqlValue[]): QueryBuilder { - const varName = normalizeVariableName(variable) - const existing = this.state.values || new Map() - const updated = new Map(existing) - updated.set(varName, vals) - + having(...conditions: SparqlValue[]): QueryBuilder { return new QueryBuilder({ ...this.state, - values: updated, + having: [...(this.state.having || []), ...conditions], }) } /** - * Convert this query to a subquery pattern. - * - * Subqueries let you use a SELECT as a pattern in another query's WHERE clause. - * The subquery executes first, binding its variables, then those bindings are - * available to the outer query. Useful for complex aggregations or filtering - * on aggregated results. - * - * @returns SparqlValue representing the subquery block - * - * @example Nested aggregation - * ```ts - * const inner = select([v('publisher'), count().as('total')]) - * .where(triple('?product', 'schema:publisher', '?publisher')) - * .groupBy('?publisher') - * - * const outer = select([v('publisher'), v('total')]) - * .where(inner.asSubquery()) - * .filter(gt(v('total'), 10)) - * ``` - * - * @example Multi-level analysis - * ```ts - * // Count per property-range pair - * const level1 = select([v('property'), v('range'), count().as('c')]) - * .where(triple('?s', '?property', '?o')) - * .where(triple('?o', 'a', '?range')) - * .groupBy('?property', '?range') - * - * // Aggregate to single range per property - * const level2 = select([v('property'), sample(v('range')).as('mainRange')]) - * .where(level1.asSubquery()) - * .groupBy('?property') + * Add ORDER BY clause. * - * const query = construct(triple('?property', 'rdfs:range', '?mainRange')) - * .where(level2.asSubquery()) - * ``` - */ - asSubquery(): SparqlValue { - const query = this.build().value - return { __sparql: true, value: `{\n ${query}\n}` } - } - - /** - * Add ORDER BY clause for sorting results. - * - * Results are sorted by the specified variable. Default is ascending order - * unless you specify 'DESC'. Multiple orderBy() calls create a multi-level - * sort (first by first variable, then by second, etc.). - * - * @param variable Variable to sort by (with or without ?) - * @param direction Optional sort direction - * - * @example Sort by age - * ```ts - * query.orderBy('?age', 'DESC') - * ``` - * - * @example Multi-level sort - * ```ts - * query - * .orderBy('?lastName') - * .orderBy('?firstName') - * ``` + * @param variable - Variable name to sort by (validated) + * @param direction - Sort direction (ASC or DESC) */ orderBy(variable: VariableName, direction?: SortDirection): QueryBuilder { + const varStr = processOrderByVariable( + isSparqlValue(variable) ? variable.value : variable + ) + return new QueryBuilder({ ...this.state, - sorts: [...this.state.sorts, { variable: normalizeVariableName(variable), direction }], + sorts: [...this.state.sorts, { variable: varStr, direction }], }) } /** - * Add LIMIT clause to cap result count. - * - * Limits the number of results returned. Useful for pagination or when you - * only need a sample of results. Combine with OFFSET for pagination. - * - * @param count Maximum number of results - * - * @example First 10 results - * ```ts - * query.limit(10) - * ``` + * 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, @@ -758,18 +438,13 @@ export class QueryBuilder { } /** - * Add OFFSET clause to skip results. - * - * Skips the first N results. Used with LIMIT for pagination. - * - * @param count Number of results to skip - * - * @example Second page (10 per page) - * ```ts - * query.offset(10).limit(10) - * ``` + * 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, @@ -778,15 +453,6 @@ export class QueryBuilder { /** * Use DISTINCT modifier to remove duplicate rows. - * - * DISTINCT ensures each result row is unique. This is useful when your patterns - * might match the same data multiple ways but you only want each unique result - * once. - * - * @example Unique names - * ```ts - * select(['?name']).distinct() - * ``` */ distinct(): QueryBuilder { return new QueryBuilder({ @@ -797,10 +463,6 @@ export class QueryBuilder { /** * Use REDUCED modifier as optimization hint. - * - * REDUCED allows the query engine to eliminate some duplicates as an optimization. - * Unlike DISTINCT, it doesn't guarantee uniqueness, but it can be faster. Use this - * when you don't need strict duplicate removal. */ reduced(): QueryBuilder { return new QueryBuilder({ @@ -809,23 +471,38 @@ export class QueryBuilder { }) } + /** + * 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: SparqlValue[]): QueryBuilder { + const name = normalizeVariableName(varName) + validateVariableName(name) + + 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(): SparqlValue { + return raw(`{ ${this.build().value} }`) + } + // -------------------------------------------------------------------------- // Build & execute // -------------------------------------------------------------------------- /** * Build the final SPARQL query string. - * - * Compiles all the clauses into a properly formatted SPARQL query. The output - * follows standard SPARQL 1.1 syntax with consistent formatting. - * - * @returns Complete query as SparqlValue - * - * @example - * ```ts - * const queryString = query.build().value - * console.log(queryString) // Pretty-printed SPARQL - * ``` */ build(): SparqlValue { const parts: string[] = [] @@ -833,9 +510,10 @@ export class QueryBuilder { // 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('') // Blank line after prefixes for readability + parts.push('') } // Query type and projection @@ -849,7 +527,10 @@ export class QueryBuilder { const proj = this.state.projection === '*' ? '*' - : this.state.projection.map(x => exprTermString(x))?.join(' ') + : (this.state.projection as PatternLike[]) + .map(x => processProjectionItem(x)) + .join(' ') + parts.push(`SELECT ${modifier}${proj}`) } else if (this.state.type === 'ASK') { parts.push('ASK') @@ -857,19 +538,21 @@ export class QueryBuilder { parts.push('CONSTRUCT') } else if (this.state.type === 'DESCRIBE') { const projection = Array.isArray(this.state.projection) - ? this.state.projection.map(x => exprTermString(x)).join(' ') - : exprTermString(this.state.projection); + ? (this.state.projection as PatternLike[]) + .map(x => processProjectionItem(x)) + .join(' ') + : processProjectionItem(this.state.projection as PatternLike) parts.push(`DESCRIBE ${projection}`) } - // FROM clauses + // FROM clauses (IRIs already validated) if (this.state.from) { for (const graph of this.state.from) { parts.push(`FROM <${graph}>`) } } - // FROM NAMED clauses + // FROM NAMED clauses (IRIs already validated) if (this.state.fromNamed) { for (const graph of this.state.fromNamed) { parts.push(`FROM NAMED <${graph}>`) @@ -901,18 +584,18 @@ export class QueryBuilder { } // FILTER expressions - for (const filter of this.state.filters) { - parts.push(` ${filter.value}`) + for (const f of this.state.filters) { + parts.push(` ${f.value}`) } // OPTIONAL blocks - for (const optional of this.state.optional) { - parts.push(` ${optional.value}`) + for (const opt of this.state.optional) { + parts.push(` ${opt.value}`) } // BIND expressions - for (const bind of this.state.bindings) { - parts.push(` ${bind.value}`) + for (const b of this.state.bindings) { + parts.push(` ${b.value}`) } // UNION blocks @@ -944,8 +627,10 @@ export class QueryBuilder { // ORDER BY clause if (this.state.sorts.length > 0) { const sorts = this.state.sorts.map((sort) => { - const dir = sort.direction ? ` ${sort.direction}` : '' - return `${sort.variable}${dir}` + if (sort.direction) { + return `${sort.direction}(${sort.variable})` + } + return sort.variable }) parts.push(`ORDER BY ${sorts.join(' ')}`) } @@ -960,30 +645,11 @@ export class QueryBuilder { parts.push(`OFFSET ${this.state.offset}`) } - return sparql`${parts.join('\n')}` + return raw(parts.join('\n')) } /** * Execute the query against a SPARQL endpoint. - * - * Builds the query and sends it to the specified endpoint. Returns a result - * object that's either successful (with data) or failed (with error details). - * - * @param config Endpoint configuration - * @returns Promise of query result - * - * @example - * ```ts - * const result = await query.execute({ - * endpoint: 'http://localhost:9999/sparql' - * }) - * - * if (result.success) { - * console.log(result.data) - * } else { - * console.error(result.error.type, result.error.message) - * } - * ``` */ execute(config: ExecutionConfig): Promise> { const executor = createExecutor(config) @@ -995,56 +661,15 @@ export class QueryBuilder { // Convenience Exports // ============================================================================ -export const select = QueryBuilder.select; -export const ask = QueryBuilder.ask; -export const construct = QueryBuilder.construct; -export const describe = QueryBuilder.describe; +export const select = QueryBuilder.select +export const ask = QueryBuilder.ask +export const construct = QueryBuilder.construct +export const describe = QueryBuilder.describe -/** - * Create a subquery from a query builder. - * - * Convenience function that's equivalent to calling builder.asSubquery(). - * Subqueries let you nest SELECT queries within WHERE clauses for complex - * analytical queries. - * - * @param builder Query to use as subquery - * @returns SparqlValue for use in WHERE clause - * - * @example - * ```ts - * const inner = select([v('property'), count().as('total')]) - * .where(triple('?s', '?property', '?o')) - * .groupBy('?property') - * - * const outer = select(['?property', '?total']) - * .where(subquery(inner)) - * .filter(gt(v('total'), 10)) - * ``` - */ export function subquery(builder: QueryBuilder): SparqlValue { return builder.asSubquery() } -/** - * Quick execute shorthand. - * - * Convenience function that builds and executes a query in one call. Useful - * when you don't need to inspect the generated SPARQL. - * - * @param builder Query builder - * @param config Endpoint configuration - * @returns Promise of query result - * - * @example - * ```ts - * const result = await execute( - * select(['?name']) - * .where(triple('?person', 'foaf:name', '?name')) - * .limit(10), - * { endpoint: 'http://localhost:9999/sparql' } - * ) - * ``` - */ export function execute( builder: QueryBuilder, config: ExecutionConfig diff --git a/patterns/objects.ts b/patterns/objects.ts index 4b0ccb5..66743c5 100644 --- a/patterns/objects.ts +++ b/patterns/objects.ts @@ -20,6 +20,7 @@ import { normalizeVariableName, raw, variable, + SPARQL_VALUE_BRAND, type SparqlValue, } from '../sparql.ts' @@ -184,7 +185,7 @@ export interface NodePropertyMap { * ``` */ export class Node implements SparqlValue { - readonly __sparql = true + readonly [SPARQL_VALUE_BRAND] = true readonly subjectTerm: SparqlValue private readonly varName: string private readonly typesTerm: TriplePredicate[] = [] @@ -357,15 +358,21 @@ export class Node implements SparqlValue { // Add rdf:type triples if (this.typesTerm.length > 0) { const typeObjs: TripleObject[] = this.typesTerm.map((t) => - typeof t === 'string' ? t : t.value, + typeof t === 'string' ? raw(t) : t.value, ) - const existing = poNormalized['rdf:type'] + + const existing = + poNormalized['a'] || + poNormalized['rdf:type'] || + poNormalized['http://www.w3.org/1999/02/22-rdf-syntax-ns#type'] || + poNormalized['']; + if (existing === undefined) { - poNormalized['rdf:type'] = typeObjs + poNormalized['a'] = typeObjs } else if (Array.isArray(existing)) { - poNormalized['rdf:type'] = [...existing, ...typeObjs] + poNormalized['a'] = [...existing, ...typeObjs] } else { - poNormalized['rdf:type'] = [existing, ...typeObjs] + poNormalized['a'] = [existing, ...typeObjs] } } @@ -572,7 +579,7 @@ export interface RelationshipPropertyMap { * ``` */ export class Relationship implements SparqlValue { - readonly __sparql = true + readonly [SPARQL_VALUE_BRAND] = true private readonly fromNode?: Node private readonly toNode?: Node private readonly fromTerm: TripleSubject @@ -675,9 +682,12 @@ export class Relationship implements SparqlValue { // Reify with properties const edgeId = this.getEdgeId() const poMap: RelationshipPropertyMap = { - 'rdf:type': 'rdf:Statement', + // `a` = `rdf:type` + 'a': raw('rdf:Statement'), 'rdf:subject': this.fromTerm, - 'rdf:predicate': this.predicate, + 'rdf:predicate': typeof this.predicate === 'string' + ? raw(this.predicate) + : this.predicate, 'rdf:object': this.toTerm, ...this.properties, } diff --git a/patterns/triples.ts b/patterns/triples.ts index 78cc33c..f4db8c4 100644 --- a/patterns/triples.ts +++ b/patterns/triples.ts @@ -171,7 +171,10 @@ export function triples( subject: TripleSubject, predicateObjects: PredicateObjectList | PredicateObjectMap, ): SparqlValue { - const s = tripleSubjectString(subject) + const subjectTerm = tripleSubjectString(subject) + + // 4 spaces; 2 (block) + 2 (extra) + const CONTINUATION_INDENT = ' '; // Normalize to list format const list: PredicateObjectList = Array.isArray(predicateObjects) @@ -185,16 +188,32 @@ export function triples( } return [[pred, value]] }) - + // Build semicolon-separated list const lines: string[] = list.map(([p, o], idx) => { const pred = triplePredicateString(p) const obj = exprTermString(o) - const sep = idx < list.length - 1 ? ' ;' : ' .' - return ` ${pred} ${obj}${sep}` + const suffix = idx < list.length - 1 ? ' ;' : ' .' + + // Continuation lines should be indented one level *beyond* the line + // where the subject appears. We assume 2-space block indent, so we + // use 4 spaces here (2 for block + 2 extra). + return `${CONTINUATION_INDENT}${pred} ${obj}${suffix}` }) - return raw(`${s}\n${lines.join('\n')}`) + const [first, ...rest] = lines + 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()}`) + } + + // Multiple: first predicate shares the line with the subject, + // continuation lines keep their internal indentation. + const firstLine = `${subjectTerm} ${first.trimStart()}` + const restLines = rest.join('\n') + + return raw(`${firstLine}\n${restLines}`) } diff --git a/sparql.ts b/sparql.ts index 52111fd..3384954 100644 --- a/sparql.ts +++ b/sparql.ts @@ -45,39 +45,38 @@ import { outdent } from "outdent" // ============================================================================ /** - * Wrapper that marks a value as SPARQL-ready. - * - * This prevents double-escaping and lets us mix raw SPARQL with constructed values. - * When you see SparqlValue in a function signature, it means that value has already - * been processed and is safe to insert directly into queries. + * Internal brand used to distinguish SPARQL values from plain strings. + */ +export const SPARQL_VALUE_BRAND = Symbol('SparqlValueBrand') + +/** + * A SPARQL snippet that is already syntactically valid and should be used + * verbatim in the final query. + * + * This is the "wrapped" representation returned by helpers like `strlit`, + * `num`, `boolean`, `dateTime`, `bnode`, `valuesList`, etc. */ export interface SparqlValue { - readonly __sparql: true + readonly [SPARQL_VALUE_BRAND]: true readonly value: string } /** - * Any value that can be safely interpolated into a SPARQL query. - * - * The system automatically converts these to proper SPARQL syntax: - * - Strings → triple-quoted literals with xsd:string datatype - * - Numbers → raw integers or decimals with appropriate datatypes - * - Booleans → raw true/false - * - Dates → xsd:dateTime literals - * - Arrays → space-separated lists for VALUES or RDF lists - * - Objects → blank nodes with properties - * - SparqlValue → used as-is (already processed) + * Values that can be safely interpolated into the `sparql` tag *as a single + * RDF term*. This deliberately does NOT include arrays or plain objects. + * + * Composite structures (lists, blank-node patterns) must use dedicated helpers: + * - `valuesList(...)`, `exprList(...)`, `rdfList(...)` + * - `bnodePattern(...)` */ export type SparqlInterpolatable = + | SparqlValue | string | number | boolean | Date | null | undefined - | SparqlValue - | SparqlInterpolatable[] - | { [key: string]: SparqlInterpolatable } /** * Variable name without the leading ? or $ sigil. @@ -103,21 +102,61 @@ export type DatatypeIRI = string export type LanguageTag = string // ============================================================================ -// Escaping & Validation +// Internal Helpers // ============================================================================ /** - * Normalize variable names to handle both ?foo and foo formats. + * Type guard for `SparqlValue`. + */ +export function isSparqlValue(value: unknown): value is SparqlValue { + return ( + typeof value === 'object' && + value !== null && + (value as SparqlValue)[SPARQL_VALUE_BRAND] === true + ) +} + +/** + * 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. * - * 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. + * 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 + * raw('ex:customFunc(?x, ?y)') // Custom function */ -export function normalizeVariableName(name: VariableName): string { - const n = isSparqlValue(name) ? name?.value : name; - return n.startsWith('?') ? (n.slice(1) as string) : (n as string) +export function raw(value: string): SparqlValue { + return { + [SPARQL_VALUE_BRAND]: true, + value, + } } +/** + * 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. + */ +export function toRawString(value: string | SparqlValue): string { + return isSparqlValue(value) ? value.value : value +} + +// ============================================================================ +// String Escaping +// ============================================================================ + /** * Escape a JavaScript string so it can be safely embedded as a * SPARQL string literal. @@ -259,19 +298,13 @@ export function escapeString( ): string { return str.replace(/[\u0000-\u001F\\'"]/g, function (ch: string): string { // Always escape backslash - if (ch === '\\') { - return '\\\\' - } + if (ch === '\\') return '\\\\' // Escape whichever quote you are actually using as the delimiter - if (ch === quote) { - return '\\' + ch - } + if (ch === quote) return '\\' + ch // The non-delimiting quote does not *need* escaping for SPARQL's grammar. - if (ch === '"' || ch === "'") { - return ch - } + if (ch === '"' || ch === "'") return ch // Control characters with explicit SPARQL-style escapes switch (ch) { @@ -291,18 +324,38 @@ export function escapeString( } /** - * Validate that a string is a proper IRI. + * Check if a string needs triple-quoting (contains newlines or quotes). + */ +function needsLongQuotes(str: string): boolean { + return str.includes('\n') || str.includes('\r') || + str.includes('"') || str.includes("'") +} + +// ============================================================================ +// Validation (Security-focused, not overly restrictive) +// ============================================================================ + +/** + * Characters that could enable SPARQL injection. + */ +const INJECTION_CHARS = /[<>"'\n\r\t{}]/ + +/** + * Validate an IRI for use in SPARQL. * - * IRIs must start with http:// or https:// and can't contain certain forbidden - * characters like spaces, angle brackets, or pipes. This catches common mistakes - * before they cause query errors. + * 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 { - if (!iri.startsWith('http://') && !iri.startsWith('https://')) { - throw new Error(`IRI must start with http:// or https://, got: ${iri}`) + // Must have a valid URI scheme (RFC 3986) + 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}`) } - - const forbidden = ['<', '>', '"', '{', '}', '|', '^', '`', '\\', ' '] + + // Block characters that break IRI syntax in SPARQL + const forbidden = ['<', '>', '"', ' ', '\n', '\r', '\t', '{', '}'] for (const char of forbidden) { if (iri.includes(char)) { throw new Error(`IRI contains forbidden character '${char}': ${iri}`) @@ -311,427 +364,778 @@ export function validateIRI(iri: string): void { } /** - * Validate SPARQL variable names. + * Validate a SPARQL variable name. * - * Variable names must start with a letter or underscore, followed by letters, - * numbers, or underscores. This matches the SPARQL 1.1 specification. + * 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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - throw new Error(`Invalid variable name: ${name}`) + 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}`) } } /** - * Validate namespace prefix names. + * Validate a namespace prefix name. * - * Prefixes follow the same rules as variable names - they're identifiers that + * 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 { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - throw new Error(`Invalid prefix name: ${name}`) + // 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}`) + } +} + +/** + * Validate a prefixed name (prefix:localPart). + * + * @throws {Error} If the prefixed name is malformed + */ +export function validatePrefixedName(prefixedName: string): void { + const colonIndex = prefixedName.indexOf(':') + 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)) { + throw new Error(`Invalid local part in prefixed name: ${prefixedName}`) + } +} + +/** + * Validate a BCP 47 language tag. + */ +export function validateLanguageTag(tag: string): void { + // Basic BCP 47 validation + if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]+)*$/.test(tag)) { + throw new Error(`Invalid language tag: ${tag}. Expected BCP 47 format (e.g., "en", "en-US")`) } } +/** + * 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. + */ +export function normalizeVariableName(name: VariableName): string { + const n = isSparqlValue(name) ? name?.value : name; + // Strip ? or $ prefix if present + if (n.startsWith('?') || n.startsWith('$')) { + return n.slice(1) + } + return n +} + // ============================================================================ -// Type Conversion +// Value Constructors // ============================================================================ /** - * Convert Date to xsd:dateTime with full timestamp. + * Create a SPARQL variable reference. * - * Uses ISO 8601 format with timezone. This is the standard way to represent - * date-time values in RDF. + * 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 "2024-01-15T10:30:00.000Z"^^ + * @example + * variable('name') // → ?name + * variable('?name') // → ?name */ -export function formatDateTime(date: Date): string { - return `"${date.toISOString()}"^^` +export function variable(name: VariableName): SparqlValue { + const n = normalizeVariableName(name) + validateVariableName(n) + return raw(`?${n}`) } /** - * Convert Date to xsd:date with date only (no time component). + * Create an IRI reference wrapped in angle brackets. * - * Useful when you only care about the calendar date, not the time. The format - * is YYYY-MM-DD. + * IRIs are how you reference resources in RDF. This function validates the IRI + * format and wraps it in the required angle brackets. * - * @example "2024-01-15"^^ + * @example + * uri('http://example.org/resource') // → + * uri('urn:isbn:0451450523') // → + */ -export function formatDate(date: Date): string { - const yyyy = date.getFullYear() - const mm = String(date.getMonth() + 1).padStart(2, '0') - const dd = String(date.getDate()).padStart(2, '0') - return `"${yyyy}-${mm}-${dd}"^^` +export function uri(iri: string): SparqlValue { + validateIRI(iri) + return raw(`<${iri}>`) } /** - * Convert array to SPARQL representation. + * Create a prefixed name (namespace:local format). * - * The conversion depends on what's in the array. For primitive values, we generate - * a space-separated list suitable for VALUES clauses. For complex values, we - * generate an RDF list using parentheses notation. + * Prefixes let you abbreviate long IRIs. * - * @example Primitive values (for VALUES) - * ```ts - * formatArray([1, 2, 3]) // → "1 2 3" - * ``` + * @example + * prefixed('foaf', 'name') // → foaf:name + */ +export function prefixed(prefix: PrefixName, localName: string): SparqlValue { + 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}`) +} + +/** + * Alias for {@link prefixed} with more explicit naming. + */ +export function prefix(namespace: PrefixName, local: string): SparqlValue { + return prefixed(namespace, local) +} + +/** + * Create a simple string literal. * - * @example Complex values (RDF list) - * ```ts - * formatArray([obj1, obj2]) // → "( [props...] [props...] )" - * ``` + * 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 formatArray(arr: SparqlInterpolatable[]): string { - if (arr.length === 0) { - throw new Error('Cannot convert empty array to SPARQL') +export function strlit(value: string): SparqlValue { + const escaped = escapeString(value) + + if (needsLongQuotes(value)) { + return raw(`"""${escaped}"""`) } - // Check if all elements are simple primitives - const allPrimitives = arr.every( - (item) => - item instanceof Date || - typeof item === 'string' || - typeof item === 'number' || - typeof item === 'boolean' || - item === null || - item === undefined - ) + return raw(`"${escaped}"`) +} - if (allPrimitives) { - // For VALUES clauses, just space-separate the values - const values = arr.map((item) => convertValue(item)).join(' ') - return values +/** + * 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): SparqlValue { + validateIRI(datatype) + const escaped = escapeString(value) + + if (needsLongQuotes(value)) { + return raw(`"""${escaped}"""^^<${datatype}>`) } - // For complex arrays, generate RDF list notation - const values = arr.map((item) => convertValue(item)).join(' ') - return `( ${values} )` + return raw(`"${escaped}"^^<${datatype}>`) } /** - * Convert object to SPARQL blank node with properties. + * Create a language-tagged literal. * - * JavaScript objects map naturally to RDF blank nodes. Each key becomes a predicate, - * and each value becomes an object. Keys can be prefixed names (foaf:name) or - * full IRIs. + * 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): SparqlValue { + validateLanguageTag(tag) + const escaped = escapeString(value) + + if (needsLongQuotes(value)) { + return raw(`"""${escaped}"""@${tag.toLowerCase()}`) + } + + return raw(`"${escaped}"@${tag.toLowerCase()}`) +} + +/** + * Create an integer literal using native SPARQL syntax. + * + * SPARQL treats bare integers like `42` as xsd:integer. * * @example - * ```ts - * formatObject({ - * 'foaf:name': 'Alice', - * 'foaf:age': 30 - * }) - * // → [ foaf:name "Alice" ; foaf:age 30 ] - * ``` + * integer(42) // → 42 + * + * @throws {Error} If value is not an integer */ -export function formatObject(obj: { [key: string]: SparqlInterpolatable }): string { - const entries = Object.entries(obj) - if (entries.length === 0) { - throw new Error('Cannot convert empty object to SPARQL') +export function integer(value: number): SparqlValue { + if (!Number.isInteger(value)) { + throw new Error(`Expected integer, got: ${value}`) } - // Generate blank node syntax with semicolon-separated properties - const properties = entries - .map(([key, value]) => { - // Handle different predicate formats - const predicate = key.includes(':') || key.startsWith('http') - ? key.includes(':') - ? key // Already a prefixed name - : `<${key}>` // Full IRI needs angle brackets - : `:${key}` // Default to colon prefix + // Use SPARQL native integer syntax + return raw(String(value)) +} - return `${predicate} ${convertValue(value)}` - }) - .join(' ; ') +/** + * 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): SparqlValue { + if (!Number.isFinite(value)) { + throw new Error(`Expected finite number, got: ${value}`) + } + + const str = String(value) + // Ensure decimal point for unambiguous decimal type + if (!str.includes('.') && !str.includes('e') && !str.includes('E')) { + return raw(str + '.0') + } + return raw(str) +} - return `[ ${properties} ]` +/** + * 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): SparqlValue { + if (!Number.isFinite(value)) { + throw new Error(`Expected finite number, got: ${value}`) + } + // Scientific notation triggers xsd:double + return raw(value.toExponential()) } /** - * Convert any JavaScript value to its SPARQL representation. + * 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 + */ +export function num(value: number): SparqlValue { + if (Number.isInteger(value)) { + return integer(value) + } + + return decimal(value) +} + +/** + * Create a boolean literal (true or false). + * + * Boolean values in SPARQL are written as bare keywords, not quoted strings. + */ +export function boolean(value: boolean): SparqlValue { + return raw(value ? 'true' : 'false') +} + +/** + * Short alias for {@link boolean}. + */ +export function bool(value: boolean): SparqlValue { + return boolean(value) +} + +/** + * Create an xsd:date literal. + * + * @example + * date(new Date('2024-01-15')) // → "2024-01-15"^^ + */ +export function date(value: Date | string): SparqlValue { + const dateObj = value instanceof Date ? value : new Date(value) + const yyyy = dateObj.getFullYear() + const mm = String(dateObj.getMonth() + 1).padStart(2, '0') + const dd = String(dateObj.getDate()).padStart(2, '0') + return raw(`"${yyyy}-${mm}-${dd}"^^`) +} + +/** + * Create an xsd:dateTime literal. + * + * @example + * dateTime(new Date()) // → "2024-01-15T10:30:00.000Z"^^ + */ +export function dateTime(value: Date | string): SparqlValue { + const dateObj = value instanceof Date ? value : new Date(value) + return raw(`"${dateObj.toISOString()}"^^`) +} + +// ============================================================================ +// Type Conversion (for DATA VALUES only) +// ============================================================================ + +/** + * 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, * numbers stay as numbers, dates get proper formatting. + * + * This function intentionally only supports **scalar** values (one RDF term). + * Composite structures (arrays, objects) are handled by dedicated helpers: + * + * - Use `valuesList(...)`, `exprList(...)`, `rdfList(...)` for lists. + * - Use `bnodePattern(...)` for `[ ... ]` blank node property lists. * - * @param value The value to convert - * @param strict If true, throw errors for null/undefined; if false, convert to empty string - * - * @throws {Error} For null/undefined when strict=true (use OPTIONAL instead) - * @throws {Error} For empty arrays or objects (they're ambiguous in SPARQL) - * @throws {Error} For values that can't be represented in SPARQL + * ⚠️ IMPORTANT: This function is for DATA VALUES only, not SPARQL syntax. + * Do not use this for variables, prefixes, IRIs, or other syntax elements. + * + * It is also used by the `sparql` template tag internally for interpolations. + * + * @param value The data value to convert. + * @param strict If true, `null`/`undefined` will throw. If false, they become + * `""` (empty string literal). + * + * @throws {Error} For `null`/`undefined` when `strict = true`. + * @throws {Error} For non-finite numbers (NaN/Infinity). + * @throws {Error} For arrays/objects (use list/blank-node helpers instead). + * + * @example Converting simple scalars + * ```ts + * convertValue('Peter Parker') // => "\"Peter Parker\"" + * convertValue(18) // => "18" + * convertValue(true) // => "true" + * convertValue(new Date()) // => "\"...\"^^xsd:dateTime" + * ``` + * + * @example Null handling + * ```ts + * convertValue(null, false) // => "\"\"" + * convertValue(undefined, false) // => "\"\"" + * convertValue(null) // throws + * ``` */ -export function convertValue(value: SparqlInterpolatable, strict = true): string { - // Already wrapped - use as-is +export function convertValue(value: SparqlInterpolatable, strict = true): string { // Already a SPARQL value – pass straight through. if (isSparqlValue(value)) { return value.value } - // Null/undefined - these are tricky in RDF + // Null/undefined cannot represent "unbound" – force caller to decide. if (value === null || value === undefined) { if (strict) { throw new Error( - 'Cannot convert null/undefined to SPARQL. Use OPTIONAL { } pattern instead.' + 'Cannot convert null/undefined to a SPARQL term. Use OPTIONAL/BOUND or pass strict=false if you explicitly want an empty string literal.' ) } return strlit('').value } - // String - becomes xsd:string literal + // Scalar primitives. if (typeof value === 'string') { return strlit(value).value } - // Boolean - raw true/false keywords if (typeof value === 'boolean') { return boolean(value).value } - // Number - raw numeric literal (SPARQL infers type) if (typeof value === 'number') { return num(value).value } - // Date - becomes xsd:dateTime if (value instanceof Date) { - return date(value).value + return dateTime(value).value } - // Array - space-separated list or RDF list + // Anything else (arrays, plain objects, etc.) is not a single term. if (Array.isArray(value)) { - return formatArray(value) + throw new Error( + 'Cannot convert an array directly to a SPARQL term. Use valuesList(), exprList(), or rdfList() to control how the list appears in your query.' + ) } - // Object - blank node with properties if (typeof value === 'object') { - return formatObject(value as { [key: string]: SparqlInterpolatable }) + throw new Error( + 'Cannot convert a plain object directly to a SPARQL term. Use bnodePattern() to create [ ... ] blank nodes, or pre-wrap it as a SparqlValue using raw().' + ) } - throw new Error(`Cannot convert value of type ${typeof value} to SPARQL`) + throw new Error( + `Cannot convert value of type "${typeof value}" to a SPARQL term` + ) } +// ============================================================================ +// List Helpers (arrays / iterables) +// ============================================================================ + /** - * Check if a value is already wrapped as SparqlValue. - * - * This type guard lets us avoid double-processing values that have already - * been converted to SPARQL format. + * Internal: check for an iterable that is *not* a string. */ -export function isSparqlValue(value: unknown): value is SparqlValue { +function isNonStringIterable(value: unknown): value is Iterable { return ( - typeof value === 'object' && value !== null && - '__sparql' in value && - value.__sparql === true + value !== undefined && + typeof value !== 'string' && + typeof (value as any)[Symbol.iterator] === 'function' ) } /** - * Wrap a string as SparqlValue without any conversion. - * - * Internal helper for creating SparqlValue objects. Marks the string as - * already-processed SPARQL so it won't be escaped or converted again. + * Create a VALUES-style list: `VALUES ?x { v1 v2 v3 }`. + * + * This is a generic "space-separated sequence of terms", suitable for: + * + * - `VALUES ?x { ${valuesList(...)} }` + * - `VALUES (?x ?y) { (${valuesList(...)} ) ... }` (when building row fragments) + * + * @throws {Error} For empty iterables. + * + * @example Simple VALUES + * ```ts + * const cities = ['London', 'Paris', 'Tokyo'] + * + * const query = sparql` + * SELECT * WHERE { + * VALUES ?city { ${valuesList(cities)} } + * ?place schema:name ?city . + * } + * ` + * // => VALUES ?city { "London" "Paris" "Tokyo" } + * ``` */ -export function wrapSparqlValue(value: string): SparqlValue { - return { __sparql: true, value: value } -} +export function valuesList( + items: Iterable +): SparqlValue { + const parts: string[] = [] -// ============================================================================ -// Value Constructors -// ============================================================================ + for (const item of items) { + parts.push(convertValue(item)) + } -/** - * 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') → - */ -export function uri(iri: string): SparqlValue { - validateIRI(iri) - return wrapSparqlValue(`<${iri}>`) + if (parts.length === 0) { + throw new Error('Cannot create VALUES list from an empty iterable') + } + + return raw(parts.join(' ')) } /** - * 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('person') → ?person - * @example variable('?person') → ?person (? is normalized) + * Create a comma-separated expression list: `IN (v1, v2, v3)`. + * + * This is appropriate where SPARQL expects an expression list, e.g. `IN`, + * function calls with multiple arguments, etc. + * + * @throws {Error} For empty iterables. + * + * @example IN list + * ```ts + * const ages = [18, 21, 25] + * + * const query = sparql` + * SELECT * WHERE { + * ?person schema:age ?age . + * FILTER(?age IN (${exprList(ages)})) + * } + * ` + * // => FILTER(?age IN (18, 21, 25)) + * ``` */ -export function variable(name: VariableName): SparqlValue { - const n = normalizeVariableName(name) - validateVariableName(n) - return wrapSparqlValue(`?${n}`) +export function exprList( + items: Iterable +): SparqlValue { + const parts: string[] = [] + + for (const item of items) { + parts.push(convertValue(item)) + } + + if (parts.length === 0) { + throw new Error('Cannot create expression list from an empty iterable') + } + + return raw(parts.join(', ')) } /** - * Create a prefixed name (namespace:local format). - * - * Prefixes let you abbreviate long IRIs. Instead of writing out the full IRI - * each time, you can use a short prefix. Your query needs matching PREFIX - * declarations at the top. - * - * @example prefixed('foaf', 'name') → foaf:name - * @example prefixed('schema', 'Person') → schema:Person + * Create an RDF collection: `( v1 v2 v3 )`. + * + * This is for canonical RDF lists, which are whitespace-separated inside + * parentheses. + * + * @throws {Error} For empty iterables. + * + * @example RDF list + * ```ts + * const items = ['a', 'b', 'c'] + * + * const query = sparql` + * ?list ex:hasItems ${rdfList(items)} . + * ` + * // => ?list ex:hasItems ( "a" "b" "c" ) . + * ``` */ -export function prefixed(prefix: PrefixName, localName: string): SparqlValue { - validatePrefixName(prefix) - return wrapSparqlValue(`${prefix}:${localName}`) +export function rdfList( + items: Iterable +): SparqlValue { + const parts: string[] = [] + + for (const item of items) { + parts.push(convertValue(item)) + } + + if (parts.length === 0) { + throw new Error('Cannot create RDF list from an empty iterable') + } + + return raw(`( ${parts.join(' ')} )`) } + +// ============================================================================ +// Blank Node Helpers +// ============================================================================ + /** - * Alias for {@link prefixed} with more explicit naming. + * Create a blank node *term*. + * + * - With no `id`, this represents the SPARQL `BNODE()` function, which creates + * a fresh blank node per evaluation. + * - With an `id`, this creates a labeled blank node like `_:b1`. + * + * This represents a **node term**, not a `[ ... ]` property pattern. For + * inline property lists, use `bnodePattern(...)` instead. + * + * @param id Optional blank node identifier (e.g. "b1") + * + * @example Generate fresh blank node with BNODE() + * ```ts + * bind(bnode(), 'restriction') + * // BIND(BNODE() AS ?restriction) + * ``` + * + * @example Stable blank node label + * ```ts + * triple(bnode('b1'), 'rdf:type', 'owl:Restriction') + * // _:b1 rdf:type owl:Restriction . + * ``` + * + * @example Use in CONSTRUCT + * ```ts + * construct(triple(bnode(), 'ex:property', '?value')) + * .where(triple('?s', 'ex:property', '?value')) + * // Fresh blank nodes for each result row. + * ``` */ -export function prefix(namespace: string, local: string): SparqlValue { - return prefixed(namespace, local) +export function bnode(id?: string): SparqlValue { + if (id) { + return raw(`_:${id}`) + } + return raw('BNODE()') } /** - * Create an xsd:date literal (calendar date without time). - * - * Use this when you only care about the date, not the time. Good for birthdays, - * publication dates, or any calendar-based data. - * - * @example date(new Date('2024-01-15')) → "2024-01-15"^^xsd:date + * Property map for `bnodePattern`. */ -export function date(value: Date | string): SparqlValue { - const dateObj = value instanceof Date ? value : new Date(value) - return wrapSparqlValue(formatDate(dateObj)) -} +export type BnodeProps = Record /** - * Create an xsd:dateTime literal (full timestamp with time). - * - * Use this when you need both date and time. The format includes milliseconds - * and timezone information. - * - * @example dateTime(new Date()) → "2024-01-15T10:30:00.000Z"^^xsd:dateTime + * Allowed values for blank node properties: + * + * - Single scalar term (string/number/boolean/Date/SparqlValue/null/undefined). + * - Arrays or other iterables → become **object lists**: + * `predicate v1 , v2 , v3`. + * - Nested property objects → become nested `[ ... ]` blank nodes. */ -export function dateTime(value: Date | string): SparqlValue { - const dateObj = value instanceof Date ? value : new Date(value) - return wrapSparqlValue(formatDateTime(dateObj)) -} +export type BnodePropValue = + | SparqlInterpolatable + | Iterable /** - * Create an xsd:integer literal. - * - * Explicitly marks a number as an integer. Throws if you pass a non-integer value. - * Most of the time you can just use {@link num} instead, which picks the right - * type automatically. - * - * @throws {Error} If value is not an integer + * Internal: check for a "plain" object (not Date, not SparqlValue, etc.). */ -export function integer(value: number): SparqlValue { - if (!Number.isInteger(value)) { - throw new Error(`Expected integer, got: ${value}`) +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false } - return wrapSparqlValue( - `"${value}"^^` - ) + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null } /** - * Create an xsd:decimal literal. - * - * Explicitly marks a number as a decimal. Good for currency or precise measurements - * where you want decimal semantics rather than floating point. - * - * @throws {Error} If value is not finite (NaN or Infinity) + * Normalise a property key into a predicate lexeme. + * + * Rules: + * - `<...>` → used as-is (already an IRI). + * - `http://...` / `https://...` → wrapped as `<...>`. + * - Anything with `:` → treated as a prefixed name (e.g. `foaf:name`). + * - Everything else → treated as `:${localName}` (assumes a default `:` prefix). */ -export function decimal(value: number): SparqlValue { - if (!Number.isFinite(value)) { - throw new Error(`Expected finite number, got: ${value}`) +function toPredicateName(key: string): string { + if (key.startsWith('<') && key.endsWith('>')) { + return key } - return wrapSparqlValue( - `"${value}"^^` - ) -} + if (key.startsWith('http://') || key.startsWith('https://')) { + return `<${key}>` + } -/** - * Create a numeric literal (integer or decimal). - * - * Picks the right type automatically based on whether the number is an integer - * or has a fractional part. This is what the automatic conversion uses. - */ -export function num(value: number): SparqlValue { - if (Number.isInteger(value)) { - return integer(value) + if (key.includes(':')) { + return key } - return decimal(value) + // Fallback: assume a default ":" prefix is bound. + return `:${key}` } /** - * Create a boolean literal (true or false). - * - * Boolean values in SPARQL are written as bare keywords, not quoted strings. + * Create an inline blank node *pattern* using the `[ ... ]` syntax. + * + * This is syntactic sugar for: + * + * ```sparql + * [ predicate1 object1 ; + * predicate2 object2 , object3 ; + * ... + * ] + * ``` + * + * Rules: + * - Plain values (string/number/boolean/Date/etc.) become single objects: + * `predicate value`. + * - Arrays / other iterables become **object lists**: + * `predicate v1 , v2 , v3`. + * - Nested plain objects become **nested blank nodes**: + * `predicate [ ...nested props... ]`. + * + * @throws {Error} For empty property maps. + * @throws {Error} If a property has an empty iterable. + * + * @example Simple blank node pattern + * ```ts + * const personPattern = bnodePattern({ + * 'foaf:name': 'Alice', + * 'foaf:age': 30, + * }) + * + * const query = sparql` + * INSERT DATA { + * ?person ${personPattern} . + * } + * ` + * // => ?person [ foaf:name "Alice" ; foaf:age 30 ] . + * ``` + * + * @example Multi-valued predicate + * ```ts + * const nicknames = bnodePattern({ + * 'foaf:nick': ['Peter', 'Spidey'], + * }) + * + * // [ foaf:nick "Peter" , "Spidey" ] + * ``` + * + * @example Nested blank node + * ```ts + * const addressPattern = bnodePattern({ + * 'schema:postalAddress': { + * 'schema:streetAddress': '123 Main St', + * 'schema:addressLocality': 'Metropolis', + * }, + * }) + * + * // [ schema:postalAddress + * // [ schema:streetAddress "123 Main St" ; + * // schema:addressLocality "Metropolis" + * // ] + * // ] + * ``` */ -export function boolean(value: boolean): SparqlValue { - return wrapSparqlValue(String(!!value)) -} +export function bnodePattern(props: BnodeProps): SparqlValue { + const entries = Object.entries(props) -/** - * Short alias for {@link boolean}. - */ -export function bool(value: boolean): SparqlValue { - return boolean(value) -} + if (entries.length === 0) { + throw new Error('Cannot create blank node pattern from an empty object') + } -/** - * 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): SparqlValue { - return wrapSparqlValue(`"""${escapeString(value)}"""@${tag}`) -} + const propertyFragments: string[] = [] -/** - * Create a typed literal with custom datatype. - * - * For when you need a specific datatype that isn't covered by the standard helpers. - * The datatype must be a full IRI. - * - * @example typed('custom value', 'http://example.org/datatype') - */ -export function typed(value: string, datatype: DatatypeIRI): SparqlValue { - validateIRI(datatype) - return wrapSparqlValue( - `"""${escapeString(value)}"""^^<${datatype}>` - ) -} + for (const [rawKey, rawVal] of entries) { + const predicate = toPredicateName(rawKey) -/** - * Create an xsd:string literal. - * - * Explicitly creates a string literal. This is what the automatic conversion uses - * for string values. - */ -export function strlit(value: string): SparqlValue { - return typed(value, "http://www.w3.org/2001/XMLSchema#string") -} + if (rawVal === null || rawVal === undefined) { + throw new Error( + `Property "${rawKey}" is null/undefined in bnodePattern(). Omit it or model absence with OPTIONAL patterns instead.` + ) + } -/** - * Insert raw SPARQL without any escaping. - * - * ⚠️ DANGEROUS: This bypasses all safety checks. Only use this when you have - * complete control over the input and you're certain it's safe. Prefer the - * type-safe helpers whenever possible. - * - * @example raw('?person foaf:name ?name') → ?person foaf:name ?name - */ -export function raw(value: string): SparqlValue { - return wrapSparqlValue(value) + // Nested blank-node via plain object. + if ( + isPlainObject(rawVal) && + !isSparqlValue(rawVal) && + !(rawVal instanceof Date) + ) { + const nested = bnodePattern(rawVal as BnodeProps) + propertyFragments.push(`${predicate} ${nested.value}`) + continue + } + + // Multi-valued via iterable / array. + if (Array.isArray(rawVal) || isNonStringIterable(rawVal)) { + const objects: string[] = [] + + for (const item of rawVal as Iterable) { + objects.push(convertValue(item)) + } + + if (objects.length === 0) { + throw new Error( + `Property "${rawKey}" has an empty iterable in bnodePattern().` + ) + } + + propertyFragments.push(`${predicate} ${objects.join(' , ')}`) + continue + } + + // Single scalar value. + propertyFragments.push( + `${predicate} ${convertValue(rawVal as SparqlInterpolatable)}` + ) + } + + return raw(`[ ${propertyFragments.join(' ; ')} ]`) } // ============================================================================ @@ -740,52 +1144,56 @@ export function raw(value: string): SparqlValue { /** * Main template tag for building SPARQL queries. - * - * This is the primary way to construct queries. It automatically converts - * interpolated values to proper SPARQL syntax and handles indentation. - * - * Use this whenever you're writing SPARQL. The automatic conversions handle - * the tedious escaping work, and the template literal format keeps your queries - * readable. - * - * @example Basic query + * + * It: + * - Interpolates values using `convertValue` (scalars) or lets you insert + * richer fragments using `SparqlValue` helpers (`raw`, `valuesList`, etc.). + * - Dedents/normalises indentation using `outdent`. + * + * Because `SparqlInterpolatable` deliberately excludes arrays/objects, you are + * guided towards the explicit helpers for composite structures. + * + * @example Basic query with scalars * ```ts - * const name = "Peter Parker"; - * const minAge = 18; - * + * const name = 'Peter Parker' + * const minAge = 18 + * * const query = sparql` * SELECT ?person WHERE { * ?person foaf:name ${name} ; - * foaf:age ?age . + * foaf:age ?age . * FILTER(?age >= ${minAge}) * } - * `; + * ` + * // ?person foaf:name "Peter Parker" ; + * // foaf:age ?age . + * // FILTER(?age >= 18) * ``` - * - * @example With arrays + * + * @example VALUES with an array (via helper) * ```ts - * const cities = ["London", "Paris", "Tokyo"]; - * + * const cities = ['London', 'Paris', 'Tokyo'] + * * const query = sparql` * SELECT * WHERE { - * VALUES ?city { ${cities} } + * VALUES ?city { ${valuesList(cities)} } * ?place schema:name ?city . * } - * `; + * ` * ``` - * - * @example With nested values + * + * @example Blank-node pattern * ```ts - * const person = { + * const person = bnodePattern({ * 'foaf:name': 'Alice', - * 'foaf:age': 30 - * }; - * - * const query = sparql` + * 'foaf:age': 30, + * }) + * + * const insert = sparql` * INSERT DATA { - * ?person ${person} + * ?person ${person} . * } - * `; + * ` * ``` */ export function sparql( @@ -795,15 +1203,20 @@ export function sparql( let result = strings[0] for (let i = 0; i < values.length; i++) { - result += convertValue(values[i]) + const value = values[i] + + // SparqlValue fragments are injected as-is. + if (isSparqlValue(value)) { + result += value.value + } else { + // Everything else must be a scalar and go through convertValue. + result += convertValue(value) + } + result += strings[i + 1] } - return wrapSparqlValue(outdent.string(result)) + return raw(outdent.string(result)) } -// ============================================================================ -// Re-exports -// ============================================================================ - export default sparql \ No newline at end of file diff --git a/update.ts b/update.ts index 16837c1..47060cd 100644 --- a/update.ts +++ b/update.ts @@ -34,7 +34,7 @@ * @module */ -import { raw, sparql, type SparqlValue } from './sparql.ts' +import { raw, sparql, SPARQL_VALUE_BRAND, type SparqlValue } from './sparql.ts' import { createExecutor, type BindingMap, type ExecutionConfig, type QueryResult } from './executor.ts' // ============================================================================ @@ -289,7 +289,7 @@ export class UpdateBuilder { return new UpdateBuilder({ operations: [ ...this.state.operations, - { type: 'LOAD', data: { __sparql: true, value: `<${url}>` }, graph, silent } + { type: 'LOAD', data: { [SPARQL_VALUE_BRAND]: true, value: `<${url}>` }, graph, silent } ] }) } diff --git a/utils.ts b/utils.ts index e84d5d9..66d3f43 100644 --- a/utils.ts +++ b/utils.ts @@ -1,14 +1,20 @@ /** * SPARQL expression helpers and query utilities. * - * Building complex SPARQL expressions by hand is tedious. You end up with lots of - * string concatenation that's hard to read and easy to mess up. These helpers let - * you build expressions programmatically, like you would with Drizzle or other - * query builders. + * These helpers build SPARQL expressions programmatically with proper escaping + * for data values and validation for syntax elements. * - * The functions here mirror SPARQL's built-in operations but give you type safety - * and composability. Need to filter by age and check a regex? Combine `and()` with - * `gte()` and `regex()`. Want to create computed fields? Use `bind()` with `concat()`. + * ## 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 */ @@ -21,10 +27,10 @@ import { strlit, validateVariableName, variable, - wrapSparqlValue, + SPARQL_VALUE_BRAND, type VariableName, + type SparqlValue, type SparqlInterpolatable, - type SparqlValue } from './sparql.ts' // ============================================================================ @@ -58,7 +64,7 @@ export function values( validateVariableName(_var) const converted = items.map((item) => convertValue(item)).join(' ') - return wrapSparqlValue(`VALUES ?${_var} { ${converted} }`) + return raw(`VALUES ?${_var} { ${converted} }`) } /** @@ -84,7 +90,7 @@ export function values( * ``` */ export function filter(expression: SparqlValue): SparqlValue { - return wrapSparqlValue(`FILTER(${expression.value})`) + return raw(`FILTER(${expression.value})`) } /** @@ -109,7 +115,7 @@ export function filter(expression: SparqlValue): SparqlValue { * ``` */ export function optional(pattern: SparqlValue): SparqlValue { - return wrapSparqlValue(`OPTIONAL { ${pattern.value} }`) + return raw(`OPTIONAL { ${pattern.value} }`) } /** @@ -132,13 +138,45 @@ export function optional(pattern: SparqlValue): SparqlValue { * ``` */ export function bind(expression: SparqlValue, varName?: VariableName): SparqlValue { - if (!varName) return wrapSparqlValue(`BIND(${expression.value})`); + if (!varName) return raw(`BIND(${expression.value})`); const _varName = normalizeVariableName(varName) validateVariableName(_varName) - return wrapSparqlValue(`BIND(${expression.value} AS ?${_varName})`) + return raw(`BIND(${expression.value} AS ?${_varName})`) +} + +/** + * 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 { + 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 { + return raw(`NOT EXISTS { ${pattern.value} }`) } + // ============================================================================ // Expression Helpers // ============================================================================ @@ -158,11 +196,13 @@ export type ExpressionPrimitive = | undefined /** - * Convert a value to a SparqlValue for use in expressions. + * Convert a value to SPARQL for use in expressions. * - * This is an internal helper that ensures values are properly formatted for - * SPARQL expressions. You usually don't need to call this directly since the - * other helpers call it for you. + * - 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. */ export function exprTerm( value: SparqlValue | ExpressionPrimitive, @@ -170,14 +210,11 @@ export function exprTerm( if (isSparqlValue(value)) { return value } - return wrapSparqlValue(convertValue(value)) + return raw(convertValue(value)) } /** * Get the raw SPARQL string for a value. - * - * Internal helper for converting values to strings that can be embedded in - * larger expressions. */ export function exprTermString( value: SparqlValue | ExpressionPrimitive, @@ -201,12 +238,6 @@ export function exprTermString( * concat(v('firstName'), ' ', v('lastName')) * // CONCAT(?firstName, " ", ?lastName) * ``` - * - * @example Label with prefix - * ```ts - * concat('Issue #', v('issueNumber')) - * // CONCAT("Issue #", ?issueNumber) - * ``` */ export function concat( ...args: Array @@ -215,7 +246,7 @@ export function concat( return fluent(strlit('')) } - const inner = args.map(exprTermString).join(', ') + const inner = args.map(a => exprTermString(a)).join(', ') return fluent(raw(`CONCAT(${inner})`)) } @@ -289,6 +320,14 @@ export function startsWith( ) } +/** Alias for {@link startsWith} (matches SPARQL function name). */ +export function strstarts( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return startsWith(text, pattern) +} + /** * Check if string ends with a suffix. * @@ -303,6 +342,47 @@ export function endsWith( ) } +/** Alias for {@link endsWith} (matches SPARQL function name). */ +export function strends( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return endsWith(text, pattern) +} + +/** + * 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') + * ``` + */ +export function regex( + text: SparqlValue | ExpressionPrimitive, + pattern: string, + flags?: string, +): SparqlValue { + 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. * @@ -326,13 +406,15 @@ export function substr( start: SparqlValue | ExpressionPrimitive, length?: SparqlValue | ExpressionPrimitive, ): FluentValue { - const t = exprTermString(text) - const s = exprTermString(start) - if (length === undefined) { - return fluent(raw(`SUBSTR(${t}, ${s})`)) + const textStr = exprTermString(text) + const startStr = exprTermString(start) + + if (length !== undefined) { + const lengthStr = exprTermString(length) + return fluent(raw(`SUBSTR(${textStr}, ${startStr}, ${lengthStr})`)) } - const l = exprTermString(length) - return fluent(raw(`SUBSTR(${t}, ${s}, ${l})`)) + + return fluent(raw(`SUBSTR(${textStr}, ${startStr})`)) } /** @@ -359,11 +441,16 @@ export function replaceStr( replacement: SparqlValue | ExpressionPrimitive, flags?: string, ): FluentValue { - const textTerm = exprTermString(text) - const patternTerm = exprTermString(pattern) - const replacementTerm = exprTermString(replacement) - const flagsTerm = flags ? `, ${exprTermString(flags)}` : '' - return fluent(raw(`REPLACE(${textTerm}, ${patternTerm}, ${replacementTerm}${flagsTerm})`)) + 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})`)) } /** @@ -420,153 +507,10 @@ export function strAfter( return fluent(raw(`STRAFTER(${textTerm}, ${matchTerm})`)) } -/** - * 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') - * ``` - */ -export function regex( - text: SparqlValue | ExpressionPrimitive, - pattern: string, - flags?: string, -): SparqlValue { - const textTerm = exprTermString(text) - const patternTerm = exprTermString(pattern) - const flagsTerm = flags ? `, ${exprTermString(flags)}` : '' - return raw(`REGEX(${textTerm}, ${patternTerm}${flagsTerm})`) -} - -// ============================================================================ -// Nullability & List Operations -// ============================================================================ - -/** - * 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'))) - * // FILTER(!BOUND(?email)) - * ``` - */ -export function isNull( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`!BOUND(${exprTermString(value)})`) -} - -/** - * Check if a variable is bound (not null). - * - * Opposite of isNull - checks if a variable has a value. - */ -export function isNotNull( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`BOUND(${exprTermString(value)})`) -} - -/** - * 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']) - * // ?publisher IN ("Marvel", "DC Comics", "Image") - * ``` - */ -export function inList( - expr: SparqlValue | ExpressionPrimitive, - values: Array, -): SparqlValue { - if (values.length === 0) { - return raw('false') - } - const list = values.map(exprTermString).join(', ') - return raw(`${exprTermString(expr)} IN (${list})`) -} - -/** - * 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( - expr: SparqlValue | ExpressionPrimitive, - values: Array, -): SparqlValue { - if (values.length === 0) { - return raw('true') - } - const list = values.map(exprTermString).join(', ') - return raw(`${exprTermString(expr)} NOT IN (${list})`) -} - -/** - * 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) - * // (?age >= 18 && ?age <= 65) - * ``` - */ -export function between( - expr: SparqlValue | ExpressionPrimitive, - low: SparqlValue | ExpressionPrimitive, - high: SparqlValue | ExpressionPrimitive, -): SparqlValue { - const e = exprTermString(expr) - const l = exprTermString(low) - const h = exprTermString(high) - return raw(`(${e} >= ${l} && ${e} <= ${h})`) -} - -/** - * 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')) - * // COALESCE(?preferredLabel, ?commonLabel, "Unnamed") - * ``` - */ -export function coalesce( - ...values: Array -): FluentValue { - if (values.length === 0) { - return fluent(strlit('')) - } - const inner = values.map(exprTermString).join(', ') - return fluent(raw(`COALESCE(${inner})`)) -} - /** * Conditional expression (ternary operator). * - * Like JavaScript's condition ? whenTrue : whenFalse. Evaluates the condition + * Like JavaScript's `condition ? whenTrue : whenFalse`. Evaluates the condition * and returns one of two values based on the result. * * @example Adult vs minor @@ -711,6 +655,67 @@ export function lte( return raw(`${exprTermString(left)} <= ${exprTermString(right)}`) } +// ============================================================================ +// Type Checking Functions +// ============================================================================ + +/** + * 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'))) + * // FILTER(!BOUND(?email)) + * ``` + */ +export function isNull( + value: SparqlValue, +): SparqlValue { + return raw(`!BOUND(${value.value})`) +} + +/** + * Check if a variable is bound (not null). + * + * Opposite of isNull - checks if a variable has a value. + */ +export function isNotNull( + value: SparqlValue, +): SparqlValue { + return raw(`BOUND(${value.value})`) +} + +/** Check if a variable is bound. Basically the same thing as {@link isNotNull} */ +export function bound( + variable: SparqlValue, +): SparqlValue { + return raw(`BOUND(${variable.value})`) +} + +/** Check if a term is an IRI. */ +export function isIri( + term: SparqlValue, +): SparqlValue { + return raw(`isIRI(${term.value})`) +} + +/** Check if a term is a blank node. */ +export function isBlank( + term: SparqlValue, +): SparqlValue { + return raw(`isBlank(${term.value})`) +} + +/** Check if a term is a literal. */ +export function isLiteral( + term: SparqlValue, +): SparqlValue { + return raw(`isLiteral(${term.value})`) +} + // ============================================================================ // Logical Operations // ============================================================================ @@ -763,120 +768,98 @@ export function or( } /** - * Negate a condition. + * Negate a condition. + * + * Flips true to false and false to true. + */ +export function not(condition: SparqlValue): SparqlValue { + return raw(`!(${condition.value})`) +} + +// ============================================================================ +// List Operations +// ============================================================================ + +/** + * 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']) + * // ?publisher IN ("Marvel", "DC Comics", "Image") + * ``` + */ +export function inList( + expr: SparqlValue | ExpressionPrimitive, + values: Array, +): SparqlValue { + if (values.length === 0) { + return raw('false') + } + const list = values.map(exprTermString).join(', ') + return raw(`${exprTermString(expr)} IN (${list})`) +} + +/** + * Check if a value is not in a list. * - * Flips true to false and false to true. + * Opposite of inList - returns true if the value doesn't match any list item. */ -export function not(condition: SparqlValue): SparqlValue { - return raw(`!(${condition.value})`) +export function notInList( + expr: SparqlValue | ExpressionPrimitive, + values: Array, +): SparqlValue { + if (values.length === 0) { + return raw('true') + } + const list = values.map(exprTermString).join(', ') + return raw(`${exprTermString(expr)} NOT IN (${list})`) } /** - * Check if a pattern exists in the data. + * Check if a value is in a range. * - * 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. + * Shorthand for value >= low AND value <= high. Both bounds are inclusive. * - * @example Has any email + * @example Age range * ```ts - * exists(triple('?person', 'foaf:email', '?anyEmail')) - * // EXISTS { ?person foaf:email ?anyEmail } + * between(v('age'), 18, 65) + * // (?age >= 18 && ?age <= 65) * ``` */ -export function exists(pattern: SparqlValue): SparqlValue { - return raw(`EXISTS { ${pattern.value} }`) +export function between( + expr: SparqlValue | ExpressionPrimitive, + low: SparqlValue | ExpressionPrimitive, + high: SparqlValue | ExpressionPrimitive, +): SparqlValue { + const exprTerm = exprTermString(expr) + const lowTerm = exprTermString(low) + const highTerm = exprTermString(high) + return raw(`(${exprTerm} >= ${lowTerm} && ${exprTerm} <= ${highTerm})`) } /** - * Check if a pattern does not exist in the data. + * Return first non-null value from a list. * - * Opposite of EXISTS - returns true if the pattern has no matches. + * Like SQL's COALESCE. Evaluates arguments left-to-right and returns the first + * one that's bound. Useful for providing fallback values. * - * @example No email address + * @example Fallback label * ```ts - * notExists(triple('?person', 'foaf:email', '?email')) - * // NOT EXISTS { ?person foaf:email ?email } + * coalesce(v('preferredLabel'), v('commonLabel'), strlit('Unnamed')) + * // COALESCE(?preferredLabel, ?commonLabel, "Unnamed") * ``` */ -export function notExists(pattern: SparqlValue): SparqlValue { - return raw(`NOT EXISTS { ${pattern.value} }`) -} - -// ============================================================================ -// RDF Term Type Tests -// ============================================================================ - -/** Check if a term is an IRI. */ -export function isIri( - term: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`isIRI(${exprTermString(term)})`) -} - -/** Check if a term is a blank node. */ -export function isBlank( - term: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`isBlank(${exprTermString(term)})`) -} - -/** Check if a term is a literal. */ -export function isLiteral( - term: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`isLiteral(${exprTermString(term)})`) -} - -/** Check if a variable is bound. */ -export function bound( - variable: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`BOUND(${exprTermString(variable)})`) -} - -/** Get the language tag of a literal. */ -export function getlang( - literal: SparqlValue | ExpressionPrimitive, -): FluentValue { - return fluent(raw(`LANG(${exprTermString(literal)})`)) -} - -/** Get the datatype IRI of a literal. */ -export function datatype( - literal: SparqlValue | ExpressionPrimitive, +export function coalesce( + ...values: Array ): FluentValue { - return fluent(raw(`DATATYPE(${exprTermString(literal)})`)) -} - -/** Alias for {@link startsWith} (matches SPARQL function name). */ -export function strstarts( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return startsWith(text, pattern) -} - -/** Alias for {@link endsWith} (matches SPARQL function name). */ -export function strends( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return endsWith(text, pattern) -} - -// ============================================================================ -// Aggregation Functions -// ============================================================================ - -/** - * 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. - */ -export interface AggregationExpression extends SparqlValue { - as(variable: string): SparqlValue + if (values.length === 0) { + return fluent(strlit('')) + } + const inner = values.map(exprTermString).join(', ') + return fluent(raw(`COALESCE(${inner})`)) } // ============================================================================ @@ -922,14 +905,14 @@ export interface FluentValue extends SparqlValue { lte(other: SparqlValue | ExpressionPrimitive): SparqlValue gt(other: SparqlValue | ExpressionPrimitive): SparqlValue gte(other: SparqlValue | ExpressionPrimitive): SparqlValue - + // Arithmetic operators add(other: SparqlValue | ExpressionPrimitive): FluentValue sub(other: SparqlValue | ExpressionPrimitive): FluentValue mul(other: SparqlValue | ExpressionPrimitive): FluentValue div(other: SparqlValue | ExpressionPrimitive): FluentValue mod(other: SparqlValue | ExpressionPrimitive): FluentValue - + // String functions concat(...others: Array): FluentValue contains(substring: SparqlValue | ExpressionPrimitive): SparqlValue @@ -943,7 +926,7 @@ export interface FluentValue extends SparqlValue { replace(pattern: SparqlValue | ExpressionPrimitive, replacement: SparqlValue | ExpressionPrimitive, flags?: string): FluentValue strBefore(match: SparqlValue | ExpressionPrimitive): FluentValue strAfter(match: SparqlValue | ExpressionPrimitive): FluentValue - + // Type checking isNull(): SparqlValue isNotNull(): SparqlValue @@ -951,18 +934,18 @@ export interface FluentValue extends SparqlValue { isBlank(): SparqlValue isLiteral(): SparqlValue bound(): SparqlValue - + // Logical operators and(other: SparqlValue): SparqlValue or(other: SparqlValue): SparqlValue not(): SparqlValue - + // Math functions abs(): FluentValue round(): FluentValue ceil(): FluentValue floor(): FluentValue - + // Utility as(variable: VariableName): SparqlValue } @@ -990,7 +973,7 @@ export interface FluentValue extends SparqlValue { export function fluent(value: SparqlValue): FluentValue { const result: FluentValue = { ...value, - + // Comparison operators eq: (other) => eq(result, other), neq: (other) => neq(result, other), @@ -998,14 +981,14 @@ export function fluent(value: SparqlValue): FluentValue { lte: (other) => lte(result, other), gt: (other) => gt(result, other), gte: (other) => gte(result, other), - + // Arithmetic operators (return FluentValue for chaining) add: (other) => fluent(add(result, other)), sub: (other) => fluent(sub(result, other)), mul: (other) => fluent(mul(result, other)), div: (other) => fluent(div(result, other)), mod: (other) => fluent(mod(result, other)), - + // String functions concat: (...others) => fluent(concat(result, ...others)), contains: (substring) => contains(result, substring), @@ -1019,7 +1002,7 @@ export function fluent(value: SparqlValue): FluentValue { replace: (pattern, replacement, flags) => fluent(replaceStr(result, pattern, replacement, flags)), strBefore: (match) => fluent(strBefore(result, match)), strAfter: (match) => fluent(strAfter(result, match)), - + // Type checking isNull: () => isNull(result), isNotNull: () => isNotNull(result), @@ -1027,32 +1010,29 @@ export function fluent(value: SparqlValue): FluentValue { isBlank: () => isBlank(result), isLiteral: () => isLiteral(result), bound: () => bound(result), - + // Logical operators and: (other) => and(result, other), or: (other) => or(result, other), not: () => not(result), - + // Math functions abs: () => fluent(abs(result)), round: () => fluent(round(result)), ceil: () => fluent(ceil(result)), floor: () => fluent(floor(result)), - + // Utility as: (variable) => { const varName = normalizeVariableName(variable) + validateVariableName(varName) return raw(`${value.value} AS ?${varName}`) } } - + return result } -// ============================================================================ -// Enhanced Value Creation (Fluent API) -// ============================================================================ - /** * Create a fluent variable reference. * @@ -1094,22 +1074,52 @@ export function v(name: string): FluentValue { return fluent(variable(name)) } +/** Get the language tag of a literal. */ +export function getlang( + literal: SparqlValue | ExpressionPrimitive, +): FluentValue { + return fluent(raw(`LANG(${exprTermString(literal)})`)) +} + +/** Get the datatype IRI of a literal. */ +export function datatype( + literal: SparqlValue | ExpressionPrimitive, +): FluentValue { + return fluent(raw(`DATATYPE(${exprTermString(literal)})`)) +} + +// ============================================================================ +// Aggregation +// ============================================================================ + +/** + * 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. + */ +export interface AggregationExpression extends SparqlValue { + as(variable: string): SparqlValue +} + /** * Internal helper to create aggregation expressions. */ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPrimitive): AggregationExpression { const exprStr = expr ? exprTermString(expr) : '*' const baseValue = `${sparqlFunc}(${exprStr})` - + const result: AggregationExpression = { - __sparql: true, + [SPARQL_VALUE_BRAND]: true, value: baseValue, as(variable: string): SparqlValue { const varName = normalizeVariableName(variable) + validateVariableName(varName) return raw(`${baseValue} AS ?${varName}`) } } - + return result } @@ -1152,18 +1162,9 @@ export function countDistinct( expr: SparqlValue | ExpressionPrimitive, ): AggregationExpression { const exprStr = exprTermString(expr) - const baseValue = `COUNT(DISTINCT ${exprStr})` - - const result: AggregationExpression = { - __sparql: true, - value: baseValue, - as(variable: string): SparqlValue { - const varName = normalizeVariableName(variable) - return raw(`${baseValue} AS ?${varName}`) - } - } - - return result + const baseValue = `DISTINCT ${exprStr}` + + return count(baseValue) } /** @@ -1204,6 +1205,18 @@ export function max( return createAggregation('MAX', expr) } +/** + * 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. + */ +export function sample( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('SAMPLE', expr) +} + /** * Concatenate values into a single string. * @@ -1221,31 +1234,11 @@ export function groupConcat( separator?: string, ): AggregationExpression { const exprStr = exprTermString(expr) - const sepStr = separator ? `; separator=${exprTermString(separator)}` : '' - const baseValue = `GROUP_CONCAT(${exprStr}${sepStr})` - - const result: AggregationExpression = { - __sparql: true, - value: baseValue, - as(variable: string): SparqlValue { - const varName = normalizeVariableName(variable) - return raw(`${baseValue} AS ?${varName}`) - } - } - - return result -} + const baseValue = separator + ? `${exprStr}; SEPARATOR=${exprTermString(separator)}` + : exprStr -/** - * 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. - */ -export function sample( - expr: SparqlValue | ExpressionPrimitive, -): AggregationExpression { - return createAggregation('SAMPLE', expr) + return createAggregation('GROUP_CONCAT', baseValue) } // ============================================================================ @@ -1289,7 +1282,7 @@ export function graph( pattern: SparqlValue ): SparqlValue { let graphRef: string - + if (typeof graphIri === 'string') { if (graphIri.startsWith('?')) { graphRef = graphIri @@ -1299,48 +1292,14 @@ export function graph( } else { graphRef = graphIri.value } - - return wrapSparqlValue(`GRAPH ${graphRef} { ${pattern.value} }`) + + return raw(`GRAPH ${graphRef} { ${pattern.value} }`) } // ============================================================================ // Special Values and Functions // ============================================================================ -/** - * Create a blank node. - * - * BNODE() generates a fresh blank node. Each call creates a distinct blank node. - * You can optionally provide an ID for stable blank node generation within a query. - * - * @param id Optional blank node identifier - * - * @example Generate fresh blank node - * ```ts - * bind(bnode(), 'restriction') - * // BIND(BNODE() AS ?restriction) - * ``` - * - * @example Stable blank node - * ```ts - * triple(bnode('b1'), 'rdf:type', 'owl:Restriction') - * // _:b1 rdf:type owl:Restriction . - * ``` - * - * @example Use in CONSTRUCT - * ```ts - * construct(triple(bnode(), 'ex:property', '?value')) - * .where(triple('?s', 'ex:property', '?value')) - * // Creates fresh blank nodes for each result - * ``` - */ -export function bnode(id?: string): SparqlValue { - if (id) { - return wrapSparqlValue(`_:${id}`) - } - return wrapSparqlValue('BNODE()') -} - /** * Unbound variable placeholder. * @@ -1371,7 +1330,7 @@ export function bnode(id?: string): SparqlValue { * ``` */ export function undef(): SparqlValue { - return wrapSparqlValue('?UNDEF') + return raw('?UNDEF') } // ============================================================================ @@ -1402,7 +1361,7 @@ export function undef(): SparqlValue { */ export function zeroOrMore(property: string | SparqlValue): SparqlValue { const prop = typeof property === 'string' ? property : property.value - return wrapSparqlValue(`${prop}*`) + return raw(`${prop}*`) } /** @@ -1428,7 +1387,7 @@ export function zeroOrMore(property: string | SparqlValue): SparqlValue { */ export function oneOrMore(property: string | SparqlValue): SparqlValue { const prop = typeof property === 'string' ? property : property.value - return wrapSparqlValue(`${prop}+`) + return raw(`${prop}+`) } /** @@ -1449,7 +1408,7 @@ export function oneOrMore(property: string | SparqlValue): SparqlValue { */ export function zeroOrOne(property: string | SparqlValue): SparqlValue { const prop = typeof property === 'string' ? property : property.value - return wrapSparqlValue(`${prop}?`) + return raw(`${prop}?`) } /** @@ -1475,7 +1434,7 @@ export function zeroOrOne(property: string | SparqlValue): SparqlValue { */ export function sequence(...properties: Array): SparqlValue { const props = properties.map(p => typeof p === 'string' ? p : p.value) - return wrapSparqlValue(props.join('/')) + return raw(props.join('/')) } /** @@ -1501,7 +1460,7 @@ export function sequence(...properties: Array): SparqlValu */ export function alternative(...properties: Array): SparqlValue { const props = properties.map(p => typeof p === 'string' ? p : p.value) - return wrapSparqlValue(`(${props.join('|')})`) + return raw(`(${props.join('|')})`) } /** @@ -1526,7 +1485,7 @@ export function alternative(...properties: Array): SparqlV */ export function inverse(property: string | SparqlValue): SparqlValue { const prop = typeof property === 'string' ? property : property.value - return wrapSparqlValue(`^${prop}`) + return raw(`^${prop}`) } /** @@ -1552,7 +1511,7 @@ export function inverse(property: string | SparqlValue): SparqlValue { */ export function negatedPropertySet(...properties: Array): SparqlValue { const props = properties.map(p => typeof p === 'string' ? p : p.value) - return wrapSparqlValue(`!(${props.join('|')})`) + return raw(`!(${props.join('|')})`) } // ============================================================================ @@ -1610,10 +1569,10 @@ export function service( const endpointRef = typeof endpoint === 'string' ? `<${endpoint}>` : endpoint.value - + const silentModifier = silent ? 'SILENT ' : '' - - return wrapSparqlValue(`SERVICE ${silentModifier}${endpointRef} { ${pattern.value} }`) + + return raw(`SERVICE ${silentModifier}${endpointRef} { ${pattern.value} }`) } // ============================================================================ @@ -1661,7 +1620,7 @@ export function service( * ``` */ export function definePrefix(name: string, iri: string): SparqlValue { - return wrapSparqlValue(`PREFIX ${name}: <${iri}>`) + return raw(`PREFIX ${name}: <${iri}>`) } // ============================================================================ @@ -1840,7 +1799,7 @@ export function sha512(value: SparqlValue | ExpressionPrimitive): FluentValue { * ``` */ export function now(): SparqlValue { - return wrapSparqlValue('NOW()') + return raw('NOW()') } /** @@ -1876,7 +1835,7 @@ export function now(): SparqlValue { * ``` */ export function uuid(): SparqlValue { - return wrapSparqlValue('UUID()') + return raw('UUID()') } /** @@ -1955,6 +1914,23 @@ export function rand(): FluentValue { // Additional String Functions // ============================================================================ +/** + * 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})`) +} + +export function strlang(lexical: SparqlValue, lang: string): SparqlValue { + return raw(`STRLANG(${lexical.value}, ${exprTermString(lang)})`) +} + +export function sameTerm(a: SparqlValue, b: SparqlValue): SparqlValue { + return raw(`sameTerm(${a.value}, ${b.value})`) +} + /** * Encode a string for use in a URI. * @@ -2149,5 +2125,5 @@ export function iri(value: SparqlValue | ExpressionPrimitive): SparqlValue { * ``` */ export function minus(pattern: SparqlValue): SparqlValue { - return wrapSparqlValue(`MINUS { ${pattern.value} }`) + return raw(`MINUS { ${pattern.value} }`) } \ No newline at end of file