[READ-ONLY] Mirror of https://github.com/okikio/sparql-client. jsr.io/@okikio/sparql
TypeScript 81%
Python 8%
5%
Shell 3%
Dockerfile 3%

README.md

SPARQL Query Builder #

Writing SPARQL queries by hand means string concatenation, manual escaping, and hunting through parentheses when something breaks. This library gives you a type-safe query builder with a fluent API. Write v('age').gte(18) instead of FILTER(?age >= 18), chain operations like v('price').mul(1.2).round(), and get autocomplete in your editor. TypeScript catches errors at compile time, values are properly escaped automatically, and you can compose queries from reusable pieces.

Installation #

deno add @okikio/sparql
import { select, triple, node, v, filter } from '@okikio/sparql'

Quick Start #

Start with a simple query using triple patterns:

const adults = select(['?name', '?age'])
  .where(triple('?person', 'foaf:name', '?name'))
  .where(triple('?person', 'foaf:age', '?age'))
  .filter(v('age').gte(18))

const sparql = adults.build()
const results = await adults.execute({
  endpoint: 'http://localhost:3030/dataset/sparql'
})

That v('age').gte(18) is the fluent API - variables become values with chainable methods. Compare values, do math, transform strings, all with natural dot notation.

SPARQL Mapping #

Every library feature maps directly to standard SPARQL 1.1. The library provides 100% spec coverage with enhanced developer experience through type safety, fluent chaining, and multiple pattern styles.

Key mappings:

  • v('age').gte(18) → ?age >= 18
  • select([v('price').mul(1.2).as('total')]) → SELECT (?price * 1.2 AS ?total)
  • triple('?s', 'rdf:type', 'ex:Person') → ?s rdf:type ex:Person .
  • md5(v('email')) → MD5(?email)
  • now() → NOW()

See sparql-mapping.md for:

  • Complete function reference (85+ functions)
  • Library → SPARQL examples for all features
  • SPARQL → Library migration guide
  • DX enhancements beyond the spec

Call .build().value on any query to see the generated SPARQL string.

Pattern Styles #

The library supports multiple ways to describe graph patterns. Use triples for simple cases, nested objects for complex structures, or ASCII art when you want visual clarity. Every pattern compiles to standard SPARQL, so choose based on readability.

Basic triples work for straightforward queries:

select(['?name', '?age'])
  .where(triple('?person', 'foaf:name', '?name'))
  .where(triple('?person', 'foaf:age', '?age'))

Nested object notation handles complex graphs without repetition. Properties can contain other nodes:

select(['?title', '?publisherName', '?city'])
  .where(
    node('product', 'schema:Product', {
      'schema:name': v('title'),
      'schema:price': v('price'),
      'schema:publisher': node('publisher', 'schema:Organization', {
        'schema:name': v('publisherName'),
        'schema:location': node('location', 'schema:Place', {
          'schema:city': v('city'),
          'schema:country': v('country')
        })
      })
    })
  )

That nesting generates all the triples automatically. Product has a publisher, publisher has a location, location has city and country. You write the structure as you think about it.

ASCII art syntax emphasizes visual clarity. The cypher template tag lets you draw connections:

const product = node('product', 'schema:Product', {
  'schema:name': v('title')
})

const publisher = node('publisher', 'schema:Organization', {
  'schema:name': v('pubName')
})

const query = select(['?title', '?pubName'])
  .where(cypher`${product}-[schema:publisher]->${publisher}`)

The arrow -> shows the relationship direction visually. This generates the same triples as the object notation, but reads like a diagram.

Combine patterns with match() when you want to build complex structures from separate pieces:

const pattern = match(
  node('person', 'foaf:Person', { 'foaf:name': v('personName') }),
  rel('person', 'foaf:knows', 'friend'),
  node('friend', 'foaf:Person', { 'foaf:name': v('friendName') })
)

select(['?personName', '?friendName']).where(pattern)

Mix patterns in the same query. Use triples for simple bindings, objects for nested structures, ASCII art for visual relationships:

select(['?person', '?skill', '?friendName'])
  .where(triple('?person', 'ex:hasSkill', '?skill'))
  .where(
    node('person')
      .prop('foaf:knows', node('friend', {
        'foaf:name': v('friendName')
      }))
  )

Fluent Operations #

Chain operations to build expressions. Arithmetic works left to right:

const pricing = select(['?product', '?total'])
  .where(triple('?product', 'schema:price', '?basePrice'))
  .bind(
    v('basePrice')
      .mul(1.2)      // Apply markup
      .add(5)        // Add shipping
      .round()       // Clean up decimals
      .as('total')
  )
  .filter(v('total').gte(20))

String operations chain naturally. Build display names with fallbacks:

select(['?displayName'])
  .where(triple('?person', 'foaf:firstName', '?first'))
  .where(triple('?person', 'foaf:lastName', '?last'))
  .optional(triple('?person', 'foaf:nickname', '?nick'))
  .bind(
    substr(coalesce(v('nick'), v('first')).ucase(), 1, 10)
      .concat(' ')
      .concat(v('last').ucase().substr(1, 1))
      .concat('.')
      .as('displayName')
  )

Read it step by step: "Use nickname if available, otherwise first name. Uppercase it. Take first 10 characters. Add space. Add uppercased first letter of last name. Add period."

Note: substr() is a standalone function, not a fluent method. Most string operations like ucase(), lcase(), concat(), strlen() are available as fluent methods for chaining.

Conditional logic stays readable with nested fluent operations:

select(['?item', '?price', '?status'])
  .where(triple('?item', 'schema:basePrice', '?base'))
  .where(triple('?item', 'schema:inStock', '?stock'))
  .bind(
    ifElse(
      v('stock').gt(10),
      v('base').mul(0.85),
      ifElse(
        v('stock').gt(0),
        v('base').mul(0.95),
        v('base').add(20)
      )
    ).round().as('price')
  )
  .bind(
    ifElse(
      v('stock').gt(10),
      'In Stock',
      ifElse(
        v('stock').gt(0),
        'Low Stock',
        'Out of Stock'
      )
    ).as('status')
  )

The nested structure mirrors the decision tree. Good stock gets 15% off, low stock gets 5% off, out of stock adds a premium.

Aggregations and Grouping #

Count, average, sum - aggregations work with the fluent API:

const analytics = select([
  v('country'),
  count().as('users'),
  avg(v('age')).as('avgAge'),
  countDistinct(v('city')).as('cities'),
  sum(v('purchases')).as('revenue')
])
  .where(triple('?user', 'schema:country', '?country'))
  .where(triple('?user', 'foaf:age', '?age'))
  .where(triple('?user', 'schema:city', '?city'))
  .where(triple('?user', 'ex:totalPurchases', '?purchases'))
  .groupBy('?country')
  .having(count().gte(10))
  .orderBy('?revenue', 'DESC')

Notice count().gte(10) in the having clause - aggregations return fluent values. Everything chains consistently.

Subqueries and Composition #

Break complex logic into pieces:

// Find top 10 best-selling products
const topSellers = select([v('product'), count().as('sales')])
  .where(triple('?order', 'schema:product', '?product'))
  .where(triple('?order', 'schema:date', '?date'))
  .filter(v('date').gte('2024-01-01'))
  .groupBy('?product')
  .orderBy('?sales', 'DESC')
  .limit(10)

// Enrich with product details
const enriched = select(['?product', '?name', '?price', '?sales'])
  .where(subquery(topSellers))
  .where(
    node('product', {
      'schema:name': v('name'),
      'schema:price': v('price')
    })
  )
  .orderBy('?sales', 'DESC')

Each piece is simple. Together they solve the complex problem.

Property Paths #

Navigate graph structures without manual recursion. Find all contacts through any number of "knows" relationships:

const network = select(['?person', '?contact'])
  .where(triple('?person', zeroOrMore('foaf:knows'), '?contact'))
  .filter(v('person').neq(v('contact')))

That zeroOrMore expands to any number of hops. The SPARQL engine handles it efficiently.

Navigate nested properties with sequences:

const cities = select(['?person', '?city'])
  .where(triple('?person', sequence('schema:address', 'schema:city'), '?city'))

"Follow address property, then city property" - one pattern instead of two triples.

Use alternatives when property names vary:

const names = select(['?person', '?name'])
  .where(triple('?person', alternative('foaf:name', 'schema:name'), '?name'))

Combine path operators for complex navigation:

// Manager or manager's manager
const bosses = select(['?employee', '?boss'])
  .where(
    triple(
      '?employee',
      sequence(
        oneOrMore('org:reportsTo'),
        alternative('org:manages', 'org:supervises')
      ),
      '?boss'
    )
  )

Updates and Modifications #

Updates use the same fluent patterns. Increment everyone's age:

const birthday = modify()
  .delete(triple('?person', 'foaf:age', '?oldAge'))
  .insert(triple('?person', 'foaf:age', v('oldAge').add(1)))
  .where(triple('?person', 'foaf:age', '?oldAge'))
  .where(filter(v('oldAge').gte(0)))
  .done()

await birthday.execute({ endpoint: 'http://localhost:3030/dataset/update' })

Notice v('oldAge').add(1) works in the insert template - fluent operations work everywhere.

Conditional inserts:

const markSeniors = modify()
  .insert(
    node('person', {
      'ex:seniorCitizen': true,
      'ex:discount': 0.15
    })
  )
  .where(triple('?person', 'foaf:age', '?age'))
  .where(filter(v('age').gte(65)))
  .done()

Delete patterns with conditions:

const cleanup = modify()
  .delete(
    node('account', {
      'ex:status': v('status'),
      'ex:lastLogin': v('lastLogin')
    })
  )
  .where(triple('?account', 'ex:status', 'inactive'))
  .where(triple('?account', 'ex:lastLogin', '?lastLogin'))
  .where(filter(v('lastLogin').lt('2023-01-01')))
  .done()

Complete Example #

Real-world e-commerce query combining multiple patterns:

const productSearch = select([
  v('title'),
  v('displayPrice'),
  v('stockStatus'),
  v('categoryName'),
  v('averageRating')
])
  .where(
    node('product', 'schema:Product', {
      'schema:name': v('title'),
      'schema:price': v('basePrice'),
      'schema:inventory': v('stock'),
      'schema:category': node('category', 'schema:Category', {
        'schema:name': v('categoryName')
      })
    })
  )
  .optional(
    node('product')
      .prop('schema:review', node('review', 'schema:Review', {
        'schema:ratingValue': v('rating')
      }))
  )
  .bind(
    ifElse(
      v('stock').gt(5),
      v('basePrice').mul(0.9),
      ifElse(
        v('stock').gt(0),
        v('basePrice').mul(0.95),
        v('basePrice').mul(1.1)
      )
    ).round().as('displayPrice')
  )
  .bind(
    ifElse(
      v('stock').gt(5),
      'In Stock',
      ifElse(
        v('stock').gt(0),
        v('stock').concat(' left'),
        'Out of Stock'
      )
    ).as('stockStatus')
  )
  .filter(v('displayPrice').gte(10))
  .groupBy('?product', '?title', '?displayPrice', '?stockStatus', '?categoryName')
  .bind(avg(v('rating')).as('averageRating'))
  .having(countDistinct(v('review')).gte(5))
  .orderBy('?averageRating', 'DESC')
  .limit(50)

const results = await productSearch.execute({
  endpoint: 'http://localhost:3030/catalog/sparql'
})

That query handles nested relationships (product → category), optional patterns (reviews), computed values (discounted price), conditional logic (stock messages), aggregations (average rating), and filtering.

Named Graphs and Federation #

Query specific graphs:

const graphData = select(['?s', '?p', '?o'])
  .fromNamed('http://example.org/metadata')
  .where(graph('?g', triple('?s', '?p', '?o')))

Combine data from multiple endpoints:

const federated = select(['?person', '?name', '?birthPlace', '?abstract'])
  .where(triple('?person', 'foaf:name', '?name'))
  .where(
    service(
      'http://dbpedia.org/sparql',
      triple('?person', 'dbo:birthPlace', '?birthPlace'),
      triple('?person', 'dbo:abstract', '?abstract')
    )
  )
  .filter(v('abstract').regex('scientist'))

Federation lets you query distributed data sources in a single query. The SERVICE clause transparently handles remote execution.

Type Safety #

Everything is fully typed. TypeScript catches errors at compile time:

const age = v('age')

age.gte(18)              // ✓ Returns SparqlValue for filters
age.add(5)               // ✓ Returns FluentValue, can chain
age.add(5).mul(2)        // ✓ Chains continue naturally
age.gte('not a number')  // ✗ TypeScript error

You get autocomplete in your editor. The library guides you toward correct code. Generated SPARQL is safe from injection attacks because values are properly escaped automatically.

Query Composition and Reuse #

Building complex queries from reusable pieces makes your code cleaner and more maintainable. These patterns show how to compose queries, extract common logic, and build flexible query templates.

Reusable Pattern Fragments #

Extract common triple patterns into functions. This reduces duplication and makes queries easier to understand:

import { RDF, FOAF, SCHEMA } from '@okikio/sparql'

// Define reusable pattern fragments
function personPattern(personVar = 'person') {
  return node(personVar, FOAF.Person, {
    [FOAF.name]: v('name'),
    [FOAF.age]: v('age')
  })
}

function addressPattern(personVar = 'person') {
  return node(personVar)
    .prop(SCHEMA.address, node('address', SCHEMA.PostalAddress, {
      [SCHEMA.addressLocality]: v('city'),
      [SCHEMA.addressCountry]: v('country')
    }))
}

// Use them in queries
const adults = select(['?name', '?age'])
  .where(personPattern())
  .filter(v('age').gte(18))

const peopleWithAddress = select(['?name', '?city', '?country'])
  .where(personPattern())
  .where(addressPattern())

Patterns compose naturally. Mix and match to build exactly the query you need without repeating yourself.

Filter Builder Functions #

Extract filter logic into reusable functions. This makes query intentions clearer:

// Define filter builders
function olderThan(age: number) {
  return v('age').gte(age)
}

function nameMatches(pattern: string, caseInsensitive = true) {
  return caseInsensitive
    ? v('name').regex(pattern, 'i')
    : v('name').regex(pattern)
}

function inCountry(country: string) {
  return v('country').eq(country)
}

// Use them in queries
const query = select(['?name', '?age'])
  .where(personPattern())
  .where(addressPattern())
  .filter(olderThan(21))
  .filter(nameMatches('^John'))
  .filter(inCountry('USA'))

Read the query like English: "Select name and age where person is older than 21, name matches '^John', and in country USA."

Query Templates with Parameters #

Build flexible query templates that adapt based on parameters. This pattern works great for search interfaces:

interface SearchFilters {
  minAge?: number
  maxAge?: number
  namePattern?: string
  city?: string
  country?: string
}

function searchPeople(filters: SearchFilters) {
  let query = select(['?name', '?age', '?city', '?country'])
    .prefix('rdf', getNamespaceIRI(RDF))
    .prefix('foaf', getNamespaceIRI(FOAF))
    .prefix('schema', getNamespaceIRI(SCHEMA))
    .where(personPattern())
    .where(addressPattern())

  // Add filters conditionally
  if (filters.minAge !== undefined) {
    query = query.filter(v('age').gte(filters.minAge))
  }

  if (filters.maxAge !== undefined) {
    query = query.filter(v('age').lte(filters.maxAge))
  }

  if (filters.namePattern) {
    query = query.filter(v('name').regex(filters.namePattern, 'i'))
  }

  if (filters.city) {
    query = query.filter(v('city').eq(filters.city))
  }

  if (filters.country) {
    query = query.filter(v('country').eq(filters.country))
  }

  return query
}

// Use it with different filter combinations
const youngAdults = searchPeople({ minAge: 18, maxAge: 25 })
const londonResidents = searchPeople({ city: 'London' })
const ukSeniors = searchPeople({ minAge: 65, country: 'UK' })

The query adapts automatically. Only the filters you provide get added.

Base Query Extension #

Start with a base query and extend it for different use cases:

// Base query everyone shares
const baseProductQuery = select(['?product', '?name', '?price'])
  .prefix('schema', getNamespaceIRI(SCHEMA))
  .where(
    node('product', SCHEMA.Product, {
      [SCHEMA.name]: v('name'),
      [SCHEMA.price]: v('price')
    })
  )

// Extend for specific needs
const affordableProducts = baseProductQuery
  .filter(v('price').lte(50))
  .orderBy('?price')
  .limit(20)

const expensiveProducts = baseProductQuery
  .filter(v('price').gte(100))
  .orderBy('?price', 'DESC')
  .limit(10)

const searchResults = baseProductQuery
  .filter(v('name').regex(userInput, 'i'))
  .limit(50)

The base query stays immutable. Each extension creates a new query without affecting the original.

Combining Subqueries #

Break complex logic into subqueries then combine them:

// Find top sellers
const topSellers = select([v('product'), count().as('sales')])
  .where(triple('?order', SCHEMA.product, '?product'))
  .where(triple('?order', SCHEMA.orderDate, '?date'))
  .filter(v('date').gte('2024-01-01'))
  .groupBy('?product')
  .orderBy('?sales', 'DESC')
  .limit(10)

// Enrich with product details
const enrichedProducts = select([
  '?product',
  '?name',
  '?price',
  '?sales',
  '?category'
])
  .where(subquery(topSellers))
  .where(
    node('product', {
      [SCHEMA.name]: v('name'),
      [SCHEMA.price]: v('price'),
      [SCHEMA.category]: v('category')
    })
  )
  .orderBy('?sales', 'DESC')

Each subquery solves one piece of the problem. Combine them to solve the whole thing.

Pattern Collections #

Group related patterns together for complex domains:

// E-commerce patterns
const ecommerce = {
  product(productVar = 'product') {
    return node(productVar, SCHEMA.Product, {
      [SCHEMA.name]: v('productName'),
      [SCHEMA.price]: v('price'),
      [SCHEMA.sku]: v('sku')
    })
  },

  order(orderVar = 'order') {
    return node(orderVar, SCHEMA.Order, {
      [SCHEMA.orderDate]: v('orderDate'),
      [SCHEMA.orderNumber]: v('orderNumber'),
      [SCHEMA.customer]: v('customer')
    })
  },

  customer(customerVar = 'customer') {
    return node(customerVar, SCHEMA.Person, {
      [SCHEMA.name]: v('customerName'),
      [SCHEMA.email]: v('email')
    })
  },

  // Relationship connectors
  orderContains(orderVar = 'order', productVar = 'product') {
    return triple(`?${orderVar}`, SCHEMA.orderedItem, `?${productVar}`)
  },

  orderBy(orderVar = 'order', customerVar = 'customer') {
    return triple(`?${orderVar}`, SCHEMA.customer, `?${customerVar}`)
  }
}

// Use pattern collection
const orderAnalysis = select([
  '?orderNumber',
  '?customerName',
  '?productName',
  '?price'
])
  .where(ecommerce.order())
  .where(ecommerce.orderBy())
  .where(ecommerce.customer())
  .where(ecommerce.orderContains())
  .where(ecommerce.product())
  .filter(v('orderDate').gte('2024-01-01'))

Pattern collections organize domain knowledge. Your queries become high-level descriptions of what you want to find.

Dynamic Query Construction #

Build queries programmatically from user input or configuration:

interface FieldSelection {
  fields: string[]
  filters: Array<{ field: string, operator: string, value: any }>
  sort?: { field: string, direction: 'ASC' | 'DESC' }
  limit?: number
}

function buildDynamicQuery(config: FieldSelection) {
  // Map user field names to actual predicates
  const fieldMap: Record<string, string> = {
    name: FOAF.name,
    age: FOAF.age,
    email: SCHEMA.email,
    city: SCHEMA.addressLocality
  }

  // Start with base pattern
  let query = select(config.fields.map(f => `?${f}`))
    .where(triple('?person', RDF.type, uri(FOAF.Person)))

  // Add triples for each requested field
  for (const field of config.fields) {
    const predicate = fieldMap[field]
    if (predicate) {
      query = query.where(triple('?person', predicate, `?${field}`))
    }
  }

  // Apply filters
  for (const filter of config.filters) {
    const varRef = v(filter.field)
    switch (filter.operator) {
      case 'eq':
        query = query.filter(varRef.eq(filter.value))
        break
      case 'gt':
        query = query.filter(varRef.gt(filter.value))
        break
      case 'lt':
        query = query.filter(varRef.lt(filter.value))
        break
      case 'contains':
        query = query.filter(varRef.contains(filter.value))
        break
    }
  }

  // Apply sorting
  if (config.sort) {
    query = query.orderBy(`?${config.sort.field}`, config.sort.direction)
  }

  // Apply limit
  if (config.limit) {
    query = query.limit(config.limit)
  }

  return query
}

// Use with different configurations
const query1 = buildDynamicQuery({
  fields: ['name', 'email'],
  filters: [{ field: 'name', operator: 'contains', value: 'John' }],
  limit: 10
})

const query2 = buildDynamicQuery({
  fields: ['name', 'age', 'city'],
  filters: [
    { field: 'age', operator: 'gt', value: 18 },
    { field: 'city', operator: 'eq', value: 'London' }
  ],
  sort: { field: 'age', direction: 'DESC' }
})

Dynamic construction turns configuration into queries. Good for building query builders, APIs, or UI-driven search.

Cached Query Builders #

Pre-configure query builders for common operations:

// Create specialized builders
class PersonQueries {
  private readonly baseQuery: QueryBuilder

  constructor() {
    this.baseQuery = select(['?person', '?name', '?age'])
      .prefix('rdf', getNamespaceIRI(RDF))
      .prefix('foaf', getNamespaceIRI(FOAF))
      .where(triple('?person', RDF.type, uri(FOAF.Person)))
      .where(triple('?person', FOAF.name, '?name'))
      .where(triple('?person', FOAF.age, '?age'))
  }

  all() {
    return this.baseQuery
  }

  adults() {
    return this.baseQuery.filter(v('age').gte(18))
  }

  children() {
    return this.baseQuery.filter(v('age').lt(18))
  }

  byName(name: string) {
    return this.baseQuery.filter(v('name').eq(name))
  }

  olderThan(age: number) {
    return this.baseQuery.filter(v('age').gt(age))
  }

  byAgeRange(min: number, max: number) {
    return this.baseQuery
      .filter(v('age').gte(min))
      .filter(v('age').lte(max))
  }
}

// Use it
const people = new PersonQueries()

const adults = await people.adults().execute(config)
const seniors = await people.olderThan(65).execute(config)
const alice = await people.byName('Alice').execute(config)
const millennials = await people.byAgeRange(25, 40).execute(config)

Query builders encapsulate domain logic. Each method returns a ready-to-execute query.

Further Reading #

Check the module documentation for architecture details and progressive examples. The pattern files (triples.ts, objects.ts, cypher.ts) show different syntax styles with comprehensive examples. Implementation docs explain technical decisions and trade-offs.

Start simple with basic queries. Add complexity as you need it. The patterns stay consistent - learning one part teaches you the rest.

License #

MIT