diff --git a/builder.ts b/builder.ts index 79e34fc..d5eb92e 100644 --- a/builder.ts +++ b/builder.ts @@ -1,22 +1,47 @@ /** - * Drizzle-like query builder for SPARQL + * Fluent query builder for SPARQL. * - * Provides chainable, type-safe query construction: + * 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. * - * @example + * 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. + * + * 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. + * + * @example Basic SELECT query * ```ts * const query = select(['?name', '?age']) * .where(triple('?person', 'foaf:name', '?name')) * .where(triple('?person', 'foaf:age', '?age')) - * .filter('?age > 18') + * .filter(gte(v('age'), 18)) * .orderBy('?name') - * .limit(10); + * .limit(10) * - * const result = await execute(query); + * 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() + * ``` + * + * @module */ - import { sparql, normalizeVariableName, @@ -35,27 +60,28 @@ import { createExecutor, type ExecutorConfig, type SparqlResult } from './execut // ============================================================================ /** - * General “pattern-like” input for WHERE/OPTIONAL/UNION. - * - * For higher-level patterns: - * - cypher-style helpers should return SparqlValue - * - object/nested patterns should usually expose a `.build(): SparqlValue` - * - if you really need, you can pass raw SPARQL strings + * 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 /** - * Query projection (SELECT variables) + * 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[] | '*' /** - * Sort direction + * Sort order for ORDER BY clauses. */ export type SortDirection = 'ASC' | 'DESC' /** - * Sort specification + * Sort specification combining variable and direction. */ export interface SortSpec { readonly variable: string @@ -63,7 +89,12 @@ export interface SortSpec { } /** - * SELECT modifier: mutually exclusive + * 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' @@ -72,17 +103,21 @@ export type SelectModifier = 'none' | 'distinct' | 'reduced' // ============================================================================ /** - * Internal query builder state + * Internal state for the query builder. + * + * 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. */ interface QueryState { readonly type: 'SELECT' | 'ASK' | 'CONSTRUCT' | 'DESCRIBE' readonly projection: Projection readonly from?: string[] readonly where: SparqlValue[] - readonly filters: SparqlValue[] // each is a FILTER(...) SparqlValue - readonly optional: SparqlValue[] // group patterns for OPTIONAL { ... } - readonly bindings: SparqlValue[] // BIND(...) SparqlValue - readonly unions: SparqlValue[][] // each entry is a UNION branch (group) + readonly filters: SparqlValue[] + readonly optional: SparqlValue[] + readonly bindings: SparqlValue[] + readonly unions: SparqlValue[][] readonly sorts: SortSpec[] readonly limit?: number readonly offset?: number @@ -90,7 +125,7 @@ interface QueryState { } /** - * Initial empty state + * Initial empty state for new queries. */ const initialState: QueryState = { type: 'SELECT', @@ -109,13 +144,54 @@ const initialState: QueryState = { // ============================================================================ /** - * SPARQL query builder with chainable methods + * 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 building a SELECT query + * 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({ @@ -126,7 +202,17 @@ export class QueryBuilder { } /** - * Start building an ASK query + * 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({ @@ -137,10 +223,25 @@ export class QueryBuilder { } /** - * Start building a CONSTRUCT query. - * - * NOTE: `template` is the construct template; you still add WHERE patterns - * with .where(). + * 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({ @@ -152,7 +253,18 @@ export class QueryBuilder { } /** - * Start building a DESCRIBE query + * 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[]): QueryBuilder { return new QueryBuilder({ @@ -167,7 +279,20 @@ export class QueryBuilder { // -------------------------------------------------------------------------- /** - * Add FROM clause (graph IRI) + * 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')) + * ``` */ from(graphIRI: string): QueryBuilder { return new QueryBuilder({ @@ -178,13 +303,27 @@ export class QueryBuilder { /** * Add a WHERE pattern. - * - * You pass a SparqlValue that represents one or more triple patterns. - * Typically created with: - * - triple() - * - triples() - * - Node.build() - * - match(), path(), etc. + * + * 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 pattern Pattern 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) + * ``` */ where(pattern: SparqlValue): QueryBuilder { return new QueryBuilder({ @@ -194,21 +333,25 @@ export class QueryBuilder { } /** - * Add a FILTER expression. - * - * You pass a SparqlValue representing the *condition*, and this will wrap - * it using the filter(...) helper from sparql.ts. - * - * @example + * 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 condition Boolean expression + * + * @example Age filter * ```ts - * import { variable, gte, and } from './sparql.ts' - * - * const condition = and( - * gte(variable('age'), 18), - * regex(variable('name'), '^Spidey', 'i'), - * ) - * - * builder.filter(condition) + * query.filter(gte(v('age'), 18)) + * ``` + * + * @example Multiple conditions + * ```ts + * query.filter(and( + * gte(v('age'), 18), + * regex(v('name'), '^Spider', 'i') + * )) * ``` */ filter(condition: SparqlValue): QueryBuilder { @@ -221,18 +364,22 @@ export class QueryBuilder { } /** - * Add an OPTIONAL block. - * - * The pattern is a SparqlValue representing the contents of the OPTIONAL - * block. For example: - * + * 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 pattern Pattern to optionally match + * + * @example Optional email * ```ts - * const opt = triples('?person', [ - * ['foaf:mbox', '?email'], - * ]) - * - * builder.optional(opt) + * query + * .where(triple('?person', 'foaf:name', '?name')) + * .optional(triple('?person', 'foaf:email', '?email')) * ``` + * + * Email will be bound if it exists, unbound otherwise. */ optional(pattern: SparqlValue): QueryBuilder { const optionalPattern = optionalExpr(pattern) @@ -244,16 +391,29 @@ export class QueryBuilder { } /** - * Add a BIND expression. - * - * You pass a SparqlValue representing the expression to compute, and a - * variable name (with or without leading '?'). - * - * @example + * 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 - * const fullNameExpr = concat(variable('firstName'), ' ', variable('lastName')) - * - * builder.bind(fullNameExpr, 'fullName') // or '?fullName' + * query.bind( + * sub(2024, v('birthYear')), + * 'age' + * ) * ``` */ bind(expression: SparqlValue, asVariable: string): QueryBuilder { @@ -267,17 +427,23 @@ export class QueryBuilder { } /** - * Add a UNION group. - * - * Each argument is a SparqlValue representing one branch of the UNION. - * - * @example + * 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 - * builder.union( - * triples('?item', [['rdf:type', 'ex:Comic']]), - * triples('?item', [['rdf:type', 'ex:GraphicNovel']]), + * 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({ @@ -287,9 +453,26 @@ export class QueryBuilder { } /** - * Add ORDER BY clause. - * - * `variable` should include the leading ?, e.g. '?name'. + * 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') + * ``` */ orderBy(variable: string, direction?: SortDirection): QueryBuilder { return new QueryBuilder({ @@ -299,7 +482,17 @@ export class QueryBuilder { } /** - * Add LIMIT clause + * 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) + * ``` */ limit(count: number): QueryBuilder { return new QueryBuilder({ @@ -309,7 +502,16 @@ export class QueryBuilder { } /** - * Add OFFSET clause + * 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) + * ``` */ offset(count: number): QueryBuilder { return new QueryBuilder({ @@ -319,7 +521,16 @@ export class QueryBuilder { } /** - * Use DISTINCT modifier on SELECT. + * 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({ @@ -329,7 +540,11 @@ export class QueryBuilder { } /** - * Use REDUCED modifier on SELECT. + * 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({ @@ -343,7 +558,18 @@ export class QueryBuilder { // -------------------------------------------------------------------------- /** - * Build the final SPARQL query as a SparqlValue. + * 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[] = [] @@ -394,7 +620,7 @@ export class QueryBuilder { parts.push(` ${pattern.value}`) } - // FILTER expressions (already include FILTER(...)) + // FILTER expressions for (const filter of this.state.filters) { parts.push(` ${filter.value}`) } @@ -404,7 +630,7 @@ export class QueryBuilder { parts.push(` ${optional.value}`) } - // BIND expressions (already BIND(...)) + // BIND expressions for (const bind of this.state.bindings) { parts.push(` ${bind.value}`) } @@ -445,7 +671,26 @@ export class QueryBuilder { } /** - * Execute query using the configured executor. + * 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: ExecutorConfig): Promise { const executor = createExecutor(config) @@ -463,14 +708,23 @@ export const construct = QueryBuilder.construct; export const describe = QueryBuilder.describe; /** - * Quick executor shorthand + * 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')), - * { endpoint: 'http://localhost:9999/blazegraph/sparql' } - * ); + * select(['?name']) + * .where(triple('?person', 'foaf:name', '?name')) + * .limit(10), + * { endpoint: 'http://localhost:9999/sparql' } + * ) * ``` */ export function execute( diff --git a/executor.ts b/executor.ts index ac36702..1aa6ef1 100644 --- a/executor.ts +++ b/executor.ts @@ -1,49 +1,62 @@ /** - * Functional SPARQL executor with discriminated union error handling - * - * Provides: - * - Type-safe query execution - * - Discriminated error types - * - Result transformation utilities - * - Label resolution for human-readable output - * - Property fetching for entity details + * SPARQL query execution with type-safe error handling. + * + * Executing SPARQL queries involves network requests that can fail in many ways - timeouts, + * bad syntax, server errors, or network issues. This module provides discriminated error + * types so you can handle each failure mode appropriately. + * + * The result is always a discriminated union: either success with data, or failure with + * a specific error type. This forces you to handle errors explicitly rather than letting + * exceptions bubble up unexpectedly. + * + * Think of this as a type-safe fetch for SPARQL. It handles the HTTP details, parses + * responses, and gives you structured errors when things go wrong. + * + * @module */ import type { SparqlValue } from './sparql.ts' // ============================================================================ -// Configuration Types +// Configuration // ============================================================================ /** - * SPARQL endpoint configuration + * Configuration for SPARQL endpoint connections. + * + * At minimum you need the endpoint URL. Optionally specify timeout and custom + * headers for authentication or other purposes. */ export interface ExecutorConfig { - /** SPARQL endpoint URL */ + /** SPARQL endpoint URL (e.g., http://localhost:9999/sparql) */ readonly endpoint: string /** Query timeout in milliseconds (default: 30000) */ readonly timeout?: number - /** Additional headers to send with requests */ + /** Additional headers for requests (auth, etc.) */ readonly headers?: Record } // ============================================================================ -// Error Types (Discriminated Union) +// Error Types // ============================================================================ /** - * SPARQL error type discriminant + * Specific error type discriminants. * - * - syntax: Malformed SPARQL query (400) - * - timeout: Query execution timeout (AbortError) - * - unavailable: Cannot connect to endpoint (TypeError) - * - database: Database returned 5xx error - * - unknown: Unexpected error + * Each error type represents a different failure mode: + * - syntax: Your SPARQL query has invalid syntax (400 response) + * - timeout: Query took too long and was aborted + * - unavailable: Can't connect to endpoint (network error or 503) + * - database: Server had an internal error (5xx responses) + * - unknown: Something unexpected happened */ export type SparqlErrorType = 'syntax' | 'timeout' | 'unavailable' | 'database' | 'unknown' /** - * SPARQL error details + * Structured error information. + * + * Provides context about what went wrong. The type discriminant tells you what + * kind of error it is, and the details provide additional context. */ export interface SparqlError { readonly type: SparqlErrorType @@ -57,7 +70,10 @@ export interface SparqlError { // ============================================================================ /** - * Raw SPARQL binding value + * Raw binding value from SPARQL results. + * + * This is what the SPARQL endpoint returns for each bound variable. It includes + * type information so you know whether it's an IRI, literal, or blank node. */ export interface SparqlBinding { readonly type: string @@ -67,7 +83,10 @@ export interface SparqlBinding { } /** - * Raw SPARQL query response + * Raw SPARQL JSON results format. + * + * This follows the SPARQL 1.1 Query Results JSON Format specification. Each + * result row is an object mapping variable names to bindings. */ export interface SparqlResponse { readonly results: { @@ -76,7 +95,10 @@ export interface SparqlResponse { } /** - * Successful query result + * Successful query result. + * + * When a query succeeds, you get this. The data is in standard SPARQL JSON + * format. You can transform it with the helper functions or process it directly. */ export interface SparqlSuccess { readonly success: true @@ -84,7 +106,10 @@ export interface SparqlSuccess { } /** - * Failed query result + * Failed query result. + * + * When a query fails, you get structured error information. Check the error + * type to determine how to handle it. */ export interface SparqlFailure { readonly success: false @@ -92,7 +117,23 @@ export interface SparqlFailure { } /** - * Discriminated union of query results + * Discriminated union of query results. + * + * Every query execution returns this type. You check the `success` field to + * determine which variant you have, then TypeScript narrows the type appropriately. + * + * @example Handling results + * ```ts + * const result = await execute(query, config) + * + * if (result.success) { + * // result.data is available + * console.log(result.data.results.bindings) + * } else { + * // result.error is available + * console.error(result.error.type, result.error.message) + * } + * ``` */ export type SparqlResult = SparqlSuccess | SparqlFailure @@ -101,26 +142,44 @@ export type SparqlResult = SparqlSuccess | SparqlFailure // ============================================================================ /** - * Execute SPARQL query against endpoint + * Execute a SPARQL query against an endpoint. * - * @param config Executor configuration - * @param query Query to execute (SparqlValue or string) - * @param overrides Optional config overrides for this query - * @returns Discriminated union result + * Sends the query via HTTP POST and handles the response. Network errors, + * timeouts, and HTTP errors are all converted to structured error types. * - * @example + * The function follows SPARQL 1.1 Protocol conventions: + * - POST request with Content-Type: application/sparql-query + * - Accept: application/sparql-results+json + * - 400 responses indicate syntax errors + * - 5xx responses indicate server errors + * + * @param config Endpoint configuration + * @param query Query to execute + * @param overrides Optional per-query config overrides + * @returns Discriminated result union + * + * @example Basic execution * ```ts * const result = await executeSparql( - * { endpoint: 'http://localhost:9999/blazegraph/sparql' }, + * { endpoint: 'http://localhost:9999/sparql' }, * sparql`SELECT * WHERE { ?s ?p ?o } LIMIT 10` - * ); + * ) * * if (result.success) { - * console.log(result.data); + * console.log(result.data) * } else { - * console.error(result.error.type, result.error.message); + * console.error(result.error.type, result.error.message) * } * ``` + * + * @example With overrides + * ```ts + * const result = await executeSparql( + * { endpoint: 'http://localhost:9999/sparql', timeout: 30000 }, + * query, + * { timeout: 60000 } // Use longer timeout for this query + * ) + * ``` */ export async function executeSparql( config: ExecutorConfig, @@ -136,7 +195,7 @@ export async function executeSparql( ? query : query.value - // Create abort controller for timeout + // Setup timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) @@ -256,23 +315,33 @@ export async function executeSparql( } /** - * Create executor function with pre-configured settings + * Create an executor function with pre-configured settings. + * + * This is useful when you have a fixed endpoint and want to execute multiple + * queries without repeating the configuration. The returned function can still + * accept overrides for individual queries. * * @param config Default executor configuration * @returns Executor function * - * @example + * @example Create reusable executor * ```ts * const execute = createExecutor({ - * endpoint: 'http://localhost:9999/blazegraph/sparql', - * timeout: 30000 - * }); + * endpoint: 'http://localhost:9999/sparql', + * timeout: 30000, + * headers: { 'Authorization': 'Bearer token123' } + * }) * - * const result = await execute(sparql`SELECT * WHERE { ?s ?p ?o } LIMIT 10`); + * // Use it for multiple queries + * const result1 = await execute(query1) + * const result2 = await execute(query2) + * const result3 = await execute(query3, { timeout: 60000 }) // Override for one query * ``` */ -export function createExecutor(config: ExecutorConfig): (query: SparqlValue | string, - overrides?: Partial) => Promise { +export function createExecutor(config: ExecutorConfig): ( + query: SparqlValue | string, + overrides?: Partial +) => Promise { return ( query: SparqlValue | string, overrides?: Partial @@ -286,15 +355,26 @@ export function createExecutor(config: ExecutorConfig): (query: SparqlValue | st // ============================================================================ /** - * Transform SPARQL bindings to simple key-value objects + * Transform SPARQL bindings to simple key-value objects. + * + * The raw SPARQL response format includes type information for each value. + * Often you just want the values themselves. This helper strips the metadata + * and gives you plain objects. * * @param response SPARQL response data * @returns Array of simple objects * * @example * ```ts - * const rows = transformResults(result.data); - * // [{ name: 'Alice', age: '30' }, { name: 'Bob', age: '25' }] + * const result = await execute(query, config) + * if (result.success) { + * const rows = transformResults(result.data) + * // [{ name: 'Alice', age: '30' }, { name: 'Bob', age: '25' }] + * + * for (const row of rows) { + * console.log(row.name, row.age) + * } + * } * ``` */ export function transformResults(response: SparqlResponse): Array> { @@ -308,10 +388,23 @@ export function transformResults(response: SparqlResponse): Array() @@ -332,19 +425,25 @@ export function extractUris(response: SparqlResponse): string[] { // ============================================================================ /** - * Label resolution configuration + * Configuration for resolving human-readable labels. + * + * Often you have URIs but want to display friendly names. Label resolution + * queries the graph for label properties and returns a map of URIs to labels. */ export interface LabelResolutionConfig { /** URIs to resolve labels for */ readonly uris: string[] - /** Label predicates to query (default: narrative predicates + common ones) */ + /** Label predicates to query (defaults to common label properties) */ readonly labelPredicates?: string[] /** Maximum URIs per batch query (default: 50) */ readonly maxBatchSize?: number } /** - * Default label predicates (priority order) + * Default label predicates in priority order. + * + * When resolving labels, we check these properties in order. This includes + * domain-specific labels followed by common vocabularies. */ const DEFAULT_LABEL_PREDICATES = [ 'http://knowledge.graph/narrative#characterName', @@ -356,18 +455,27 @@ const DEFAULT_LABEL_PREDICATES = [ ] /** - * Resolve labels for URIs + * Resolve human-readable labels for URIs. + * + * Queries the graph for label properties on the specified URIs. Returns a map + * where each URI gets an array of labels (there can be multiple if different + * properties have values). + * + * Processes URIs in batches to avoid overwhelming the endpoint with huge queries. * * @param config Executor configuration * @param options Label resolution options - * @returns Map of URI → array of labels + * @returns Map of URI to array of labels * * @example * ```ts - * const labels = await resolveLabels(config, { - * uris: ['http://example.org/person/1', 'http://example.org/person/2'] - * }); - * // Map { 'http://example.org/person/1' => ['Alice', 'Alice Smith'], ... } + * const uris = extractUris(queryResult.data) + * const labels = await resolveLabels(config, { uris }) + * + * for (const uri of uris) { + * const uriLabels = labels.get(uri) + * console.log(uri, uriLabels?.[0] ?? 'No label') + * } * ``` */ export async function resolveLabels( @@ -410,11 +518,24 @@ export async function resolveLabels( } /** - * Get first label for URI (or fallback to URI fragment/path) + * Get the first label for a URI, with fallback. + * + * Returns the first label if available, otherwise extracts a reasonable name + * from the URI itself (fragment or last path segment). * * @param labels Label map from resolveLabels * @param uri URI to get label for * @returns First label or URI fragment + * + * @example + * ```ts + * const labels = await resolveLabels(config, { uris }) + * + * for (const uri of uris) { + * const label = getFirstLabel(labels, uri) ?? uri + * console.log(label) + * } + * ``` */ export function getFirstLabel(labels: Map, uri: string): string | undefined { const uriLabels = labels.get(uri) @@ -432,7 +553,7 @@ export function getFirstLabel(labels: Map, uri: string): strin // ============================================================================ /** - * Property fetching configuration + * Configuration for fetching all properties of resources. */ export interface PropertyFetchConfig { /** URIs to fetch properties for */ @@ -442,18 +563,26 @@ export interface PropertyFetchConfig { } /** - * Fetch all properties for URIs + * Fetch all properties for specified URIs. + * + * Queries the graph for all triples where these URIs are the subject. Returns + * a nested map structure: URI → predicate → array of values. + * + * This is useful when you need to inspect resources in detail or build entity + * detail views. * * @param config Executor configuration * @param options Property fetch options - * @returns Map of URI → Map of predicate → array of values + * @returns Map of URI to map of predicate to array of values * * @example * ```ts - * const properties = await fetchProperties(config, { - * uris: ['http://example.org/person/1'] - * }); - * // Map { 'http://example.org/person/1' => Map { 'foaf:name' => ['Alice'], ... } } + * const uris = ['http://example.org/person/1'] + * const properties = await fetchProperties(config, { uris }) + * + * const person = properties.get(uris[0]) + * const names = person?.get('http://xmlns.com/foaf/0.1/name') + * console.log(names?.[0]) // First name value * ``` */ export async function fetchProperties( @@ -498,4 +627,4 @@ export async function fetchProperties( } return propertyMap -} +} \ No newline at end of file diff --git a/mod.ts b/mod.ts index f4f763a..8a072f7 100644 --- a/mod.ts +++ b/mod.ts @@ -1,36 +1,92 @@ /** - * SPARQL Query Builder + * SPARQL Query Builder - Type-safe graph database queries. * - * A type-safe, fluent query builder for SPARQL inspired by Drizzle ORM and - * Supabase PostgREST.js. Makes graph database queries readable and maintainable. + * Building SPARQL queries by hand is tedious and error-prone. This library gives + * you a fluent, type-safe API inspired by Drizzle ORM and Cypher. Write queries + * that look natural in code, get proper escaping and validation automatically. * - * @example - * ```typescript - * import { node, rel, select, variable, execute } from './mod.ts' + * The library has three layers: * - * const person = node('person', 'foaf:Person') - * .with.prop('foaf:name', variable('name')) - * .and.prop('foaf:age', variable('age')) + * 1. **Core types and values** (sparql.ts) - The foundation for representing SPARQL + * values. Template literal support for automatic type conversion. + * + * 2. **Pattern builders** (triples.ts, objects.ts, cypher.ts) - Higher-level APIs + * for describing graph patterns. Choose between triple-based patterns, object-like + * node descriptions, or ASCII art syntax. + * + * 3. **Query builder** (builder.ts) - Fluent chainable interface for constructing + * complete queries. Add clauses, filters, sorting, pagination. + * + * Start with the pattern style that feels natural for your use case. Simple queries + * might use raw triples. Complex graph structures benefit from node patterns. The + * query builder ties it all together. + * + * @example Quick start + * ```ts + * import { select, triple, v, execute } from './mod.ts' * * const result = await select(['?name', '?age']) - * .where(person) - * .filter(gte(variable('age'), 18)) + * .where(triple('?person', 'foaf:name', '?name')) + * .where(triple('?person', 'foaf:age', '?age')) + * .filter(gte(v('age'), 18)) * .orderBy('?name') * .limit(10) * .execute({ endpoint: 'http://localhost:9999/sparql' }) + * + * if (result.success) { + * console.log(result.data.results.bindings) + * } + * ``` + * + * @example Using node patterns + * ```ts + * import { node, rel, select, v, str } from './mod.ts' + * + * const person = node('person', 'foaf:Person') + * .prop('foaf:name', v('name')) + * .prop('foaf:age', v('age')) + * + * const friend = node('friend', 'foaf:Person') + * .prop('foaf:name', str('Alice')) + * + * const query = select(['?name', '?age']) + * .where(person) + * .where(rel('person', 'foaf:knows', 'friend')) + * .where(friend) + * ``` + * + * @example Complex filtering and aggregation + * ```ts + * import { select, triples, v, and, gte, regex, count } from './mod.ts' + * + * const query = select([count(v('product')).as('total')]) + * .where(triples('?product', [ + * ['rdf:type', 'schema:Product'], + * ['schema:price', '?price'], + * ['schema:name', '?name'] + * ])) + * .filter(and( + * gte(v('price'), 10), + * regex(v('name'), 'Spider', 'i') + * )) * ``` * * @module */ -// ============================================================================ -// Core SPARQL Value Types & Helpers -// ============================================================================ - +// Core SPARQL types and template tag export * from './sparql.ts' + +// Expression helpers and query utilities export * from './utils.ts' + +// Pattern construction helpers export * from './patterns/triples.ts' export * from './patterns/objects.ts' export * from './patterns/cypher.ts' + +// Query builder export * from './builder.ts' + +// Query execution export * from './executor.ts' \ No newline at end of file diff --git a/patterns/cypher.ts b/patterns/cypher.ts index f539ad0..13ff9e0 100644 --- a/patterns/cypher.ts +++ b/patterns/cypher.ts @@ -1,35 +1,79 @@ +/** + * Neo4J like Cypher syntax for graph patterns. + * + * Visual representation of relationships makes queries more intuitive. Instead of + * writing separate node and relationship definitions, you can draw the connections + * with ASCII art arrows. This is inspired by Cypher's visual syntax. + * + * The cypher template tag parses patterns like `node1-[predicate]->node2` and + * generates the appropriate SPARQL triples. It's syntactic sugar - the RDF semantics + * are unchanged, but the code reads more like a diagram of your graph. + * + * ⚠️ Note: This generates standard SPARQL triples. The arrows are just a visual + * aid for writing patterns - they get compiled to subject-predicate-object triples. + * + * @module + */ + import { raw, type SparqlValue } from '../sparql.ts' import { Node } from './objects.ts' -// ============================================================================ -// Approach 3: ASCII Art Pattern (Cypher-like visual) -// ============================================================================ - /** - * APPROACH 3: ASCII Art Pattern + * Create graph patterns using ASCII art syntax. * - * Cypher-inspired visual representation. - * ⚠️ RDF SEMANTICS: This is syntactic sugar. Arrows show triple direction. + * Draw your graph with arrows and brackets. The cypher template tag parses this + * visual representation and generates SPARQL triples. Node objects get substituted + * in and connected according to the arrows. * - * @example - * ```typescript - * const product = node('?product is narrative:Product', { - * 'narrative:releaseDate': '?releaseDate', - * }); + * The syntax supports: + * - `node1-[predicate]->node2` - directed edge from node1 to node2 + * - `node1<-[predicate]-node2` - directed edge from node2 to node1 + * - `node1-[predicate]-node2` - undirected (generates forward direction) * - * const publisher = node('?publisher is narrative:Publisher', { - * 'rdfs:label': str('Marvel'), - * }); + * Under the hood, this extracts the node patterns and creates additional triples + * for the relationships. It's a more readable way to write what would otherwise + * be multiple triple() or rel() calls. * - * const pattern = path`${product}-[narrative:publishedBy]->${publisher}`; + * @example Simple connection + * ```ts + * const product = node('product', 'schema:Product', { + * 'schema:name': v('title') + * }) + * + * const publisher = node('publisher', 'schema:Organization', { + * 'rdfs:label': str('Marvel Comics') + * }) + * + * const pattern = cypher`${product}-[schema:publisher]->${publisher}` + * ``` + * + * Generates: + * ```sparql + * ?product a schema:Product . + * ?product schema:name ?title . + * ?publisher a schema:Organization . + * ?publisher rdfs:label "Marvel Comics" . + * ?product schema:publisher ?publisher . * ``` * - * Create path pattern with ASCII art + * @example Multiple connections + * ```ts + * const person = node('person', 'foaf:Person') + * const friend = node('friend', 'foaf:Person') + * const group = node('group', 'foaf:Group') * - * Supported patterns: - * - `${node1}-[predicate]->${node2}` - directed edge - * - `${node1}<-[predicate]-${node2}` - reverse direction - * - `${node1}-[predicate]-${node2}` - undirected (generates forward) + * const pattern = cypher` + * ${person}-[foaf:knows]->${friend} + * ${person}-[foaf:member]->${group} + * ` + * ``` + * + * @example Reverse direction + * ```ts + * // These are equivalent: + * cypher`${person}-[foaf:knows]->${friend}` + * cypher`${friend}<-[foaf:knows]-${person}` + * ``` */ export function cypher( strings: TemplateStringsArray, @@ -38,6 +82,7 @@ export function cypher( let result = strings[0] const nodes: Node[] = [] + // Substitute node placeholders for (let i = 0; i < values.length; i++) { const value = values[i] @@ -51,17 +96,16 @@ export function cypher( result += strings[i + 1] } - // Parse ASCII art pattern - // Pattern: NODE_0-[predicate]->NODE_1 + // Parse ASCII art patterns const edgePattern = /NODE_(\d+)\s*?\s*NODE_(\d+)/g const triples: string[] = [] - // Add all node triples first + // First, add all node patterns for (const node of nodes) { - triples.push(...node.value) + triples.push(...node.value.split('\n')) } - // Parse edges + // Then parse and add edge patterns let match while ((match = edgePattern.exec(result)) !== null) { const fromIdx = parseInt(match[1]) @@ -75,5 +119,4 @@ export function cypher( } return raw(`${triples.join('\n')}`) -} - +} \ No newline at end of file diff --git a/patterns/objects.ts b/patterns/objects.ts index dc75160..a2813c2 100644 --- a/patterns/objects.ts +++ b/patterns/objects.ts @@ -1,16 +1,19 @@ /** - * Cypher-inspired pattern matching for SPARQL + * Graph pattern matching inspired by Cypher. * - * Makes queries more intuitive by hiding SPARQL's verbose syntax: + * SPARQL's verbose syntax makes queries hard to read, especially when you're describing + * complex graph structures. These pattern helpers let you think in terms of nodes and + * relationships instead of raw triples. * - * @example - * ```ts - * // Instead of: ?person a foaf:Person ; foaf:name ?name - * // Write: node('person', Person).prop('name', var('name')) + * The core idea comes from Cypher (Neo4j's query language). Instead of repeating + * `?person foaf:name ?name ; foaf:age ?age`, you describe the node once with all its + * properties. Relationships work similarly - you define how nodes connect without + * manually writing every triple. * - * // Instead of: ?person foaf:knows ?friend - * // Write: rel('person', knows, 'friend') - * ``` + * This is syntactic sugar that generates standard SPARQL triples under the hood. + * The benefit is readability - your queries look more like the graph you're querying. + * + * @module */ import { @@ -32,84 +35,96 @@ import { } from "./triples.ts" // ============================================================================ -// Best Practice: Always Use Explicit Functions +// Design Philosophy // ============================================================================ /** - * BEST PRACTICE: Use explicit functions for ALL values + * Best practice: Use explicit value constructors. + * + * When building patterns, always use str(), num(), v() etc. for values. + * Don't rely on implicit conversion. This makes your intent clear and avoids + * ambiguity about whether something is a literal value or a variable name. * - * ✅ GOOD: node('person', Person).prop('name', str('Alice')) - * ✅ GOOD: node('person', Person).prop('age', num(30)) - * ❌ BAD: node('person', Person).prop('name', 'Alice') // Implicit conversion + * Good: node('person', Person).prop('name', str('Alice')) + * Good: node('person', Person).prop('age', num(30)) + * Bad: node('person', Person).prop('name', 'Alice') // Unclear intent * - * Why? Clarity and intent. When reading code, you immediately know: - * - str() = literal string value - * - var() = SPARQL variable - * - iri() = IRI reference - * - num() = numeric literal + * The explicit style makes it obvious what's a value vs. a variable vs. an IRI. */ -// Unified "Node"/"NodePattern" and "Relationship"/"RelationshipPattern" patterns. +// ============================================================================ +// Node Patterns +// ============================================================================ -// A single "atomic" property value can be either: -// - a normal triple object (literal/IRI/var) -// - another Node (nested resource) +/** + * Property value for a node. + * + * Can be a simple triple object (literal, IRI, variable) or another Node for + * nested structures. Arrays let you specify multiple values for one property. + */ export type PropertyAtomic = TripleObject | Node - -// The real stored type can be a single or an array. export type PropertyValue = PropertyAtomic | PropertyAtomic[] -// --------------------------------------------------------------------------- -// ResourcePattern: describes a single resource (node) -// --------------------------------------------------------------------------- - +/** + * Map of property names to values. + */ export interface NodePropertyMap { [predicate: string]: PropertyValue } /** - * A resource (node) pattern: subject + rdf:type(s) + properties. - * - * Implements SparqlValue so you can pass it to .where(). - * Its .value is a group of triples describing that subject. + * A node in your graph pattern. * + * Nodes represent resources - people, places, things. Each node has a variable + * that will bind to matching resources in your data. You can specify the node's + * type (what kind of resource it is) and properties (facts about it). * - * APPROACH 1: Nested Object Pattern + * The pattern gets compiled to SPARQL triples, but you write it in a more + * intuitive nested structure. This handles the bookkeeping of variable names + * and relationships between nodes. * - * Most intuitive for developers familiar with JSON/JavaScript. - * - * ⚠️ RDF SEMANTICS: This LOOKS nested but generates flat triples. - * In RDF, all properties are edges. We're just making the DX nicer. + * @example Basic node + * ```ts + * const person = node('person', 'foaf:Person') + * // Generates: ?person a foaf:Person . + * ``` * - * @example - * ```typescript - * const pattern = node('?product is narrative:Product', { - * 'narrative:productTitle': '?title', - * 'narrative:publishedBy': node('?publisher is narrative:Publisher', { - * 'rdfs:label': str('Marvel Comics'), - * }), - * }); + * @example Node with properties + * ```ts + * const person = node('person', 'foaf:Person', { + * 'foaf:name': v('name'), + * 'foaf:age': v('age') + * }) + * // Generates: + * // ?person a foaf:Person . + * // ?person foaf:name ?name . + * // ?person foaf:age ?age . + * ``` * + * @example Nested nodes + * ```ts + * const product = node('product', 'schema:Product', { + * 'schema:name': v('title'), + * 'schema:publisher': node('publisher', 'schema:Organization', { + * 'rdfs:label': str('Marvel Comics') + * }) + * }) * // Generates: - * // ?product a narrative:Product . - * // ?product narrative:productTitle ?title . - * // ?product narrative:publishedBy ?publisher . - * // ?publisher a narrative:Publisher . + * // ?product a schema:Product . + * // ?product schema:name ?title . + * // ?product schema:publisher ?publisher . + * // ?publisher a schema:Organization . * // ?publisher rdfs:label "Marvel Comics" . * ``` */ export class Node implements SparqlValue { readonly __sparql = true - // The SPARQL term for this node's subject, e.g. ?product readonly subjectTerm: SparqlValue - // Just the variable name, mainly for debugging private readonly varName: string - - // rdf:type values and properties private readonly typesTerm: TriplePredicate[] = [] private readonly properties: NodePropertyMap = {} - // Getters return `this` - zero overhead, pure syntax + // Fluent getters for natural chaining get is(): this { return this } get with(): this { return this } get and(): this { return this } @@ -123,7 +138,6 @@ export class Node implements SparqlValue { this.varName = variableName this.subjectTerm = variable(variableName) - // Handle type(s) explicitly if (type) { if (Array.isArray(type)) { this.typesTerm.push(...type) @@ -139,75 +153,94 @@ export class Node implements SparqlValue { } } - /** - * Helper: create a node bound to ? - */ static create(name: string, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap): Node { return new Node(name, type, options) } /** - * Access the subject term (?product) when this node is used as an object. + * Get the variable term for this node. + * + * Use this when you need to reference the node as an object in another triple. + * For example, when connecting two nodes with a relationship. */ term(): SparqlValue { return this.subjectTerm } /** - * Add an rdf:type triple. - * + * Add an rdf:type to this node. + * + * Types indicate what kind of resource this is. A node can have multiple types + * (someone can be both a Person and an Author). + * * @example - * resource.a('narrative:Product') + * ```ts + * node('person').a('foaf:Person').a('schema:Author') + * ``` */ a(typeIri: TriplePredicate): this { this.typesTerm.push(typeIri) return this } - /** Alias to {@link a} */ + /** Alias for {@link a} with more explicit naming. */ type(typeIri: TriplePredicate): this { this.a(typeIri) return this } /** - * Set multiple types for a node backed by {@link a} - * @param typesIri - * @returns + * Add multiple types at once. + * + * @example + * ```ts + * node('item').types(['schema:Product', 'schema:CreativeWork']) + * ``` */ types(typesIri: TriplePredicate[]): this { for (const typeIri of typesIri) this.a(typeIri); return this - } /** - * Add a property. - * - * Value can be: - * - a primitive/literal/IRI/var (TripleObject) - * - another Node - * - an array of those - * - * @example - * resource.prop('narrative:productTitle', 'Amazing Spider-Man #1') + * Add a property to this node. + * + * Properties describe facts about the resource. The value can be a literal, + * variable, IRI, or even another node for nested structures. Arrays let you + * specify multiple values for one property. + * + * If you call prop() multiple times with the same predicate, the values + * accumulate - you'll get multiple triples with that predicate. + * + * @example Single value + * ```ts + * node('person').prop('foaf:name', v('name')) + * ``` + * + * @example Multiple values + * ```ts + * node('person').prop('foaf:nick', ['Spidey', 'Web-Head']) + * ``` + * + * @example Nested node + * ```ts + * node('product').prop('schema:publisher', node('publisher', 'schema:Organization')) + * ``` */ prop(predicate: string | SparqlValue, value: TripleObject | TripleObject[]): this { const key = typeof predicate === 'string' ? predicate : predicate.value const existing = this.properties[key] + if (existing === undefined) { this.properties[key] = value - } - - if (Array.isArray(existing)) { + } else if (Array.isArray(existing)) { if (Array.isArray(value)) { existing.push(...value) } else { existing.push(value) } } else { - // existing is atomic if (Array.isArray(value)) { this.properties[key] = [existing, ...value] } else { @@ -218,10 +251,19 @@ export class Node implements SparqlValue { } /** - * Set multiple {@link prop}'s at the same time + * Add multiple properties at once. + * + * Convenient when you have several properties to set. Just pass an object + * where keys are predicates and values are objects. * - * @param map - * @returns + * @example + * ```ts + * node('person').props({ + * 'foaf:name': v('name'), + * 'foaf:age': v('age'), + * 'foaf:email': v('email') + * }) + * ``` */ props(map: Record): this { for (const [key, value] of Object.entries(map)) { @@ -231,16 +273,14 @@ export class Node implements SparqlValue { } /** - * Build the triples for this node and any nested nodes, ensuring that: - * - * - this node's subject is used as the subject for its properties - * - nested Node values are used as triple *objects* via .term() - * - nested Node patterns are also emitted (recursively) - * - cycles are guarded against via the visited set + * Build the SPARQL pattern for this node. + * + * Recursively processes this node and any nested nodes, generating all the + * necessary triples. The visited set prevents infinite recursion if there + * are circular references. */ private buildPatternInternal(visited: Set): string { if (visited.has(this)) { - // Avoid infinite recursion if there are cycles. return '' } visited.add(this) @@ -248,7 +288,7 @@ export class Node implements SparqlValue { const poNormalized: PredicateObjectMap = {} const nestedChunks: string[] = [] - // ---- rdf:type ---- + // Add rdf:type triples if (this.typesTerm.length > 0) { const typeObjs: TripleObject[] = this.typesTerm.map((t) => typeof t === 'string' ? t : t.value, @@ -263,20 +303,19 @@ export class Node implements SparqlValue { } } - // ---- properties (including nested Nodes) ---- + // Process properties, handling nested nodes const pushAtomic = (key: string, atomic: PropertyAtomic): void => { let object: TripleObject if (atomic instanceof Node) { - // Use the Node's term as the object (e.g. ?publisher) + // Use the nested node's variable as the object object = atomic.term() - // And also append its own pattern + // Also generate the nested node's pattern const nested = atomic.buildPatternInternal(visited) if (nested.trim().length > 0) { nestedChunks.push(nested) } } else { - // Normal TripleObject (literal/IRI/var) object = atomic } @@ -300,10 +339,10 @@ export class Node implements SparqlValue { } } - // ---- build this node's own triples ---- + // Build this node's triples const selfPattern = triples(this.subjectTerm, poNormalized).value - // Combine this node's triples with any nested node triples. + // Combine with nested patterns const allChunks = [selfPattern, ...nestedChunks].filter( (chunk) => chunk.trim().length > 0, ) @@ -312,9 +351,9 @@ export class Node implements SparqlValue { } /** - * Expose the pattern as a SparqlValue. - * - * This is what the query builder will see when you call .where(node(...)). + * Get the full SPARQL pattern as a SparqlValue. + * + * Call this to get the complete pattern including all nested nodes. */ pattern(): SparqlValue { const visited = new Set() @@ -323,9 +362,13 @@ export class Node implements SparqlValue { } /** - * Implement SparqlValue directly for convenience. - * WARNING: This is the *pattern*, NOT the term. For use as an object in - * a triple, always use .term() instead. + * Get the SPARQL pattern string. + * + * This implements SparqlValue.value, which means you can pass Node objects + * directly to query builder methods that expect SparqlValue. + * + * ⚠️ Warning: This returns the full pattern, not just the variable. If you + * want to use this node as an object in a triple, call term() instead. */ get value(): string { return this.pattern().value @@ -336,47 +379,51 @@ export class Node implements SparqlValue { } } -// --------------------------------------------------------------------------- -// RelationshipPattern: describes a relationship between two resources -// --------------------------------------------------------------------------- +// ============================================================================ +// Relationship Patterns +// ============================================================================ +/** + * Properties on a relationship. + * + * Like nodes, relationships can have properties too. This is called reification + * in RDF - treating the edge itself as a resource with facts about it. + */ export interface RelationshipPropertyMap { [predicate: string]: PropertyValue } /** - * Relationship between two nodes. - * - * Minimal form: - * ```ts - * rel('product', 'narrative:publishedBy', 'publisher') - * ``` - * - * If you want the relationship to also carry properties, you can reify - * with `.with.prop(...)` as before. - * - * NOTE: Relationship itself does not try to include node patterns; you - * should add the relevant nodes to the WHERE clause separately: + * A relationship between two nodes. + * + * Relationships describe how nodes connect. In the simplest case, a relationship + * is just an edge between two nodes with a predicate. But you can also add + * properties to the relationship itself (metadata about the connection). + * + * When you add properties to a relationship, it uses RDF reification to represent + * the edge as a resource. This lets you attach information like timestamps, + * confidence scores, or provenance data to connections. + * + * @example Simple relationship * ```ts - * builder.where(product).where(publisher).where(publishedBy) + * rel('person', 'foaf:knows', 'friend') + * // Generates: ?person foaf:knows ?friend . * ``` * - * - * By default it simply emits: - * ```sparql - * from predicate to . - * ``` - * - * If properties are added, it emits: - * ```sparql - * from predicate to . - * _:edge rdf:type some:RelationshipType ; - * some:from from ; - * some:to to ; - * ...props... + * @example Relationship with metadata + * ```ts + * rel('person', 'foaf:knows', 'friend') + * .prop('rel:since', date(new Date('2020-01-01'))) + * .prop('rel:confidence', num(0.95)) + * // Generates: + * // ?person foaf:knows ?friend . + * // _:edge_xyz a rdf:Statement ; + * // rdf:subject ?person ; + * // rdf:predicate foaf:knows ; + * // rdf:object ?friend ; + * // rel:since "2020-01-01"^^xsd:date ; + * // rel:confidence 0.95 . * ``` - * - * You can later refine this reification scheme to your OWL-ish style. */ export class Relationship implements SparqlValue { readonly __sparql = true @@ -387,7 +434,6 @@ export class Relationship implements SparqlValue { private readonly predicate: TriplePredicate private readonly properties: RelationshipPropertyMap = {} - // Getters that return this for chaining get with(): this { return this } get and(): this { return this } get that(): this { return this } @@ -426,11 +472,21 @@ export class Relationship implements SparqlValue { } /** - * Add a property on the relationship itself (reification). + * Add a property to this relationship. + * + * When you add properties, the relationship gets reified (represented as a + * blank node with rdf:Statement type). This lets you attach metadata to + * the connection itself. + * + * @example Timestamp on relationship + * ```ts + * rel('person', 'knows', 'friend').prop('timestamp', dateTime(new Date())) + * ``` */ prop(predicate: string | SparqlValue, value: TripleObject | TripleObject[]): this { const key = typeof predicate === 'string' ? predicate : predicate.value const existing = this.properties[key] + if (existing === undefined) { this.properties[key] = value } else if (Array.isArray(existing)) { @@ -445,22 +501,32 @@ export class Relationship implements SparqlValue { return this } - // Deterministic edge ID based on content + /** + * Generate deterministic ID for reified edge. + * + * Uses a simple hash of the subject-predicate-object to create a stable + * blank node identifier. Same relationship always gets the same ID. + */ private getEdgeId(): string { const hash = simpleHash(`${tripleSubjectString(this.fromTerm)}|${triplePredicateString(this.predicate)}|${tripleSubjectString(this.toTerm)}`) return `_:edge_${hash}` } + /** + * Build the triples for this relationship. + * + * If there are no properties, just generates the basic triple. If there are + * properties, generates the triple plus a reification structure. + */ private buildTriples(): SparqlValue { const base = triple(this.fromTerm, this.predicate, this.toTerm) - // If no relationship properties, just return the base triple. const keys = Object.keys(this.properties) if (keys.length === 0) { return base } - // Otherwise, reify with a blank node. + // Reify with properties const edgeId = this.getEdgeId() const poMap: RelationshipPropertyMap = { 'rdf:type': 'rdf:Statement', @@ -480,27 +546,45 @@ export class Relationship implements SparqlValue { } } -// --------------------------------------------------------------------------- -// Convenience helpers around patterns -// --------------------------------------------------------------------------- +// ============================================================================ +// Convenience Functions +// ============================================================================ /** - * A small helper to create a resource with type in one go. - * + * Create a node pattern. + * + * Convenience function for creating Node instances. Lets you quickly define + * graph patterns without the `new` keyword. + * + * @param name Variable name for this node (without ? prefix) + * @param type Optional RDF type(s) for the node + * @param options Optional property map + * * @example - * const product = node('product') - * .is.a('narrative:Product') - * .with.prop('narrative:productTitle', variable('title')) + * ```ts + * const person = node('person', 'foaf:Person') + * .prop('foaf:name', v('name')) + * .prop('foaf:age', v('age')) + * ``` */ export function node(name: string, type?: TriplePredicate | TriplePredicate[], options?: NodePropertyMap): Node { return Node.create(name, type, options) } /** - * Relationship helper with variable subjects. - * + * Create a relationship pattern. + * + * Convenience function for creating Relationship instances. Describes how + * two nodes connect. + * + * @param fromVar Source node variable name + * @param predicate Relationship type/predicate + * @param toVar Target node variable name + * * @example - * const rel = rel('product', 'narrative:publishedBy', 'publisher') + * ```ts + * const knows = rel('person', 'foaf:knows', 'friend') + * ``` */ export function rel( fromVar: string, @@ -510,18 +594,18 @@ export function rel( return Relationship.create(fromVar, predicate, toVar) } - /** - * Match pattern (combines multiple patterns) + * Combine multiple patterns into one. * - * Cypher-inspired way to build graph patterns + * Takes several patterns (nodes, relationships, or raw SPARQL) and combines + * them into a single pattern. Useful for building complex graph structures. * * @example * ```ts - * match( - * node('person', Person).prop('name', var('name')), - * rel('person', knows, 'friend'), - * node('friend', Person) + * const pattern = match( + * node('person', 'foaf:Person').prop('name', v('name')), + * rel('person', 'foaf:knows', 'friend'), + * node('friend', 'foaf:Person') * ) * ``` */ @@ -529,16 +613,21 @@ export function match( ...patterns: Array ): SparqlValue { const built = patterns.map((p) => p.value) - return raw(`${built.join('\n ')}`) } +/** + * Simple string hash for generating IDs. + * + * Uses a basic hash algorithm to create deterministic IDs from strings. + * Not cryptographically secure, but fine for generating blank node identifiers. + */ export function simpleHash(str: string): string { let hash = 0 for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i) hash = ((hash << 5) - hash) + char - hash = hash & hash // Convert to 32bit integer + hash = hash & hash } return Math.abs(hash).toString(36) } \ No newline at end of file diff --git a/patterns/triples.ts b/patterns/triples.ts index a169627..72f772e 100644 --- a/patterns/triples.ts +++ b/patterns/triples.ts @@ -1,31 +1,95 @@ +/** + * Basic triple pattern construction. + * + * SPARQL queries are built from triple patterns (subject-predicate-object). Writing + * these by hand means lots of repetitive code. These helpers let you construct + * triples programmatically with less boilerplate. + * + * Think of triples as the sentences of your graph query. Each triple makes a statement + * about a resource. The functions here help you write those statements concisely. + * + * @module + */ + import { raw, type SparqlValue } from '../sparql.ts' import { exprTermString, type ExpressionPrimitive } from '../utils.ts' +// ============================================================================ +// Triple Component Types +// ============================================================================ + /** - * Triple pattern component (subject, predicate, or object) + * Subject of a triple pattern. + * + * Can be a variable (?person), an IRI (), or a blank node. + * Most often you'll use variables to match multiple resources. */ export type TripleSubject = string | SparqlValue + +/** + * Predicate of a triple pattern. + * + * Can be a prefixed name (foaf:name), full IRI, or variable. Predicates + * describe relationships or properties. + */ export type TriplePredicate = string | SparqlValue + +/** + * Object of a triple pattern. + * + * Can be any RDF term - variables, IRIs, literals, or blank nodes. This is + * what the subject is related to or what value a property has. + */ export type TripleObject = SparqlValue | ExpressionPrimitive +/** + * Convert subject to string form. + * + * Handles both raw strings and SparqlValue objects. + */ export function tripleSubjectString(subject: TripleSubject): string { if (typeof subject === 'string') return subject return subject.value } +/** + * Convert predicate to string form. + */ export function triplePredicateString(predicate: TriplePredicate): string { if (typeof predicate === 'string') return predicate return predicate.value } +// ============================================================================ +// Triple Construction +// ============================================================================ + /** - * Create a triple pattern + * Create a single triple pattern. * - * @example + * This is the basic building block of SPARQL queries. A triple makes a statement + * about a resource - who they are, what properties they have, how they relate + * to other resources. + * + * The pattern will match any data in your graph that fits this structure. + * Variables (like ?person) will bind to whatever values make the pattern true. + * + * @example Match by name * ```ts * triple('?person', 'foaf:name', '?name') + * // ?person foaf:name ?name . + * ``` + * + * @example Match specific value + * ```ts * triple('?person', 'foaf:age', 30) + * // ?person foaf:age 30 . + * ``` + * + * @example With full IRI + * ```ts * triple(uri('http://example.org/person/1'), 'foaf:name', 'Alice') + * // foaf:name "Alice" . * ``` */ export function triple( @@ -40,29 +104,68 @@ export function triple( return raw(`${s} ${p} ${o} .`) } +// ============================================================================ +// Multiple Triples with Shared Subject +// ============================================================================ + +/** + * Array format for predicate-object pairs. + * + * Each entry is [predicate, object]. Use this when you want explicit control + * over the order of properties. + */ export type PredicateObjectList = Array<[TriplePredicate, TripleObject]> + +/** + * Object format for predicate-object pairs. + * + * Keys are predicates, values are objects. Values can be single items or arrays + * for properties with multiple values. + */ export type PredicateObjectMap = Record< string, TripleObject | TripleObject[] > /** - * Multiple triples with a shared subject. - * - * Supports: - * - * - Array form: - * triples('?person', [ - * ['foaf:name', 'Peter Parker'], - * ['foaf:age', 18], - * ]) - * - * - Object form: - * triples('?person', { - * 'foaf:name': 'Peter Parker', - * 'foaf:age': 18, - * 'foaf:nick': ['Spidey', 'Friendly Neighborhood Spider-Man'], - * }) + * Create multiple triples with the same subject. + * + * When you have several facts about one resource, you don't want to repeat the + * subject for each triple. This helper uses SPARQL's semicolon syntax to share + * the subject across multiple predicate-object pairs. + * + * You can pass properties as an array of [predicate, object] pairs, or as an + * object where keys are predicates. The object format is more convenient, but + * the array format gives you control over ordering. + * + * @example Array format + * ```ts + * triples('?person', [ + * ['foaf:name', 'Peter Parker'], + * ['foaf:age', 18], + * ['foaf:nick', 'Spidey'] + * ]) + * ``` + * + * Generates: + * ```sparql + * ?person + * foaf:name "Peter Parker" ; + * foaf:age 18 ; + * foaf:nick "Spidey" . + * ``` + * + * @example Object format + * ```ts + * triples('?person', { + * 'foaf:name': 'Peter Parker', + * 'foaf:age': 18, + * 'foaf:nick': ['Spidey', 'Spider-Man'] + * }) + * ``` + * + * When a property has an array value, it creates multiple triples with the + * same predicate (one for each value). */ export function triples( subject: TripleSubject, @@ -70,10 +173,12 @@ export function triples( ): SparqlValue { const s = tripleSubjectString(subject) + // Normalize to list format const list: PredicateObjectList = Array.isArray(predicateObjects) ? predicateObjects : Object.entries(predicateObjects).flatMap(([pred, value]) => { if (Array.isArray(value)) { + // Multiple values for same predicate → multiple pairs return value.map( (v): [TriplePredicate, TripleObject] => [pred, v], ) @@ -81,6 +186,7 @@ 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) diff --git a/sparql.ts b/sparql.ts index 0f2dc51..ab5b8da 100644 --- a/sparql.ts +++ b/sparql.ts @@ -1,35 +1,55 @@ /** - * Type-safe SPARQL query construction with template literals + * Type-safe SPARQL construction using template literals. * - * Provides automatic type conversion for: - * - Primitives: strings, numbers, booleans, dates - * - Complex types: arrays, objects, nested structures - * - SPARQL constructs: variables, prefixes, IRIs + * Writing SPARQL by hand gets messy fast. String concatenation leads to injection + * vulnerabilities, and manually escaping values is error-prone. This module lets + * you write queries with automatic type conversion and proper escaping. * - * @example + * The core idea is simple: use template literals with automatic value conversion. + * Strings become properly escaped literals, numbers stay as numbers, dates get + * formatted correctly, and complex values are handled intelligently. + * + * @example Basic query construction * ```ts - * const name = "John Doe"; + * const name = "Peter Parker"; * const age = 30; - * const tags = ["hero", "villain"]; * * const query = sparql` * SELECT * WHERE { * ?person foaf:name ${name} ; - * foaf:age ${age} ; - * tags:has ${tags} . + * foaf:age ${age} . + * } + * `; + * ``` + * + * @example Working with arrays + * ```ts + * const cities = ["London", "Paris", "Tokyo"]; + * + * // Arrays become space-separated values for VALUES clauses + * const query = sparql` + * SELECT * WHERE { + * VALUES ?city { ${cities} } + * ?place schema:name ?city . * } * `; * ``` + * + * @module */ +import { outdent } from "outdent" + // ============================================================================ // Core Types // ============================================================================ -import { outdent } from "outdent" - /** - * SPARQL value wrapper for type-safe interpolation + * 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. */ export interface SparqlValue { readonly __sparql: true @@ -37,7 +57,16 @@ export interface SparqlValue { } /** - * Any value that can be interpolated into SPARQL query + * 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) */ export type SparqlInterpolatable = | string @@ -50,24 +79,26 @@ export type SparqlInterpolatable = | SparqlInterpolatable[] | { [key: string]: SparqlInterpolatable } - /** - * Variable name in SPARQL (without ? or $ prefix) + * Variable name without the leading ? or $ sigil. + * + * In SPARQL, variables can be written as ?name or $name. We normalize these + * internally to just store the name part, then add the ? when generating queries. */ export type VariableName = string /** - * Prefix name for namespace abbreviation + * Namespace prefix for abbreviated IRIs (e.g., "foaf" in foaf:name). */ export type PrefixName = string /** - * Literal datatype IRI + * Full IRI for a datatype (e.g., http://www.w3.org/2001/XMLSchema#integer). */ export type DatatypeIRI = string /** - * Language tag (e.g., 'en', 'fr', 'ja') + * Language tag for multilingual literals (e.g., "en", "fr", "ja-JP"). */ export type LanguageTag = string @@ -76,18 +107,22 @@ export type LanguageTag = string // ============================================================================ /** - * Normalize a variable name so that both "foo" and "?foo" are accepted, - * but internally we always store "foo" and validate via sparql.ts. + * 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: string | `?${string}`): VariableName { - // Accept ?foo or foo; store as foo return name.startsWith('?') ? (name.slice(1) as VariableName) : (name as VariableName) } /** - * Escape string for use in SPARQL triple-quoted literal + * Escape special characters for SPARQL string literals. * - * Escapes: backslash, double-quote, newline, carriage return, tab + * SPARQL strings can contain newlines, quotes, and other special characters. + * This function ensures they're properly escaped so the query stays valid. + * We use backslash escaping for: \, ", \n, \r, \t */ export function escapeString(str: string): string { return str @@ -99,9 +134,11 @@ export function escapeString(str: string): string { } /** - * Validate IRI format + * Validate that a string is a proper IRI. * - * Must be http/https and not contain forbidden characters + * 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. */ export function validateIRI(iri: string): void { if (!iri.startsWith('http://') && !iri.startsWith('https://')) { @@ -117,20 +154,24 @@ export function validateIRI(iri: string): void { } /** - * Validate variable name (must be valid SPARQL variable) + * Validate SPARQL variable names. + * + * Variable names must start with a letter or underscore, followed by letters, + * numbers, or underscores. This matches the SPARQL 1.1 specification. */ export function validateVariableName(name: string): void { - // SPARQL variable names must match: [A-Za-z_][A-Za-z0-9_]* if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new Error(`Invalid variable name: ${name}`) } } /** - * Validate prefix name (must be valid SPARQL prefix) + * Validate namespace prefix names. + * + * Prefixes follow the same rules as variable names - they're identifiers that + * get expanded to full IRIs during query execution. */ export function validatePrefixName(name: string): void { - // Prefix names match same rules as variables if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw new Error(`Invalid prefix name: ${name}`) } @@ -141,14 +182,24 @@ export function validatePrefixName(name: string): void { // ============================================================================ /** - * Convert Date to xsd:dateTime literal + * Convert Date to xsd:dateTime with full timestamp. + * + * Uses ISO 8601 format with timezone. This is the standard way to represent + * date-time values in RDF. + * + * @example "2024-01-15T10:30:00.000Z"^^ */ export function formatDateTime(date: Date): string { return `"${date.toISOString()}"^^` } /** - * Convert Date to xsd:date literal (date only, no time) + * Convert Date to xsd:date with date only (no time component). + * + * Useful when you only care about the calendar date, not the time. The format + * is YYYY-MM-DD. + * + * @example "2024-01-15"^^ */ export function formatDate(date: Date): string { const yyyy = date.getFullYear() @@ -158,14 +209,28 @@ export function formatDate(date: Date): string { } /** - * Convert array to SPARQL VALUES clause or list + * Convert array to SPARQL representation. + * + * 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. + * + * @example Primitive values (for VALUES) + * ```ts + * formatArray([1, 2, 3]) // → "1 2 3" + * ``` + * + * @example Complex values (RDF list) + * ```ts + * formatArray([obj1, obj2]) // → "( [props...] [props...] )" + * ``` */ export function formatArray(arr: SparqlInterpolatable[]): string { if (arr.length === 0) { throw new Error('Cannot convert empty array to SPARQL') } - // Check if all elements are primitives (for VALUES clause) + // Check if all elements are simple primitives const allPrimitives = arr.every( (item) => item instanceof Date || @@ -177,18 +242,31 @@ export function formatArray(arr: SparqlInterpolatable[]): string { ) if (allPrimitives) { - // Generate VALUES clause: VALUES ?var { val1 val2 val3 } + // For VALUES clauses, just space-separate the values const values = arr.map((item) => convertValue(item)).join(' ') return values } - // For complex arrays, generate RDF list: ( val1 val2 val3 ) + // For complex arrays, generate RDF list notation const values = arr.map((item) => convertValue(item)).join(' ') return `( ${values} )` } /** - * Convert object to SPARQL inline data or blank node + * Convert object to SPARQL blank node with properties. + * + * 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. + * + * @example + * ```ts + * formatObject({ + * 'foaf:name': 'Alice', + * 'foaf:age': 30 + * }) + * // → [ foaf:name "Alice" ; foaf:age 30 ] + * ``` */ export function formatObject(obj: { [key: string]: SparqlInterpolatable }): string { const entries = Object.entries(obj) @@ -196,14 +274,14 @@ export function formatObject(obj: { [key: string]: SparqlInterpolatable }): stri throw new Error('Cannot convert empty object to SPARQL') } - // Generate blank node with properties: [ pred1 val1 ; pred2 val2 ] + // Generate blank node syntax with semicolon-separated properties const properties = entries .map(([key, value]) => { - // Assume keys are predicates (can be prefixed or IRIs) + // Handle different predicate formats const predicate = key.includes(':') || key.startsWith('http') ? key.includes(':') - ? key // Already prefixed - : `<${key}>` // Full IRI + ? key // Already a prefixed name + : `<${key}>` // Full IRI needs angle brackets : `:${key}` // Default to colon prefix return `${predicate} ${convertValue(value)}` @@ -214,44 +292,57 @@ export function formatObject(obj: { [key: string]: SparqlInterpolatable }): stri } /** - * Convert any value to SPARQL representation + * Convert any JavaScript value to its SPARQL 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. + * + * @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 */ export function convertValue(value: SparqlInterpolatable, strict = true): string { - // Already wrapped SparqlValue + // Already wrapped - use as-is if (isSparqlValue(value)) { return value.value } - // Null/undefined - throw error (use OPTIONAL instead) + // Null/undefined - these are tricky in RDF if (value === null || value === undefined) { - if (strict) throw new Error( - 'Cannot convert null/undefined to SPARQL. Use OPTIONAL { } pattern instead.' - ) - - return strlit('').value; + if (strict) { + throw new Error( + 'Cannot convert null/undefined to SPARQL. Use OPTIONAL { } pattern instead.' + ) + } + return strlit('').value } - // String - triple-quoted literal with xsd:string + // String - becomes xsd:string literal if (typeof value === 'string') { return strlit(value).value } - // Boolean - raw true/false + // Boolean - raw true/false keywords if (typeof value === 'boolean') { return boolean(value).value } - // Number - raw number (SPARQL infers integer/decimal/double) + // Number - raw numeric literal (SPARQL infers type) if (typeof value === 'number') { return num(value).value } - // Date - xsd:dateTime + // Date - becomes xsd:dateTime if (value instanceof Date) { return date(value).value } - // Array - VALUES clause or RDF list + // Array - space-separated list or RDF list if (Array.isArray(value)) { return formatArray(value) } @@ -265,7 +356,10 @@ export function convertValue(value: SparqlInterpolatable, strict = true): string } /** - * Type guard for SparqlValue + * 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. */ export function isSparqlValue(value: unknown): value is SparqlValue { return ( @@ -277,15 +371,24 @@ export function isSparqlValue(value: unknown): value is SparqlValue { } /** - * Wrap string as SparqlValue (for internal use) + * 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. */ export function wrapSparqlValue(value: string): SparqlValue { return { __sparql: true, value: value } } +// ============================================================================ +// Value Constructors +// ============================================================================ /** - * Create IRI reference + * 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') → */ @@ -295,21 +398,20 @@ export function uri(iri: string): SparqlValue { } /** - * Create IRI reference (explicit, clear) - * - * @example iri('http://example.org/person/1') → + * Alias for {@link uri} with a more explicit name. */ export function iri(value: string): SparqlValue { return uri(value) } /** - * Create variable reference + * Create a SPARQL variable reference. * - * Note: In SPARQL, variables are written as ?name or $name. - * This function handles the ? prefix automatically. + * 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) */ export function variable(name: VariableName): SparqlValue { const n = name.startsWith('?') ? name.slice(1) : name @@ -318,18 +420,23 @@ export function variable(name: VariableName): SparqlValue { } /** - * Create variable reference (explicit, clear) + * Short alias for {@link variable}. * - * @example var('name') → ?name + * Convenient when you're writing lots of variable references. */ export function v(name: string): SparqlValue { return variable(name) } /** - * Create prefixed name reference + * 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 */ export function prefixed(prefix: PrefixName, localName: string): SparqlValue { validatePrefixName(prefix) @@ -337,16 +444,17 @@ export function prefixed(prefix: PrefixName, localName: string): SparqlValue { } /** - * Create prefixed name (explicit, clear) - * - * @example prefix('foaf', 'name') → foaf:name + * Alias for {@link prefixed} with more explicit naming. */ export function prefix(namespace: string, local: string): SparqlValue { return prefixed(namespace, local) } /** - * Create date literal (xsd:date - no time component) + * 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 */ @@ -356,7 +464,10 @@ export function date(value: Date | string): SparqlValue { } /** - * Create dateTime literal (xsd:dateTime - full timestamp) + * 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 */ @@ -366,9 +477,13 @@ export function dateTime(value: Date | string): SparqlValue { } /** - * Create integer literal (xsd:integer) + * 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. * - * @example integer(42) → "42"^^xsd:integer + * @throws {Error} If value is not an integer */ export function integer(value: number): SparqlValue { if (!Number.isInteger(value)) { @@ -380,9 +495,12 @@ export function integer(value: number): SparqlValue { } /** - * Create decimal literal (xsd:decimal) + * Create an xsd:decimal literal. * - * @example decimal(3.14) → "3.14"^^xsd:decimal + * 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) */ export function decimal(value: number): SparqlValue { if (!Number.isFinite(value)) { @@ -395,7 +513,10 @@ export function decimal(value: number): SparqlValue { } /** - * Number literal (explicit) + * 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)) { @@ -406,34 +527,41 @@ export function num(value: number): SparqlValue { } /** - * Create boolean literal + * Create a boolean literal (true or false). * - * @example boolean(true) → true + * Boolean values in SPARQL are written as bare keywords, not quoted strings. */ export function boolean(value: boolean): SparqlValue { return wrapSparqlValue(String(!!value)) } /** - * Boolean literal (explicit) + * Short alias for {@link boolean}. */ export function bool(value: boolean): SparqlValue { return boolean(value) } /** - * Create language-tagged literal + * 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}`) } /** - * Create typed literal with custom datatype + * Create a typed literal with custom datatype. * - * @example typed('custom value', 'http://example.org/datatype') → "custom value"^^ + * 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) @@ -443,16 +571,21 @@ export function typed(value: string, datatype: DatatypeIRI): SparqlValue { } /** - * String literal (explicit) + * 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") } /** - * Create raw SPARQL (no escaping) + * Insert raw SPARQL without any escaping. * - * ⚠️ DANGEROUS: Use only when you control the input completely + * ⚠️ 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 */ @@ -461,18 +594,55 @@ export function raw(value: string): SparqlValue { } // ============================================================================ -// Main Template Tag Function +// Main Template Tag // ============================================================================ /** - * SPARQL template tag for type-safe query construction + * Main template tag for building SPARQL queries. * - * @example + * 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 * ```ts + * const name = "Peter Parker"; + * const minAge = 18; + * * const query = sparql` - * SELECT ?name ?age WHERE { + * SELECT ?person WHERE { * ?person foaf:name ${name} ; - * foaf:age ${age} . + * foaf:age ?age . + * FILTER(?age >= ${minAge}) + * } + * `; + * ``` + * + * @example With arrays + * ```ts + * const cities = ["London", "Paris", "Tokyo"]; + * + * const query = sparql` + * SELECT * WHERE { + * VALUES ?city { ${cities} } + * ?place schema:name ?city . + * } + * `; + * ``` + * + * @example With nested values + * ```ts + * const person = { + * 'foaf:name': 'Alice', + * 'foaf:age': 30 + * }; + * + * const query = sparql` + * INSERT DATA { + * ?person ${person} * } * `; * ``` @@ -492,7 +662,7 @@ export function sparql( } // ============================================================================ -// Re-export for convenience +// Re-exports // ============================================================================ export default sparql \ No newline at end of file diff --git a/utils.ts b/utils.ts index e15e2b9..926ce30 100644 --- a/utils.ts +++ b/utils.ts @@ -1,11 +1,53 @@ -import { convertValue, isSparqlValue, normalizeVariableName, raw, strlit, validateVariableName, wrapSparqlValue, type VariableName, type SparqlInterpolatable, type SparqlValue } from './sparql.ts' +/** + * 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. + * + * 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()`. + * + * @module + */ + +import { + convertValue, + isSparqlValue, + normalizeVariableName, + raw, + strlit, + validateVariableName, + wrapSparqlValue, + type VariableName, + type SparqlInterpolatable, + type SparqlValue +} from './sparql.ts' + +// ============================================================================ +// Query Clauses +// ============================================================================ /** - * Create VALUES clause for multiple values + * Create a VALUES clause for filtering by a list of values. * - * @example + * VALUES clauses let you provide a set of possible values for a variable. + * Think of it like an IN clause in SQL. The query engine will try each value + * and return results that match any of them. + * + * @example Simple list + * ```ts * values('city', ['London', 'Paris', 'Tokyo']) - * → VALUES ?city { "London" "Paris" "Tokyo" } + * // VALUES ?city { "London" "Paris" "Tokyo" } + * ``` + * + * @example With numbers + * ```ts + * values('age', [18, 21, 25]) + * // VALUES ?age { 18 21 25 } + * ``` */ export function values( varName: VariableName, @@ -17,33 +59,74 @@ export function values( } /** - * Create FILTER expression + * Wrap an expression in a FILTER clause. * - * @example - * filter(sparql`?age > ${18}`) - * → FILTER(?age > 18) + * Filters restrict results based on boolean conditions. The expression you pass + * should evaluate to true or false. Use this with comparison operators, regex + * checks, or any other boolean expression. + * + * @example Age filter + * ```ts + * filter(gte(v('age'), 18)) + * // FILTER(?age >= 18) + * ``` + * + * @example Multiple conditions + * ```ts + * filter(and( + * gte(v('age'), 18), + * regex(v('name'), '^Spider') + * )) + * // FILTER(?age >= 18 && REGEX(?name, "^Spider")) + * ``` */ export function filter(expression: SparqlValue): SparqlValue { return wrapSparqlValue(`FILTER(${expression.value})`) } /** - * Create OPTIONAL block + * Wrap a pattern in an OPTIONAL block. * - * @example - * optional(sparql`?person foaf:email ?email`) - * → OPTIONAL { ?person foaf:email ?email } + * Optional patterns don't fail the whole query if they don't match - they just + * leave variables unbound. This is like a LEFT JOIN in SQL. Use it for properties + * that might not exist on all results. + * + * @example Email might not exist + * ```ts + * optional(triple('?person', 'foaf:email', '?email')) + * // OPTIONAL { ?person foaf:email ?email } + * ``` + * + * @example Multiple optional triples + * ```ts + * optional(triples('?person', [ + * ['foaf:email', '?email'], + * ['foaf:phone', '?phone'] + * ])) + * ``` */ export function optional(pattern: SparqlValue): SparqlValue { return wrapSparqlValue(`OPTIONAL { ${pattern.value} }`) } /** - * Create BIND expression + * Create a BIND expression to compute new variables. * - * @example - * bind(sparql`CONCAT(?firstName, " ", ?lastName)`, 'fullName') - * → BIND(CONCAT(?firstName, " ", ?lastName) AS ?fullName) + * BIND lets you create new variables from expressions. Think of it like a computed + * column - you're deriving a new value from existing data. The variable will be + * available in the rest of the query. + * + * @example Full name from parts + * ```ts + * bind(concat(v('firstName'), ' ', v('lastName')), 'fullName') + * // BIND(CONCAT(?firstName, " ", ?lastName) AS ?fullName) + * ``` + * + * @example Age calculation + * ```ts + * bind(sub(2024, v('birthYear')), 'age') + * // BIND(2024 - ?birthYear AS ?age) + * ``` */ export function bind(expression: SparqlValue, varName: VariableName): SparqlValue { const _varName = normalizeVariableName(varName) @@ -52,9 +135,15 @@ export function bind(expression: SparqlValue, varName: VariableName): SparqlValu } // ============================================================================ -// Expression helpers (Drizzle-like SPARQL operations) +// Expression Helpers // ============================================================================ +/** + * Values that can be used in SPARQL expressions. + * + * These are the building blocks: literals, numbers, dates, and already-constructed + * SparqlValue objects. Most expression helpers accept these types. + */ export type ExpressionPrimitive = | string | number @@ -64,12 +153,11 @@ export type ExpressionPrimitive = | undefined /** - * Treat a value as an expression term. - * - * - SparqlValue → use its `.value` directly - * - primitives → encoded using the `sparql` template, so they become - * correctly escaped literals or IRIs/variables (depending - * on how you pass them) + * Convert a value to a SparqlValue 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. */ export function exprTerm( value: SparqlValue | ExpressionPrimitive, @@ -77,12 +165,14 @@ export function exprTerm( if (isSparqlValue(value)) { return value } - // primitives go through convertValue via the template tag return wrapSparqlValue(convertValue(value)) } /** - * Internal: convert a value to the raw SPARQL term string. + * 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, @@ -90,47 +180,87 @@ export function exprTermString( return exprTerm(value).value } +// ============================================================================ +// String Functions +// ============================================================================ + /** - * CONCAT(arg1, arg2, ...) - * - * @example + * Concatenate strings or values. + * + * CONCAT joins multiple values into a single string. All arguments are converted + * to strings first. This is your go-to for building full names, labels, or any + * composite string field. + * + * @example Full name * ```ts - * const fullName = concat(variable('firstName'), ' ', variable('lastName')) - * const q = select(['?fullName']) - * .bind(fullName, 'fullName') + * 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 ): SparqlValue { if (args.length === 0) { - // CONCAT() is invalid; return an empty string literal - return strlit('') // """" + return strlit('') } const inner = args.map(exprTermString).join(', ') return raw(`CONCAT(${inner})`) } +/** + * Convert a value to a string. + * + * Forces conversion to string representation. Useful when you need to ensure + * a value is treated as a string for comparison or manipulation. + */ export function str(value: SparqlValue | ExpressionPrimitive): SparqlValue { return raw(`STR(${exprTermString(value)})`) } +/** + * Get the length of a string. + * + * Returns the character count. Note that this counts Unicode characters, not bytes. + */ export function strlen( value: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`STRLEN(${exprTermString(value)})`) } +/** + * Convert string to uppercase. + */ export function ucase(value: SparqlValue | ExpressionPrimitive): SparqlValue { return raw(`UCASE(${exprTermString(value)})`) } +/** + * Convert string to lowercase. + */ export function lcase(value: SparqlValue | ExpressionPrimitive): SparqlValue { return raw(`LCASE(${exprTermString(value)})`) } - +/** + * Check if a string contains a substring. + * + * Case-sensitive substring search. Returns true if pattern appears anywhere + * in the text. + * + * @example + * ```ts + * contains(v('title'), 'Spider') + * // CONTAINS(?title, "Spider") + * ``` + */ export function contains( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -140,6 +270,11 @@ export function contains( ) } +/** + * Check if string starts with a prefix. + * + * Case-sensitive prefix check. + */ export function startsWith( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -149,6 +284,11 @@ export function startsWith( ) } +/** + * Check if string ends with a suffix. + * + * Case-sensitive suffix check. + */ export function endsWith( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -158,6 +298,24 @@ export function endsWith( ) } +/** + * Extract substring from a string. + * + * Starting position is 1-indexed (SPARQL convention). If length is omitted, + * extracts to the end of the string. + * + * @example First 5 characters + * ```ts + * substr(v('title'), 1, 5) + * // SUBSTR(?title, 1, 5) + * ``` + * + * @example Everything after position 10 + * ```ts + * substr(v('description'), 10) + * // SUBSTR(?description, 10) + * ``` + */ export function substr( text: SparqlValue | ExpressionPrimitive, start: SparqlValue | ExpressionPrimitive, @@ -172,6 +330,17 @@ export function substr( return raw(`SUBSTR(${t}, ${s}, ${l})`) } +/** + * Replace occurrences of a pattern in text. + * + * Replaces all occurrences of pattern with replacement string. + * + * @example Remove dashes + * ```ts + * replaceStr(v('isbn'), '-', '') + * // REPLACE(?isbn, "-", "") + * ``` + */ export function replaceStr( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -183,14 +352,21 @@ export function replaceStr( return raw(`REPLACE(${textTerm}, ${patternTerm}, ${replacementTerm})`) } - /** - * REGEX helper - * - * @example + * 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 - * const condition = regex(variable('name'), '^Spidey', 'i') - * builder.filter(condition) + * 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( @@ -204,45 +380,88 @@ export function regex( return raw(`REGEX(${textTerm}, ${patternTerm}${flagsTerm})`) } +// ============================================================================ +// Nullability & List Operations +// ============================================================================ -// Nullish / list helpers (Drizzle-like) - +/** + * 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) { - // Nothing is in an empty set. 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) { - // Everything is not in an empty set. 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, @@ -254,6 +473,18 @@ export function between( 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 ): SparqlValue { @@ -264,6 +495,18 @@ export function coalesce( return raw(`COALESCE(${inner})`) } +/** + * Conditional expression (ternary operator). + * + * Like JavaScript's condition ? whenTrue : whenFalse. Evaluates the condition + * and returns one of two values based on the result. + * + * @example Adult vs minor + * ```ts + * ifElse(gte(v('age'), 18), strlit('Adult'), strlit('Minor')) + * // IF(?age >= 18, "Adult", "Minor") + * ``` + */ export function ifElse( condition: SparqlValue, whenTrue: SparqlValue | ExpressionPrimitive, @@ -276,9 +519,11 @@ export function ifElse( ) } +// ============================================================================ +// Numeric Operations +// ============================================================================ -// ---- Numeric helpers ---- - +/** Add two numbers. */ export function add( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -286,6 +531,7 @@ export function add( return raw(`${exprTermString(left)} + ${exprTermString(right)}`) } +/** Subtract two numbers. */ export function sub( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -293,6 +539,7 @@ export function sub( return raw(`${exprTermString(left)} - ${exprTermString(right)}`) } +/** Multiply two numbers. */ export function mul( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -300,6 +547,7 @@ export function mul( return raw(`${exprTermString(left)} * ${exprTermString(right)}`) } +/** Divide two numbers. */ export function div( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -307,6 +555,7 @@ export function div( return raw(`${exprTermString(left)} / ${exprTermString(right)}`) } +/** Modulo operation (remainder after division). */ export function mod( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -314,61 +563,39 @@ export function mod( return raw(`(${exprTermString(left)} % ${exprTermString(right)})`) } +/** Absolute value. */ export function abs( value: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`ABS(${exprTermString(value)})`) } -export function ceil( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`CEIL(${exprTermString(value)})`) -} - -export function floor( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`FLOOR(${exprTermString(value)})`) -} - +/** Round to nearest integer. */ export function round( value: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`ROUND(${exprTermString(value)})`) } -// ---- Date/Time helpers (SPARQL 1.1) ---- - -export function nowFn(): SparqlValue { - return raw('NOW()') -} - -export function yearFn( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`YEAR(${exprTermString(value)})`) -} - -export function monthFn( +/** Round up to next integer. */ +export function ceil( value: SparqlValue | ExpressionPrimitive, ): SparqlValue { - return raw(`MONTH(${exprTermString(value)})`) + return raw(`CEIL(${exprTermString(value)})`) } -export function dayFn( +/** Round down to previous integer. */ +export function floor( value: SparqlValue | ExpressionPrimitive, ): SparqlValue { - return raw(`DAY(${exprTermString(value)})`) + return raw(`FLOOR(${exprTermString(value)})`) } +// ============================================================================ +// Comparison Operations +// ============================================================================ -/** - * Comparison helpers - * - * These all build simple binary expressions and return them as SparqlValue. - */ - +/** Equal to. */ export function eq( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -376,6 +603,7 @@ export function eq( return raw(`${exprTermString(left)} = ${exprTermString(right)}`) } +/** Not equal to. */ export function neq( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -383,6 +611,7 @@ export function neq( return raw(`${exprTermString(left)} != ${exprTermString(right)}`) } +/** Greater than. */ export function gt( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -390,6 +619,7 @@ export function gt( return raw(`${exprTermString(left)} > ${exprTermString(right)}`) } +/** Greater than or equal to. */ export function gte( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -397,6 +627,7 @@ export function gte( return raw(`${exprTermString(left)} >= ${exprTermString(right)}`) } +/** Less than. */ export function lt( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -404,6 +635,7 @@ export function lt( return raw(`${exprTermString(left)} < ${exprTermString(right)}`) } +/** Less than or equal to. */ export function lte( left: SparqlValue | ExpressionPrimitive, right: SparqlValue | ExpressionPrimitive, @@ -411,10 +643,26 @@ export function lte( return raw(`${exprTermString(left)} <= ${exprTermString(right)}`) } +// ============================================================================ +// Logical Operations +// ============================================================================ + /** - * Logical helpers + * Combine conditions with AND. + * + * All conditions must be true for the result to be true. Short-circuits on + * the first false condition. + * + * @example Multiple filters + * ```ts + * and( + * gte(v('age'), 18), + * lte(v('age'), 65), + * eq(v('status'), 'active') + * ) + * // ?age >= 18 && ?age <= 65 && ?status = "active" + * ``` */ - export function and( ...conditions: SparqlValue[] ): SparqlValue { @@ -423,6 +671,21 @@ export function and( return raw(conditions.map((c) => c.value).join(' && ')) } +/** + * Combine conditions with OR. + * + * Any condition being true makes the result true. Short-circuits on the + * first true condition. + * + * @example Alternative publishers + * ```ts + * or( + * eq(v('publisher'), 'Marvel'), + * eq(v('publisher'), 'DC Comics') + * ) + * // ?publisher = "Marvel" || ?publisher = "DC Comics" + * ``` + */ export function or( ...conditions: SparqlValue[] ): SparqlValue { @@ -431,85 +694,93 @@ export function or( return raw(conditions.map((c) => c.value).join(' || ')) } +/** + * Negate a condition. + * + * Flips true to false and false to true. + */ export function not(condition: SparqlValue): SparqlValue { return raw(`!(${condition.value})`) } /** - * EXISTS/NOT EXISTS helpers - * - * You pass a SparqlValue that represents a group pattern: - * exists(triple('?s', 'rdf:type', 'ex:Thing')) + * 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} }`) } // ============================================================================ -// RDF Term Type Testing Functions +// RDF Term Type Tests // ============================================================================ -/** - * Test if a term is an IRI - */ +/** Check if a term is an IRI. */ export function isIri( term: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`isIRI(${exprTermString(term)})`) } -/** - * Test if a term is a blank node - */ +/** Check if a term is a blank node. */ export function isBlank( term: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`isBlank(${exprTermString(term)})`) } -/** - * Test if a term is a literal - */ +/** Check if a term is a literal. */ export function isLiteral( term: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`isLiteral(${exprTermString(term)})`) } -/** - * Test if a variable is bound - */ +/** Check if a variable is bound. */ export function bound( variable: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`BOUND(${exprTermString(variable)})`) } -/** - * Get language tag of a literal - */ +/** Get the language tag of a literal. */ export function getlang( literal: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`LANG(${exprTermString(literal)})`) } -/** - * Get datatype IRI of a literal - */ +/** Get the datatype IRI of a literal. */ export function datatype( literal: SparqlValue | ExpressionPrimitive, ): SparqlValue { return raw(`DATATYPE(${exprTermString(literal)})`) } -/** - * Alias for startsWith (matches SPARQL STRSTARTS function name) - */ +/** Alias for {@link startsWith} (matches SPARQL function name). */ export function strstarts( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -517,9 +788,7 @@ export function strstarts( return startsWith(text, pattern) } -/** - * Alias for endsWith (matches SPARQL STRENDS function name) - */ +/** Alias for {@link endsWith} (matches SPARQL function name). */ export function strends( text: SparqlValue | ExpressionPrimitive, pattern: SparqlValue | ExpressionPrimitive, @@ -532,14 +801,18 @@ export function strends( // ============================================================================ /** - * Interface for aggregation expressions with AS clause + * 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 } /** - * Create an aggregation expression with optional AS binding + * Internal helper to create aggregation expressions. */ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPrimitive): AggregationExpression { const exprStr = expr ? exprTermString(expr) : '*' @@ -558,18 +831,21 @@ function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPr } /** - * COUNT aggregation + * Count the number of rows. * - * @example - * ```ts - * // COUNT(*) - * count() + * Without arguments, counts all rows (COUNT(*)). With an expression, counts + * non-null values of that expression. * - * // COUNT(?person) - * count(variable('person')) + * @example Count all + * ```ts + * select([count().as('total')]) + * // SELECT COUNT(*) AS ?total + * ``` * - * // COUNT(?person) AS ?personCount - * count(variable('person')).as('personCount') + * @example Count specific values + * ```ts + * select([count(v('email')).as('emailCount')]) + * // SELECT COUNT(?email) AS ?emailCount * ``` */ export function count( @@ -579,7 +855,15 @@ export function count( } /** - * COUNT DISTINCT aggregation + * Count distinct values. + * + * Like COUNT but only counts unique values. + * + * @example Unique publishers + * ```ts + * select([countDistinct(v('publisher')).as('publisherCount')]) + * // SELECT COUNT(DISTINCT ?publisher) AS ?publisherCount + * ``` */ export function countDistinct( expr: SparqlValue | ExpressionPrimitive, @@ -600,15 +884,14 @@ export function countDistinct( } /** - * SUM aggregation + * Sum numeric values. * - * @example - * ```ts - * // SUM(?amount) - * sum(variable('amount')) + * Adds up all values in the group. * - * // SUM(?amount) AS ?total - * sum(variable('amount')).as('total') + * @example Total price + * ```ts + * select([sum(v('price')).as('totalPrice')]) + * // SELECT SUM(?price) AS ?totalPrice * ``` */ export function sum( @@ -617,27 +900,21 @@ export function sum( return createAggregation('SUM', expr) } -/** - * AVG aggregation - */ +/** Calculate average of numeric values. */ export function avg( expr: SparqlValue | ExpressionPrimitive, ): AggregationExpression { return createAggregation('AVG', expr) } -/** - * MIN aggregation - */ +/** Find minimum value. */ export function min( expr: SparqlValue | ExpressionPrimitive, ): AggregationExpression { return createAggregation('MIN', expr) } -/** - * MAX aggregation - */ +/** Find maximum value. */ export function max( expr: SparqlValue | ExpressionPrimitive, ): AggregationExpression { @@ -645,18 +922,15 @@ export function max( } /** - * GROUP_CONCAT aggregation + * Concatenate values into a single string. * - * @example - * ```ts - * // GROUP_CONCAT(?name) - * groupConcat(variable('name')) + * Joins multiple values with an optional separator. Useful for creating + * comma-separated lists or similar aggregations. * - * // GROUP_CONCAT(?name; separator=", ") - * groupConcat(variable('name'), ', ') - * - * // GROUP_CONCAT(?name) AS ?names - * groupConcat(variable('name')).as('names') + * @example Author list + * ```ts + * select([groupConcat(v('author'), ', ').as('authors')]) + * // SELECT GROUP_CONCAT(?author; separator=", ") AS ?authors * ``` */ export function groupConcat( @@ -680,12 +954,13 @@ export function groupConcat( } /** - * SAMPLE aggregation + * Return an arbitrary value from the group. * - * Returns 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) -} +} \ No newline at end of file