import { startShape, fieldsOf } from './profile.js'; import { NUMERIC_DATATYPES } from './assemble.js'; import { sharedSteps } from './spec.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 runs the checks in order and returns the first // error, or null when the spec is fine. 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. const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8); const ANYOF_MAX_TERMS = 24; const ANYOF_MAX_TERM_LENGTH = 100; // null when the profile exists, else what to write instead. export function profileError(profiles, uri) { if (profiles.has(uri)) return null; return `no profile <${uri}>. Available: ${[...profiles.values()].map(p => p.title).join(', ')}.`; } // The spec names an entity we have: by rep:entity, or by a targetClass only // one entity carries. A class two entities share is refused with both names, // so the model can add rep:entity instead of guessing. function startEntity(spec, profile) { if (!spec.targetClass) { return `no sh:targetClass. In "${profile.title}" you can list: ${shapeList(profile)}.`; } if (spec.entity) { const s = profile.shapes.find(x => x.uri === spec.entity); if (!s) { return `no entity <${spec.entity}> in "${profile.title}". It has: ${shapeList(profile)}.`; } if (s.targetClass !== spec.targetClass) { return `rep:entity <${spec.entity}> is a <${s.targetClass}>, but sh:targetClass says <${spec.targetClass}>. Make them agree.`; } return null; } const matches = profile.shapes.filter(s => s.targetClass === spec.targetClass); if (matches.length === 1) return null; if (!matches.length) { return `"${profile.title}" cannot list <${spec.targetClass}>. It has: ${shapeList(profile)}.`; } const names = matches.map(m => `${m.label || m.uri} <${m.uri}>`).join(', '); return `<${spec.targetClass}> is the class of ${matches.length} entities: ${names}. Add rep:entity <...> to say which one.`; } function shapeList(profile) { return profile.shapes.map(s => s.label || s.targetClass).join(', '); } // Every condition in the spec: the filters, and the rep:where conditions on // filters and columns, with the words that name each one in a message. A // rep:where also carries its host, the filter or column it hangs on. function conditions(spec) { const out = spec.filters.map((f, i) => ({ what: `filter ${i + 1}`, cond: f })); spec.filters.forEach((f, i) => f.where.forEach((w, j) => out.push({ what: `rep:where ${j + 1} on filter ${i + 1}`, cond: w, host: { what: `filter ${i + 1}`, path: f.path }, }))); spec.columns.forEach(c => c.where.forEach((w, j) => out.push({ what: `rep:where ${j + 1} on column "${c.label}"`, cond: w, host: { what: `column "${c.label}"`, path: c.path }, }))); return out; } // Columns exist function columnsPresent(spec) { if (!spec.columns.length) return 'no columns. Add at least one rep:columns entry.'; return null; } // 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; } // Labels are unique function columnLabelsUnique(spec) { const seen = new Set(); for (const col of spec.columns) { if (seen.has(col.label)) { return `two columns are called "${col.label}". Labels must differ.`; } seen.add(col.label); } return null; } // Every path resolves, columns end on a value, depth capped function pathsResolve(spec, profile) { const startShapeUri = startShape(profile, spec).uri; for (const col of spec.columns) { const err = walk(profile, startShapeUri, col.path, `column "${col.label}"`, true, col.nodeKind === SH + 'IRI'); if (err) return err; } for (const [i, filter] of spec.filters.entries()) { const err = walk(profile, startShapeUri, filter.path, `filter ${i + 1}`, false); if (err) return err; } return null; } // sh:min/sh:max only on numbers and dates function collectMinMaxTyped(spec, profile) { const startShapeUri = startShape(profile, spec).uri; 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]; // no end field: pathsResolve already reported the real error const f = endFields(profile, startShapeUri, col.path) .find(x => x.datatype !== XSD_DATETIME && x.datatype !== XSD_DATE && !NUMERIC_DATATYPES.has(x.datatype)); if (f) { return `"${f.name || last.predicate}" is text, so ${name} does not apply. Use rep:row (same as leaving rep:collect out: every value gets its own row).`; } } return null; } // 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; } // 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, or in rep:where for a condition on a step of this column.`; } if (col.nodeKind && col.nodeKind !== SH + 'IRI') { return `column "${col.label}" has sh:nodeKind <${col.nodeKind}>. A column takes only sh:nodeKind sh:IRI, to show the URI of the node its path ends on.`; } 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 (joins the values), rep:row or nothing (each value gets its own row), sh:min or sh:max.`; } } return null; } // No label inside a filter or rep:where function filterLabel(spec) { for (const { what, cond } of conditions(spec)) { if ((cond.constraints || {}).label) { return `${what} carries rdfs:label. Move it to rep:columns to show it.`; } } return null; } // 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 { what, cond } of conditions(spec)) { const c = cond.constraints || {}; if (!MEANINGFUL.some(k => c[k] !== undefined && c[k] !== null)) { const p = cond.path.map(h => h.predicate).join(' → '); return `${what} (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; } // sh:maxCount above 0 needs counting function maxCountZero(spec) { for (const { cond } of conditions(spec)) { const c = cond.constraints; if (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 > 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; } // rep:anyOf size limits function anyOfLimits(spec) { for (const { cond } of conditions(spec)) { const c = cond.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}. Narrow the words, or split the filter into two sh:property blocks.`; } 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.'; } 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; } // sh:in must not mix URIs and literals function inHomogeneous(spec) { for (const { cond } of conditions(spec)) { const c = cond.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; } // Typed literals must parse function literalsParse(spec) { const constraintTerms = []; for (const { cond } of conditions(spec)) { const c = cond.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; } // rep:where: walks from the row through the profile, shares at least one // step with its filter or column, and holds no rep:where of its own. function whereConditions(spec, profile) { const startShapeUri = startShape(profile, spec).uri; for (const { what, cond, host } of conditions(spec)) { if (!host) continue; if (cond.where.length) { return `${what} holds a rep:where of its own. Put every rep:where directly on the filter or column.`; } const err = walk(profile, startShapeUri, cond.path, what, false); if (err) return err; const k = sharedSteps(host.path, cond.path); if (!k) { return `${what} shares no step with ${host.what}. Start its path with the same steps as ${host.what}; a condition on the row itself goes in sh:property.`; } const c = cond.constraints; if (k === cond.path.length && (c.maxCount === 0 || c.minCount === 2)) { return `${what} ends on a step of ${host.what}, so ${c.maxCount === 0 ? 'sh:maxCount 0' : 'sh:minCount 2'} has nothing to count. Add the step that must be missing or repeated.`; } } for (const [i, filter] of spec.filters.entries()) { if (filter.where.length && filter.constraints.minCount === 2) { return `filter ${i + 1} combines sh:minCount 2 with rep:where. Use one of the two.`; } } return null; } // A date or number condition compares values. On a link (an entity or a // code) it compares a URI and never matches, so its path must end on a field // with a datatype. function rangeOnValue(spec, profile) { const startShapeUri = startShape(profile, spec).uri; for (const { what, cond } of conditions(spec)) { const key = ['minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive'] .find(k => cond.constraints[k]); if (!key || !cond.path.length) continue; // pathsResolve reports a missing step const f = endFields(profile, startShapeUri, cond.path).find(x => !x.datatype); if (!f) continue; const target = f.nodes.length ? f.nodes.map(uri => shapeName(profile, uri)).join(' or ') : `the code "${f.name}"`; const steps = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri)) .filter(x => x.datatype === XSD_DATE || x.datatype === XSD_DATETIME || NUMERIC_DATATYPES.has(x.datatype)) .map(x => `${x.name} (${x.path})`))]; const add = steps.length ? `Add a step: ${steps.join(' or ')}.` : 'End the path on a date or number field.'; return `${what} ends on ${target}, not on a value, so sh:${key} never matches. ${add}`; } return null; } // Where each rep:where applies, in the profile's words, so the proposal can // say it. Profile only, no database. export function whereNotes(spec, profile) { const startShapeUri = startShape(profile, spec).uri; return conditions(spec).filter(x => x.host).map(({ what, cond, host }) => { const k = sharedSteps(host.path, cond.path); return `${what} applies at ${nodeName(profile, startShapeUri, host.path.slice(0, k))}, after step ${k} of ${host.what}.`; }); } // The entity a path lands on, or the value field when it ends on one. A // step that leads to several entities names them all. function nodeName(profile, startShapeUri, hops) { if (!hops.length) return shapeName(profile, startShapeUri); const ends = endFields(profile, startShapeUri, hops); if (!ends.length) return `step ${hops.length}`; const value = ends.find(f => !f.nodes.length); if (value) return `the value "${value.name}"`; return [...new Set(ends.flatMap(f => f.nodes))].map(uri => shapeName(profile, uri)).join(' or '); } function shapeName(profile, shapeUri) { return `"${profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri}"`; } 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); } // In this order: startEntity comes first, the checks after it rely on a // start shape. const CHECKS = [ startEntity, columnsPresent, columnLabel, columnLabelsUnique, columnPure, filterLabel, filterConstraint, maxCountZero, anyOfLimits, inHomogeneous, literalsParse, oneRowColumn, pathsResolve, whereConditions, rangeOnValue, collectMinMaxTyped, ]; // checkSpec(spec, profiles) → the first error, or null. export function checkSpec(spec, profiles) { const error = profileError(profiles, spec.profileUri); if (error) return error; const profile = profiles.get(spec.profileUri); for (const check of CHECKS) { const err = check(spec, profile); if (err) return err; } return null; } // The fields a hop takes from any of the shapes a path has reached. A field // may link to several entities (sh:or of sh:node: an eenheid's political and // leidinggevende organen): the walk follows them all and the next steps // decide. The query follows predicates only, so this is what the data does. function hopFields(profile, shapeUris, hop) { return shapeUris.flatMap(uri => fieldsOf(profile, uri) .filter(f => f.path === hop.predicate && !!f.inverse === !!hop.inverse)); } // The fields a path can end on: follow sh:node through every hop but the // last. walk() has already validated the path, so none here means the walk // reported the real error and the caller can bail. function endFields(profile, startShapeUri, hops) { let shapeUris = [startShapeUri]; for (const hop of hops.slice(0, -1)) { shapeUris = [...new Set(hopFields(profile, shapeUris, hop).flatMap(f => f.nodes))]; } return hopFields(profile, shapeUris, hops[hops.length - 1]); } function walk(profile, startShapeUri, hops, what, isColumn, wantsIri = false) { if (hops.length > MAX_PATH_DEPTH) { return `${what} walks ${hops.length} hops. The limit is ${MAX_PATH_DEPTH}.`; } if (!hops.length) return null; let shapeUris = [startShapeUri]; for (let i = 0; i < hops.length; i++) { const hop = hops[i]; const found = hopFields(profile, shapeUris, hop); if (!found.length) { const have = [...new Set(shapeUris.flatMap(uri => fieldsOf(profile, uri)) .map(x => (x.inverse ? `${x.name} (${x.path}, inverse)` : `${x.name} (${x.path})`)))].join(', '); const shapeLabel = shapeUris.map(uri => shapeName(profile, uri)).join(' or '); return `at ${shapeLabel} there is no "${hop.predicate}". It has: ${have || '(nothing)'}.`; } if (i < hops.length - 1) { const links = found.filter(f => f.nodes.length); if (!links.length) { return `${what} hop ${i + 1} <${hop.predicate}> holds a value, not a link. You cannot walk past it.`; } shapeUris = [...new Set(links.flatMap(f => f.nodes))]; } else if (isColumn) { // every field the path can end on must suit the column for (const f of found) { if (wantsIri && f.datatype) { return `${what} has sh:nodeKind sh:IRI but ends on the value "${f.name}", which is no URI. Drop sh:nodeKind.`; } if (!wantsIri && !f.datatype && !f.class) { const valueFields = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri)) .filter(x => x.datatype || x.class) .map(x => `${x.name} (${x.path})`))]; return `${what} ends on a link. Add a hop: ${valueFields.join(' or ')}. Only if the user asked for the URI of that node itself, add sh:nodeKind sh:IRI to the column instead.`; } } } } return null; }