htmlineage 0.0.0 → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +64 -2
- package/index.js +4 -0
- package/package.json +52 -3
- package/src/check/rules.js +379 -0
- package/src/check/summary.js +154 -0
- package/src/cli.js +99 -0
- package/src/commands/check.js +71 -0
- package/src/commands/index.js +7 -0
- package/src/commands/sql.js +65 -0
- package/src/commands/view.js +80 -0
- package/src/errors.js +39 -0
- package/src/graph/duplicates.js +78 -0
- package/src/graph/expr.js +54 -0
- package/src/graph/ids.js +24 -0
- package/src/graph/numbers.js +61 -0
- package/src/graph/parse.js +196 -0
- package/src/graph/schema.js +465 -0
- package/src/graph/trace.js +45 -0
- package/src/help.js +25 -0
- package/src/html/embed.js +142 -0
- package/src/html/page.js +101 -0
- package/src/meta.js +13 -0
- package/src/sql/compose.js +215 -0
- package/src/sql/style.js +193 -0
- package/src/viewer/client/bridge.js +141 -0
- package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
- package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
- package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
- package/src/viewer/client/index.html +95 -0
- package/src/viewer/client/lineage.js +650 -0
- package/src/viewer/client/themes.css +76 -0
- package/src/viewer/client/viewer.css +1418 -0
- package/src/viewer/client/viewer.js +761 -0
- package/src/viewer/payload.js +45 -0
- package/src/viewer/server.js +188 -0
package/src/html/page.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the parts of a report that `check` looks at.
|
|
3
|
+
*
|
|
4
|
+
* A report is read into two flat lists, in document order:
|
|
5
|
+
*
|
|
6
|
+
* bindings every element carrying `data-lineage`, however deep or
|
|
7
|
+
* hidden, for C1 (unknown/invalid ids) and C3 (missing bindings).
|
|
8
|
+
* prose every visible text run, tagged with the id of its nearest
|
|
9
|
+
* bound ancestor (or `null` when unbound), for C2 (bare
|
|
10
|
+
* numbers).
|
|
11
|
+
*
|
|
12
|
+
* The HTML is only ever read here, never written back: `check` edits a
|
|
13
|
+
* report with string surgery over the raw bytes, using the offsets this
|
|
14
|
+
* module returns, so parsing it into a tree and reserializing would risk
|
|
15
|
+
* changing bytes the report's author never touched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parse } from "node-html-parser";
|
|
19
|
+
|
|
20
|
+
const HIDDEN_TAGS = new Set([
|
|
21
|
+
"head",
|
|
22
|
+
"script",
|
|
23
|
+
"style",
|
|
24
|
+
"noscript",
|
|
25
|
+
"template",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const clean = (s) => s.replace(/\s+/g, " ").trim();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a report's bindings and prose.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} html the report, as read from disk
|
|
34
|
+
* @param {{ skip?: (id: string) => boolean }} [options]
|
|
35
|
+
* skip(id) true: leave that bound element's text, and everything inside
|
|
36
|
+
* it, out of `prose`. Default: skip nothing.
|
|
37
|
+
* @returns {{
|
|
38
|
+
* bindings: { id: string, text: string, offset: number }[],
|
|
39
|
+
* prose: { text: string, node: string | null, offset: number }[],
|
|
40
|
+
* }}
|
|
41
|
+
*/
|
|
42
|
+
export function readPage(html, { skip = () => false } = {}) {
|
|
43
|
+
const root = parse(html, {
|
|
44
|
+
comment: false,
|
|
45
|
+
blockTextElements: { script: true, style: true, noscript: true },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const bindings = [];
|
|
49
|
+
const prose = [];
|
|
50
|
+
|
|
51
|
+
const walk = (el, node, hidden) => {
|
|
52
|
+
for (const child of el.childNodes) {
|
|
53
|
+
if (child.nodeType === 1) {
|
|
54
|
+
let childNode = node;
|
|
55
|
+
let childHidden = hidden;
|
|
56
|
+
if (child.hasAttribute("data-lineage")) {
|
|
57
|
+
const id = child.getAttribute("data-lineage") ?? "";
|
|
58
|
+
bindings.push({
|
|
59
|
+
id,
|
|
60
|
+
text: clean(child.textContent),
|
|
61
|
+
offset: child.range[0],
|
|
62
|
+
});
|
|
63
|
+
childNode = id;
|
|
64
|
+
if (skip(id)) childHidden = true;
|
|
65
|
+
}
|
|
66
|
+
if (HIDDEN_TAGS.has(child.rawTagName.toLowerCase())) {
|
|
67
|
+
childHidden = true;
|
|
68
|
+
}
|
|
69
|
+
walk(child, childNode, childHidden);
|
|
70
|
+
} else if (child.nodeType === 3) {
|
|
71
|
+
if (hidden) continue;
|
|
72
|
+
if (child.rawText.startsWith("<!")) continue;
|
|
73
|
+
const text = clean(child.text);
|
|
74
|
+
if (text === "") continue;
|
|
75
|
+
prose.push({ text, node, offset: child.range[0] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
walk(root, null, false);
|
|
81
|
+
|
|
82
|
+
return { bindings, prose };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The skip predicate C2 uses: text counts only when it is unbound or its
|
|
87
|
+
* nearest binding is a grounded insight.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} graph as returned by readGraph; may be malformed
|
|
90
|
+
* @returns {(id: string) => boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function proseSkip(graph) {
|
|
93
|
+
const nodes = graph?.nodes;
|
|
94
|
+
if (nodes === null || typeof nodes !== "object" || Array.isArray(nodes)) {
|
|
95
|
+
return () => true;
|
|
96
|
+
}
|
|
97
|
+
return (id) => {
|
|
98
|
+
const n = Object.hasOwn(nodes, id) ? nodes[id] : null;
|
|
99
|
+
return !(n?.step === "insight" && n.ungrounded !== true);
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/meta.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read name and version straight from package.json rather than importing it,
|
|
6
|
+
* so this works identically on every supported Node version without relying
|
|
7
|
+
* on import attributes.
|
|
8
|
+
*/
|
|
9
|
+
export function packageMeta() {
|
|
10
|
+
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
11
|
+
const { name, version } = JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
return { name, version };
|
|
13
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a lineage graph into one SQL script. Pure: no file I/O.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { exprProblem, exprRefs } from "../graph/expr.js";
|
|
6
|
+
import { traceGraph } from "../graph/trace.js";
|
|
7
|
+
|
|
8
|
+
/** Text collapsed to one line, so it cannot end a `--` comment early. */
|
|
9
|
+
const oneLine = (text) => text.replace(/\s+/g, " ").trim();
|
|
10
|
+
|
|
11
|
+
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
12
|
+
|
|
13
|
+
/** A recorded value as a SQL literal: number as written, string quoted, else null. */
|
|
14
|
+
const literal = (value) => {
|
|
15
|
+
if (typeof value === "number") return String(value);
|
|
16
|
+
if (typeof value === "string") return quote(value);
|
|
17
|
+
return "null";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The node's SQL as written, minus outer whitespace and one final `;`, indented. */
|
|
21
|
+
const indent = (sql) =>
|
|
22
|
+
sql
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/\s*;$/, "")
|
|
25
|
+
.replace(/^(?=.)/gm, " ");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An expression compiler that reads figures from `nodes`. The text is copied
|
|
29
|
+
* as written; only each word that names a node changes (not quoted text, and
|
|
30
|
+
* not a function name before `(`).
|
|
31
|
+
*/
|
|
32
|
+
function compiler(nodes) {
|
|
33
|
+
const ids = new Set(Object.keys(nodes));
|
|
34
|
+
const compile = (expr) =>
|
|
35
|
+
expr.replace(
|
|
36
|
+
/('[^']*'|"[^"]*")|([A-Za-z_][A-Za-z0-9_]*\b(?!\s*\())/g,
|
|
37
|
+
(match, quoted, word) => {
|
|
38
|
+
if (quoted || !ids.has(word)) return match;
|
|
39
|
+
const { expr: inner } = nodes[word];
|
|
40
|
+
return inner ? `(${compile(inner)})` : `(select value from ${word})`;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
return compile;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Why a report that fails `check` gets no script. */
|
|
47
|
+
export const refusal = (report) =>
|
|
48
|
+
`${report} does not pass \`htmlineage check\`, so no script was composed`;
|
|
49
|
+
|
|
50
|
+
const NOTHING = ", so there is nothing to compose";
|
|
51
|
+
|
|
52
|
+
/** Whether `script`, from `composeSql`, says there was nothing to compose. */
|
|
53
|
+
export const isEmptyScript = (script) =>
|
|
54
|
+
script.split("\n")[1].endsWith(NOTHING);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} graph a graph with no error-severity issues
|
|
58
|
+
* @param {{ report: string, id?: string }} options `report` is the file name
|
|
59
|
+
* for the header; `id` limits the script to that node and what is below it
|
|
60
|
+
* @returns {string} the script, with no trailing newline
|
|
61
|
+
*/
|
|
62
|
+
export function composeSql(graph, options) {
|
|
63
|
+
const report = oneLine(options.report);
|
|
64
|
+
const header =
|
|
65
|
+
options.id === undefined ? report : `${report} (${options.id})`;
|
|
66
|
+
// The node and everything below it through `from`; the whole graph without an id.
|
|
67
|
+
const scope = new Set();
|
|
68
|
+
const collect = (id) => {
|
|
69
|
+
if (scope.has(id)) return;
|
|
70
|
+
scope.add(id);
|
|
71
|
+
(graph.nodes[id].from ?? []).forEach(collect);
|
|
72
|
+
};
|
|
73
|
+
if (options.id === undefined) Object.keys(graph.nodes).forEach(collect);
|
|
74
|
+
else collect(options.id);
|
|
75
|
+
const entries = Object.entries(graph.nodes).filter(([id]) => scope.has(id));
|
|
76
|
+
const figures = entries.filter(([, node]) => node.step === "figure");
|
|
77
|
+
// A sql source whose query was never recorded cannot run.
|
|
78
|
+
const noSql = (node) =>
|
|
79
|
+
node.step === "source" && node.kind === "sql" && node.sql === undefined;
|
|
80
|
+
const unreadable = (node) =>
|
|
81
|
+
node.expr !== undefined && exprProblem(node.expr) !== null;
|
|
82
|
+
const notCovered = entries.flatMap(([id, node]) => {
|
|
83
|
+
if (noSql(node)) return [`-- ${id} has no sql`];
|
|
84
|
+
if (unreadable(node)) return [`-- ${id} expr cannot be read`];
|
|
85
|
+
if (node.kind === "external") {
|
|
86
|
+
const ref = oneLine(node.ref ?? "");
|
|
87
|
+
return [ref ? `-- ${id} external: ${ref}` : `-- ${id} external`];
|
|
88
|
+
}
|
|
89
|
+
if (node.ungrounded) {
|
|
90
|
+
const text = oneLine(node.text ?? node.label ?? "");
|
|
91
|
+
return [text ? `-- ${id} ungrounded: ${text}` : `-- ${id} ungrounded`];
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
});
|
|
95
|
+
const tail =
|
|
96
|
+
notCovered.length > 0
|
|
97
|
+
? `\n\n-- not covered by this script:\n${notCovered.join("\n")}`
|
|
98
|
+
: "";
|
|
99
|
+
// Sources anywhere below a node that pass `test`, in graph order.
|
|
100
|
+
const sourcesBelow = (id, test) => {
|
|
101
|
+
const below = new Set();
|
|
102
|
+
const walk = (parent) => {
|
|
103
|
+
if (test(graph.nodes[parent])) below.add(parent);
|
|
104
|
+
(graph.nodes[parent].from ?? []).forEach(walk);
|
|
105
|
+
};
|
|
106
|
+
walk(id);
|
|
107
|
+
return entries.map(([other]) => other).filter((other) => below.has(other));
|
|
108
|
+
};
|
|
109
|
+
const externalsBelow = (id) =>
|
|
110
|
+
sourcesBelow(id, (node) => node.kind === "external");
|
|
111
|
+
const states = traceGraph(graph);
|
|
112
|
+
const runs = (id) =>
|
|
113
|
+
states.get(id) === "full" && sourcesBelow(id, noSql).length === 0;
|
|
114
|
+
const target = graph.nodes[options.id];
|
|
115
|
+
// The first view below an insight, looking through insights only.
|
|
116
|
+
const viewBelow = (id) => {
|
|
117
|
+
const node = graph.nodes[id];
|
|
118
|
+
if (node.step === "view") return id;
|
|
119
|
+
if (node.step !== "insight") return null;
|
|
120
|
+
for (const parent of node.from) {
|
|
121
|
+
const found = viewBelow(parent);
|
|
122
|
+
if (found !== null) return found;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
// A source or view with SQL to run ends in a select of itself; so does an
|
|
127
|
+
// insight that rests on no figures, through the first view below it.
|
|
128
|
+
const selected =
|
|
129
|
+
target?.step === "source" || target?.step === "view"
|
|
130
|
+
? options.id
|
|
131
|
+
: target?.step === "insight" && figures.length === 0
|
|
132
|
+
? viewBelow(options.id)
|
|
133
|
+
: null;
|
|
134
|
+
const selectsTarget = selected !== null && runs(selected);
|
|
135
|
+
if (figures.length === 0 && !selectsTarget) {
|
|
136
|
+
const { id } = options;
|
|
137
|
+
const externals = id === undefined ? [] : externalsBelow(id);
|
|
138
|
+
const ungrounded = entries
|
|
139
|
+
.filter(([, node]) => node.ungrounded)
|
|
140
|
+
.map(([other]) => other);
|
|
141
|
+
const missing = id === undefined ? [] : sourcesBelow(id, noSql);
|
|
142
|
+
const ref = oneLine(target?.ref ?? "");
|
|
143
|
+
let why = `${id} rests on no figures`;
|
|
144
|
+
if (id === undefined) why = "no figures";
|
|
145
|
+
else if (target.kind === "external") {
|
|
146
|
+
why = ref ? `${id} is external (${ref})` : `${id} is external`;
|
|
147
|
+
} else if (target.ungrounded) why = `${id} is ungrounded`;
|
|
148
|
+
else if (noSql(target)) why = `${id} has no sql`;
|
|
149
|
+
else if (externals.length > 0) {
|
|
150
|
+
why = `${id} rests on external ${externals.join(", ")}`;
|
|
151
|
+
} else if (missing.length > 0) {
|
|
152
|
+
why = `${id} rests on ${missing.join(", ")}, which ${missing.length > 1 ? "have" : "has"} no sql`;
|
|
153
|
+
} else if (ungrounded.length > 0) {
|
|
154
|
+
why = `${id} rests on ungrounded ${ungrounded.join(", ")}`;
|
|
155
|
+
}
|
|
156
|
+
return `-- ${header}\n-- ${why}${NOTHING}${tail}`;
|
|
157
|
+
}
|
|
158
|
+
const compile = compiler(graph.nodes);
|
|
159
|
+
const ofStep = (step) => entries.filter(([, node]) => node.step === step);
|
|
160
|
+
const views = [];
|
|
161
|
+
const visit = ([id, node]) => {
|
|
162
|
+
if (views.some(([seen]) => seen === id)) return;
|
|
163
|
+
for (const parent of node.from) {
|
|
164
|
+
if (graph.nodes[parent].step === "view") {
|
|
165
|
+
visit([parent, graph.nodes[parent]]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
views.push([id, node]);
|
|
169
|
+
};
|
|
170
|
+
ofStep("view")
|
|
171
|
+
.filter(([id]) => runs(id))
|
|
172
|
+
.forEach(visit);
|
|
173
|
+
const sqlSources = ofStep("source").filter(([id]) => runs(id));
|
|
174
|
+
const ctes = [...sqlSources, ...views].map(
|
|
175
|
+
([id, node]) => `${id} as (\n${indent(node.sql)}\n)`,
|
|
176
|
+
);
|
|
177
|
+
// A sql figure with no CTE cannot be read: its value is null.
|
|
178
|
+
const unread = new Set();
|
|
179
|
+
for (const [id, node] of figures.filter(([, node]) => node.sql)) {
|
|
180
|
+
const externals = externalsBelow(id);
|
|
181
|
+
if (runs(id)) {
|
|
182
|
+
ctes.push(`${id} (value) as (\n${indent(node.sql)}\n)`);
|
|
183
|
+
} else if (externals.length > 0 && !node.ungrounded) {
|
|
184
|
+
ctes.push(
|
|
185
|
+
`${id} (value) as (\n -- from the report: rests on external ${externals.join(", ")}\n select ${literal(node.value)}\n)`,
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
unread.add(id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const isNull = (id) =>
|
|
192
|
+
graph.nodes[id].ungrounded === true ||
|
|
193
|
+
unread.has(id) ||
|
|
194
|
+
unreadable(graph.nodes[id]) ||
|
|
195
|
+
(graph.nodes[id].expr !== undefined &&
|
|
196
|
+
exprRefs(graph.nodes[id].expr, Object.keys(graph.nodes)).some(isNull));
|
|
197
|
+
const rows = figures.map(([id, node], i) => {
|
|
198
|
+
const reading = node.expr
|
|
199
|
+
? compile(node.expr)
|
|
200
|
+
: `(select value from ${id})`;
|
|
201
|
+
const [figure, label, value] = [
|
|
202
|
+
`'${id}'`,
|
|
203
|
+
node.label === undefined ? "null" : quote(node.label),
|
|
204
|
+
isNull(id) ? "null" : `cast(${reading} as varchar)`,
|
|
205
|
+
];
|
|
206
|
+
return i === 0
|
|
207
|
+
? `select ${figure} as figure, ${label} as label, ${value} as value`
|
|
208
|
+
: `select ${figure}, ${label}, ${value}`;
|
|
209
|
+
});
|
|
210
|
+
const final = selectsTarget
|
|
211
|
+
? `select * from ${selected}`
|
|
212
|
+
: rows.join("\nunion all\n");
|
|
213
|
+
const withClause = ctes.length > 0 ? `with\n${ctes.join(",\n")}\n` : "";
|
|
214
|
+
return `-- ${header}\n${withClause}${final}${tail}`;
|
|
215
|
+
}
|
package/src/sql/style.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical SQL checks: no `select *`, no date functions, and parents
|
|
3
|
+
* named by node id so the composed script runs. They scan a query's words and
|
|
4
|
+
* parentheses, never its meaning. `check` runs them over sources, views and
|
|
5
|
+
* figure queries (rule C8) and over a source's own SQL for date functions
|
|
6
|
+
* (rule C7). A SQL parser is deliberately out of scope; that is rule C10,
|
|
7
|
+
* deferred.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CLAUSE_PATTERN =
|
|
11
|
+
/\b(select|from|where|group\s+by|having|order\s+by|limit|qualify|union|join)\b/gi;
|
|
12
|
+
|
|
13
|
+
const DATE_FUNCTION_PATTERN =
|
|
14
|
+
/\b(current_date|current_timestamp|current_time|localtimestamp|localtime|sysdate|systimestamp|getdate|now)\b/gi;
|
|
15
|
+
|
|
16
|
+
/** A select item that is a star, bare or qualified, with Snowflake's modifiers. */
|
|
17
|
+
const STAR_PATTERN = /(^|\.)\*(\s+(exclude|replace|rename|ilike)\b[\s\S]*)?$/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Blank out comments, and the contents of quoted strings and identifiers, so
|
|
21
|
+
* rules can scan the code with regexes. Same length as `sql`; line breaks
|
|
22
|
+
* kept; quote characters kept.
|
|
23
|
+
* @param {string} sql
|
|
24
|
+
* @param {{ keepStrings?: boolean }} [options] keep single-quoted string
|
|
25
|
+
* contents (for finding date literals). Default false.
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function maskSql(sql, { keepStrings = false } = {}) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
const n = sql.length;
|
|
32
|
+
|
|
33
|
+
while (i < n) {
|
|
34
|
+
const c = sql[i];
|
|
35
|
+
|
|
36
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
37
|
+
while (i < n && sql[i] !== "\n") {
|
|
38
|
+
out.push(" ");
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
45
|
+
out.push(" ", " ");
|
|
46
|
+
i += 2;
|
|
47
|
+
while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) {
|
|
48
|
+
out.push(sql[i] === "\n" ? "\n" : " ");
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
if (i < n) {
|
|
52
|
+
out.push(" ", " ");
|
|
53
|
+
i += 2;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (c === "'" || c === '"') {
|
|
59
|
+
const quote = c;
|
|
60
|
+
const keep = keepStrings && quote === "'";
|
|
61
|
+
out.push(quote);
|
|
62
|
+
i++;
|
|
63
|
+
while (i < n) {
|
|
64
|
+
if (sql[i] === quote) {
|
|
65
|
+
if (sql[i + 1] === quote) {
|
|
66
|
+
out.push(keep ? quote : " ", keep ? quote : " ");
|
|
67
|
+
i += 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
out.push(quote);
|
|
71
|
+
i++;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
out.push(keep ? sql[i] : sql[i] === "\n" ? "\n" : " ");
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
out.push(c);
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return out.join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Functions that read the clock instead of a literal date: current_date,
|
|
89
|
+
* current_timestamp, current_time, localtimestamp, localtime, sysdate,
|
|
90
|
+
* systimestamp, getdate, now. Date arithmetic over literals (`dateadd`) is
|
|
91
|
+
* pinned and allowed. Whole words, any case, outside strings and comments.
|
|
92
|
+
* @param {string} sql
|
|
93
|
+
* @returns {string[]} each function once, as first written, in order
|
|
94
|
+
*/
|
|
95
|
+
export function dateFunctions(sql) {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const match of maskSql(sql).matchAll(DATE_FUNCTION_PATTERN)) {
|
|
99
|
+
const name = match[0].toLowerCase();
|
|
100
|
+
if (seen.has(name)) continue;
|
|
101
|
+
seen.add(name);
|
|
102
|
+
found.push(match[0]);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Index of each char in `code`: paren depth before that index. */
|
|
108
|
+
function parenDepths(code) {
|
|
109
|
+
const depths = new Array(code.length);
|
|
110
|
+
let depth = 0;
|
|
111
|
+
for (let i = 0; i < code.length; i++) {
|
|
112
|
+
if (code[i] === ")") depth = Math.max(0, depth - 1);
|
|
113
|
+
depths[i] = depth;
|
|
114
|
+
if (code[i] === "(") depth++;
|
|
115
|
+
}
|
|
116
|
+
return depths;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Escape a string for use inside a regex, so an arbitrary id cannot break it. */
|
|
120
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The mechanical SQL rules for one query.
|
|
124
|
+
* @param {string} sql
|
|
125
|
+
* @param {{ parents?: string[] }} [options] node ids the query must name
|
|
126
|
+
* @returns {{ rule: string, message: string }[]} at most one finding per
|
|
127
|
+
* rule, in this order: select-star, date-function, parent-ref
|
|
128
|
+
*/
|
|
129
|
+
export function checkSqlStyle(sql, { parents = [] } = {}) {
|
|
130
|
+
const code = maskSql(sql);
|
|
131
|
+
const depths = parenDepths(code);
|
|
132
|
+
const findings = [];
|
|
133
|
+
|
|
134
|
+
const clauses = [...code.matchAll(CLAUSE_PATTERN)];
|
|
135
|
+
|
|
136
|
+
// select-star: split each select list, at any nesting depth, into items.
|
|
137
|
+
// A list runs from its "select" (after any "distinct") to the next clause
|
|
138
|
+
// at the same depth, the parenthesis that closes it, or the end.
|
|
139
|
+
let star = false;
|
|
140
|
+
for (const match of clauses) {
|
|
141
|
+
if (match[0].toLowerCase() !== "select") continue;
|
|
142
|
+
const depth = depths[match.index];
|
|
143
|
+
let start = match.index + match[0].length;
|
|
144
|
+
const distinct = /^\s*\bdistinct\b/i.exec(code.slice(start));
|
|
145
|
+
if (distinct) start += distinct[0].length;
|
|
146
|
+
const next = clauses.find(
|
|
147
|
+
(m) => m.index > match.index && depths[m.index] === depth,
|
|
148
|
+
);
|
|
149
|
+
let end = next ? next.index : code.length;
|
|
150
|
+
for (let i = start; i < end; i++) {
|
|
151
|
+
if (depths[i] < depth) {
|
|
152
|
+
end = i;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let itemStart = start;
|
|
158
|
+
for (let i = start; i <= end; i++) {
|
|
159
|
+
if (i === end || (code[i] === "," && depths[i] === depth)) {
|
|
160
|
+
if (STAR_PATTERN.test(code.slice(itemStart, i).trim())) star = true;
|
|
161
|
+
itemStart = i + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (star) {
|
|
166
|
+
findings.push({
|
|
167
|
+
rule: "select-star",
|
|
168
|
+
message: "uses select *; list the columns",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// date-function
|
|
173
|
+
const dates = dateFunctions(sql);
|
|
174
|
+
if (dates.length > 0) {
|
|
175
|
+
findings.push({
|
|
176
|
+
rule: "date-function",
|
|
177
|
+
message: `use literal ISO dates, not date functions: ${dates.join(", ")}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// parent-ref
|
|
182
|
+
const missingParents = parents.filter(
|
|
183
|
+
(id) => !new RegExp(`\\b${escapeRegExp(id)}\\b`, "i").test(code),
|
|
184
|
+
);
|
|
185
|
+
if (missingParents.length > 0) {
|
|
186
|
+
findings.push({
|
|
187
|
+
rule: "parent-ref",
|
|
188
|
+
message: `refer to parents by node id: ${missingParents.join(", ")}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Runs inside the report, added by the viewer's server to the copy it serves.
|
|
2
|
+
// The report sits in a sandboxed frame, so the panel cannot touch it; the two
|
|
3
|
+
// talk only through postMessage. A classic script: it is inlined in a tag, and
|
|
4
|
+
// the frame's origin is "null", so it cannot import or fetch anything.
|
|
5
|
+
(function () {
|
|
6
|
+
var BOUND = "[data-lineage]";
|
|
7
|
+
var CSS_TEXT =
|
|
8
|
+
BOUND +
|
|
9
|
+
" { cursor: pointer; transition: outline-color .15s, background-color .15s; outline: 1.5px solid transparent; outline-offset: 2px; border-radius: 2px; }" +
|
|
10
|
+
" .tl-hover { outline-color: rgba(35, 70, 176, .45) !important; }" +
|
|
11
|
+
" .tl-peek { outline: 1.5px dashed #111 !important; }" +
|
|
12
|
+
" .tl-sel { outline: 2px solid #2346b0 !important; background-color: rgba(35, 70, 176, .07) !important; }" +
|
|
13
|
+
" @keyframes tl-flash { 0% { background-color: rgba(35,70,176,.28); } 100% { background-color: rgba(35,70,176,.07); } }" +
|
|
14
|
+
" .tl-flash { animation: tl-flash .9s ease-out; }" +
|
|
15
|
+
" " +
|
|
16
|
+
BOUND +
|
|
17
|
+
":focus-visible { outline: 2px solid #2346b0; }";
|
|
18
|
+
|
|
19
|
+
var marked = null; // the id the panel has open
|
|
20
|
+
var current = null; // the element of it that is outlined
|
|
21
|
+
|
|
22
|
+
function post(message) {
|
|
23
|
+
parent.postMessage(message, "*");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bound() {
|
|
27
|
+
return Array.prototype.slice.call(document.querySelectorAll(BOUND));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function places(id) {
|
|
31
|
+
return bound().filter(function (el) {
|
|
32
|
+
return el.getAttribute("data-lineage") === id;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clear(name) {
|
|
37
|
+
Array.prototype.forEach.call(
|
|
38
|
+
document.querySelectorAll("." + name),
|
|
39
|
+
function (el) {
|
|
40
|
+
el.classList.remove(name);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function paint() {
|
|
46
|
+
clear("tl-sel");
|
|
47
|
+
if (marked === null) return;
|
|
48
|
+
var id = marked;
|
|
49
|
+
if (
|
|
50
|
+
!current ||
|
|
51
|
+
!current.isConnected ||
|
|
52
|
+
current.getAttribute("data-lineage") !== id
|
|
53
|
+
) {
|
|
54
|
+
current = places(id)[0] || null;
|
|
55
|
+
}
|
|
56
|
+
if (current) current.classList.add("tl-sel");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function open(el) {
|
|
60
|
+
current = el;
|
|
61
|
+
post({ lineage: "select", id: el.getAttribute("data-lineage") });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function start() {
|
|
65
|
+
var style = document.createElement("style");
|
|
66
|
+
style.textContent = CSS_TEXT;
|
|
67
|
+
document.head.appendChild(style);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ready() {
|
|
71
|
+
var counts = {};
|
|
72
|
+
bound().forEach(function (el) {
|
|
73
|
+
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
|
|
74
|
+
var id = el.getAttribute("data-lineage");
|
|
75
|
+
counts[id] = (counts[id] || 0) + 1;
|
|
76
|
+
});
|
|
77
|
+
post({ lineage: "ready", counts: counts });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
document.addEventListener("click", function (e) {
|
|
81
|
+
var el = e.target.closest && e.target.closest(BOUND);
|
|
82
|
+
if (!el) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
open(el);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
document.addEventListener("keydown", function (e) {
|
|
88
|
+
if (e.key === "Escape") return post({ lineage: "back" });
|
|
89
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
90
|
+
var el = e.target.closest && e.target.closest(BOUND);
|
|
91
|
+
if (!el || el !== e.target) return;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
open(el);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
document.addEventListener("mouseover", function (e) {
|
|
97
|
+
clear("tl-hover");
|
|
98
|
+
var el = e.target.closest && e.target.closest(BOUND);
|
|
99
|
+
if (el) el.classList.add("tl-hover");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
document.addEventListener("mouseleave", function () {
|
|
103
|
+
clear("tl-hover");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// What the panel asks for: outline the open node, outline one on hover,
|
|
107
|
+
// scroll to one of a node's places on the page.
|
|
108
|
+
window.addEventListener("message", function (e) {
|
|
109
|
+
if (e.source !== parent) return;
|
|
110
|
+
var m = e.data;
|
|
111
|
+
if (!m || typeof m !== "object") return;
|
|
112
|
+
if (m.lineage === "mark") {
|
|
113
|
+
marked = typeof m.id === "string" ? m.id : null;
|
|
114
|
+
paint();
|
|
115
|
+
} else if (m.lineage === "peek") {
|
|
116
|
+
clear("tl-peek");
|
|
117
|
+
if (typeof m.id === "string") {
|
|
118
|
+
places(m.id).forEach(function (el) {
|
|
119
|
+
el.classList.add("tl-peek");
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} else if (m.lineage === "scroll") {
|
|
123
|
+
var el = typeof m.id === "string" && places(m.id)[m.index];
|
|
124
|
+
if (!el) return;
|
|
125
|
+
current = el;
|
|
126
|
+
paint();
|
|
127
|
+
var calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
128
|
+
el.scrollIntoView({
|
|
129
|
+
behavior: calm ? "auto" : "smooth",
|
|
130
|
+
block: "center",
|
|
131
|
+
});
|
|
132
|
+
el.classList.remove("tl-flash");
|
|
133
|
+
void el.offsetWidth;
|
|
134
|
+
el.classList.add("tl-flash");
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
start();
|
|
139
|
+
if (document.readyState === "complete") ready();
|
|
140
|
+
else window.addEventListener("load", ready);
|
|
141
|
+
})();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|