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