@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,1465 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import {
|
|
4
|
+
extractBindVariables,
|
|
5
|
+
buildPositionalQuery,
|
|
6
|
+
resolveParams,
|
|
7
|
+
executeReport,
|
|
8
|
+
} from "./engine.js";
|
|
9
|
+
import { createReportSqlClient } from "./sqlite-sql-client.js";
|
|
10
|
+
import type { ReportDefinition, ReportOutputNode } from "./report.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Unit tests: bind variable extraction
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
describe("extractBindVariables", () => {
|
|
17
|
+
it("extracts named variables", () => {
|
|
18
|
+
expect(
|
|
19
|
+
extractBindVariables("SELECT * FROM t WHERE a = $foo AND b = $bar"),
|
|
20
|
+
).toEqual(["foo", "bar"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("deduplicates", () => {
|
|
24
|
+
expect(
|
|
25
|
+
extractBindVariables("SELECT * FROM t WHERE a = $x OR b = $x"),
|
|
26
|
+
).toEqual(["x"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("skips positional $1 params", () => {
|
|
30
|
+
expect(extractBindVariables("SELECT * FROM t WHERE a = $1")).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips variables inside single-quoted strings", () => {
|
|
34
|
+
expect(
|
|
35
|
+
extractBindVariables("SELECT '$not_a_var' FROM t WHERE a = $real"),
|
|
36
|
+
).toEqual(["real"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("skips variables in line comments", () => {
|
|
40
|
+
expect(
|
|
41
|
+
extractBindVariables("SELECT * FROM t -- $not_this\nWHERE a = $this"),
|
|
42
|
+
).toEqual(["this"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("skips variables in block comments", () => {
|
|
46
|
+
expect(
|
|
47
|
+
extractBindVariables("SELECT /* $not_this */ * FROM t WHERE a = $this"),
|
|
48
|
+
).toEqual(["this"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// -- escaped quotes (the latent bug this commit fixes) --
|
|
52
|
+
|
|
53
|
+
it("skips bind vars inside strings with escaped quotes", () => {
|
|
54
|
+
expect(
|
|
55
|
+
extractBindVariables("SELECT * FROM t WHERE a = 'it''s $nope' AND b = $real"),
|
|
56
|
+
).toEqual(["real"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles multiple escaped quotes in a row", () => {
|
|
60
|
+
expect(
|
|
61
|
+
extractBindVariables("SELECT '''' || $x"),
|
|
62
|
+
).toEqual(["x"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles escaped quote followed immediately by closing quote", () => {
|
|
66
|
+
expect(
|
|
67
|
+
extractBindVariables("SELECT 'ab''cd' FROM t WHERE a = $y"),
|
|
68
|
+
).toEqual(["y"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// -- unterminated constructs --
|
|
72
|
+
|
|
73
|
+
it("handles unterminated single-quoted string", () => {
|
|
74
|
+
expect(
|
|
75
|
+
extractBindVariables("SELECT 'unterminated $hidden"),
|
|
76
|
+
).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles unterminated block comment", () => {
|
|
80
|
+
expect(
|
|
81
|
+
extractBindVariables("SELECT /* unclosed $hidden"),
|
|
82
|
+
).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// -- line comments at EOF --
|
|
86
|
+
|
|
87
|
+
it("handles line comment at end of input with no newline", () => {
|
|
88
|
+
expect(
|
|
89
|
+
extractBindVariables("SELECT $x -- trailing $not_this"),
|
|
90
|
+
).toEqual(["x"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// -- things inside strings that look like comments --
|
|
94
|
+
|
|
95
|
+
it("does not treat -- inside a string as a comment", () => {
|
|
96
|
+
expect(
|
|
97
|
+
extractBindVariables("SELECT '-- not a comment $nope' FROM t WHERE a = $real"),
|
|
98
|
+
).toEqual(["real"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not treat /* inside a string as a block comment", () => {
|
|
102
|
+
expect(
|
|
103
|
+
extractBindVariables("SELECT '/* not a comment */' FROM t WHERE a = $real"),
|
|
104
|
+
).toEqual(["real"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// -- $ edge cases --
|
|
108
|
+
|
|
109
|
+
it("returns empty for no bind variables", () => {
|
|
110
|
+
expect(extractBindVariables("SELECT 1")).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns empty for empty string", () => {
|
|
114
|
+
expect(extractBindVariables("")).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("ignores bare $ at end of input", () => {
|
|
118
|
+
expect(extractBindVariables("SELECT $")).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("ignores $ followed by a digit", () => {
|
|
122
|
+
expect(extractBindVariables("SELECT $1, $22, $x")).toEqual(["x"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("extracts vars starting with underscore", () => {
|
|
126
|
+
expect(extractBindVariables("SELECT $_foo")).toEqual(["_foo"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("extracts vars with mixed case and digits", () => {
|
|
130
|
+
expect(extractBindVariables("SELECT $camelCase_2")).toEqual(["camelCase_2"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles adjacent bind variables", () => {
|
|
134
|
+
expect(extractBindVariables("SELECT $a$b")).toEqual(["a", "b"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("extracts bind var at very start of input", () => {
|
|
138
|
+
expect(extractBindVariables("$x FROM t")).toEqual(["x"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("extracts bind var at very end of input", () => {
|
|
142
|
+
expect(extractBindVariables("SELECT $x")).toEqual(["x"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// -- comment nesting edge cases --
|
|
146
|
+
|
|
147
|
+
it("does not nest block comments", () => {
|
|
148
|
+
expect(
|
|
149
|
+
extractBindVariables("SELECT /* a /* b */ $real FROM t"),
|
|
150
|
+
).toEqual(["real"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("treats -- inside a block comment as part of the comment", () => {
|
|
154
|
+
expect(
|
|
155
|
+
extractBindVariables("SELECT /* -- $nope */ $real FROM t"),
|
|
156
|
+
).toEqual(["real"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("treats /* inside a line comment as part of the comment", () => {
|
|
160
|
+
expect(
|
|
161
|
+
extractBindVariables("SELECT $real -- /* $nope\nFROM t"),
|
|
162
|
+
).toEqual(["real"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// -- preserves order of first appearance --
|
|
166
|
+
|
|
167
|
+
it("preserves first-appearance order across many vars", () => {
|
|
168
|
+
expect(
|
|
169
|
+
extractBindVariables("$c + $a + $b + $a + $c"),
|
|
170
|
+
).toEqual(["c", "a", "b"]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Unit tests: positional query building (SQLite ? placeholders)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("buildPositionalQuery", () => {
|
|
179
|
+
it("replaces named vars with positional ? placeholders", () => {
|
|
180
|
+
const result = buildPositionalQuery(
|
|
181
|
+
"SELECT * FROM t WHERE a = $foo AND b = $bar",
|
|
182
|
+
["foo", "bar"],
|
|
183
|
+
{ foo: 1, bar: "x" },
|
|
184
|
+
);
|
|
185
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE a = ? AND b = ?");
|
|
186
|
+
expect(result.values).toEqual([1, "x"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles duplicate vars (separate ? with duplicated value)", () => {
|
|
190
|
+
const result = buildPositionalQuery(
|
|
191
|
+
"SELECT * FROM t WHERE a = $x OR b = $x",
|
|
192
|
+
["x"],
|
|
193
|
+
{ x: 42 },
|
|
194
|
+
);
|
|
195
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE a = ? OR b = ?");
|
|
196
|
+
expect(result.values).toEqual([42, 42]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("uses null for missing values", () => {
|
|
200
|
+
const result = buildPositionalQuery("SELECT $missing", ["missing"], {});
|
|
201
|
+
expect(result.values).toEqual([null]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// -- string preservation --
|
|
205
|
+
|
|
206
|
+
it("preserves strings with escaped quotes", () => {
|
|
207
|
+
const result = buildPositionalQuery(
|
|
208
|
+
"SELECT * FROM t WHERE a = 'it''s' AND b = $x",
|
|
209
|
+
["x"],
|
|
210
|
+
{ x: 1 },
|
|
211
|
+
);
|
|
212
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE a = 'it''s' AND b = ?");
|
|
213
|
+
expect(result.values).toEqual([1]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not replace $name inside strings", () => {
|
|
217
|
+
const result = buildPositionalQuery(
|
|
218
|
+
"SELECT * FROM t WHERE a = '$x' AND b = $x",
|
|
219
|
+
["x"],
|
|
220
|
+
{ x: 1 },
|
|
221
|
+
);
|
|
222
|
+
expect(result.sql).toBe("SELECT * FROM t WHERE a = '$x' AND b = ?");
|
|
223
|
+
expect(result.values).toEqual([1]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// -- comment preservation --
|
|
227
|
+
|
|
228
|
+
it("preserves line comments verbatim", () => {
|
|
229
|
+
const result = buildPositionalQuery(
|
|
230
|
+
"SELECT $x -- $x in comment\nFROM t",
|
|
231
|
+
["x"],
|
|
232
|
+
{ x: 1 },
|
|
233
|
+
);
|
|
234
|
+
expect(result.sql).toBe("SELECT ? -- $x in comment\nFROM t");
|
|
235
|
+
expect(result.values).toEqual([1]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("preserves block comments verbatim", () => {
|
|
239
|
+
const result = buildPositionalQuery(
|
|
240
|
+
"SELECT $x /* $x in comment */ FROM t",
|
|
241
|
+
["x"],
|
|
242
|
+
{ x: 1 },
|
|
243
|
+
);
|
|
244
|
+
expect(result.sql).toBe("SELECT ? /* $x in comment */ FROM t");
|
|
245
|
+
expect(result.values).toEqual([1]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// -- var not in bindVars list --
|
|
249
|
+
|
|
250
|
+
it("leaves unknown $name unchanged when not in bindVars", () => {
|
|
251
|
+
const result = buildPositionalQuery(
|
|
252
|
+
"SELECT $known, $unknown",
|
|
253
|
+
["known"],
|
|
254
|
+
{ known: 1 },
|
|
255
|
+
);
|
|
256
|
+
// SQLite buildPositionalQuery replaces ALL $name occurrences with ?
|
|
257
|
+
// (the scanner doesn't distinguish known vs unknown — it replaces
|
|
258
|
+
// every $name). Unknown vars get null value.
|
|
259
|
+
expect(result.sql).toBe("SELECT ?, ?");
|
|
260
|
+
expect(result.values).toEqual([1, null]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// -- no bind variables --
|
|
264
|
+
|
|
265
|
+
it("returns SQL unchanged when there are no bind vars", () => {
|
|
266
|
+
const result = buildPositionalQuery("SELECT 1", [], {});
|
|
267
|
+
expect(result.sql).toBe("SELECT 1");
|
|
268
|
+
expect(result.values).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// -- unterminated constructs don't crash --
|
|
272
|
+
|
|
273
|
+
it("handles unterminated string without crashing", () => {
|
|
274
|
+
const result = buildPositionalQuery(
|
|
275
|
+
"SELECT 'unterminated $x",
|
|
276
|
+
["x"],
|
|
277
|
+
{ x: 1 },
|
|
278
|
+
);
|
|
279
|
+
// $x is inside the unterminated string, so it's not replaced
|
|
280
|
+
expect(result.sql).toBe("SELECT 'unterminated $x");
|
|
281
|
+
expect(result.values).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("handles unterminated block comment without crashing", () => {
|
|
285
|
+
const result = buildPositionalQuery(
|
|
286
|
+
"SELECT /* unclosed $x",
|
|
287
|
+
["x"],
|
|
288
|
+
{ x: 1 },
|
|
289
|
+
);
|
|
290
|
+
expect(result.sql).toBe("SELECT /* unclosed $x");
|
|
291
|
+
expect(result.values).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Unit tests: param resolution
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
describe("resolveParams", () => {
|
|
300
|
+
it("resolves required params", () => {
|
|
301
|
+
const result = resolveParams(
|
|
302
|
+
[{ name: "x", type: "integer", required: true }],
|
|
303
|
+
{ x: "42" },
|
|
304
|
+
);
|
|
305
|
+
expect(result.x).toBe(42);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("throws on missing required param", () => {
|
|
309
|
+
expect(() =>
|
|
310
|
+
resolveParams(
|
|
311
|
+
[{ name: "x", type: "integer", required: true }],
|
|
312
|
+
{},
|
|
313
|
+
),
|
|
314
|
+
).toThrow('Required parameter "x" is missing');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("uses default for optional params", () => {
|
|
318
|
+
const result = resolveParams(
|
|
319
|
+
[{ name: "x", type: "string", required: false, default: "hello" }],
|
|
320
|
+
{},
|
|
321
|
+
);
|
|
322
|
+
expect(result.x).toBe("hello");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("coerces date params as strings", () => {
|
|
326
|
+
const result = resolveParams(
|
|
327
|
+
[{ name: "d", type: "date", required: true }],
|
|
328
|
+
{ d: "2024-01-01" },
|
|
329
|
+
);
|
|
330
|
+
expect(result.d).toBe("2024-01-01");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Integration tests: full report execution with SQLite
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe("executeReport (integration)", () => {
|
|
339
|
+
let sqlite: Database.Database;
|
|
340
|
+
let sql: any;
|
|
341
|
+
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
sqlite = new Database(":memory:");
|
|
344
|
+
sqlite.pragma("foreign_keys = ON");
|
|
345
|
+
sql = createReportSqlClient(sqlite);
|
|
346
|
+
|
|
347
|
+
// Create accounting schema
|
|
348
|
+
sqlite.exec(`
|
|
349
|
+
CREATE TABLE accounts (
|
|
350
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
351
|
+
name TEXT NOT NULL,
|
|
352
|
+
account_type TEXT NOT NULL,
|
|
353
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
354
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
CREATE TABLE journals (
|
|
358
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
359
|
+
date TEXT NOT NULL,
|
|
360
|
+
narration TEXT,
|
|
361
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
362
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
CREATE TABLE journal_entries (
|
|
366
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
367
|
+
journal_id INTEGER REFERENCES journals(id) NOT NULL,
|
|
368
|
+
account_id INTEGER REFERENCES accounts(id) NOT NULL,
|
|
369
|
+
debit REAL DEFAULT 0,
|
|
370
|
+
credit REAL DEFAULT 0,
|
|
371
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
372
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
373
|
+
);
|
|
374
|
+
`);
|
|
375
|
+
|
|
376
|
+
// Seed accounts
|
|
377
|
+
sqlite.exec(`
|
|
378
|
+
INSERT INTO accounts (id, name, account_type) VALUES
|
|
379
|
+
(1, 'Cash', 'Asset'),
|
|
380
|
+
(2, 'Revenue', 'Revenue'),
|
|
381
|
+
(3, 'Rent Expense', 'Expense'),
|
|
382
|
+
(4, 'Owner''s Capital', 'Equity'),
|
|
383
|
+
(5, 'Accounts Payable', 'Liability');
|
|
384
|
+
`);
|
|
385
|
+
|
|
386
|
+
// Seed journals and entries
|
|
387
|
+
// J1 (2024-01-15): Cash DR 10,000 / Owner's Capital CR 10,000
|
|
388
|
+
sqlite.exec(`
|
|
389
|
+
INSERT INTO journals (id, date, narration) VALUES
|
|
390
|
+
(1, '2024-01-15', 'Capital contribution');
|
|
391
|
+
INSERT INTO journal_entries (journal_id, account_id, debit, credit) VALUES
|
|
392
|
+
(1, 1, 10000, 0),
|
|
393
|
+
(1, 4, 0, 10000);
|
|
394
|
+
`);
|
|
395
|
+
|
|
396
|
+
// J2 (2024-02-01): Rent Expense DR 1,500 / Cash CR 1,500
|
|
397
|
+
sqlite.exec(`
|
|
398
|
+
INSERT INTO journals (id, date, narration) VALUES
|
|
399
|
+
(2, '2024-02-01', 'Rent payment');
|
|
400
|
+
INSERT INTO journal_entries (journal_id, account_id, debit, credit) VALUES
|
|
401
|
+
(2, 3, 1500, 0),
|
|
402
|
+
(2, 1, 0, 1500);
|
|
403
|
+
`);
|
|
404
|
+
|
|
405
|
+
// J3 (2024-03-01): Cash DR 5,000 / Revenue CR 5,000
|
|
406
|
+
sqlite.exec(`
|
|
407
|
+
INSERT INTO journals (id, date, narration) VALUES
|
|
408
|
+
(3, '2024-03-01', 'Sales');
|
|
409
|
+
INSERT INTO journal_entries (journal_id, account_id, debit, credit) VALUES
|
|
410
|
+
(3, 1, 5000, 0),
|
|
411
|
+
(3, 2, 0, 5000);
|
|
412
|
+
`);
|
|
413
|
+
|
|
414
|
+
// J4 (2024-03-15): Accounts Payable DR 2,000 / Cash CR 2,000
|
|
415
|
+
sqlite.exec(`
|
|
416
|
+
INSERT INTO journals (id, date, narration) VALUES
|
|
417
|
+
(4, '2024-03-15', 'Payment to supplier');
|
|
418
|
+
INSERT INTO journal_entries (journal_id, account_id, debit, credit) VALUES
|
|
419
|
+
(4, 5, 2000, 0),
|
|
420
|
+
(4, 1, 0, 2000);
|
|
421
|
+
`);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
afterEach(() => {
|
|
425
|
+
sqlite.close();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// -----------------------------------------------------------------------
|
|
429
|
+
// Trial Balance
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
describe("Trial Balance", () => {
|
|
433
|
+
const trialBalance: ReportDefinition = {
|
|
434
|
+
name: "trial-balance",
|
|
435
|
+
label: "Trial Balance",
|
|
436
|
+
params: [
|
|
437
|
+
{ name: "as_of_date", type: "date", required: true, label: "As of Date" },
|
|
438
|
+
],
|
|
439
|
+
sources: {
|
|
440
|
+
account_types: {
|
|
441
|
+
query: `
|
|
442
|
+
SELECT DISTINCT a.account_type,
|
|
443
|
+
CASE a.account_type
|
|
444
|
+
WHEN 'Asset' THEN 1
|
|
445
|
+
WHEN 'Liability' THEN 2
|
|
446
|
+
WHEN 'Equity' THEN 3
|
|
447
|
+
WHEN 'Revenue' THEN 4
|
|
448
|
+
WHEN 'Expense' THEN 5
|
|
449
|
+
END AS sort_order
|
|
450
|
+
FROM accounts a
|
|
451
|
+
ORDER BY sort_order
|
|
452
|
+
`,
|
|
453
|
+
},
|
|
454
|
+
accounts_by_type: {
|
|
455
|
+
query: `
|
|
456
|
+
SELECT a.id, a.name, a.account_type,
|
|
457
|
+
COALESCE(SUM(je.debit), 0) AS total_debit,
|
|
458
|
+
COALESCE(SUM(je.credit), 0) AS total_credit,
|
|
459
|
+
COALESCE(SUM(je.debit), 0) - COALESCE(SUM(je.credit), 0) AS balance
|
|
460
|
+
FROM accounts a
|
|
461
|
+
LEFT JOIN (
|
|
462
|
+
SELECT je.account_id, je.debit, je.credit
|
|
463
|
+
FROM journal_entries je
|
|
464
|
+
JOIN journals j ON j.id = je.journal_id
|
|
465
|
+
WHERE j.date <= $as_of_date
|
|
466
|
+
) je ON je.account_id = a.id
|
|
467
|
+
WHERE a.account_type = $account_type
|
|
468
|
+
GROUP BY a.id, a.name, a.account_type
|
|
469
|
+
ORDER BY a.name
|
|
470
|
+
`,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
tree: {
|
|
474
|
+
source: "account_types",
|
|
475
|
+
levelName: "account_type_group",
|
|
476
|
+
columns: [{ name: "account_type", header: "Account Type" }],
|
|
477
|
+
rollup: (children) => ({
|
|
478
|
+
total_debit: children.accounts.reduce(
|
|
479
|
+
(s, n) => s + Number(n.columns.total_debit ?? 0), 0,
|
|
480
|
+
),
|
|
481
|
+
total_credit: children.accounts.reduce(
|
|
482
|
+
(s, n) => s + Number(n.columns.total_credit ?? 0), 0,
|
|
483
|
+
),
|
|
484
|
+
balance: children.accounts.reduce(
|
|
485
|
+
(s, n) => s + Number(n.columns.balance ?? 0), 0,
|
|
486
|
+
),
|
|
487
|
+
}),
|
|
488
|
+
footer: [{
|
|
489
|
+
label: "Grand Total",
|
|
490
|
+
compute: (nodes) => ({
|
|
491
|
+
total_debit: nodes.reduce(
|
|
492
|
+
(s, n) => s + Number(n.rollup?.total_debit ?? 0), 0,
|
|
493
|
+
),
|
|
494
|
+
total_credit: nodes.reduce(
|
|
495
|
+
(s, n) => s + Number(n.rollup?.total_credit ?? 0), 0,
|
|
496
|
+
),
|
|
497
|
+
balance: nodes.reduce(
|
|
498
|
+
(s, n) => s + Number(n.rollup?.balance ?? 0), 0,
|
|
499
|
+
),
|
|
500
|
+
}),
|
|
501
|
+
}],
|
|
502
|
+
children: [{
|
|
503
|
+
source: "accounts_by_type",
|
|
504
|
+
levelName: "accounts",
|
|
505
|
+
bind: { account_type: "$parent.account_type" },
|
|
506
|
+
columns: [
|
|
507
|
+
{ name: "name", header: "Account" },
|
|
508
|
+
{ name: "total_debit", header: "Debit", format: "currency" },
|
|
509
|
+
{ name: "total_credit", header: "Credit", format: "currency" },
|
|
510
|
+
{ name: "balance", header: "Balance", format: "currency" },
|
|
511
|
+
],
|
|
512
|
+
}],
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
it("produces grouped account balances", async () => {
|
|
517
|
+
const result = await executeReport(sql, trialBalance, {
|
|
518
|
+
as_of_date: "2024-12-31",
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(result.name).toBe("trial-balance");
|
|
522
|
+
|
|
523
|
+
// Data should be pure data nodes (no footers mixed in)
|
|
524
|
+
expect(result.data.length).toBe(5);
|
|
525
|
+
|
|
526
|
+
// Footer rows are separate
|
|
527
|
+
expect(result.footerRows).toHaveLength(1);
|
|
528
|
+
|
|
529
|
+
// Check Asset group
|
|
530
|
+
const assetGroup = result.data.find(
|
|
531
|
+
(n) => n.columns.account_type === "Asset",
|
|
532
|
+
)!;
|
|
533
|
+
expect(assetGroup).toBeDefined();
|
|
534
|
+
expect(assetGroup.children?.accounts).toHaveLength(1);
|
|
535
|
+
|
|
536
|
+
const cash = (assetGroup.children!.accounts as ReportOutputNode[])[0];
|
|
537
|
+
expect(cash.columns.name).toBe("Cash");
|
|
538
|
+
expect(cash.columns.total_debit).toBe(15000);
|
|
539
|
+
expect(cash.columns.total_credit).toBe(3500);
|
|
540
|
+
expect(cash.columns.balance).toBe(11500);
|
|
541
|
+
|
|
542
|
+
// Rollup should match
|
|
543
|
+
expect(assetGroup.rollup?.total_debit).toBe(15000);
|
|
544
|
+
expect(assetGroup.rollup?.balance).toBe(11500);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("has balanced grand total (debits = credits)", async () => {
|
|
548
|
+
const result = await executeReport(sql, trialBalance, {
|
|
549
|
+
as_of_date: "2024-12-31",
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(result.footerRows).toBeDefined();
|
|
553
|
+
expect(result.footerRows![0].label).toBe("Grand Total");
|
|
554
|
+
expect(result.footerRows![0].columns.total_debit).toBe(
|
|
555
|
+
result.footerRows![0].columns.total_credit,
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// -----------------------------------------------------------------------
|
|
561
|
+
// Account Ledger
|
|
562
|
+
// -----------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
describe("Account Ledger", () => {
|
|
565
|
+
const accountLedger: ReportDefinition = {
|
|
566
|
+
name: "account-ledger",
|
|
567
|
+
label: "Account Ledger",
|
|
568
|
+
params: [
|
|
569
|
+
{ name: "account_id", type: "integer", required: true },
|
|
570
|
+
{ name: "from_date", type: "date", required: true },
|
|
571
|
+
{ name: "to_date", type: "date", required: true },
|
|
572
|
+
],
|
|
573
|
+
sources: {
|
|
574
|
+
account_info: {
|
|
575
|
+
query: `SELECT id, name, account_type FROM accounts WHERE id = $account_id`,
|
|
576
|
+
},
|
|
577
|
+
opening_balance: {
|
|
578
|
+
query: `
|
|
579
|
+
SELECT COALESCE(SUM(je.debit), 0) - COALESCE(SUM(je.credit), 0) AS opening_balance
|
|
580
|
+
FROM journal_entries je
|
|
581
|
+
JOIN journals j ON j.id = je.journal_id
|
|
582
|
+
WHERE je.account_id = $account_id AND j.date < $from_date
|
|
583
|
+
`,
|
|
584
|
+
},
|
|
585
|
+
transactions: {
|
|
586
|
+
query: `
|
|
587
|
+
SELECT je.id, j.date, j.narration,
|
|
588
|
+
je.debit, je.credit
|
|
589
|
+
FROM journal_entries je
|
|
590
|
+
JOIN journals j ON j.id = je.journal_id
|
|
591
|
+
WHERE je.account_id = $account_id
|
|
592
|
+
AND j.date >= $from_date
|
|
593
|
+
AND j.date <= $to_date
|
|
594
|
+
ORDER BY j.date, j.id
|
|
595
|
+
`,
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
tree: {
|
|
599
|
+
source: "account_info",
|
|
600
|
+
levelName: "account",
|
|
601
|
+
columns: [
|
|
602
|
+
{ name: "name", header: "Account Name" },
|
|
603
|
+
{ name: "account_type", header: "Account Type" },
|
|
604
|
+
],
|
|
605
|
+
children: [
|
|
606
|
+
{
|
|
607
|
+
source: "opening_balance",
|
|
608
|
+
levelName: "opening",
|
|
609
|
+
singular: true,
|
|
610
|
+
bind: { account_id: "$parent.id" },
|
|
611
|
+
columns: [
|
|
612
|
+
{ name: "opening_balance", header: "Opening Balance", format: "currency" },
|
|
613
|
+
],
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
source: "transactions",
|
|
617
|
+
levelName: "entries",
|
|
618
|
+
bind: { account_id: "$parent.id" },
|
|
619
|
+
columns: [
|
|
620
|
+
{ name: "date", header: "Date", format: "date" },
|
|
621
|
+
{ name: "narration", header: "Narration" },
|
|
622
|
+
{ name: "debit", header: "Debit", format: "currency" },
|
|
623
|
+
{ name: "credit", header: "Credit", format: "currency" },
|
|
624
|
+
{ name: "balance", header: "Balance", format: "currency" },
|
|
625
|
+
],
|
|
626
|
+
transform: (nodes, context) => {
|
|
627
|
+
const opening = context.siblings.opening as ReportOutputNode | null;
|
|
628
|
+
let balance = Number(opening?.columns.opening_balance ?? 0);
|
|
629
|
+
for (const node of nodes) {
|
|
630
|
+
balance += Number(node.columns.debit ?? 0) - Number(node.columns.credit ?? 0);
|
|
631
|
+
node.columns.balance = balance;
|
|
632
|
+
}
|
|
633
|
+
return nodes;
|
|
634
|
+
},
|
|
635
|
+
footer: [{
|
|
636
|
+
label: "Closing Balance",
|
|
637
|
+
compute: (nodes) => {
|
|
638
|
+
const last = nodes[nodes.length - 1];
|
|
639
|
+
return { balance: last?.columns.balance ?? 0 };
|
|
640
|
+
},
|
|
641
|
+
}],
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
it("computes running balance for Cash account", async () => {
|
|
648
|
+
const result = await executeReport(sql, accountLedger, {
|
|
649
|
+
account_id: 1,
|
|
650
|
+
from_date: "2024-01-01",
|
|
651
|
+
to_date: "2024-12-31",
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(result.data).toHaveLength(1);
|
|
655
|
+
const account = result.data[0];
|
|
656
|
+
expect(account.columns.name).toBe("Cash");
|
|
657
|
+
|
|
658
|
+
// Opening balance should be 0 (no transactions before 2024-01-01)
|
|
659
|
+
const opening = account.children!.opening as ReportOutputNode;
|
|
660
|
+
expect(opening.columns.opening_balance).toBe(0);
|
|
661
|
+
|
|
662
|
+
// Should have 4 entries (from 4 journals touching Cash) — pure data, no footers
|
|
663
|
+
const entries = account.children!.entries as ReportOutputNode[];
|
|
664
|
+
expect(entries).toHaveLength(4);
|
|
665
|
+
|
|
666
|
+
// Check running balance
|
|
667
|
+
// J1: +10,000 → balance 10,000
|
|
668
|
+
expect(entries[0].columns.debit).toBe(10000);
|
|
669
|
+
expect(entries[0].columns.balance).toBe(10000);
|
|
670
|
+
|
|
671
|
+
// J2: -1,500 → balance 8,500
|
|
672
|
+
expect(entries[1].columns.credit).toBe(1500);
|
|
673
|
+
expect(entries[1].columns.balance).toBe(8500);
|
|
674
|
+
|
|
675
|
+
// J3: +5,000 → balance 13,500
|
|
676
|
+
expect(entries[2].columns.debit).toBe(5000);
|
|
677
|
+
expect(entries[2].columns.balance).toBe(13500);
|
|
678
|
+
|
|
679
|
+
// J4: -2,000 → balance 11,500
|
|
680
|
+
expect(entries[3].columns.credit).toBe(2000);
|
|
681
|
+
expect(entries[3].columns.balance).toBe(11500);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("footer shows closing balance", async () => {
|
|
685
|
+
const result = await executeReport(sql, accountLedger, {
|
|
686
|
+
account_id: 1,
|
|
687
|
+
from_date: "2024-01-01",
|
|
688
|
+
to_date: "2024-12-31",
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const account = result.data[0];
|
|
692
|
+
const childFooters = account.childFooterRows!.entries;
|
|
693
|
+
expect(childFooters).toHaveLength(1);
|
|
694
|
+
expect(childFooters[0].label).toBe("Closing Balance");
|
|
695
|
+
expect(childFooters[0].columns.balance).toBe(11500);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("uses opening balance from prior period", async () => {
|
|
699
|
+
// Query from March onwards — opening should include Jan + Feb
|
|
700
|
+
const result = await executeReport(sql, accountLedger, {
|
|
701
|
+
account_id: 1,
|
|
702
|
+
from_date: "2024-03-01",
|
|
703
|
+
to_date: "2024-12-31",
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const opening = result.data[0].children!.opening as ReportOutputNode;
|
|
707
|
+
// Cash before March: +10,000 (J1) - 1,500 (J2) = 8,500
|
|
708
|
+
expect(opening.columns.opening_balance).toBe(8500);
|
|
709
|
+
|
|
710
|
+
// Pure data entries, no footers mixed in
|
|
711
|
+
const entries = result.data[0].children!.entries as ReportOutputNode[];
|
|
712
|
+
|
|
713
|
+
// First entry should start from 8,500 opening
|
|
714
|
+
// J3: +5,000 → 13,500
|
|
715
|
+
expect(entries[0].columns.balance).toBe(13500);
|
|
716
|
+
// J4: -2,000 → 11,500
|
|
717
|
+
expect(entries[1].columns.balance).toBe(11500);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// -----------------------------------------------------------------------
|
|
722
|
+
// Balance Sheet
|
|
723
|
+
// -----------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
describe("Balance Sheet", () => {
|
|
726
|
+
const balanceSheet: ReportDefinition = {
|
|
727
|
+
name: "balance-sheet",
|
|
728
|
+
label: "Balance Sheet",
|
|
729
|
+
params: [
|
|
730
|
+
{ name: "as_of_date", type: "date", required: true },
|
|
731
|
+
],
|
|
732
|
+
sources: {
|
|
733
|
+
sections: {
|
|
734
|
+
// SQLite doesn't have unnest(ARRAY[...]) — use UNION ALL SELECTs
|
|
735
|
+
query: `
|
|
736
|
+
SELECT 'Asset' AS section, 1 AS sort_order
|
|
737
|
+
UNION ALL SELECT 'Liability', 2
|
|
738
|
+
UNION ALL SELECT 'Equity', 3
|
|
739
|
+
ORDER BY sort_order
|
|
740
|
+
`,
|
|
741
|
+
},
|
|
742
|
+
section_accounts: {
|
|
743
|
+
query: `
|
|
744
|
+
SELECT a.id, a.name,
|
|
745
|
+
CASE WHEN $section IN ('Liability', 'Equity')
|
|
746
|
+
THEN COALESCE(SUM(je.credit), 0) - COALESCE(SUM(je.debit), 0)
|
|
747
|
+
ELSE COALESCE(SUM(je.debit), 0) - COALESCE(SUM(je.credit), 0)
|
|
748
|
+
END AS balance
|
|
749
|
+
FROM accounts a
|
|
750
|
+
LEFT JOIN (
|
|
751
|
+
SELECT je.account_id, je.debit, je.credit
|
|
752
|
+
FROM journal_entries je
|
|
753
|
+
JOIN journals j ON j.id = je.journal_id
|
|
754
|
+
WHERE j.date <= $as_of_date
|
|
755
|
+
) je ON je.account_id = a.id
|
|
756
|
+
WHERE a.account_type = $section
|
|
757
|
+
GROUP BY a.id, a.name
|
|
758
|
+
HAVING COALESCE(SUM(je.debit), 0) - COALESCE(SUM(je.credit), 0) != 0
|
|
759
|
+
ORDER BY a.name
|
|
760
|
+
`,
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
tree: {
|
|
764
|
+
source: "sections",
|
|
765
|
+
levelName: "section",
|
|
766
|
+
columns: [{ name: "section", header: "Section" }],
|
|
767
|
+
rollup: (children) => ({
|
|
768
|
+
section_total: children.accounts.reduce(
|
|
769
|
+
(s, n) => s + Number(n.columns.balance ?? 0), 0,
|
|
770
|
+
),
|
|
771
|
+
}),
|
|
772
|
+
footer: [{
|
|
773
|
+
label: "Total Liabilities + Equity",
|
|
774
|
+
compute: (nodes) => {
|
|
775
|
+
const liabilities = nodes.find((n) => n.columns.section === "Liability");
|
|
776
|
+
const equity = nodes.find((n) => n.columns.section === "Equity");
|
|
777
|
+
return {
|
|
778
|
+
section_total:
|
|
779
|
+
Number(liabilities?.rollup?.section_total ?? 0) +
|
|
780
|
+
Number(equity?.rollup?.section_total ?? 0),
|
|
781
|
+
};
|
|
782
|
+
},
|
|
783
|
+
}],
|
|
784
|
+
children: [{
|
|
785
|
+
source: "section_accounts",
|
|
786
|
+
levelName: "accounts",
|
|
787
|
+
bind: { section: "$parent.section" },
|
|
788
|
+
columns: [
|
|
789
|
+
{ name: "name", header: "Account" },
|
|
790
|
+
{ name: "balance", header: "Balance", format: "currency" },
|
|
791
|
+
],
|
|
792
|
+
}],
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
it("shows three sections with correct balances", async () => {
|
|
797
|
+
const result = await executeReport(sql, balanceSheet, {
|
|
798
|
+
as_of_date: "2024-12-31",
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Pure data — no footers mixed in
|
|
802
|
+
expect(result.data).toHaveLength(3);
|
|
803
|
+
|
|
804
|
+
const asset = result.data.find((n) => n.columns.section === "Asset")!;
|
|
805
|
+
expect(asset.rollup?.section_total).toBe(11500); // Cash: 11,500
|
|
806
|
+
|
|
807
|
+
const liability = result.data.find((n) => n.columns.section === "Liability")!;
|
|
808
|
+
// AP: J4 debit=2000, no credits → credit-normal sign: 0-2000 = -2000
|
|
809
|
+
expect(liability.rollup?.section_total).toBe(-2000);
|
|
810
|
+
|
|
811
|
+
const equity = result.data.find((n) => n.columns.section === "Equity")!;
|
|
812
|
+
// Owner's Capital: J1 credit=10000, no debits → credit-normal sign: 10000-0 = 10000
|
|
813
|
+
expect(equity.rollup?.section_total).toBe(10000);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("excludes zero-balance accounts", async () => {
|
|
817
|
+
const result = await executeReport(sql, balanceSheet, {
|
|
818
|
+
as_of_date: "2024-12-31",
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const asset = result.data.find((n) => n.columns.section === "Asset")!;
|
|
822
|
+
const accounts = asset.children!.accounts as ReportOutputNode[];
|
|
823
|
+
// Only Cash should appear (the only Asset account with non-zero balance)
|
|
824
|
+
expect(accounts).toHaveLength(1);
|
|
825
|
+
expect(accounts[0].columns.name).toBe("Cash");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("footer shows total liabilities + equity", async () => {
|
|
829
|
+
const result = await executeReport(sql, balanceSheet, {
|
|
830
|
+
as_of_date: "2024-12-31",
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(result.footerRows).toBeDefined();
|
|
834
|
+
expect(result.footerRows![0].label).toBe("Total Liabilities + Equity");
|
|
835
|
+
// L + E = -2000 + 10000 = 8000
|
|
836
|
+
expect(result.footerRows![0].columns.section_total).toBe(8000);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// -----------------------------------------------------------------------
|
|
841
|
+
// Root-level transforms
|
|
842
|
+
// -----------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
describe("Root-level transforms", () => {
|
|
845
|
+
const netWorthOverTime: ReportDefinition = {
|
|
846
|
+
name: "net-worth-over-time",
|
|
847
|
+
label: "Net Worth Over Time",
|
|
848
|
+
params: [],
|
|
849
|
+
sources: {
|
|
850
|
+
monthly_deltas: {
|
|
851
|
+
query: `
|
|
852
|
+
SELECT
|
|
853
|
+
strftime('%Y-%m', j.date) AS month,
|
|
854
|
+
COALESCE(SUM(CASE WHEN a.account_type = 'Asset' THEN je.debit - je.credit ELSE 0 END), 0) AS asset_delta,
|
|
855
|
+
COALESCE(SUM(CASE WHEN a.account_type = 'Liability' THEN je.debit - je.credit ELSE 0 END), 0) AS liability_delta
|
|
856
|
+
FROM journals j
|
|
857
|
+
JOIN journal_entries je ON je.journal_id = j.id
|
|
858
|
+
JOIN accounts a ON a.id = je.account_id
|
|
859
|
+
GROUP BY strftime('%Y-%m', j.date)
|
|
860
|
+
ORDER BY month
|
|
861
|
+
`,
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
tree: {
|
|
865
|
+
source: "monthly_deltas",
|
|
866
|
+
levelName: "month_row",
|
|
867
|
+
columns: [
|
|
868
|
+
{ name: "month", header: "Month" },
|
|
869
|
+
{ name: "asset_delta", header: "Asset Delta", format: "currency" },
|
|
870
|
+
{ name: "liability_delta", header: "Liability Delta", format: "currency" },
|
|
871
|
+
{ name: "assets", header: "Assets", format: "currency" },
|
|
872
|
+
{ name: "liabilities", header: "Liabilities", format: "currency" },
|
|
873
|
+
{ name: "net_worth", header: "Net Worth", format: "currency" },
|
|
874
|
+
],
|
|
875
|
+
transform: (nodes) => {
|
|
876
|
+
let assets = 0;
|
|
877
|
+
let liabilities = 0;
|
|
878
|
+
for (const node of nodes) {
|
|
879
|
+
assets += Number(node.columns.asset_delta ?? 0);
|
|
880
|
+
liabilities += Number(node.columns.liability_delta ?? 0);
|
|
881
|
+
node.columns.assets = assets;
|
|
882
|
+
node.columns.liabilities = liabilities;
|
|
883
|
+
node.columns.net_worth = assets - liabilities;
|
|
884
|
+
}
|
|
885
|
+
return nodes;
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
it("computes cumulative virtual columns at root level", async () => {
|
|
891
|
+
const result = await executeReport(sql, netWorthOverTime, {});
|
|
892
|
+
|
|
893
|
+
expect(result.data).toHaveLength(3); // Jan, Feb, Mar
|
|
894
|
+
|
|
895
|
+
// Jan: Cash DR 10000 → asset_delta=10000, liability_delta=0
|
|
896
|
+
const jan = result.data[0];
|
|
897
|
+
expect(jan.columns.month).toBe("2024-01");
|
|
898
|
+
expect(jan.columns.assets).toBe(10000);
|
|
899
|
+
expect(jan.columns.liabilities).toBe(0);
|
|
900
|
+
expect(jan.columns.net_worth).toBe(10000);
|
|
901
|
+
|
|
902
|
+
// Feb: Cash CR 1500 → asset_delta=-1500, liability_delta=0
|
|
903
|
+
const feb = result.data[1];
|
|
904
|
+
expect(feb.columns.month).toBe("2024-02");
|
|
905
|
+
expect(feb.columns.assets).toBe(8500);
|
|
906
|
+
expect(feb.columns.liabilities).toBe(0);
|
|
907
|
+
expect(feb.columns.net_worth).toBe(8500);
|
|
908
|
+
|
|
909
|
+
// Mar: Cash DR 5000+CR 2000 → asset_delta=3000; AP DR 2000 → liability_delta=2000
|
|
910
|
+
const mar = result.data[2];
|
|
911
|
+
expect(mar.columns.month).toBe("2024-03");
|
|
912
|
+
expect(mar.columns.assets).toBe(11500);
|
|
913
|
+
expect(mar.columns.liabilities).toBe(2000);
|
|
914
|
+
expect(mar.columns.net_worth).toBe(9500);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("root-level transform receives synthetic context with params", async () => {
|
|
918
|
+
let capturedContext: any = null;
|
|
919
|
+
|
|
920
|
+
const reportWithParamCheck: ReportDefinition = {
|
|
921
|
+
name: "param-check",
|
|
922
|
+
label: "Param Check",
|
|
923
|
+
params: [
|
|
924
|
+
{ name: "as_of_date", type: "date", required: true },
|
|
925
|
+
],
|
|
926
|
+
sources: {
|
|
927
|
+
items: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 1" },
|
|
928
|
+
},
|
|
929
|
+
tree: {
|
|
930
|
+
source: "items",
|
|
931
|
+
levelName: "item",
|
|
932
|
+
columns: [{ name: "name" }],
|
|
933
|
+
transform: (nodes, context) => {
|
|
934
|
+
capturedContext = context;
|
|
935
|
+
return nodes;
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
await executeReport(sql, reportWithParamCheck, {
|
|
941
|
+
as_of_date: "2024-12-31",
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
expect(capturedContext).not.toBeNull();
|
|
945
|
+
expect(capturedContext.parent.levelName).toBe("__root__");
|
|
946
|
+
expect(capturedContext.parent.columns).toEqual({});
|
|
947
|
+
expect(capturedContext.siblings).toEqual({});
|
|
948
|
+
expect(capturedContext.params.as_of_date).toBe("2024-12-31");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("root-level transform works with footer", async () => {
|
|
952
|
+
const netWorthWithFooter: ReportDefinition = {
|
|
953
|
+
...netWorthOverTime,
|
|
954
|
+
name: "net-worth-with-footer",
|
|
955
|
+
tree: {
|
|
956
|
+
...netWorthOverTime.tree,
|
|
957
|
+
footer: [{
|
|
958
|
+
label: "Current",
|
|
959
|
+
compute: (nodes) => {
|
|
960
|
+
const last = nodes[nodes.length - 1];
|
|
961
|
+
return {
|
|
962
|
+
assets: last?.columns.assets ?? 0,
|
|
963
|
+
liabilities: last?.columns.liabilities ?? 0,
|
|
964
|
+
net_worth: last?.columns.net_worth ?? 0,
|
|
965
|
+
};
|
|
966
|
+
},
|
|
967
|
+
}],
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const result = await executeReport(sql, netWorthWithFooter, {});
|
|
972
|
+
|
|
973
|
+
expect(result.footerRows).toBeDefined();
|
|
974
|
+
expect(result.footerRows).toHaveLength(1);
|
|
975
|
+
expect(result.footerRows![0].label).toBe("Current");
|
|
976
|
+
|
|
977
|
+
// Footer should see the transform's virtual columns from the last row (Mar)
|
|
978
|
+
expect(result.footerRows![0].columns.assets).toBe(11500);
|
|
979
|
+
expect(result.footerRows![0].columns.liabilities).toBe(2000);
|
|
980
|
+
expect(result.footerRows![0].columns.net_worth).toBe(9500);
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// -----------------------------------------------------------------------
|
|
985
|
+
// Edge cases
|
|
986
|
+
// -----------------------------------------------------------------------
|
|
987
|
+
|
|
988
|
+
describe("Edge cases", () => {
|
|
989
|
+
it("handles empty results gracefully", async () => {
|
|
990
|
+
const emptyReport: ReportDefinition = {
|
|
991
|
+
name: "empty",
|
|
992
|
+
label: "Empty Report",
|
|
993
|
+
params: [],
|
|
994
|
+
sources: {
|
|
995
|
+
nothing: {
|
|
996
|
+
query: "SELECT * FROM accounts WHERE 1 = 0",
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
tree: {
|
|
1000
|
+
source: "nothing",
|
|
1001
|
+
levelName: "item",
|
|
1002
|
+
columns: [{ name: "name" }],
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const result = await executeReport(sql, emptyReport, {});
|
|
1007
|
+
expect(result.data).toEqual([]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("collects errors for missing sources", async () => {
|
|
1011
|
+
const badReport: ReportDefinition = {
|
|
1012
|
+
name: "bad",
|
|
1013
|
+
label: "Bad Report",
|
|
1014
|
+
params: [],
|
|
1015
|
+
sources: {},
|
|
1016
|
+
tree: {
|
|
1017
|
+
source: "nonexistent",
|
|
1018
|
+
levelName: "item",
|
|
1019
|
+
columns: [{ name: "name" }],
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const result = await executeReport(sql, badReport, {});
|
|
1024
|
+
expect(result.errors).toBeDefined();
|
|
1025
|
+
expect(result.errors![0].message).toContain("not found");
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("handles date boundary conditions", async () => {
|
|
1029
|
+
// Only include journals up to 2024-01-15
|
|
1030
|
+
const trialBalance: ReportDefinition = {
|
|
1031
|
+
name: "tb",
|
|
1032
|
+
label: "TB",
|
|
1033
|
+
params: [{ name: "as_of_date", type: "date", required: true }],
|
|
1034
|
+
sources: {
|
|
1035
|
+
accounts: {
|
|
1036
|
+
query: `
|
|
1037
|
+
SELECT a.id, a.name,
|
|
1038
|
+
COALESCE(SUM(je.debit), 0) AS total_debit,
|
|
1039
|
+
COALESCE(SUM(je.credit), 0) AS total_credit
|
|
1040
|
+
FROM accounts a
|
|
1041
|
+
LEFT JOIN (
|
|
1042
|
+
SELECT je.account_id, je.debit, je.credit
|
|
1043
|
+
FROM journal_entries je
|
|
1044
|
+
JOIN journals j ON j.id = je.journal_id
|
|
1045
|
+
WHERE j.date <= $as_of_date
|
|
1046
|
+
) je ON je.account_id = a.id
|
|
1047
|
+
GROUP BY a.id, a.name
|
|
1048
|
+
ORDER BY a.name
|
|
1049
|
+
`,
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
tree: {
|
|
1053
|
+
source: "accounts",
|
|
1054
|
+
levelName: "account",
|
|
1055
|
+
columns: [
|
|
1056
|
+
{ name: "name" },
|
|
1057
|
+
{ name: "total_debit" },
|
|
1058
|
+
{ name: "total_credit" },
|
|
1059
|
+
],
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const result = await executeReport(sql, trialBalance, {
|
|
1064
|
+
as_of_date: "2024-01-15",
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
// Only J1 should be included
|
|
1068
|
+
const cash = result.data.find((n) => n.columns.name === "Cash")!;
|
|
1069
|
+
expect(cash.columns.total_debit).toBe(10000);
|
|
1070
|
+
expect(cash.columns.total_credit).toBe(0);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("when condition skips children", async () => {
|
|
1074
|
+
const reportDef: ReportDefinition = {
|
|
1075
|
+
name: "conditional",
|
|
1076
|
+
label: "Conditional",
|
|
1077
|
+
params: [],
|
|
1078
|
+
sources: {
|
|
1079
|
+
items: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 2" },
|
|
1080
|
+
details: { query: "SELECT 'detail' AS info WHERE 1 = 1" },
|
|
1081
|
+
},
|
|
1082
|
+
tree: {
|
|
1083
|
+
source: "items",
|
|
1084
|
+
levelName: "item",
|
|
1085
|
+
columns: [{ name: "name" }],
|
|
1086
|
+
children: [{
|
|
1087
|
+
source: "details",
|
|
1088
|
+
levelName: "detail",
|
|
1089
|
+
// Only show details for account id 1
|
|
1090
|
+
when: (parent) => parent.id === 1,
|
|
1091
|
+
columns: [{ name: "info" }],
|
|
1092
|
+
}],
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1097
|
+
// First item (id=1) should have detail
|
|
1098
|
+
expect(result.data[0].children!.detail).toHaveLength(1);
|
|
1099
|
+
// Second item (id=2) should have empty detail
|
|
1100
|
+
expect(result.data[1].children!.detail).toHaveLength(0);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("transform receives rawRows with undeclared SQL columns", async () => {
|
|
1104
|
+
let capturedRawRows: Record<string, unknown>[] = [];
|
|
1105
|
+
|
|
1106
|
+
const reportDef: ReportDefinition = {
|
|
1107
|
+
name: "raw-rows",
|
|
1108
|
+
label: "Raw Rows",
|
|
1109
|
+
params: [],
|
|
1110
|
+
sources: {
|
|
1111
|
+
items: { query: "SELECT 1 AS id, 'Root' AS name" },
|
|
1112
|
+
// SQL returns id, name, extra_col — but columns[] only declares name
|
|
1113
|
+
details: { query: "SELECT id, name, account_type AS extra_col FROM accounts ORDER BY id LIMIT 2" },
|
|
1114
|
+
},
|
|
1115
|
+
tree: {
|
|
1116
|
+
source: "items",
|
|
1117
|
+
levelName: "root",
|
|
1118
|
+
columns: [{ name: "name" }],
|
|
1119
|
+
children: [{
|
|
1120
|
+
source: "details",
|
|
1121
|
+
levelName: "detail",
|
|
1122
|
+
columns: [{ name: "name" }],
|
|
1123
|
+
transform: (nodes, context) => {
|
|
1124
|
+
capturedRawRows = context.rawRows;
|
|
1125
|
+
return nodes;
|
|
1126
|
+
},
|
|
1127
|
+
}],
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1132
|
+
// No warning errors — undeclared columns are no longer flagged
|
|
1133
|
+
expect(result.errors).toBeUndefined();
|
|
1134
|
+
|
|
1135
|
+
// rawRows should contain ALL SQL columns, including undeclared ones
|
|
1136
|
+
expect(capturedRawRows).toHaveLength(2);
|
|
1137
|
+
expect(capturedRawRows[0]).toHaveProperty("id");
|
|
1138
|
+
expect(capturedRawRows[0]).toHaveProperty("name");
|
|
1139
|
+
expect(capturedRawRows[0]).toHaveProperty("extra_col");
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it("warns when rollup keys are not declared in columns[]", async () => {
|
|
1143
|
+
const reportDef: ReportDefinition = {
|
|
1144
|
+
name: "rollup-warn",
|
|
1145
|
+
label: "Rollup Warn",
|
|
1146
|
+
params: [],
|
|
1147
|
+
sources: {
|
|
1148
|
+
parents: { query: "SELECT 'Group A' AS name" },
|
|
1149
|
+
kids: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 2" },
|
|
1150
|
+
},
|
|
1151
|
+
tree: {
|
|
1152
|
+
source: "parents",
|
|
1153
|
+
levelName: "group",
|
|
1154
|
+
// Only declares "name" — not "total" which rollup produces
|
|
1155
|
+
columns: [{ name: "name" }],
|
|
1156
|
+
rollup: (children) => ({
|
|
1157
|
+
total: children.item.reduce((s, n) => s + Number(n.columns.id ?? 0), 0),
|
|
1158
|
+
}),
|
|
1159
|
+
children: [{
|
|
1160
|
+
source: "kids",
|
|
1161
|
+
levelName: "item",
|
|
1162
|
+
columns: [{ name: "id" }, { name: "name" }],
|
|
1163
|
+
}],
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1168
|
+
expect(result.errors).toBeDefined();
|
|
1169
|
+
const warning = result.errors!.find((e) => e.message.includes("Rollup produces keys"));
|
|
1170
|
+
expect(warning).toBeDefined();
|
|
1171
|
+
expect(warning!.message).toContain("total");
|
|
1172
|
+
expect(warning!.message).toContain("columns[]");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("warns when footer keys are not declared in columns[]", async () => {
|
|
1176
|
+
const reportDef: ReportDefinition = {
|
|
1177
|
+
name: "footer-warn",
|
|
1178
|
+
label: "Footer Warn",
|
|
1179
|
+
params: [],
|
|
1180
|
+
sources: {
|
|
1181
|
+
items: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 2" },
|
|
1182
|
+
},
|
|
1183
|
+
tree: {
|
|
1184
|
+
source: "items",
|
|
1185
|
+
levelName: "item",
|
|
1186
|
+
// Only declares "name" — not "grand_total" which footer produces
|
|
1187
|
+
columns: [{ name: "name" }],
|
|
1188
|
+
footer: [{
|
|
1189
|
+
label: "Grand Total",
|
|
1190
|
+
compute: (nodes) => ({
|
|
1191
|
+
grand_total: nodes.length,
|
|
1192
|
+
}),
|
|
1193
|
+
}],
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1198
|
+
expect(result.errors).toBeDefined();
|
|
1199
|
+
const warning = result.errors!.find((e) => e.message.includes('Footer "Grand Total"'));
|
|
1200
|
+
expect(warning).toBeDefined();
|
|
1201
|
+
expect(warning!.message).toContain("grand_total");
|
|
1202
|
+
expect(warning!.message).toContain("columns[]");
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("singular children produce object or null", async () => {
|
|
1206
|
+
const reportDef: ReportDefinition = {
|
|
1207
|
+
name: "singular",
|
|
1208
|
+
label: "Singular",
|
|
1209
|
+
params: [],
|
|
1210
|
+
sources: {
|
|
1211
|
+
items: { query: "SELECT id, name FROM accounts WHERE id = 1" },
|
|
1212
|
+
info: { query: "SELECT 'extra' AS info" },
|
|
1213
|
+
empty: { query: "SELECT 'x' AS val WHERE 1 = 0" },
|
|
1214
|
+
},
|
|
1215
|
+
tree: {
|
|
1216
|
+
source: "items",
|
|
1217
|
+
levelName: "item",
|
|
1218
|
+
columns: [{ name: "name" }],
|
|
1219
|
+
children: [
|
|
1220
|
+
{
|
|
1221
|
+
source: "info",
|
|
1222
|
+
levelName: "extra",
|
|
1223
|
+
singular: true,
|
|
1224
|
+
columns: [{ name: "info" }],
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
source: "empty",
|
|
1228
|
+
levelName: "missing",
|
|
1229
|
+
singular: true,
|
|
1230
|
+
columns: [{ name: "val" }],
|
|
1231
|
+
},
|
|
1232
|
+
],
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1237
|
+
const item = result.data[0];
|
|
1238
|
+
// Singular with result → object
|
|
1239
|
+
expect(item.children!.extra).toBeDefined();
|
|
1240
|
+
expect((item.children!.extra as ReportOutputNode).columns.info).toBe("extra");
|
|
1241
|
+
// Singular with no result → null
|
|
1242
|
+
expect(item.children!.missing).toBeNull();
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// -----------------------------------------------------------------------
|
|
1247
|
+
// Display functions
|
|
1248
|
+
// -----------------------------------------------------------------------
|
|
1249
|
+
|
|
1250
|
+
describe("Display functions", () => {
|
|
1251
|
+
it("column display function formats output values", async () => {
|
|
1252
|
+
const reportDef: ReportDefinition = {
|
|
1253
|
+
name: "display-basic",
|
|
1254
|
+
label: "Display Basic",
|
|
1255
|
+
params: [],
|
|
1256
|
+
sources: {
|
|
1257
|
+
items: {
|
|
1258
|
+
query: "SELECT id, name, account_type FROM accounts ORDER BY id LIMIT 2",
|
|
1259
|
+
},
|
|
1260
|
+
},
|
|
1261
|
+
tree: {
|
|
1262
|
+
source: "items",
|
|
1263
|
+
levelName: "item",
|
|
1264
|
+
columns: [
|
|
1265
|
+
{
|
|
1266
|
+
name: "name",
|
|
1267
|
+
header: "Name",
|
|
1268
|
+
display: (row) => `${row.name} (${row.account_type})`,
|
|
1269
|
+
},
|
|
1270
|
+
{ name: "account_type", header: "Type" },
|
|
1271
|
+
],
|
|
1272
|
+
},
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1276
|
+
expect(result.data).toHaveLength(2);
|
|
1277
|
+
// display formatted the name column using data from both columns
|
|
1278
|
+
expect(result.data[0].columns.name).toBe("Cash (Asset)");
|
|
1279
|
+
expect(result.data[1].columns.name).toBe("Revenue (Revenue)");
|
|
1280
|
+
// account_type without display is unchanged
|
|
1281
|
+
expect(result.data[0].columns.account_type).toBe("Asset");
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
it("display receives undeclared SQL columns", async () => {
|
|
1285
|
+
const reportDef: ReportDefinition = {
|
|
1286
|
+
name: "display-undeclared",
|
|
1287
|
+
label: "Display Undeclared",
|
|
1288
|
+
params: [],
|
|
1289
|
+
sources: {
|
|
1290
|
+
items: {
|
|
1291
|
+
query: "SELECT id, name, account_type FROM accounts WHERE id = 1",
|
|
1292
|
+
},
|
|
1293
|
+
},
|
|
1294
|
+
tree: {
|
|
1295
|
+
source: "items",
|
|
1296
|
+
levelName: "item",
|
|
1297
|
+
columns: [
|
|
1298
|
+
{
|
|
1299
|
+
name: "name",
|
|
1300
|
+
header: "Name",
|
|
1301
|
+
display: (row) => `${row.name} [${row.account_type}]`,
|
|
1302
|
+
},
|
|
1303
|
+
],
|
|
1304
|
+
},
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1308
|
+
expect(result.data[0].columns.name).toBe("Cash [Asset]");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it("display runs after rollup — rollup sees raw values, not display strings", async () => {
|
|
1312
|
+
const reportDef: ReportDefinition = {
|
|
1313
|
+
name: "display-after-rollup",
|
|
1314
|
+
label: "Display After Rollup",
|
|
1315
|
+
params: [],
|
|
1316
|
+
sources: {
|
|
1317
|
+
groups: { query: "SELECT 'Assets' AS section" },
|
|
1318
|
+
items: {
|
|
1319
|
+
query: `
|
|
1320
|
+
SELECT a.name,
|
|
1321
|
+
COALESCE(SUM(je.debit), 0) - COALESCE(SUM(je.credit), 0) AS balance
|
|
1322
|
+
FROM accounts a
|
|
1323
|
+
LEFT JOIN journal_entries je ON je.account_id = a.id
|
|
1324
|
+
WHERE a.account_type = 'Asset'
|
|
1325
|
+
GROUP BY a.name
|
|
1326
|
+
`,
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
tree: {
|
|
1330
|
+
source: "groups",
|
|
1331
|
+
levelName: "group",
|
|
1332
|
+
columns: [
|
|
1333
|
+
{ name: "section" },
|
|
1334
|
+
{
|
|
1335
|
+
name: "total",
|
|
1336
|
+
header: "Total",
|
|
1337
|
+
display: (row) => `$${Number(row.total).toLocaleString()}`,
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
rollup: (children) => ({
|
|
1341
|
+
total: children.item.reduce(
|
|
1342
|
+
(s, n) => s + Number(n.columns.balance ?? 0), 0,
|
|
1343
|
+
),
|
|
1344
|
+
}),
|
|
1345
|
+
children: [{
|
|
1346
|
+
source: "items",
|
|
1347
|
+
levelName: "item",
|
|
1348
|
+
columns: [
|
|
1349
|
+
{ name: "name" },
|
|
1350
|
+
{
|
|
1351
|
+
name: "balance",
|
|
1352
|
+
display: (row) => `$${Number(row.balance).toLocaleString()}`,
|
|
1353
|
+
},
|
|
1354
|
+
],
|
|
1355
|
+
}],
|
|
1356
|
+
},
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1360
|
+
const group = result.data[0];
|
|
1361
|
+
|
|
1362
|
+
// Rollup computed correctly from raw numeric values
|
|
1363
|
+
expect(group.rollup?.total).toBe(11500);
|
|
1364
|
+
|
|
1365
|
+
// Display formatted the rollup value on the group
|
|
1366
|
+
expect(group.columns.total).toBe("$11,500");
|
|
1367
|
+
|
|
1368
|
+
// Child display also formatted
|
|
1369
|
+
const items = group.children!.item as ReportOutputNode[];
|
|
1370
|
+
expect(items[0].columns.balance).toBe("$11,500");
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
it("display applies to footer rows", async () => {
|
|
1374
|
+
const reportDef: ReportDefinition = {
|
|
1375
|
+
name: "display-footer",
|
|
1376
|
+
label: "Display Footer",
|
|
1377
|
+
params: [],
|
|
1378
|
+
sources: {
|
|
1379
|
+
items: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 3" },
|
|
1380
|
+
},
|
|
1381
|
+
tree: {
|
|
1382
|
+
source: "items",
|
|
1383
|
+
levelName: "item",
|
|
1384
|
+
columns: [
|
|
1385
|
+
{ name: "name" },
|
|
1386
|
+
{
|
|
1387
|
+
name: "count",
|
|
1388
|
+
header: "Count",
|
|
1389
|
+
display: (row) => `${row.count} items`,
|
|
1390
|
+
},
|
|
1391
|
+
],
|
|
1392
|
+
footer: [{
|
|
1393
|
+
label: "Total",
|
|
1394
|
+
compute: (nodes) => ({
|
|
1395
|
+
count: nodes.length,
|
|
1396
|
+
}),
|
|
1397
|
+
}],
|
|
1398
|
+
},
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1402
|
+
expect(result.footerRows).toHaveLength(1);
|
|
1403
|
+
expect(result.footerRows![0].columns.count).toBe("3 items");
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
it("display applies to child footer rows", async () => {
|
|
1407
|
+
const reportDef: ReportDefinition = {
|
|
1408
|
+
name: "display-child-footer",
|
|
1409
|
+
label: "Display Child Footer",
|
|
1410
|
+
params: [],
|
|
1411
|
+
sources: {
|
|
1412
|
+
parents: { query: "SELECT 1 AS id, 'Group' AS name" },
|
|
1413
|
+
kids: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 3" },
|
|
1414
|
+
},
|
|
1415
|
+
tree: {
|
|
1416
|
+
source: "parents",
|
|
1417
|
+
levelName: "group",
|
|
1418
|
+
columns: [{ name: "name" }],
|
|
1419
|
+
children: [{
|
|
1420
|
+
source: "kids",
|
|
1421
|
+
levelName: "item",
|
|
1422
|
+
columns: [
|
|
1423
|
+
{ name: "name" },
|
|
1424
|
+
{
|
|
1425
|
+
name: "total",
|
|
1426
|
+
header: "Total",
|
|
1427
|
+
display: (row) => row.total != null ? `${row.total} accounts` : null,
|
|
1428
|
+
},
|
|
1429
|
+
],
|
|
1430
|
+
footer: [{
|
|
1431
|
+
label: "Count",
|
|
1432
|
+
compute: (nodes) => ({ total: nodes.length }),
|
|
1433
|
+
}],
|
|
1434
|
+
}],
|
|
1435
|
+
},
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1439
|
+
const group = result.data[0];
|
|
1440
|
+
const childFooters = group.childFooterRows!.item;
|
|
1441
|
+
expect(childFooters).toHaveLength(1);
|
|
1442
|
+
expect(childFooters[0].columns.total).toBe("3 accounts");
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
it("__rawRow is cleaned up from output nodes", async () => {
|
|
1446
|
+
const reportDef: ReportDefinition = {
|
|
1447
|
+
name: "no-raw-row-leak",
|
|
1448
|
+
label: "No Raw Row Leak",
|
|
1449
|
+
params: [],
|
|
1450
|
+
sources: {
|
|
1451
|
+
items: { query: "SELECT id, name FROM accounts ORDER BY id LIMIT 1" },
|
|
1452
|
+
},
|
|
1453
|
+
tree: {
|
|
1454
|
+
source: "items",
|
|
1455
|
+
levelName: "item",
|
|
1456
|
+
columns: [{ name: "name" }],
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
const result = await executeReport(sql, reportDef, {});
|
|
1461
|
+
// __rawRow must not leak into the output
|
|
1462
|
+
expect((result.data[0] as any).__rawRow).toBeUndefined();
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
});
|