@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,55 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import type { ReportDefinition } from "./report.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a value looks like a ReportDefinition (duck-typing).
|
|
7
|
+
*/
|
|
8
|
+
function isReportDefinition(val: unknown): val is ReportDefinition {
|
|
9
|
+
if (typeof val !== "object" || val === null) return false;
|
|
10
|
+
const obj = val as Record<string, unknown>;
|
|
11
|
+
return (
|
|
12
|
+
typeof obj.name === "string" &&
|
|
13
|
+
typeof obj.label === "string" &&
|
|
14
|
+
Array.isArray(obj.params) &&
|
|
15
|
+
typeof obj.sources === "object" &&
|
|
16
|
+
obj.sources !== null &&
|
|
17
|
+
typeof obj.tree === "object" &&
|
|
18
|
+
obj.tree !== null
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load all report definitions from a directory.
|
|
24
|
+
* Each .ts file should default-export a report().
|
|
25
|
+
*/
|
|
26
|
+
export async function loadReports(
|
|
27
|
+
dir: string,
|
|
28
|
+
): Promise<ReportDefinition[]> {
|
|
29
|
+
const absDir = resolve(dir);
|
|
30
|
+
const files = await readdir(absDir);
|
|
31
|
+
const reports: ReportDefinition[] = [];
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
|
|
35
|
+
if (file.endsWith(".test.ts") || file.endsWith(".test.js")) continue;
|
|
36
|
+
|
|
37
|
+
const filePath = join(absDir, file);
|
|
38
|
+
const mod = await import(filePath);
|
|
39
|
+
|
|
40
|
+
// Check default export first
|
|
41
|
+
if (mod.default && isReportDefinition(mod.default)) {
|
|
42
|
+
reports.push(mod.default);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check named exports
|
|
47
|
+
for (const key of Object.keys(mod)) {
|
|
48
|
+
if (isReportDefinition(mod[key])) {
|
|
49
|
+
reports.push(mod[key]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return reports;
|
|
55
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// REPORT DEFINITION API
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A Sapporta report is a TypeScript file that default-exports a report definition.
|
|
6
|
+
// Reports are declarative: you specify SQL data sources and a tree structure that
|
|
7
|
+
// describes how to assemble the query results into hierarchical output.
|
|
8
|
+
//
|
|
9
|
+
// The engine loads report files from a directory, executes them with user-supplied
|
|
10
|
+
// parameters, and returns a tree of output nodes.
|
|
11
|
+
//
|
|
12
|
+
// QUICK EXAMPLE:
|
|
13
|
+
//
|
|
14
|
+
// import { report } from "@sapporta/server/report";
|
|
15
|
+
//
|
|
16
|
+
// export default report({
|
|
17
|
+
// name: "my-report",
|
|
18
|
+
// label: "My Report",
|
|
19
|
+
// params: [{ name: "year", type: "integer", required: true, label: "Year" }],
|
|
20
|
+
// sources: {
|
|
21
|
+
// items: { query: "SELECT id, name, amount FROM items WHERE year = $year" },
|
|
22
|
+
// },
|
|
23
|
+
// tree: {
|
|
24
|
+
// source: "items",
|
|
25
|
+
// levelName: "item",
|
|
26
|
+
// columns: [
|
|
27
|
+
// { name: "name", header: "Name" },
|
|
28
|
+
// { name: "amount", header: "Amount", format: "currency" },
|
|
29
|
+
// ],
|
|
30
|
+
// },
|
|
31
|
+
// });
|
|
32
|
+
//
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Params — user-supplied inputs that parameterize SQL queries
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
//
|
|
39
|
+
// Params are declared up front. The UI renders a form for them. The engine
|
|
40
|
+
// resolves defaults, validates types, and injects values into SQL queries as
|
|
41
|
+
// bind variables.
|
|
42
|
+
//
|
|
43
|
+
// In SQL sources, reference params with $name syntax:
|
|
44
|
+
// "SELECT * FROM accounts WHERE date <= $as_of_date"
|
|
45
|
+
|
|
46
|
+
export type ParamType = "date" | "string" | "integer" | "float";
|
|
47
|
+
|
|
48
|
+
export type ReportParam = {
|
|
49
|
+
/** Bind variable name. Used as $name in SQL sources. Must be unique. */
|
|
50
|
+
name: string;
|
|
51
|
+
|
|
52
|
+
/** Data type. The engine coerces user input to this type. */
|
|
53
|
+
type: ParamType;
|
|
54
|
+
|
|
55
|
+
/** If true, execution throws when this param is not supplied. */
|
|
56
|
+
required: boolean;
|
|
57
|
+
|
|
58
|
+
/** Used when the param is not supplied. Only relevant if required=false. */
|
|
59
|
+
default?: unknown;
|
|
60
|
+
|
|
61
|
+
/** Display label for the UI parameter form. Falls back to name if omitted. */
|
|
62
|
+
label?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Sources — named SQL queries that provide data for the tree
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
//
|
|
69
|
+
// Each source is a raw SQL query string. Sources are referenced by tree nodes
|
|
70
|
+
// via the `source` field. The same source can be referenced by multiple tree
|
|
71
|
+
// nodes (e.g. a "transactions" source used by several child nodes).
|
|
72
|
+
//
|
|
73
|
+
// Bind variables use $name syntax: $param_name for global params, and $varname
|
|
74
|
+
// for bind variables injected by parent tree nodes.
|
|
75
|
+
//
|
|
76
|
+
// The engine converts $name to positional $1, $2, ... for postgres.js execution.
|
|
77
|
+
//
|
|
78
|
+
// IMPORTANT: PostgreSQL returns numeric/decimal columns as strings. The engine
|
|
79
|
+
// automatically parses these to numbers. Use ::numeric casts in SQL to ensure
|
|
80
|
+
// consistent types: COALESCE(SUM(amount::numeric), 0) AS total
|
|
81
|
+
|
|
82
|
+
export type ReportSource = {
|
|
83
|
+
/** Raw SQL query. Use $name for bind variables (params and parent binds). */
|
|
84
|
+
query: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Columns — which fields from the source row to include in the output
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
//
|
|
91
|
+
// Each column maps a name from the SQL result to the output node. Only declared
|
|
92
|
+
// columns appear in the output — undeclared fields from the SQL row are discarded.
|
|
93
|
+
//
|
|
94
|
+
// Columns can also be "virtual" — not present in the SQL result but populated
|
|
95
|
+
// by a `transform` function. Declare them in columns so the UI knows about them.
|
|
96
|
+
//
|
|
97
|
+
// Report columns use ColumnSchema (the same type used for table columns).
|
|
98
|
+
// Only `name` is required; use `header` for display label and `format` for
|
|
99
|
+
// rendering hints. DB-specific fields (primary, foreignKey, etc.) are optional
|
|
100
|
+
// and default to inert values.
|
|
101
|
+
|
|
102
|
+
import type { ColumnSchema } from "../schema/extract.js";
|
|
103
|
+
export type { ColumnSchema } from "../schema/extract.js";
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Sort — ordering of output nodes within a tree level
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
//
|
|
109
|
+
// Sorting happens after all nodes at a level are assembled (including rollup
|
|
110
|
+
// computation). You can sort by column keys or rollup keys.
|
|
111
|
+
//
|
|
112
|
+
// If no sort is specified, nodes appear in the order returned by the SQL query.
|
|
113
|
+
|
|
114
|
+
export type ReportSort = {
|
|
115
|
+
/** Column key or rollup key to sort by. */
|
|
116
|
+
key: string;
|
|
117
|
+
|
|
118
|
+
/** Sort direction. Default is "asc". */
|
|
119
|
+
direction: "asc" | "desc";
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// TransformContext — context available to the transform function
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export type TransformContext = {
|
|
127
|
+
/** The parent output node. Has columns, rollup, and children processed so far. */
|
|
128
|
+
parent: ReportOutputNode;
|
|
129
|
+
|
|
130
|
+
/** Children of the parent that were processed before the current child.
|
|
131
|
+
* Keyed by level name. Contains the fully materialized output.
|
|
132
|
+
*
|
|
133
|
+
* For singular children: the value is a single ReportOutputNode or null.
|
|
134
|
+
* For list children: the value is ReportOutputNode[].
|
|
135
|
+
*/
|
|
136
|
+
siblings: Record<string, ReportOutputNode | ReportOutputNode[] | null>;
|
|
137
|
+
|
|
138
|
+
/** The resolved global params. */
|
|
139
|
+
params: Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
/** Full SQL rows, parallel to the nodes array passed to the transform.
|
|
142
|
+
* Includes ALL columns from the SQL result, even those not declared in
|
|
143
|
+
* columns[]. This lets transforms access undeclared SQL columns without
|
|
144
|
+
* requiring them in the columns[] declaration. */
|
|
145
|
+
rawRows: Record<string, unknown>[];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Footer — synthetic rows appended after all data nodes at a level
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export type ReportFooter = {
|
|
153
|
+
/** Label for the footer row. Appears as a special column or is used by the UI. */
|
|
154
|
+
label: string;
|
|
155
|
+
|
|
156
|
+
/** Compute footer values from the assembled sibling nodes at this level.
|
|
157
|
+
* The returned Record becomes the footer node's `columns`. */
|
|
158
|
+
compute: (nodes: ReportOutputNode[]) => Record<string, unknown>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Tree Node — the hierarchical structure of the report
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
export type ReportTreeNode = {
|
|
166
|
+
/** Name of the source in the report's `sources` map. */
|
|
167
|
+
source: string;
|
|
168
|
+
|
|
169
|
+
/** Name for this tree level. Used as the key in the parent's children map. */
|
|
170
|
+
levelName: string;
|
|
171
|
+
|
|
172
|
+
/** Columns to extract from each source row into the output node. */
|
|
173
|
+
columns: ColumnSchema[];
|
|
174
|
+
|
|
175
|
+
// --- Parent-child binding ---
|
|
176
|
+
|
|
177
|
+
/** How to pass values from the parent row into this node's SQL query. */
|
|
178
|
+
bind?:
|
|
179
|
+
| Record<string, string>
|
|
180
|
+
| ((
|
|
181
|
+
parent: Record<string, unknown>,
|
|
182
|
+
params: Record<string, unknown>,
|
|
183
|
+
) => Record<string, unknown>);
|
|
184
|
+
|
|
185
|
+
/** Conditional execution. If returns false, the child is skipped. */
|
|
186
|
+
when?: (parent: Record<string, unknown>) => boolean;
|
|
187
|
+
|
|
188
|
+
/** If true, this child produces a single object (or null) instead of an array. */
|
|
189
|
+
singular?: boolean;
|
|
190
|
+
|
|
191
|
+
// --- Post-processing ---
|
|
192
|
+
|
|
193
|
+
/** Rollup: compute values on a parent node from its materialized children. */
|
|
194
|
+
rollup?: (
|
|
195
|
+
children: Record<string, ReportOutputNode[]>,
|
|
196
|
+
) => Record<string, unknown>;
|
|
197
|
+
|
|
198
|
+
/** Transform: post-process assembled output nodes for this tree level. */
|
|
199
|
+
transform?: (
|
|
200
|
+
nodes: ReportOutputNode[],
|
|
201
|
+
context: TransformContext,
|
|
202
|
+
) => ReportOutputNode[];
|
|
203
|
+
|
|
204
|
+
/** Sort specification. Applied after transform. */
|
|
205
|
+
sort?: ReportSort[];
|
|
206
|
+
|
|
207
|
+
/** Footer rows. Computed after all data nodes are finalized. */
|
|
208
|
+
footer?: ReportFooter[];
|
|
209
|
+
|
|
210
|
+
/** When true, the UI renders this level's children collapsed by default.
|
|
211
|
+
*
|
|
212
|
+
* This flag is set on the PARENT level and applies to its children's
|
|
213
|
+
* visibility. The engine extracts it into `ReportResult.levelOptions`
|
|
214
|
+
* (keyed by levelName) so the UI can read it without walking the tree
|
|
215
|
+
* definition. When omitted or false, children start expanded. */
|
|
216
|
+
defaultCollapsed?: boolean;
|
|
217
|
+
|
|
218
|
+
/** Child tree nodes. Processed in declaration order for each parent row. */
|
|
219
|
+
children?: ReportTreeNode[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Report Definition — the top-level structure
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export type ReportDefinition = {
|
|
227
|
+
/** URL-safe identifier. Used in API routes: /api/_reports/:name/execute */
|
|
228
|
+
name: string;
|
|
229
|
+
|
|
230
|
+
/** Human-readable title. */
|
|
231
|
+
label: string;
|
|
232
|
+
|
|
233
|
+
/** Parameter definitions. */
|
|
234
|
+
params: ReportParam[];
|
|
235
|
+
|
|
236
|
+
/** Named SQL data sources. */
|
|
237
|
+
sources: Record<string, ReportSource>;
|
|
238
|
+
|
|
239
|
+
/** The tree structure defining how to assemble query results. */
|
|
240
|
+
tree: ReportTreeNode;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Output Types — what the engine produces
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
export type ReportFooterRow = {
|
|
248
|
+
label: string;
|
|
249
|
+
columns: Record<string, unknown>;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export type ReportOutputNode = {
|
|
253
|
+
/** Level name from the tree node definition. */
|
|
254
|
+
levelName: string;
|
|
255
|
+
|
|
256
|
+
/** Column values extracted from the source row. */
|
|
257
|
+
columns: Record<string, unknown>;
|
|
258
|
+
|
|
259
|
+
/** Values computed by the `rollup` function. */
|
|
260
|
+
rollup?: Record<string, unknown>;
|
|
261
|
+
|
|
262
|
+
/** Materialized children, keyed by level name. */
|
|
263
|
+
children?: Record<string, ReportOutputNode[] | ReportOutputNode | null>;
|
|
264
|
+
|
|
265
|
+
/** Footer rows for each child level, keyed by levelName. */
|
|
266
|
+
childFooterRows?: Record<string, ReportFooterRow[]>;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export type ReportResult = {
|
|
270
|
+
/** Report name from the definition. */
|
|
271
|
+
name: string;
|
|
272
|
+
|
|
273
|
+
/** Report label from the definition. */
|
|
274
|
+
label: string;
|
|
275
|
+
|
|
276
|
+
/** Parameter definitions (so the UI can render a form for re-execution). */
|
|
277
|
+
params: ReportParam[];
|
|
278
|
+
|
|
279
|
+
/** Column definitions from the root tree level. */
|
|
280
|
+
columns: ColumnSchema[];
|
|
281
|
+
|
|
282
|
+
/** Column definitions for each tree level, keyed by levelName. */
|
|
283
|
+
levelColumns: Record<string, ColumnSchema[]>;
|
|
284
|
+
|
|
285
|
+
/** The assembled tree data. */
|
|
286
|
+
data: ReportOutputNode[];
|
|
287
|
+
|
|
288
|
+
/** Per-level UI options extracted from the tree definition, keyed by levelName.
|
|
289
|
+
*
|
|
290
|
+
* The engine walks the tree and collects `defaultCollapsed` (and potentially
|
|
291
|
+
* future per-level flags) into this flat map so the UI doesn't need access
|
|
292
|
+
* to the tree definition itself. Only levels with non-default values appear. */
|
|
293
|
+
levelOptions?: Record<string, { defaultCollapsed?: boolean }>;
|
|
294
|
+
|
|
295
|
+
/** Root-level footer rows (e.g. "Grand Total"). */
|
|
296
|
+
footerRows?: ReportFooterRow[];
|
|
297
|
+
|
|
298
|
+
/** Non-fatal errors collected during execution. */
|
|
299
|
+
errors?: { path: string; message: string }[];
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// report() factory — creates a report definition with type checking
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export function report(def: ReportDefinition): ReportDefinition {
|
|
307
|
+
return def;
|
|
308
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQL BIND VARIABLES
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A SQL query in Sapporta uses $name syntax for bind variables. This module
|
|
6
|
+
// provides the operations for working with those variables:
|
|
7
|
+
//
|
|
8
|
+
// scan — walk SQL respecting strings/comments, call back for each $name
|
|
9
|
+
// extract — discover which bind variable names appear in a query
|
|
10
|
+
// buildPositional — convert $name → ?,?,... for SQLite execution
|
|
11
|
+
//
|
|
12
|
+
// The scanner is the shared primitive. extract and buildPositional are both
|
|
13
|
+
// built on top of it.
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Scanner — the shared primitive
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk a SQL string respecting single-quoted strings, line comments (--),
|
|
21
|
+
* and block comments. Calls onBind(name) for each $name bind variable found;
|
|
22
|
+
* the callback's return value replaces `$name` in the output.
|
|
23
|
+
* Returns the rebuilt SQL string.
|
|
24
|
+
*/
|
|
25
|
+
export function scanSqlBindVariables(
|
|
26
|
+
sql: string,
|
|
27
|
+
onBind: (name: string) => string,
|
|
28
|
+
): string {
|
|
29
|
+
let result = "";
|
|
30
|
+
let i = 0;
|
|
31
|
+
|
|
32
|
+
while (i < sql.length) {
|
|
33
|
+
// Single-quoted string — copy verbatim, handling '' escapes
|
|
34
|
+
if (sql[i] === "'") {
|
|
35
|
+
result += "'";
|
|
36
|
+
i++;
|
|
37
|
+
while (i < sql.length) {
|
|
38
|
+
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
39
|
+
result += "''";
|
|
40
|
+
i += 2;
|
|
41
|
+
} else if (sql[i] === "'") {
|
|
42
|
+
break;
|
|
43
|
+
} else {
|
|
44
|
+
result += sql[i];
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (i < sql.length) {
|
|
49
|
+
result += "'";
|
|
50
|
+
i++; // closing quote
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// -- line comment — copy verbatim
|
|
56
|
+
if (sql[i] === "-" && sql[i + 1] === "-") {
|
|
57
|
+
while (i < sql.length && sql[i] !== "\n") {
|
|
58
|
+
result += sql[i];
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// /* block comment */ — copy verbatim
|
|
65
|
+
if (sql[i] === "/" && sql[i + 1] === "*") {
|
|
66
|
+
result += "/*";
|
|
67
|
+
i += 2;
|
|
68
|
+
while (i < sql.length - 1 && !(sql[i] === "*" && sql[i + 1] === "/")) {
|
|
69
|
+
result += sql[i];
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
if (i < sql.length - 1) {
|
|
73
|
+
result += "*/";
|
|
74
|
+
i += 2;
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// $name bind variable (not $1 positional params)
|
|
80
|
+
if (sql[i] === "$" && i + 1 < sql.length && /[a-zA-Z_]/.test(sql[i + 1])) {
|
|
81
|
+
let name = "";
|
|
82
|
+
i++; // skip $
|
|
83
|
+
while (i < sql.length && /[a-zA-Z0-9_]/.test(sql[i])) {
|
|
84
|
+
name += sql[i];
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
result += onBind(name);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result += sql[i];
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Extract — discover bind variable names
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract $name bind variable references from a SQL string.
|
|
104
|
+
* Skips strings (single-quoted) and comments (-- and /* ... */).
|
|
105
|
+
* Returns variable names in order of first appearance (no duplicates).
|
|
106
|
+
*/
|
|
107
|
+
export function extractBindVariables(sql: string): string[] {
|
|
108
|
+
const vars: string[] = [];
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
scanSqlBindVariables(sql, (name) => {
|
|
111
|
+
if (!seen.has(name)) {
|
|
112
|
+
seen.add(name);
|
|
113
|
+
vars.push(name);
|
|
114
|
+
}
|
|
115
|
+
return `$${name}`;
|
|
116
|
+
});
|
|
117
|
+
return vars;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Build positional — convert $name to ? for SQLite
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert $name bind variables to ? positional parameters for SQLite.
|
|
126
|
+
*
|
|
127
|
+
* Each occurrence of $name becomes a separate ? in the output SQL,
|
|
128
|
+
* with the corresponding value appended to the values array. If the
|
|
129
|
+
* same $name appears multiple times, the value is duplicated — SQLite's
|
|
130
|
+
* ? parameters are strictly positional with no reuse mechanism (unlike
|
|
131
|
+
* Postgres's $N which allows same-position reuse).
|
|
132
|
+
*
|
|
133
|
+
* Example:
|
|
134
|
+
* Input: "SELECT * FROM t WHERE year = $year AND month = $month"
|
|
135
|
+
* values: { year: 2024, month: 1 }
|
|
136
|
+
* Output: { sql: "SELECT * FROM t WHERE year = ? AND month = ?",
|
|
137
|
+
* values: [2024, 1] }
|
|
138
|
+
*
|
|
139
|
+
* Example with duplicate:
|
|
140
|
+
* Input: "SELECT * FROM t WHERE start = $year OR end = $year"
|
|
141
|
+
* values: { year: 2024 }
|
|
142
|
+
* Output: { sql: "SELECT * FROM t WHERE start = ? OR end = ?",
|
|
143
|
+
* values: [2024, 2024] }
|
|
144
|
+
*/
|
|
145
|
+
export function buildPositionalQuery(
|
|
146
|
+
sql: string,
|
|
147
|
+
_bindVars: string[],
|
|
148
|
+
values: Record<string, unknown>,
|
|
149
|
+
): { sql: string; values: unknown[] } {
|
|
150
|
+
const orderedValues: unknown[] = [];
|
|
151
|
+
|
|
152
|
+
// Replace every $name with ?, appending the value for each occurrence.
|
|
153
|
+
// SQLite's ? params are consumed in order — no way to reference by
|
|
154
|
+
// position number like Postgres's $N.
|
|
155
|
+
const result = scanSqlBindVariables(sql, (name) => {
|
|
156
|
+
orderedValues.push(values[name] ?? null);
|
|
157
|
+
return "?";
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return { sql: result, values: orderedValues };
|
|
161
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildSQLitePositionalQuery, extractBindVariables } from "./sqlite-bind.js";
|
|
3
|
+
|
|
4
|
+
describe("buildSQLitePositionalQuery", () => {
|
|
5
|
+
it("replaces $name with ?", () => {
|
|
6
|
+
const result = buildSQLitePositionalQuery(
|
|
7
|
+
"SELECT * FROM t WHERE year = $year",
|
|
8
|
+
["year"],
|
|
9
|
+
{ year: 2024 },
|
|
10
|
+
);
|
|
11
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE year = ?");
|
|
12
|
+
expect(result.values).toEqual([2024]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("handles multiple different variables", () => {
|
|
16
|
+
const result = buildSQLitePositionalQuery(
|
|
17
|
+
"SELECT * FROM t WHERE year = $year AND month = $month",
|
|
18
|
+
["year", "month"],
|
|
19
|
+
{ year: 2024, month: 1 },
|
|
20
|
+
);
|
|
21
|
+
expect(result.sql).toBe(
|
|
22
|
+
"SELECT * FROM t WHERE year = ? AND month = ?",
|
|
23
|
+
);
|
|
24
|
+
expect(result.values).toEqual([2024, 1]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("duplicates values for repeated $name references", () => {
|
|
28
|
+
// SQLite's ? params are strictly positional — each ? needs its own value
|
|
29
|
+
const result = buildSQLitePositionalQuery(
|
|
30
|
+
"SELECT * FROM t WHERE start_year = $year OR end_year = $year",
|
|
31
|
+
["year"],
|
|
32
|
+
{ year: 2024 },
|
|
33
|
+
);
|
|
34
|
+
expect(result.sql).toBe(
|
|
35
|
+
"SELECT * FROM t WHERE start_year = ? OR end_year = ?",
|
|
36
|
+
);
|
|
37
|
+
// Value must be duplicated — two ?s need two values
|
|
38
|
+
expect(result.values).toEqual([2024, 2024]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("uses null for missing values", () => {
|
|
42
|
+
const result = buildSQLitePositionalQuery(
|
|
43
|
+
"SELECT * FROM t WHERE x = $missing",
|
|
44
|
+
["missing"],
|
|
45
|
+
{},
|
|
46
|
+
);
|
|
47
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE x = ?");
|
|
48
|
+
expect(result.values).toEqual([null]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not replace $name inside single-quoted strings", () => {
|
|
52
|
+
const result = buildSQLitePositionalQuery(
|
|
53
|
+
"SELECT * FROM t WHERE name = '$literal'",
|
|
54
|
+
[],
|
|
55
|
+
{},
|
|
56
|
+
);
|
|
57
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE name = '$literal'");
|
|
58
|
+
expect(result.values).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not replace $name inside line comments", () => {
|
|
62
|
+
const result = buildSQLitePositionalQuery(
|
|
63
|
+
"SELECT * FROM t -- WHERE year = $year\nWHERE 1=1",
|
|
64
|
+
[],
|
|
65
|
+
{},
|
|
66
|
+
);
|
|
67
|
+
expect(result.sql).toBe(
|
|
68
|
+
"SELECT * FROM t -- WHERE year = $year\nWHERE 1=1",
|
|
69
|
+
);
|
|
70
|
+
expect(result.values).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not replace $name inside block comments", () => {
|
|
74
|
+
const result = buildSQLitePositionalQuery(
|
|
75
|
+
"SELECT * FROM t /* $year */ WHERE 1=1",
|
|
76
|
+
[],
|
|
77
|
+
{},
|
|
78
|
+
);
|
|
79
|
+
expect(result.sql).toBe("SELECT * FROM t /* $year */ WHERE 1=1");
|
|
80
|
+
expect(result.values).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("extractBindVariables (re-exported)", () => {
|
|
85
|
+
it("extracts variables from SQL", () => {
|
|
86
|
+
const vars = extractBindVariables(
|
|
87
|
+
"SELECT * FROM t WHERE year = $year AND month = $month",
|
|
88
|
+
);
|
|
89
|
+
expect(vars).toEqual(["year", "month"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("deduplicates repeated variables", () => {
|
|
93
|
+
const vars = extractBindVariables(
|
|
94
|
+
"SELECT * FROM t WHERE x = $year OR y = $year",
|
|
95
|
+
);
|
|
96
|
+
expect(vars).toEqual(["year"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Bind Variable Conversion — $name → ? positional parameters
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Reuses the dialect-agnostic scanner from sql-bind.ts (scanSqlBindVariables,
|
|
6
|
+
// extractBindVariables) which handles string/comment skipping. Only the
|
|
7
|
+
// positional parameter format differs between Postgres and SQLite:
|
|
8
|
+
//
|
|
9
|
+
// Postgres: $name → $1, $2, ... (reusable — same $1 for duplicate $name)
|
|
10
|
+
// SQLite: $name → ? (strictly positional — duplicates get separate ?s
|
|
11
|
+
// with the same value repeated in the values array)
|
|
12
|
+
//
|
|
13
|
+
// This is because SQLite's ? parameters are consumed in order, with no way
|
|
14
|
+
// to reference a parameter by position number like Postgres's $N.
|
|
15
|
+
|
|
16
|
+
import { scanSqlBindVariables } from "./sql-bind.js";
|
|
17
|
+
|
|
18
|
+
// Re-export the dialect-agnostic utilities — callers of this module
|
|
19
|
+
// shouldn't need to import from sql-bind.ts separately
|
|
20
|
+
export { extractBindVariables } from "./sql-bind.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert $name bind variables to ? positional parameters for SQLite.
|
|
24
|
+
*
|
|
25
|
+
* Each occurrence of $name becomes a separate ? in the output SQL,
|
|
26
|
+
* with the corresponding value appended to the values array. If the
|
|
27
|
+
* same $name appears multiple times, the value is duplicated — SQLite's
|
|
28
|
+
* ? parameters are strictly positional with no reuse mechanism.
|
|
29
|
+
*
|
|
30
|
+
* Example:
|
|
31
|
+
* Input: "SELECT * FROM t WHERE year = $year AND month = $month"
|
|
32
|
+
* values: { year: 2024, month: 1 }
|
|
33
|
+
* Output: { sql: "SELECT * FROM t WHERE year = ? AND month = ?",
|
|
34
|
+
* values: [2024, 1] }
|
|
35
|
+
*
|
|
36
|
+
* Example with duplicate:
|
|
37
|
+
* Input: "SELECT * FROM t WHERE start = $year OR end = $year"
|
|
38
|
+
* values: { year: 2024 }
|
|
39
|
+
* Output: { sql: "SELECT * FROM t WHERE start = ? OR end = ?",
|
|
40
|
+
* values: [2024, 2024] }
|
|
41
|
+
*/
|
|
42
|
+
export function buildSQLitePositionalQuery(
|
|
43
|
+
sql: string,
|
|
44
|
+
_bindVars: string[],
|
|
45
|
+
values: Record<string, unknown>,
|
|
46
|
+
): { sql: string; values: unknown[] } {
|
|
47
|
+
const orderedValues: unknown[] = [];
|
|
48
|
+
|
|
49
|
+
// Replace every $name with ?, appending the value for each occurrence.
|
|
50
|
+
// Unlike Postgres buildPositionalQuery which maps $name → $N (allowing
|
|
51
|
+
// reuse of the same positional param), SQLite requires one ? per value.
|
|
52
|
+
const processedSql = scanSqlBindVariables(sql, (name) => {
|
|
53
|
+
orderedValues.push(values[name] ?? null);
|
|
54
|
+
return "?";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { sql: processedSql, values: orderedValues };
|
|
58
|
+
}
|