@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,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the UI-managed tables system (Plan 1b).
|
|
3
|
+
*
|
|
4
|
+
* Tests the full lifecycle: dynamic table builder, metadata tables bootstrap,
|
|
5
|
+
* schema mutation API, schema API extensions, and boot sequence integration.
|
|
6
|
+
*
|
|
7
|
+
* Uses in-memory SQLite — no external DB needed.
|
|
8
|
+
* We use the raw better-sqlite3 handle for DDL; ORM queries go through
|
|
9
|
+
* Drizzle's BetterSQLite3Database.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import {
|
|
14
|
+
sqliteTable,
|
|
15
|
+
text,
|
|
16
|
+
integer,
|
|
17
|
+
getTableConfig,
|
|
18
|
+
} from "drizzle-orm/sqlite-core";
|
|
19
|
+
import { table } from "./table.js";
|
|
20
|
+
import { SchemaRegistry } from "./registry.js";
|
|
21
|
+
import { extractSchemas } from "./extract.js";
|
|
22
|
+
import { crud } from "../data/crud.js";
|
|
23
|
+
import {
|
|
24
|
+
buildDrizzleTable,
|
|
25
|
+
planTable,
|
|
26
|
+
generateCreateTableDDL,
|
|
27
|
+
generateAddColumnDDL,
|
|
28
|
+
type ColumnMetaRow,
|
|
29
|
+
} from "./dynamic-builder.js";
|
|
30
|
+
import { validateTableName, validateColumnName } from "./reserved.js";
|
|
31
|
+
import { createTestDb } from "../testing/test-utils.js";
|
|
32
|
+
import { ensureMetadataTables, loadUISchemas } from "./metadata-tables.js";
|
|
33
|
+
import Database from "better-sqlite3";
|
|
34
|
+
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
// 1. Dynamic Table Builder
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
describe("buildDrizzleTable", () => {
|
|
40
|
+
let registry: SchemaRegistry;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
registry = new SchemaRegistry();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("creates a table with system columns (id, created_at, updated_at)", () => {
|
|
47
|
+
const def = buildDrizzleTable(
|
|
48
|
+
{ name: "expenses" },
|
|
49
|
+
[],
|
|
50
|
+
registry,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(def.sqlName).toBe("expenses");
|
|
54
|
+
const config = getTableConfig(def.drizzle);
|
|
55
|
+
expect(config.name).toBe("expenses");
|
|
56
|
+
|
|
57
|
+
// System columns are always present
|
|
58
|
+
const colNames = config.columns.map((c) => c.name);
|
|
59
|
+
expect(colNames).toContain("id");
|
|
60
|
+
expect(colNames).toContain("created_at");
|
|
61
|
+
expect(colNames).toContain("updated_at");
|
|
62
|
+
|
|
63
|
+
// id is primary key
|
|
64
|
+
const idCol = config.columns.find((c) => c.name === "id")!;
|
|
65
|
+
expect(idCol.primary).toBe(true);
|
|
66
|
+
expect(idCol.hasDefault).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("adds user-defined columns from metadata", () => {
|
|
70
|
+
const columns: ColumnMetaRow[] = [
|
|
71
|
+
{ column_name: "description", column_type: "text", position: 0, not_null: true },
|
|
72
|
+
{ column_name: "amount", column_type: "numeric", position: 1 },
|
|
73
|
+
{ column_name: "is_active", column_type: "boolean", position: 2, default_value: "true" },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
|
|
77
|
+
const config = getTableConfig(def.drizzle);
|
|
78
|
+
|
|
79
|
+
const descCol = config.columns.find((c) => c.name === "description")!;
|
|
80
|
+
expect(descCol.notNull).toBe(true);
|
|
81
|
+
expect(descCol.dataType).toBe("string"); // text → string in Drizzle
|
|
82
|
+
|
|
83
|
+
const amountCol = config.columns.find((c) => c.name === "amount")!;
|
|
84
|
+
expect(amountCol.dataType).toBe("number"); // numeric → real() → number in Drizzle SQLite
|
|
85
|
+
|
|
86
|
+
const activeCol = config.columns.find((c) => c.name === "is_active")!;
|
|
87
|
+
expect(activeCol.dataType).toBe("boolean");
|
|
88
|
+
expect(activeCol.hasDefault).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("maps all column types to correct Drizzle builders", () => {
|
|
92
|
+
const columns: ColumnMetaRow[] = [
|
|
93
|
+
{ column_name: "col_text", column_type: "text", position: 0 },
|
|
94
|
+
{ column_name: "col_integer", column_type: "integer", position: 1 },
|
|
95
|
+
{ column_name: "col_numeric", column_type: "numeric", position: 2 },
|
|
96
|
+
{ column_name: "col_boolean", column_type: "boolean", position: 3 },
|
|
97
|
+
{ column_name: "col_date", column_type: "date", position: 4 },
|
|
98
|
+
{ column_name: "col_timestamp", column_type: "timestamp", position: 5 },
|
|
99
|
+
{ column_name: "col_select", column_type: "select", position: 6, select_options: ["a", "b"] },
|
|
100
|
+
{ column_name: "col_currency", column_type: "currency", position: 7 },
|
|
101
|
+
{ column_name: "col_percentage", column_type: "percentage", position: 8 },
|
|
102
|
+
{ column_name: "col_email", column_type: "email", position: 9 },
|
|
103
|
+
{ column_name: "col_url", column_type: "url", position: 10 },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const def = buildDrizzleTable({ name: "test_types" }, columns, registry);
|
|
107
|
+
const config = getTableConfig(def.drizzle);
|
|
108
|
+
|
|
109
|
+
// All columns should exist (11 user + 3 system)
|
|
110
|
+
expect(config.columns).toHaveLength(14);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("applies UNIQUE constraint", () => {
|
|
114
|
+
const columns: ColumnMetaRow[] = [
|
|
115
|
+
{ column_name: "email", column_type: "email", position: 0, is_unique: true },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const def = buildDrizzleTable({ name: "users" }, columns, registry);
|
|
119
|
+
const config = getTableConfig(def.drizzle);
|
|
120
|
+
const emailCol = config.columns.find((c) => c.name === "email")!;
|
|
121
|
+
expect(emailCol.isUnique).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("creates FK references for link columns when target exists in registry", () => {
|
|
125
|
+
// Register a target table first
|
|
126
|
+
const categoriesTable = sqliteTable("categories", {
|
|
127
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
128
|
+
name: text("name").notNull(),
|
|
129
|
+
});
|
|
130
|
+
const catDef = table({ drizzle: categoriesTable, meta: { label: "Categories" } });
|
|
131
|
+
registry.register(catDef, "file");
|
|
132
|
+
|
|
133
|
+
// Build a table with a link column to categories
|
|
134
|
+
const columns: ColumnMetaRow[] = [
|
|
135
|
+
{ column_name: "category_id", column_type: "link", position: 0, references_table: "categories" },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
|
|
139
|
+
const config = getTableConfig(def.drizzle);
|
|
140
|
+
|
|
141
|
+
// Should have FK metadata
|
|
142
|
+
expect(config.foreignKeys).toHaveLength(1);
|
|
143
|
+
const fkRef = config.foreignKeys[0].reference();
|
|
144
|
+
const targetConfig = getTableConfig(fkRef.foreignColumns[0].table);
|
|
145
|
+
expect(targetConfig.name).toBe("categories");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("skips FK reference when target is not in registry (dangling link)", () => {
|
|
149
|
+
const columns: ColumnMetaRow[] = [
|
|
150
|
+
{ column_name: "category_id", column_type: "link", position: 0, references_table: "nonexistent" },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const def = buildDrizzleTable({ name: "expenses" }, columns, registry);
|
|
154
|
+
const config = getTableConfig(def.drizzle);
|
|
155
|
+
|
|
156
|
+
// Column exists as integer but without FK
|
|
157
|
+
const col = config.columns.find((c) => c.name === "category_id")!;
|
|
158
|
+
expect(col.dataType).toBe("number");
|
|
159
|
+
expect(config.foreignKeys).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("builds SapportaMeta with label, displayColumn, and selects", () => {
|
|
163
|
+
const columns: ColumnMetaRow[] = [
|
|
164
|
+
{ column_name: "status", column_type: "select", position: 0, select_options: ["open", "closed"] },
|
|
165
|
+
{ column_name: "total", column_type: "currency", position: 1 },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const def = buildDrizzleTable(
|
|
169
|
+
{ name: "orders", label: "Orders", display_column: "status" },
|
|
170
|
+
columns,
|
|
171
|
+
registry,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(def.meta.label).toBe("Orders");
|
|
175
|
+
expect(def.meta.displayColumn).toBe("status");
|
|
176
|
+
expect(def.meta.selects).toHaveLength(1);
|
|
177
|
+
expect(def.meta.selects![0].column).toBe("status");
|
|
178
|
+
expect(def.meta.selects![0].options).toEqual(["open", "closed"]);
|
|
179
|
+
|
|
180
|
+
// Currency columns get "money" type for formatting
|
|
181
|
+
expect(def.meta.columns?.total?.type).toBe("money");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("builds SapportaMeta with immutable flag", () => {
|
|
185
|
+
const def = buildDrizzleTable(
|
|
186
|
+
{ name: "audit_log", immutable: true },
|
|
187
|
+
[],
|
|
188
|
+
registry,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(def.meta.immutable).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
196
|
+
// 1b. planTable (pure layer)
|
|
197
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
198
|
+
|
|
199
|
+
describe("planTable", () => {
|
|
200
|
+
it("empty columns → empty spec", () => {
|
|
201
|
+
const spec = planTable({ name: "empty" }, []);
|
|
202
|
+
expect(spec.sqlName).toBe("empty");
|
|
203
|
+
expect(spec.columns).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("maps column metadata to correct ColumnSpec fields", () => {
|
|
207
|
+
const columns: ColumnMetaRow[] = [
|
|
208
|
+
{ column_name: "title", column_type: "text", position: 0, not_null: true, is_unique: true, default_value: "untitled" },
|
|
209
|
+
{ column_name: "amount", column_type: "numeric", position: 1 },
|
|
210
|
+
];
|
|
211
|
+
const spec = planTable({ name: "items" }, columns);
|
|
212
|
+
|
|
213
|
+
expect(spec.columns).toHaveLength(2);
|
|
214
|
+
|
|
215
|
+
const title = spec.columns[0];
|
|
216
|
+
expect(title.name).toBe("title");
|
|
217
|
+
expect(title.builderType).toBe("text");
|
|
218
|
+
expect(title.notNull).toBe(true);
|
|
219
|
+
expect(title.isUnique).toBe(true);
|
|
220
|
+
expect(title.defaultValue).toBe("untitled");
|
|
221
|
+
expect(title.fkTarget).toBeNull();
|
|
222
|
+
|
|
223
|
+
const amount = spec.columns[1];
|
|
224
|
+
expect(amount.name).toBe("amount");
|
|
225
|
+
expect(amount.builderType).toBe("numeric");
|
|
226
|
+
expect(amount.notNull).toBe(false);
|
|
227
|
+
expect(amount.isUnique).toBe(false);
|
|
228
|
+
expect(amount.defaultValue).toBeUndefined();
|
|
229
|
+
expect(amount.fkTarget).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("link column → fkTarget set to table name", () => {
|
|
233
|
+
const columns: ColumnMetaRow[] = [
|
|
234
|
+
{ column_name: "category_id", column_type: "link", position: 0, references_table: "categories" },
|
|
235
|
+
];
|
|
236
|
+
const spec = planTable({ name: "expenses" }, columns);
|
|
237
|
+
|
|
238
|
+
expect(spec.columns[0].builderType).toBe("link");
|
|
239
|
+
expect(spec.columns[0].fkTarget).toBe("categories");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("unknown column type → skipped", () => {
|
|
243
|
+
const columns: ColumnMetaRow[] = [
|
|
244
|
+
{ column_name: "good", column_type: "text", position: 0 },
|
|
245
|
+
{ column_name: "bad", column_type: "imaginary_type", position: 1 },
|
|
246
|
+
];
|
|
247
|
+
const spec = planTable({ name: "test" }, columns);
|
|
248
|
+
|
|
249
|
+
expect(spec.columns).toHaveLength(1);
|
|
250
|
+
expect(spec.columns[0].name).toBe("good");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("builds SapportaMeta with selects, currency→money, label, displayColumn, immutable, inferred", () => {
|
|
254
|
+
const columns: ColumnMetaRow[] = [
|
|
255
|
+
{ column_name: "status", column_type: "select", position: 0, select_options: ["active", "inactive"] },
|
|
256
|
+
{ column_name: "total", column_type: "currency", position: 1 },
|
|
257
|
+
];
|
|
258
|
+
const spec = planTable(
|
|
259
|
+
{ name: "orders", label: "Orders", display_column: "status", immutable: true, inferred: true },
|
|
260
|
+
columns,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(spec.meta.label).toBe("Orders");
|
|
264
|
+
expect(spec.meta.displayColumn).toBe("status");
|
|
265
|
+
expect(spec.meta.immutable).toBe(true);
|
|
266
|
+
expect(spec.meta.inferred).toBe(true);
|
|
267
|
+
expect(spec.meta.selects).toHaveLength(1);
|
|
268
|
+
expect(spec.meta.selects![0]).toEqual({ type: "select", column: "status", options: ["active", "inactive"] });
|
|
269
|
+
expect(spec.meta.columns?.total?.type).toBe("money");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("includes ui_hints in column metadata", () => {
|
|
273
|
+
const columns: ColumnMetaRow[] = [
|
|
274
|
+
{ column_name: "notes", column_type: "text", position: 0, ui_hints: { header: "Notes", hidden: true, width: 200 } },
|
|
275
|
+
];
|
|
276
|
+
const spec = planTable({ name: "tasks" }, columns);
|
|
277
|
+
|
|
278
|
+
expect(spec.meta.columns?.notes?.header).toBe("Notes");
|
|
279
|
+
expect(spec.meta.columns?.notes?.hidden).toBe(true);
|
|
280
|
+
expect(spec.meta.columns?.notes?.width).toBe(200);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
285
|
+
// 3. Dynamic Table Builder + SQLite Integration
|
|
286
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
287
|
+
|
|
288
|
+
describe("buildDrizzleTable + SQLite integration", () => {
|
|
289
|
+
let db: any;
|
|
290
|
+
let sqlite: any;
|
|
291
|
+
let registry: SchemaRegistry;
|
|
292
|
+
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
({ db, sqlite } = createTestDb());
|
|
295
|
+
registry = new SchemaRegistry();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("creates table via DDL, inserts data via Drizzle, queries back", async () => {
|
|
299
|
+
// Create the table using generated DDL
|
|
300
|
+
const ddl = generateCreateTableDDL("expenses");
|
|
301
|
+
sqlite.exec(ddl);
|
|
302
|
+
|
|
303
|
+
for (const stmt of generateAddColumnDDL("expenses", {
|
|
304
|
+
column_name: "description",
|
|
305
|
+
column_type: "text",
|
|
306
|
+
position: 0,
|
|
307
|
+
not_null: true,
|
|
308
|
+
})) sqlite.exec(stmt);
|
|
309
|
+
|
|
310
|
+
for (const stmt of generateAddColumnDDL("expenses", {
|
|
311
|
+
column_name: "amount",
|
|
312
|
+
column_type: "numeric",
|
|
313
|
+
position: 1,
|
|
314
|
+
})) sqlite.exec(stmt);
|
|
315
|
+
|
|
316
|
+
// Build the dynamic table
|
|
317
|
+
const def = buildDrizzleTable(
|
|
318
|
+
{ name: "expenses", label: "Expenses" },
|
|
319
|
+
[
|
|
320
|
+
{ column_name: "description", column_type: "text", position: 0, not_null: true },
|
|
321
|
+
{ column_name: "amount", column_type: "numeric", position: 1 },
|
|
322
|
+
],
|
|
323
|
+
registry,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Insert via Drizzle
|
|
327
|
+
await db.insert(def.drizzle as any).values({ description: "Lunch", amount: "15.50" });
|
|
328
|
+
const rows = await db.select().from(def.drizzle as any);
|
|
329
|
+
|
|
330
|
+
expect(rows).toHaveLength(1);
|
|
331
|
+
expect(rows[0].description).toBe("Lunch");
|
|
332
|
+
// SQLite REAL stores as a float — "15.50" becomes 15.5
|
|
333
|
+
expect(rows[0].amount).toBe(15.5);
|
|
334
|
+
expect(rows[0].id).toBe(1);
|
|
335
|
+
expect(typeof rows[0].created_at).toBe("string");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("CRUD sub-app works with dynamically built table", async () => {
|
|
339
|
+
sqlite.exec(generateCreateTableDDL("tasks"));
|
|
340
|
+
for (const stmt of generateAddColumnDDL("tasks", {
|
|
341
|
+
column_name: "title",
|
|
342
|
+
column_type: "text",
|
|
343
|
+
position: 0,
|
|
344
|
+
not_null: true,
|
|
345
|
+
})) sqlite.exec(stmt);
|
|
346
|
+
|
|
347
|
+
const def = buildDrizzleTable(
|
|
348
|
+
{ name: "tasks" },
|
|
349
|
+
[{ column_name: "title", column_type: "text", position: 0, not_null: true }],
|
|
350
|
+
registry,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const app = new Hono();
|
|
354
|
+
app.route("/tasks", crud(def, db));
|
|
355
|
+
|
|
356
|
+
// POST
|
|
357
|
+
const createRes = await app.request("/tasks", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "Content-Type": "application/json" },
|
|
360
|
+
body: JSON.stringify({ title: "Buy groceries" }),
|
|
361
|
+
});
|
|
362
|
+
expect(createRes.status).toBe(201);
|
|
363
|
+
const created = await createRes.json();
|
|
364
|
+
expect(created.data.title).toBe("Buy groceries");
|
|
365
|
+
|
|
366
|
+
// GET
|
|
367
|
+
const listRes = await app.request("/tasks");
|
|
368
|
+
const list = await listRes.json();
|
|
369
|
+
expect(list.data).toHaveLength(1);
|
|
370
|
+
|
|
371
|
+
// GET /:id
|
|
372
|
+
const getRes = await app.request("/tasks/1");
|
|
373
|
+
const record = await getRes.json();
|
|
374
|
+
expect(record.data.title).toBe("Buy groceries");
|
|
375
|
+
|
|
376
|
+
// PUT
|
|
377
|
+
const updateRes = await app.request("/tasks/1", {
|
|
378
|
+
method: "PUT",
|
|
379
|
+
headers: { "Content-Type": "application/json" },
|
|
380
|
+
body: JSON.stringify({ title: "Updated title" }),
|
|
381
|
+
});
|
|
382
|
+
expect(updateRes.status).toBe(200);
|
|
383
|
+
const updated = await updateRes.json();
|
|
384
|
+
expect(updated.data.title).toBe("Updated title");
|
|
385
|
+
|
|
386
|
+
// DELETE
|
|
387
|
+
const deleteRes = await app.request("/tasks/1", { method: "DELETE" });
|
|
388
|
+
expect(deleteRes.status).toBe(200);
|
|
389
|
+
|
|
390
|
+
const check = await app.request("/tasks/1");
|
|
391
|
+
expect(check.status).toBe(404);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("extractSchemas works with dynamically built tables", () => {
|
|
395
|
+
const def = buildDrizzleTable(
|
|
396
|
+
{ name: "expenses", label: "Expenses" },
|
|
397
|
+
[
|
|
398
|
+
{ column_name: "description", column_type: "text", position: 0, not_null: true },
|
|
399
|
+
{ column_name: "status", column_type: "select", position: 1, select_options: ["open", "closed"] },
|
|
400
|
+
{ column_name: "amount", column_type: "currency", position: 2 },
|
|
401
|
+
],
|
|
402
|
+
registry,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const schemas = extractSchemas([def]);
|
|
406
|
+
expect(schemas).toHaveLength(1);
|
|
407
|
+
|
|
408
|
+
const s = schemas[0];
|
|
409
|
+
expect(s.name).toBe("expenses");
|
|
410
|
+
expect(s.label).toBe("Expenses");
|
|
411
|
+
expect(s.source).toBe("file"); // Default when using plain array
|
|
412
|
+
|
|
413
|
+
// Select column
|
|
414
|
+
const statusCol = s.columns.find((c) => c.name === "status")!;
|
|
415
|
+
expect(statusCol.select).toEqual({ options: ["open", "closed"] });
|
|
416
|
+
expect(statusCol.selectOptions).toEqual(["open", "closed"]);
|
|
417
|
+
|
|
418
|
+
// Currency column
|
|
419
|
+
const amountCol = s.columns.find((c) => c.name === "amount")!;
|
|
420
|
+
expect(amountCol.money).toBe(true);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
425
|
+
// 4. Schema API Extensions (source, shadowedByFile, displayColumn)
|
|
426
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
427
|
+
|
|
428
|
+
describe("extractSchemas with registry (source + shadow tracking)", () => {
|
|
429
|
+
it("includes source: 'file' for file-managed tables", () => {
|
|
430
|
+
const registry = new SchemaRegistry();
|
|
431
|
+
const drizzleTable = sqliteTable("accounts", {
|
|
432
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
433
|
+
name: text("name").notNull(),
|
|
434
|
+
});
|
|
435
|
+
const def = table({ drizzle: drizzleTable, meta: { label: "Accounts" } });
|
|
436
|
+
registry.register(def, "file");
|
|
437
|
+
|
|
438
|
+
const schemas = extractSchemas(registry);
|
|
439
|
+
expect(schemas[0].source).toBe("file");
|
|
440
|
+
expect(schemas[0].shadowedByFile).toBeUndefined();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("includes source: 'ui' for UI-managed tables", () => {
|
|
444
|
+
const registry = new SchemaRegistry();
|
|
445
|
+
const def = buildDrizzleTable(
|
|
446
|
+
{ name: "expenses", label: "Expenses" },
|
|
447
|
+
[],
|
|
448
|
+
registry,
|
|
449
|
+
);
|
|
450
|
+
registry.register(def, "ui");
|
|
451
|
+
|
|
452
|
+
const schemas = extractSchemas(registry);
|
|
453
|
+
expect(schemas[0].source).toBe("ui");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("marks shadowed tables", () => {
|
|
457
|
+
const registry = new SchemaRegistry();
|
|
458
|
+
|
|
459
|
+
// Register a file-managed table
|
|
460
|
+
const drizzleTable = sqliteTable("accounts", {
|
|
461
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
462
|
+
name: text("name").notNull(),
|
|
463
|
+
});
|
|
464
|
+
const fileDef = table({ drizzle: drizzleTable, meta: { label: "Accounts" } });
|
|
465
|
+
registry.register(fileDef, "file");
|
|
466
|
+
|
|
467
|
+
// Try to register a UI-managed table with the same name — it gets shadowed
|
|
468
|
+
const uiDef = buildDrizzleTable({ name: "accounts", label: "Accounts UI" }, [], registry);
|
|
469
|
+
registry.register(uiDef, "ui");
|
|
470
|
+
|
|
471
|
+
// The file version should be active, but isShadowed returns true
|
|
472
|
+
// because a UI definition exists for this name
|
|
473
|
+
expect(registry.isShadowed("accounts")).toBe(true);
|
|
474
|
+
|
|
475
|
+
const schemas = extractSchemas(registry);
|
|
476
|
+
expect(schemas).toHaveLength(1); // Only one active entry
|
|
477
|
+
expect(schemas[0].source).toBe("file"); // File wins
|
|
478
|
+
expect(schemas[0].shadowedByFile).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("includes displayColumn from meta", () => {
|
|
482
|
+
const registry = new SchemaRegistry();
|
|
483
|
+
const drizzleTable = sqliteTable("accounts", {
|
|
484
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
485
|
+
name: text("name").notNull(),
|
|
486
|
+
});
|
|
487
|
+
const def = table({
|
|
488
|
+
drizzle: drizzleTable,
|
|
489
|
+
meta: { label: "Accounts", displayColumn: "name" },
|
|
490
|
+
});
|
|
491
|
+
registry.register(def, "file");
|
|
492
|
+
|
|
493
|
+
const schemas = extractSchemas(registry);
|
|
494
|
+
expect(schemas[0].displayColumn).toBe("name");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
499
|
+
// 5. ensureMetadataTables — DDL bootstrap and migration
|
|
500
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
501
|
+
|
|
502
|
+
describe("ensureMetadataTables", () => {
|
|
503
|
+
it("creates both metadata tables from scratch", () => {
|
|
504
|
+
const sqlite = new Database(":memory:");
|
|
505
|
+
sqlite.pragma("foreign_keys = ON");
|
|
506
|
+
|
|
507
|
+
ensureMetadataTables(sqlite);
|
|
508
|
+
|
|
509
|
+
const tables = sqlite
|
|
510
|
+
.prepare(
|
|
511
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '_sapporta_%'`,
|
|
512
|
+
)
|
|
513
|
+
.all() as { name: string }[];
|
|
514
|
+
|
|
515
|
+
const names = tables.map((t) => t.name).sort();
|
|
516
|
+
expect(names).toEqual(["_sapporta_columns", "_sapporta_tables"]);
|
|
517
|
+
sqlite.close();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("is idempotent — calling twice does not throw", () => {
|
|
521
|
+
const sqlite = new Database(":memory:");
|
|
522
|
+
sqlite.pragma("foreign_keys = ON");
|
|
523
|
+
|
|
524
|
+
ensureMetadataTables(sqlite);
|
|
525
|
+
expect(() => ensureMetadataTables(sqlite)).not.toThrow();
|
|
526
|
+
sqlite.close();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("migrates old schema: adds inferred column if missing", () => {
|
|
530
|
+
// Simulate a database from before the inferred column was added.
|
|
531
|
+
const sqlite = new Database(":memory:");
|
|
532
|
+
sqlite.pragma("foreign_keys = ON");
|
|
533
|
+
|
|
534
|
+
// Create _sapporta_tables WITHOUT the inferred column (old schema)
|
|
535
|
+
sqlite.exec(`
|
|
536
|
+
CREATE TABLE _sapporta_tables (
|
|
537
|
+
name TEXT PRIMARY KEY,
|
|
538
|
+
label TEXT,
|
|
539
|
+
display_column TEXT,
|
|
540
|
+
immutable INTEGER NOT NULL DEFAULT 0,
|
|
541
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
542
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
543
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
544
|
+
)
|
|
545
|
+
`);
|
|
546
|
+
sqlite.exec(`
|
|
547
|
+
CREATE TABLE _sapporta_columns (
|
|
548
|
+
table_name TEXT NOT NULL REFERENCES _sapporta_tables(name) ON DELETE CASCADE,
|
|
549
|
+
column_name TEXT NOT NULL,
|
|
550
|
+
column_type TEXT NOT NULL DEFAULT 'text',
|
|
551
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
552
|
+
not_null INTEGER NOT NULL DEFAULT 0,
|
|
553
|
+
is_unique INTEGER NOT NULL DEFAULT 0,
|
|
554
|
+
default_value TEXT,
|
|
555
|
+
references_table TEXT,
|
|
556
|
+
select_options TEXT,
|
|
557
|
+
ui_hints TEXT NOT NULL DEFAULT '{}',
|
|
558
|
+
PRIMARY KEY (table_name, column_name)
|
|
559
|
+
)
|
|
560
|
+
`);
|
|
561
|
+
|
|
562
|
+
// Verify inferred column does NOT exist yet
|
|
563
|
+
const colsBefore = sqlite.pragma('table_info("_sapporta_tables")') as { name: string }[];
|
|
564
|
+
expect(colsBefore.map((c) => c.name)).not.toContain("inferred");
|
|
565
|
+
|
|
566
|
+
// Run ensureMetadataTables — should migrate without error
|
|
567
|
+
ensureMetadataTables(sqlite);
|
|
568
|
+
|
|
569
|
+
// Verify inferred column now exists
|
|
570
|
+
const colsAfter = sqlite.pragma('table_info("_sapporta_tables")') as { name: string }[];
|
|
571
|
+
expect(colsAfter.map((c) => c.name)).toContain("inferred");
|
|
572
|
+
|
|
573
|
+
// Verify default value works: inserting a row without inferred should default to 0
|
|
574
|
+
sqlite.prepare(`INSERT INTO _sapporta_tables (name) VALUES (?)`).run("test");
|
|
575
|
+
const row = sqlite.prepare(`SELECT inferred FROM _sapporta_tables WHERE name = ?`).get("test") as { inferred: number };
|
|
576
|
+
expect(row.inferred).toBe(0);
|
|
577
|
+
|
|
578
|
+
sqlite.close();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("cascades column deletes when table row is deleted", () => {
|
|
582
|
+
const sqlite = new Database(":memory:");
|
|
583
|
+
sqlite.pragma("foreign_keys = ON");
|
|
584
|
+
ensureMetadataTables(sqlite);
|
|
585
|
+
|
|
586
|
+
sqlite.prepare(`INSERT INTO _sapporta_tables (name, label) VALUES (?, ?)`).run("test_table", "Test");
|
|
587
|
+
sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type) VALUES (?, ?, ?)`).run("test_table", "col1", "text");
|
|
588
|
+
|
|
589
|
+
sqlite.prepare(`DELETE FROM _sapporta_tables WHERE name = ?`).run("test_table");
|
|
590
|
+
|
|
591
|
+
const cols = sqlite.prepare(`SELECT * FROM _sapporta_columns WHERE table_name = ?`).all("test_table");
|
|
592
|
+
expect(cols).toHaveLength(0);
|
|
593
|
+
|
|
594
|
+
sqlite.close();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
599
|
+
// 6. loadUISchemas — reading metadata into live TableDefs
|
|
600
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
601
|
+
|
|
602
|
+
describe("loadUISchemas", () => {
|
|
603
|
+
it("returns empty array for fresh database", () => {
|
|
604
|
+
const sqlite = new Database(":memory:");
|
|
605
|
+
sqlite.pragma("foreign_keys = ON");
|
|
606
|
+
ensureMetadataTables(sqlite);
|
|
607
|
+
|
|
608
|
+
const registry = new SchemaRegistry();
|
|
609
|
+
const defs = loadUISchemas(sqlite, registry);
|
|
610
|
+
expect(defs).toEqual([]);
|
|
611
|
+
|
|
612
|
+
sqlite.close();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("loads a table with columns into live TableDefs", () => {
|
|
616
|
+
const sqlite = new Database(":memory:");
|
|
617
|
+
sqlite.pragma("foreign_keys = ON");
|
|
618
|
+
ensureMetadataTables(sqlite);
|
|
619
|
+
|
|
620
|
+
sqlite.prepare(`INSERT INTO _sapporta_tables (name, label, position) VALUES (?, ?, ?)`).run("contacts", "Contacts", 0);
|
|
621
|
+
sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type, position, not_null) VALUES (?, ?, ?, ?, ?)`).run("contacts", "name", "text", 0, 1);
|
|
622
|
+
sqlite.prepare(`INSERT INTO _sapporta_columns (table_name, column_name, column_type, position) VALUES (?, ?, ?, ?)`).run("contacts", "email", "email", 1);
|
|
623
|
+
|
|
624
|
+
const registry = new SchemaRegistry();
|
|
625
|
+
const defs = loadUISchemas(sqlite, registry);
|
|
626
|
+
|
|
627
|
+
expect(defs).toHaveLength(1);
|
|
628
|
+
expect(defs[0].sqlName).toBe("contacts");
|
|
629
|
+
expect(defs[0].meta.label).toBe("Contacts");
|
|
630
|
+
|
|
631
|
+
sqlite.close();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("deserializes JSON select_options and ui_hints", () => {
|
|
635
|
+
const sqlite = new Database(":memory:");
|
|
636
|
+
sqlite.pragma("foreign_keys = ON");
|
|
637
|
+
ensureMetadataTables(sqlite);
|
|
638
|
+
|
|
639
|
+
sqlite.prepare(`INSERT INTO _sapporta_tables (name) VALUES (?)`).run("items");
|
|
640
|
+
sqlite.prepare(
|
|
641
|
+
`INSERT INTO _sapporta_columns (table_name, column_name, column_type, select_options, ui_hints) VALUES (?, ?, ?, ?, ?)`,
|
|
642
|
+
).run("items", "status", "select", JSON.stringify(["active", "inactive"]), JSON.stringify({ width: 40 }));
|
|
643
|
+
|
|
644
|
+
const registry = new SchemaRegistry();
|
|
645
|
+
const defs = loadUISchemas(sqlite, registry);
|
|
646
|
+
|
|
647
|
+
expect(defs[0].meta.selects).toBeDefined();
|
|
648
|
+
expect(defs[0].meta.selects![0].options).toEqual(["active", "inactive"]);
|
|
649
|
+
expect(defs[0].meta.columns?.status?.width).toBe(40);
|
|
650
|
+
|
|
651
|
+
sqlite.close();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("converts immutable and inferred flags (SQLite 0/1 → boolean)", () => {
|
|
655
|
+
const sqlite = new Database(":memory:");
|
|
656
|
+
sqlite.pragma("foreign_keys = ON");
|
|
657
|
+
ensureMetadataTables(sqlite);
|
|
658
|
+
|
|
659
|
+
sqlite.prepare(`INSERT INTO _sapporta_tables (name, immutable, inferred) VALUES (?, ?, ?)`).run("audit_log", 1, 1);
|
|
660
|
+
|
|
661
|
+
const registry = new SchemaRegistry();
|
|
662
|
+
const defs = loadUISchemas(sqlite, registry);
|
|
663
|
+
|
|
664
|
+
expect(defs[0].meta.immutable).toBe(true);
|
|
665
|
+
expect(defs[0].meta.inferred).toBe(true);
|
|
666
|
+
|
|
667
|
+
sqlite.close();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe("file vs UI table shadowing", () => {
|
|
672
|
+
it("file-managed tables shadow UI-managed tables", async () => {
|
|
673
|
+
const registry = new SchemaRegistry();
|
|
674
|
+
|
|
675
|
+
// Register a file-managed table first
|
|
676
|
+
const drizzleTable = sqliteTable("accounts", {
|
|
677
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
678
|
+
name: text("name").notNull(),
|
|
679
|
+
balance: integer("balance"),
|
|
680
|
+
});
|
|
681
|
+
const fileDef = table({ drizzle: drizzleTable, meta: { label: "Accounts (File)" } });
|
|
682
|
+
registry.register(fileDef, "file");
|
|
683
|
+
|
|
684
|
+
// Now register a UI-managed table with the same name
|
|
685
|
+
const uiDef = buildDrizzleTable(
|
|
686
|
+
{ name: "accounts", label: "Accounts (UI)" },
|
|
687
|
+
[{ column_name: "description", column_type: "text", position: 0 }],
|
|
688
|
+
registry,
|
|
689
|
+
);
|
|
690
|
+
registry.register(uiDef, "ui");
|
|
691
|
+
|
|
692
|
+
// File should win
|
|
693
|
+
expect(registry.all()).toHaveLength(1);
|
|
694
|
+
expect(registry.get("accounts")?.source).toBe("file");
|
|
695
|
+
expect(registry.get("accounts")?.def.meta.label).toBe("Accounts (File)");
|
|
696
|
+
|
|
697
|
+
// UI def should be in the shadow map
|
|
698
|
+
expect(registry.isShadowed("accounts")).toBe(true);
|
|
699
|
+
expect(registry.shadowedEntries().get("accounts")?.meta.label).toBe("Accounts (UI)");
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
704
|
+
// 6. Validation Helpers
|
|
705
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
706
|
+
|
|
707
|
+
describe("validateTableName / validateColumnName", () => {
|
|
708
|
+
|
|
709
|
+
it("accepts valid table names", () => {
|
|
710
|
+
expect(validateTableName("expenses").valid).toBe(true);
|
|
711
|
+
expect(validateTableName("order_items").valid).toBe(true);
|
|
712
|
+
expect(validateTableName("a123").valid).toBe(true);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("rejects invalid table names", () => {
|
|
716
|
+
expect(validateTableName("123abc").valid).toBe(false); // starts with number
|
|
717
|
+
expect(validateTableName("My Table").valid).toBe(false); // spaces
|
|
718
|
+
expect(validateTableName("UPPER").valid).toBe(false); // uppercase
|
|
719
|
+
expect(validateTableName("").valid).toBe(false); // empty
|
|
720
|
+
expect(validateTableName("_sapporta_meta").valid).toBe(false); // reserved prefix
|
|
721
|
+
expect(validateTableName("_schema").valid).toBe(false); // reserved name
|
|
722
|
+
expect(validateTableName("_meta").valid).toBe(false); // reserved name
|
|
723
|
+
expect(validateTableName("select").valid).toBe(false); // PG reserved word
|
|
724
|
+
expect(validateTableName("table").valid).toBe(false); // PG reserved word
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("accepts valid column names", () => {
|
|
728
|
+
expect(validateColumnName("amount").valid).toBe(true);
|
|
729
|
+
expect(validateColumnName("first_name").valid).toBe(true);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("rejects auto-managed column names", () => {
|
|
733
|
+
expect(validateColumnName("id").valid).toBe(false);
|
|
734
|
+
expect(validateColumnName("created_at").valid).toBe(false);
|
|
735
|
+
expect(validateColumnName("updated_at").valid).toBe(false);
|
|
736
|
+
});
|
|
737
|
+
});
|