This commit is contained in:
felix
2026-09-14 13:57:42 +02:00
commit 4ef33c6417
951 changed files with 155793 additions and 0 deletions

83
src/runner/assemble.js Normal file
View File

@@ -0,0 +1,83 @@
// Values to rows. Subjects in stable order, dedup on term, collect per cell.
// rep:self columns take the subject URI; a column with no values is an
// empty cell, never a dropped row. rep:row expands one subject into several
// lines; at most one column per spec may do that (checked in check.js).
const SH = 'http://www.w3.org/ns/shacl#';
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
export const NUMERIC_DATATYPES = new Set([
'integer', 'decimal', 'double', 'float', 'long', 'int', 'short', 'byte',
'nonNegativeInteger', 'positiveInteger', 'nonPositiveInteger', 'negativeInteger',
'unsignedLong', 'unsignedInt', 'unsignedShort', 'unsignedByte',
].map(d => XSD + d));
export const TEMPORAL_DATATYPES = new Set([XSD + 'date', XSD + 'dateTime']);
export function assemble(subjects, values, spec) {
const sorted = [...subjects].sort();
const rows = [spec.columns.map(c => c.label)];
for (const subject of sorted) {
const perColumn = values.get(subject) || new Map();
const cells = spec.columns.map((col, i) => {
if (!col.path.length) return [subject]; // rep:self
const list = dedup(perColumn.get(i) || []);
return collect(list, col);
});
const lines = Math.max(1, ...cells.map(c => c.length));
for (let r = 0; r < lines; r++) {
// every cell except the expanding one keeps its value on each line
rows.push(cells.map(c => c[Math.min(r, c.length - 1)]));
}
}
return rows;
}
function collect(terms, col) {
switch (col.collect) {
case SH + 'groupConcat':
case null:
case undefined:
return [terms.map(t => t.value).join(col.separator ?? ',')];
case SH + 'min':
return [extreme(terms, col, -1)];
case SH + 'max':
return [extreme(terms, col, 1)];
case REP + 'row':
return terms.length ? terms.map(t => t.value) : [''];
default:
throw new Error(`column "${col.label}" has an unknown rep:collect <${col.collect}>. Use sh:groupConcat, rep:row, sh:min or sh:max.`);
}
}
// min/max over values that share one datatype. Numbers compare as numbers,
// dates and times as instants. Mixed datatypes throw: check.js should have
// caught it, and a silently wrong cell is worse than a failed job.
function extreme(terms, col, direction) {
if (!terms.length) return '';
const datatypes = [...new Set(terms.map(t => t.datatype?.value || ''))];
if (datatypes.length > 1) {
throw new Error(`column "${col.label}" asks for sh:${direction < 0 ? 'min' : 'max'} but its values carry ${datatypes.length} datatypes (${datatypes.join(', ')}).`);
}
const dt = datatypes[0];
const key = NUMERIC_DATATYPES.has(dt) ? t => Number(t.value)
: TEMPORAL_DATATYPES.has(dt) ? t => new Date(t.value).getTime()
: t => t.value;
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const best = terms.reduce((a, b) => (cmp(key(b), key(a)) * direction > 0 ? b : a));
return best.value;
}
// Two terms are the same when the URIs match, or lexical form, datatype
// and language tag all match.
export function dedup(terms) {
const seen = new Set();
const out = [];
for (const t of terms) {
const key = t.termType === 'NamedNode'
? `u:${t.value}`
: `l:${t.value}|${t.datatype?.value || ''}|${t.language || ''}`;
if (!seen.has(key)) { seen.add(key); out.push(t); }
}
return out;
}

311
src/runner/check.js Normal file
View File

@@ -0,0 +1,311 @@
import { entityForClass, fieldsOf } from './profile.js';
import { NUMERIC_DATATYPES } from './assemble.js';
const SH = 'http://www.w3.org/ns/shacl#';
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
const XSD_DATE = XSD + 'date';
const XSD_DATETIME = XSD + 'dateTime';
// The validator. checkSpec(spec, profile, maxPathDepth) runs the named checks
// in order and returns the FIRST error as a one-element array (empty if ok).
// Every message names the thing that is wrong in the profile's own words and
// says what to write instead. No database call anywhere in this file — that is
// what lets an LLM iterate for free.
const ANYOF_MAX_TERMS = 24;
const ANYOF_MAX_TERM_LENGTH = 100;
// 1 + 2. the profile names itself and is one we have. knownProfiles is the
// Map app.js loaded at boot; omit it and the check is skipped (fixtures).
function profileNamed(spec, knownProfiles) {
if (!spec.profileUri) {
return `no rep:profile named. Available: ${knownProfiles ? [...knownProfiles.values()].map(p => p.title).join(', ') : '(none)'} .`;
}
if (knownProfiles && !knownProfiles.has(spec.profileUri)) {
return `no profile <${spec.profileUri}>. Available: ${knownProfiles ? [...knownProfiles.values()].map(p => p.title).join(', ') : '(none)'}.`;
}
return null;
}
// 3 + 4. the target class is in the profile
function targetClass(spec, profile) {
if (!spec.targetClass) {
return `no sh:targetClass. In "${profile.title}" you can list: ${shapeList(profile)}.`;
}
if (!entityForClass(profile, spec.targetClass)) {
return `"${profile.title}" cannot list <${spec.targetClass}>. It has: ${shapeList(profile)}.`;
}
return null;
}
function shapeList(profile) {
return profile.shapes.map(s => s.label || s.targetClass).join(', ');
}
// 5. columns exist
function columnsPresent(spec) {
if (!spec.columns.length) return 'no columns. Add at least one rep:columns entry.';
return null;
}
// 6. every column has a label
function columnLabel(spec) {
for (const col of spec.columns) {
if (!col.label) return 'a column has no rdfs:label.';
}
return null;
}
// 7. labels unique
function columnLabelsUnique(spec) {
const seen = new Map();
for (const col of spec.columns) {
if (seen.has(col.label)) {
return `two columns are called "${col.label}". Labels must differ.`;
}
seen.set(col.label, true);
}
return null;
}
// 8 + 9 + 10. every path resolves, columns end on a value, depth capped
function pathsResolve(spec, profile, maxPathDepth) {
const startShapeUri = entityForClass(profile, spec.targetClass);
for (const col of spec.columns) {
const err = walk(profile, startShapeUri, col.path, maxPathDepth,
`column "${col.label}"`, true);
if (err) return err;
}
for (const [i, filter] of spec.filters.entries()) {
const err = walk(profile, startShapeUri, filter.path, maxPathDepth,
`filter ${i + 1}`, false);
if (err) return err;
}
return null;
}
// 11. sh:min/sh:max only on numbers and dates
function collectMinMaxTyped(spec, profile) {
const startShapeUri = entityForClass(profile, spec.targetClass);
for (const col of spec.columns) {
if (col.collect !== SH + 'min' && col.collect !== SH + 'max') continue;
const name = col.collect === SH + 'min' ? 'sh:min' : 'sh:max';
if (!col.path.length) return `${name} on a rep:self column does not apply. A subject URI is not a number or date.`;
const last = col.path[col.path.length - 1];
const shapeUri = shapeAfter(profile, startShapeUri, col.path);
if (!shapeUri) continue; // pathsResolve already reported the real error
const f = fieldsOf(profile, shapeUri)
.find(x => x.path === last.predicate && !!x.inverse === !!last.inverse);
if (!f) continue;
const kind = f.datatype === XSD_DATETIME || f.datatype === XSD_DATE ? 'date'
: NUMERIC_DATATYPES.has(f.datatype) ? 'number'
: null;
if (!kind) {
return `"${f.name || last.predicate}" is text, so ${name} does not apply. Use rep:row, or leave it out to join the values.`;
}
}
return null;
}
// 13. at most one rep:row column
function oneRowColumn(spec) {
const rowCols = spec.columns.filter(c => c.collect === REP + 'row');
if (rowCols.length > 1) {
const names = rowCols.map(c => `"${c.label}"`).join(' and ');
return `only one column may use rep:row. ${names} both do.`;
}
return null;
}
// 12 + 14. columns carry no constraints; known collect mode
function columnPure(spec) {
for (const col of spec.columns) {
if (Object.keys(col.constraints || {}).length) {
const which = Object.keys(col.constraints)[0];
return `a column carries ${which}. Constraints belong in sh:property.`;
}
if (col.collect && ![SH + 'groupConcat', SH + 'min', SH + 'max', REP + 'row'].includes(col.collect)) {
return `column "${col.label}" has an unknown rep:collect <${col.collect}>. Use sh:groupConcat (default), rep:row, sh:min or sh:max.`;
}
}
return null;
}
// 15. no label inside a filter
function filterLabel(spec) {
for (const filter of spec.filters) {
if ((filter.constraints || {}).label) {
return 'a filter carries rdfs:label. Move it to rep:columns to show it.';
}
}
return null;
}
// 15b. a filter with a path but no constraint selects everything; the model
// almost certainly meant to filter. Refuse and say what to add.
function filterConstraint(spec) {
const MEANINGFUL = ['minCount', 'maxCount', 'hasValue', 'in', 'minInclusive',
'maxInclusive', 'minExclusive', 'maxExclusive', 'pattern', 'flags', 'anyOf'];
for (const [i, filter] of spec.filters.entries()) {
const c = filter.constraints || {};
if (!MEANINGFUL.some(k => c[k] !== undefined && c[k] !== null)) {
const p = filter.path.map(h => h.predicate).join(' → ');
return `filter ${i + 1} (path ${p}) has no condition. Add one: sh:hasValue for an exact value, sh:in for a list, rep:anyOf for words, or remove the filter.`;
}
}
return null;
}
// 16. sh:maxCount above 0 needs counting
function maxCountZero(spec) {
for (const [i, filter] of spec.filters.entries()) {
const c = filter.constraints;
if (c.maxCount !== null && c.maxCount !== undefined && c.maxCount > 0) {
return `sh:maxCount ${c.maxCount} needs counting, which this service cannot do. Use sh:maxCount 0 for "has none", or drop it.`;
}
if (c.minCount !== null && c.minCount !== undefined && c.minCount > 2) {
return `sh:minCount ${c.minCount} needs counting. Use sh:minCount 1 (at least one) or sh:minCount 2 (two distinct values); anything higher is beyond this service.`;
}
}
return null;
}
// 17 + 18. rep:anyOf size limits
function anyOfLimits(spec) {
for (const [i, filter] of spec.filters.entries()) {
const c = filter.constraints;
if (!c.anyOf) continue;
if (c.anyOf.length > ANYOF_MAX_TERMS) {
return `rep:anyOf has ${c.anyOf.length} terms. The limit is ${ANYOF_MAX_TERMS}. Use sh:in with exact URIs instead (find them with lookup_values).`;
}
for (const term of c.anyOf) {
if (term.type === 'uri') {
return 'a rep:anyOf term is a URI. rep:anyOf takes Dutch words, not URIs. Use sh:in with the exact URI instead.';
}
if (term.value && term.value.length > ANYOF_MAX_TERM_LENGTH) {
return `a rep:anyOf term is ${term.value.length} characters. The limit is ${ANYOF_MAX_TERM_LENGTH}.`;
}
}
}
return null;
}
// 19. sh:in mixes URIs and literals
function inHomogeneous(spec) {
for (const filter of spec.filters) {
const c = filter.constraints;
if (!c.in) continue;
const uris = c.in.filter(t => t.type === 'uri').length;
if (uris > 0 && uris < c.in.length) {
return 'sh:in mixes URIs and text. Pick one.';
}
}
return null;
}
// 20. typed literals that will not parse
function literalsParse(spec) {
const constraintTerms = [];
for (const filter of spec.filters) {
const c = filter.constraints;
for (const key of ['hasValue', 'minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive']) {
if (c[key]) constraintTerms.push(c[key]);
}
if (c.in) constraintTerms.push(...c.in);
}
for (const t of constraintTerms) {
if (t.type !== 'literal' || !t.datatype) continue;
if (t.datatype === XSD_DATETIME && !parsesAsDate(t.value, true)) {
return `"${t.value}" is not a valid xsd:dateTime.`;
}
if (t.datatype === XSD_DATE && !parsesAsDate(t.value, false)) {
return `"${t.value}" is not a valid xsd:date.`;
}
}
return null;
}
function parsesAsDate(value, withTime) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return false;
return withTime ? value.includes('T') : /^\d{4}-\d{2}-\d{2}$/.test(value);
}
const CHECKS = [
['no sh:targetClass or unknown', targetClass],
['no columns', columnsPresent],
['column without rdfs:label', columnLabel],
['two columns with one label', columnLabelsUnique],
['column carries constraints', columnPure],
['filter carries rdfs:label', filterLabel],
['filter without a condition', filterConstraint],
['sh:maxCount above 0', maxCountZero],
['rep:anyOf too big', anyOfLimits],
['sh:in mixes URIs and literals', inHomogeneous],
['typed literal does not parse', literalsParse],
['two rep:row columns', oneRowColumn],
['path does not resolve', pathsResolve],
['sh:min/sh:max on a non-number', collectMinMaxTyped],
];
export function checkSpec(spec, profile, maxPathDepth, knownProfiles = null) {
const profileErr = profileNamed(spec, knownProfiles);
if (profileErr) return [profileErr];
for (const [name, check] of CHECKS) {
const err = check(spec, profile, maxPathDepth);
if (err) return [err];
}
return [];
}
// The shape a column's path lands in: follow sh:node through every hop but
// the last. walk() has already validated the path, so any gap means the walk
// reported the real error and this can bail.
function shapeAfter(profile, startShapeUri, hops) {
let shapeUri = startShapeUri;
for (let i = 0; i < hops.length - 1; i++) {
const fields = fieldsOf(profile, shapeUri);
const f = fields.find(x => x.path === hops[i].predicate && !!x.inverse === !!hops[i].inverse);
if (!f || !f.node) return null;
shapeUri = f.node;
}
return shapeUri;
}
function walk(profile, startShapeUri, hops, maxPathDepth, what, isColumn) {
if (hops.length > maxPathDepth) {
return `${what} walks ${hops.length} hops. The limit is ${maxPathDepth}.`;
}
if (!hops.length) return null;
let shapeUri = startShapeUri;
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
const fields = fieldsOf(profile, shapeUri);
const f = fields.find(x => x.path === hop.predicate && !!x.inverse === !!hop.inverse);
if (!f) {
const have = fields.map(x => (x.inverse ? `geen ${x.name} (${x.path}, omgekeerd)` : `${x.name} (${x.path})`)).join(', ');
const shapeLabel = profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri;
return `at "${shapeLabel}" there is no "${hop.predicate}". It has: ${have || '(nothing)'}.`;
}
if (i < hops.length - 1) {
if (!f.node) {
return `${what} hop ${i + 1} <${hop.predicate}> holds a value, not a link. You cannot walk past it.`;
}
shapeUri = f.node;
} else if (isColumn) {
if (!f.datatype && !f.class) {
const valueFields = fieldsOf(profile, f.node)
.filter(x => x.datatype || x.class)
.map(x => `${x.name} (${x.path})`);
return `column "${colLabel(what)}" ends on a link. Add a hop: ${valueFields.join(' or ')}.`;
}
}
}
return null;
}
function colLabel(what) {
const m = what.match(/"([^"]+)"/);
return m ? m[1] : what;
}

67
src/runner/columns.js Normal file
View File

@@ -0,0 +1,67 @@
import { sparqlEscapeUri } from '../db.js';
// Fetch column values for known subjects. Columns are grouped by their
// shared non-leaf hops; two columns share a query only when every hop
// before the leaf is identical, and the leaf is read the same way.
// rep:self columns (empty path) are filled by assemble.js.
const SUBJECT_CHUNK_SIZE = Number(process.env.SUBJECT_CHUNK_SIZE || 250);
export async function fetchColumns(sessionQuery, subjects, spec, values = new Map(), onChunk = null) {
const groups = groupColumns(spec.columns);
for (const group of groups) {
for (let i = 0; i < subjects.length; i += SUBJECT_CHUNK_SIZE) {
const chunk = subjects.slice(i, i + SUBJECT_CHUNK_SIZE);
const result = await sessionQuery(groupQuery(group, chunk));
collect(result, group, values);
}
if (onChunk) await onChunk(group);
}
return values;
}
// Group columns by their shared prefix (every hop before the leaf) and the
// direction of the leaf. A column with an empty path (rep:self) joins no group.
export function groupColumns(columns) {
const groups = new Map();
columns.forEach((col, index) => {
if (!col.path.length) return; // rep:self
const leaf = col.path[col.path.length - 1];
const key = col.path.map(h => `${h.inverse ? '^' : ''}${h.predicate}`).join(' > ');
if (!groups.has(key)) {
groups.set(key, { hops: col.path, columns: [] });
}
groups.get(key).columns.push({ index, leaf: leaf.predicate });
});
return [...groups.values()];
}
export function groupQuery(group, chunk) {
const subjects = chunk.map(sparqlEscapeUri).join(' ');
const lines = [];
let prev = '?s';
// every hop except the last gets its own variable; the leaf is ?v
group.hops.slice(0, -1).forEach((hop, i) => {
const v = `?x${i}`;
if (hop.inverse) lines.push(`${v} ${sparqlEscapeUri(hop.predicate)} ${prev} .`);
else lines.push(`${prev} ${sparqlEscapeUri(hop.predicate)} ${v} .`);
prev = v;
});
const leaf = group.hops[group.hops.length - 1];
if (leaf.inverse) lines.push(`?v ${sparqlEscapeUri(leaf.predicate)} ${prev} .`);
else lines.push(`${prev} ${sparqlEscapeUri(leaf.predicate)} ?v .`);
return `SELECT ?s ?v WHERE {\n VALUES ?s { ${subjects} }\n ${lines.join('\n ')}\n}`;
}
function collect(result, group, values) {
for (const b of result.results.bindings) {
let perColumn = values.get(b.s.value);
if (!perColumn) { perColumn = new Map(); values.set(b.s.value, perColumn); }
for (const col of group.columns) {
let list = perColumn.get(col.index);
if (!list) { list = []; perColumn.set(col.index, list); }
list.push(b.v);
}
}
}

27
src/runner/csv.js Normal file
View File

@@ -0,0 +1,27 @@
import { mkdirSync, writeFileSync } from 'fs';
// Rows to a CSV file. CSV_SEPARATOR is ';' by default.
// A cell is quoted only when it holds the separator, a quote or a newline —
// this matches the shape of today's loket-report-generation-service output.
// A quote inside a cell is doubled, per RFC 4180.
const CSV_SEPARATOR = process.env.CSV_SEPARATOR || ';';
const SHARE_DIR = process.env.SHARE_DIR || '/share';
export function rowsToCsv(rows) {
return rows.map(row => row.map(escapeCell).join(CSV_SEPARATOR)).join('\n');
}
function escapeCell(cell) {
const s = String(cell ?? '');
return s.includes(CSV_SEPARATOR) || s.includes('"') || /[\n\r]/.test(s)
? `"${s.replace(/"/g, '""')}"`
: s;
}
export function writeCsv(name, csv) {
mkdirSync(SHARE_DIR, { recursive: true });
const file = `${SHARE_DIR}/${name}`;
writeFileSync(file, csv, 'utf8');
return file;
}

158
src/runner/job.js Normal file
View File

@@ -0,0 +1,158 @@
import { randomUUID } from 'crypto';
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from '../db.js';
// cogs:Job + task:Task, written by us. No other service is involved.
// Triples follow §6 of plan-opus-5.iteration-1-4.md; the shape follows
// config/resources/master-job-domain.lisp.
// Every write carries mu-call-scope-id (REPORT_SCOPE) so the delta notifier
// lets jobs-controller ignore our statuses — it throws on unknown operations.
// All functions take the update function to use: scopedUpdate(sparql, extra),
// which sends the captured groups plus this scope header.
const REPORT_SCOPE = 'http://redpencil.data.gift/id/concept/muScope/report-generation';
const JOB_BASE = process.env.JOB_URI_BASE || 'http://redpencil.data.gift/id/job/';
const TASK_BASE = process.env.TASK_URI_BASE || 'http://redpencil.data.gift/id/task/';
const STATUS = {
scheduled: 'http://redpencil.data.gift/id/concept/JobStatus/scheduled',
busy: 'http://redpencil.data.gift/id/concept/JobStatus/busy',
success: 'http://redpencil.data.gift/id/concept/JobStatus/success',
failed: 'http://redpencil.data.gift/id/concept/JobStatus/failed',
};
const JOB_OPERATION = 'http://lblod.data.gift/id/jobs/concept/JobOperation/reportGeneration';
const TASK_OPERATION = 'http://lblod.data.gift/id/jobs/concept/TaskOperation/reportGeneration';
const COGS_JOB = 'http://vocab.deri.ie/cogs#Job';
const TASK_TASK = 'http://redpencil.data.gift/vocabularies/tasks/Task';
const DATA_CONTAINER = 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#DataContainer';
const MU_UUID = 'http://mu.semte.ch/vocabularies/core/uuid';
const ADMS_STATUS = 'http://www.w3.org/ns/adms#status';
const DCT = 'http://purl.org/dc/terms/';
const TASK = 'http://redpencil.data.gift/vocabularies/tasks/';
const OSLC_MESSAGE = 'http://open-services.net/ns/core#message';
const OSLC_ERROR_URI = 'http://open-services.net/ns/core#Error';
// updateFn(sparql, extraHeaders) — app.js passes scopedUpdate, which sends
// the captured groups; the scope header is added here so every job write is
// opt-out-able for the delta notifier.
function withScope(updateFn) {
return async sparql => updateFn(sparql, { 'mu-call-scope-id': REPORT_SCOPE });
}
// Create job + task + result container, all at status scheduled.
// Returns { jobUri, taskUri, containerUri }.
export async function createJob(updateFn, title, question, creator) {
const upd = withScope(updateFn);
const jobId = randomUUID();
const taskId = randomUUID();
const containerId = randomUUID();
const jobUri = `${JOB_BASE}${jobId}`;
const taskUri = `${TASK_BASE}${taskId}`;
const containerUri = `http://redpencil.data.gift/id/dataContainers/${containerId}`;
const now = sparqlEscapeDateTime(new Date());
const creatorTriple = creator
? `${sparqlEscapeUri(jobUri)} ${sparqlEscapeUri(DCT + 'creator')} ${sparqlEscapeUri(creator)} .`
: '';
const questionTriple = question
? `${sparqlEscapeUri(jobUri)} ${sparqlEscapeUri('http://mu.semte.ch/vocabularies/ext/question')} ${sparqlEscapeString(question)} .`
: '';
await upd(`
INSERT DATA {
${sparqlEscapeUri(jobUri)} a ${sparqlEscapeUri(COGS_JOB)} ;
${sparqlEscapeUri(MU_UUID)} ${sparqlEscapeString(jobId)} ;
${sparqlEscapeUri(DCT + 'title')} ${sparqlEscapeString(title)} ;
${sparqlEscapeUri(ADMS_STATUS)} ${sparqlEscapeUri(STATUS.scheduled)} ;
${sparqlEscapeUri(TASK + 'operation')} ${sparqlEscapeUri(JOB_OPERATION)} ;
${sparqlEscapeUri(DCT + 'created')} ${now} ;
${sparqlEscapeUri(DCT + 'modified')} ${now} .
${creatorTriple}
${questionTriple}
${sparqlEscapeUri(taskUri)} a ${sparqlEscapeUri(TASK_TASK)} ;
${sparqlEscapeUri(MU_UUID)} ${sparqlEscapeString(taskId)} ;
${sparqlEscapeUri(DCT + 'isPartOf')} ${sparqlEscapeUri(jobUri)} ;
${sparqlEscapeUri(ADMS_STATUS)} ${sparqlEscapeUri(STATUS.scheduled)} ;
${sparqlEscapeUri(TASK + 'operation')} ${sparqlEscapeUri(TASK_OPERATION)} ;
${sparqlEscapeUri(TASK + 'index')} ${sparqlEscapeString('0')} ;
${sparqlEscapeUri(DCT + 'created')} ${now} ;
${sparqlEscapeUri(DCT + 'modified')} ${now} ;
${sparqlEscapeUri(TASK + 'resultsContainer')} ${sparqlEscapeUri(containerUri)} .
${sparqlEscapeUri(containerUri)} a ${sparqlEscapeUri(DATA_CONTAINER)} ;
${sparqlEscapeUri(MU_UUID)} ${sparqlEscapeString(containerId)} .
}`);
return { jobUri, taskUri, containerUri };
}
export async function setStatus(updateFn, uri, status) {
const s = STATUS[status];
if (!s) throw new Error(`unknown job status "${status}"`);
const now = sparqlEscapeDateTime(new Date());
await withScope(updateFn)(`
DELETE {
${sparqlEscapeUri(uri)} ${sparqlEscapeUri(ADMS_STATUS)} ?old ;
${sparqlEscapeUri(DCT + 'modified')} ?modified .
}
INSERT {
${sparqlEscapeUri(uri)} ${sparqlEscapeUri(ADMS_STATUS)} ${sparqlEscapeUri(s)} ;
${sparqlEscapeUri(DCT + 'modified')} ${now} .
}
WHERE {
${sparqlEscapeUri(uri)} ${sparqlEscapeUri(ADMS_STATUS)} ?old .
OPTIONAL { ${sparqlEscapeUri(uri)} ${sparqlEscapeUri(DCT + 'modified')} ?modified . }
}`);
}
export async function touch(updateFn, taskUri) {
const now = sparqlEscapeDateTime(new Date());
await withScope(updateFn)(`
DELETE {
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(DCT + 'modified')} ?modified .
}
INSERT {
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(DCT + 'modified')} ${now} .
}
WHERE {
OPTIONAL { ${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(DCT + 'modified')} ?modified . }
}`);
}
// Link the finished CSV to the task's result container. The container is
// created with the job; the file URI is the logical file from registerFile.
export async function attachResult(updateFn, taskUri, fileUri) {
await withScope(updateFn)(`
INSERT {
?container ${sparqlEscapeUri(TASK + 'hasFile')} ${sparqlEscapeUri(fileUri)} .
}
WHERE {
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(TASK + 'resultsContainer')} ?container .
}`);
}
// An oslc:Error with the message, job and task both failed.
export async function fail(updateFn, jobUri, taskUri, message) {
const errorId = randomUUID();
const errorUri = `http://redpencil.data.gift/id/jobs/error/${errorId}`;
const now = sparqlEscapeDateTime(new Date());
await withScope(updateFn)(`
DELETE {
${sparqlEscapeUri(jobUri)} ${sparqlEscapeUri(ADMS_STATUS)} ?js .
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(ADMS_STATUS)} ?ts .
}
INSERT DATA {
${sparqlEscapeUri(errorUri)} a ${sparqlEscapeUri(OSLC_ERROR_URI)} ;
${sparqlEscapeUri(MU_UUID)} ${sparqlEscapeString(errorId)} ;
${sparqlEscapeUri(OSLC_MESSAGE)} ${sparqlEscapeString(message)} ;
${sparqlEscapeUri(DCT + 'created')} ${now} .
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(TASK + 'error')} ${sparqlEscapeUri(errorUri)} ;
${sparqlEscapeUri(ADMS_STATUS)} ${sparqlEscapeUri(STATUS.failed)} ;
${sparqlEscapeUri(DCT + 'modified')} ${now} .
${sparqlEscapeUri(jobUri)} ${sparqlEscapeUri(ADMS_STATUS)} ${sparqlEscapeUri(STATUS.failed)} ;
${sparqlEscapeUri(DCT + 'modified')} ${now} .
}
WHERE {
${sparqlEscapeUri(jobUri)} ${sparqlEscapeUri(ADMS_STATUS)} ?js .
${sparqlEscapeUri(taskUri)} ${sparqlEscapeUri(ADMS_STATUS)} ?ts .
}`);
}

106
src/runner/profile.js Normal file
View File

@@ -0,0 +1,106 @@
import N3 from 'n3';
import { readFileSync } from 'fs';
// Parse a profile (a SHACL shapes graph) and answer questions about it.
const SH = 'http://www.w3.org/ns/shacl#';
const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
const OWL_ONTOLOGY = 'http://www.w3.org/2002/07/owl#Ontology';
export async function loadProfiles(dir) {
// eslint-disable-next-line no-undef
const fs = await import('fs');
// eslint-disable-next-line no-undef
const path = await import('path');
const files = fs.readdirSync(dir).filter(f => f.endsWith('.ttl')).sort();
const profiles = new Map();
for (const file of files) {
const profile = await loadProfile(path.join(dir, file));
profiles.set(profile.uri, profile);
console.log(`[profile] "${profile.title}" (${file}): ${profile.shapes.length} entities, ` +
`${profile.shapes.reduce((n, s) => n + s.fields.length, 0)} fields`);
}
if (!profiles.size) throw new Error(`no profiles found in ${dir}`);
return profiles;
}
export async function loadProfile(file) {
const parser = new N3.Parser();
const store = new N3.Store();
await new Promise((resolve, reject) => {
parser.parse(readFileSync(file, 'utf8'), (err, quad) => {
if (err) return reject(new Error(`${file}: ${err.message}`));
if (quad) store.addQuad(quad); else resolve();
});
});
const ontology = store.getQuads(null, RDF + 'type', OWL_ONTOLOGY)[0];
if (!ontology) throw new Error(`${file}: no owl:Ontology. A profile file must name itself.`);
const uri = ontology.subject.value;
const title = store.getQuads(ontology.subject, 'http://purl.org/dc/terms/title', null)[0]?.object.value || uri;
const prefixQuads = store.getQuads(null, SH + 'declare', null);
const prefixes = {};
for (const q of prefixQuads) {
const p = store.getQuads(q.object, SH + 'prefix', null)[0]?.object.value;
const ns = store.getQuads(q.object, SH + 'namespace', null)[0]?.object.value;
if (p && ns) prefixes[p] = ns;
}
const shapes = store.getQuads(null, RDF + 'type', SH + 'NodeShape').map(q => q.subject);
if (!shapes.length) throw new Error(`${file}: no sh:NodeShape. A profile without entities is useless.`);
const shapeList = shapes.map(shape => {
const targetClass = store.getQuads(shape, SH + 'targetClass', null)[0]?.object.value || null;
const label = store.getQuads(shape, 'http://www.w3.org/2000/01/rdf-schema#label', null)[0]?.object.value || null;
const fields = store.getQuads(shape, SH + 'property', null).map(q => parseField(store, q.object));
return { uri: shape.value, targetClass, label, fields };
});
return { uri, title, prefixes, shapes: shapeList, store };
}
function parseField(store, p) {
let path = null, inverse = false;
const pathQuad = store.getQuads(p, SH + 'path', null)[0]?.object;
if (pathQuad) {
if (pathQuad.termType === 'BlankNode') {
const inv = store.getQuads(pathQuad, SH + 'inversePath', null)[0]?.object;
if (inv) { path = inv.value; inverse = true; }
} else {
path = pathQuad.value;
}
}
const one = (pred) => store.getQuads(p, pred, null)[0]?.object || null;
return {
path, inverse,
name: one(SH + 'name')?.value || null,
description: one(SH + 'description')?.value || null,
datatype: one(SH + 'datatype')?.value || null,
class: one(SH + 'class')?.value || null,
node: one(SH + 'node')?.value || null,
nodeKind: one(SH + 'nodeKind')?.value || null,
maxCount: one(SH + 'maxCount')?.value || null,
};
}
export function entityForClass(profile, cls) {
const shape = profile.shapes.find(s => s.targetClass === cls);
return shape ? shape.uri : null;
}
export function shape(profile, shapeUri) {
return profile.shapes.find(s => s.uri === shapeUri) || null;
}
export function fieldsOf(profile, shapeUri) {
return shape(profile, shapeUri)?.fields || [];
}
export function field(profile, shapeUri, predicate, inverse) {
return fieldsOf(profile, shapeUri).find(f => f.path === predicate && !!f.inverse === !!inverse) || null;
}
export function prefixes(profile) {
return profile.prefixes;
}

62
src/runner/report.js Normal file
View File

@@ -0,0 +1,62 @@
import { randomUUID } from 'crypto';
import { statSync } from 'fs';
import { sessionUpdate, sparqlEscapeUri, sparqlEscapeString, sparqlEscapeInt,
sparqlEscapeDateTime } from '../db.js';
const REPORT_CLASS = process.env.REPORT_CLASS || 'http://lblod.data.gift/vocabularies/reporting/Report';
// Write the file to /share, then register it once: a logical file
// (http://data.lblod.info/files/<uuid>) plus a physical share:// file
// pointing back with nie:dataSource. The logical URI is what you link to.
export async function registerFile(sessionUpdateFn, fileName, filePath) {
const size = statSync(filePath).size;
const uuid = randomUUID();
const uuid2 = randomUUID();
const now = new Date();
const logicalUri = `http://data.lblod.info/files/${uuid}`;
const physicalUri = `share://${fileName}`;
await (sessionUpdateFn || sessionUpdate)(`
INSERT DATA {
${sparqlEscapeUri(logicalUri)} a ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject')} ;
${sparqlEscapeUri('http://mu.semte.ch/vocabularies/core/uuid')} ${sparqlEscapeString(uuid)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/format')} ${sparqlEscapeString('text/csv')} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize')} ${sparqlEscapeInt(size)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/created')} ${sparqlEscapeDateTime(now)} .
${sparqlEscapeUri(physicalUri)} a ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject')} ;
${sparqlEscapeUri('http://mu.semte.ch/vocabularies/core/uuid')} ${sparqlEscapeString(uuid2)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/format')} ${sparqlEscapeString('text/csv')} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize')} ${sparqlEscapeInt(size)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileCreated')} ${sparqlEscapeDateTime(now)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/01/19/nie#dataSource')} ${sparqlEscapeUri(logicalUri)} .
}`);
return logicalUri;
}
// One report pointing at the logical file URI. Insert without naming a graph;
// the auth layer places the triples from the caller's session.
export async function registerReport(sessionUpdateFn, title, fileUri, extra = {}) {
const uuid = randomUUID();
const now = new Date();
const reportUri = `http://data.lblod.info/id/reports/${uuid}`;
const extraTriples = [];
if (extra.question) extraTriples.push(`${sparqlEscapeUri(reportUri)} ${sparqlEscapeUri('http://mu.semte.ch/vocabularies/ext/question')} ${sparqlEscapeString(extra.question)} .`);
if (extra.spec) extraTriples.push(`${sparqlEscapeUri(reportUri)} ${sparqlEscapeUri('http://mu.semte.ch/vocabularies/ext/spec')} ${sparqlEscapeString(extra.spec)} .`);
if (extra.creator) extraTriples.push(`${sparqlEscapeUri(reportUri)} ${sparqlEscapeUri('http://purl.org/dc/terms/creator')} ${sparqlEscapeUri(extra.creator)} .`);
await (sessionUpdateFn || sessionUpdate)(`
INSERT DATA {
${sparqlEscapeUri(reportUri)} a ${sparqlEscapeUri(REPORT_CLASS)} ;
${sparqlEscapeUri('http://mu.semte.ch/vocabularies/core/uuid')} ${sparqlEscapeString(uuid)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/title')} ${sparqlEscapeString(title)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/created')} ${sparqlEscapeDateTime(now)} ;
${sparqlEscapeUri('http://www.w3.org/ns/prov#generated')} ${sparqlEscapeUri(fileUri)} .
${extraTriples.join('\n ')}
}`);
return reportUri;
}

41
src/runner/run.js Normal file
View File

@@ -0,0 +1,41 @@
import { seed } from './seed.js';
import { fetchColumns } from './columns.js';
import { assemble } from './assemble.js';
import { rowsToCsv, writeCsv } from './csv.js';
import { registerFile, registerReport } from './report.js';
import { touch } from './job.js';
import { groupsQuery, groupsUpdate } from '../db.js';
// Ties the six steps together. Runs with the captured groups (see
// captureGroups), returns { reportUri, fileUri, rowCount }. The task is
// touched once per column group so the dashboard shows movement.
export async function run(allowedGroups, parsed, profile, title, taskUri, extra = {}) {
const queryFn = sparql => groupsQuery(sparql, allowedGroups);
const updateFn = (sparql, extra) => groupsUpdate(sparql, allowedGroups, extra);
const subjects = await seed(queryFn, parsed);
const values = new Map();
await fetchColumns(queryFn, subjects, parsed, values,
taskUri ? () => touch(updateFn, taskUri) : null);
const rows = assemble(subjects, values, parsed);
const fileName = `${slug(title)}.csv`;
const csv = rowsToCsv(rows);
const filePath = writeCsv(fileName, csv);
const fileUri = await registerFile((sparql) => updateFn(sparql), fileName, filePath);
const reportUri = await registerReport((sparql) => updateFn(sparql), title, fileUri, extra);
return { reportUri, fileUri, rowCount: rows.length - 1, filePath };
}
// Exported for run_report: the file name must be known before the run starts,
// so the chat can show a pending card named after the report.
export function slug(title) {
return (title || 'report').toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'report';
}

121
src/runner/seed.js Normal file
View File

@@ -0,0 +1,121 @@
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime, sparqlEscapeDate } from '../db.js';
// Subject selection. One paged query per page; stops at ROW_LIMIT.
// No GRAPH, no property paths, every hop its own variable.
const ROW_LIMIT = Number(process.env.ROW_LIMIT || 200000);
const PAGE_SIZE = Number(process.env.SEED_PAGE_SIZE || 5000);
export async function seed(sessionQuery, spec) {
const subjects = [];
let offset = 0;
for (;;) {
const query = seedPageQuery(spec, PAGE_SIZE, offset);
const result = await sessionQuery(query);
const rows = result.results.bindings.map(b => b.s.value);
subjects.push(...rows);
if (subjects.length > ROW_LIMIT) {
throw new Error(`the question matches more than ${ROW_LIMIT} subjects. Add a filter, for example a date range or a bestuurseenheid.`);
}
if (rows.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return subjects;
}
export function seedPageQuery(spec, limit, offset) {
const parts = [];
parts.push(`?s a ${sparqlEscapeUri(spec.targetClass)} .`);
spec.filters.forEach((filter, fi) => {
parts.push(hopsPattern(filter.path, `f${fi}`, '?s'));
parts.push(constraintsPattern(filter.constraints, `f${fi}`));
});
return `SELECT DISTINCT ?s WHERE {
${parts.join('\n ')}
}
LIMIT ${limit} OFFSET ${offset}`;
}
// Writes out every hop as its own triple pattern.
// Forward: ?s <p1> ?f0_1 . ?f0_1 <p2> ?f0_v
// Inverse: ?s ^<p1> → ?f0_1 <p1> ?s
export function hopsPattern(hops, varPrefix, startVar) {
let prev = startVar;
const patterns = [];
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
const isLast = i === hops.length - 1;
const v = isLast ? `?${varPrefix}_v` : `?${varPrefix}_${i + 1}`;
if (hop.inverse) {
patterns.push(`${v} ${sparqlEscapeUri(hop.predicate)} ${prev} .`);
} else {
patterns.push(`${prev} ${sparqlEscapeUri(hop.predicate)} ${v} .`);
}
prev = v;
}
return patterns.join('\n ');
}
export function constraintsPattern(c, varPrefix) {
const parts = [];
const v = `?${varPrefix}_v`;
if (c.hasValue) parts.push(`FILTER(${v} = ${escapeTerm(c.hasValue)})`);
if (c.in) parts.push(`FILTER(${v} IN (${c.in.map(escapeTerm).join(', ')}))`);
if (c.minInclusive) parts.push(`FILTER(${v} >= ${escapeTerm(c.minInclusive)})`);
if (c.maxInclusive) parts.push(`FILTER(${v} <= ${escapeTerm(c.maxInclusive)})`);
if (c.minExclusive) parts.push(`FILTER(${v} > ${escapeTerm(c.minExclusive)})`);
if (c.maxExclusive) parts.push(`FILTER(${v} < ${escapeTerm(c.maxExclusive)})`);
if (c.pattern) {
const flags = c.flags ? `, ${sparqlEscapeString(c.flags)}` : '';
parts.push(`FILTER(REGEX(str(${v}), ${sparqlEscapeString(c.pattern)}${flags}))`);
}
if (c.anyOf) {
const alternation = c.anyOf.map(t => escapeRegexTerm(t.value)).join('|');
const flags = c.flags ? `, ${sparqlEscapeString(c.flags)}` : ', "i"';
parts.push(`FILTER(REGEX(str(${v}), ${sparqlEscapeString(alternation)}${flags}))`);
}
if (c.minCount === 2) {
// two distinct leaf values on the same path
const w = `?${varPrefix}_w`;
parts.push(hopsPattern2(c, varPrefix, w));
parts.push(`FILTER(${v} != ${w})`);
}
if (c.maxCount === 0) {
parts.push(`FILTER NOT EXISTS { ${hopsPattern(c.path, varPrefix + '_nex', '?s')} }`);
}
return parts.join('\n ');
}
function hopsPattern2(filter, varPrefix, wVar) {
// repeat the path to a second leaf variable
const hops = filter.path.map(h => ({ ...h }));
let prev = '?s';
const patterns = [];
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
const isLast = i === hops.length - 1;
const v = isLast ? wVar : `?${varPrefix}_2_${i + 1}`;
if (hop.inverse) patterns.push(`${v} ${sparqlEscapeUri(hop.predicate)} ${prev} .`);
else patterns.push(`${prev} ${sparqlEscapeUri(hop.predicate)} ${v} .`);
prev = v;
}
return patterns.join('\n ');
}
function escapeTerm(t) {
if (t.type === 'uri') return sparqlEscapeUri(t.value);
if (t.datatype === 'http://www.w3.org/2001/XMLSchema#dateTime')
return sparqlEscapeDateTime(new Date(t.value));
if (t.datatype === 'http://www.w3.org/2001/XMLSchema#date')
return sparqlEscapeDate(new Date(t.value));
if (t.datatype === 'http://www.w3.org/2001/XMLSchema#integer')
return Number(t.value);
if (t.datatype === 'http://www.w3.org/2001/XMLSchema#boolean')
return t.value === 'true';
return sparqlEscapeString(t.value);
}
function escapeRegexTerm(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

153
src/runner/spec.js Normal file
View File

@@ -0,0 +1,153 @@
import N3 from 'n3';
// Parse a spec (rep:ReportSpec + sh:NodeShape) into a plain object.
// No validation here; that is check.js.
const SH = 'http://www.w3.org/ns/shacl#';
const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label';
export function parseSpec(turtle) {
const parser = new N3.Parser();
const quads = parser.parse(turtle);
const store = new N3.Store(quads);
const specNode = store.getQuads(null, RDF + 'type', REP + 'ReportSpec')[0]?.subject;
if (!specNode) throw new Error('spec has no rep:ReportSpec');
const one = (s, pred) => store.getQuads(s, pred, null)[0]?.object || null;
const targetClass = one(specNode, SH + 'targetClass')?.value || null;
const profileUri = one(specNode, REP + 'profile')?.value || null;
const title = one(specNode, 'http://purl.org/dc/terms/title')?.value || null;
const filters = store.getQuads(specNode, SH + 'property', null).map(q =>
parseFilter(store, q.object, { withLabel: true }));
const columnsQuad = one(specNode, REP + 'columns');
const columns = columnsQuad ? parseColumnList(store, columnsQuad) : [];
return { uri: specNode.value, title, profileUri, targetClass, filters, columns };
}
function parseFilter(store, p, { withLabel = false } = {}) {
let path = [];
const pathQuad = store.getQuads(p, SH + 'path', null)[0]?.object;
if (pathQuad) path = parsePath(store, pathQuad);
return {
path,
constraints: readConstraints(store, p, { withLabel }),
};
}
function parseColumnList(store, listNode) {
const columns = [];
let cur = listNode;
while (cur && cur.value !== RDF + 'nil') {
const item = store.getQuads(cur, RDF + 'first', null)[0]?.object;
if (item) columns.push(parseColumn(store, item));
cur = store.getQuads(cur, RDF + 'rest', null)[0]?.object;
if (!cur) throw new Error('rep:columns list is broken (no rdf:rest terminator)');
}
return columns;
}
function parseColumn(store, col) {
let path = [];
const pathQuad = store.getQuads(col, SH + 'path', null)[0]?.object;
if (pathQuad) {
if (pathQuad.termType === 'NamedNode' && pathQuad.value === REP + 'self') {
path = []; // rep:self: the row's own subject
} else {
path = parsePath(store, pathQuad);
}
}
return {
path,
label: store.getQuads(col, RDFS_LABEL, null)[0]?.object.value || null,
collect: store.getQuads(col, REP + 'collect', null)[0]?.object.value || null,
separator: store.getQuads(col, SH + 'separator', null)[0]?.object.value || null,
constraints: readConstraints(store, col), // kept so check.js can refuse it
};
}
// A path is a single node (predicate, or a blank with sh:inversePath)
// or an RDF list of predicates. Returns Hop[] = { predicate, inverse }.
function parsePath(store, pathQuad) {
if (pathQuad.termType === 'BlankNode') {
const inv = store.getQuads(pathQuad, SH + 'inversePath', null)[0]?.object;
if (inv) return [{ predicate: inv.value, inverse: true }];
const first = store.getQuads(pathQuad, RDF + 'first', null)[0]?.object;
if (!first) throw new Error('sh:path blank node without sh:inversePath is not supported');
}
if (pathQuad.termType === 'NamedNode') {
return [{ predicate: pathQuad.value, inverse: false }];
}
// RDF list of predicates
const hops = [];
let cur = pathQuad;
while (cur && cur.value !== RDF + 'nil') {
const item = store.getQuads(cur, RDF + 'first', null)[0]?.object;
if (!item) throw new Error('sh:path list is broken');
if (item.termType !== 'NamedNode') throw new Error('sh:path list items must be URIs');
hops.push({ predicate: item.value, inverse: false });
cur = store.getQuads(cur, RDF + 'rest', null)[0]?.object;
}
return hops;
}
function readConstraints(store, p, { withLabel = false } = {}) {
const one = (pred) => store.getQuads(p, pred, null)[0]?.object || null;
const constraints = {};
const minCount = one(SH + 'minCount');
if (minCount) constraints.minCount = Number(minCount.value);
const maxCount = one(SH + 'maxCount');
if (maxCount) constraints.maxCount = Number(maxCount.value);
const hasValue = one(SH + 'hasValue');
if (hasValue) constraints.hasValue = term(hasValue);
const inList = one(SH + 'in');
if (inList) constraints.in = readRdfList(store, inList).map(term);
for (const [key, pred] of [
['minInclusive', SH + 'minInclusive'], ['maxInclusive', SH + 'maxInclusive'],
['minExclusive', SH + 'minExclusive'], ['maxExclusive', SH + 'maxExclusive'],
]) {
const q = one(pred);
if (q) constraints[key] = term(q);
}
const pattern = one(SH + 'pattern');
if (pattern) constraints.pattern = pattern.value;
const flags = one(SH + 'flags');
if (flags) constraints.flags = flags.value;
const anyOf = one(REP + 'anyOf');
if (anyOf) constraints.anyOf = readRdfList(store, anyOf).map(term)
.map(t => ({ type: t.type, value: t.value }));
// read but never used by the runner; check.js refuses it (a filter has no label)
if (withLabel) {
const label = one(RDFS_LABEL);
if (label) constraints.label = label.value;
}
return constraints;
}
function readRdfList(store, node) {
const items = [];
let cur = node;
while (cur && cur.value !== RDF + 'nil') {
const item = store.getQuads(cur, RDF + 'first', null)[0]?.object;
if (!item) throw new Error('RDF list is broken');
items.push(item);
cur = store.getQuads(cur, RDF + 'rest', null)[0]?.object;
}
return items;
}
function term(object) {
if (object.termType === 'NamedNode') return { type: 'uri', value: object.value };
return {
type: 'literal',
value: object.value,
datatype: object.datatype ? object.datatype.value : null,
language: object.language || null,
};
}