diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d8a4a2 --- /dev/null +++ b/README.md @@ -0,0 +1,610 @@ +# SPARQL Query Builder + +A type-safe, fluent query builder for SPARQL inspired by [Drizzle ORM](https://orm.drizzle.team/) and [Supabase PostgREST](https://postgrest.org/). Makes graph database queries readable, maintainable, and enjoyable to write. + +```typescript +// Instead of this verbose SPARQL: +const rawQuery = ` + PREFIX foaf: + SELECT ?name ?age WHERE { + ?person a foaf:Person ; + foaf:name ?name ; + foaf:age ?age . + FILTER(?age >= 18) + } + ORDER BY ?name + LIMIT 10 +` + +// Write this beautiful, type-safe code: +const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) + +const result = await select(['?name', '?age']) + .where(person) + .filter(gte(variable('age'), 18)) + .orderBy('?name') + .limit(10) + .execute(config) +``` + +--- + +## Why This Exists + +**The Problem:** SPARQL queries are powerful but painful to write. They're verbose, error-prone, and hard to maintain. When building [Pop Modern](https://popmodern.co)—a comic book discovery platform with Neptune graph database and Supabase PostgreSQL—we needed a better way to query our knowledge graph. + +**The Solution:** Bring the delightful developer experience of Drizzle and PostgREST to graph databases. Write queries that are: +- **Type-safe** - Catch errors at compile time +- **Readable** - Self-documenting, semantic patterns +- **Composable** - Build complex queries from simple parts +- **Maintainable** - Refactor with confidence + +--- + +## Installation + +```bash +# Deno +deno add jsr:@your-org/sparql-builder + +# Node/npm (coming soon) +npm install sparql-builder +``` + +--- + +## Quick Start + +### Basic Query + +```typescript +import { node, select, variable, gte } from 'sparql-builder' + +const config = { + endpoint: 'http://localhost:9999/blazegraph/sparql', +} + +// Find adults +const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) + +const result = await select(['?name', '?age']) + .where(person) + .filter(gte(variable('age'), 18)) + .orderBy('?name') + .limit(10) + .execute(config) + +if (result.success) { + console.log(result.data.results.bindings) +} else { + console.error(result.error.type, result.error.message) +} +``` + +### Relationships + +```typescript +import { node, rel, select } from 'sparql-builder' + +// Find friends of friends +const alice = node('alice', 'foaf:Person') + .with.prop('foaf:name', str('Alice')) + +const friend = node('friend', 'foaf:Person') + .with.prop('foaf:name', variable('friendName')) + +const friendOfFriend = node('fof', 'foaf:Person') + .with.prop('foaf:name', variable('fofName')) + +const result = await select(['?friendName', '?fofName']) + .where(alice) + .where(friend) + .where(friendOfFriend) + .where(rel('alice', 'foaf:knows', 'friend')) + .where(rel('friend', 'foaf:knows', 'fof')) + .execute(config) +``` + +--- + +## Core Concepts + +### The Chai Pattern: Semantic Readability + +The `.is.a()` and `.with.prop()` syntax isn't just sugar—it's **semantic markers** that make complex queries parseable at a glance: + +```typescript +const issue = node('issue', Types.Product) + .is.a('narrative:ComicIssue') // What it IS + .with.prop('narrative:issueNumber', variable('num')) // What it HAS + .and.prop('narrative:variantType', variable('variant')) + .that.prop('narrative:issueNumber', 1) // Constraints THAT filter it +``` + +When queries get complex (5+ nodes, multiple relationships), this visual separation becomes critical: + +```typescript +// Instantly readable: types → properties → filters +const character = node('character', Types.Character) + .is.a('narrative:Character') + .with.prop(Props.characterName, 'Spider-Man') + .and.prop('narrative:firstAppearance', variable('firstApp')) + .that.prop('narrative:status', 'active') +``` + +### Fluent Patterns + +Build patterns that read like natural language: + +```typescript +// Nodes (resources) +const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:email', variable('email')) + +// Relationships +const knows = rel('person', 'foaf:knows', 'friend') + +// Relationships with properties (reification) +const credit = rel('creator', 'narrative:createdWork', 'comic') + .with.prop('narrative:confidence', variable('confidence')) + .and.prop('narrative:source', str('marvel-api')) +``` + +### Type Safety + +Leverage TypeScript for compile-time safety: + +```typescript +// Discriminated union for error handling +const result = await select(['?name']).where(person).execute(config) + +if (result.success) { + // Type: SparqlSuccess + const bindings = result.data.results.bindings +} else { + // Type: SparqlFailure + switch (result.error.type) { + case 'syntax': + console.error('Invalid SPARQL:', result.error.message) + break + case 'timeout': + console.error('Query timeout:', result.error.message) + break + case 'unavailable': + console.error('Endpoint down:', result.error.message) + break + } +} +``` + +--- + +## API Reference + +### Pattern Builders + +#### `node(name, type?, options?)` + +Create a node pattern representing a resource. + +```typescript +// Simple node +const person = node('person', 'foaf:Person') + +// Node with properties +const person = node('person', 'foaf:Person', { + 'foaf:name': variable('name'), + 'foaf:age': variable('age'), +}) + +// Multiple types +const item = node('item') + .is.a('narrative:Product') + .and.a('schema:Thing') + +// Nested nodes +const comic = node('comic', Types.Product, { + 'narrative:publishedBy': node('publisher', Types.Organization, { + 'rdfs:label': str('Marvel Comics'), + }), +}) +``` + +**Fluent methods:** +- `.is.a(type)` - Add RDF type +- `.with.prop(predicate, value)` - Add property +- `.and.prop(predicate, value)` - Chain properties +- `.that.prop(predicate, value)` - Add filter constraint + +#### `rel(from, predicate, to)` + +Create a relationship pattern between two nodes. + +```typescript +// Simple relationship +const knows = rel('person', 'foaf:knows', 'friend') + +// Relationship with properties (reification) +const credit = rel('creator', 'narrative:createdWork', 'comic') + .with.prop('narrative:confidence', 0.95) + .and.prop('narrative:role', variable('role')) +``` + +### Query Builder + +#### `select(variables)` + +Start a SELECT query. + +```typescript +const query = select(['?name', '?age']) + .where(person) + .orderBy('?name') + .limit(10) + +// Or select all +const query = select('*').where(person) +``` + +**Chainable methods:** +- `.where(pattern)` - Add WHERE pattern +- `.filter(condition)` - Add FILTER condition +- `.optional(pattern)` - Add OPTIONAL pattern +- `.bind(expression, variable)` - Add BIND expression +- `.union(...branches)` - Add UNION branches +- `.orderBy(variable, direction?)` - Add ORDER BY +- `.limit(count)` - Add LIMIT +- `.offset(count)` - Add OFFSET +- `.distinct()` - Use DISTINCT modifier +- `.reduced()` - Use REDUCED modifier + +#### `ask()` + +Create an ASK query (boolean result). + +```typescript +const exists = await ask() + .where(node('person', 'foaf:Person')) + .execute(config) + +if (exists.success && exists.data.boolean) { + console.log('At least one person exists') +} +``` + +### Value Constructors + +```typescript +// Variables +variable('name') // ?name + +// Literals +str('Alice') // "Alice" +num(42) // 42 +bool(true) // true +date('2024-01-15') // "2024-01-15"^^xsd:date + +// IRIs +uri('http://example.org/person/1') // +prefixed('foaf:Person') // foaf:Person +``` + +### Expression Helpers + +```typescript +// Comparisons +eq(variable('age'), 30) // ?age = 30 +ne(variable('age'), 30) // ?age != 30 +lt(variable('age'), 30) // ?age < 30 +lte(variable('age'), 30) // ?age <= 30 +gt(variable('age'), 30) // ?age > 30 +gte(variable('age'), 30) // ?age >= 30 + +// Logical operators +and(condition1, condition2) // condition1 && condition2 +or(condition1, condition2) // condition1 || condition2 +not(condition) // !condition + +// String functions +regex(variable('name'), '^Alice', 'i') // REGEX(?name, "^Alice", "i") +concat(str('Hello '), variable('name')) // CONCAT("Hello ", ?name) +strlen(variable('name')) // STRLEN(?name) + +// Aggregations +count(variable('person')) // COUNT(?person) +sum(variable('amount')) // SUM(?amount) +avg(variable('score')) // AVG(?score) +min(variable('date')) // MIN(?date) +max(variable('date')) // MAX(?date) +``` + +### Execution + +#### `execute(config)` + +Execute a query against a SPARQL endpoint. + +```typescript +const config = { + endpoint: 'http://localhost:9999/blazegraph/sparql', + timeout: 30000, // Optional, default 30000ms + headers: { // Optional + 'Authorization': 'Bearer token', + }, +} + +const result = await select(['?name']) + .where(person) + .execute(config) + +// Result is a discriminated union +if (result.success) { + // Type: SparqlSuccess + const data: SparqlResponse = result.data +} else { + // Type: SparqlFailure + const error: SparqlError = result.error +} +``` + +#### Error Types + +```typescript +type SparqlErrorType = + | 'syntax' // 400 - Malformed SPARQL + | 'timeout' // AbortError - Query timeout + | 'unavailable' // Cannot connect to endpoint + | 'database' // 5xx - Database error + | 'unknown' // Unexpected error +``` + +#### Result Transformation + +```typescript +import { transformResults, extractUris } from 'sparql-builder' + +const result = await select(['?name', '?age']).where(person).execute(config) + +if (result.success) { + // Transform to simple objects + const rows = transformResults(result.data) + // [{ name: 'Alice', age: '30' }, { name: 'Bob', age: '25' }] + + // Extract all URIs + const uris = extractUris(result.data) + // ['http://example.org/person/1', 'http://example.org/person/2'] +} +``` + +--- + +## Domain-Specific Patterns + +For domain-specific applications (like Pop Modern), create helper constants: + +```typescript +// types.ts +export const Types = { + Character: raw('narrative:Character'), + Series: raw('narrative:Series'), + Product: raw('narrative:Product'), + Person: raw('narrative:Person'), + Organization: raw('narrative:Organization'), +} + +export const Relationships = { + createdBy: raw('narrative:createdBy'), + publishedBy: raw('narrative:publishedBy'), + featuresCharacter: raw('narrative:featuresCharacter'), + partOfSeries: raw('narrative:partOfSeries'), +} + +export const Props = { + name: raw('rdfs:label'), + characterName: raw('narrative:characterName'), + seriesName: raw('narrative:seriesName'), + productTitle: raw('narrative:productTitle'), + issueNumber: raw('narrative:issueNumber'), +} + +// Usage +const comic = node('comic', Types.Product) + .with.prop(Props.productTitle, variable('title')) + .and.prop(Props.issueNumber, variable('num')) +``` + +--- + +## Real-World Example: Pop Modern + +Finding Amazing Spider-Man #1 variants with creator credits: + +```typescript +const series = node('series', Types.Series) + .with.prop(Props.seriesName, 'Amazing Spider-Man') + +const issue = node('issue', Types.Product) + .with.prop(Props.issueNumber, variable('issueNum')) + .and.prop('narrative:variantType', variable('variantType')) + .that.prop(Props.issueNumber, 1) + +const creator = node('creator', Types.Person) + .with.prop(Props.name, variable('creatorName')) + .and.prop('narrative:role', variable('role')) + +const publisher = node('publisher', Types.Organization) + .with.prop(Props.name, variable('publisherName')) + +const result = await select([ + '?issueNum', + '?variantType', + '?creatorName', + '?role', + '?publisherName', +]) + .where(series) + .where(issue) + .where(creator) + .where(publisher) + .where(rel(issue, Relationships.partOfSeries, series)) + .where(rel(issue, Relationships.publishedBy, publisher)) + .where( + rel(creator, Relationships.createdBy, issue) + .with.prop('narrative:confidence', variable('confidence')) + ) + .filter(gte(variable('confidence'), 0.8)) + .orderBy('?variantType') + .limit(50) + .execute(config) +``` + +Compare to raw SPARQL—the builder version is: +- **50% shorter** +- **Type-safe** at compile time +- **Self-documenting** with semantic markers +- **Refactorable** without string manipulation + +--- + +## Examples + +See the [`examples/`](./examples) directory for complete working examples: + +- **[basic.ts](./examples/basic.ts)** - Simple queries, filtering, sorting +- **[complex.ts](./examples/complex.ts)** - Relationships, multi-hop, aggregations +- **[pop-modern.ts](./examples/pop-modern.ts)** - Real-world comic book queries + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Query Builder │ +│ (Drizzle-inspired chainable interface) │ +└─────────────┬───────────────────────────────────┘ + │ + ├─► Builds SparqlValue objects + │ +┌─────────────▼───────────────────────────────────┐ +│ Pattern Builders │ +│ (Chai-inspired semantic markers) │ +│ │ +│ • node() - Resource patterns │ +│ • rel() - Relationship patterns │ +│ • match() - Pattern composition │ +└─────────────┬───────────────────────────────────┘ + │ + ├─► Generates SPARQL strings + │ +┌─────────────▼───────────────────────────────────┐ +│ Executor │ +│ (Functional execution with discriminated │ +│ union error handling) │ +└─────────────┬───────────────────────────────────┘ + │ + ├─► HTTP POST to SPARQL endpoint + │ +┌─────────────▼───────────────────────────────────┐ +│ SPARQL Endpoint │ +│ (Blazegraph, Neptune, Virtuoso, etc.) │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Design Philosophy + +### 1. Drizzle-Inspired Query Building + +Like Drizzle, we prioritize: +- **Type safety** over string templates +- **Method chaining** over configuration objects +- **Composability** over monolithic builders + +### 2. PostgREST-Inspired Clarity + +Like PostgREST, we believe: +- **URLs should be readable** → Queries should be readable +- **REST verbs map to SQL** → Methods map to SPARQL clauses +- **Composable filters** → Composable patterns + +### 3. Chai-Inspired Semantics + +Like Chai assertions, we use: +- **Semantic markers** (`.is`, `.with`, `.and`, `.that`) for visual chunking +- **Natural language flow** for cognitive ease +- **Progressive disclosure** of complexity + +### 4. Graph-First Thinking + +Unlike SQL builders, we embrace: +- **Relationships as first-class** patterns, not foreign keys +- **Multi-hop traversal** as natural operations +- **Reification** for relationship properties + +--- + +## Roadmap + +- [x] Core query builder (SELECT, ASK, CONSTRUCT, DESCRIBE) +- [x] Pattern builders (node, rel, match) +- [x] Expression helpers (filters, aggregations) +- [x] Discriminated union error handling +- [x] Result transformation utilities +- [ ] SPARQL 1.1 UPDATE support (INSERT, DELETE) +- [ ] Property path patterns (`foaf:knows+`, `foaf:knows*`) +- [ ] Federated queries (SERVICE) +- [ ] Query optimization hints +- [ ] Streaming results for large datasets +- [ ] Schema validation and inference +- [ ] GraphQL bridge + +--- + +## Contributing + +Contributions welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. + +### Development + +```bash +# Run tests +deno test + +# Run examples +deno run examples/basic.ts + +# Format code +deno fmt + +# Lint +deno lint +``` + +--- + +## License + +MIT License - see [LICENSE](./LICENSE) for details. + +--- + +## Acknowledgments + +Built for [Pop Modern](https://popmodern.co), a comic book discovery platform + +Inspired by: +- [Drizzle ORM](https://orm.drizzle.team/) - Type-safe SQL builder +- [Supabase PostgREST](https://postgrest.org/) - RESTful query interface +- [Chai](https://www.chaijs.com/) - Semantic assertion library +- [Cypher](https://neo4j.com/docs/cypher-manual/) - Graph query language + +--- + +Built with ❤️ for the graph database community. \ No newline at end of file diff --git a/builder.ts b/builder.ts index 848f72f..76a7caf 100644 --- a/builder.ts +++ b/builder.ts @@ -19,17 +19,15 @@ import { sparql, - uri, - variable, - prefixed, - date, - dateTime, - bind as bindExpr, - filter as filterExpr, - optional as optionalExpr, normalizeVariableName, type SparqlValue, } from './sparql.ts' +import { + bind as bindExpr, + exprTermString, + filter as filterExpr, + optional as optionalExpr, +} from './utils.ts' import { createExecutor, type ExecutorConfig, type SparqlResult } from './executor.ts' // ============================================================================ @@ -49,7 +47,7 @@ export type PatternLike = string | SparqlValue /** * Query projection (SELECT variables) */ -export type Projection = string[] | '*' +export type Projection = PatternLike[] | '*' /** * Sort direction @@ -361,7 +359,7 @@ export class QueryBuilder { const proj = this.state.projection === '*' ? '*' - : this.state.projection.join(' ') + : this.state.projection.map(x => exprTermString(x))?.join(' ') parts.push(`SELECT ${modifier}${proj}`) } else if (this.state.type === 'ASK') { parts.push('ASK') @@ -369,8 +367,8 @@ export class QueryBuilder { parts.push('CONSTRUCT') } else if (this.state.type === 'DESCRIBE') { const projection = Array.isArray(this.state.projection) - ? this.state.projection.join(' ') - : this.state.projection; + ? this.state.projection.map(x => exprTermString(x)).join(' ') + : exprTermString(this.state.projection); parts.push(`DESCRIBE ${projection}`) } diff --git a/examples/aggregations.ts b/examples/aggregations.ts new file mode 100644 index 0000000..f3282eb --- /dev/null +++ b/examples/aggregations.ts @@ -0,0 +1,277 @@ +/** + * Aggregation Functions Example + * + * Demonstrates SPARQL aggregation functions: + * - COUNT, SUM, AVG, MIN, MAX + * - GROUP_CONCAT, SAMPLE + * - Using .as() for variable binding + * - GROUP BY patterns + */ + +import { + node, + rel, + select, + variable, + count, + countDistinct, + sum, + avg, + min, + max, + groupConcat, + sample, + type ExecutorConfig, + transformResults, +} from '../mod.ts' + +const config: ExecutorConfig = { + endpoint: 'http://localhost:9999/blazegraph/sparql', + timeout: 30000, +} + +// ============================================================================ +// Example 1: COUNT - Simple Counting +// ============================================================================ + +async function countPeople() { + console.log('\n=== Counting total people ===\n') + + const person = node('person', 'foaf:Person') + + // COUNT(*) AS ?total + const result = await select([count().as('total')]) + .where(person) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Total people:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 2: COUNT with GROUP BY +// ============================================================================ + +async function countByAge() { + console.log('\n=== Counting people by age group ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:age', variable('age')) + + // COUNT(?person) AS ?count + const result = await select([ + '?age', + count(variable('person')).as('count') + ]) + .where(person) + .orderBy('?count', 'DESC') + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Count by age:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 3: COUNT DISTINCT +// ============================================================================ + +async function countUniqueNames() { + console.log('\n=== Counting unique names ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + + const result = await select([ + countDistinct(variable('name')).as('uniqueNames') + ]) + .where(person) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Unique names:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 4: SUM, AVG, MIN, MAX - Numeric Aggregations +// ============================================================================ + +async function numericAggregations() { + console.log('\n=== Numeric aggregations on age ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:age', variable('age')) + + const result = await select([ + sum(variable('age')).as('totalAge'), + avg(variable('age')).as('avgAge'), + min(variable('age')).as('minAge'), + max(variable('age')).as('maxAge'), + ]) + .where(person) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Age statistics:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 5: GROUP_CONCAT - String Aggregation +// ============================================================================ + +async function concatenateNames() { + console.log('\n=== Concatenating friend names ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('personName')) + + const friend = node('friend', 'foaf:Person') + .with.prop('foaf:name', variable('friendName')) + + const knows = rel('person', 'foaf:knows', 'friend') + + // GROUP_CONCAT(?friendName; separator=", ") AS ?allFriends + const result = await select([ + '?personName', + groupConcat(variable('friendName'), ', ').as('allFriends') + ]) + .where(person) + .where(friend) + .where(knows) + .orderBy('?personName') + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Friends list:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 6: SAMPLE - Arbitrary Value Selection +// ============================================================================ + +async function sampleValues() { + console.log('\n=== Sampling one value per group ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:age', variable('age')) + .and.prop('foaf:name', variable('name')) + + // Get one example name for each age + const result = await select([ + '?age', + sample(variable('name')).as('exampleName') + ]) + .where(person) + .orderBy('?age') + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Sample names by age:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 7: Multiple Aggregations with Filtering +// ============================================================================ + +async function complexAggregation() { + console.log('\n=== Complex aggregation with multiple metrics ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) + + const friend = node('friend', 'foaf:Person') + + const knows = rel('person', 'foaf:knows', 'friend') + + const result = await select([ + '?name', + '?age', + count(variable('friend')).as('friendCount'), + avg(variable('age')).as('avgAge'), + ]) + .where(person) + .where(friend) + .where(knows) + .orderBy('?friendCount', 'DESC') + .limit(10) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Complex aggregation:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 8: Pop Modern - Count Comics by Publisher +// ============================================================================ + +async function countComicsByPublisher() { + console.log('\n=== Counting comics by publisher ===\n') + + const comic = node('comic', 'narrative:Product') + + const publisher = node('publisher', 'narrative:Organization') + .with.prop('rdfs:label', variable('publisherName')) + + const publishedBy = rel('comic', 'narrative:publishedBy', 'publisher') + + const result = await select([ + '?publisherName', + count(variable('comic')).as('comicCount') + ]) + .where(comic) + .where(publisher) + .where(publishedBy) + .orderBy('?comicCount', 'DESC') + .limit(20) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Comics by publisher:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Run Examples +// ============================================================================ + +if (import.meta.main) { + await countPeople() + await countByAge() + await countUniqueNames() + await numericAggregations() + await concatenateNames() + await sampleValues() + await complexAggregation() + await countComicsByPublisher() +} \ No newline at end of file diff --git a/examples/basic.ts b/examples/basic.ts index 9ac9c80..e5a0fda 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -1,23 +1,147 @@ -#!/usr/bin/env -S deno run --allow-net +/** + * Basic Query Example + * + * Demonstrates fundamental usage of the query builder: + * - Creating node patterns + * - Using fluent property chains + * - Basic filtering and sorting + * - Query execution + */ -import { select, triple, createExecutor } from '../src/mod.ts' +import { + node, + select, + variable, + gte, + regex, + and, + type ExecutorConfig, +} from '../mod.ts' -const executor = createExecutor({ - endpoint: 'https://dbpedia.org/sparql' -}) +// Configure your SPARQL endpoint +const config: ExecutorConfig = { + endpoint: 'http://localhost:9999/blazegraph/sparql', + timeout: 30000, +} -const query = select(['?city', '?population']) - .where(triple('?city', 'a', 'dbo:City')) - .where(triple('?city', 'dbo:populationTotal', '?population')) - .limit(10) - .build() +// ============================================================================ +// Example 1: Simple Person Query +// ============================================================================ -console.log('Query:', query.value) +async function findPeopleByAge() { + console.log('\n=== Finding people over 18 ===\n') -const result = await executor(query) + // Build the query using fluent API + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) -if (result.ok) { - console.log('Results:', result.data) -} else { - console.error('Error:', result.error) + const result = await select(['?name', '?age']) + .where(person) + .filter(gte(variable('age'), 18)) + .orderBy('?age', 'DESC') + .limit(10) + .execute(config) + + if (result.success) { + console.log('Results:', result.data.results.bindings) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 2: Pattern Matching with Filters +// ============================================================================ + +async function findPeopleByNamePattern() { + console.log('\n=== Finding people with names starting with "John" ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:email', variable('email')) + + const nameCondition = regex(variable('name'), '^John', 'i') + + const result = await select(['?name', '?email']) + .where(person) + .filter(nameCondition) + .orderBy('?name') + .execute(config) + + if (result.success) { + console.log('Results:', result.data.results.bindings) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 3: Multiple Conditions +// ============================================================================ + +async function findAdultsWithEmail() { + console.log('\n=== Finding adults with email addresses ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) + .and.prop('foaf:email', variable('email')) + + // Combine multiple conditions + const conditions = and( + gte(variable('age'), 18), + regex(variable('email'), '@', 'i') + ) + + const result = await select(['?name', '?age', '?email']) + .where(person) + .filter(conditions) + .orderBy('?name') + .limit(20) + .execute(config) + + if (result.success) { + console.log('Results:', result.data.results.bindings) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 4: Using Domain Types +// ============================================================================ + +async function queryWithTypes() { + console.log('\n=== Query with explicit typing ===\n') + + // You can specify multiple types + const person = node('person') + .is.a('foaf:Person') + .and.a('schema:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('foaf:age', variable('age')) + + const result = await select(['?name', '?age']) + .where(person) + .orderBy('?name') + .limit(10) + .execute(config) + + if (result.success) { + console.log('Results:', result.data.results.bindings) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Run Examples +// ============================================================================ + +if (import.meta.main) { + await findPeopleByAge() + await findPeopleByNamePattern() + await findAdultsWithEmail() + await queryWithTypes() } \ No newline at end of file diff --git a/examples/complex.ts b/examples/complex.ts new file mode 100644 index 0000000..8c1ca49 --- /dev/null +++ b/examples/complex.ts @@ -0,0 +1,241 @@ +/** + * Complex Query Example + * + * Demonstrates advanced query patterns: + * - Relationships between nodes + * - Multi-hop graph traversal + * - Optional patterns + * - Aggregation and grouping + * - Union queries + */ + +import { + node, + rel, + select, + variable, + str, + gte, + count, + transformResults, + type ExecutorConfig, +} from '../mod.ts' + +const config: ExecutorConfig = { + endpoint: 'http://localhost:9999/blazegraph/sparql', + timeout: 30000, +} + +// ============================================================================ +// Example 1: Social Network Query +// ============================================================================ + +async function findFriendsOfFriends() { + console.log('\n=== Finding friends of friends ===\n') + + // Person A + const personA = node('personA', 'foaf:Person') + .with.prop('foaf:name', str('Alice')) + + // Person B (direct friend) + const personB = node('personB', 'foaf:Person') + .with.prop('foaf:name', variable('friendName')) + + // Person C (friend of friend) + const personC = node('personC', 'foaf:Person') + .with.prop('foaf:name', variable('friendOfFriendName')) + + // Relationships + const knowsB = rel('personA', 'foaf:knows', 'personB') + const knowsC = rel('personB', 'foaf:knows', 'personC') + + const result = await select(['?friendName', '?friendOfFriendName']) + .where(personA) + .where(personB) + .where(personC) + .where(knowsB) + .where(knowsC) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Friends of friends:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 2: Optional Properties +// ============================================================================ + +async function findPeopleWithOptionalEmail() { + console.log('\n=== Finding people (email optional) ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + + // Email is optional + const emailPattern = node('person') + .with.prop('foaf:email', variable('email')) + + const result = await select(['?name', '?email']) + .where(person) + .optional(emailPattern) + .orderBy('?name') + .limit(20) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('People (with/without email):', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 3: Aggregation - Count Friends +// ============================================================================ + +async function countFriendsPerPerson() { + console.log('\n=== Counting friends per person ===\n') + + const person = node('person', 'foaf:Person') + .with.prop('foaf:name', variable('name')) + + const friend = node('friend', 'foaf:Person') + + const knows = rel('person', 'foaf:knows', 'friend') + + // Use aggregation with GROUP BY + const result = await select(['?name', count(variable('friend')).as('friendCount')]) + .where(person) + .where(friend) + .where(knows) + .orderBy('?friendCount', 'DESC') + .limit(10) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Friend counts:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 4: Union - Find Multiple Types +// ============================================================================ + +async function findCreatorsAndPublishers() { + console.log('\n=== Finding creators OR publishers ===\n') + + // Branch 1: Creators + const creator = node('entity', 'narrative:Person') + .with.prop('foaf:name', variable('name')) + .and.prop('narrative:role', variable('role')) + + // Branch 2: Publishers + const publisher = node('entity', 'narrative:Organization') + .with.prop('rdfs:label', variable('name')) + + const result = await select(['?name', '?role']) + .union(creator, publisher) + .orderBy('?name') + .limit(20) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('Creators and publishers:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 5: Relationship with Properties (Reification) +// ============================================================================ + +async function findHighConfidenceConnections() { + console.log('\n=== Finding high-confidence relationships ===\n') + + const personA = node('personA', 'foaf:Person') + .with.prop('foaf:name', variable('nameA')) + + const personB = node('personB', 'foaf:Person') + .with.prop('foaf:name', variable('nameB')) + + // Relationship with confidence score + const connection = rel('personA', 'ex:relatedTo', 'personB') + .with.prop('ex:confidence', variable('confidence')) + .and.prop('ex:source', variable('source')) + + const result = await select(['?nameA', '?nameB', '?confidence', '?source']) + .where(personA) + .where(personB) + .where(connection) + .filter(gte(variable('confidence'), 0.8)) + .orderBy('?confidence', 'DESC') + .limit(10) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('High-confidence connections:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Example 6: Multi-Hop with Distance Limit +// ============================================================================ + +async function findPeopleWithinTwoHops() { + console.log('\n=== Finding people within 2 hops ===\n') + + const start = node('start', 'foaf:Person') + .with.prop('foaf:name', str('Alice')) + + const hop1 = node('hop1', 'foaf:Person') + .with.prop('foaf:name', variable('hop1Name')) + + const hop2 = node('hop2', 'foaf:Person') + .with.prop('foaf:name', variable('hop2Name')) + + const knows1 = rel('start', 'foaf:knows', 'hop1') + const knows2 = rel('hop1', 'foaf:knows', 'hop2') + + const result = await select(['?hop1Name', '?hop2Name']) + .where(start) + .where(hop1) + .where(hop2) + .where(knows1) + .where(knows2) + .distinct() + .limit(50) + .execute(config) + + if (result.success) { + const rows = transformResults(result.data) + console.log('People within 2 hops:', rows) + } else { + console.error('Query failed:', result.error) + } +} + +// ============================================================================ +// Run Examples +// ============================================================================ + +if (import.meta.main) { + await findFriendsOfFriends() + await findPeopleWithOptionalEmail() + await countFriendsPerPerson() + await findCreatorsAndPublishers() + await findHighConfidenceConnections() + await findPeopleWithinTwoHops() +} \ No newline at end of file diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..f4f763a --- /dev/null +++ b/mod.ts @@ -0,0 +1,36 @@ +/** + * SPARQL Query Builder + * + * A type-safe, fluent query builder for SPARQL inspired by Drizzle ORM and + * Supabase PostgREST.js. Makes graph database queries readable and maintainable. + * + * @example + * ```typescript + * import { node, rel, select, variable, execute } from './mod.ts' + * + * const person = node('person', 'foaf:Person') + * .with.prop('foaf:name', variable('name')) + * .and.prop('foaf:age', variable('age')) + * + * const result = await select(['?name', '?age']) + * .where(person) + * .filter(gte(variable('age'), 18)) + * .orderBy('?name') + * .limit(10) + * .execute({ endpoint: 'http://localhost:9999/sparql' }) + * ``` + * + * @module + */ + +// ============================================================================ +// Core SPARQL Value Types & Helpers +// ============================================================================ + +export * from './sparql.ts' +export * from './utils.ts' +export * from './patterns/triples.ts' +export * from './patterns/objects.ts' +export * from './patterns/cypher.ts' +export * from './builder.ts' +export * from './executor.ts' \ No newline at end of file diff --git a/patterns/objects.ts b/patterns/objects.ts index 1d09f69..dc75160 100644 --- a/patterns/objects.ts +++ b/patterns/objects.ts @@ -463,9 +463,10 @@ export class Relationship implements SparqlValue { // Otherwise, reify with a blank node. const edgeId = this.getEdgeId() const poMap: RelationshipPropertyMap = { - 'rdf:type': 'narrative:Relationship', - 'narrative:from': this.fromTerm, - 'narrative:to': this.toTerm, + 'rdf:type': 'rdf:Statement', + 'rdf:subject': this.fromTerm, + 'rdf:predicate': this.predicate, + 'rdf:object': this.toTerm, ...this.properties, } @@ -532,52 +533,6 @@ export function match( return raw(`${built.join('\n ')}`) } -// ============================================================================ -// Common Domain Patterns -// ============================================================================ - -/** - * PopModern narrative types (convenience) - */ -export const Types = { - Character: raw('narrative:Character'), - Series: raw('narrative:Series'), - Product: raw('narrative:Product'), - Person: raw('narrative:Person'), - Organization: raw('narrative:Organization'), - Universe: raw('narrative:Universe'), - StoryWork: raw('narrative:StoryWork'), -} - -/** - * PopModern narrative relationships (convenience) - */ -export const Relationships = { - createdBy: raw('narrative:createdBy'), - publishedBy: raw('narrative:publishedBy'), - featuresCharacter: raw('narrative:featuresCharacter'), - partOfSeries: raw('narrative:partOfSeries'), - partOfUniverse: raw('narrative:partOfUniverse'), - adaptationOf: raw('narrative:adaptationOf'), - - // FOAF relationships - knows: raw('foaf:knows'), - member: raw('foaf:member'), -} - -/** - * Common predicates (convenience) - */ -export const Props = { - name: raw('rdfs:label'), - characterName: raw('narrative:characterName'), - seriesName: raw('narrative:seriesName'), - productTitle: raw('narrative:productTitle'), - releaseDate: raw('narrative:releaseDate'), - storeDate: raw('narrative:storeDate'), - issueNumber: raw('narrative:issueNumber'), -} - export function simpleHash(str: string): string { let hash = 0 for (let i = 0; i < str.length; i++) { diff --git a/sparql.ts b/sparql.ts index 570a9ae..0f2dc51 100644 --- a/sparql.ts +++ b/sparql.ts @@ -50,6 +50,7 @@ export type SparqlInterpolatable = | SparqlInterpolatable[] | { [key: string]: SparqlInterpolatable } + /** * Variable name in SPARQL (without ? or $ prefix) */ @@ -88,7 +89,7 @@ export function normalizeVariableName(name: string | `?${string}`): VariableName * * Escapes: backslash, double-quote, newline, carriage return, tab */ -function escapeString(str: string): string { +export function escapeString(str: string): string { return str .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') @@ -102,7 +103,7 @@ function escapeString(str: string): string { * * Must be http/https and not contain forbidden characters */ -function validateIRI(iri: string): void { +export function validateIRI(iri: string): void { if (!iri.startsWith('http://') && !iri.startsWith('https://')) { throw new Error(`IRI must start with http:// or https://, got: ${iri}`) } @@ -118,7 +119,7 @@ function validateIRI(iri: string): void { /** * Validate variable name (must be valid SPARQL variable) */ -function validateVariableName(name: string): void { +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}`) @@ -128,7 +129,7 @@ function validateVariableName(name: string): void { /** * Validate prefix name (must be valid SPARQL prefix) */ -function validatePrefixName(name: string): void { +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}`) @@ -142,14 +143,14 @@ function validatePrefixName(name: string): void { /** * Convert Date to xsd:dateTime literal */ -function formatDateTime(date: Date): string { +export function formatDateTime(date: Date): string { return `"${date.toISOString()}"^^` } /** * Convert Date to xsd:date literal (date only, no time) */ -function formatDate(date: Date): string { +export function formatDate(date: Date): string { const yyyy = date.getFullYear() const mm = String(date.getMonth() + 1).padStart(2, '0') const dd = String(date.getDate()).padStart(2, '0') @@ -159,7 +160,7 @@ function formatDate(date: Date): string { /** * Convert array to SPARQL VALUES clause or list */ -function formatArray(arr: SparqlInterpolatable[]): string { +export function formatArray(arr: SparqlInterpolatable[]): string { if (arr.length === 0) { throw new Error('Cannot convert empty array to SPARQL') } @@ -189,7 +190,7 @@ function formatArray(arr: SparqlInterpolatable[]): string { /** * Convert object to SPARQL inline data or blank node */ -function formatObject(obj: { [key: string]: SparqlInterpolatable }): string { +export function formatObject(obj: { [key: string]: SparqlInterpolatable }): string { const entries = Object.entries(obj) if (entries.length === 0) { throw new Error('Cannot convert empty object to SPARQL') @@ -215,7 +216,7 @@ function formatObject(obj: { [key: string]: SparqlInterpolatable }): string { /** * Convert any value to SPARQL representation */ -function convertValue(value: SparqlInterpolatable, strict = true): string { +export function convertValue(value: SparqlInterpolatable, strict = true): string { // Already wrapped SparqlValue if (isSparqlValue(value)) { return value.value @@ -266,7 +267,7 @@ function convertValue(value: SparqlInterpolatable, strict = true): string { /** * Type guard for SparqlValue */ -function isSparqlValue(value: unknown): value is SparqlValue { +export function isSparqlValue(value: unknown): value is SparqlValue { return ( typeof value === 'object' && value !== null && @@ -278,44 +279,10 @@ function isSparqlValue(value: unknown): value is SparqlValue { /** * Wrap string as SparqlValue (for internal use) */ -function wrapSparqlValue(value: string): SparqlValue { +export function wrapSparqlValue(value: string): SparqlValue { return { __sparql: true, value: value } } -// ============================================================================ -// Main Template Tag Function -// ============================================================================ - -/** - * SPARQL template tag for type-safe query construction - * - * @example - * ```ts - * const query = sparql` - * SELECT ?name ?age WHERE { - * ?person foaf:name ${name} ; - * foaf:age ${age} . - * } - * `; - * ``` - */ -export function sparql( - strings: TemplateStringsArray, - ...values: SparqlInterpolatable[] -): SparqlValue { - let result = strings[0] - - for (let i = 0; i < values.length; i++) { - result += convertValue(values[i]) - result += strings[i + 1] - } - - return wrapSparqlValue(outdent.string(result)) -} - -// ============================================================================ -// Explicit Constructors -// ============================================================================ /** * Create IRI reference @@ -493,458 +460,37 @@ export function raw(value: string): SparqlValue { return wrapSparqlValue(value) } -/** - * Create VALUES clause for multiple values - * - * @example - * values('city', ['London', 'Paris', 'Tokyo']) - * → VALUES ?city { "London" "Paris" "Tokyo" } - */ -export function values( - varName: VariableName, - items: SparqlInterpolatable[] -): SparqlValue { - validateVariableName(varName) - const converted = items.map((item) => convertValue(item)).join(' ') - return wrapSparqlValue(`VALUES ?${varName} { ${converted} }`) -} - -/** - * Create FILTER expression - * - * @example - * filter(sparql`?age > ${18}`) - * → FILTER(?age > 18) - */ -export function filter(expression: SparqlValue): SparqlValue { - return wrapSparqlValue(`FILTER(${expression.value})`) -} - -/** - * Create OPTIONAL block - * - * @example - * optional(sparql`?person foaf:email ?email`) - * → OPTIONAL { ?person foaf:email ?email } - */ -export function optional(pattern: SparqlValue): SparqlValue { - return wrapSparqlValue(`OPTIONAL { ${pattern.value} }`) -} - -/** - * Create BIND expression - * - * @example - * bind(sparql`CONCAT(?firstName, " ", ?lastName)`, 'fullName') - * → BIND(CONCAT(?firstName, " ", ?lastName) AS ?fullName) - */ -export function bind(expression: SparqlValue, varName: VariableName): SparqlValue { - const _varName = normalizeVariableName(varName) - validateVariableName(_varName) - return wrapSparqlValue(`BIND(${expression.value} AS ?${_varName})`) -} - // ============================================================================ -// Expression helpers (Drizzle-like SPARQL operations) +// Main Template Tag Function // ============================================================================ -export type ExpressionPrimitive = - | string - | number - | boolean - | Date - | null - | 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) - */ -export function exprTerm( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - 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. - */ -export function exprTermString( - value: SparqlValue | ExpressionPrimitive, -): string { - return exprTerm(value).value -} - -/** - * CONCAT(arg1, arg2, ...) - * - * @example - * ```ts - * const fullName = concat(variable('firstName'), ' ', variable('lastName')) - * const q = select(['?fullName']) - * .bind(fullName, 'fullName') - * ``` - */ -export function concat( - ...args: Array -): SparqlValue { - if (args.length === 0) { - // CONCAT() is invalid; return an empty string literal - return strlit('') // """" - } - - const inner = args.map(exprTermString).join(', ') - return raw(`CONCAT(${inner})`) -} - -export function str(value: SparqlValue | ExpressionPrimitive): SparqlValue { - return raw(`STR(${exprTermString(value)})`) -} - -export function strlen( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`STRLEN(${exprTermString(value)})`) -} - -export function ucase(value: SparqlValue | ExpressionPrimitive): SparqlValue { - return raw(`UCASE(${exprTermString(value)})`) -} - -export function lcase(value: SparqlValue | ExpressionPrimitive): SparqlValue { - return raw(`LCASE(${exprTermString(value)})`) -} - - -export function contains( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw( - `CONTAINS(${exprTermString(text)}, ${exprTermString(pattern)})`, - ) -} - -export function startsWith( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw( - `STRSTARTS(${exprTermString(text)}, ${exprTermString(pattern)})`, - ) -} - -export function endsWith( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw( - `STRENDS(${exprTermString(text)}, ${exprTermString(pattern)})`, - ) -} - -export function substr( - text: SparqlValue | ExpressionPrimitive, - start: SparqlValue | ExpressionPrimitive, - length?: SparqlValue | ExpressionPrimitive, -): SparqlValue { - const t = exprTermString(text) - const s = exprTermString(start) - if (length === undefined) { - return raw(`SUBSTR(${t}, ${s})`) - } - const l = exprTermString(length) - return raw(`SUBSTR(${t}, ${s}, ${l})`) -} - -export function replaceStr( - text: SparqlValue | ExpressionPrimitive, - pattern: SparqlValue | ExpressionPrimitive, - replacement: SparqlValue | ExpressionPrimitive, -): SparqlValue { - const textTerm = exprTermString(text) - const patternTerm = exprTermString(pattern) - const replacementTerm = exprTermString(replacement) - return raw(`REPLACE(${textTerm}, ${patternTerm}, ${replacementTerm})`) -} - - /** - * REGEX helper - * + * SPARQL template tag for type-safe query construction + * * @example * ```ts - * const condition = regex(variable('name'), '^Spidey', 'i') - * builder.filter(condition) + * const query = sparql` + * SELECT ?name ?age WHERE { + * ?person foaf:name ${name} ; + * foaf:age ${age} . + * } + * `; * ``` */ -export function regex( - text: SparqlValue | ExpressionPrimitive, - pattern: string, - flags?: string, -): SparqlValue { - const textTerm = exprTermString(text) - const patternTerm = exprTermString(pattern) - const flagsTerm = flags ? `, ${exprTermString(flags)}` : '' - return raw(`REGEX(${textTerm}, ${patternTerm}${flagsTerm})`) -} - - -// Nullish / list helpers (Drizzle-like) - -export function isNull( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`!BOUND(${exprTermString(value)})`) -} - -export function isNotNull( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`BOUND(${exprTermString(value)})`) -} - -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})`) -} - -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})`) -} - -export function between( - expr: SparqlValue | ExpressionPrimitive, - low: SparqlValue | ExpressionPrimitive, - high: SparqlValue | ExpressionPrimitive, +export function sparql( + strings: TemplateStringsArray, + ...values: SparqlInterpolatable[] ): SparqlValue { - const e = exprTermString(expr) - const l = exprTermString(low) - const h = exprTermString(high) - return raw(`(${e} >= ${l} && ${e} <= ${h})`) -} + let result = strings[0] -export function coalesce( - ...values: Array -): SparqlValue { - if (values.length === 0) { - return strlit('') + for (let i = 0; i < values.length; i++) { + result += convertValue(values[i]) + result += strings[i + 1] } - const inner = values.map(exprTermString).join(', ') - return raw(`COALESCE(${inner})`) -} - -export function ifElse( - condition: SparqlValue, - whenTrue: SparqlValue | ExpressionPrimitive, - whenFalse: SparqlValue | ExpressionPrimitive, -): SparqlValue { - const trueTerm = exprTermString(whenTrue); - const falseTerm = exprTermString(whenFalse) - return raw( - `IF(${condition.value}, ${trueTerm}, ${falseTerm})`, - ) -} - - -// ---- Numeric helpers ---- - -export function add( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} + ${exprTermString(right)}`) -} - -export function sub( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} - ${exprTermString(right)}`) -} - -export function mul( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} * ${exprTermString(right)}`) -} - -export function div( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} / ${exprTermString(right)}`) -} - -export function mod( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`(${exprTermString(left)} % ${exprTermString(right)})`) -} - -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)})`) -} - -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( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`MONTH(${exprTermString(value)})`) -} - -export function dayFn( - value: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`DAY(${exprTermString(value)})`) -} - - -/** - * Comparison helpers - * - * These all build simple binary expressions and return them as SparqlValue. - */ - -export function eq( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} = ${exprTermString(right)}`) -} - -export function neq( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} != ${exprTermString(right)}`) -} - -export function gt( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} > ${exprTermString(right)}`) -} - -export function gte( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} >= ${exprTermString(right)}`) -} - -export function lt( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} < ${exprTermString(right)}`) -} - -export function lte( - left: SparqlValue | ExpressionPrimitive, - right: SparqlValue | ExpressionPrimitive, -): SparqlValue { - return raw(`${exprTermString(left)} <= ${exprTermString(right)}`) -} - -/** - * Logical helpers - */ - -export function and( - ...conditions: SparqlValue[] -): SparqlValue { - if (conditions.length === 0) return raw('true') - if (conditions.length === 1) return conditions[0] - return raw(conditions.map((c) => c.value).join(' && ')) -} - -export function or( - ...conditions: SparqlValue[] -): SparqlValue { - if (conditions.length === 0) return raw('false') - if (conditions.length === 1) return conditions[0] - return raw(conditions.map((c) => c.value).join(' || ')) -} - -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')) - */ -export function exists(pattern: SparqlValue): SparqlValue { - return raw(`EXISTS { ${pattern.value} }`) -} - -export function notExists(pattern: SparqlValue): SparqlValue { - return raw(`NOT EXISTS { ${pattern.value} }`) + return wrapSparqlValue(outdent.string(result)) } - - - // ============================================================================ // Re-export for convenience // ============================================================================ diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..e15e2b9 --- /dev/null +++ b/utils.ts @@ -0,0 +1,691 @@ +import { convertValue, isSparqlValue, normalizeVariableName, raw, strlit, validateVariableName, wrapSparqlValue, type VariableName, type SparqlInterpolatable, type SparqlValue } from './sparql.ts' + +/** + * Create VALUES clause for multiple values + * + * @example + * values('city', ['London', 'Paris', 'Tokyo']) + * → VALUES ?city { "London" "Paris" "Tokyo" } + */ +export function values( + varName: VariableName, + items: SparqlInterpolatable[] +): SparqlValue { + validateVariableName(varName) + const converted = items.map((item) => convertValue(item)).join(' ') + return wrapSparqlValue(`VALUES ?${varName} { ${converted} }`) +} + +/** + * Create FILTER expression + * + * @example + * filter(sparql`?age > ${18}`) + * → FILTER(?age > 18) + */ +export function filter(expression: SparqlValue): SparqlValue { + return wrapSparqlValue(`FILTER(${expression.value})`) +} + +/** + * Create OPTIONAL block + * + * @example + * optional(sparql`?person foaf:email ?email`) + * → OPTIONAL { ?person foaf:email ?email } + */ +export function optional(pattern: SparqlValue): SparqlValue { + return wrapSparqlValue(`OPTIONAL { ${pattern.value} }`) +} + +/** + * Create BIND expression + * + * @example + * bind(sparql`CONCAT(?firstName, " ", ?lastName)`, 'fullName') + * → BIND(CONCAT(?firstName, " ", ?lastName) AS ?fullName) + */ +export function bind(expression: SparqlValue, varName: VariableName): SparqlValue { + const _varName = normalizeVariableName(varName) + validateVariableName(_varName) + return wrapSparqlValue(`BIND(${expression.value} AS ?${_varName})`) +} + +// ============================================================================ +// Expression helpers (Drizzle-like SPARQL operations) +// ============================================================================ + +export type ExpressionPrimitive = + | string + | number + | boolean + | Date + | null + | 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) + */ +export function exprTerm( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + 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. + */ +export function exprTermString( + value: SparqlValue | ExpressionPrimitive, +): string { + return exprTerm(value).value +} + +/** + * CONCAT(arg1, arg2, ...) + * + * @example + * ```ts + * const fullName = concat(variable('firstName'), ' ', variable('lastName')) + * const q = select(['?fullName']) + * .bind(fullName, 'fullName') + * ``` + */ +export function concat( + ...args: Array +): SparqlValue { + if (args.length === 0) { + // CONCAT() is invalid; return an empty string literal + return strlit('') // """" + } + + const inner = args.map(exprTermString).join(', ') + return raw(`CONCAT(${inner})`) +} + +export function str(value: SparqlValue | ExpressionPrimitive): SparqlValue { + return raw(`STR(${exprTermString(value)})`) +} + +export function strlen( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`STRLEN(${exprTermString(value)})`) +} + +export function ucase(value: SparqlValue | ExpressionPrimitive): SparqlValue { + return raw(`UCASE(${exprTermString(value)})`) +} + +export function lcase(value: SparqlValue | ExpressionPrimitive): SparqlValue { + return raw(`LCASE(${exprTermString(value)})`) +} + + +export function contains( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw( + `CONTAINS(${exprTermString(text)}, ${exprTermString(pattern)})`, + ) +} + +export function startsWith( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw( + `STRSTARTS(${exprTermString(text)}, ${exprTermString(pattern)})`, + ) +} + +export function endsWith( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw( + `STRENDS(${exprTermString(text)}, ${exprTermString(pattern)})`, + ) +} + +export function substr( + text: SparqlValue | ExpressionPrimitive, + start: SparqlValue | ExpressionPrimitive, + length?: SparqlValue | ExpressionPrimitive, +): SparqlValue { + const t = exprTermString(text) + const s = exprTermString(start) + if (length === undefined) { + return raw(`SUBSTR(${t}, ${s})`) + } + const l = exprTermString(length) + return raw(`SUBSTR(${t}, ${s}, ${l})`) +} + +export function replaceStr( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, + replacement: SparqlValue | ExpressionPrimitive, +): SparqlValue { + const textTerm = exprTermString(text) + const patternTerm = exprTermString(pattern) + const replacementTerm = exprTermString(replacement) + return raw(`REPLACE(${textTerm}, ${patternTerm}, ${replacementTerm})`) +} + + +/** + * REGEX helper + * + * @example + * ```ts + * const condition = regex(variable('name'), '^Spidey', 'i') + * builder.filter(condition) + * ``` + */ +export function regex( + text: SparqlValue | ExpressionPrimitive, + pattern: string, + flags?: string, +): SparqlValue { + const textTerm = exprTermString(text) + const patternTerm = exprTermString(pattern) + const flagsTerm = flags ? `, ${exprTermString(flags)}` : '' + return raw(`REGEX(${textTerm}, ${patternTerm}${flagsTerm})`) +} + + +// Nullish / list helpers (Drizzle-like) + +export function isNull( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`!BOUND(${exprTermString(value)})`) +} + +export function isNotNull( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`BOUND(${exprTermString(value)})`) +} + +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})`) +} + +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})`) +} + +export function between( + expr: SparqlValue | ExpressionPrimitive, + low: SparqlValue | ExpressionPrimitive, + high: SparqlValue | ExpressionPrimitive, +): SparqlValue { + const e = exprTermString(expr) + const l = exprTermString(low) + const h = exprTermString(high) + return raw(`(${e} >= ${l} && ${e} <= ${h})`) +} + +export function coalesce( + ...values: Array +): SparqlValue { + if (values.length === 0) { + return strlit('') + } + const inner = values.map(exprTermString).join(', ') + return raw(`COALESCE(${inner})`) +} + +export function ifElse( + condition: SparqlValue, + whenTrue: SparqlValue | ExpressionPrimitive, + whenFalse: SparqlValue | ExpressionPrimitive, +): SparqlValue { + const trueTerm = exprTermString(whenTrue); + const falseTerm = exprTermString(whenFalse) + return raw( + `IF(${condition.value}, ${trueTerm}, ${falseTerm})`, + ) +} + + +// ---- Numeric helpers ---- + +export function add( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} + ${exprTermString(right)}`) +} + +export function sub( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} - ${exprTermString(right)}`) +} + +export function mul( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} * ${exprTermString(right)}`) +} + +export function div( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} / ${exprTermString(right)}`) +} + +export function mod( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`(${exprTermString(left)} % ${exprTermString(right)})`) +} + +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)})`) +} + +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( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`MONTH(${exprTermString(value)})`) +} + +export function dayFn( + value: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`DAY(${exprTermString(value)})`) +} + + +/** + * Comparison helpers + * + * These all build simple binary expressions and return them as SparqlValue. + */ + +export function eq( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} = ${exprTermString(right)}`) +} + +export function neq( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} != ${exprTermString(right)}`) +} + +export function gt( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} > ${exprTermString(right)}`) +} + +export function gte( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} >= ${exprTermString(right)}`) +} + +export function lt( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} < ${exprTermString(right)}`) +} + +export function lte( + left: SparqlValue | ExpressionPrimitive, + right: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`${exprTermString(left)} <= ${exprTermString(right)}`) +} + +/** + * Logical helpers + */ + +export function and( + ...conditions: SparqlValue[] +): SparqlValue { + if (conditions.length === 0) return raw('true') + if (conditions.length === 1) return conditions[0] + return raw(conditions.map((c) => c.value).join(' && ')) +} + +export function or( + ...conditions: SparqlValue[] +): SparqlValue { + if (conditions.length === 0) return raw('false') + if (conditions.length === 1) return conditions[0] + return raw(conditions.map((c) => c.value).join(' || ')) +} + +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')) + */ +export function exists(pattern: SparqlValue): SparqlValue { + return raw(`EXISTS { ${pattern.value} }`) +} + +export function notExists(pattern: SparqlValue): SparqlValue { + return raw(`NOT EXISTS { ${pattern.value} }`) +} + +// ============================================================================ +// RDF Term Type Testing Functions +// ============================================================================ + +/** + * Test 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 + */ +export function isBlank( + term: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`isBlank(${exprTermString(term)})`) +} + +/** + * Test if a term is a literal + */ +export function isLiteral( + term: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`isLiteral(${exprTermString(term)})`) +} + +/** + * Test if a variable is bound + */ +export function bound( + variable: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`BOUND(${exprTermString(variable)})`) +} + +/** + * Get language tag of a literal + */ +export function getlang( + literal: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return raw(`LANG(${exprTermString(literal)})`) +} + +/** + * Get 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) + */ +export function strstarts( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return startsWith(text, pattern) +} + +/** + * Alias for endsWith (matches SPARQL STRENDS function name) + */ +export function strends( + text: SparqlValue | ExpressionPrimitive, + pattern: SparqlValue | ExpressionPrimitive, +): SparqlValue { + return endsWith(text, pattern) +} + +// ============================================================================ +// Aggregation Functions +// ============================================================================ + +/** + * Interface for aggregation expressions with AS clause + */ +export interface AggregationExpression extends SparqlValue { + as(variable: string): SparqlValue +} + +/** + * Create an aggregation expression with optional AS binding + */ +function createAggregation(sparqlFunc: string, expr?: SparqlValue | ExpressionPrimitive): AggregationExpression { + const exprStr = expr ? exprTermString(expr) : '*' + const baseValue = `${sparqlFunc}(${exprStr})` + + const result: AggregationExpression = { + __sparql: true, + value: baseValue, + as(variable: string): SparqlValue { + const varName = normalizeVariableName(variable) + return raw(`${baseValue} AS ?${varName}`) + } + } + + return result +} + +/** + * COUNT aggregation + * + * @example + * ```ts + * // COUNT(*) + * count() + * + * // COUNT(?person) + * count(variable('person')) + * + * // COUNT(?person) AS ?personCount + * count(variable('person')).as('personCount') + * ``` + */ +export function count( + expr?: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('COUNT', expr) +} + +/** + * COUNT DISTINCT aggregation + */ +export function countDistinct( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + const exprStr = exprTermString(expr) + const baseValue = `COUNT(DISTINCT ${exprStr})` + + const result: AggregationExpression = { + __sparql: true, + value: baseValue, + as(variable: string): SparqlValue { + const varName = normalizeVariableName(variable) + return raw(`${baseValue} AS ?${varName}`) + } + } + + return result +} + +/** + * SUM aggregation + * + * @example + * ```ts + * // SUM(?amount) + * sum(variable('amount')) + * + * // SUM(?amount) AS ?total + * sum(variable('amount')).as('total') + * ``` + */ +export function sum( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('SUM', expr) +} + +/** + * AVG aggregation + */ +export function avg( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('AVG', expr) +} + +/** + * MIN aggregation + */ +export function min( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('MIN', expr) +} + +/** + * MAX aggregation + */ +export function max( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('MAX', expr) +} + +/** + * GROUP_CONCAT aggregation + * + * @example + * ```ts + * // GROUP_CONCAT(?name) + * groupConcat(variable('name')) + * + * // GROUP_CONCAT(?name; separator=", ") + * groupConcat(variable('name'), ', ') + * + * // GROUP_CONCAT(?name) AS ?names + * groupConcat(variable('name')).as('names') + * ``` + */ +export function groupConcat( + expr: SparqlValue | ExpressionPrimitive, + separator?: string, +): AggregationExpression { + const exprStr = exprTermString(expr) + const sepStr = separator ? `; separator=${exprTermString(separator)}` : '' + const baseValue = `GROUP_CONCAT(${exprStr}${sepStr})` + + const result: AggregationExpression = { + __sparql: true, + value: baseValue, + as(variable: string): SparqlValue { + const varName = normalizeVariableName(variable) + return raw(`${baseValue} AS ?${varName}`) + } + } + + return result +} + +/** + * SAMPLE aggregation + * + * Returns an arbitrary value from the group + */ +export function sample( + expr: SparqlValue | ExpressionPrimitive, +): AggregationExpression { + return createAggregation('SAMPLE', expr) +}