parent
e90a65e4c2
commit
3c004980e0
@ -1,14 +0,0 @@ |
||||
# SPARQL builders |
||||
|
||||
Utilities to build SPARQL SELECT and CONSTRUCT queries from a ShapeConstraint structure. |
||||
|
||||
Exports: |
||||
|
||||
- buildSelectQuery(shape, options) |
||||
- buildConstructQuery(shape, options) |
||||
|
||||
Options: |
||||
|
||||
- prefixes: Record<prefix, IRI> |
||||
- graph: named graph IRI or CURIE |
||||
- includeOptionalForMinZero: wrap min=0 predicates in OPTIONAL (default true) |
@ -1,152 +0,0 @@ |
||||
import type { |
||||
BuildContext, |
||||
PredicateConstraint, |
||||
ShapeConstraint, |
||||
SparqlBuildOptions, |
||||
} from "./common"; |
||||
import { |
||||
predicateToSparql, |
||||
prefixesToText, |
||||
toIriOrCurie, |
||||
uniqueVar, |
||||
valuesBlock, |
||||
varToken, |
||||
} from "./common"; |
||||
|
||||
/** |
||||
* Build a SPARQL SELECT query from a ShapeConstraint definition. |
||||
* The query matches the shape subject and constraints; optional predicates (min=0) are wrapped in OPTIONAL. |
||||
*/ |
||||
export function buildSelectQuery( |
||||
shape: ShapeConstraint, |
||||
options?: SparqlBuildOptions, |
||||
): string { |
||||
const ctx: BuildContext = { usedVars: new Set<string>() }; |
||||
const prefixes = prefixesToText(options?.prefixes); |
||||
const subject = toIriOrCurie(shape.subject); |
||||
|
||||
const selectVars: string[] = []; |
||||
const whereLines: string[] = []; |
||||
const postFilters: string[] = []; |
||||
const valuesBlocks: string[] = []; |
||||
|
||||
// ensure a consistent root variable when subject is a variable
|
||||
const rootVar = |
||||
subject.startsWith("?") || subject.startsWith("$") |
||||
? subject |
||||
: uniqueVar(ctx, "s"); |
||||
if (!subject.startsWith("?") && !subject.startsWith("$")) { |
||||
// bind fixed subject via VALUES for portability
|
||||
valuesBlocks.push(valuesBlock(rootVar, [subject] as any)); |
||||
} |
||||
|
||||
const predicates = Array.isArray(shape.predicates) |
||||
? shape.predicates |
||||
: [...shape.predicates]; |
||||
|
||||
for (const pred of predicates) { |
||||
addPredicatePattern( |
||||
ctx, |
||||
pred, |
||||
rootVar, |
||||
whereLines, |
||||
selectVars, |
||||
postFilters, |
||||
valuesBlocks, |
||||
options, |
||||
); |
||||
} |
||||
|
||||
const graphWrap = (body: string) => |
||||
options?.graph |
||||
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}` |
||||
: body; |
||||
|
||||
const where = [ |
||||
...valuesBlocks, |
||||
graphWrap(whereLines.join("\n")), |
||||
...postFilters, |
||||
] |
||||
.filter(Boolean) |
||||
.join("\n"); |
||||
|
||||
const select = selectVars.length ? selectVars.join(" ") : "*"; |
||||
|
||||
return [prefixes, `SELECT ${select} WHERE {`, where, `}`].join("\n"); |
||||
} |
||||
|
||||
function addPredicatePattern( |
||||
ctx: BuildContext, |
||||
pred: PredicateConstraint, |
||||
subjectVar: string, |
||||
where: string[], |
||||
selectVars: string[], |
||||
postFilters: string[], |
||||
valuesBlocks: string[], |
||||
options?: SparqlBuildOptions, |
||||
) { |
||||
const p = predicateToSparql(pred.uri); |
||||
const objVar = uniqueVar(ctx, pred.displayName || "o"); |
||||
const objTerm = |
||||
pred.type === "nested" && |
||||
pred.nested?.subject && |
||||
!pred.nested.subject.match(/^\?|^\$/) |
||||
? toIriOrCurie(pred.nested.subject) |
||||
: objVar; |
||||
|
||||
const triple = `${subjectVar} ${p} ${objTerm} .`; |
||||
|
||||
const isOptional = |
||||
(pred.min ?? 0) === 0 && (options?.includeOptionalForMinZero ?? true); |
||||
|
||||
if (pred.type === "nested" && pred.nested) { |
||||
// For nested, we select the nested object var and then recurse
|
||||
if (objTerm === objVar) selectVars.push(objVar); |
||||
const nestedBody: string[] = [triple]; |
||||
const nestedPreds = Array.isArray(pred.nested.predicates) |
||||
? pred.nested.predicates |
||||
: [...pred.nested.predicates]; |
||||
for (const n of nestedPreds) { |
||||
addPredicatePattern( |
||||
ctx, |
||||
n, |
||||
objTerm, |
||||
nestedBody, |
||||
selectVars, |
||||
postFilters, |
||||
valuesBlocks, |
||||
options, |
||||
); |
||||
} |
||||
const block = nestedBody.join("\n"); |
||||
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||
return; |
||||
} |
||||
|
||||
// Non-nested: literals or IRIs
|
||||
selectVars.push(objVar); |
||||
const blockLines: string[] = [triple]; |
||||
|
||||
if (pred.type === "literal" && pred.literalValue !== undefined) { |
||||
if (Array.isArray(pred.literalValue)) { |
||||
// VALUES block for IN-like matching
|
||||
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[])); |
||||
} else { |
||||
// simple equality filter
|
||||
const lit = |
||||
typeof pred.literalValue === "string" || |
||||
typeof pred.literalValue === "number" || |
||||
typeof pred.literalValue === "boolean" |
||||
? pred.literalValue |
||||
: String(pred.literalValue); |
||||
postFilters.push( |
||||
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})`, |
||||
); |
||||
} |
||||
} |
||||
|
||||
const block = blockLines.join("\n"); |
||||
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||
} |
||||
|
||||
export default buildSelectQuery; |
@ -0,0 +1,140 @@ |
||||
import type { Predicate, Shape, Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
export const buildConstructQuery = ({ |
||||
schema, |
||||
shapeId, |
||||
}: { |
||||
schema: Schema; |
||||
shapeId: keyof Schema; |
||||
}): string => { |
||||
const rootShape = schema[shapeId]; |
||||
|
||||
const constructStatements: { |
||||
s: string; |
||||
p: string; |
||||
o: string; |
||||
optional: boolean; |
||||
literals: Predicate["literalValue"]; |
||||
}[] = []; |
||||
|
||||
const idToVarName: Record<string, string> = {}; |
||||
const getVarNameFor = (id: string) => { |
||||
const currentName = idToVarName[id]; |
||||
if (currentName) return currentName; |
||||
|
||||
const newVar = `o${Object.entries(idToVarName).length + 1}`; |
||||
idToVarName[id] = newVar; |
||||
return newVar; |
||||
}; |
||||
|
||||
// Create s,p,o records where subject and object var names are mapped to shape or predicate ids.
|
||||
const addTriples = (shape: Shape) => { |
||||
const predicates = shape.predicates; |
||||
const shapeId = shape.iri; |
||||
|
||||
for (const pred of predicates) { |
||||
const subjectVarName = getVarNameFor(shapeId); |
||||
|
||||
if (pred.type === "nested") { |
||||
if (typeof pred.nestedShape !== "string") |
||||
throw new Error("Nested shapes must be by reference"); |
||||
|
||||
// If a name for this shape was assigned already, it's triples have been added
|
||||
// and we don't have to recurse.
|
||||
const shapeAlreadyRegistered = !!idToVarName[pred.nestedShape]; |
||||
|
||||
const shapeVarName = getVarNameFor(pred.nestedShape); |
||||
|
||||
constructStatements.push({ |
||||
s: `?${subjectVarName}`, |
||||
p: `<${pred.predicateUri}>`, |
||||
o: `?${shapeVarName}`, |
||||
optional: pred.minCardinality < 1, |
||||
literals: pred.literalValue, |
||||
// TODO: eitherOf ?
|
||||
}); |
||||
|
||||
if (!shapeAlreadyRegistered) |
||||
addTriples(schema[pred.nestedShape]); |
||||
} else { |
||||
const objVarName = getVarNameFor( |
||||
shapeId + "__separator__" + pred.predicateUri |
||||
); |
||||
|
||||
constructStatements.push({ |
||||
s: `?${subjectVarName}`, |
||||
p: `<${pred.predicateUri}>`, |
||||
o: `?${objVarName}`, |
||||
optional: pred.minCardinality < 1, |
||||
literals: pred.literalValue, |
||||
// TODO: eitherOf ?
|
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
addTriples(rootShape); |
||||
|
||||
const construct = `CONSTRUCT {
|
||||
${constructStatements.map(({ s, p, o }) => ` ${s} ${p} ${o} .\n`).join("")} }`;
|
||||
|
||||
const statementToWhere = ({ |
||||
s, |
||||
p, |
||||
o, |
||||
optional, |
||||
}: { |
||||
s: string; |
||||
p: string; |
||||
o: string; |
||||
optional: boolean; |
||||
}) => { |
||||
if (optional) return ` OPTIONAL { ${s} ${p} ${o} . }\n`; |
||||
else return ` ${s} ${p} ${o} .\n`; |
||||
}; |
||||
|
||||
const literalToSparqlFormat = ( |
||||
literal: string | number | boolean |
||||
): string => { |
||||
if (typeof literal === "number") return String(literal); |
||||
if (typeof literal === "boolean") return literal ? "true" : "false"; |
||||
if (typeof literal === "string") { |
||||
return isIri(literal) |
||||
? `<${literal}>` |
||||
: `"${escapeString(literal)}"`; |
||||
} |
||||
return `"${String(literal)}"`; |
||||
}; |
||||
|
||||
// Filters for optional values.
|
||||
const filters = constructStatements |
||||
.filter((statement) => statement.literals !== undefined) |
||||
.map((statement) => { |
||||
const vals = arrayOf(statement.literals!); |
||||
if (vals.length === 0) return ""; |
||||
if (vals.length === 1) { |
||||
return ` FILTER(${statement.o} = ${literalToSparqlFormat(vals[0]!)})\n`; |
||||
} |
||||
const list = vals.map(literalToSparqlFormat).join(", "); |
||||
return ` FILTER(${statement.o} IN (${list}))\n`; |
||||
}) |
||||
.join(""); |
||||
|
||||
const where = `WHERE {
|
||||
${constructStatements.map(statementToWhere).join("")} |
||||
${filters} |
||||
}`;
|
||||
|
||||
return `${construct}\n${where}`; |
||||
}; |
||||
|
||||
const arrayOf = <T extends any>(arrayOrLiteral: T | T[]) => { |
||||
if (typeof arrayOrLiteral === "undefined" || arrayOrLiteral === null) |
||||
return []; |
||||
if (Array.isArray(arrayOrLiteral)) return arrayOrLiteral; |
||||
return [arrayOrLiteral]; |
||||
}; |
||||
|
||||
const isIri = (str: string) => /^[a-zA-Z][a-zA-Z0-9+.-]{1,7}:/.test(str); |
||||
|
||||
const escapeString = (str: string) => str.replace(/["\\]/g, "\\$&"); |
@ -1,125 +0,0 @@ |
||||
/** |
||||
* Shared helpers and types to build SPARQL queries from ShapeConstraint |
||||
*/ |
||||
|
||||
export type LiteralKind = |
||||
| "number" |
||||
| "string" |
||||
| "boolean" |
||||
| "nested" |
||||
| "literal"; |
||||
|
||||
export interface PredicateConstraint { |
||||
displayName: string; |
||||
uri: string; |
||||
type: LiteralKind; |
||||
literalValue?: number | string | boolean | number[] | string[]; |
||||
nested?: ShapeConstraint; |
||||
min: number; |
||||
max: number; |
||||
currentCount: number; |
||||
} |
||||
|
||||
export interface ShapeConstraint { |
||||
subject: string; |
||||
// In upstream code this is typed as a 1-length tuple; we normalize to an array here
|
||||
predicates: PredicateConstraint[] | [PredicateConstraint]; |
||||
} |
||||
|
||||
export interface SparqlBuildOptions { |
||||
prefixes?: Record<string, string>; |
||||
graph?: string; // IRI of the named graph to query, if any
|
||||
includeOptionalForMinZero?: boolean; // default true
|
||||
} |
||||
|
||||
export const defaultPrefixes: Record<string, string> = { |
||||
xsd: "http://www.w3.org/2001/XMLSchema#", |
||||
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", |
||||
rdfs: "http://www.w3.org/2000/01/rdf-schema#", |
||||
}; |
||||
|
||||
export function prefixesToText(prefixes?: Record<string, string>): string { |
||||
const all = { ...defaultPrefixes, ...(prefixes ?? {}) }; |
||||
return Object.entries(all) |
||||
.map(([p, iri]) => `PREFIX ${p}: <${iri}>`) |
||||
.join("\n"); |
||||
} |
||||
|
||||
export function toIriOrCurie(term: string): string { |
||||
// variable
|
||||
if (term.startsWith("?") || term.startsWith("$")) return term; |
||||
// blank node
|
||||
if (term.startsWith("_:")) return term; |
||||
// full IRI
|
||||
if (term.includes("://")) return `<${term}>`; |
||||
// fallback: assume CURIE or already-angled
|
||||
if (term.startsWith("<") && term.endsWith(">")) return term; |
||||
return term; // CURIE, caller must ensure prefix provided
|
||||
} |
||||
|
||||
export function predicateToSparql(uri: string): string { |
||||
// Allow CURIEs or IRIs
|
||||
return toIriOrCurie(uri); |
||||
} |
||||
|
||||
export function safeVarName(name: string): string { |
||||
const base = name |
||||
.replace(/[^a-zA-Z0-9_]/g, "_") |
||||
.replace(/^([0-9])/, "_$1") |
||||
.slice(0, 60); |
||||
return base || "v"; |
||||
} |
||||
|
||||
export function varToken(name: string): string { |
||||
const n = name.startsWith("?") || name.startsWith("$") ? name.slice(1) : name; |
||||
return `?${safeVarName(n)}`; |
||||
} |
||||
|
||||
export function formatLiteral(value: string | number | boolean): string { |
||||
if (typeof value === "number") return String(value); |
||||
if (typeof value === "boolean") return value ? "true" : "false"; |
||||
// default string literal
|
||||
const escaped = value.replace(/"/g, '\\"'); |
||||
return `"${escaped}"`; |
||||
} |
||||
|
||||
export function formatTermForValues(value: string | number | boolean): string { |
||||
if (typeof value === "number" || typeof value === "boolean") |
||||
return formatLiteral(value); |
||||
// strings: detect IRI or CURIE and keep raw; otherwise quote
|
||||
const v = value.trim(); |
||||
const looksLikeIri = v.startsWith("<") && v.endsWith(">"); |
||||
const looksLikeHttp = v.includes("://"); |
||||
const looksLikeCurie = |
||||
/^[A-Za-z_][A-Za-z0-9_-]*:.+$/u.test(v) && !looksLikeHttp; |
||||
if (looksLikeIri || looksLikeHttp || looksLikeCurie) { |
||||
return looksLikeHttp ? `<${v}>` : v; |
||||
} |
||||
return formatLiteral(v); |
||||
} |
||||
|
||||
export function valuesBlock( |
||||
varName: string, |
||||
values: Array<string | number | boolean>, |
||||
): string { |
||||
const rendered = values.map(formatTermForValues).join(" "); |
||||
return `VALUES ${varName} { ${rendered} }`; |
||||
} |
||||
|
||||
export interface BuildContext { |
||||
// Tracks used variable names to avoid collisions
|
||||
usedVars: Set<string>; |
||||
} |
||||
|
||||
export function uniqueVar(ctx: BuildContext, base: string): string { |
||||
let candidate = varToken(base); |
||||
if (!ctx.usedVars.has(candidate)) { |
||||
ctx.usedVars.add(candidate); |
||||
return candidate; |
||||
} |
||||
let i = 2; |
||||
while (ctx.usedVars.has(`${candidate}_${i}`)) i++; |
||||
const unique = `${candidate}_${i}`; |
||||
ctx.usedVars.add(unique); |
||||
return unique; |
||||
} |
@ -0,0 +1,9 @@ |
||||
import { buildConstructQuery } from "./buildSparqlConstructFromShape.ts"; |
||||
import { testShapeSchema } from "./testShape.schema.ts"; |
||||
|
||||
console.log( |
||||
buildConstructQuery({ |
||||
schema: testShapeSchema, |
||||
shapeId: "http://example.org/TestObject", |
||||
}) |
||||
); |
@ -0,0 +1,129 @@ |
||||
import type { Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
/** |
||||
* ============================================================================= |
||||
* testShapeSchema: Schema for testShape |
||||
* ============================================================================= |
||||
*/ |
||||
export const testShapeSchema: Schema = { |
||||
"http://example.org/TestObject": { |
||||
iri: "http://example.org/TestObject", |
||||
predicates: [ |
||||
{ |
||||
type: "literal", |
||||
literalValue: ["TestObject"], |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
||||
readablePredicate: "type", |
||||
extra: true, |
||||
}, |
||||
{ |
||||
type: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/stringValue", |
||||
readablePredicate: "stringValue", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/numValue", |
||||
readablePredicate: "numValue", |
||||
}, |
||||
{ |
||||
type: "boolean", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/boolValue", |
||||
readablePredicate: "boolValue", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
predicateUri: "http://example.org/arrayValue", |
||||
readablePredicate: "arrayValue", |
||||
}, |
||||
{ |
||||
type: "nested", |
||||
nestedShape: |
||||
"http://example.org/TestObject||http://example.org/objectValue", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/objectValue", |
||||
readablePredicate: "objectValue", |
||||
}, |
||||
{ |
||||
type: "nested", |
||||
nestedShape: |
||||
"http://example.org/TestObject||http://example.org/anotherObject", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
predicateUri: "http://example.org/anotherObject", |
||||
readablePredicate: "anotherObject", |
||||
}, |
||||
{ |
||||
type: "eitherOf", |
||||
eitherOf: [ |
||||
{ |
||||
type: "string", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
}, |
||||
], |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/numOrStr", |
||||
readablePredicate: "numOrStr", |
||||
}, |
||||
], |
||||
}, |
||||
"http://example.org/TestObject||http://example.org/objectValue": { |
||||
iri: "http://example.org/TestObject||http://example.org/objectValue", |
||||
predicates: [ |
||||
{ |
||||
type: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/nestedString", |
||||
readablePredicate: "nestedString", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/nestedNum", |
||||
readablePredicate: "nestedNum", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
predicateUri: "http://example.org/nestedArray", |
||||
readablePredicate: "nestedArray", |
||||
}, |
||||
], |
||||
}, |
||||
"http://example.org/TestObject||http://example.org/anotherObject": { |
||||
iri: "http://example.org/TestObject||http://example.org/anotherObject", |
||||
predicates: [ |
||||
{ |
||||
type: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/prop1", |
||||
readablePredicate: "prop1", |
||||
}, |
||||
{ |
||||
type: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
predicateUri: "http://example.org/prop2", |
||||
readablePredicate: "prop2", |
||||
}, |
||||
], |
||||
}, |
||||
}; |
Loading…
Reference in new issue