shapecraft 1.0.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.
Files changed (112) hide show
  1. package/CLAUDE.md +227 -0
  2. package/README.md +22 -0
  3. package/apps/cli/node_modules/.bin/prettier +21 -0
  4. package/apps/cli/node_modules/.bin/tsc +21 -0
  5. package/apps/cli/node_modules/.bin/tsserver +21 -0
  6. package/apps/cli/node_modules/.bin/tsx +21 -0
  7. package/apps/cli/node_modules/.bin/vitest +21 -0
  8. package/apps/cli/package.json +47 -0
  9. package/apps/cli/src/index.ts +98 -0
  10. package/apps/cli/tsconfig.cjs.json +10 -0
  11. package/apps/cli/tsconfig.esm.json +10 -0
  12. package/apps/cli/tsconfig.json +22 -0
  13. package/package.json +16 -0
  14. package/packages/core/node_modules/.bin/prettier +21 -0
  15. package/packages/core/node_modules/.bin/tsc +21 -0
  16. package/packages/core/node_modules/.bin/tsserver +21 -0
  17. package/packages/core/node_modules/.bin/tsx +21 -0
  18. package/packages/core/node_modules/.bin/vitest +21 -0
  19. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  20. package/packages/core/package.json +44 -0
  21. package/packages/core/src/common/array.test.ts +19 -0
  22. package/packages/core/src/common/array.ts +15 -0
  23. package/packages/core/src/common/index.ts +5 -0
  24. package/packages/core/src/common/is.ts +23 -0
  25. package/packages/core/src/common/object.ts +35 -0
  26. package/packages/core/src/common/phantom.ts +1 -0
  27. package/packages/core/src/common/result.ts +43 -0
  28. package/packages/core/src/common/string.ts +28 -0
  29. package/packages/core/src/common/types.ts +34 -0
  30. package/packages/core/src/index.ts +1 -0
  31. package/packages/core/src/shape/annotate.ts +139 -0
  32. package/packages/core/src/shape/annotation.ts +47 -0
  33. package/packages/core/src/shape/base.ts +71 -0
  34. package/packages/core/src/shape/builder.test.ts +728 -0
  35. package/packages/core/src/shape/builder.ts +475 -0
  36. package/packages/core/src/shape/error.ts +4 -0
  37. package/packages/core/src/shape/index.ts +3 -0
  38. package/packages/core/src/shape/number.ts +118 -0
  39. package/packages/core/src/shape/shape.test.ts +792 -0
  40. package/packages/core/src/shape/shape.ts +377 -0
  41. package/packages/core/src/shape/tags.ts +14 -0
  42. package/packages/core/src/shape/transforms/index.ts +3 -0
  43. package/packages/core/src/shape/transforms/json-schema/index.ts +2 -0
  44. package/packages/core/src/shape/transforms/json-schema/transform.test.ts +850 -0
  45. package/packages/core/src/shape/transforms/json-schema/transform.ts +882 -0
  46. package/packages/core/src/shape/transforms/json-schema/types.ts +132 -0
  47. package/packages/core/src/shape/transforms/sql/dialects/dialect.ts +89 -0
  48. package/packages/core/src/shape/transforms/sql/dialects/index.ts +14 -0
  49. package/packages/core/src/shape/transforms/sql/dialects/postgres.ts +392 -0
  50. package/packages/core/src/shape/transforms/sql/dialects/sqlite.ts +333 -0
  51. package/packages/core/src/shape/transforms/sql/from-sql.test.ts +704 -0
  52. package/packages/core/src/shape/transforms/sql/from-sql.ts +210 -0
  53. package/packages/core/src/shape/transforms/sql/index.ts +3 -0
  54. package/packages/core/src/shape/transforms/sql/options.ts +6 -0
  55. package/packages/core/src/shape/transforms/sql/parser/check-decoder.ts +457 -0
  56. package/packages/core/src/shape/transforms/sql/parser/create-domain.ts +105 -0
  57. package/packages/core/src/shape/transforms/sql/parser/create-table.ts +809 -0
  58. package/packages/core/src/shape/transforms/sql/parser/create-type.ts +91 -0
  59. package/packages/core/src/shape/transforms/sql/parser/cursor.ts +179 -0
  60. package/packages/core/src/shape/transforms/sql/parser/default-decoder.ts +129 -0
  61. package/packages/core/src/shape/transforms/sql/parser/lexer.ts +289 -0
  62. package/packages/core/src/shape/transforms/sql/parser/pg-types.ts +247 -0
  63. package/packages/core/src/shape/transforms/sql/parser/sqlite-types.ts +103 -0
  64. package/packages/core/src/shape/transforms/sql/parser/statements.ts +127 -0
  65. package/packages/core/src/shape/transforms/sql/parser/type-spec.ts +159 -0
  66. package/packages/core/src/shape/transforms/sql/transform.sqlite.test.ts +448 -0
  67. package/packages/core/src/shape/transforms/sql/transform.test.ts +880 -0
  68. package/packages/core/src/shape/transforms/sql/transform.ts +295 -0
  69. package/packages/core/src/shape/transforms/typescript/index.ts +1 -0
  70. package/packages/core/src/shape/transforms/typescript/transform.ts +211 -0
  71. package/packages/core/src/shape/tuple.test.ts +171 -0
  72. package/packages/core/src/shape/validate.ts +413 -0
  73. package/packages/core/tsconfig.cjs.json +11 -0
  74. package/packages/core/tsconfig.esm.json +10 -0
  75. package/packages/core/tsconfig.json +23 -0
  76. package/packages/samples/node_modules/.bin/prettier +21 -0
  77. package/packages/samples/node_modules/.bin/tsc +21 -0
  78. package/packages/samples/node_modules/.bin/tsserver +21 -0
  79. package/packages/samples/node_modules/.bin/tsx +21 -0
  80. package/packages/samples/node_modules/.bin/vitest +21 -0
  81. package/packages/samples/package.json +47 -0
  82. package/packages/samples/src/blog.ts +49 -0
  83. package/packages/samples/src/config.ts +50 -0
  84. package/packages/samples/src/ecommerce.ts +65 -0
  85. package/packages/samples/src/embeddings.ts +43 -0
  86. package/packages/samples/src/events.ts +52 -0
  87. package/packages/samples/src/geometry.ts +62 -0
  88. package/packages/samples/src/index.ts +9 -0
  89. package/packages/samples/src/relational.ts +17 -0
  90. package/packages/samples/src/tuples.ts +67 -0
  91. package/packages/samples/src/user.ts +9 -0
  92. package/packages/samples/tsconfig.cjs.json +11 -0
  93. package/packages/samples/tsconfig.esm.json +10 -0
  94. package/packages/samples/tsconfig.json +23 -0
  95. package/pnpm-workspace.yaml +3 -0
  96. package/test-data/json-schema/address.json +35 -0
  97. package/test-data/json-schema/array-of-things.json +36 -0
  98. package/test-data/json-schema/basic.json +21 -0
  99. package/test-data/json-schema/blog-post.json +29 -0
  100. package/test-data/json-schema/calendar.json +48 -0
  101. package/test-data/json-schema/complex-object-with-nested-properties.json +41 -0
  102. package/test-data/json-schema/ecommerce-complex.json +344 -0
  103. package/test-data/json-schema/ecommerce-system.json +27 -0
  104. package/test-data/json-schema/enumerated-values.json +11 -0
  105. package/test-data/json-schema/fstab-entry.json +92 -0
  106. package/test-data/json-schema/geographical-location.json +20 -0
  107. package/test-data/json-schema/health-record.json +41 -0
  108. package/test-data/json-schema/job-posting.json +33 -0
  109. package/test-data/json-schema/movie.json +35 -0
  110. package/test-data/json-schema/regular-expression-pattern.json +12 -0
  111. package/test-data/json-schema/user-profile.json +33 -0
  112. package/test-data/sql/ecommerce.sql +641 -0
@@ -0,0 +1,704 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect } from "vitest";
4
+ import { R } from "../../../common/result";
5
+ import { shapes } from "../../shape";
6
+ import { fromSQL } from "./from-sql";
7
+ import { toSQL } from "./transform";
8
+
9
+ const ok = (res: R.Result<shapes.Shape, string[]>): shapes.Shape => {
10
+ if (!R.isOk(res))
11
+ throw new Error(`expected ok, got err: ${res.error.join("; ")}`);
12
+ return res.value;
13
+ };
14
+
15
+ const err = (res: R.Result<shapes.Shape, string[]>): string[] => {
16
+ if (R.isOk(res))
17
+ throw new Error(`expected err, got ok: ${String(res.value)}`);
18
+ return res.error;
19
+ };
20
+
21
+ const asMapping = (s: shapes.Shape): shapes.ShapeMapping => {
22
+ if (s.type !== "mapping") throw new Error(`expected mapping, got ${s.type}`);
23
+ return s;
24
+ };
25
+
26
+ const asModule = (s: shapes.Shape): shapes.ShapeModule => {
27
+ if (s.type !== "module") throw new Error(`expected module, got ${s.type}`);
28
+ return s;
29
+ };
30
+
31
+ describe("fromSQL — basics", (it) => {
32
+ it("empty / no tables → err", () => {
33
+ expect(err(fromSQL("-- nothing here"))).toEqual([
34
+ "no CREATE TABLE statements found",
35
+ ]);
36
+ });
37
+
38
+ it("minimal table", () => {
39
+ const m = asMapping(
40
+ ok(
41
+ fromSQL(`
42
+ CREATE TABLE t (
43
+ id integer primary key,
44
+ name text not null
45
+ );
46
+ `),
47
+ ),
48
+ );
49
+ expect(m.anno.title).toBe("t");
50
+ expect(Object.keys(m.input)).toEqual(["id", "name"]);
51
+ const id = m.input["id"]!;
52
+ expect(id.type).toBe("number");
53
+ expect(id.anno.primary).toBe(true);
54
+ // primary implies notNull, so no optional
55
+ expect(id.anno.optional).toBeUndefined();
56
+ const name = m.input["name"]!;
57
+ expect(name.type).toBe("string");
58
+ expect(name.anno.optional).toBeUndefined();
59
+ });
60
+
61
+ it("optional column", () => {
62
+ const m = asMapping(ok(fromSQL(`CREATE TABLE t (a integer, b text);`)));
63
+ expect(m.input["a"]!.anno.optional).toBe(true);
64
+ expect(m.input["b"]!.anno.optional).toBe(true);
65
+ });
66
+
67
+ it("scalar pg types map to expected number tags", () => {
68
+ const m = asMapping(
69
+ ok(
70
+ fromSQL(
71
+ `CREATE TABLE t (
72
+ a smallint not null,
73
+ b integer not null,
74
+ c bigint not null,
75
+ d real not null,
76
+ e double precision not null,
77
+ f boolean not null
78
+ );`,
79
+ ),
80
+ ),
81
+ );
82
+ expect((m.input["a"] as shapes.ShapeNumber).tag).toBe("int16");
83
+ expect((m.input["b"] as shapes.ShapeNumber).tag).toBe("int32");
84
+ expect((m.input["c"] as shapes.ShapeNumber).tag).toBe("int64");
85
+ expect((m.input["d"] as shapes.ShapeNumber).tag).toBe("float32");
86
+ expect((m.input["e"] as shapes.ShapeNumber).tag).toBe("float64");
87
+ expect(m.input["f"]!.type).toBe("boolean");
88
+ });
89
+
90
+ it("SERIAL family → autoIncrement", () => {
91
+ const m = asMapping(
92
+ ok(
93
+ fromSQL(
94
+ `CREATE TABLE t (
95
+ a smallserial not null,
96
+ b serial not null,
97
+ c bigserial not null
98
+ );`,
99
+ ),
100
+ ),
101
+ );
102
+ expect((m.input["a"] as shapes.ShapeNumber).tag).toBe("int16");
103
+ expect(m.input["a"]!.anno.autoIncrement).toBe(true);
104
+ expect((m.input["b"] as shapes.ShapeNumber).tag).toBe("int32");
105
+ expect(m.input["b"]!.anno.autoIncrement).toBe(true);
106
+ expect((m.input["c"] as shapes.ShapeNumber).tag).toBe("int64");
107
+ expect(m.input["c"]!.anno.autoIncrement).toBe(true);
108
+ });
109
+
110
+ it("VARCHAR(n) → string with max=n; CHAR(n) → string with min=max=n", () => {
111
+ const m = asMapping(
112
+ ok(
113
+ fromSQL(`CREATE TABLE t (a varchar(50) not null, b char(3) not null);`),
114
+ ),
115
+ );
116
+ expect(m.input["a"]!.anno.max).toBe(50);
117
+ expect(m.input["a"]!.anno.min).toBeUndefined();
118
+ expect(m.input["b"]!.anno.min).toBe(3);
119
+ expect(m.input["b"]!.anno.max).toBe(3);
120
+ });
121
+
122
+ it("NUMERIC(20,0) → uint64; NUMERIC(p,s) → number+format", () => {
123
+ const m = asMapping(
124
+ ok(
125
+ fromSQL(
126
+ `CREATE TABLE t (a numeric(20,0) not null, b numeric(10,2) not null);`,
127
+ ),
128
+ ),
129
+ );
130
+ expect((m.input["a"] as shapes.ShapeNumber).tag).toBe("uint64");
131
+ expect(m.input["b"]!.type).toBe("number");
132
+ expect(m.input["b"]!.anno.format).toBe("pgtype:numeric(10,2)");
133
+ });
134
+
135
+ it("uuid → string + format=uuid + pattern", () => {
136
+ const m = asMapping(ok(fromSQL(`CREATE TABLE t (id uuid not null);`)));
137
+ expect(m.input["id"]!.type).toBe("string");
138
+ expect(m.input["id"]!.anno.format).toBe("uuid");
139
+ expect(m.input["id"]!.anno.pattern).toBeInstanceOf(RegExp);
140
+ });
141
+
142
+ it("text[] / int[] → array shapes", () => {
143
+ const m = asMapping(
144
+ ok(
145
+ fromSQL(`CREATE TABLE t (tags text[] not null, ints int[] not null);`),
146
+ ),
147
+ );
148
+ const tags = m.input["tags"] as shapes.ShapeArray;
149
+ expect(tags.type).toBe("array");
150
+ expect(tags.input.type).toBe("string");
151
+ const ints = m.input["ints"] as shapes.ShapeArray;
152
+ expect(ints.input.type).toBe("number");
153
+ expect((ints.input as shapes.ShapeNumber).tag).toBe("int32");
154
+ });
155
+
156
+ it("vector(N) → vector shape", () => {
157
+ const m = asMapping(
158
+ ok(fromSQL(`CREATE TABLE t (e vector(1536) not null);`)),
159
+ );
160
+ const v = m.input["e"] as shapes.ShapeVector;
161
+ expect(v.type).toBe("vector");
162
+ expect(v.input.dims).toBe(1536);
163
+ });
164
+ });
165
+
166
+ describe("fromSQL — CREATE TYPE / DOMAIN", (it) => {
167
+ it("ENUM resolved to union of literals + title", () => {
168
+ const m = asMapping(
169
+ ok(
170
+ fromSQL(`
171
+ CREATE TYPE color AS ENUM ('red','green','blue');
172
+ CREATE TABLE t (c color not null);
173
+ `),
174
+ ),
175
+ );
176
+ const u = m.input["c"] as shapes.ShapeUnion;
177
+ expect(u.type).toBe("union");
178
+ expect(u.anno.title).toBe("color");
179
+ const vals = (u.input as shapes.Shape[]).map(
180
+ (v) => (v as shapes.ShapeString).input,
181
+ );
182
+ expect(vals).toEqual(["red", "green", "blue"]);
183
+ });
184
+
185
+ it("schema-qualified ENUM", () => {
186
+ const m = asMapping(
187
+ ok(
188
+ fromSQL(`
189
+ CREATE SCHEMA shop;
190
+ CREATE TYPE shop.status AS ENUM ('a','b');
191
+ CREATE TABLE shop.t (s shop.status not null);
192
+ `),
193
+ ),
194
+ );
195
+ expect(m.anno.schema).toBe("shop");
196
+ const u = m.input["s"] as shapes.ShapeUnion;
197
+ expect(u.anno.title).toBe("status");
198
+ expect(u.anno.schema).toBe("shop");
199
+ });
200
+
201
+ it("DOMAIN folds pattern from CHECK", () => {
202
+ const m = asMapping(
203
+ ok(
204
+ fromSQL(`
205
+ CREATE DOMAIN email AS text CHECK (VALUE ~ '^[A-Z]+$');
206
+ CREATE TABLE t (e email not null);
207
+ `),
208
+ ),
209
+ );
210
+ expect(m.input["e"]!.type).toBe("string");
211
+ expect(m.input["e"]!.anno.format).toBe("pgtype:email");
212
+ // Pattern from the domain check (the value~ pattern lands as a pattern)
213
+ expect(m.input["e"]!.anno.pattern).toBe("^[A-Z]+$");
214
+ });
215
+ });
216
+
217
+ describe("fromSQL — constraints", (it) => {
218
+ it("composite primary key (table-level)", () => {
219
+ const m = asMapping(
220
+ ok(
221
+ fromSQL(`
222
+ CREATE TABLE t (
223
+ a integer not null,
224
+ b integer not null,
225
+ primary key (a, b)
226
+ );
227
+ `),
228
+ ),
229
+ );
230
+ expect(m.input["a"]!.anno.primary).toBe(true);
231
+ expect(m.input["b"]!.anno.primary).toBe(true);
232
+ });
233
+
234
+ it("column-level UNIQUE", () => {
235
+ const m = asMapping(
236
+ ok(
237
+ fromSQL(`CREATE TABLE t (id integer primary key, email text unique);`),
238
+ ),
239
+ );
240
+ expect(m.input["email"]!.anno.unique).toBe(true);
241
+ });
242
+
243
+ it("table-level single-col UNIQUE", () => {
244
+ const m = asMapping(
245
+ ok(
246
+ fromSQL(
247
+ `CREATE TABLE t (id integer primary key, email text not null, unique (email));`,
248
+ ),
249
+ ),
250
+ );
251
+ expect(m.input["email"]!.anno.unique).toBe(true);
252
+ });
253
+
254
+ it("multi-col UNIQUE (bare) lands on mapping anno", () => {
255
+ const m = asMapping(
256
+ ok(
257
+ fromSQL(`
258
+ CREATE TABLE t (
259
+ a integer not null,
260
+ b integer not null,
261
+ unique (a, b)
262
+ );
263
+ `),
264
+ ),
265
+ );
266
+ expect(m.anno.uniqueConstraints).toEqual([{ columns: ["a", "b"] }]);
267
+ expect(m.input["a"]!.anno.unique).not.toBe(true);
268
+ expect(m.input["b"]!.anno.unique).not.toBe(true);
269
+ });
270
+
271
+ it("multi-col UNIQUE with named CONSTRAINT preserves name", () => {
272
+ const m = asMapping(
273
+ ok(
274
+ fromSQL(`
275
+ CREATE TABLE t (
276
+ a integer not null,
277
+ b integer not null,
278
+ constraint uniq_ab unique (a, b)
279
+ );
280
+ `),
281
+ ),
282
+ );
283
+ expect(m.anno.uniqueConstraints).toEqual([
284
+ { columns: ["a", "b"], name: "uniq_ab" },
285
+ ]);
286
+ });
287
+
288
+ it("multi-col UNIQUE round-trips through toSQL → fromSQL", () => {
289
+ const m1 = asMapping(
290
+ ok(
291
+ fromSQL(`
292
+ CREATE TABLE t (
293
+ a integer not null,
294
+ b integer not null,
295
+ c integer not null,
296
+ constraint uniq_ab unique (a, b),
297
+ unique (b, c)
298
+ );
299
+ `),
300
+ ),
301
+ );
302
+ const sqlRes = toSQL(m1);
303
+ if (!R.isOk(sqlRes)) throw new Error(sqlRes.error.join("; "));
304
+ const m2 = asMapping(ok(fromSQL(sqlRes.value)));
305
+ expect(m2.anno.uniqueConstraints).toEqual(m1.anno.uniqueConstraints);
306
+ });
307
+ });
308
+
309
+ describe("fromSQL — multi-table", (it) => {
310
+ it("two tables → module", () => {
311
+ const mod = asModule(
312
+ ok(
313
+ fromSQL(`
314
+ CREATE TABLE u (id integer primary key);
315
+ CREATE TABLE t (
316
+ uid integer not null references u(id) on delete cascade
317
+ );
318
+ `),
319
+ ),
320
+ );
321
+ expect(Object.keys(mod.input).sort()).toEqual(["t", "u"]);
322
+ const t = mod.input["t"]!;
323
+ const uid = t.input["uid"]!;
324
+ expect(uid.anno.foreign).toEqual({
325
+ table: "u",
326
+ column: "id",
327
+ onDelete: "cascade",
328
+ });
329
+ });
330
+
331
+ it("multi-col FK decomposes to per-column foreign", () => {
332
+ const mod = asModule(
333
+ ok(
334
+ fromSQL(`
335
+ CREATE TABLE parent (
336
+ id uuid not null,
337
+ created_at timestamptz not null,
338
+ primary key (id, created_at)
339
+ );
340
+ CREATE TABLE child (
341
+ pid uuid not null,
342
+ pcreated timestamptz not null,
343
+ foreign key (pid, pcreated) references parent(id, created_at) on delete cascade
344
+ );
345
+ `),
346
+ ),
347
+ );
348
+ const child = mod.input["child"]!;
349
+ expect(child.input["pid"]!.anno.foreign).toEqual({
350
+ table: "parent",
351
+ column: "id",
352
+ onDelete: "cascade",
353
+ });
354
+ expect(child.input["pcreated"]!.anno.foreign).toEqual({
355
+ table: "parent",
356
+ column: "created_at",
357
+ onDelete: "cascade",
358
+ });
359
+ });
360
+ });
361
+
362
+ describe("fromSQL — defaults", (it) => {
363
+ it("literal defaults are decoded; expressions preserved", () => {
364
+ const m = asMapping(
365
+ ok(
366
+ fromSQL(`
367
+ CREATE TABLE t (
368
+ a text not null default 'hello',
369
+ b integer not null default 42,
370
+ c boolean not null default true,
371
+ d uuid not null default uuid_generate_v4(),
372
+ e timestamptz not null default CURRENT_TIMESTAMP,
373
+ f jsonb not null default '{}'::jsonb
374
+ );
375
+ `),
376
+ ),
377
+ );
378
+ expect(m.input["a"]!.anno.default).toBe("hello");
379
+ expect(m.input["b"]!.anno.default).toBe(42);
380
+ expect(m.input["c"]!.anno.default).toBe(true);
381
+ expect(m.input["d"]!.anno.defaultExpr).toBe("UUID_GENERATE_V4 ( )");
382
+ expect(m.input["e"]!.anno.defaultExpr).toBe("CURRENT_TIMESTAMP");
383
+ expect(m.input["f"]!.anno.default).toEqual({});
384
+ });
385
+ });
386
+
387
+ describe("fromSQL — CHECK decoding", (it) => {
388
+ it("char_length / cardinality / pattern / IN", () => {
389
+ const m = asMapping(
390
+ ok(
391
+ fromSQL(`
392
+ CREATE TABLE t (
393
+ name text not null check (char_length(name) >= 3),
394
+ tags text[] not null check (cardinality(tags) <= 5),
395
+ ssn text not null check (ssn ~ '^[0-9]+$'),
396
+ color text not null check (color in ('red','blue'))
397
+ );
398
+ `),
399
+ ),
400
+ );
401
+ expect(m.input["name"]!.anno.min).toBe(3);
402
+ expect(m.input["tags"]!.anno.max).toBe(5);
403
+ expect(m.input["ssn"]!.anno.pattern).toBe("^[0-9]+$");
404
+ expect(m.input["color"]!.type).toBe("union");
405
+ const u = m.input["color"] as shapes.ShapeUnion;
406
+ expect((u.input as shapes.Shape[]).map((x) => (x as any).input)).toEqual([
407
+ "red",
408
+ "blue",
409
+ ]);
410
+ });
411
+
412
+ it("BETWEEN decoded", () => {
413
+ const m = asMapping(
414
+ ok(
415
+ fromSQL(`
416
+ CREATE TABLE t (
417
+ r integer not null check (r between 1 and 5)
418
+ );
419
+ `),
420
+ ),
421
+ );
422
+ expect(m.input["r"]!.anno.min).toBe(1);
423
+ expect(m.input["r"]!.anno.max).toBe(5);
424
+ });
425
+
426
+ it("opaque CHECK preserved as raw", () => {
427
+ const m = asMapping(
428
+ ok(
429
+ fromSQL(`
430
+ CREATE TABLE t (
431
+ a integer not null,
432
+ b integer not null,
433
+ check (a + b > 0)
434
+ );
435
+ `),
436
+ ),
437
+ );
438
+ expect(m.anno.check).toBeDefined();
439
+ });
440
+
441
+ it("IS NULL OR pattern is peeled", () => {
442
+ const m = asMapping(
443
+ ok(
444
+ fromSQL(`
445
+ CREATE TABLE t (
446
+ dob date,
447
+ phone text check (phone is null or phone ~ '^\\+?[0-9]+$')
448
+ );
449
+ `),
450
+ ),
451
+ );
452
+ expect(m.input["phone"]!.anno.pattern).toBe("^\\+?[0-9]+$");
453
+ });
454
+ });
455
+
456
+ describe("fromSQL — GENERATED columns", (it) => {
457
+ it("GENERATED ALWAYS AS (...) STORED", () => {
458
+ const m = asMapping(
459
+ ok(
460
+ fromSQL(`
461
+ CREATE TABLE t (
462
+ a integer not null,
463
+ b integer not null,
464
+ c integer generated always as (a + b) stored
465
+ );
466
+ `),
467
+ ),
468
+ );
469
+ expect(m.input["c"]!.anno.generated).toEqual({
470
+ expression: "a + b",
471
+ stored: true,
472
+ });
473
+ });
474
+
475
+ it("GENERATED ALWAYS AS IDENTITY → autoIncrement", () => {
476
+ const m = asMapping(
477
+ ok(
478
+ fromSQL(`
479
+ CREATE TABLE t (id integer generated always as identity primary key);
480
+ `),
481
+ ),
482
+ );
483
+ expect(m.input["id"]!.anno.autoIncrement).toBe(true);
484
+ expect(m.input["id"]!.anno.primary).toBe(true);
485
+ });
486
+ });
487
+
488
+ describe("fromSQL — TSTZRANGE", (it) => {
489
+ it("tstzrange → range(date())", () => {
490
+ const m = asMapping(
491
+ ok(fromSQL(`CREATE TABLE t (during tstzrange not null);`)),
492
+ );
493
+ const r = m.input["during"] as shapes.ShapeRange;
494
+ expect(r.type).toBe("range");
495
+ expect(r.input.type).toBe("date");
496
+ expect(r.anno.format).toBe("pgtype:tstzrange");
497
+ });
498
+ });
499
+
500
+ describe("fromSQL — skip-able statements", (it) => {
501
+ it("ignores INDEX / TRIGGER / VIEW / INSERT / EXTENSION / SCHEMA / FUNCTION", () => {
502
+ const m = asMapping(
503
+ ok(
504
+ fromSQL(`
505
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
506
+ CREATE SCHEMA IF NOT EXISTS s;
507
+ CREATE TABLE t (id integer primary key);
508
+ CREATE INDEX t_idx ON t(id);
509
+ CREATE OR REPLACE FUNCTION foo() RETURNS integer LANGUAGE sql AS $$ SELECT 1; $$;
510
+ CREATE TRIGGER tr BEFORE UPDATE ON t FOR EACH ROW EXECUTE FUNCTION foo();
511
+ CREATE VIEW v AS SELECT * FROM t;
512
+ CREATE MATERIALIZED VIEW mv AS SELECT * FROM t WITH NO DATA;
513
+ ALTER TABLE t ENABLE ROW LEVEL SECURITY;
514
+ CREATE POLICY p ON t FOR SELECT USING (true);
515
+ INSERT INTO t (id) VALUES (1);
516
+ `),
517
+ ),
518
+ );
519
+ expect(m.anno.title).toBe("t");
520
+ expect(Object.keys(m.input)).toEqual(["id"]);
521
+ });
522
+
523
+ it("PARTITION OF children skipped; parent with PARTITION BY parsed", () => {
524
+ const mod = asModule(
525
+ ok(
526
+ fromSQL(`
527
+ CREATE TABLE orders (
528
+ id uuid not null,
529
+ created_at timestamptz not null,
530
+ primary key (id, created_at)
531
+ ) PARTITION BY RANGE (created_at);
532
+ CREATE TABLE orders_2025 PARTITION OF orders FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
533
+ CREATE TABLE other (id integer primary key);
534
+ `),
535
+ ),
536
+ );
537
+ expect(Object.keys(mod.input).sort()).toEqual(["orders", "other"]);
538
+ expect(mod.input["orders"]!.input["id"]!.anno.primary).toBe(true);
539
+ });
540
+ });
541
+
542
+ describe("fromSQL — schema qualifier", (it) => {
543
+ it("CREATE TABLE shop.users → title=users, schema=shop", () => {
544
+ const m = asMapping(
545
+ ok(fromSQL(`CREATE TABLE shop.users (id integer primary key);`)),
546
+ );
547
+ expect(m.anno.title).toBe("users");
548
+ expect(m.anno.schema).toBe("shop");
549
+ });
550
+ });
551
+
552
+ describe("fromSQL — ecommerce.sql fixture", (it) => {
553
+ const fixturePath = join(
554
+ process.cwd(),
555
+ "..",
556
+ "..",
557
+ "test-data",
558
+ "sql",
559
+ "ecommerce.sql",
560
+ );
561
+
562
+ it("parses successfully and produces expected tables", () => {
563
+ const sql = readFileSync(fixturePath, "utf8");
564
+ const result = fromSQL(sql);
565
+ if (!R.isOk(result)) {
566
+ throw new Error(`fromSQL failed: ${result.error.join("\n")}`);
567
+ }
568
+ const mod = asModule(result.value);
569
+ const expected = [
570
+ "users",
571
+ "addresses",
572
+ "vendors",
573
+ "categories",
574
+ "products",
575
+ "product_variants",
576
+ "inventory_movements",
577
+ "carts",
578
+ "cart_items",
579
+ "orders",
580
+ "order_items",
581
+ "payments",
582
+ "reviews",
583
+ "wishlists",
584
+ "coupons",
585
+ "promotions",
586
+ "event_log",
587
+ ];
588
+ for (const name of expected) {
589
+ expect(
590
+ Object.keys(mod.input).includes(name),
591
+ `missing table ${name}`,
592
+ ).toBe(true);
593
+ }
594
+ // orders_2024/5/6 should NOT be present (skipped partition children)
595
+ expect(Object.keys(mod.input).includes("orders_2024")).toBe(false);
596
+
597
+ // Spot-check users
598
+ const users = mod.input["users"]!;
599
+ expect(users.anno.schema).toBe("shop");
600
+ expect(users.input["email"]!.type).toBe("string");
601
+ // email is a domain — pattern from domain CHECK
602
+ expect(users.input["email"]!.anno.format).toBe("pgtype:shop.email");
603
+ // is_active should have default true
604
+ expect(users.input["is_active"]!.anno.default).toBe(true);
605
+
606
+ // FK in addresses with onDelete cascade
607
+ const addrs = mod.input["addresses"]!;
608
+ expect(addrs.input["user_id"]!.anno.foreign).toEqual({
609
+ table: "users",
610
+ column: "id",
611
+ onDelete: "cascade",
612
+ });
613
+
614
+ // products.search_doc should be a generated column
615
+ const products = mod.input["products"]!;
616
+ expect(products.input["search_doc"]!.anno.generated).toBeDefined();
617
+ });
618
+
619
+ it("toSQL round-trip emits something that re-parses to the same table count", () => {
620
+ const sql = readFileSync(fixturePath, "utf8");
621
+ const first = fromSQL(sql);
622
+ if (!R.isOk(first)) throw new Error(first.error.join("; "));
623
+ const emitted = toSQL(first.value);
624
+ if (!R.isOk(emitted))
625
+ throw new Error(`toSQL failed: ${emitted.error.join("; ")}`);
626
+ const second = fromSQL(emitted.value);
627
+ if (!R.isOk(second))
628
+ throw new Error(`re-parse failed: ${second.error.join("; ")}`);
629
+ const a = asModule(first.value);
630
+ const b = asModule(second.value);
631
+ expect(Object.keys(b.input).sort()).toEqual(Object.keys(a.input).sort());
632
+ });
633
+ });
634
+
635
+ describe("toSQL — new annotation emission", (it) => {
636
+ it("ON DELETE CASCADE in REFERENCES", () => {
637
+ const shape = ok(
638
+ fromSQL(`CREATE TABLE u (id integer primary key);
639
+ CREATE TABLE t (uid integer not null references u(id) on delete cascade);
640
+ `),
641
+ );
642
+ const out = toSQL(shape);
643
+ if (!R.isOk(out)) throw new Error(out.error.join("; "));
644
+ expect(out.value).toContain('REFERENCES "u"("id") ON DELETE CASCADE');
645
+ });
646
+
647
+ it("schema prefix on table name + CREATE SCHEMA prelude", () => {
648
+ const out = toSQL(
649
+ ok(
650
+ fromSQL(`
651
+ CREATE TABLE s.t (id integer primary key);
652
+ CREATE TABLE other (id integer primary key);
653
+ `),
654
+ ),
655
+ );
656
+ if (!R.isOk(out)) throw new Error(out.error.join("; "));
657
+ expect(out.value).toContain('CREATE SCHEMA IF NOT EXISTS "s"');
658
+ expect(out.value).toContain('CREATE TABLE IF NOT EXISTS "s"."t"');
659
+ });
660
+
661
+ it("defaultExpr is emitted verbatim", () => {
662
+ const out = toSQL(
663
+ ok(
664
+ fromSQL(
665
+ `CREATE TABLE t (id uuid not null default uuid_generate_v4() primary key);`,
666
+ ),
667
+ ),
668
+ );
669
+ if (!R.isOk(out)) throw new Error(out.error.join("; "));
670
+ expect(out.value).toContain("DEFAULT UUID_GENERATE_V4 ( )");
671
+ });
672
+
673
+ it("raw CHECK at mapping level", () => {
674
+ const out = toSQL(
675
+ ok(
676
+ fromSQL(`
677
+ CREATE TABLE t (
678
+ a integer not null,
679
+ b integer not null,
680
+ check (a + b > 0)
681
+ );
682
+ `),
683
+ ),
684
+ );
685
+ if (!R.isOk(out)) throw new Error(out.error.join("; "));
686
+ expect(out.value).toContain("CHECK (a + b > 0)");
687
+ });
688
+
689
+ it("GENERATED ALWAYS AS (...) STORED is re-emitted", () => {
690
+ const out = toSQL(
691
+ ok(
692
+ fromSQL(`
693
+ CREATE TABLE t (
694
+ a integer not null,
695
+ b integer not null,
696
+ c integer generated always as (a + b) stored
697
+ );
698
+ `),
699
+ ),
700
+ );
701
+ if (!R.isOk(out)) throw new Error(out.error.join("; "));
702
+ expect(out.value).toContain("GENERATED ALWAYS AS (a + b) STORED");
703
+ });
704
+ });