@sapporta/server 0.0.1
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/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import type { ColumnSchema } from "../schema/extract.js";
|
|
2
|
+
import { extractBindVariables, buildPositionalQuery } from "./sql-bind.js";
|
|
3
|
+
|
|
4
|
+
// Re-export for existing consumers (check.ts, tests)
|
|
5
|
+
export { extractBindVariables, buildPositionalQuery } from "./sql-bind.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal SQL client interface for the report engine.
|
|
9
|
+
* Only `unsafe()` is needed — the engine doesn't manage connections or
|
|
10
|
+
* transactions. This is intentionally narrower than the full SqlClient,
|
|
11
|
+
* so the engine stays decoupled from CLI concerns and can be tested with
|
|
12
|
+
* a simple adapter (e.g., createReportSqlClient() from sqlite-sql-client.ts).
|
|
13
|
+
*
|
|
14
|
+
* Parameters use ? positional binding (SQLite convention). The caller must
|
|
15
|
+
* convert $name variables to ? placeholders via buildPositionalQuery()
|
|
16
|
+
* before passing the query to this client.
|
|
17
|
+
*/
|
|
18
|
+
export interface ReportSqlClient {
|
|
19
|
+
unsafe: (query: string, params?: any[]) => Promise<any[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
ReportDefinition,
|
|
24
|
+
ReportTreeNode,
|
|
25
|
+
ReportSource,
|
|
26
|
+
ReportParam,
|
|
27
|
+
ReportOutputNode,
|
|
28
|
+
ReportFooterRow,
|
|
29
|
+
ReportResult,
|
|
30
|
+
ReportSort,
|
|
31
|
+
} from "./report.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Internal engine type — extends ReportOutputNode with the full SQL row.
|
|
35
|
+
*
|
|
36
|
+
* Created during tree execution (assembleOutputNode), threaded through
|
|
37
|
+
* transforms and rollups, then stripped by applyDisplayFunctions before
|
|
38
|
+
* the result leaves the engine. Code outside engine.ts never sees this type.
|
|
39
|
+
*/
|
|
40
|
+
type EngineNode = ReportOutputNode & { __rawRow: Record<string, unknown> };
|
|
41
|
+
|
|
42
|
+
type TreeNodeResult = { nodes: EngineNode[]; footerRows: ReportFooterRow[] };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Session-level context threaded through recursive tree execution.
|
|
46
|
+
*
|
|
47
|
+
* These values are constant for the entire execution of a report — they
|
|
48
|
+
* don't change as the engine recurses into child tree nodes. Bundling them
|
|
49
|
+
* into a named type reduces executeTreeNode's parameter count and makes
|
|
50
|
+
* the distinction between session state and per-node state explicit.
|
|
51
|
+
*/
|
|
52
|
+
type TreeContext = {
|
|
53
|
+
sql: ReportSqlClient;
|
|
54
|
+
sources: Record<string, ReportSource>;
|
|
55
|
+
params: Record<string, unknown>;
|
|
56
|
+
errors: { path: string; message: string }[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Param resolution
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export function resolveParams(
|
|
64
|
+
paramDefs: ReportParam[],
|
|
65
|
+
userParams: Record<string, unknown>,
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
const resolved: Record<string, unknown> = {};
|
|
68
|
+
|
|
69
|
+
for (const def of paramDefs) {
|
|
70
|
+
let value = userParams[def.name];
|
|
71
|
+
|
|
72
|
+
if (value === undefined || value === null || value === "") {
|
|
73
|
+
if (def.required) {
|
|
74
|
+
throw new Error(`Required parameter "${def.name}" is missing`);
|
|
75
|
+
}
|
|
76
|
+
value = def.default ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Type coercion
|
|
80
|
+
if (value != null) {
|
|
81
|
+
switch (def.type) {
|
|
82
|
+
case "integer":
|
|
83
|
+
value = typeof value === "string" ? parseInt(value, 10) : value;
|
|
84
|
+
break;
|
|
85
|
+
case "float":
|
|
86
|
+
value = typeof value === "string" ? parseFloat(value as string) : value;
|
|
87
|
+
break;
|
|
88
|
+
case "date":
|
|
89
|
+
// Pass through as string — SQLite stores dates as ISO 8601 text
|
|
90
|
+
break;
|
|
91
|
+
case "string":
|
|
92
|
+
value = String(value);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resolved[def.name] = value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Bind resolution
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function resolveBinds(
|
|
108
|
+
bind:
|
|
109
|
+
| Record<string, string>
|
|
110
|
+
| ((
|
|
111
|
+
parent: Record<string, unknown>,
|
|
112
|
+
params: Record<string, unknown>,
|
|
113
|
+
) => Record<string, unknown>)
|
|
114
|
+
| undefined,
|
|
115
|
+
parentRow: Record<string, unknown> | null,
|
|
116
|
+
params: Record<string, unknown>,
|
|
117
|
+
): Record<string, unknown> {
|
|
118
|
+
if (!bind) return {};
|
|
119
|
+
|
|
120
|
+
if (typeof bind === "function") {
|
|
121
|
+
return bind(parentRow ?? {}, params);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Map form: { account_id: "$parent.id" }
|
|
125
|
+
const result: Record<string, unknown> = {};
|
|
126
|
+
for (const [key, ref] of Object.entries(bind)) {
|
|
127
|
+
if (typeof ref === "string" && ref.startsWith("$parent.")) {
|
|
128
|
+
const col = ref.slice("$parent.".length);
|
|
129
|
+
result[key] = parentRow?.[col] ?? null;
|
|
130
|
+
} else {
|
|
131
|
+
result[key] = ref;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Numeric coercion
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Coerce string values that look like numbers to actual numbers.
|
|
143
|
+
*
|
|
144
|
+
* SQLite REAL returns native JS numbers, but currency/percentage columns
|
|
145
|
+
* stored as TEXT return strings like "95.50". Coercing these to numbers
|
|
146
|
+
* is correct for report calculations — the formatted display happens in
|
|
147
|
+
* the UI layer, not the engine layer.
|
|
148
|
+
*/
|
|
149
|
+
function coerceNumericStrings(
|
|
150
|
+
rows: Record<string, unknown>[],
|
|
151
|
+
): Record<string, unknown>[] {
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
for (const key of Object.keys(row)) {
|
|
154
|
+
const val = row[key];
|
|
155
|
+
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
|
|
156
|
+
// Check it's actually a numeric string, not a date or other format
|
|
157
|
+
if (/^-?\d+(\.\d+)?$/.test(val)) {
|
|
158
|
+
row[key] = Number(val);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Source execution
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async function executeSource(
|
|
171
|
+
sql: ReportSqlClient,
|
|
172
|
+
source: ReportSource,
|
|
173
|
+
allValues: Record<string, unknown>,
|
|
174
|
+
): Promise<Record<string, unknown>[]> {
|
|
175
|
+
const bindVars = extractBindVariables(source.query);
|
|
176
|
+
const { sql: processedSql, values } = buildPositionalQuery(
|
|
177
|
+
source.query,
|
|
178
|
+
bindVars,
|
|
179
|
+
allValues,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const result = await sql.unsafe(processedSql, values as any[]);
|
|
183
|
+
return coerceNumericStrings(result as unknown as Record<string, unknown>[]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Output node assembly
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function assembleOutputNode(
|
|
191
|
+
treeNode: ReportTreeNode,
|
|
192
|
+
row: Record<string, unknown>,
|
|
193
|
+
): EngineNode {
|
|
194
|
+
const columns: Record<string, unknown> = {};
|
|
195
|
+
for (const col of treeNode.columns) {
|
|
196
|
+
if (col.name in row) {
|
|
197
|
+
columns[col.name] = row[col.name];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { levelName: treeNode.levelName, columns, __rawRow: row };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Sorting
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function sortNodes<T extends ReportOutputNode>(
|
|
208
|
+
nodes: T[],
|
|
209
|
+
sortSpec: ReportSort[],
|
|
210
|
+
): T[] {
|
|
211
|
+
if (sortSpec.length === 0) return nodes;
|
|
212
|
+
|
|
213
|
+
return nodes.sort((a, b) => {
|
|
214
|
+
for (const spec of sortSpec) {
|
|
215
|
+
const aVal = a.columns[spec.key] ?? a.rollup?.[spec.key];
|
|
216
|
+
const bVal = b.columns[spec.key] ?? b.rollup?.[spec.key];
|
|
217
|
+
|
|
218
|
+
let cmp = 0;
|
|
219
|
+
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
220
|
+
cmp = aVal - bVal;
|
|
221
|
+
} else {
|
|
222
|
+
cmp = String(aVal ?? "").localeCompare(String(bVal ?? ""));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (cmp !== 0) {
|
|
226
|
+
return spec.direction === "desc" ? -cmp : cmp;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Child processing — execute children for a single parent row
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Process all child tree nodes for a given parent row.
|
|
239
|
+
*
|
|
240
|
+
* For each child definition: check `when` condition, execute the child tree,
|
|
241
|
+
* assign the result (respecting singular vs array mode), and store footer rows.
|
|
242
|
+
* Children are processed in declaration order so that later children can see
|
|
243
|
+
* earlier siblings via `childSiblings`.
|
|
244
|
+
*/
|
|
245
|
+
async function processChildren(
|
|
246
|
+
ctx: TreeContext,
|
|
247
|
+
treeNode: ReportTreeNode,
|
|
248
|
+
node: EngineNode,
|
|
249
|
+
row: Record<string, unknown>,
|
|
250
|
+
nodePath: string,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
if (!treeNode.children || treeNode.children.length === 0) return;
|
|
253
|
+
|
|
254
|
+
node.children = {};
|
|
255
|
+
const childSiblings: Record<
|
|
256
|
+
string,
|
|
257
|
+
ReportOutputNode | ReportOutputNode[] | null
|
|
258
|
+
> = {};
|
|
259
|
+
|
|
260
|
+
for (const childDef of treeNode.children) {
|
|
261
|
+
const emptyValue = childDef.singular ? null : [];
|
|
262
|
+
|
|
263
|
+
// Check `when` condition — skip child if false
|
|
264
|
+
if (childDef.when && !childDef.when(row)) {
|
|
265
|
+
node.children[childDef.levelName] = emptyValue;
|
|
266
|
+
childSiblings[childDef.levelName] = emptyValue;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let childResult: TreeNodeResult;
|
|
271
|
+
try {
|
|
272
|
+
childResult = await executeTreeNode(
|
|
273
|
+
ctx,
|
|
274
|
+
childDef,
|
|
275
|
+
row,
|
|
276
|
+
node,
|
|
277
|
+
childSiblings,
|
|
278
|
+
`${nodePath}.${childDef.levelName}`,
|
|
279
|
+
);
|
|
280
|
+
} catch (err: any) {
|
|
281
|
+
ctx.errors.push({
|
|
282
|
+
path: `${nodePath}.${childDef.levelName}`,
|
|
283
|
+
message: err.message,
|
|
284
|
+
});
|
|
285
|
+
node.children[childDef.levelName] = emptyValue;
|
|
286
|
+
childSiblings[childDef.levelName] = emptyValue;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (childDef.singular) {
|
|
291
|
+
const single = childResult.nodes[0] ?? null;
|
|
292
|
+
node.children[childDef.levelName] = single;
|
|
293
|
+
childSiblings[childDef.levelName] = single;
|
|
294
|
+
} else {
|
|
295
|
+
node.children[childDef.levelName] = childResult.nodes;
|
|
296
|
+
childSiblings[childDef.levelName] = childResult.nodes;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!childDef.singular && childResult.footerRows.length > 0) {
|
|
300
|
+
if (!node.childFooterRows) node.childFooterRows = {};
|
|
301
|
+
node.childFooterRows[childDef.levelName] = childResult.footerRows;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Compute rollup from children
|
|
306
|
+
if (treeNode.rollup) {
|
|
307
|
+
node.rollup = computeRollup(treeNode.rollup, node.children);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Rollup computation
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Compute rollup values from a node's materialized children.
|
|
317
|
+
*
|
|
318
|
+
* Normalizes children to arrays (singular children become single-element
|
|
319
|
+
* arrays) and calls the rollup function. Warning about undeclared keys
|
|
320
|
+
* is handled by the caller (once per tree level, not per row).
|
|
321
|
+
*/
|
|
322
|
+
function computeRollup(
|
|
323
|
+
rollupFn: (children: Record<string, ReportOutputNode[]>) => Record<string, unknown>,
|
|
324
|
+
children: Record<string, ReportOutputNode[] | ReportOutputNode | null>,
|
|
325
|
+
): Record<string, unknown> {
|
|
326
|
+
const childArrays: Record<string, ReportOutputNode[]> = {};
|
|
327
|
+
for (const [key, val] of Object.entries(children)) {
|
|
328
|
+
if (Array.isArray(val)) {
|
|
329
|
+
childArrays[key] = val;
|
|
330
|
+
} else if (val != null) {
|
|
331
|
+
childArrays[key] = [val];
|
|
332
|
+
} else {
|
|
333
|
+
childArrays[key] = [];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return rollupFn(childArrays);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Tree execution
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async function executeTreeNode(
|
|
344
|
+
ctx: TreeContext,
|
|
345
|
+
treeNode: ReportTreeNode,
|
|
346
|
+
parentRow: Record<string, unknown> | null,
|
|
347
|
+
parentOutputNode: ReportOutputNode | null,
|
|
348
|
+
siblingsSoFar: Record<
|
|
349
|
+
string,
|
|
350
|
+
ReportOutputNode | ReportOutputNode[] | null
|
|
351
|
+
>,
|
|
352
|
+
path: string,
|
|
353
|
+
): Promise<TreeNodeResult> {
|
|
354
|
+
// 1. Resolve bind values and merge with params
|
|
355
|
+
const bindValues = resolveBinds(treeNode.bind, parentRow, ctx.params);
|
|
356
|
+
const allValues = { ...ctx.params, ...bindValues };
|
|
357
|
+
|
|
358
|
+
// 2. Execute source query
|
|
359
|
+
const source = ctx.sources[treeNode.source];
|
|
360
|
+
if (!source) {
|
|
361
|
+
ctx.errors.push({ path, message: `Source "${treeNode.source}" not found` });
|
|
362
|
+
return { nodes: [], footerRows: [] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let rows: Record<string, unknown>[];
|
|
366
|
+
try {
|
|
367
|
+
rows = await executeSource(ctx.sql, source, allValues);
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
ctx.errors.push({
|
|
370
|
+
path,
|
|
371
|
+
message: `Query error on source "${treeNode.source}": ${err.message}`,
|
|
372
|
+
});
|
|
373
|
+
return { nodes: [], footerRows: [] };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 3. For each row, assemble output node and process children
|
|
377
|
+
const nodes: EngineNode[] = [];
|
|
378
|
+
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
379
|
+
const row = rows[rowIdx];
|
|
380
|
+
const node = assembleOutputNode(treeNode, row);
|
|
381
|
+
await processChildren(ctx, treeNode, node, row, `${path}[${rowIdx}]`);
|
|
382
|
+
nodes.push(node);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Warn once per tree level if rollup produces keys not declared in columns[].
|
|
386
|
+
// This is a definition-level warning (the rollup function's output shape
|
|
387
|
+
// doesn't change per row), so we check only the first node that has a rollup.
|
|
388
|
+
if (treeNode.rollup && nodes.length > 0 && nodes[0].rollup) {
|
|
389
|
+
const declaredColNames = new Set(treeNode.columns.map((c) => c.name));
|
|
390
|
+
const undeclaredRollup = Object.keys(nodes[0].rollup).filter(
|
|
391
|
+
(k) => !declaredColNames.has(k),
|
|
392
|
+
);
|
|
393
|
+
if (undeclaredRollup.length > 0) {
|
|
394
|
+
ctx.errors.push({
|
|
395
|
+
path: `${path}.rollup`,
|
|
396
|
+
message:
|
|
397
|
+
`Rollup produces keys not declared in columns[]: ${undeclaredRollup.join(", ")}. ` +
|
|
398
|
+
`The UI will not render these values. Add them to columns[] on the "${treeNode.levelName}" level.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 4. Run transform if declared
|
|
404
|
+
//
|
|
405
|
+
// Transforms run at every tree level, including the root. Root-level
|
|
406
|
+
// reports (e.g. Net Worth Over Time) use transforms to compute cumulative
|
|
407
|
+
// virtual columns from SQL-sourced deltas.
|
|
408
|
+
//
|
|
409
|
+
// When there is no parent (root level), we provide a synthetic empty
|
|
410
|
+
// context so the TransformContext contract is satisfied without requiring
|
|
411
|
+
// transform authors to handle nulls.
|
|
412
|
+
//
|
|
413
|
+
// rawRows gives transforms access to ALL SQL columns — including those not
|
|
414
|
+
// declared in columns[]. The array is parallel to the nodes array.
|
|
415
|
+
let resultNodes: EngineNode[] = nodes;
|
|
416
|
+
if (treeNode.transform) {
|
|
417
|
+
const rawRows = nodes.map((n) => n.__rawRow);
|
|
418
|
+
const context = parentOutputNode
|
|
419
|
+
? { parent: parentOutputNode, siblings: siblingsSoFar, params: ctx.params, rawRows }
|
|
420
|
+
: { parent: { levelName: "__root__" as const, columns: {} }, siblings: {} as Record<string, ReportOutputNode | ReportOutputNode[] | null>, params: ctx.params, rawRows };
|
|
421
|
+
const transformed = treeNode.transform(nodes, context);
|
|
422
|
+
|
|
423
|
+
// Carry forward __rawRow from the original nodes to the transform's output.
|
|
424
|
+
// Transforms return ReportOutputNode[] (the public type), but the engine
|
|
425
|
+
// needs EngineNode[] for display functions. If a transform returns a new
|
|
426
|
+
// object without __rawRow, copy it from the original node at the same index.
|
|
427
|
+
resultNodes = transformed.map((tNode, i) => {
|
|
428
|
+
if ('__rawRow' in tNode) return tNode as EngineNode;
|
|
429
|
+
const rawRow = i < nodes.length ? nodes[i].__rawRow : {};
|
|
430
|
+
return Object.assign(tNode, { __rawRow: rawRow }) as EngineNode;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 5. Sort if declared
|
|
435
|
+
if (treeNode.sort && treeNode.sort.length > 0) {
|
|
436
|
+
resultNodes = sortNodes(resultNodes, treeNode.sort);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 6. Compute footer rows (separate from data nodes)
|
|
440
|
+
const footerRows: ReportFooterRow[] = [];
|
|
441
|
+
if (treeNode.footer && treeNode.footer.length > 0) {
|
|
442
|
+
const declaredColNames = new Set(treeNode.columns.map((c) => c.name));
|
|
443
|
+
for (const footer of treeNode.footer) {
|
|
444
|
+
const footerValues = footer.compute(resultNodes);
|
|
445
|
+
footerRows.push({ label: footer.label, columns: footerValues });
|
|
446
|
+
|
|
447
|
+
const undeclaredFooter = Object.keys(footerValues).filter(
|
|
448
|
+
(k) => !declaredColNames.has(k),
|
|
449
|
+
);
|
|
450
|
+
if (undeclaredFooter.length > 0) {
|
|
451
|
+
ctx.errors.push({
|
|
452
|
+
path: `${path}.footer["${footer.label}"]`,
|
|
453
|
+
message:
|
|
454
|
+
`Footer "${footer.label}" produces keys not declared in columns[]: ${undeclaredFooter.join(", ")}. ` +
|
|
455
|
+
`The UI will not render these values. Add them to columns[] on the "${treeNode.levelName}" level.`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { nodes: resultNodes, footerRows };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Level column collection
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Walk the tree definition and collect column schemas for each level.
|
|
470
|
+
* Returns a map of levelName → ColumnSchema[].
|
|
471
|
+
*/
|
|
472
|
+
function collectLevelColumns(
|
|
473
|
+
tree: ReportTreeNode,
|
|
474
|
+
): Record<string, ColumnSchema[]> {
|
|
475
|
+
const result: Record<string, ColumnSchema[]> = {};
|
|
476
|
+
|
|
477
|
+
function walk(node: ReportTreeNode) {
|
|
478
|
+
result[node.levelName] = node.columns;
|
|
479
|
+
if (node.children) {
|
|
480
|
+
for (const child of node.children) {
|
|
481
|
+
walk(child);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
walk(tree);
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Level options collection
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Walk the tree definition and collect per-level UI options into a flat map.
|
|
496
|
+
*
|
|
497
|
+
* Mirrors `collectLevelColumns` — both do a pre-order walk of the tree and
|
|
498
|
+
* extract definition-time metadata that the UI needs. The UI receives these
|
|
499
|
+
* as part of ReportResult so it can initialize expand/collapse state without
|
|
500
|
+
* access to the ReportDefinition itself (which lives on the server and
|
|
501
|
+
* contains functions that can't be serialized over JSON).
|
|
502
|
+
*
|
|
503
|
+
* Only levels that have explicitly set options are included in the result;
|
|
504
|
+
* the UI treats absence as "use defaults" (e.g. expanded).
|
|
505
|
+
*/
|
|
506
|
+
function collectLevelOptions(
|
|
507
|
+
tree: ReportTreeNode,
|
|
508
|
+
): Record<string, { defaultCollapsed?: boolean }> {
|
|
509
|
+
const result: Record<string, { defaultCollapsed?: boolean }> = {};
|
|
510
|
+
|
|
511
|
+
function walk(node: ReportTreeNode) {
|
|
512
|
+
if (node.defaultCollapsed != null) {
|
|
513
|
+
result[node.levelName] = { defaultCollapsed: node.defaultCollapsed };
|
|
514
|
+
}
|
|
515
|
+
if (node.children) {
|
|
516
|
+
for (const child of node.children) {
|
|
517
|
+
walk(child);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
walk(tree);
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Display function application
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Recursively walk the result tree and apply column `display` functions.
|
|
532
|
+
*
|
|
533
|
+
* TIMING: This runs as the LAST step in executeReport, AFTER the entire tree
|
|
534
|
+
* is materialized — transforms, rollups, sorts, and footers are all complete.
|
|
535
|
+
* This ordering is critical: rollup and footer functions see raw numeric
|
|
536
|
+
* column values, not display-formatted strings. Display is purely cosmetic
|
|
537
|
+
* and only affects the final output sent to the UI.
|
|
538
|
+
*
|
|
539
|
+
* For each node, the display function receives a merged object:
|
|
540
|
+
* { ...rawSqlRow, ...node.columns, ...node.rollup }
|
|
541
|
+
* This means display can reference any SQL column (including undeclared ones),
|
|
542
|
+
* declared column values (possibly modified by transform), and rollup values.
|
|
543
|
+
*
|
|
544
|
+
* Also cleans up the internal __rawRow property from all nodes.
|
|
545
|
+
*/
|
|
546
|
+
function applyDisplayFunctions(
|
|
547
|
+
nodes: EngineNode[],
|
|
548
|
+
tree: ReportTreeNode,
|
|
549
|
+
): void {
|
|
550
|
+
// Build a lookup of column display functions for this level
|
|
551
|
+
const displayFns = new Map<string, (data: Record<string, unknown>) => string | number | null>();
|
|
552
|
+
for (const col of tree.columns) {
|
|
553
|
+
if (col.display) {
|
|
554
|
+
displayFns.set(col.name, col.display);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const node of nodes) {
|
|
559
|
+
// Spread order: raw SQL row first, then declared columns (which may have
|
|
560
|
+
// been modified by transform), then rollup values. Later spreads win on
|
|
561
|
+
// key collisions, so transform modifications take precedence over raw SQL.
|
|
562
|
+
const merged = { ...node.__rawRow, ...node.columns, ...node.rollup };
|
|
563
|
+
|
|
564
|
+
// Apply display functions — store the result back into node.columns
|
|
565
|
+
for (const [colName, displayFn] of displayFns) {
|
|
566
|
+
node.columns[colName] = displayFn(merged);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Strip __rawRow — transitioning from EngineNode to ReportOutputNode.
|
|
570
|
+
// After this point the node is a plain ReportOutputNode.
|
|
571
|
+
delete (node as ReportOutputNode & { __rawRow?: unknown }).__rawRow;
|
|
572
|
+
|
|
573
|
+
// Recurse into children, matching each child group to its tree definition.
|
|
574
|
+
// Children are EngineNodes — they were produced by executeTreeNode which
|
|
575
|
+
// returns EngineNode[], and the children map preserves the concrete type
|
|
576
|
+
// even though its declared type is ReportOutputNode[].
|
|
577
|
+
if (node.children && tree.children) {
|
|
578
|
+
for (const childTree of tree.children) {
|
|
579
|
+
const childData = node.children[childTree.levelName];
|
|
580
|
+
if (Array.isArray(childData)) {
|
|
581
|
+
applyDisplayFunctions(childData as EngineNode[], childTree);
|
|
582
|
+
} else if (childData != null) {
|
|
583
|
+
applyDisplayFunctions([childData] as EngineNode[], childTree);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Apply display to child footer rows (e.g. "Closing Balance" on entries)
|
|
587
|
+
const childFooters = node.childFooterRows?.[childTree.levelName];
|
|
588
|
+
if (childFooters) {
|
|
589
|
+
applyFooterDisplayFunctions(childFooters, childTree);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Apply display functions to footer rows. Footer rows don't have __rawRow
|
|
598
|
+
* (they're synthetic), so the merged data is just the footer's columns.
|
|
599
|
+
*/
|
|
600
|
+
function applyFooterDisplayFunctions(
|
|
601
|
+
footerRows: ReportFooterRow[],
|
|
602
|
+
tree: ReportTreeNode,
|
|
603
|
+
): void {
|
|
604
|
+
const displayFns = new Map<string, (data: Record<string, unknown>) => string | number | null>();
|
|
605
|
+
for (const col of tree.columns) {
|
|
606
|
+
if (col.display) {
|
|
607
|
+
displayFns.set(col.name, col.display);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (displayFns.size === 0) return;
|
|
611
|
+
|
|
612
|
+
for (const row of footerRows) {
|
|
613
|
+
const merged = { ...row.columns };
|
|
614
|
+
for (const [colName, displayFn] of displayFns) {
|
|
615
|
+
row.columns[colName] = displayFn(merged);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// Main entry point
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
export async function executeReport(
|
|
625
|
+
sql: ReportSqlClient,
|
|
626
|
+
definition: ReportDefinition,
|
|
627
|
+
userParams: Record<string, unknown>,
|
|
628
|
+
): Promise<ReportResult> {
|
|
629
|
+
const params = resolveParams(definition.params, userParams);
|
|
630
|
+
|
|
631
|
+
const ctx: TreeContext = {
|
|
632
|
+
sql,
|
|
633
|
+
sources: definition.sources,
|
|
634
|
+
params,
|
|
635
|
+
errors: [],
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// Execute tree from root — produces EngineNode[] (ReportOutputNode + __rawRow).
|
|
639
|
+
// applyDisplayFunctions consumes __rawRow and deletes it, leaving plain
|
|
640
|
+
// ReportOutputNode objects that are safe to return in the result.
|
|
641
|
+
const { nodes: data, footerRows } = await executeTreeNode(
|
|
642
|
+
ctx,
|
|
643
|
+
definition.tree,
|
|
644
|
+
null, // no parent row for root
|
|
645
|
+
null, // no parent output node for root
|
|
646
|
+
{}, // no siblings for root
|
|
647
|
+
definition.tree.levelName,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
// Apply display functions AFTER the entire tree is materialized.
|
|
651
|
+
// This ordering ensures rollup/footer/transform all see raw numeric
|
|
652
|
+
// values. Display is the last step before returning results.
|
|
653
|
+
// Also cleans up __rawRow from all nodes.
|
|
654
|
+
applyDisplayFunctions(data, definition.tree);
|
|
655
|
+
if (footerRows.length > 0) {
|
|
656
|
+
applyFooterDisplayFunctions(footerRows, definition.tree);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Collect static metadata from the tree definition. These are pre-order
|
|
660
|
+
// walks that flatten hierarchical definition-time info into keyed maps
|
|
661
|
+
// for the UI. Both use levelName as the key, which is unique per level.
|
|
662
|
+
const levelColumns = collectLevelColumns(definition.tree);
|
|
663
|
+
const levelOptions = collectLevelOptions(definition.tree);
|
|
664
|
+
|
|
665
|
+
// Omit empty optional fields to keep the JSON response clean.
|
|
666
|
+
// The UI treats missing fields as "use defaults".
|
|
667
|
+
return {
|
|
668
|
+
name: definition.name,
|
|
669
|
+
label: definition.label,
|
|
670
|
+
params: definition.params,
|
|
671
|
+
columns: definition.tree.columns,
|
|
672
|
+
levelColumns,
|
|
673
|
+
data,
|
|
674
|
+
...(Object.keys(levelOptions).length > 0 ? { levelOptions } : {}),
|
|
675
|
+
...(footerRows.length > 0 ? { footerRows } : {}),
|
|
676
|
+
...(ctx.errors.length > 0 ? { errors: ctx.errors } : {}),
|
|
677
|
+
};
|
|
678
|
+
}
|