@casekit/orm2-cli 0.0.0-20250331202540 → 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 (65) hide show
  1. package/build/cli.js +1 -2
  2. package/build/commands/db-drop/handler.js +0 -3
  3. package/build/commands/db-drop.test.js +1 -0
  4. package/build/commands/db-pull/handler.js +21 -8
  5. package/build/commands/db-pull/options.d.ts +5 -0
  6. package/build/commands/db-pull/options.js +5 -0
  7. package/build/commands/db-pull/util/relationNames.d.ts +3 -0
  8. package/build/commands/db-pull/util/relationNames.js +14 -0
  9. package/build/commands/db-pull/util/relationNames.test.js +61 -0
  10. package/build/commands/db-pull/util/renderDefault.d.ts +3 -0
  11. package/build/commands/db-pull/util/renderDefault.js +46 -0
  12. package/build/commands/db-pull/util/renderDefault.test.d.ts +1 -0
  13. package/build/commands/db-pull/util/renderDefault.test.js +67 -0
  14. package/build/commands/db-pull/util/renderFieldDefinition.d.ts +2 -0
  15. package/build/commands/db-pull/util/renderFieldDefinition.js +50 -0
  16. package/build/commands/db-pull/util/renderFieldDefinition.test.d.ts +1 -0
  17. package/build/commands/db-pull/util/renderFieldDefinition.test.js +182 -0
  18. package/build/commands/db-pull/util/renderModel.basic.test.d.ts +1 -0
  19. package/build/commands/db-pull/util/renderModel.basic.test.js +169 -0
  20. package/build/commands/db-pull/util/renderModel.constraints.test.d.ts +1 -0
  21. package/build/commands/db-pull/util/renderModel.constraints.test.js +677 -0
  22. package/build/commands/db-pull/util/renderModel.d.ts +2 -0
  23. package/build/commands/db-pull/util/renderModel.defaultValues.test.d.ts +1 -0
  24. package/build/commands/db-pull/util/renderModel.defaultValues.test.js +518 -0
  25. package/build/commands/db-pull/util/renderModel.js +88 -0
  26. package/build/commands/db-pull/util/renderModel.relations.test.d.ts +1 -0
  27. package/build/commands/db-pull/util/renderModel.relations.test.js +880 -0
  28. package/build/commands/db-pull/util/renderModel.types.test.d.ts +1 -0
  29. package/build/commands/db-pull/util/renderModel.types.test.js +703 -0
  30. package/build/commands/db-pull/util/renderRelations.d.ts +2 -0
  31. package/build/commands/db-pull/util/renderRelations.js +55 -0
  32. package/build/commands/db-pull/util/renderRelations.test.d.ts +1 -0
  33. package/build/commands/db-pull/util/renderRelations.test.js +165 -0
  34. package/build/commands/db-pull/util/renderType.d.ts +6 -0
  35. package/build/commands/db-pull/util/renderType.js +55 -0
  36. package/build/commands/db-pull/util/renderType.test.d.ts +1 -0
  37. package/build/commands/db-pull/util/renderType.test.js +46 -0
  38. package/build/commands/db-pull.d.ts +10 -0
  39. package/build/commands/db-pull.test.js +625 -0
  40. package/build/commands/db-push/handler.js +0 -3
  41. package/build/commands/init/handler.js +16 -3
  42. package/build/commands/init/util/generateConfigFile.js +2 -3
  43. package/build/commands/init/util/generateDbFile.js +13 -24
  44. package/build/commands/init/util/generateModelsFile.d.ts +1 -1
  45. package/build/commands/init/util/generateModelsFile.js +6 -2
  46. package/build/commands/init.test.js +19 -31
  47. package/build/types.d.ts +2 -2
  48. package/build/util/createOrOverwriteFile.d.ts +1 -1
  49. package/build/util/createOrOverwriteFile.js +1 -1
  50. package/build/util/loadConfig.js +5 -1
  51. package/package.json +26 -25
  52. package/build/commands/generate-model/handler.d.ts +0 -3
  53. package/build/commands/generate-model/handler.js +0 -10
  54. package/build/commands/generate-model/options.d.ts +0 -18
  55. package/build/commands/generate-model/options.js +0 -18
  56. package/build/commands/generate-model/util/generateModelFile.d.ts +0 -3
  57. package/build/commands/generate-model/util/generateModelFile.js +0 -59
  58. package/build/commands/generate-model/util/generateModelFile.test.js +0 -67
  59. package/build/commands/generate-model/util/regenerateModelsFile.d.ts +0 -2
  60. package/build/commands/generate-model/util/regenerateModelsFile.js +0 -50
  61. package/build/commands/generate-model.d.ts +0 -40
  62. package/build/commands/generate-model.js +0 -8
  63. package/build/commands/generate-model.test.js +0 -55
  64. /package/build/commands/{generate-model/util/generateModelFile.test.d.ts → db-pull/util/relationNames.test.d.ts} +0 -0
  65. /package/build/commands/{generate-model.test.d.ts → db-pull.test.d.ts} +0 -0
@@ -0,0 +1,625 @@
1
+ import * as prompts from "@inquirer/prompts";
2
+ import { fs, vol } from "memfs";
3
+ import { randomUUID } from "node:crypto";
4
+ import pg from "pg";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
+ import yargs from "yargs";
7
+ import { orm, sql } from "@casekit/orm2";
8
+ import { unindent } from "@casekit/unindent";
9
+ import { globalOptions } from "#options.js";
10
+ import * as loadConfig from "#util/loadConfig.js";
11
+ import { dbPull } from "./db-pull.js";
12
+ import { dbPush } from "./db-push.js";
13
+ describe("db pull", () => {
14
+ const schema = `orm_${randomUUID()}`;
15
+ let db;
16
+ beforeEach(async () => {
17
+ db = new pg.Client();
18
+ await db.connect();
19
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
20
+ // Mock filesystem
21
+ vi.spyOn(process, "cwd").mockReturnValue("/project");
22
+ const userModelContent = unindent `
23
+ import { type ModelDefinition, sql } from "@casekit/orm2";
24
+
25
+ export const user = {
26
+ fields: {
27
+ id: { type: "uuid", primaryKey: true },
28
+ createdAt: { type: "timestamp", default: sql\`now()\` },
29
+ email: { type: "text", unique: true },
30
+ name: { type: "text", nullable: true },
31
+ },
32
+ } as const satisfies ModelDefinition;
33
+ `;
34
+ vol.fromJSON({
35
+ "orm.config.ts": unindent `
36
+ import { orm } from "@casekit/orm2";
37
+ import { user } from "./app/db.server/models/user.ts";
38
+
39
+ export default {
40
+ db: orm({
41
+ schema: "${schema}",
42
+ models: { user },
43
+ }),
44
+ directory: "./app/db.server",
45
+ };
46
+ `,
47
+ "app/db.server/models/user.ts": userModelContent,
48
+ }, "/project");
49
+ // Mock the specific config file path to return the memfs content
50
+ vi.doMock("/project/orm.config.ts", () => {
51
+ const userContent = fs.readFileSync("/project/app/db.server/models/user.ts", "utf8");
52
+ // Extract the user object from the file content
53
+ const userMatch = /export const user = ({[\s\S]*}) as const satisfies ModelDefinition;/.exec(userContent);
54
+ if (!userMatch) {
55
+ throw new Error("Could not extract user from model file");
56
+ }
57
+ // Dynamic evaluation to get the user object
58
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
59
+ const user = new Function("sql", "orm", `return ${userMatch[1]}`)(sql, orm);
60
+ return {
61
+ default: {
62
+ db: orm({
63
+ schema,
64
+ models: { user },
65
+ }),
66
+ directory: "./app/db.server",
67
+ },
68
+ };
69
+ });
70
+ // Import the mocked config
71
+ const path = "/project/orm.config.ts";
72
+ const { default: config } = await import(path);
73
+ vi.spyOn(loadConfig, "loadConfig").mockResolvedValue(config);
74
+ });
75
+ afterEach(async () => {
76
+ await db.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
77
+ await db.end();
78
+ vol.reset();
79
+ vi.restoreAllMocks();
80
+ });
81
+ test("creates model files for tables in the specified schema", async () => {
82
+ // Set up database schema
83
+ // await yargs().options(globalOptions).command(dbDrop).parseAsync("drop");
84
+ await yargs().options(globalOptions).command(dbPush).parseAsync("push");
85
+ await db.query(`
86
+ CREATE TABLE "${schema}".post (
87
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
88
+ title TEXT NOT NULL,
89
+ content TEXT,
90
+ created_at TIMESTAMP DEFAULT now()
91
+ );
92
+ `);
93
+ // Mock prompts to auto-confirm file writes
94
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
95
+ // Run db-pull
96
+ await yargs()
97
+ .options(globalOptions)
98
+ .command(dbPull)
99
+ .parseAsync(`pull --schema ${schema}`);
100
+ // Check that post model was created
101
+ const postModel = fs.readFileSync("/project/app/db.server/models/post.ts", "utf8");
102
+ expect(postModel.trim()).toEqual(unindent `
103
+ import { type ModelDefinition, sql } from "@casekit/orm2";
104
+
105
+ export const post = {
106
+ schema: "${schema}",
107
+ fields: {
108
+ id: { type: "uuid", primaryKey: true, default: sql\`gen_random_uuid()\` },
109
+ title: { type: "text" },
110
+ content: { type: "text", nullable: true },
111
+ createdAt: {
112
+ column: "created_at",
113
+ type: "timestamp without time zone",
114
+ nullable: true,
115
+ default: sql\`now()\`,
116
+ },
117
+ },
118
+ } as const satisfies ModelDefinition;
119
+ `);
120
+ });
121
+ test("handles multiple schemas", async () => {
122
+ // Set up database with multiple schemas
123
+ const schema1 = `schema1_${randomUUID()}`;
124
+ const schema2 = `schema2_${randomUUID()}`;
125
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${schema1}"`);
126
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${schema2}"`);
127
+ await db.query(`
128
+ CREATE TABLE "${schema1}".table1 (
129
+ id SERIAL PRIMARY KEY,
130
+ name TEXT NOT NULL
131
+ );
132
+ `);
133
+ await db.query(`
134
+ CREATE TABLE "${schema2}".table2 (
135
+ id SERIAL PRIMARY KEY,
136
+ description TEXT
137
+ );
138
+ `);
139
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
140
+ // Run db-pull with multiple schemas
141
+ await yargs()
142
+ .options(globalOptions)
143
+ .command(dbPull)
144
+ .parseAsync(`pull --schema ${schema1} --schema ${schema2}`);
145
+ // Check that both models were created
146
+ const table1Model = fs.readFileSync("/project/app/db.server/models/table1.ts", "utf8");
147
+ const table2Model = fs.readFileSync("/project/app/db.server/models/table2.ts", "utf8");
148
+ expect(table1Model.trim()).toEqual(unindent `
149
+ import { type ModelDefinition } from "@casekit/orm2";
150
+
151
+ export const table1 = {
152
+ schema: "${schema1}",
153
+ fields: {
154
+ id: { type: "serial", primaryKey: true },
155
+ name: { type: "text" },
156
+ },
157
+ } as const satisfies ModelDefinition;
158
+ `);
159
+ expect(table2Model.trim()).toEqual(unindent `
160
+ import { type ModelDefinition } from "@casekit/orm2";
161
+
162
+ export const table2 = {
163
+ schema: "${schema2}",
164
+ fields: {
165
+ id: { type: "serial", primaryKey: true },
166
+ description: { type: "text", nullable: true },
167
+ },
168
+ } as const satisfies ModelDefinition;
169
+ `);
170
+ // Clean up
171
+ await db.query(`DROP SCHEMA "${schema1}" CASCADE`);
172
+ await db.query(`DROP SCHEMA "${schema2}" CASCADE`);
173
+ });
174
+ test("uses default schema from config when no schema specified", async () => {
175
+ // Set up database schema
176
+ await yargs().options(globalOptions).command(dbPush).parseAsync("push");
177
+ await db.query(`
178
+ CREATE TABLE "${schema}".product (
179
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
180
+ name TEXT NOT NULL,
181
+ price NUMERIC(10,2)
182
+ );
183
+ `);
184
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
185
+ // Run db-pull without specifying schema
186
+ await yargs().options(globalOptions).command(dbPull).parseAsync("pull");
187
+ // Check that product model was created
188
+ const productModel = fs.readFileSync("/project/app/db.server/models/product.ts", "utf8");
189
+ expect(productModel.trim()).toEqual(unindent `
190
+ import { type ModelDefinition, sql } from "@casekit/orm2";
191
+
192
+ export const product = {
193
+ schema: "${schema}",
194
+ fields: {
195
+ id: { type: "uuid", primaryKey: true, default: sql\`gen_random_uuid()\` },
196
+ name: { type: "text" },
197
+ price: { type: "numeric", nullable: true },
198
+ },
199
+ } as const satisfies ModelDefinition;
200
+ `);
201
+ });
202
+ test("overwrites existing files with --force flag", async () => {
203
+ // Set up database schema
204
+ await yargs().options(globalOptions).command(dbPush).parseAsync("push");
205
+ // Create existing file with different content
206
+ vol.fromJSON({
207
+ "app/db.server/models/user.ts": unindent `
208
+ // This is the old user model that should be overwritten
209
+ export const user = { old: true };
210
+ `,
211
+ }, "/project");
212
+ // Run db-pull with --force
213
+ await yargs()
214
+ .options(globalOptions)
215
+ .command(dbPull)
216
+ .parseAsync(`pull --schema ${schema} --force`);
217
+ // Check that user model was overwritten
218
+ const userModel = fs.readFileSync("/project/app/db.server/models/user.ts", "utf8");
219
+ // Verify it doesn't contain the old content
220
+ expect(userModel).not.toContain("old: true");
221
+ expect(userModel.trim()).toEqual(unindent `
222
+ import { type ModelDefinition, sql } from "@casekit/orm2";
223
+
224
+ export const user = {
225
+ schema: "${schema}",
226
+ fields: {
227
+ id: { type: "uuid", primaryKey: true },
228
+ createdAt: { type: "timestamp without time zone", default: sql\`now()\` },
229
+ email: { type: "text", unique: true },
230
+ name: { type: "text", nullable: true },
231
+ },
232
+ } as const satisfies ModelDefinition;
233
+ `);
234
+ });
235
+ test("handles tables with multi-column primary keys", async () => {
236
+ const testSchema = `testschema_${randomUUID()}`;
237
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${testSchema}"`);
238
+ await db.query(`
239
+ CREATE TABLE "${testSchema}".user_role (
240
+ user_id UUID NOT NULL,
241
+ role_id UUID NOT NULL,
242
+ assigned_at TIMESTAMP DEFAULT now(),
243
+ PRIMARY KEY (user_id, role_id)
244
+ );
245
+ `);
246
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
247
+ await yargs()
248
+ .options(globalOptions)
249
+ .command(dbPull)
250
+ .parseAsync(`pull --schema ${testSchema}`);
251
+ const model = fs.readFileSync("/project/app/db.server/models/userRole.ts", "utf8");
252
+ expect(model.trim()).toEqual(unindent `
253
+ import { type ModelDefinition, sql } from "@casekit/orm2";
254
+
255
+ export const userRole = {
256
+ schema: "${testSchema}",
257
+ table: "user_role",
258
+ fields: {
259
+ userId: { column: "user_id", type: "uuid" },
260
+ roleId: { column: "role_id", type: "uuid" },
261
+ assignedAt: {
262
+ column: "assigned_at",
263
+ type: "timestamp without time zone",
264
+ nullable: true,
265
+ default: sql\`now()\`,
266
+ },
267
+ },
268
+ primaryKey: ["userId", "roleId"],
269
+ } as const satisfies ModelDefinition;
270
+ `);
271
+ await db.query(`DROP SCHEMA "${testSchema}" CASCADE`);
272
+ });
273
+ test("handles empty schema gracefully", async () => {
274
+ const emptySchema = `emptyschema_${randomUUID()}`;
275
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${emptySchema}"`);
276
+ const result = await yargs()
277
+ .options(globalOptions)
278
+ .command(dbPull)
279
+ .parseAsync(`pull --schema ${emptySchema}`);
280
+ // Should complete without error
281
+ expect(result).toBeDefined();
282
+ await db.query(`DROP SCHEMA "${emptySchema}"`);
283
+ });
284
+ test("handles all supported data types (kitchen sink test)", async () => {
285
+ const testSchema = `kitchensink_${randomUUID()}`;
286
+ const modelFileContent = unindent `
287
+ // This file will be overwritten by the pull command
288
+ import { type ModelDefinition, sql } from "@casekit/orm2";
289
+
290
+ export const kitchenSink = {
291
+ schema: "${testSchema}",
292
+ fields: {
293
+ id: { type: "serial", primaryKey: true },
294
+ charField: { type: "character", default: "A" },
295
+ characterField: { type: "character", default: "B" },
296
+ characterNField: { type: "character", default: "Test Character" },
297
+ varcharField: {
298
+ type: "character varying",
299
+ default: sql\`'varchar test'::character varying\`,
300
+ },
301
+ varcharNField: {
302
+ type: "character varying",
303
+ default: sql\`'varchar24 test'::character varying\`,
304
+ },
305
+ textField: { type: "text", default: "This is a text field" },
306
+ bitField: { type: "bit", default: sql\`'1'::bit(1)\` },
307
+ bitNField: { type: "bit", default: sql\`'101'::bit(3)\` },
308
+ bitVaryingField: {
309
+ type: "bit varying",
310
+ default: sql\`'11010'::bit varying\`,
311
+ },
312
+ bitVaryingNField: {
313
+ type: "bit varying",
314
+ default: sql\`'110'::bit varying(3)\`,
315
+ },
316
+ numericField: { type: "numeric", default: 123.45 },
317
+ numericPrecisionField: { type: "numeric", default: 999.99 },
318
+ integerField: { type: "integer", default: 42 },
319
+ bigintField: { type: "bigint", default: "9223372036854775807" },
320
+ smallintField: { type: "smallint", default: 32767 },
321
+ serialField: { type: "serial" },
322
+ bigserialField: { type: "bigserial" },
323
+ smallserialField: { type: "smallserial" },
324
+ doublePrecisionField: {
325
+ type: "double precision",
326
+ default: 3.141592653589793,
327
+ },
328
+ realField: { type: "real", default: 2.71828 },
329
+ moneyField: { type: "money", default: sql\`'$1,234.56'::money\` },
330
+ booleanField: { type: "boolean", default: true },
331
+ uuidField: { type: "uuid", default: sql\`gen_random_uuid()\` },
332
+ dateField: { type: "date", default: sql\`CURRENT_DATE\` },
333
+ timeField: {
334
+ type: "time without time zone",
335
+ default: sql\`CURRENT_TIME\`,
336
+ },
337
+ timeWithPrecisionField: {
338
+ type: "time without time zone",
339
+ default: sql\`CURRENT_TIME\`,
340
+ },
341
+ timetzField: {
342
+ type: "time with time zone",
343
+ default: sql\`CURRENT_TIME\`,
344
+ },
345
+ timeWithTzField: {
346
+ type: "time with time zone",
347
+ default: sql\`CURRENT_TIME\`,
348
+ },
349
+ timestampField: {
350
+ type: "timestamp without time zone",
351
+ default: sql\`CURRENT_TIMESTAMP\`,
352
+ },
353
+ timestampWithPrecisionField: {
354
+ type: "timestamp without time zone",
355
+ default: sql\`CURRENT_TIMESTAMP\`,
356
+ },
357
+ timestamptzField: {
358
+ type: "timestamp with time zone",
359
+ default: sql\`CURRENT_TIMESTAMP\`,
360
+ },
361
+ timestampWithTzField: {
362
+ type: "timestamp with time zone",
363
+ default: sql\`CURRENT_TIMESTAMP\`,
364
+ },
365
+ jsonField: { type: "json", default: sql\`'{"key": "value"}'::json\` },
366
+ jsonbField: { type: "jsonb", default: sql\`'{"key": "value"}'::jsonb\` },
367
+ lineField: { type: "line", default: sql\`'{1,2,3}'::line\` },
368
+ lsegField: { type: "lseg", default: sql\`'[(0,0),(1,1)]'::lseg\` },
369
+ boxField: { type: "box", default: sql\`'(1,1),(0,0)'::box\` },
370
+ polygonField: {
371
+ type: "polygon",
372
+ default: sql\`'((0,0),(1,0),(1,1),(0,1))'::polygon\`,
373
+ },
374
+ cidrField: { type: "cidr", default: sql\`'192.168.1.0/24'::cidr\` },
375
+ inetField: { type: "inet", default: sql\`'192.168.1.1'::inet\` },
376
+ macaddrField: {
377
+ type: "macaddr",
378
+ default: sql\`'08:00:2b:01:02:03'::macaddr\`,
379
+ },
380
+ macaddr8Field: {
381
+ type: "macaddr8",
382
+ default: sql\`'08:00:2b:01:02:03:04:05'::macaddr8\`,
383
+ },
384
+ byteaField: { type: "bytea", default: sql\`'\\x41'::bytea\` },
385
+ xmlField: {
386
+ type: "xml",
387
+ default: sql\`'<root><element>value</element></root>'::xml\`,
388
+ },
389
+ pathField: { type: "path", default: sql\`'((0,0),(1,1),(2,0))'::path\` },
390
+ tsqueryField: {
391
+ type: "tsquery",
392
+ default: sql\`'''fat'' & ''rat'''::tsquery\`,
393
+ },
394
+ tsvectorField: {
395
+ type: "tsvector",
396
+ default: sql\`'''cat'':3 ''fat'':2,4 ''rat'':5A,6B'::tsvector\`,
397
+ },
398
+ int4RangeField: {
399
+ column: "int4rangeField",
400
+ type: "int4range",
401
+ default: sql\`'[1,10)'::int4range\`,
402
+ },
403
+ int8RangeField: {
404
+ column: "int8rangeField",
405
+ type: "int8range",
406
+ default: sql\`'[100,200)'::int8range\`,
407
+ },
408
+ numrangeField: {
409
+ type: "numrange",
410
+ default: sql\`'[1.1,2.2)'::numrange\`,
411
+ },
412
+ tsrangeField: {
413
+ type: "tsrange",
414
+ default: sql\`'["2023-01-01 00:00:00","2023-01-02 00:00:00")'::tsrange\`,
415
+ },
416
+ tstzrangeField: {
417
+ type: "tstzrange",
418
+ default: sql\`'["2023-01-01 00:00:00+00","2023-01-02 00:00:00+00")'::tstzrange\`,
419
+ },
420
+ daterangeField: {
421
+ type: "daterange",
422
+ default: sql\`'[2023-01-01,2023-01-02)'::daterange\`,
423
+ },
424
+ int2VectorField: {
425
+ column: "int2vectorField",
426
+ type: "int2vector",
427
+ default: sql\`'1 2 3'::int2vector\`,
428
+ },
429
+ oidField: { type: "oid", default: sql\`'12345'::oid\` },
430
+ pglsnField: { type: "pg_lsn", default: sql\`'16/B374D848'::pg_lsn\` },
431
+ regclassField: { type: "regclass", default: sql\`'pg_class'::regclass\` },
432
+ regnamespaceField: {
433
+ type: "regnamespace",
434
+ default: sql\`'public'::regnamespace\`,
435
+ },
436
+ regprocField: { type: "regproc", default: sql\`'now'::regproc\` },
437
+ regprocedureField: {
438
+ type: "regprocedure",
439
+ default: sql\`'abs(integer)'::regprocedure\`,
440
+ },
441
+ regroleField: {
442
+ type: "regrole",
443
+ default: sql\`(CURRENT_USER)::regrole\`,
444
+ },
445
+ regtypeField: { type: "regtype", default: sql\`'integer'::regtype\` },
446
+ tidField: { type: "tid", default: sql\`'(0,1)'::tid\` },
447
+ xidField: { type: "xid", default: sql\`'123'::xid\` },
448
+ txidSnapshotField: {
449
+ type: "txid_snapshot",
450
+ default: sql\`'10:20:10,14,15'::txid_snapshot\`,
451
+ },
452
+ arrayField: {
453
+ type: "text[]",
454
+ default: sql\`'{item1,item2,item3}'::text[]\`,
455
+ },
456
+ multiArrayField: {
457
+ type: "text[][][]",
458
+ default: sql\`ARRAY[ARRAY[ARRAY['a'::text, 'b'::text]], ARRAY[ARRAY['c'::text, 'd'::text]]]\`,
459
+ },
460
+ },
461
+ } as const satisfies ModelDefinition;
462
+ `;
463
+ vol.fromJSON({
464
+ "orm.config.ts": unindent `
465
+ import { orm } from "@casekit/orm2";
466
+ import { kitchenSink } from "./app/db.server/models/kitchenSink.ts";
467
+
468
+ export default {
469
+ db: orm({
470
+ schema: "${testSchema}",
471
+ models: { kitchenSink },
472
+ }),
473
+ directory: "./app/db.server",
474
+ };
475
+ `,
476
+ "app/db.server/models/kitchenSink.ts": modelFileContent,
477
+ }, "/project");
478
+ // Mock the specific config file path to return the memfs content
479
+ vi.doMock("/project/orm.config.ts", () => {
480
+ const kitchenSinkContent = fs.readFileSync("/project/app/db.server/models/kitchenSink.ts", "utf8");
481
+ // Extract the kitchenSink object from the file content
482
+ const kitchenSinkMatch = /export const kitchenSink = ({[\s\S]*}) as const satisfies ModelDefinition;/.exec(kitchenSinkContent);
483
+ if (!kitchenSinkMatch) {
484
+ throw new Error("Could not extract kitchenSink from model file");
485
+ }
486
+ // doing this is really annoying but it's the only way i've figured out
487
+ // to get the kitchenSink object into the config without having to redefine it,
488
+ // as i haven't been able to figure out how to make `import` use files from the memfs
489
+ // mocked filesystem.
490
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
491
+ const kitchenSink = new Function("sql", "orm", `return ${kitchenSinkMatch[1]}`)(sql, orm);
492
+ return {
493
+ default: {
494
+ db: orm({
495
+ schema: "${testSchema}",
496
+ models: { kitchenSink },
497
+ }),
498
+ directory: "./app/db.server",
499
+ },
500
+ };
501
+ });
502
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${testSchema}"`);
503
+ // Import the mocked config
504
+ const path = "/project/orm.config.ts";
505
+ const { default: config } = await import(path);
506
+ vi.spyOn(loadConfig, "loadConfig").mockResolvedValue(config);
507
+ // Push the kitchen sink model to the database
508
+ await yargs()
509
+ .options(globalOptions)
510
+ .command(dbPush)
511
+ .parseAsync(`push --schema ${testSchema}`);
512
+ // Mock prompts to auto-confirm file writes
513
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
514
+ // Pull the schema back
515
+ await yargs()
516
+ .options(globalOptions)
517
+ .command(dbPull)
518
+ .parseAsync(`pull --schema ${testSchema} --force`);
519
+ // Read the generated model file
520
+ const pulledModel = fs.readFileSync("/project/app/db.server/models/kitchenSink.ts", { encoding: "utf8" });
521
+ // Verify the pulled model has actually been overwritten
522
+ expect(pulledModel).not.toMatch(/This file will be overwritten by the pull command/);
523
+ expect(pulledModel.trim()).toEqual(modelFileContent
524
+ .trim()
525
+ .replace("// This file will be overwritten by the pull command\n", ""));
526
+ // Push the pulled model back to the database
527
+ await db.query(`DROP SCHEMA "${testSchema}" CASCADE`);
528
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${testSchema}"`);
529
+ await yargs()
530
+ .options(globalOptions)
531
+ .command(dbPush)
532
+ .parseAsync(`push --schema ${testSchema}`);
533
+ // Verify the table was created successfully by querying it
534
+ const result = await db.query(`SELECT column_name, data_type, column_default
535
+ FROM information_schema.columns
536
+ WHERE table_schema = $1 AND table_name = $2
537
+ ORDER BY ordinal_position`, [testSchema, "kitchenSink"]);
538
+ expect(result.rows.length).toEqual(68);
539
+ await db.query(`DROP SCHEMA "${testSchema}" CASCADE`);
540
+ });
541
+ test("handles foreign key relations (many-to-one and one-to-many)", async () => {
542
+ const testSchema = `relations_${randomUUID()}`;
543
+ await db.query(`CREATE SCHEMA IF NOT EXISTS "${testSchema}"`);
544
+ // Create tables with foreign key relationships directly in the database
545
+ await db.query(`
546
+ CREATE TABLE "${testSchema}".user (
547
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
548
+ name TEXT NOT NULL,
549
+ email TEXT UNIQUE NOT NULL
550
+ );
551
+ `);
552
+ await db.query(`
553
+ CREATE TABLE "${testSchema}".post (
554
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
555
+ title TEXT NOT NULL,
556
+ content TEXT,
557
+ author_id UUID NOT NULL,
558
+ created_at TIMESTAMP DEFAULT now(),
559
+ CONSTRAINT post_author_id_fkey
560
+ FOREIGN KEY (author_id) REFERENCES "${testSchema}".user(id)
561
+ );
562
+ `);
563
+ vi.spyOn(prompts, "confirm").mockResolvedValue(true);
564
+ // Run db-pull to generate model files
565
+ await yargs()
566
+ .options(globalOptions)
567
+ .command(dbPull)
568
+ .parseAsync(`pull --schema ${testSchema}`);
569
+ // Check that both model files were created
570
+ const userModel = fs.readFileSync("/project/app/db.server/models/user.ts", "utf8");
571
+ const postModel = fs.readFileSync("/project/app/db.server/models/post.ts", "utf8");
572
+ expect(userModel.trim()).toEqual(unindent `
573
+ import { type ModelDefinition, sql } from "@casekit/orm2";
574
+
575
+ export const user = {
576
+ schema: "${testSchema}",
577
+ fields: {
578
+ id: { type: "uuid", primaryKey: true, default: sql\`gen_random_uuid()\` },
579
+ name: { type: "text" },
580
+ email: { type: "text", unique: true },
581
+ },
582
+ relations: {
583
+ authorPosts: {
584
+ type: "1:N",
585
+ model: "post",
586
+ fromField: "id",
587
+ toField: "authorId",
588
+ },
589
+ },
590
+ } as const satisfies ModelDefinition;
591
+ `);
592
+ expect(postModel.trim()).toEqual(unindent `
593
+ import { type ModelDefinition, sql } from "@casekit/orm2";
594
+
595
+ export const post = {
596
+ schema: "${testSchema}",
597
+ fields: {
598
+ id: { type: "uuid", primaryKey: true, default: sql\`gen_random_uuid()\` },
599
+ title: { type: "text" },
600
+ content: { type: "text", nullable: true },
601
+ authorId: {
602
+ column: "author_id",
603
+ type: "uuid",
604
+ references: { model: "user", field: "id" },
605
+ },
606
+ createdAt: {
607
+ column: "created_at",
608
+ type: "timestamp without time zone",
609
+ nullable: true,
610
+ default: sql\`now()\`,
611
+ },
612
+ },
613
+ relations: {
614
+ author: {
615
+ type: "N:1",
616
+ model: "user",
617
+ fromField: "authorId",
618
+ toField: "id",
619
+ },
620
+ },
621
+ } as const satisfies ModelDefinition;
622
+ `);
623
+ await db.query(`DROP SCHEMA "${testSchema}" CASCADE`);
624
+ });
625
+ });
@@ -12,7 +12,4 @@ export const handler = async (opts) => {
12
12
  process.exitCode = 1;
13
13
  throw e;
14
14
  }
15
- finally {
16
- await db.close();
17
- }
18
15
  };
@@ -1,6 +1,9 @@
1
1
  import { input } from "@inquirer/prompts";
2
2
  import fs from "fs";
3
+ import path from "path";
4
+ import { unindent } from "@casekit/unindent";
3
5
  import { createOrOverwriteFile } from "#util/createOrOverwriteFile.js";
6
+ import { prettify } from "../../util/prettify.js";
4
7
  import { generateConfigFile } from "./util/generateConfigFile.js";
5
8
  import { generateDbFile } from "./util/generateDbFile.js";
6
9
  import { generateModelsFile } from "./util/generateModelsFile.js";
@@ -12,9 +15,19 @@ export const handler = async (opts) => {
12
15
  default: `./${srcDir}/db.server`,
13
16
  }));
14
17
  const dbFile = generateDbFile();
15
- await createOrOverwriteFile(`${dir}/db.ts`, dbFile, opts.force);
16
- const modelsFile = generateModelsFile();
17
- await createOrOverwriteFile(`${dir}/models/index.ts`, modelsFile, opts.force);
18
+ await createOrOverwriteFile(`${dir}/index.ts`, dbFile, opts.force);
19
+ await createOrOverwriteFile(`${dir}/config.ts`, unindent `
20
+ import { type Config } from "@casekit/orm2";
21
+ import { models } from "./models";
22
+
23
+ export const config = {
24
+ models,
25
+ schema: "public",
26
+ connection: { connectionString: process.env.DATABASE_URL },
27
+ } as const satisfies Config;
28
+ `, opts.force);
29
+ const modelsFile = generateModelsFile([]);
30
+ await createOrOverwriteFile(`${dir}/models/index.ts`, await prettify(path.join(process.cwd(), `${dir}/models/index.ts`), modelsFile), opts.force);
18
31
  const configFile = generateConfigFile(dir);
19
32
  await createOrOverwriteFile(`orm.config.ts`, configFile, opts.force);
20
33
  };