@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,115 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SqlClient, OperationResult } from "./types.js";
|
|
3
|
+
import { OperationError, ErrorCode } from "./types.js";
|
|
4
|
+
import { validateTableName } from "./sql-safety.js";
|
|
5
|
+
|
|
6
|
+
export const tableRenameInput = z.object({
|
|
7
|
+
oldName: z.string().describe("Current SQL table name"),
|
|
8
|
+
newName: z.string().describe("New SQL table name (snake_case)"),
|
|
9
|
+
newLabel: z.string().optional().describe("New display label (defaults to title-cased newName)"),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Title-case a snake_case name: "sales_targets" → "Sales Targets"
|
|
14
|
+
*/
|
|
15
|
+
function titleCase(name: string): string {
|
|
16
|
+
return name
|
|
17
|
+
.split("_")
|
|
18
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
19
|
+
.join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rename a UI-managed table atomically.
|
|
24
|
+
*
|
|
25
|
+
* Steps inside sql.begin():
|
|
26
|
+
* 1. Verify oldName exists in _sapporta_tables (reject file-managed tables)
|
|
27
|
+
* 2. ALTER TABLE "oldName" RENAME TO "newName"
|
|
28
|
+
* 3. INSERT new row into _sapporta_tables (copy metadata with new name/label)
|
|
29
|
+
* 4. UPDATE _sapporta_columns SET table_name = newName WHERE table_name = oldName
|
|
30
|
+
* 5. UPDATE _sapporta_columns SET references_table = newName WHERE references_table = oldName
|
|
31
|
+
* 6. DELETE old row from _sapporta_tables
|
|
32
|
+
*
|
|
33
|
+
* Why insert-then-delete: _sapporta_columns.table_name FK references
|
|
34
|
+
* _sapporta_tables.name with NO ON UPDATE CASCADE. Can't update PK in place.
|
|
35
|
+
* Instead: insert new row so FK target exists, move column references, then
|
|
36
|
+
* delete old row.
|
|
37
|
+
*/
|
|
38
|
+
export async function tableRename(
|
|
39
|
+
sql: SqlClient,
|
|
40
|
+
oldName: string,
|
|
41
|
+
newName: string,
|
|
42
|
+
newLabel?: string,
|
|
43
|
+
): Promise<OperationResult> {
|
|
44
|
+
validateTableName(oldName);
|
|
45
|
+
validateTableName(newName);
|
|
46
|
+
|
|
47
|
+
if (oldName === newName) {
|
|
48
|
+
throw new OperationError("Old and new names are the same", ErrorCode.VALIDATION_FAILED);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const label = newLabel ?? titleCase(newName);
|
|
52
|
+
|
|
53
|
+
await sql.begin(async (tx) => {
|
|
54
|
+
// 1. Verify oldName exists in _sapporta_tables
|
|
55
|
+
const existing = await tx.unsafe(
|
|
56
|
+
`SELECT name FROM _sapporta_tables WHERE name = $1`,
|
|
57
|
+
[oldName],
|
|
58
|
+
);
|
|
59
|
+
if (existing.length === 0) {
|
|
60
|
+
throw new OperationError(
|
|
61
|
+
`Table "${oldName}" not found in _sapporta_tables (only UI-managed tables can be renamed)`,
|
|
62
|
+
ErrorCode.TABLE_NOT_FOUND,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check newName doesn't already exist
|
|
67
|
+
const conflict = await tx.unsafe(
|
|
68
|
+
`SELECT name FROM _sapporta_tables WHERE name = $1`,
|
|
69
|
+
[newName],
|
|
70
|
+
);
|
|
71
|
+
if (conflict.length > 0) {
|
|
72
|
+
throw new OperationError(
|
|
73
|
+
`Table "${newName}" already exists in _sapporta_tables`,
|
|
74
|
+
ErrorCode.VALIDATION_FAILED,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Rename the actual PG table
|
|
79
|
+
await tx.unsafe(`ALTER TABLE "${oldName}" RENAME TO "${newName}"`);
|
|
80
|
+
|
|
81
|
+
// 3. Insert new metadata row (copy from old with new name/label)
|
|
82
|
+
await tx.unsafe(
|
|
83
|
+
`INSERT INTO _sapporta_tables (name, label, display_column, immutable, inferred, position)
|
|
84
|
+
SELECT $1, $2, display_column, immutable, inferred, position
|
|
85
|
+
FROM _sapporta_tables WHERE name = $3`,
|
|
86
|
+
[newName, label, oldName],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// 4. Move columns to new table name
|
|
90
|
+
await tx.unsafe(
|
|
91
|
+
`UPDATE _sapporta_columns SET table_name = $1 WHERE table_name = $2`,
|
|
92
|
+
[newName, oldName],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// 5. Update FK references pointing to the old table
|
|
96
|
+
await tx.unsafe(
|
|
97
|
+
`UPDATE _sapporta_columns SET references_table = $1 WHERE references_table = $2`,
|
|
98
|
+
[newName, oldName],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// 6. Delete old metadata row (columns already moved, so no FK violation)
|
|
102
|
+
await tx.unsafe(
|
|
103
|
+
`DELETE FROM _sapporta_tables WHERE name = $1`,
|
|
104
|
+
[oldName],
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
data: [{ old_name: oldName, new_name: newName, label }],
|
|
111
|
+
meta: {
|
|
112
|
+
message: `Renamed table "${oldName}" → "${newName}" (label: "${label}")`,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Operation result envelope — the contract between domain operations and
|
|
3
|
+
// the layers that consume them (API routes, CLI output).
|
|
4
|
+
//
|
|
5
|
+
// These types were originally in cli-utils.ts as CliResult/CliError but have
|
|
6
|
+
// nothing to do with the CLI — they're a structured result pattern used by
|
|
7
|
+
// database introspection functions, SQL proxy, and other operations.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every operation returns an OperationResult instead of printing directly.
|
|
12
|
+
* This separates data production from presentation, enabling:
|
|
13
|
+
* - HTTP API responses (meta-api.ts converts to JSON + status codes)
|
|
14
|
+
* - CLI output formatting (emitResult converts to table or JSON)
|
|
15
|
+
* - Tests that assert on data, not console output
|
|
16
|
+
*/
|
|
17
|
+
export type OperationResult = OperationSuccess | OperationFailure;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Well-known metadata fields used by the output layer for rendering.
|
|
21
|
+
* These fields are the contract between operation functions and consumers:
|
|
22
|
+
* - message: displayed before the data table (or as the sole output if tableOutputHandled)
|
|
23
|
+
* - tableOutputHandled: signals that message/additionalOutput contain the full rendering,
|
|
24
|
+
* so the output layer should NOT format data[] as a table
|
|
25
|
+
* - additionalOutput: extra text sections printed after the main table
|
|
26
|
+
* - errorText: printed to stderr (e.g. warnings from report execution)
|
|
27
|
+
* - rowCount, dryRun: informational fields for agents consuming JSON output
|
|
28
|
+
*
|
|
29
|
+
* The index signature allows operation-specific extras (e.g. foreignKeys, reportData).
|
|
30
|
+
*/
|
|
31
|
+
export type OperationMeta = {
|
|
32
|
+
message?: string;
|
|
33
|
+
tableOutputHandled?: boolean;
|
|
34
|
+
additionalOutput?: string;
|
|
35
|
+
errorText?: string;
|
|
36
|
+
rowCount?: number;
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type OperationSuccess = {
|
|
42
|
+
ok: true;
|
|
43
|
+
/** Primary result rows. Most operations return a single table of rows. */
|
|
44
|
+
data: Record<string, unknown>[];
|
|
45
|
+
/** Optional metadata (row counts, messages, secondary data like foreign keys). */
|
|
46
|
+
meta?: OperationMeta;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type OperationFailure = {
|
|
50
|
+
ok: false;
|
|
51
|
+
error: string;
|
|
52
|
+
code: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Well-known error codes for structured error output.
|
|
57
|
+
* Agents can match on these programmatically instead of parsing error messages.
|
|
58
|
+
*/
|
|
59
|
+
export const ErrorCode = {
|
|
60
|
+
TABLE_NOT_FOUND: "TABLE_NOT_FOUND",
|
|
61
|
+
INVALID_TABLE_NAME: "INVALID_TABLE_NAME",
|
|
62
|
+
INVALID_COLUMN_NAME: "INVALID_COLUMN_NAME",
|
|
63
|
+
INVALID_JSON: "INVALID_JSON",
|
|
64
|
+
DANGEROUS_SQL: "DANGEROUS_SQL",
|
|
65
|
+
SELECT_ONLY: "SELECT_ONLY",
|
|
66
|
+
PROJECT_NOT_FOUND: "PROJECT_NOT_FOUND",
|
|
67
|
+
REPORT_NOT_FOUND: "REPORT_NOT_FOUND",
|
|
68
|
+
VALIDATION_FAILED: "VALIDATION_FAILED",
|
|
69
|
+
MISSING_ARGUMENT: "MISSING_ARGUMENT",
|
|
70
|
+
INTERNAL: "INTERNAL",
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Typed error that carries a machine-readable error code.
|
|
75
|
+
* Operations throw these; consumers catch and convert to appropriate output.
|
|
76
|
+
*/
|
|
77
|
+
export class OperationError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
message: string,
|
|
80
|
+
public code: string,
|
|
81
|
+
) {
|
|
82
|
+
super(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SQL client interface matching the subset of postgres.js used by operations.
|
|
88
|
+
* In production, a real postgres.js client is passed.
|
|
89
|
+
* In tests, a PGlite adapter is injected.
|
|
90
|
+
*/
|
|
91
|
+
export interface SqlClient {
|
|
92
|
+
unsafe: (query: string, params?: any[]) => Promise<any[]>;
|
|
93
|
+
begin: (fn: (sql: SqlClient) => Promise<any>) => Promise<any>;
|
|
94
|
+
end: () => Promise<void>;
|
|
95
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { checkReportDefinition, checkReportSqlColumns } from "./check.js";
|
|
4
|
+
import type { CheckSql } from "./check.js";
|
|
5
|
+
import type { ReportDefinition } from "./report.js";
|
|
6
|
+
|
|
7
|
+
describe("checkReportDefinition", () => {
|
|
8
|
+
it("returns no issues for a valid report", () => {
|
|
9
|
+
const def: ReportDefinition = {
|
|
10
|
+
name: "valid",
|
|
11
|
+
label: "Valid Report",
|
|
12
|
+
params: [],
|
|
13
|
+
sources: {
|
|
14
|
+
items: { query: "SELECT name, amount FROM items" },
|
|
15
|
+
},
|
|
16
|
+
tree: {
|
|
17
|
+
source: "items",
|
|
18
|
+
levelName: "item",
|
|
19
|
+
columns: [
|
|
20
|
+
{ name: "name", header: "Name" },
|
|
21
|
+
{ name: "amount", header: "Amount" },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
expect(checkReportDefinition(def)).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("detects missing source reference", () => {
|
|
30
|
+
const def: ReportDefinition = {
|
|
31
|
+
name: "bad-source",
|
|
32
|
+
label: "Bad Source",
|
|
33
|
+
params: [],
|
|
34
|
+
sources: {
|
|
35
|
+
items: { query: "SELECT 1" },
|
|
36
|
+
},
|
|
37
|
+
tree: {
|
|
38
|
+
source: "nonexistent",
|
|
39
|
+
levelName: "item",
|
|
40
|
+
columns: [{ name: "name" }],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const issues = checkReportDefinition(def);
|
|
45
|
+
expect(issues).toHaveLength(1);
|
|
46
|
+
expect(issues[0].path).toBe("item");
|
|
47
|
+
expect(issues[0].message).toContain("nonexistent");
|
|
48
|
+
expect(issues[0].message).toContain("not found in sources");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("detects missing source in child node", () => {
|
|
52
|
+
const def: ReportDefinition = {
|
|
53
|
+
name: "bad-child-source",
|
|
54
|
+
label: "Bad Child Source",
|
|
55
|
+
params: [],
|
|
56
|
+
sources: {
|
|
57
|
+
parents: { query: "SELECT name FROM groups" },
|
|
58
|
+
},
|
|
59
|
+
tree: {
|
|
60
|
+
source: "parents",
|
|
61
|
+
levelName: "group",
|
|
62
|
+
columns: [{ name: "name" }],
|
|
63
|
+
children: [
|
|
64
|
+
{
|
|
65
|
+
source: "missing_source",
|
|
66
|
+
levelName: "detail",
|
|
67
|
+
columns: [{ name: "info" }],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const issues = checkReportDefinition(def);
|
|
74
|
+
expect(issues).toHaveLength(1);
|
|
75
|
+
expect(issues[0].path).toBe("group.detail");
|
|
76
|
+
expect(issues[0].message).toContain("missing_source");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("detects rollup keys not declared in columns[]", () => {
|
|
80
|
+
const def: ReportDefinition = {
|
|
81
|
+
name: "rollup-warn",
|
|
82
|
+
label: "Rollup Warn",
|
|
83
|
+
params: [],
|
|
84
|
+
sources: {
|
|
85
|
+
parents: { query: "SELECT name FROM groups" },
|
|
86
|
+
kids: { query: "SELECT name FROM items" },
|
|
87
|
+
},
|
|
88
|
+
tree: {
|
|
89
|
+
source: "parents",
|
|
90
|
+
levelName: "group",
|
|
91
|
+
columns: [{ name: "name" }],
|
|
92
|
+
rollup: (children) => ({
|
|
93
|
+
total: children.item.reduce((s) => s + 1, 0),
|
|
94
|
+
count: children.item.length,
|
|
95
|
+
}),
|
|
96
|
+
children: [
|
|
97
|
+
{
|
|
98
|
+
source: "kids",
|
|
99
|
+
levelName: "item",
|
|
100
|
+
columns: [{ name: "name" }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const issues = checkReportDefinition(def);
|
|
107
|
+
expect(issues).toHaveLength(2);
|
|
108
|
+
expect(issues[0].path).toBe("group.rollup");
|
|
109
|
+
expect(issues[0].message).toContain('"total"');
|
|
110
|
+
expect(issues[0].message).toContain('level "group"');
|
|
111
|
+
expect(issues[1].path).toBe("group.rollup");
|
|
112
|
+
expect(issues[1].message).toContain('"count"');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not warn for rollup keys that are declared", () => {
|
|
116
|
+
const def: ReportDefinition = {
|
|
117
|
+
name: "rollup-ok",
|
|
118
|
+
label: "Rollup OK",
|
|
119
|
+
params: [],
|
|
120
|
+
sources: {
|
|
121
|
+
parents: { query: "SELECT name FROM groups" },
|
|
122
|
+
kids: { query: "SELECT name FROM items" },
|
|
123
|
+
},
|
|
124
|
+
tree: {
|
|
125
|
+
source: "parents",
|
|
126
|
+
levelName: "group",
|
|
127
|
+
columns: [
|
|
128
|
+
{ name: "name" },
|
|
129
|
+
{ name: "total", header: "Total", format: "currency" },
|
|
130
|
+
],
|
|
131
|
+
rollup: (children) => ({
|
|
132
|
+
total: children.item.reduce((s) => s + 1, 0),
|
|
133
|
+
}),
|
|
134
|
+
children: [
|
|
135
|
+
{
|
|
136
|
+
source: "kids",
|
|
137
|
+
levelName: "item",
|
|
138
|
+
columns: [{ name: "name" }],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
expect(checkReportDefinition(def)).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("detects footer keys not declared in columns[]", () => {
|
|
148
|
+
const def: ReportDefinition = {
|
|
149
|
+
name: "footer-warn",
|
|
150
|
+
label: "Footer Warn",
|
|
151
|
+
params: [],
|
|
152
|
+
sources: {
|
|
153
|
+
items: { query: "SELECT name FROM items" },
|
|
154
|
+
},
|
|
155
|
+
tree: {
|
|
156
|
+
source: "items",
|
|
157
|
+
levelName: "item",
|
|
158
|
+
columns: [{ name: "name" }],
|
|
159
|
+
footer: [
|
|
160
|
+
{
|
|
161
|
+
label: "Grand Total",
|
|
162
|
+
compute: () => ({
|
|
163
|
+
grand_total: 0,
|
|
164
|
+
extra: 0,
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const issues = checkReportDefinition(def);
|
|
172
|
+
expect(issues).toHaveLength(2);
|
|
173
|
+
expect(issues[0].path).toBe('item.footer["Grand Total"]');
|
|
174
|
+
expect(issues[0].message).toContain('"grand_total"');
|
|
175
|
+
expect(issues[1].message).toContain('"extra"');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not warn for footer keys that are declared", () => {
|
|
179
|
+
const def: ReportDefinition = {
|
|
180
|
+
name: "footer-ok",
|
|
181
|
+
label: "Footer OK",
|
|
182
|
+
params: [],
|
|
183
|
+
sources: {
|
|
184
|
+
items: { query: "SELECT name, amount FROM items" },
|
|
185
|
+
},
|
|
186
|
+
tree: {
|
|
187
|
+
source: "items",
|
|
188
|
+
levelName: "item",
|
|
189
|
+
columns: [
|
|
190
|
+
{ name: "name" },
|
|
191
|
+
{ name: "amount", header: "Amount" },
|
|
192
|
+
],
|
|
193
|
+
footer: [
|
|
194
|
+
{
|
|
195
|
+
label: "Total",
|
|
196
|
+
compute: () => ({ amount: 0 }),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
expect(checkReportDefinition(def)).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("detects multiple issues across the tree", () => {
|
|
206
|
+
const def: ReportDefinition = {
|
|
207
|
+
name: "multi-issue",
|
|
208
|
+
label: "Multi Issue",
|
|
209
|
+
params: [],
|
|
210
|
+
sources: {
|
|
211
|
+
sections: { query: "SELECT section FROM sections" },
|
|
212
|
+
// "accounts" source is missing
|
|
213
|
+
},
|
|
214
|
+
tree: {
|
|
215
|
+
source: "sections",
|
|
216
|
+
levelName: "section",
|
|
217
|
+
columns: [{ name: "section" }],
|
|
218
|
+
rollup: (children) => ({
|
|
219
|
+
section_total: children.accounts?.reduce((s) => s, 0) ?? 0,
|
|
220
|
+
}),
|
|
221
|
+
footer: [
|
|
222
|
+
{
|
|
223
|
+
label: "Net",
|
|
224
|
+
compute: () => ({ section_total: 0 }),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
children: [
|
|
228
|
+
{
|
|
229
|
+
source: "accounts",
|
|
230
|
+
levelName: "accounts",
|
|
231
|
+
columns: [{ name: "name" }],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const issues = checkReportDefinition(def);
|
|
238
|
+
// Should have: rollup undeclared key, footer undeclared key, child missing source
|
|
239
|
+
expect(issues.length).toBe(3);
|
|
240
|
+
|
|
241
|
+
const rollupIssue = issues.find((i) => i.path.includes("rollup"));
|
|
242
|
+
expect(rollupIssue).toBeDefined();
|
|
243
|
+
expect(rollupIssue!.message).toContain('"section_total"');
|
|
244
|
+
|
|
245
|
+
const footerIssue = issues.find((i) => i.path.includes("footer"));
|
|
246
|
+
expect(footerIssue).toBeDefined();
|
|
247
|
+
expect(footerIssue!.message).toContain('"section_total"');
|
|
248
|
+
|
|
249
|
+
const sourceIssue = issues.find((i) => i.path === "section.accounts");
|
|
250
|
+
expect(sourceIssue).toBeDefined();
|
|
251
|
+
expect(sourceIssue!.message).toContain("accounts");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("validates the balance-sheet pattern (valid)", () => {
|
|
255
|
+
const def: ReportDefinition = {
|
|
256
|
+
name: "balance-sheet",
|
|
257
|
+
label: "Balance Sheet",
|
|
258
|
+
params: [
|
|
259
|
+
{ name: "as_of_date", type: "date", required: true, label: "As of Date" },
|
|
260
|
+
],
|
|
261
|
+
sources: {
|
|
262
|
+
sections: { query: "SELECT section FROM sections" },
|
|
263
|
+
section_accounts: { query: "SELECT name, balance FROM accounts WHERE section = $section" },
|
|
264
|
+
},
|
|
265
|
+
tree: {
|
|
266
|
+
source: "sections",
|
|
267
|
+
levelName: "section",
|
|
268
|
+
columns: [
|
|
269
|
+
{ name: "section", header: "Section" },
|
|
270
|
+
{ name: "section_total", header: "Total", format: "currency" },
|
|
271
|
+
],
|
|
272
|
+
rollup: (children) => ({
|
|
273
|
+
section_total: children.accounts.reduce(
|
|
274
|
+
(s, n) => s + Number(n.columns.balance ?? 0),
|
|
275
|
+
0,
|
|
276
|
+
),
|
|
277
|
+
}),
|
|
278
|
+
footer: [
|
|
279
|
+
{
|
|
280
|
+
label: "Total",
|
|
281
|
+
compute: (nodes) => ({
|
|
282
|
+
section_total: nodes.reduce(
|
|
283
|
+
(s, n) => s + Number(n.rollup?.section_total ?? 0),
|
|
284
|
+
0,
|
|
285
|
+
),
|
|
286
|
+
}),
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
children: [
|
|
290
|
+
{
|
|
291
|
+
source: "section_accounts",
|
|
292
|
+
levelName: "accounts",
|
|
293
|
+
columns: [
|
|
294
|
+
{ name: "name", header: "Account" },
|
|
295
|
+
{ name: "balance", header: "Balance", format: "currency" },
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
expect(checkReportDefinition(def)).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("handles rollup that throws with empty children gracefully", () => {
|
|
306
|
+
const def: ReportDefinition = {
|
|
307
|
+
name: "rollup-throws",
|
|
308
|
+
label: "Rollup Throws",
|
|
309
|
+
params: [],
|
|
310
|
+
sources: {
|
|
311
|
+
parents: { query: "SELECT name FROM groups" },
|
|
312
|
+
kids: { query: "SELECT name FROM items" },
|
|
313
|
+
},
|
|
314
|
+
tree: {
|
|
315
|
+
source: "parents",
|
|
316
|
+
levelName: "group",
|
|
317
|
+
columns: [{ name: "name" }],
|
|
318
|
+
rollup: (children) => {
|
|
319
|
+
// This will throw because children.item[0] is undefined
|
|
320
|
+
return { total: children.item[0].columns.amount as number };
|
|
321
|
+
},
|
|
322
|
+
children: [
|
|
323
|
+
{
|
|
324
|
+
source: "kids",
|
|
325
|
+
levelName: "item",
|
|
326
|
+
columns: [{ name: "name" }],
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Should not throw — just silently skip the rollup check
|
|
333
|
+
const issues = checkReportDefinition(def);
|
|
334
|
+
// Only the items that could be checked are reported; no crash
|
|
335
|
+
expect(issues).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("handles footer compute that throws with empty input gracefully", () => {
|
|
339
|
+
const def: ReportDefinition = {
|
|
340
|
+
name: "footer-throws",
|
|
341
|
+
label: "Footer Throws",
|
|
342
|
+
params: [],
|
|
343
|
+
sources: {
|
|
344
|
+
items: { query: "SELECT name FROM items" },
|
|
345
|
+
},
|
|
346
|
+
tree: {
|
|
347
|
+
source: "items",
|
|
348
|
+
levelName: "item",
|
|
349
|
+
columns: [{ name: "name" }],
|
|
350
|
+
footer: [
|
|
351
|
+
{
|
|
352
|
+
label: "Problematic",
|
|
353
|
+
compute: (nodes) => {
|
|
354
|
+
// This will throw when nodes is empty
|
|
355
|
+
return { total: nodes[0].columns.amount as number };
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Should not throw — gracefully skip
|
|
363
|
+
const issues = checkReportDefinition(def);
|
|
364
|
+
expect(issues).toEqual([]);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// DB-aware column checks (checkReportSqlColumns)
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create a CheckSql adapter from better-sqlite3. The CheckSql interface
|
|
374
|
+
* requires result arrays with a `.columns` property containing column
|
|
375
|
+
* metadata. We use .prepare().columns() to discover column names, then
|
|
376
|
+
* attach them to the result array.
|
|
377
|
+
*/
|
|
378
|
+
function createCheckSql(sqlite: Database.Database): CheckSql {
|
|
379
|
+
return {
|
|
380
|
+
unsafe: (query: string, params?: unknown[]) => {
|
|
381
|
+
const stmt = sqlite.prepare(query);
|
|
382
|
+
const rows = stmt.all(...(params ?? [])) as any;
|
|
383
|
+
// Attach column metadata matching the CheckSql contract
|
|
384
|
+
rows.columns = stmt.columns().map((c: any) => ({ name: c.name }));
|
|
385
|
+
return Promise.resolve(rows);
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
describe("checkReportSqlColumns", () => {
|
|
391
|
+
let sqlite: Database.Database;
|
|
392
|
+
let sql: CheckSql;
|
|
393
|
+
|
|
394
|
+
beforeEach(() => {
|
|
395
|
+
sqlite = new Database(":memory:");
|
|
396
|
+
sqlite.pragma("foreign_keys = ON");
|
|
397
|
+
sql = createCheckSql(sqlite);
|
|
398
|
+
sqlite.exec(`
|
|
399
|
+
CREATE TABLE items (
|
|
400
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
401
|
+
name TEXT NOT NULL,
|
|
402
|
+
amount REAL NOT NULL,
|
|
403
|
+
category TEXT
|
|
404
|
+
)
|
|
405
|
+
`);
|
|
406
|
+
sqlite.exec(`
|
|
407
|
+
CREATE TABLE sections (
|
|
408
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
409
|
+
section TEXT NOT NULL
|
|
410
|
+
)
|
|
411
|
+
`);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
afterEach(() => {
|
|
415
|
+
sqlite.close();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("no issues for valid SQL with undeclared columns (no longer flagged)", async () => {
|
|
419
|
+
const def: ReportDefinition = {
|
|
420
|
+
name: "undeclared-ok",
|
|
421
|
+
label: "Undeclared OK",
|
|
422
|
+
params: [],
|
|
423
|
+
sources: {
|
|
424
|
+
items: { query: "SELECT id, name, amount, category FROM items" },
|
|
425
|
+
},
|
|
426
|
+
tree: {
|
|
427
|
+
source: "items",
|
|
428
|
+
levelName: "item",
|
|
429
|
+
columns: [{ name: "name" }],
|
|
430
|
+
transform: (nodes) => nodes,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const issues = await checkReportSqlColumns(sql, def);
|
|
435
|
+
expect(issues).toEqual([]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("validates valid SQL passes planning", async () => {
|
|
439
|
+
const def: ReportDefinition = {
|
|
440
|
+
name: "valid-sql",
|
|
441
|
+
label: "Valid SQL",
|
|
442
|
+
params: [],
|
|
443
|
+
sources: {
|
|
444
|
+
items: { query: "SELECT name, amount FROM items" },
|
|
445
|
+
},
|
|
446
|
+
tree: {
|
|
447
|
+
source: "items",
|
|
448
|
+
levelName: "item",
|
|
449
|
+
columns: [{ name: "name" }, { name: "amount" }],
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const issues = await checkReportSqlColumns(sql, def);
|
|
454
|
+
expect(issues).toEqual([]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("catches SQL planning errors", async () => {
|
|
458
|
+
const def: ReportDefinition = {
|
|
459
|
+
name: "bad-planning",
|
|
460
|
+
label: "Bad Planning",
|
|
461
|
+
params: [],
|
|
462
|
+
sources: {
|
|
463
|
+
missing: { query: "SELECT * FROM nonexistent_table" },
|
|
464
|
+
},
|
|
465
|
+
tree: {
|
|
466
|
+
source: "missing",
|
|
467
|
+
levelName: "item",
|
|
468
|
+
columns: [{ name: "name" }],
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const issues = await checkReportSqlColumns(sql, def);
|
|
473
|
+
expect(issues).toHaveLength(1);
|
|
474
|
+
expect(issues[0].message).toContain("SQL planning failed");
|
|
475
|
+
expect(issues[0].message).toContain("nonexistent_table");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("handles bind variables (replaced with NULLs for planning)", async () => {
|
|
479
|
+
const def: ReportDefinition = {
|
|
480
|
+
name: "bind-vars",
|
|
481
|
+
label: "Bind Vars",
|
|
482
|
+
params: [],
|
|
483
|
+
sources: {
|
|
484
|
+
items: {
|
|
485
|
+
query: "SELECT name, amount FROM items WHERE category = $cat AND amount > $min",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
tree: {
|
|
489
|
+
source: "items",
|
|
490
|
+
levelName: "item",
|
|
491
|
+
columns: [{ name: "name" }],
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Valid SQL with bind variables should pass planning
|
|
496
|
+
const issues = await checkReportSqlColumns(sql, def);
|
|
497
|
+
expect(issues).toEqual([]);
|
|
498
|
+
});
|
|
499
|
+
});
|