- one node per decision (tag coloured by status) with decides links to its elements and supersedes links - one node per registry file with defines links to its elements - own legend chips, side panels (Open decision, Open registry), type:decision and type:registry - walks and paths follow the diagrams' connections only; attached nodes follow their elements into a result
252 lines
9.7 KiB
TypeScript
252 lines
9.7 KiB
TypeScript
import { GraphEdge, GraphNode, ModelGraph } from './graph';
|
|
|
|
/**
|
|
* The graph page's query box. A few words, no language to learn: terms are
|
|
* ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk
|
|
* the graph. Pure, shared by the webview bundle and the unit tests.
|
|
*
|
|
* database label, id, sublabel, brand or type contain it
|
|
* type:backend elements of that type
|
|
* diagram:0200-* drawn by a diagram whose path matches (glob)
|
|
* decision:<id> what the decision is about or attached to
|
|
* uses:api api and its neighbours
|
|
* near:api,2 api and everything within two steps
|
|
* from:web to:db the shortest path between two elements
|
|
* unused registry elements no diagram draws
|
|
* shared / -shared registry elements / inline elements
|
|
*/
|
|
|
|
export interface QueryTerm {
|
|
key: string;
|
|
value: string;
|
|
negate: boolean;
|
|
}
|
|
|
|
export interface QueryResult {
|
|
/** Ids of the elements that match. Every element when the query is empty. */
|
|
nodes: Set<string>;
|
|
/** Ids of the connections shown: both ends match, or the path found. */
|
|
edges: Set<string>;
|
|
/** The path found by `from: to:`, in order. */
|
|
path?: string[];
|
|
/** Why the result is empty or partial, when it is. */
|
|
message?: string;
|
|
terms: QueryTerm[];
|
|
}
|
|
|
|
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
|
|
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
|
|
|
|
export function parseQuery(text: string): QueryTerm[] {
|
|
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
const terms: QueryTerm[] = [];
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
let token = tokens[i];
|
|
const negate = token.startsWith('-') && token.length > 1;
|
|
if (negate) token = token.slice(1);
|
|
const colon = token.indexOf(':');
|
|
let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase();
|
|
let value = colon > 0 ? token.slice(colon + 1) : '';
|
|
if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) {
|
|
key = 'text';
|
|
value = token;
|
|
} else if (colon < 0 && key !== 'shared' && key !== 'unused') {
|
|
key = 'text';
|
|
value = token;
|
|
}
|
|
// `near:api 2` is also accepted: a bare number after near is the hop count.
|
|
if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) {
|
|
value = `${value},${tokens[i + 1]}`;
|
|
i += 1;
|
|
}
|
|
if (!value && key === 'text') continue;
|
|
terms.push({ key, value, negate });
|
|
}
|
|
return terms;
|
|
}
|
|
|
|
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
|
|
|
|
const isElement = (n: GraphNode) => n.kind === 'element';
|
|
const isConnection = (e: GraphEdge) => e.kind === 'connection';
|
|
|
|
/** An element by id, then by id or label ignoring case. Decision and registry nodes are not walked from. */
|
|
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
|
|
const elements = graph.nodes.filter(isElement);
|
|
const exact = elements.find((n) => n.id === ref);
|
|
if (exact) return exact;
|
|
const l = ref.toLowerCase();
|
|
return elements.find((n) => n.id.toLowerCase() === l) ?? elements.find((n) => n.label.toLowerCase() === l);
|
|
}
|
|
|
|
function globToRegExp(pattern: string): RegExp {
|
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
return new RegExp(`^${escaped}$`, 'i');
|
|
}
|
|
|
|
function diagramMatches(path: string, pattern: string): boolean {
|
|
const bare = path.replace(/\.archify$/, '');
|
|
const wanted = pattern.replace(/\.archify$/, '');
|
|
if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path);
|
|
const l = wanted.toLowerCase();
|
|
return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`);
|
|
}
|
|
|
|
/** Element-to-element adjacency over the diagrams' connections; decision and registry links do not count as steps. */
|
|
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
|
|
const map = new Map<string, Set<string>>();
|
|
for (const n of graph.nodes) map.set(n.id, new Set());
|
|
for (const e of graph.edges.filter(isConnection)) {
|
|
map.get(e.from)?.add(e.to);
|
|
map.get(e.to)?.add(e.from);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function within(graph: ModelGraph, start: string, hops: number): Set<string> {
|
|
const adjacency = neighbours(graph);
|
|
let frontier = new Set([start]);
|
|
const seen = new Set([start]);
|
|
for (let i = 0; i < hops; i += 1) {
|
|
const next = new Set<string>();
|
|
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
|
|
frontier = next;
|
|
if (!frontier.size) break;
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
/** Shortest path by breadth-first search; directed first, then ignoring direction. */
|
|
export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined {
|
|
const attempt = (directed: boolean) => {
|
|
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
|
for (const n of graph.nodes) out.set(n.id, []);
|
|
for (const e of graph.edges.filter(isConnection)) {
|
|
out.get(e.from)?.push({ to: e.to, edge: e });
|
|
if (!directed) out.get(e.to)?.push({ to: e.from, edge: e });
|
|
}
|
|
const previous = new Map<string, { from: string; edge: GraphEdge }>();
|
|
const queue = [from];
|
|
const seen = new Set([from]);
|
|
while (queue.length) {
|
|
const id = queue.shift()!;
|
|
if (id === to) break;
|
|
for (const step of out.get(id) ?? []) {
|
|
if (seen.has(step.to)) continue;
|
|
seen.add(step.to);
|
|
previous.set(step.to, { from: id, edge: step.edge });
|
|
queue.push(step.to);
|
|
}
|
|
}
|
|
if (!seen.has(to)) return undefined;
|
|
const nodes = [to];
|
|
const edges: string[] = [];
|
|
let at = to;
|
|
while (at !== from) {
|
|
const step = previous.get(at)!;
|
|
edges.unshift(step.edge.id);
|
|
nodes.unshift(step.from);
|
|
at = step.from;
|
|
}
|
|
return { nodes, edges };
|
|
};
|
|
if (from === to) return { nodes: [from], edges: [] };
|
|
return attempt(true) ?? attempt(false);
|
|
}
|
|
|
|
export function runQuery(graph: ModelGraph, text: string): QueryResult {
|
|
const terms = parseQuery(text);
|
|
const everything = new Set(graph.nodes.map((n) => n.id));
|
|
let result = new Set(everything);
|
|
const notes: string[] = [];
|
|
let from: string | undefined;
|
|
let to: string | undefined;
|
|
let pathEdges: Set<string> | undefined;
|
|
let path: string[] | undefined;
|
|
|
|
const matching = (term: QueryTerm): Set<string> | undefined => {
|
|
const v = term.value;
|
|
switch (term.key) {
|
|
case 'text': {
|
|
const l = v.toLowerCase();
|
|
return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id));
|
|
}
|
|
case 'type':
|
|
return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id));
|
|
case 'diagram':
|
|
return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id));
|
|
case 'decision': {
|
|
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
|
|
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
|
|
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
|
|
else ids.add(`decision:${v}`);
|
|
return ids;
|
|
}
|
|
case 'shared':
|
|
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
|
|
case 'unused':
|
|
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared && n.diagrams.length === 0).map((n) => n.id));
|
|
case 'uses':
|
|
case 'near': {
|
|
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
|
|
const node = findNode(graph, ref);
|
|
if (!node) { notes.push(`No element "${ref}".`); return new Set(); }
|
|
const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50));
|
|
return within(graph, node.id, hops);
|
|
}
|
|
case 'from':
|
|
from = v;
|
|
return undefined;
|
|
case 'to':
|
|
to = v;
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const negated: Set<string>[] = [];
|
|
for (const term of terms) {
|
|
const ids = matching(term);
|
|
if (!ids) continue;
|
|
if (term.negate) {
|
|
negated.push(ids);
|
|
for (const id of ids) result.delete(id);
|
|
} else result = new Set([...result].filter((id) => ids.has(id)));
|
|
}
|
|
// Decisions and registries follow their elements: a matching element brings the nodes attached to it.
|
|
if (terms.length) {
|
|
for (const e of graph.edges) {
|
|
if (isConnection(e)) continue;
|
|
if (result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from);
|
|
}
|
|
for (const ids of negated) for (const id of ids) result.delete(id);
|
|
}
|
|
|
|
if (from || to) {
|
|
if (!from || !to) notes.push('A path needs both from: and to:.');
|
|
else {
|
|
const a = findNode(graph, from);
|
|
const b = findNode(graph, to);
|
|
if (!a || !b) notes.push(`No element "${!a ? from : to}".`);
|
|
else {
|
|
const found = shortestPath(graph, a.id, b.id);
|
|
if (!found) notes.push(`No path from ${a.id} to ${b.id}.`);
|
|
else {
|
|
path = found.nodes;
|
|
pathEdges = new Set(found.edges);
|
|
result = new Set(found.nodes.filter((id) => result.has(id)));
|
|
}
|
|
}
|
|
if (!path) result = new Set();
|
|
}
|
|
}
|
|
|
|
const edges = new Set<string>();
|
|
for (const e of graph.edges) {
|
|
if (!result.has(e.from) || !result.has(e.to)) continue;
|
|
if (pathEdges && !pathEdges.has(e.id)) continue;
|
|
edges.add(e.id);
|
|
}
|
|
return { nodes: result, edges, path, message: notes[0], terms };
|
|
}
|