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)
+}