@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,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { tableExists, columnExists, getTableColumns } from "./db-helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("tableExists", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
sqlite.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns true for existing table", () => {
|
|
17
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY)`);
|
|
18
|
+
expect(tableExists(sqlite, "accounts")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns false for non-existent table", () => {
|
|
22
|
+
expect(tableExists(sqlite, "accounts")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns false for sqlite_ internal tables", () => {
|
|
26
|
+
// sqlite_master always exists but tableExists filters by name = ?
|
|
27
|
+
// so it would find it — this is correct behavior (it does exist).
|
|
28
|
+
// The filtering of internal tables is done at the list-tables level, not here.
|
|
29
|
+
sqlite.exec(`CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`);
|
|
30
|
+
sqlite.prepare("INSERT INTO test (name) VALUES (?)").run("x");
|
|
31
|
+
// sqlite_sequence exists after AUTOINCREMENT insert
|
|
32
|
+
expect(tableExists(sqlite, "sqlite_sequence")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("columnExists", () => {
|
|
37
|
+
let sqlite: Database.Database;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
sqlite = new Database(":memory:");
|
|
41
|
+
sqlite.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
sqlite.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns true for existing column", () => {
|
|
49
|
+
expect(columnExists(sqlite, "users", "name")).toBe(true);
|
|
50
|
+
expect(columnExists(sqlite, "users", "email")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns false for non-existent column", () => {
|
|
54
|
+
expect(columnExists(sqlite, "users", "phone")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("getTableColumns", () => {
|
|
59
|
+
let sqlite: Database.Database;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
sqlite = new Database(":memory:");
|
|
63
|
+
sqlite.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
sqlite.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns set of column names", () => {
|
|
71
|
+
const cols = getTableColumns(sqlite, "users");
|
|
72
|
+
expect(cols).toEqual(new Set(["id", "name", "email"]));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns empty set for non-existent table", () => {
|
|
76
|
+
const cols = getTableColumns(sqlite, "nonexistent");
|
|
77
|
+
expect(cols).toEqual(new Set());
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite DB Helpers — low-level existence checks and query builders
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// These are the SQLite equivalents of the Postgres db-helpers.ts functions.
|
|
6
|
+
// They use sqlite_master and PRAGMA table_info instead of information_schema.
|
|
7
|
+
//
|
|
8
|
+
// Unlike the Postgres versions which are async (because postgres.js is async),
|
|
9
|
+
// these are synchronous — better-sqlite3 returns results immediately.
|
|
10
|
+
|
|
11
|
+
import type Database from "better-sqlite3";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check whether a table exists in the database.
|
|
15
|
+
* Uses sqlite_master (the authoritative catalog for SQLite).
|
|
16
|
+
*/
|
|
17
|
+
export function tableExists(
|
|
18
|
+
sqlite: Database.Database,
|
|
19
|
+
tableName: string,
|
|
20
|
+
): boolean {
|
|
21
|
+
const row = sqlite
|
|
22
|
+
.prepare(
|
|
23
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
24
|
+
)
|
|
25
|
+
.get(tableName);
|
|
26
|
+
return row !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check whether a specific column exists in a table.
|
|
31
|
+
* Uses PRAGMA table_info which returns one row per column.
|
|
32
|
+
*/
|
|
33
|
+
export function columnExists(
|
|
34
|
+
sqlite: Database.Database,
|
|
35
|
+
tableName: string,
|
|
36
|
+
columnName: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
const cols = sqlite.pragma(
|
|
39
|
+
`table_info("${tableName}")`,
|
|
40
|
+
) as { name: string }[];
|
|
41
|
+
return cols.some((c) => c.name === columnName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Return the set of column names for a table.
|
|
46
|
+
* Used to validate payload columns against actual table structure.
|
|
47
|
+
*/
|
|
48
|
+
export function getTableColumns(
|
|
49
|
+
sqlite: Database.Database,
|
|
50
|
+
tableName: string,
|
|
51
|
+
): Set<string> {
|
|
52
|
+
const cols = sqlite.pragma(
|
|
53
|
+
`table_info("${tableName}")`,
|
|
54
|
+
) as { name: string }[];
|
|
55
|
+
return new Set(cols.map((c) => c.name));
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Describe All — batch table description
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Calls describeTable() for each table from listTables().
|
|
6
|
+
// No new PRAGMA patterns — just composition of existing primitives.
|
|
7
|
+
|
|
8
|
+
import type Database from "better-sqlite3";
|
|
9
|
+
import { listTables } from "./list-tables.js";
|
|
10
|
+
import { describeTable, type TableDescription } from "./describe.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Describe all user tables in the database.
|
|
14
|
+
* Returns an array of TableDescription objects in alphabetical order.
|
|
15
|
+
*/
|
|
16
|
+
export function describeAllTables(
|
|
17
|
+
sqlite: Database.Database,
|
|
18
|
+
): TableDescription[] {
|
|
19
|
+
const tables = listTables(sqlite);
|
|
20
|
+
return tables.map((t) => describeTable(sqlite, t.name));
|
|
21
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import {
|
|
4
|
+
buildColumnDescriptions,
|
|
5
|
+
describeTable,
|
|
6
|
+
type PragmaTableInfo,
|
|
7
|
+
type PragmaForeignKey,
|
|
8
|
+
} from "./describe.js";
|
|
9
|
+
|
|
10
|
+
// ─── Pure function tests (no DB) ───────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("buildColumnDescriptions (pure)", () => {
|
|
13
|
+
it("maps basic column info", () => {
|
|
14
|
+
const pragmaInfo: PragmaTableInfo[] = [
|
|
15
|
+
{ cid: 0, name: "id", type: "INTEGER", notnull: 1, dflt_value: null, pk: 1 },
|
|
16
|
+
{ cid: 1, name: "name", type: "TEXT", notnull: 1, dflt_value: null, pk: 0 },
|
|
17
|
+
{ cid: 2, name: "email", type: "TEXT", notnull: 0, dflt_value: null, pk: 0 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const result = buildColumnDescriptions(pragmaInfo, []);
|
|
21
|
+
expect(result).toEqual([
|
|
22
|
+
{
|
|
23
|
+
column_name: "id",
|
|
24
|
+
data_type: "INTEGER",
|
|
25
|
+
is_nullable: "NO",
|
|
26
|
+
column_default: null,
|
|
27
|
+
is_primary_key: "YES",
|
|
28
|
+
is_unique: "NO",
|
|
29
|
+
foreign_table: null,
|
|
30
|
+
foreign_column: null,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
column_name: "name",
|
|
34
|
+
data_type: "TEXT",
|
|
35
|
+
is_nullable: "NO",
|
|
36
|
+
column_default: null,
|
|
37
|
+
is_primary_key: "NO",
|
|
38
|
+
is_unique: "NO",
|
|
39
|
+
foreign_table: null,
|
|
40
|
+
foreign_column: null,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
column_name: "email",
|
|
44
|
+
data_type: "TEXT",
|
|
45
|
+
is_nullable: "YES",
|
|
46
|
+
column_default: null,
|
|
47
|
+
is_primary_key: "NO",
|
|
48
|
+
is_unique: "NO",
|
|
49
|
+
foreign_table: null,
|
|
50
|
+
foreign_column: null,
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("attaches FK information from foreign_key_list", () => {
|
|
56
|
+
const pragmaInfo: PragmaTableInfo[] = [
|
|
57
|
+
{ cid: 0, name: "id", type: "INTEGER", notnull: 1, dflt_value: null, pk: 1 },
|
|
58
|
+
{ cid: 1, name: "account_id", type: "INTEGER", notnull: 1, dflt_value: null, pk: 0 },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const pragmaFks: PragmaForeignKey[] = [
|
|
62
|
+
{ id: 0, seq: 0, table: "accounts", from: "account_id", to: "id", on_update: "NO ACTION", on_delete: "NO ACTION", match: "NONE" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const result = buildColumnDescriptions(pragmaInfo, pragmaFks);
|
|
66
|
+
expect(result[1].foreign_table).toBe("accounts");
|
|
67
|
+
expect(result[1].foreign_column).toBe("id");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles default values", () => {
|
|
71
|
+
const pragmaInfo: PragmaTableInfo[] = [
|
|
72
|
+
{ cid: 0, name: "status", type: "TEXT", notnull: 1, dflt_value: "'active'", pk: 0 },
|
|
73
|
+
{ cid: 1, name: "count", type: "INTEGER", notnull: 0, dflt_value: "0", pk: 0 },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const result = buildColumnDescriptions(pragmaInfo, []);
|
|
77
|
+
expect(result[0].column_default).toBe("'active'");
|
|
78
|
+
expect(result[1].column_default).toBe("0");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles empty type string (SQLite allows typeless columns)", () => {
|
|
82
|
+
const pragmaInfo: PragmaTableInfo[] = [
|
|
83
|
+
{ cid: 0, name: "data", type: "", notnull: 0, dflt_value: null, pk: 0 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const result = buildColumnDescriptions(pragmaInfo, []);
|
|
87
|
+
expect(result[0].data_type).toBe("TEXT"); // falls back to TEXT
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("marks unique columns from provided set", () => {
|
|
91
|
+
const pragmaInfo: PragmaTableInfo[] = [
|
|
92
|
+
{ cid: 0, name: "id", type: "INTEGER", notnull: 1, dflt_value: null, pk: 1 },
|
|
93
|
+
{ cid: 1, name: "email", type: "TEXT", notnull: 1, dflt_value: null, pk: 0 },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const uniqueCols = new Set(["email"]);
|
|
97
|
+
const result = buildColumnDescriptions(pragmaInfo, [], uniqueCols);
|
|
98
|
+
expect(result[1].is_unique).toBe("YES");
|
|
99
|
+
expect(result[0].is_unique).toBe("NO");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── I/O wrapper tests (with in-memory DB) ─────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("describeTable", () => {
|
|
106
|
+
let sqlite: Database.Database;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
sqlite = new Database(":memory:");
|
|
110
|
+
sqlite.pragma("foreign_keys = ON");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
sqlite.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("describes a simple table", () => {
|
|
118
|
+
sqlite.exec(`CREATE TABLE users (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
name TEXT NOT NULL,
|
|
121
|
+
email TEXT UNIQUE
|
|
122
|
+
)`);
|
|
123
|
+
|
|
124
|
+
const desc = describeTable(sqlite, "users");
|
|
125
|
+
expect(desc.name).toBe("users");
|
|
126
|
+
expect(desc.columns).toHaveLength(3);
|
|
127
|
+
|
|
128
|
+
const idCol = desc.columns.find((c) => c.column_name === "id")!;
|
|
129
|
+
expect(idCol.is_primary_key).toBe("YES");
|
|
130
|
+
// SQLite quirk: PRAGMA table_info reports notnull=0 for INTEGER PRIMARY KEY
|
|
131
|
+
// columns even though they are implicitly NOT NULL. The PRAGMA only reflects
|
|
132
|
+
// the explicit NOT NULL constraint in the CREATE TABLE statement, not the
|
|
133
|
+
// implicit one from being a PK. This is a known SQLite behavior.
|
|
134
|
+
expect(idCol.is_nullable).toBe("YES");
|
|
135
|
+
|
|
136
|
+
const emailCol = desc.columns.find((c) => c.column_name === "email")!;
|
|
137
|
+
expect(emailCol.is_unique).toBe("YES");
|
|
138
|
+
expect(emailCol.is_nullable).toBe("YES");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("describes foreign key relationships", () => {
|
|
142
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)`);
|
|
143
|
+
sqlite.exec(`CREATE TABLE entries (
|
|
144
|
+
id INTEGER PRIMARY KEY,
|
|
145
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
146
|
+
amount REAL
|
|
147
|
+
)`);
|
|
148
|
+
|
|
149
|
+
const desc = describeTable(sqlite, "entries");
|
|
150
|
+
const fkCol = desc.columns.find((c) => c.column_name === "account_id")!;
|
|
151
|
+
expect(fkCol.foreign_table).toBe("accounts");
|
|
152
|
+
expect(fkCol.foreign_column).toBe("id");
|
|
153
|
+
expect(fkCol.is_nullable).toBe("NO");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns empty columns for non-existent table", () => {
|
|
157
|
+
const desc = describeTable(sqlite, "nonexistent");
|
|
158
|
+
expect(desc.columns).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Table Description — column metadata via PRAGMA table_info
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Split into a pure transformer (buildColumnDescriptions) and a thin I/O
|
|
6
|
+
// wrapper (describeTable). The pure function can be tested with mock
|
|
7
|
+
// PRAGMA output, without touching any database.
|
|
8
|
+
//
|
|
9
|
+
// SQLite's PRAGMA table_info returns a fundamentally different shape than
|
|
10
|
+
// PostgreSQL's information_schema. Key differences:
|
|
11
|
+
// - Types are affinity-based (TEXT, INTEGER, REAL, BLOB, NUMERIC),
|
|
12
|
+
// not rich types (varchar(255), timestamptz, etc.)
|
|
13
|
+
// - Primary key is indicated by pk > 0, not a separate constraint query
|
|
14
|
+
// - Foreign keys require a separate PRAGMA foreign_key_list() call
|
|
15
|
+
// - No UNIQUE information from table_info (requires index_list + index_info)
|
|
16
|
+
|
|
17
|
+
import type Database from "better-sqlite3";
|
|
18
|
+
import type { OperationResult } from "../types.js";
|
|
19
|
+
|
|
20
|
+
// ─── Raw PRAGMA result types ────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Row from PRAGMA table_info("tableName") */
|
|
23
|
+
export interface PragmaTableInfo {
|
|
24
|
+
cid: number;
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
notnull: number; // 0 or 1
|
|
28
|
+
dflt_value: string | null;
|
|
29
|
+
pk: number; // 0 = not PK, >0 = PK ordinal position
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Row from PRAGMA foreign_key_list("tableName") */
|
|
33
|
+
export interface PragmaForeignKey {
|
|
34
|
+
id: number;
|
|
35
|
+
seq: number;
|
|
36
|
+
table: string;
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
on_update: string;
|
|
40
|
+
on_delete: string;
|
|
41
|
+
match: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Output types ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface ColumnDescription {
|
|
47
|
+
column_name: string;
|
|
48
|
+
data_type: string;
|
|
49
|
+
is_nullable: string; // "YES" | "NO" — matches Postgres information_schema convention
|
|
50
|
+
column_default: string | null;
|
|
51
|
+
is_primary_key: string; // "YES" | "NO"
|
|
52
|
+
is_unique: string; // "YES" | "NO"
|
|
53
|
+
foreign_table: string | null;
|
|
54
|
+
foreign_column: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TableDescription {
|
|
58
|
+
name: string;
|
|
59
|
+
columns: ColumnDescription[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Pure transformer ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Transform raw PRAGMA output into a unified column description format.
|
|
66
|
+
*
|
|
67
|
+
* This is the pure, testable core. It maps SQLite's PRAGMA structures into
|
|
68
|
+
* the same shape used by the Postgres describe.ts, so the CLI and API
|
|
69
|
+
* layers can consume both uniformly.
|
|
70
|
+
*
|
|
71
|
+
* @param pragmaInfo Rows from PRAGMA table_info()
|
|
72
|
+
* @param pragmaFks Rows from PRAGMA foreign_key_list()
|
|
73
|
+
* @param uniqueCols Set of column names that have a UNIQUE index (from describeTable)
|
|
74
|
+
*/
|
|
75
|
+
export function buildColumnDescriptions(
|
|
76
|
+
pragmaInfo: PragmaTableInfo[],
|
|
77
|
+
pragmaFks: PragmaForeignKey[],
|
|
78
|
+
uniqueCols: Set<string> = new Set(),
|
|
79
|
+
): ColumnDescription[] {
|
|
80
|
+
// Build FK lookup: source column name → { table, column }
|
|
81
|
+
// A column can only be the source of one FK in practice, but
|
|
82
|
+
// PRAGMA foreign_key_list can return multi-column FKs (seq > 0).
|
|
83
|
+
// We only handle single-column FKs here (seq === 0).
|
|
84
|
+
const fkMap = new Map<string, { table: string; column: string }>();
|
|
85
|
+
for (const fk of pragmaFks) {
|
|
86
|
+
if (fk.seq === 0) {
|
|
87
|
+
fkMap.set(fk.from, { table: fk.table, column: fk.to });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return pragmaInfo.map((col) => {
|
|
92
|
+
const fk = fkMap.get(col.name);
|
|
93
|
+
return {
|
|
94
|
+
column_name: col.name,
|
|
95
|
+
data_type: col.type || "TEXT", // SQLite allows empty type strings
|
|
96
|
+
is_nullable: col.notnull === 1 ? "NO" : "YES",
|
|
97
|
+
column_default: col.dflt_value,
|
|
98
|
+
is_primary_key: col.pk > 0 ? "YES" : "NO",
|
|
99
|
+
is_unique: uniqueCols.has(col.name) ? "YES" : "NO",
|
|
100
|
+
foreign_table: fk?.table ?? null,
|
|
101
|
+
foreign_column: fk?.column ?? null,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── I/O wrapper ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Describe a table's structure by querying SQLite PRAGMAs.
|
|
110
|
+
*
|
|
111
|
+
* Gathers column info, foreign keys, and unique constraints from three
|
|
112
|
+
* separate PRAGMAs and merges them via buildColumnDescriptions.
|
|
113
|
+
*/
|
|
114
|
+
export function describeTable(
|
|
115
|
+
sqlite: Database.Database,
|
|
116
|
+
tableName: string,
|
|
117
|
+
): TableDescription {
|
|
118
|
+
const info = sqlite.pragma(`table_info("${tableName}")`) as PragmaTableInfo[];
|
|
119
|
+
const fks = sqlite.pragma(
|
|
120
|
+
`foreign_key_list("${tableName}")`,
|
|
121
|
+
) as PragmaForeignKey[];
|
|
122
|
+
|
|
123
|
+
// Determine which columns have UNIQUE indexes.
|
|
124
|
+
// PRAGMA index_list returns all indexes; we filter for unique ones,
|
|
125
|
+
// then check index_info for single-column indexes to mark them.
|
|
126
|
+
const uniqueCols = new Set<string>();
|
|
127
|
+
const indexes = sqlite.pragma(`index_list("${tableName}")`) as {
|
|
128
|
+
name: string;
|
|
129
|
+
unique: number;
|
|
130
|
+
}[];
|
|
131
|
+
for (const idx of indexes) {
|
|
132
|
+
if (idx.unique === 1) {
|
|
133
|
+
const cols = sqlite.pragma(`index_info("${idx.name}")`) as {
|
|
134
|
+
name: string;
|
|
135
|
+
}[];
|
|
136
|
+
// Only mark single-column unique indexes (multi-column unique
|
|
137
|
+
// constraints are a table-level property, not per-column)
|
|
138
|
+
if (cols.length === 1) {
|
|
139
|
+
uniqueCols.add(cols[0].name);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: tableName,
|
|
146
|
+
columns: buildColumnDescriptions(info, fks, uniqueCols),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
152
|
+
* Matches the interface of the Postgres dbDescribe().
|
|
153
|
+
*/
|
|
154
|
+
export function dbDescribe(
|
|
155
|
+
sqlite: Database.Database,
|
|
156
|
+
tableName: string,
|
|
157
|
+
): OperationResult {
|
|
158
|
+
const desc = describeTable(sqlite, tableName);
|
|
159
|
+
|
|
160
|
+
if (desc.columns.length === 0) {
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
data: [],
|
|
164
|
+
meta: { message: `Table '${tableName}' not found.` },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Separate FK info for structured output (same pattern as Postgres version)
|
|
169
|
+
const fkColumns = desc.columns.filter((c) => c.foreign_table !== null);
|
|
170
|
+
const foreignKeys = fkColumns.map((c) => ({
|
|
171
|
+
column_name: c.column_name,
|
|
172
|
+
foreign_table: c.foreign_table,
|
|
173
|
+
foreign_column: c.foreign_column,
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
data: desc.columns as unknown as Record<string, unknown>[],
|
|
179
|
+
meta: {
|
|
180
|
+
message: `Table: ${tableName}\n`,
|
|
181
|
+
tableName,
|
|
182
|
+
foreignKeys,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite SQL Proxy — mutating statement execution
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// SQLite equivalent of the Postgres exec.ts. Allows INSERT, UPDATE, DELETE,
|
|
6
|
+
// and DDL statements. Rejects dangerous operations (DROP DATABASE, TRUNCATE).
|
|
7
|
+
//
|
|
8
|
+
// Key difference from Postgres: uses EXPLAIN QUERY PLAN (not EXPLAIN) for
|
|
9
|
+
// dry-run validation, since SQLite's EXPLAIN output is bytecode-level and
|
|
10
|
+
// not useful for human consumption. EXPLAIN QUERY PLAN gives a high-level
|
|
11
|
+
// description of the query strategy.
|
|
12
|
+
|
|
13
|
+
import type Database from "better-sqlite3";
|
|
14
|
+
import type { OperationResult } from "../types.js";
|
|
15
|
+
import { rejectDangerousSQL } from "../sql-safety.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a mutating SQL statement against the SQLite database.
|
|
19
|
+
*
|
|
20
|
+
* When dryRun is true, uses EXPLAIN QUERY PLAN to validate syntax
|
|
21
|
+
* without executing. Returns the query plan as data rows.
|
|
22
|
+
*
|
|
23
|
+
* When executing, returns the number of rows changed via info.changes.
|
|
24
|
+
*/
|
|
25
|
+
export function dbExec(
|
|
26
|
+
sqlite: Database.Database,
|
|
27
|
+
rawSql: string,
|
|
28
|
+
dryRun: boolean = false,
|
|
29
|
+
): OperationResult {
|
|
30
|
+
rejectDangerousSQL(rawSql);
|
|
31
|
+
|
|
32
|
+
if (dryRun) {
|
|
33
|
+
const plan = sqlite
|
|
34
|
+
.prepare(`EXPLAIN QUERY PLAN ${rawSql}`)
|
|
35
|
+
.all() as Record<string, unknown>[];
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
data: plan,
|
|
39
|
+
meta: {
|
|
40
|
+
message: "Dry run: SQL is valid (EXPLAIN QUERY PLAN succeeded)",
|
|
41
|
+
dryRun: true,
|
|
42
|
+
tableOutputHandled: true,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const info = sqlite.prepare(rawSql).run();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
data: [],
|
|
52
|
+
meta: {
|
|
53
|
+
rowCount: info.changes,
|
|
54
|
+
...(info.changes === 0 && { message: "OK (0 rows)" }),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { describeIndexes } from "./indexes.js";
|
|
4
|
+
|
|
5
|
+
describe("describeIndexes", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
sqlite.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty array for table with no explicit indexes", () => {
|
|
17
|
+
sqlite.exec(`CREATE TABLE simple (id INTEGER PRIMARY KEY, name TEXT)`);
|
|
18
|
+
// INTEGER PRIMARY KEY doesn't create an entry in index_list in SQLite
|
|
19
|
+
const indexes = describeIndexes(sqlite, "simple");
|
|
20
|
+
expect(indexes).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("lists UNIQUE constraint indexes", () => {
|
|
24
|
+
sqlite.exec(`CREATE TABLE users (
|
|
25
|
+
id INTEGER PRIMARY KEY,
|
|
26
|
+
email TEXT UNIQUE
|
|
27
|
+
)`);
|
|
28
|
+
|
|
29
|
+
const indexes = describeIndexes(sqlite, "users");
|
|
30
|
+
expect(indexes.length).toBeGreaterThanOrEqual(1);
|
|
31
|
+
const emailIdx = indexes.find((i) => i.columns.includes("email"));
|
|
32
|
+
expect(emailIdx).toBeDefined();
|
|
33
|
+
expect(emailIdx!.unique).toBe(true);
|
|
34
|
+
expect(emailIdx!.origin).toBe("u"); // from UNIQUE constraint
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("lists explicit CREATE INDEX", () => {
|
|
38
|
+
sqlite.exec(`CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, date TEXT)`);
|
|
39
|
+
sqlite.exec(`CREATE INDEX idx_orders_customer ON orders(customer)`);
|
|
40
|
+
sqlite.exec(`CREATE UNIQUE INDEX idx_orders_date ON orders(date)`);
|
|
41
|
+
|
|
42
|
+
const indexes = describeIndexes(sqlite, "orders");
|
|
43
|
+
const customerIdx = indexes.find((i) => i.name === "idx_orders_customer")!;
|
|
44
|
+
expect(customerIdx.unique).toBe(false);
|
|
45
|
+
expect(customerIdx.columns).toEqual(["customer"]);
|
|
46
|
+
|
|
47
|
+
const dateIdx = indexes.find((i) => i.name === "idx_orders_date")!;
|
|
48
|
+
expect(dateIdx.unique).toBe(true);
|
|
49
|
+
expect(dateIdx.columns).toEqual(["date"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles multi-column indexes", () => {
|
|
53
|
+
sqlite.exec(`CREATE TABLE events (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER)`);
|
|
54
|
+
sqlite.exec(`CREATE INDEX idx_events_period ON events(year, month)`);
|
|
55
|
+
|
|
56
|
+
const indexes = describeIndexes(sqlite, "events");
|
|
57
|
+
const periodIdx = indexes.find((i) => i.name === "idx_events_period")!;
|
|
58
|
+
expect(periodIdx.columns).toEqual(["year", "month"]);
|
|
59
|
+
});
|
|
60
|
+
});
|