@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veducio/keja",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "description": "Veducio Keja - RESTFUL Web Server, Based on Lightweight Hono Framework, Written in TypeScript, Powered by Bun",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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
- readonly type: ColumnType;
15
- readonly nullable: boolean;
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 TsValue<Type extends ColumnType> = Type extends "text"
19
- ? string
20
- : Type extends "boolean"
21
- ? boolean
22
- : Type extends "timestamptz"
23
- ? Date
24
- : Type extends "integer"
25
- ? number
26
- : Type extends "bigint"
27
- ? string
28
- : Type extends "numeric"
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
- : Type extends "jsonb"
31
- ? unknown
32
- : never;
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
- ? TsValue<Columns[Key]["type"]> | null
37
- : TsValue<Columns[Key]["type"]>;
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(([name, column]) => ({
137
- name,
138
- type: column.type,
139
- nullable: column.nullable,
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.type, column.nullable);
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(type: ColumnType, nullable: boolean) {
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
- : z.string();
272
- return nullable ? schema.nullable() : schema;
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
  });
@@ -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
- name: string;
14
- type: ColumnType;
15
- nullable: boolean;
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 { pattern: RegExp; type: ColumnType }[] = [
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 ParsedColumn = DomainColumn;
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, ParsedColumn>;
55
- columns: DomainColumn[];
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: ParsedColumn, file: string) {
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(table: WorkingTable): TableSchema {
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 { name: table.name, columns: table.columns };
260
+ return {
261
+ name: table.name,
262
+ columns: table.columns.map((column) => finishColumn(column, enums)),
263
+ };
180
264
  }
181
265
 
182
- function readColumn(definition: string, file: string, table: string) {
183
- const ident = readIdent(definition.trim());
184
- if (!ident) {
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
- `${file} has a column in ${table} Keja cannot read`,
277
+ `${column.name} references missing type ${column.enumName}`,
188
278
  );
189
279
  }
190
- const mapped = typePatterns.find((item) => item.pattern.test(ident.rest));
191
- if (!mapped) {
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}.${ident.name} has no TypeScript type`,
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
- return { name: ident.name, type: mapped.type, nullable };
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
  }