WIP
This commit is contained in:
83
src/runner/assemble.js
Normal file
83
src/runner/assemble.js
Normal 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
311
src/runner/check.js
Normal 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
67
src/runner/columns.js
Normal 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
27
src/runner/csv.js
Normal 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
158
src/runner/job.js
Normal 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
106
src/runner/profile.js
Normal 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
62
src/runner/report.js
Normal 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
41
src/runner/run.js
Normal 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
121
src/runner/seed.js
Normal 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
153
src/runner/spec.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user