@veducio/keja 0.4.0 → 0.6.0
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 +1 -1
- package/src/cli/dev-reload.ts +2 -0
- package/src/cli/scaffold-empty-route.test.ts +67 -0
- package/src/cli/scaffold-empty-route.ts +72 -0
- package/src/repository/generate.test.ts +16 -0
- package/src/repository/generate.ts +11 -4
- package/src/repository/query.ts +5 -0
- package/src/repository/runtime.test.ts +18 -0
- package/src/repository/runtime.ts +57 -34
- package/src/repository/sql.test.ts +33 -0
- package/src/repository/sql.ts +199 -27
package/package.json
CHANGED
package/src/cli/dev-reload.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
printServers,
|
|
11
11
|
readProjectFailure,
|
|
12
12
|
} from "./run-project.ts";
|
|
13
|
+
import { scaffoldEmptyRoutes } from "./scaffold-empty-route.ts";
|
|
13
14
|
|
|
14
15
|
export const reloadDebounceMs = 50;
|
|
15
16
|
|
|
@@ -111,6 +112,7 @@ export async function runDev(root = process.cwd()) {
|
|
|
111
112
|
const token = String(generation);
|
|
112
113
|
if (running) {
|
|
113
114
|
printReloading();
|
|
115
|
+
await scaffoldEmptyRoutes(running.paths.public, running.paths.service);
|
|
114
116
|
}
|
|
115
117
|
const result = await refreshDevProject({
|
|
116
118
|
running: running !== null,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
emptyRouteSource,
|
|
8
|
+
routePathFromFile,
|
|
9
|
+
scaffoldEmptyRouteFile,
|
|
10
|
+
scaffoldEmptyRoutes,
|
|
11
|
+
} from "./scaffold-empty-route.ts";
|
|
12
|
+
|
|
13
|
+
describe("scaffold empty route", () => {
|
|
14
|
+
it("derives a path from the file name", () => {
|
|
15
|
+
expect(routePathFromFile("src/public/notes.ts")).toBe("/notes");
|
|
16
|
+
expect(routePathFromFile("hello.world.ts")).toBe("/hello.world");
|
|
17
|
+
expect(routePathFromFile("bad name.ts")).toBe("/route");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("writes a public get stub and a service post stub", () => {
|
|
21
|
+
expect(emptyRouteSource("public", "/items")).toContain('method: "get"');
|
|
22
|
+
expect(emptyRouteSource("public", "/items")).toContain('path: "/items"');
|
|
23
|
+
expect(emptyRouteSource("service", "/jobs")).toContain('method: "post"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("fills an empty route file once", async () => {
|
|
27
|
+
const directory = await mkdtemp(path.join(tmpdir(), "keja-scaffold-"));
|
|
28
|
+
const file = path.join(directory, "items.ts");
|
|
29
|
+
await writeFile(file, "");
|
|
30
|
+
expect(await scaffoldEmptyRouteFile(file, "public")).toBe(true);
|
|
31
|
+
const first = await readFile(file, "utf8");
|
|
32
|
+
expect(first).toContain('path: "/items"');
|
|
33
|
+
expect(await scaffoldEmptyRouteFile(file, "public")).toBe(false);
|
|
34
|
+
expect(await readFile(file, "utf8")).toBe(first);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("fills empty route modules under public and service folders", async () => {
|
|
38
|
+
const root = mkdtempSync(path.join(tmpdir(), "keja-scaffold-dirs-"));
|
|
39
|
+
const publicDir = path.join(root, "public");
|
|
40
|
+
const serviceDir = path.join(root, "service");
|
|
41
|
+
mkdirSync(publicDir);
|
|
42
|
+
mkdirSync(serviceDir);
|
|
43
|
+
writeFileSync(path.join(publicDir, "notes.ts"), " \n");
|
|
44
|
+
writeFileSync(path.join(publicDir, "_middleware.ts"), "");
|
|
45
|
+
writeFileSync(path.join(serviceDir, "jobs.ts"), "");
|
|
46
|
+
writeFileSync(path.join(publicDir, "ready.ts"), "export {}\n");
|
|
47
|
+
const written = await scaffoldEmptyRoutes(publicDir, serviceDir);
|
|
48
|
+
expect(written.sort()).toEqual(
|
|
49
|
+
[
|
|
50
|
+
path.join(publicDir, "notes.ts"),
|
|
51
|
+
path.join(serviceDir, "jobs.ts"),
|
|
52
|
+
].sort(),
|
|
53
|
+
);
|
|
54
|
+
expect(await readFile(path.join(publicDir, "notes.ts"), "utf8")).toContain(
|
|
55
|
+
'method: "get"',
|
|
56
|
+
);
|
|
57
|
+
expect(await readFile(path.join(serviceDir, "jobs.ts"), "utf8")).toContain(
|
|
58
|
+
'method: "post"',
|
|
59
|
+
);
|
|
60
|
+
expect(await readFile(path.join(publicDir, "_middleware.ts"), "utf8")).toBe(
|
|
61
|
+
"",
|
|
62
|
+
);
|
|
63
|
+
expect(await readFile(path.join(publicDir, "ready.ts"), "utf8")).toBe(
|
|
64
|
+
"export {}\n",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { collectRouteFiles } from "../project/load.ts";
|
|
5
|
+
|
|
6
|
+
export function routePathFromFile(file: string) {
|
|
7
|
+
const base = path.basename(file, ".ts");
|
|
8
|
+
if (/^[A-Za-z0-9._~-]+$/.test(base)) {
|
|
9
|
+
return `/${base}`;
|
|
10
|
+
}
|
|
11
|
+
return "/route";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function emptyRouteSource(
|
|
15
|
+
surface: "public" | "service",
|
|
16
|
+
routePath: string,
|
|
17
|
+
) {
|
|
18
|
+
const method = surface === "service" ? "post" : "get";
|
|
19
|
+
return `import { defineRoute, response } from "@veducio/keja";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
export default defineRoute({
|
|
23
|
+
method: "${method}",
|
|
24
|
+
path: "${routePath}",
|
|
25
|
+
version: "v1",
|
|
26
|
+
responses: {
|
|
27
|
+
Ok: response({
|
|
28
|
+
code: 200,
|
|
29
|
+
json: z.object({ ok: z.literal(true) }),
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
handler: ({ respond }) => respond.Ok({ ok: true }),
|
|
33
|
+
});
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function scaffoldEmptyRouteFile(
|
|
38
|
+
file: string,
|
|
39
|
+
surface: "public" | "service",
|
|
40
|
+
) {
|
|
41
|
+
if (!existsSync(file)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const content = await readFile(file, "utf8");
|
|
45
|
+
if (content.trim() !== "") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
await writeFile(file, emptyRouteSource(surface, routePathFromFile(file)));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function scaffoldEmptyRoutes(
|
|
53
|
+
publicDir: string,
|
|
54
|
+
serviceDir: string,
|
|
55
|
+
) {
|
|
56
|
+
const written: string[] = [];
|
|
57
|
+
for (const [surface, directory] of [
|
|
58
|
+
["public", publicDir],
|
|
59
|
+
["service", serviceDir],
|
|
60
|
+
] as const) {
|
|
61
|
+
if (!existsSync(directory)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const relative of await collectRouteFiles(directory)) {
|
|
65
|
+
const file = path.join(directory, relative);
|
|
66
|
+
if (await scaffoldEmptyRouteFile(file, surface)) {
|
|
67
|
+
written.push(file);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return written;
|
|
72
|
+
}
|
|
@@ -55,4 +55,20 @@ describe("renderRepositories", () => {
|
|
|
55
55
|
);
|
|
56
56
|
expect(source).toContain('list: { filters: ["name"], limit: 20 },');
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("writes enum columns with their values", () => {
|
|
60
|
+
const tables = readTables([
|
|
61
|
+
{
|
|
62
|
+
file: "0001_example.sql",
|
|
63
|
+
sql: `
|
|
64
|
+
create type post_status as enum ('draft', 'published');
|
|
65
|
+
create table posts (${owned}, status post_status not null);
|
|
66
|
+
`,
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
const source = renderRepositories(tables, new Map());
|
|
70
|
+
expect(source).toContain(
|
|
71
|
+
'status: { type: "enum", values: ["draft", "published"], nullable: false },',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
58
74
|
});
|
|
@@ -82,10 +82,7 @@ function renderRepository(
|
|
|
82
82
|
flags: { list: ResolvedList; get: boolean } | undefined,
|
|
83
83
|
) {
|
|
84
84
|
const columns = table.columns
|
|
85
|
-
.map(
|
|
86
|
-
(column) =>
|
|
87
|
-
` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`,
|
|
88
|
-
)
|
|
85
|
+
.map((column) => renderColumn(column))
|
|
89
86
|
.join("\n");
|
|
90
87
|
const options = [
|
|
91
88
|
renderList(flags?.list),
|
|
@@ -115,3 +112,13 @@ function renderList(list: ResolvedList | undefined) {
|
|
|
115
112
|
].filter((part) => part !== "");
|
|
116
113
|
return ` list: { ${parts.join(", ")} },`;
|
|
117
114
|
}
|
|
115
|
+
|
|
116
|
+
function renderColumn(column: TableSchema["columns"][number]) {
|
|
117
|
+
if (column.type === "enum") {
|
|
118
|
+
const values = column.values
|
|
119
|
+
.map((value) => JSON.stringify(value))
|
|
120
|
+
.join(", ");
|
|
121
|
+
return ` ${column.name}: { type: "enum", values: [${values}], nullable: ${column.nullable} },`;
|
|
122
|
+
}
|
|
123
|
+
return ` ${column.name}: { type: "${column.type}", nullable: ${column.nullable} },`;
|
|
124
|
+
}
|
package/src/repository/query.ts
CHANGED
|
@@ -245,6 +245,11 @@ function parseValue(column: DomainColumn, value: string) {
|
|
|
245
245
|
}
|
|
246
246
|
return value;
|
|
247
247
|
}
|
|
248
|
+
if (column.type === "enum" && !column.values.includes(value)) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${column.name} filter must be ${column.values.join(", ")}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
248
253
|
return value;
|
|
249
254
|
}
|
|
250
255
|
|
|
@@ -29,6 +29,23 @@ describe("repository", () => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it("validates enum columns against declared values", () => {
|
|
33
|
+
const posts = repository({
|
|
34
|
+
table: "posts",
|
|
35
|
+
columns: {
|
|
36
|
+
status: {
|
|
37
|
+
type: "enum",
|
|
38
|
+
values: ["draft", "published"],
|
|
39
|
+
nullable: false,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect(posts.schema.parse({ status: "draft" })).toEqual({
|
|
44
|
+
status: "draft",
|
|
45
|
+
});
|
|
46
|
+
expect(() => posts.schema.parse({ status: "archived" })).toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
32
49
|
it("exposes a row schema with and without meta", () => {
|
|
33
50
|
const users = repository({ table: "users", columns });
|
|
34
51
|
const at = new Date("2026-01-15T00:00:00.000Z");
|
|
@@ -38,6 +55,7 @@ describe("repository", () => {
|
|
|
38
55
|
name: "Ada",
|
|
39
56
|
email: "ada@example.com",
|
|
40
57
|
});
|
|
58
|
+
|
|
41
59
|
expect(
|
|
42
60
|
users.meta.parse({
|
|
43
61
|
...row("users_a", "Ada"),
|
|
@@ -10,31 +10,42 @@ import {
|
|
|
10
10
|
} from "./query.ts";
|
|
11
11
|
import type { ColumnType, DomainColumn } from "./sql.ts";
|
|
12
12
|
|
|
13
|
-
export type ColumnSpec =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
export type ColumnSpec =
|
|
14
|
+
| {
|
|
15
|
+
readonly type: Exclude<ColumnType, "enum">;
|
|
16
|
+
readonly nullable: boolean;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly type: "enum";
|
|
20
|
+
readonly values: readonly [string, ...string[]];
|
|
21
|
+
readonly nullable: boolean;
|
|
22
|
+
};
|
|
17
23
|
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
type SpecValue<Spec extends ColumnSpec> = Spec extends {
|
|
25
|
+
type: "enum";
|
|
26
|
+
values: readonly (infer Value)[];
|
|
27
|
+
}
|
|
28
|
+
? Value
|
|
29
|
+
: Spec["type"] extends "text"
|
|
30
|
+
? string
|
|
31
|
+
: Spec["type"] extends "boolean"
|
|
32
|
+
? boolean
|
|
33
|
+
: Spec["type"] extends "timestamptz"
|
|
34
|
+
? Date
|
|
35
|
+
: Spec["type"] extends "integer"
|
|
36
|
+
? number
|
|
37
|
+
: Spec["type"] extends "bigint"
|
|
29
38
|
? string
|
|
30
|
-
:
|
|
31
|
-
?
|
|
32
|
-
:
|
|
39
|
+
: Spec["type"] extends "numeric"
|
|
40
|
+
? string
|
|
41
|
+
: Spec["type"] extends "jsonb"
|
|
42
|
+
? unknown
|
|
43
|
+
: never;
|
|
33
44
|
|
|
34
45
|
export type DomainRow<Columns extends Record<string, ColumnSpec>> = {
|
|
35
46
|
[Key in keyof Columns]: Columns[Key]["nullable"] extends true
|
|
36
|
-
?
|
|
37
|
-
:
|
|
47
|
+
? SpecValue<Columns[Key]> | null
|
|
48
|
+
: SpecValue<Columns[Key]>;
|
|
38
49
|
};
|
|
39
50
|
|
|
40
51
|
export type RepositoryRow<Columns extends Record<string, ColumnSpec>> = {
|
|
@@ -133,11 +144,21 @@ export function repository<
|
|
|
133
144
|
}): Repository<Columns> &
|
|
134
145
|
(List extends false ? object : { list: ListProcessor<Columns, List> }) &
|
|
135
146
|
(Get extends true ? { get: GetProcessor<Columns> } : object) {
|
|
136
|
-
const columns = Object.entries(input.columns).map(
|
|
137
|
-
name,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
const columns = Object.entries(input.columns).map(
|
|
148
|
+
([name, column]): DomainColumn =>
|
|
149
|
+
column.type === "enum"
|
|
150
|
+
? {
|
|
151
|
+
name,
|
|
152
|
+
type: "enum",
|
|
153
|
+
nullable: column.nullable,
|
|
154
|
+
values: column.values,
|
|
155
|
+
}
|
|
156
|
+
: {
|
|
157
|
+
name,
|
|
158
|
+
type: column.type,
|
|
159
|
+
nullable: column.nullable,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
141
162
|
const shape = rowSchemas(columns);
|
|
142
163
|
const base: Repository<Columns> = {
|
|
143
164
|
table: input.table,
|
|
@@ -240,7 +261,7 @@ function listFilters(
|
|
|
240
261
|
function rowSchemas(columns: readonly DomainColumn[]) {
|
|
241
262
|
const domain: Record<string, z.ZodType> = {};
|
|
242
263
|
for (const column of columns) {
|
|
243
|
-
domain[column.name] = columnSchema(column
|
|
264
|
+
domain[column.name] = columnSchema(column);
|
|
244
265
|
}
|
|
245
266
|
return {
|
|
246
267
|
schema: z.object(domain),
|
|
@@ -256,20 +277,22 @@ function rowSchemas(columns: readonly DomainColumn[]) {
|
|
|
256
277
|
};
|
|
257
278
|
}
|
|
258
279
|
|
|
259
|
-
function columnSchema(
|
|
280
|
+
function columnSchema(column: DomainColumn) {
|
|
260
281
|
const schema =
|
|
261
|
-
type === "boolean"
|
|
282
|
+
column.type === "boolean"
|
|
262
283
|
? z.boolean()
|
|
263
|
-
: type === "timestamptz"
|
|
284
|
+
: column.type === "timestamptz"
|
|
264
285
|
? z.date()
|
|
265
|
-
: type === "integer"
|
|
286
|
+
: column.type === "integer"
|
|
266
287
|
? z.number().int()
|
|
267
|
-
: type === "bigint" || type === "numeric"
|
|
288
|
+
: column.type === "bigint" || column.type === "numeric"
|
|
268
289
|
? z.string()
|
|
269
|
-
: type === "jsonb"
|
|
290
|
+
: column.type === "jsonb"
|
|
270
291
|
? z.unknown()
|
|
271
|
-
:
|
|
272
|
-
|
|
292
|
+
: column.type === "enum"
|
|
293
|
+
? z.enum(column.values)
|
|
294
|
+
: z.string();
|
|
295
|
+
return column.nullable ? schema.nullable() : schema;
|
|
273
296
|
}
|
|
274
297
|
|
|
275
298
|
function builtQuerySort(sort: string | undefined) {
|
|
@@ -75,4 +75,37 @@ describe("readTables", () => {
|
|
|
75
75
|
]),
|
|
76
76
|
).toThrow("users.photo has no TypeScript type");
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it("reads create type enums and columns that use them", () => {
|
|
80
|
+
const tables = readTables([
|
|
81
|
+
{
|
|
82
|
+
file: "0001_status.sql",
|
|
83
|
+
sql: `
|
|
84
|
+
create type post_status as enum ('draft', 'published');
|
|
85
|
+
create table posts (${owned}, status post_status not null);
|
|
86
|
+
alter type post_status add value 'archived';
|
|
87
|
+
alter table posts add column visibility post_status;
|
|
88
|
+
`,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
expect(tables).toEqual([
|
|
92
|
+
{
|
|
93
|
+
name: "posts",
|
|
94
|
+
columns: [
|
|
95
|
+
{
|
|
96
|
+
name: "status",
|
|
97
|
+
type: "enum",
|
|
98
|
+
nullable: false,
|
|
99
|
+
values: ["draft", "published", "archived"],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "visibility",
|
|
103
|
+
type: "enum",
|
|
104
|
+
nullable: true,
|
|
105
|
+
values: ["draft", "published", "archived"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
78
111
|
});
|
package/src/repository/sql.ts
CHANGED
|
@@ -7,13 +7,21 @@ export type ColumnType =
|
|
|
7
7
|
| "integer"
|
|
8
8
|
| "bigint"
|
|
9
9
|
| "numeric"
|
|
10
|
-
| "jsonb"
|
|
10
|
+
| "jsonb"
|
|
11
|
+
| "enum";
|
|
11
12
|
|
|
12
|
-
export type DomainColumn =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
export type DomainColumn =
|
|
14
|
+
| {
|
|
15
|
+
name: string;
|
|
16
|
+
type: Exclude<ColumnType, "enum">;
|
|
17
|
+
nullable: boolean;
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
name: string;
|
|
21
|
+
type: "enum";
|
|
22
|
+
nullable: boolean;
|
|
23
|
+
values: readonly [string, ...string[]];
|
|
24
|
+
};
|
|
17
25
|
|
|
18
26
|
export type TableSchema = {
|
|
19
27
|
name: string;
|
|
@@ -29,7 +37,10 @@ const reservedTypes = {
|
|
|
29
37
|
_updatedAt: "timestamptz",
|
|
30
38
|
} as const;
|
|
31
39
|
|
|
32
|
-
const typePatterns: readonly {
|
|
40
|
+
const typePatterns: readonly {
|
|
41
|
+
pattern: RegExp;
|
|
42
|
+
type: Exclude<ColumnType, "enum">;
|
|
43
|
+
}[] = [
|
|
33
44
|
{ pattern: /^timestamptz\b/i, type: "timestamptz" },
|
|
34
45
|
{ pattern: /^timestamp\s+with\s+time\s+zone\b/i, type: "timestamptz" },
|
|
35
46
|
{ pattern: /^boolean\b/i, type: "boolean" },
|
|
@@ -47,48 +58,114 @@ const typePatterns: readonly { pattern: RegExp; type: ColumnType }[] = [
|
|
|
47
58
|
{ pattern: /^character\s+varying\b/i, type: "text" },
|
|
48
59
|
];
|
|
49
60
|
|
|
50
|
-
type
|
|
61
|
+
type WorkingColumn =
|
|
62
|
+
| {
|
|
63
|
+
name: string;
|
|
64
|
+
type: Exclude<ColumnType, "enum">;
|
|
65
|
+
nullable: boolean;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
name: string;
|
|
69
|
+
type: "enum";
|
|
70
|
+
nullable: boolean;
|
|
71
|
+
enumName: string;
|
|
72
|
+
};
|
|
51
73
|
|
|
52
74
|
type WorkingTable = {
|
|
53
75
|
name: string;
|
|
54
|
-
reserved: Map<string,
|
|
55
|
-
columns:
|
|
76
|
+
reserved: Map<string, WorkingColumn>;
|
|
77
|
+
columns: WorkingColumn[];
|
|
56
78
|
};
|
|
57
79
|
|
|
58
80
|
export function readTables(files: readonly { file: string; sql: string }[]) {
|
|
59
81
|
const tables = new Map<string, WorkingTable>();
|
|
82
|
+
const enums = new Map<string, [string, ...string[]]>();
|
|
60
83
|
for (const file of files) {
|
|
61
84
|
for (const statement of statements(stripComments(file.sql))) {
|
|
62
|
-
applyStatement(tables, file.file, statement);
|
|
85
|
+
applyStatement(tables, enums, file.file, statement);
|
|
63
86
|
}
|
|
64
87
|
}
|
|
65
88
|
return [...tables.values()]
|
|
66
|
-
.map((table) => finishTable(table))
|
|
89
|
+
.map((table) => finishTable(table, enums))
|
|
67
90
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
function applyStatement(
|
|
71
94
|
tables: Map<string, WorkingTable>,
|
|
95
|
+
enums: Map<string, [string, ...string[]]>,
|
|
72
96
|
file: string,
|
|
73
97
|
statement: string,
|
|
74
98
|
) {
|
|
99
|
+
const createType = statement.match(
|
|
100
|
+
/^create\s+type\s+(?:if\s+not\s+exists\s+)?(.+?)\s+as\s+enum\s*\(([\s\S]*)\)\s*$/i,
|
|
101
|
+
);
|
|
102
|
+
if (createType?.[1] && createType[2] !== undefined) {
|
|
103
|
+
createEnum(enums, file, createType[1].trim(), createType[2]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const alterType = statement.match(
|
|
107
|
+
/^alter\s+type\s+(.+?)\s+add\s+value\s+(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
108
|
+
);
|
|
109
|
+
if (alterType?.[1] && alterType[2]) {
|
|
110
|
+
addEnumValue(enums, file, alterType[1].trim(), alterType[2].trim());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
75
113
|
const create = statement.match(
|
|
76
114
|
/^create\s+table\s+(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
77
115
|
);
|
|
78
116
|
if (create?.[1]) {
|
|
79
|
-
createTable(tables, file, create[1]);
|
|
117
|
+
createTable(tables, enums, file, create[1]);
|
|
80
118
|
return;
|
|
81
119
|
}
|
|
82
120
|
const alter = statement.match(
|
|
83
121
|
/^alter\s+table\s+(.+?)\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
84
122
|
);
|
|
85
123
|
if (alter?.[1] && alter[2]) {
|
|
86
|
-
addColumn(tables, file, alter[1].trim(), alter[2].trim());
|
|
124
|
+
addColumn(tables, enums, file, alter[1].trim(), alter[2].trim());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createEnum(
|
|
129
|
+
enums: Map<string, [string, ...string[]]>,
|
|
130
|
+
file: string,
|
|
131
|
+
token: string,
|
|
132
|
+
body: string,
|
|
133
|
+
) {
|
|
134
|
+
const name = typeName(token, file);
|
|
135
|
+
if (enums.has(name)) {
|
|
136
|
+
throw projectError(
|
|
137
|
+
"invalidRepositorySql",
|
|
138
|
+
`${file} creates type ${name} more than once`,
|
|
139
|
+
);
|
|
87
140
|
}
|
|
141
|
+
const values = readEnumValues(body, file, name);
|
|
142
|
+
enums.set(name, values);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function addEnumValue(
|
|
146
|
+
enums: Map<string, [string, ...string[]]>,
|
|
147
|
+
file: string,
|
|
148
|
+
token: string,
|
|
149
|
+
rest: string,
|
|
150
|
+
) {
|
|
151
|
+
const name = typeName(token, file);
|
|
152
|
+
const current = enums.get(name);
|
|
153
|
+
if (!current) {
|
|
154
|
+
throw projectError(
|
|
155
|
+
"invalidRepositorySql",
|
|
156
|
+
`${file} adds a value to missing type ${name}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const value = readEnumLiteral(rest, file, name);
|
|
160
|
+
if (current.includes(value)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
enums.set(name, [...current, value]);
|
|
88
164
|
}
|
|
89
165
|
|
|
90
166
|
function createTable(
|
|
91
167
|
tables: Map<string, WorkingTable>,
|
|
168
|
+
enums: Map<string, [string, ...string[]]>,
|
|
92
169
|
file: string,
|
|
93
170
|
rest: string,
|
|
94
171
|
) {
|
|
@@ -116,7 +193,7 @@ function createTable(
|
|
|
116
193
|
if (isConstraint(part)) {
|
|
117
194
|
continue;
|
|
118
195
|
}
|
|
119
|
-
const column = readColumn(part, file, name);
|
|
196
|
+
const column = readColumn(part, file, name, enums);
|
|
120
197
|
storeColumn(table, column, file);
|
|
121
198
|
}
|
|
122
199
|
tables.set(name, table);
|
|
@@ -124,6 +201,7 @@ function createTable(
|
|
|
124
201
|
|
|
125
202
|
function addColumn(
|
|
126
203
|
tables: Map<string, WorkingTable>,
|
|
204
|
+
enums: Map<string, [string, ...string[]]>,
|
|
127
205
|
file: string,
|
|
128
206
|
tableToken: string,
|
|
129
207
|
definition: string,
|
|
@@ -136,11 +214,11 @@ function addColumn(
|
|
|
136
214
|
`${file} adds a column to missing table ${name}`,
|
|
137
215
|
);
|
|
138
216
|
}
|
|
139
|
-
const column = readColumn(definition, file, name);
|
|
217
|
+
const column = readColumn(definition, file, name, enums);
|
|
140
218
|
storeColumn(table, column, file);
|
|
141
219
|
}
|
|
142
220
|
|
|
143
|
-
function storeColumn(table: WorkingTable, column:
|
|
221
|
+
function storeColumn(table: WorkingTable, column: WorkingColumn, file: string) {
|
|
144
222
|
if (column.name in reservedTypes) {
|
|
145
223
|
if (table.reserved.has(column.name)) {
|
|
146
224
|
throw projectError(
|
|
@@ -166,7 +244,10 @@ function storeColumn(table: WorkingTable, column: ParsedColumn, file: string) {
|
|
|
166
244
|
table.columns.push(column);
|
|
167
245
|
}
|
|
168
246
|
|
|
169
|
-
function finishTable(
|
|
247
|
+
function finishTable(
|
|
248
|
+
table: WorkingTable,
|
|
249
|
+
enums: Map<string, [string, ...string[]]>,
|
|
250
|
+
): TableSchema {
|
|
170
251
|
for (const [name, type] of Object.entries(reservedTypes)) {
|
|
171
252
|
const column = table.reserved.get(name);
|
|
172
253
|
if (!column || column.nullable || column.type !== type) {
|
|
@@ -176,26 +257,78 @@ function finishTable(table: WorkingTable): TableSchema {
|
|
|
176
257
|
);
|
|
177
258
|
}
|
|
178
259
|
}
|
|
179
|
-
return {
|
|
260
|
+
return {
|
|
261
|
+
name: table.name,
|
|
262
|
+
columns: table.columns.map((column) => finishColumn(column, enums)),
|
|
263
|
+
};
|
|
180
264
|
}
|
|
181
265
|
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
266
|
+
function finishColumn(
|
|
267
|
+
column: WorkingColumn,
|
|
268
|
+
enums: Map<string, [string, ...string[]]>,
|
|
269
|
+
): DomainColumn {
|
|
270
|
+
if (column.type !== "enum") {
|
|
271
|
+
return column;
|
|
272
|
+
}
|
|
273
|
+
const values = enums.get(column.enumName);
|
|
274
|
+
if (!values) {
|
|
185
275
|
throw projectError(
|
|
186
276
|
"invalidRepositorySql",
|
|
187
|
-
`${
|
|
277
|
+
`${column.name} references missing type ${column.enumName}`,
|
|
188
278
|
);
|
|
189
279
|
}
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
return {
|
|
281
|
+
name: column.name,
|
|
282
|
+
type: "enum",
|
|
283
|
+
nullable: column.nullable,
|
|
284
|
+
values,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readColumn(
|
|
289
|
+
definition: string,
|
|
290
|
+
file: string,
|
|
291
|
+
table: string,
|
|
292
|
+
enums: Map<string, [string, ...string[]]>,
|
|
293
|
+
): WorkingColumn {
|
|
294
|
+
const ident = readIdent(definition.trim());
|
|
295
|
+
if (!ident) {
|
|
192
296
|
throw projectError(
|
|
193
297
|
"invalidRepositorySql",
|
|
194
|
-
`${file} column ${table}
|
|
298
|
+
`${file} has a column in ${table} Keja cannot read`,
|
|
195
299
|
);
|
|
196
300
|
}
|
|
301
|
+
const mapped = typePatterns.find((item) => item.pattern.test(ident.rest));
|
|
197
302
|
const nullable = !/\b(?:not\s+null|primary\s+key)\b/i.test(ident.rest);
|
|
198
|
-
|
|
303
|
+
if (mapped) {
|
|
304
|
+
return { name: ident.name, type: mapped.type, nullable };
|
|
305
|
+
}
|
|
306
|
+
const enumName = readTypeReference(ident.rest);
|
|
307
|
+
if (enumName && enums.has(enumName)) {
|
|
308
|
+
return { name: ident.name, type: "enum", nullable, enumName };
|
|
309
|
+
}
|
|
310
|
+
throw projectError(
|
|
311
|
+
"invalidRepositorySql",
|
|
312
|
+
`${file} column ${table}.${ident.name} has no TypeScript type`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readTypeReference(rest: string) {
|
|
317
|
+
const parts: string[] = [];
|
|
318
|
+
let remaining = rest.trim();
|
|
319
|
+
while (remaining !== "") {
|
|
320
|
+
const ident = readIdent(remaining);
|
|
321
|
+
if (!ident) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
parts.push(ident.name);
|
|
325
|
+
remaining = ident.rest;
|
|
326
|
+
if (!remaining.startsWith(".")) {
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
remaining = remaining.slice(1).trim();
|
|
330
|
+
}
|
|
331
|
+
return parts.at(-1);
|
|
199
332
|
}
|
|
200
333
|
|
|
201
334
|
function tableName(token: string, file: string) {
|
|
@@ -214,6 +347,45 @@ function tableName(token: string, file: string) {
|
|
|
214
347
|
return name;
|
|
215
348
|
}
|
|
216
349
|
|
|
350
|
+
function typeName(token: string, file: string) {
|
|
351
|
+
const name = readTypeReference(token);
|
|
352
|
+
if (!name || !/^[a-z_][a-z0-9_]*$/.test(name)) {
|
|
353
|
+
throw projectError(
|
|
354
|
+
"invalidRepositorySql",
|
|
355
|
+
`${file} has a type name Keja cannot read`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return name;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function readEnumValues(body: string, file: string, type: string) {
|
|
362
|
+
const values: string[] = [];
|
|
363
|
+
for (const part of splitTopLevel(body)) {
|
|
364
|
+
if (part.trim() === "") {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
values.push(readEnumLiteral(part.trim(), file, type));
|
|
368
|
+
}
|
|
369
|
+
if (values.length === 0) {
|
|
370
|
+
throw projectError(
|
|
371
|
+
"invalidRepositorySql",
|
|
372
|
+
`${file} type ${type} has no enum values`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return values as [string, ...string[]];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function readEnumLiteral(value: string, file: string, type: string) {
|
|
379
|
+
const match = value.match(/^'((?:[^']|'')*)'(?:\s+.*)?$/s);
|
|
380
|
+
if (!match?.[1]) {
|
|
381
|
+
throw projectError(
|
|
382
|
+
"invalidRepositorySql",
|
|
383
|
+
`${file} type ${type} has an enum value Keja cannot read`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return match[1].replaceAll("''", "'");
|
|
387
|
+
}
|
|
388
|
+
|
|
217
389
|
function isConstraint(part: string) {
|
|
218
390
|
return /^(?:check|constraint|primary|unique|foreign)\b/i.test(part.trim());
|
|
219
391
|
}
|