diff --git a/examples/complex.ts b/examples/complex.ts index c0d55c8..e30d388 100644 --- a/examples/complex.ts +++ b/examples/complex.ts @@ -29,6 +29,8 @@ import { import { FOAF, RDFS, + SCHEMA, + getNamespaceIRI } from '../namespaces.ts' const config: ExecutionConfig = { @@ -45,7 +47,7 @@ async function findFriendsOfFriends() { // Person A const personA = node('personA', 'foaf:Person') - .with.prop('foaf:name', str('Alice')) + .with.prop('foaf:name', 'Alice') // Person B (direct friend) const personB = node('personB', 'foaf:Person') @@ -62,6 +64,7 @@ async function findFriendsOfFriends() { try { const result = await select(['?friendName', '?friendOfFriendName']) .prefix('foaf', FOAF._namespace) + .prefix('ex', getNamespaceIRI(SCHEMA)) .where(personA) .where(personB) .where(personC) @@ -93,6 +96,7 @@ async function findPeopleWithOptionalEmail() { try { const result = await select(['?name', '?email']) .prefix('foaf', FOAF._namespace) + .prefix('ex', getNamespaceIRI(SCHEMA)) .where(person) .optional(emailPattern) .orderBy('?name') @@ -123,9 +127,11 @@ async function countFriendsPerPerson() { try { const result = await select(['?name', count(variable('friend')).as('friendCount')]) .prefix('foaf', FOAF._namespace) + .prefix('ex', getNamespaceIRI(SCHEMA)) .where(person) .where(friend) .where(knows) + .groupBy("?name") .orderBy('?friendCount', 'DESC') .limit(10) .execute(config) @@ -158,6 +164,7 @@ async function findCreatorsAndPublishers() { .prefix('narrative', "http://knowledge.graph/ontology/narrative#") .prefix('foaf', FOAF._namespace) .prefix('rdfs', RDFS._namespace) + .prefix('ex', getNamespaceIRI(SCHEMA)) .union(creator, publisher) .orderBy('?name') .limit(20) @@ -193,6 +200,7 @@ async function findHighConfidenceConnections() { // In your real code, you'd also wire `ex` via namespaces.ts; // for now we'll assume Blazegraph has PREFIX ex: already configured. .prefix('foaf', FOAF._namespace) + .prefix('ex', getNamespaceIRI(SCHEMA)) .where(personA) .where(personB) .where(connection) diff --git a/patterns/objects.ts b/patterns/objects.ts index c9b7dd2..2fb1431 100644 --- a/patterns/objects.ts +++ b/patterns/objects.ts @@ -690,7 +690,7 @@ export class Relationship implements SparqlValue { ...this.properties, } - const edgeTriples = triples(edgeId, poMap) + const edgeTriples = triples(raw(edgeId), poMap) return raw(`${base.value}\n ${edgeTriples.value}`) } diff --git a/patterns/triples.ts b/patterns/triples.ts index 0313ebb..a6ec053 100644 --- a/patterns/triples.ts +++ b/patterns/triples.ts @@ -11,9 +11,9 @@ * @module */ -import { toVarToken } from '../sparql.ts' +import { isSparqlValue, toVarToken } from '../sparql.ts' import { raw, type VariableName, type SparqlValue, toPredicateName, toRawString } from '../sparql.ts' -import { exprTermString, type ExpressionPrimitive } from '../utils.ts' +import { exprTermString, termString, type ExpressionPrimitive } from '../utils.ts' // ============================================================================ // Triple Component Types @@ -36,13 +36,43 @@ export type TripleSubject = VariableName | SparqlValue export type TriplePredicate = string | SparqlValue /** - * Object of a triple pattern. + * Values that are allowed in the object position of a triple, per SPARQL. + * + * Object can be: + * - a variable + * - an IRI or prefixed name + * - a literal + * - a blank node * - * Can be any RDF term - variables, IRIs, literals, or blank nodes. This is - * what the subject is related to or what value a property has. + * (We can later extend this to collections `( ... )` and blank-node property + * lists `[ ... ]` via additional SparqlValue kinds.) */ -export type TripleObject = VariableName | SparqlValue | ExpressionPrimitive +export type TripleObject = + | VariableName + | SparqlValue // but only certain `kind`s, enforced at runtime + | ExpressionPrimitive +/** + * Convert subject to string form. + * + * Handles both raw strings and SparqlValue objects. + */ +export function tripleSubjectString(subject: TripleSubject): string { + // If it's already a SparqlValue (iri, bnode, literal, raw, etc.) + if (isSparqlValue(subject)) { + return subject.value + } + + // Otherwise, it’s a variable name like "person" or "?person" + return toVarToken(subject) +} + +/** + * Convert predicate to string form. + */ +export function tripleObjectString(object: TripleObject): string { + return termString(object, 'object') +} // ============================================================================ // Triple Construction @@ -81,9 +111,9 @@ export function triple( predicate: TriplePredicate, object: TripleObject, ): SparqlValue { - const s = toVarToken(subject) + const s = tripleSubjectString(subject) const p = toPredicateName(toRawString(predicate)) - const o = exprTermString(object) + const o = tripleObjectString(object) return raw(`${s} ${p} ${o} .`) } @@ -155,7 +185,7 @@ export function triples( subject: TripleSubject, predicateObjects: PredicateObjectList | PredicateObjectMap, ): SparqlValue { - const subjectTerm = toVarToken(subject) + const subjectTerm = tripleSubjectString(subject) // 4 spaces; 2 (block) + 2 (extra) const CONTINUATION_INDENT = ' '; @@ -176,7 +206,7 @@ export function triples( // Build semicolon-separated list const lines: string[] = list.map(([p, o], idx) => { const pred = toPredicateName(toRawString(p)) - const obj = exprTermString(o) + const obj = tripleObjectString(o) const suffix = idx < list.length - 1 ? ' ;' : ' .' // Continuation lines should be indented one level *beyond* the line @@ -248,9 +278,9 @@ export function quotedTriple( predicate: string | SparqlValue, object: SparqlValue | ExpressionPrimitive ): SparqlValue { - const s = toVarToken(subject) + const s = tripleSubjectString(subject) const p = toPredicateName(toRawString(predicate)) - const o = exprTermString(object) + const o = tripleObjectString(object) return raw(`<< ${s} ${p} ${o} >>`) } \ No newline at end of file diff --git a/sparql.ts b/sparql.ts index 6e138b1..755af24 100644 --- a/sparql.ts +++ b/sparql.ts @@ -595,7 +595,7 @@ export function needsLongQuotes(str: string): boolean { /** * Characters that could enable SPARQL injection. */ -export const INJECTION_CHARS = /[<>"'\n\r\t{}]/ +export const INJECTION_CHARS = /[<>"'\n\r\t{}:]/ /** * Validate an IRI for use in SPARQL. @@ -1271,6 +1271,9 @@ export function toPredicateName(key: string): string { return key } + // `a` = `rdf:type` its a common shortcut in SPARQL + if (key === "a") return key + // Fallback: assume a default ":" prefix is bound. return `:${key}` } diff --git a/utils.ts b/utils.ts index 2239c55..18528ac 100644 --- a/utils.ts +++ b/utils.ts @@ -227,6 +227,99 @@ export function exprTermString( return exprTerm(value).value } +// ============================================================================ +// Term Helpers (GraphNode / VarOrTerm for triples) +// ============================================================================ + +/** + * Positions where an RDF term (not a full expression) is required. + * + * For now we focus on triple positions; you can extend this later if + * you want to validate GRAPH names etc. + */ +export type TermPosition = 'subject' | 'object' | 'graph' + +/** + * Very small SPARQL-style validator for GraphNode/VarOrTerm lexicals. + * + * We lean on the fact that `exprTerm()` has already: + * - turned primitives into valid literals/IRIs + * - left SparqlValue.value as-is when it represents syntax + * + * So here we just check that the lexical form looks like: + * - variable (?x, $x) + * - IRI (<...>) + * - prefixed name (prefix:local) + * - blank node label (_:b1) + * - literal ("...", 42, true, "..."@en, "..."^^<...>) + * - RDF* quoted triple (<< ... >>) + * + * Anything that looks like a function call or complex expression + * (STR(...), CONCAT(...), BNODE(), etc.) is rejected. + */ +export function isGraphNodeLexical(lex: string): boolean { + const t = lex.trim() + if (!t) return false + + // Variable ?x or $x + if (/^[?$][A-Za-z_][\w-]*$/.test(t)) return true + + // IRI reference: + if (/^<[^<>"{}|^`\\\s]+>$/.test(t)) return true + + // Blank node label _:b1 + if (/^_:[A-Za-z][A-Za-z0-9_]*$/.test(t)) return true + + // RDF* quoted triple << ... >> + if (/^<<[\s\S]*>>$/.test(t)) return true + + // Boolean literal + if (/^(true|false)$/i.test(t)) return true + + // Numeric literal (very simple integer/decimal/double checks) + if (/^[+-]?[0-9]+$/.test(t)) return true // integer + if (/^[+-]?[0-9]*\.[0-9]+([eE][+-]?[0-9]+)?$/.test(t)) return true // decimal + if (/^[+-]?[0-9]+(\.[0-9]+)?[eE][+-]?[0-9]+$/.test(t)) return true // double + + // String / language-tagged / typed literals: start with " or ' + if (/^["']/.test(t)) return true + + // Prefixed name prefix:local (approximate but safe enough) + if (/^[A-Za-z_][\w.-]*:[\w.-]+$/.test(t)) return true + + return false +} + +/** + * Convert a value into a *term* suitable for triple subject/object. + * + * - Uses the same primitive conversion as expressions (exprTerm) + * - Then **validates** that the lexical matches GraphNode/VarOrTerm + * + * This is what you want for triple objects and any context where + * SPARQL forbids arbitrary expressions. + * + * @throws Error if the value serializes to something that is not + * a valid SPARQL term (e.g. STR(...), CONCAT(...), BNODE()). + */ +export function termString( + value: SparqlValue | ExpressionPrimitive, + position: TermPosition = 'object', +): string { + const lex = exprTermString(value) + + if (!isGraphNodeLexical(lex)) { + throw new Error( + `Invalid ${position} term "${lex}". Triple ${position}s must be variables, ` + + `IRIs, blank node labels, literals, prefixed names, or RDF* quoted triples. ` + + `Use BIND(...) / FILTER(...) to compute a value (e.g. STR(), CONCAT(), ` + + `BNODE()) and then use the bound variable in the triple.`, + ) + } + + return lex +} + // ============================================================================ // String Functions // ============================================================================