@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.
- package/build/cli.js +1 -2
- package/build/commands/db-drop/handler.js +0 -3
- package/build/commands/db-drop.test.js +1 -0
- package/build/commands/db-pull/handler.js +21 -8
- package/build/commands/db-pull/options.d.ts +5 -0
- package/build/commands/db-pull/options.js +5 -0
- package/build/commands/db-pull/util/relationNames.d.ts +3 -0
- package/build/commands/db-pull/util/relationNames.js +14 -0
- package/build/commands/db-pull/util/relationNames.test.js +61 -0
- package/build/commands/db-pull/util/renderDefault.d.ts +3 -0
- package/build/commands/db-pull/util/renderDefault.js +46 -0
- package/build/commands/db-pull/util/renderDefault.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderDefault.test.js +67 -0
- package/build/commands/db-pull/util/renderFieldDefinition.d.ts +2 -0
- package/build/commands/db-pull/util/renderFieldDefinition.js +50 -0
- package/build/commands/db-pull/util/renderFieldDefinition.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderFieldDefinition.test.js +182 -0
- package/build/commands/db-pull/util/renderModel.basic.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderModel.basic.test.js +169 -0
- package/build/commands/db-pull/util/renderModel.constraints.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderModel.constraints.test.js +677 -0
- package/build/commands/db-pull/util/renderModel.d.ts +2 -0
- package/build/commands/db-pull/util/renderModel.defaultValues.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderModel.defaultValues.test.js +518 -0
- package/build/commands/db-pull/util/renderModel.js +88 -0
- package/build/commands/db-pull/util/renderModel.relations.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderModel.relations.test.js +880 -0
- package/build/commands/db-pull/util/renderModel.types.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderModel.types.test.js +703 -0
- package/build/commands/db-pull/util/renderRelations.d.ts +2 -0
- package/build/commands/db-pull/util/renderRelations.js +55 -0
- package/build/commands/db-pull/util/renderRelations.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderRelations.test.js +165 -0
- package/build/commands/db-pull/util/renderType.d.ts +6 -0
- package/build/commands/db-pull/util/renderType.js +55 -0
- package/build/commands/db-pull/util/renderType.test.d.ts +1 -0
- package/build/commands/db-pull/util/renderType.test.js +46 -0
- package/build/commands/db-pull.d.ts +10 -0
- package/build/commands/db-pull.test.js +625 -0
- package/build/commands/db-push/handler.js +0 -3
- package/build/commands/init/handler.js +16 -3
- package/build/commands/init/util/generateConfigFile.js +2 -3
- package/build/commands/init/util/generateDbFile.js +13 -24
- package/build/commands/init/util/generateModelsFile.d.ts +1 -1
- package/build/commands/init/util/generateModelsFile.js +6 -2
- package/build/commands/init.test.js +19 -31
- package/build/types.d.ts +2 -2
- package/build/util/createOrOverwriteFile.d.ts +1 -1
- package/build/util/createOrOverwriteFile.js +1 -1
- package/build/util/loadConfig.js +5 -1
- package/package.json +26 -25
- package/build/commands/generate-model/handler.d.ts +0 -3
- package/build/commands/generate-model/handler.js +0 -10
- package/build/commands/generate-model/options.d.ts +0 -18
- package/build/commands/generate-model/options.js +0 -18
- package/build/commands/generate-model/util/generateModelFile.d.ts +0 -3
- package/build/commands/generate-model/util/generateModelFile.js +0 -59
- package/build/commands/generate-model/util/generateModelFile.test.js +0 -67
- package/build/commands/generate-model/util/regenerateModelsFile.d.ts +0 -2
- package/build/commands/generate-model/util/regenerateModelsFile.js +0 -50
- package/build/commands/generate-model.d.ts +0 -40
- package/build/commands/generate-model.js +0 -8
- package/build/commands/generate-model.test.js +0 -55
- /package/build/commands/{generate-model/util/generateModelFile.test.d.ts → db-pull/util/relationNames.test.d.ts} +0 -0
- /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
|
+
});
|
|
@@ -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}/
|
|
16
|
-
|
|
17
|
-
|
|
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
|
};
|