@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.
Files changed (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. 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
+ });