@rebasepro/server-postgresql 0.0.1-canary.f81da60 → 0.1.2
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/dist/index.es.js +383 -1080
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +314 -1011
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
- package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/collections.d.ts +20 -1
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/entity_views.d.ts +2 -1
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/properties.d.ts +15 -3
- package/dist/types/src/types/translations.d.ts +2 -0
- package/package.json +5 -5
- package/src/PostgresBackendDriver.ts +23 -6
- package/src/cli.ts +10 -2
- package/src/data-transformer.ts +84 -1
- package/src/schema/doctor.ts +14 -2
- package/src/schema/generate-drizzle-schema-logic.ts +52 -5
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +365 -61
- package/src/schema/introspect-db.ts +66 -23
- package/src/services/EntityFetchService.ts +16 -0
- package/src/services/EntityPersistService.ts +88 -12
- package/test/generate-drizzle-schema.test.ts +295 -0
- package/test/introspect-db-generation.test.ts +32 -10
- package/test/property-ordering.test.ts +395 -0
- package/jest-all.log +0 -3128
- package/jest.log +0 -49
- package/scratch.ts +0 -41
- package/test-drizzle-bug.ts +0 -18
- package/test-drizzle-out/0000_cultured_freak.sql +0 -7
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
- package/test-drizzle-out/meta/0000_snapshot.json +0 -55
- package/test-drizzle-out/meta/0001_snapshot.json +0 -63
- package/test-drizzle-out/meta/_journal.json +0 -20
- package/test-drizzle-prompt.sh +0 -2
- package/test-policy-prompt.sh +0 -3
- package/test-programmatic.ts +0 -30
- package/test-programmatic2.ts +0 -59
- package/test-schema-no-policies.ts +0 -12
- package/test_drizzle_mock.js +0 -3
- package/test_find_changed.mjs +0 -32
- package/test_hash.js +0 -14
- package/test_output.txt +0 -3145
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import pg from "pg";
|
|
5
5
|
import arg from "arg";
|
|
6
6
|
import * as dotenv from "dotenv";
|
|
7
|
+
import readline from "readline";
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
TableRow,
|
|
@@ -24,15 +25,24 @@ async function main() {
|
|
|
24
25
|
const args = arg(
|
|
25
26
|
{
|
|
26
27
|
"--output": String,
|
|
28
|
+
"--collections": String,
|
|
27
29
|
"--force": Boolean,
|
|
28
30
|
"--schema": String,
|
|
31
|
+
"--data-inference": Boolean,
|
|
29
32
|
"-o": "--output",
|
|
33
|
+
"-c": "--collections",
|
|
30
34
|
"-f": "--force",
|
|
31
35
|
},
|
|
32
36
|
{ permissive: true }
|
|
33
37
|
);
|
|
34
38
|
|
|
35
|
-
const
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const isBackendDir = path.basename(cwd) === "backend";
|
|
41
|
+
const defaultOutDir = isBackendDir
|
|
42
|
+
? path.resolve(cwd, "..", "config", "collections")
|
|
43
|
+
: path.resolve(cwd, "config", "collections");
|
|
44
|
+
|
|
45
|
+
const outDir = args["--output"] || args["--collections"] || defaultOutDir;
|
|
36
46
|
const force = args["--force"] || false;
|
|
37
47
|
const pgSchema = args["--schema"] || "public";
|
|
38
48
|
|
|
@@ -143,32 +153,65 @@ async function main() {
|
|
|
143
153
|
|
|
144
154
|
console.log(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
|
|
145
155
|
|
|
156
|
+
let runDataInference = false;
|
|
157
|
+
if (args["--data-inference"] !== undefined) {
|
|
158
|
+
runDataInference = args["--data-inference"];
|
|
159
|
+
} else {
|
|
160
|
+
const rl = readline.createInterface({
|
|
161
|
+
input: process.stdin,
|
|
162
|
+
output: process.stdout
|
|
163
|
+
});
|
|
164
|
+
const answer = await new Promise<string>((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve));
|
|
165
|
+
runDataInference = answer.trim().toLowerCase() === 'y';
|
|
166
|
+
rl.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (runDataInference) {
|
|
170
|
+
console.log(chalk.gray(`Sampling database rows for data inference...`));
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
// Generate Collections
|
|
147
174
|
const generatedFiles: string[] = [];
|
|
148
175
|
const skippedFiles: string[] = [];
|
|
149
176
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
|
|
178
|
+
|
|
179
|
+
const BATCH_SIZE = 10;
|
|
180
|
+
for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
|
|
181
|
+
const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
|
|
182
|
+
|
|
183
|
+
await Promise.all(batch.map(async ([tableName, meta]) => {
|
|
184
|
+
// ── File overwrite protection ──────────────────────────────
|
|
185
|
+
const filePath = path.join(outDir, `${tableName}.ts`);
|
|
186
|
+
if (fs.existsSync(filePath) && !force) {
|
|
187
|
+
skippedFiles.push(tableName);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let sampleData: Record<string, unknown>[] | undefined = undefined;
|
|
192
|
+
if (runDataInference) {
|
|
193
|
+
try {
|
|
194
|
+
const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
|
|
195
|
+
sampleData = rows;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fileContent = generateCollectionFile(
|
|
202
|
+
tableName,
|
|
203
|
+
meta,
|
|
204
|
+
fks,
|
|
205
|
+
joinTables,
|
|
206
|
+
tablesMap,
|
|
207
|
+
enumMap,
|
|
208
|
+
sampleData,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
fs.writeFileSync(filePath, fileContent, "utf-8");
|
|
212
|
+
generatedFiles.push(tableName);
|
|
213
|
+
console.log(chalk.green(` ✓ ${filePath}`));
|
|
214
|
+
}));
|
|
172
215
|
}
|
|
173
216
|
|
|
174
217
|
// Generate index.ts (sorted alphabetically for deterministic output)
|
|
@@ -617,6 +617,10 @@ export class EntityFetchService {
|
|
|
617
617
|
|
|
618
618
|
return entity;
|
|
619
619
|
} catch (e) {
|
|
620
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
621
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
622
|
+
console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
|
|
623
|
+
}
|
|
620
624
|
console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
|
|
621
625
|
}
|
|
622
626
|
}
|
|
@@ -733,6 +737,10 @@ export class EntityFetchService {
|
|
|
733
737
|
|
|
734
738
|
return entities;
|
|
735
739
|
} catch (e) {
|
|
740
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
741
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
742
|
+
console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
|
|
743
|
+
}
|
|
736
744
|
console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
|
|
737
745
|
}
|
|
738
746
|
}
|
|
@@ -1195,6 +1203,10 @@ export class EntityFetchService {
|
|
|
1195
1203
|
|
|
1196
1204
|
return restRows;
|
|
1197
1205
|
} catch (e) {
|
|
1206
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1207
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1208
|
+
console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
|
|
1209
|
+
}
|
|
1198
1210
|
console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
|
|
1199
1211
|
}
|
|
1200
1212
|
}
|
|
@@ -1305,6 +1317,10 @@ export class EntityFetchService {
|
|
|
1305
1317
|
|
|
1306
1318
|
return restRow;
|
|
1307
1319
|
} catch (e) {
|
|
1320
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1321
|
+
console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1322
|
+
console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
|
|
1323
|
+
}
|
|
1308
1324
|
console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
|
|
1309
1325
|
}
|
|
1310
1326
|
}
|
|
@@ -298,51 +298,127 @@ export class EntityPersistService {
|
|
|
298
298
|
|
|
299
299
|
if (pgError) {
|
|
300
300
|
const detail = pgError.detail as string | undefined;
|
|
301
|
+
const hint = pgError.hint as string | undefined;
|
|
301
302
|
const constraint = pgError.constraint as string | undefined;
|
|
302
303
|
const column = pgError.column as string | undefined;
|
|
303
304
|
const table = pgError.table as string | undefined;
|
|
305
|
+
const dataType = pgError.dataType as string | undefined;
|
|
306
|
+
const pgMessage = pgError.message || "Unknown database error";
|
|
307
|
+
|
|
308
|
+
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
309
|
+
const tableRef = table ?? collectionSlug;
|
|
304
310
|
|
|
305
311
|
switch (pgError.code) {
|
|
306
312
|
case "23503": // foreign_key_violation
|
|
307
313
|
return new Error(
|
|
308
314
|
detail
|
|
309
|
-
? `Foreign key constraint violated: ${detail}`
|
|
310
|
-
: `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
315
|
+
? `Foreign key constraint violated: ${detail}${suffix}`
|
|
316
|
+
: `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
311
317
|
);
|
|
312
318
|
case "23505": // unique_violation
|
|
313
319
|
return new Error(
|
|
314
320
|
detail
|
|
315
|
-
? `Duplicate value: ${detail}`
|
|
316
|
-
: `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
321
|
+
? `Duplicate value: ${detail}${suffix}`
|
|
322
|
+
: `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
317
323
|
);
|
|
318
324
|
case "23502": // not_null_violation
|
|
319
325
|
return new Error(
|
|
320
|
-
`Missing required field: "${column ?? "unknown"}" in "${
|
|
326
|
+
`Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`
|
|
321
327
|
);
|
|
322
328
|
case "23514": // check_violation
|
|
323
329
|
return new Error(
|
|
324
|
-
`Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}"
|
|
330
|
+
`Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".${suffix}`
|
|
331
|
+
);
|
|
332
|
+
case "22P02": // invalid_text_representation (e.g. invalid UUID, wrong enum value)
|
|
333
|
+
return new Error(
|
|
334
|
+
`Invalid data format in "${collectionSlug}": ${pgMessage}${suffix}`
|
|
335
|
+
);
|
|
336
|
+
case "22001": // string_data_right_truncation (value too long)
|
|
337
|
+
return new Error(
|
|
338
|
+
`Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
|
|
339
|
+
);
|
|
340
|
+
case "22003": // numeric_value_out_of_range
|
|
341
|
+
return new Error(
|
|
342
|
+
`Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`
|
|
325
343
|
);
|
|
344
|
+
case "42703": // undefined_column
|
|
345
|
+
return new Error(
|
|
346
|
+
`Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
|
|
347
|
+
);
|
|
348
|
+
case "42P01": // undefined_table
|
|
349
|
+
return new Error(
|
|
350
|
+
`Table not found for "${collectionSlug}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`
|
|
351
|
+
);
|
|
352
|
+
default: {
|
|
353
|
+
// Unhandled PG code — still surface the actual database message
|
|
354
|
+
const parts = [`Database error in "${collectionSlug}" [${pgError.code}]: ${pgMessage}`];
|
|
355
|
+
if (detail) parts.push(`Detail: ${detail}`);
|
|
356
|
+
if (column) parts.push(`Column: ${column}`);
|
|
357
|
+
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
358
|
+
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
359
|
+
if (hint) parts.push(`Hint: ${hint}`);
|
|
360
|
+
return new Error(parts.join(". "));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// No PG error found — try to extract a useful message from the
|
|
366
|
+
// Drizzle wrapper instead of leaking the raw SQL query + params.
|
|
367
|
+
const causeMessage = this.extractCauseMessage(error);
|
|
368
|
+
if (causeMessage) {
|
|
369
|
+
return new Error(`Database error in "${collectionSlug}": ${causeMessage}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Last resort: use the original error message but strip the SQL query
|
|
373
|
+
if (error instanceof Error) {
|
|
374
|
+
const cleaned = this.stripSqlFromMessage(error.message, collectionSlug);
|
|
375
|
+
return new Error(cleaned);
|
|
376
|
+
}
|
|
377
|
+
return new Error(`Database error in "${collectionSlug}": ${String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Walk the error cause chain and return the deepest meaningful message.
|
|
382
|
+
*/
|
|
383
|
+
private extractCauseMessage(error: unknown): string | null {
|
|
384
|
+
if (!error || typeof error !== "object") return null;
|
|
385
|
+
const err = error as Error & { cause?: unknown };
|
|
386
|
+
|
|
387
|
+
if (err.cause && typeof err.cause === "object") {
|
|
388
|
+
const deeper = this.extractCauseMessage(err.cause);
|
|
389
|
+
if (deeper) return deeper;
|
|
390
|
+
// The cause itself has a message
|
|
391
|
+
if (err.cause instanceof Error && err.cause.message) {
|
|
392
|
+
return err.cause.message;
|
|
326
393
|
}
|
|
327
394
|
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
328
397
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Strip the raw SQL query from a Drizzle "Failed query: ..." message,
|
|
400
|
+
* keeping only the error description.
|
|
401
|
+
*/
|
|
402
|
+
private stripSqlFromMessage(message: string, collectionSlug: string): string {
|
|
403
|
+
// Drizzle format: "Failed query: <SQL>\nparams: <params>"
|
|
404
|
+
if (message.startsWith("Failed query:")) {
|
|
405
|
+
return `Failed to save entity in "${collectionSlug}". Check server logs for details.`;
|
|
406
|
+
}
|
|
407
|
+
return message;
|
|
332
408
|
}
|
|
333
409
|
|
|
334
410
|
/**
|
|
335
411
|
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
336
412
|
* Drizzle wraps PG errors in a `cause` property.
|
|
337
413
|
*/
|
|
338
|
-
private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown }) | null {
|
|
414
|
+
private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown }) | null {
|
|
339
415
|
if (!error || typeof error !== "object") return null;
|
|
340
416
|
|
|
341
417
|
const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
|
|
342
418
|
|
|
343
419
|
// Check if the error itself has a PG error code
|
|
344
|
-
if (err.code && /^[0-
|
|
345
|
-
return err as Error & { code: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown };
|
|
420
|
+
if (err.code && /^[0-9A-Z]{5}$/.test(err.code)) {
|
|
421
|
+
return err as Error & { code: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown };
|
|
346
422
|
}
|
|
347
423
|
|
|
348
424
|
// Check the cause chain (Drizzle wraps PG errors)
|
|
@@ -1033,3 +1033,298 @@ isId: true }
|
|
|
1033
1033
|
expect(cleanResult).toContain("user_name: varchar(\"user_name\").primaryKey()");
|
|
1034
1034
|
});
|
|
1035
1035
|
});
|
|
1036
|
+
|
|
1037
|
+
// ── columnName tests ────────────────────────────────────────────────────
|
|
1038
|
+
describe("generateDrizzleSchema columnName support", () => {
|
|
1039
|
+
const cleanSchema = (schema: string) => {
|
|
1040
|
+
return schema
|
|
1041
|
+
.replace(/\/\/.*$/gm, "")
|
|
1042
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
1043
|
+
.replace(/\n{2,}/g, "\n")
|
|
1044
|
+
.replace(/\s+/g, " ")
|
|
1045
|
+
.trim();
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
it("should use explicit columnName as the SQL column name instead of deriving from the property key", async () => {
|
|
1049
|
+
const collections: EntityCollection[] = [{
|
|
1050
|
+
slug: "billing",
|
|
1051
|
+
table: "company_billing_config",
|
|
1052
|
+
name: "Billing",
|
|
1053
|
+
properties: {
|
|
1054
|
+
employee_number_140a: {
|
|
1055
|
+
type: "string",
|
|
1056
|
+
name: "Employee Number 140a",
|
|
1057
|
+
columnName: "employee_number_140a",
|
|
1058
|
+
},
|
|
1059
|
+
contract_number_140a: {
|
|
1060
|
+
type: "string",
|
|
1061
|
+
name: "Contract Number 140a",
|
|
1062
|
+
columnName: "contract_number_140a",
|
|
1063
|
+
},
|
|
1064
|
+
},
|
|
1065
|
+
}];
|
|
1066
|
+
|
|
1067
|
+
const result = await generateSchema(collections);
|
|
1068
|
+
const cleanResult = cleanSchema(result);
|
|
1069
|
+
|
|
1070
|
+
// Must use the exact columnName, NOT toSnakeCase(propKey) which would produce "employee_number_140_a"
|
|
1071
|
+
expect(cleanResult).toContain('employee_number_140a: varchar("employee_number_140a")');
|
|
1072
|
+
expect(cleanResult).toContain('contract_number_140a: varchar("contract_number_140a")');
|
|
1073
|
+
|
|
1074
|
+
// Must NOT contain the broken snake_case version
|
|
1075
|
+
expect(cleanResult).not.toContain("employee_number_140_a");
|
|
1076
|
+
expect(cleanResult).not.toContain("contract_number_140_a");
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("should fall back to toSnakeCase when columnName is not set (manually-authored collections)", async () => {
|
|
1080
|
+
const collections: EntityCollection[] = [{
|
|
1081
|
+
slug: "products",
|
|
1082
|
+
table: "products",
|
|
1083
|
+
name: "Products",
|
|
1084
|
+
properties: {
|
|
1085
|
+
productName: {
|
|
1086
|
+
type: "string",
|
|
1087
|
+
name: "Product Name",
|
|
1088
|
+
// No columnName — should derive from key
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
}];
|
|
1092
|
+
|
|
1093
|
+
const result = await generateSchema(collections);
|
|
1094
|
+
const cleanResult = cleanSchema(result);
|
|
1095
|
+
|
|
1096
|
+
// JS key stays camelCase, SQL column name gets snake_cased
|
|
1097
|
+
expect(cleanResult).toContain('productName: varchar("product_name")');
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("should handle mixed properties — some with columnName, some without", async () => {
|
|
1101
|
+
const collections: EntityCollection[] = [{
|
|
1102
|
+
slug: "config",
|
|
1103
|
+
table: "app_config",
|
|
1104
|
+
name: "Config",
|
|
1105
|
+
properties: {
|
|
1106
|
+
// Introspected: has explicit columnName
|
|
1107
|
+
fee_number_140a: {
|
|
1108
|
+
type: "string",
|
|
1109
|
+
name: "Fee Number",
|
|
1110
|
+
columnName: "fee_number_140a",
|
|
1111
|
+
},
|
|
1112
|
+
// Manually added: no columnName
|
|
1113
|
+
displayName: {
|
|
1114
|
+
type: "string",
|
|
1115
|
+
name: "Display Name",
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
}];
|
|
1119
|
+
|
|
1120
|
+
const result = await generateSchema(collections);
|
|
1121
|
+
const cleanResult = cleanSchema(result);
|
|
1122
|
+
|
|
1123
|
+
// Introspected prop uses exact columnName
|
|
1124
|
+
expect(cleanResult).toContain('fee_number_140a: varchar("fee_number_140a")');
|
|
1125
|
+
// Manual prop: JS key stays camelCase, SQL column gets snake_cased
|
|
1126
|
+
expect(cleanResult).toContain('displayName: varchar("display_name")');
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it("should use columnName for all property types, not just strings", async () => {
|
|
1130
|
+
const collections: EntityCollection[] = [{
|
|
1131
|
+
slug: "metrics",
|
|
1132
|
+
table: "metrics",
|
|
1133
|
+
name: "Metrics",
|
|
1134
|
+
properties: {
|
|
1135
|
+
count_v2: {
|
|
1136
|
+
type: "number",
|
|
1137
|
+
name: "Count V2",
|
|
1138
|
+
columnName: "count_v2",
|
|
1139
|
+
},
|
|
1140
|
+
is_active_v2: {
|
|
1141
|
+
type: "boolean",
|
|
1142
|
+
name: "Is Active V2",
|
|
1143
|
+
columnName: "is_active_v2",
|
|
1144
|
+
},
|
|
1145
|
+
created_at_v2: {
|
|
1146
|
+
type: "date",
|
|
1147
|
+
name: "Created At V2",
|
|
1148
|
+
columnName: "created_at_v2",
|
|
1149
|
+
},
|
|
1150
|
+
metadata_v2: {
|
|
1151
|
+
type: "map",
|
|
1152
|
+
name: "Metadata V2",
|
|
1153
|
+
columnName: "metadata_v2",
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
}];
|
|
1157
|
+
|
|
1158
|
+
const result = await generateSchema(collections);
|
|
1159
|
+
const cleanResult = cleanSchema(result);
|
|
1160
|
+
|
|
1161
|
+
expect(cleanResult).toContain('count_v2: numeric("count_v2")');
|
|
1162
|
+
expect(cleanResult).toContain('is_active_v2: boolean("is_active_v2")');
|
|
1163
|
+
expect(cleanResult).toContain('created_at_v2: timestamp("created_at_v2"');
|
|
1164
|
+
expect(cleanResult).toContain('metadata_v2: jsonb("metadata_v2")');
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it("should reproduce and prevent the medmot bug: digit+letter column names", async () => {
|
|
1168
|
+
// This is the exact scenario from the medmot project that caused the production failure
|
|
1169
|
+
const collections: EntityCollection[] = [{
|
|
1170
|
+
slug: "company_billing_config",
|
|
1171
|
+
table: "company_billing_config",
|
|
1172
|
+
name: "Company Billing Config",
|
|
1173
|
+
properties: {
|
|
1174
|
+
employee_number_140a: { type: "string", name: "Employee Number", columnName: "employee_number_140a" },
|
|
1175
|
+
contract_number_140a: { type: "string", name: "Contract Number", columnName: "contract_number_140a" },
|
|
1176
|
+
amount: { type: "number", name: "Amount" },
|
|
1177
|
+
id: { type: "number", name: "ID", isId: "increment" },
|
|
1178
|
+
service_provider_140a: { type: "string", name: "Service Provider", columnName: "service_provider_140a" },
|
|
1179
|
+
internal_area_code_140a: { type: "string", name: "Internal Area Code", columnName: "internal_area_code_140a" },
|
|
1180
|
+
fee_number_140a: { type: "string", name: "Fee Number", columnName: "fee_number_140a" },
|
|
1181
|
+
receiver_market_participant_140a: { type: "string", name: "Receiver Market Participant", columnName: "receiver_market_participant_140a" },
|
|
1182
|
+
employee_value_number_140a: { type: "string", name: "Employee Value Number", columnName: "employee_value_number_140a" },
|
|
1183
|
+
sender_market_participant_140a: { type: "string", name: "Sender Market Participant", columnName: "sender_market_participant_140a" },
|
|
1184
|
+
processing_indicator_140a: { type: "string", name: "Processing Indicator", columnName: "processing_indicator_140a" },
|
|
1185
|
+
insurance_id_140a: { type: "string", name: "Insurance ID", columnName: "insurance_id_140a" },
|
|
1186
|
+
company_id: { type: "number", name: "Company ID" },
|
|
1187
|
+
},
|
|
1188
|
+
}];
|
|
1189
|
+
|
|
1190
|
+
const result = await generateSchema(collections);
|
|
1191
|
+
|
|
1192
|
+
// Every _140a column must stay _140a, not become _140_a
|
|
1193
|
+
const brokenPattern = /_140_a/;
|
|
1194
|
+
expect(result).not.toMatch(brokenPattern);
|
|
1195
|
+
|
|
1196
|
+
// Spot-check a few exact columns
|
|
1197
|
+
expect(result).toContain('"employee_number_140a"');
|
|
1198
|
+
expect(result).toContain('"contract_number_140a"');
|
|
1199
|
+
expect(result).toContain('"service_provider_140a"');
|
|
1200
|
+
expect(result).toContain('"insurance_id_140a"');
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
describe("generateDrizzleSchema autoValue date properties", () => {
|
|
1204
|
+
|
|
1205
|
+
it("should add .default(sql`now()`) for on_create autoValue", async () => {
|
|
1206
|
+
const collections: EntityCollection[] = [
|
|
1207
|
+
{
|
|
1208
|
+
slug: "articles",
|
|
1209
|
+
table: "articles",
|
|
1210
|
+
name: "Articles",
|
|
1211
|
+
properties: {
|
|
1212
|
+
title: { type: "string" },
|
|
1213
|
+
created_at: {
|
|
1214
|
+
type: "date",
|
|
1215
|
+
autoValue: "on_create"
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
];
|
|
1220
|
+
|
|
1221
|
+
const result = await generateSchema(collections, true);
|
|
1222
|
+
|
|
1223
|
+
// on_create should produce .default(sql`now()`)
|
|
1224
|
+
expect(result).toContain(".default(sql`now()`)");
|
|
1225
|
+
// No $onUpdate or triggers — on_update logic lives in the backend driver
|
|
1226
|
+
expect(result).not.toContain(".$onUpdate");
|
|
1227
|
+
expect(result).not.toContain("CREATE OR REPLACE TRIGGER");
|
|
1228
|
+
expect(result).not.toContain("CREATE OR REPLACE FUNCTION");
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it("should add .default(sql`now()`) for on_update autoValue (INSERT default only)", async () => {
|
|
1232
|
+
const collections: EntityCollection[] = [
|
|
1233
|
+
{
|
|
1234
|
+
slug: "articles",
|
|
1235
|
+
table: "articles",
|
|
1236
|
+
name: "Articles",
|
|
1237
|
+
properties: {
|
|
1238
|
+
title: { type: "string" },
|
|
1239
|
+
updated_at: {
|
|
1240
|
+
type: "date",
|
|
1241
|
+
autoValue: "on_update"
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
];
|
|
1246
|
+
|
|
1247
|
+
const result = await generateSchema(collections, true);
|
|
1248
|
+
|
|
1249
|
+
// on_update should produce .default(sql`now()`) for initial INSERT value
|
|
1250
|
+
expect(result).toContain(".default(sql`now()`)");
|
|
1251
|
+
// No $onUpdate or triggers — update logic is handled by the backend driver
|
|
1252
|
+
expect(result).not.toContain(".$onUpdate");
|
|
1253
|
+
expect(result).not.toContain("CREATE OR REPLACE TRIGGER");
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("should not modify date columns without autoValue", async () => {
|
|
1257
|
+
const collections: EntityCollection[] = [
|
|
1258
|
+
{
|
|
1259
|
+
slug: "events",
|
|
1260
|
+
table: "events",
|
|
1261
|
+
name: "Events",
|
|
1262
|
+
properties: {
|
|
1263
|
+
name: { type: "string" },
|
|
1264
|
+
event_date: { type: "date" }
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
];
|
|
1268
|
+
|
|
1269
|
+
const result = await generateSchema(collections, true);
|
|
1270
|
+
|
|
1271
|
+
// A plain date should NOT have any autoValue-related modifiers
|
|
1272
|
+
expect(result).not.toContain(".default(sql`now()`)");
|
|
1273
|
+
expect(result).not.toContain(".$onUpdate");
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
it("should handle both on_create and on_update in the same collection", async () => {
|
|
1277
|
+
const collections: EntityCollection[] = [
|
|
1278
|
+
{
|
|
1279
|
+
slug: "posts",
|
|
1280
|
+
table: "posts",
|
|
1281
|
+
name: "Posts",
|
|
1282
|
+
properties: {
|
|
1283
|
+
title: { type: "string" },
|
|
1284
|
+
created_at: {
|
|
1285
|
+
type: "date",
|
|
1286
|
+
autoValue: "on_create"
|
|
1287
|
+
},
|
|
1288
|
+
updated_at: {
|
|
1289
|
+
type: "date",
|
|
1290
|
+
autoValue: "on_update"
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
];
|
|
1295
|
+
|
|
1296
|
+
const result = await generateSchema(collections, true);
|
|
1297
|
+
|
|
1298
|
+
// Both should get .default(sql`now()`) for INSERT defaults
|
|
1299
|
+
expect(result).toMatch(/created_at:.*\.default\(sql`now\(\)`\)/);
|
|
1300
|
+
expect(result).toMatch(/updated_at:.*\.default\(sql`now\(\)`\)/);
|
|
1301
|
+
// No $onUpdate or triggers
|
|
1302
|
+
expect(result).not.toContain(".$onUpdate");
|
|
1303
|
+
expect(result).not.toContain("CREATE OR REPLACE TRIGGER");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it("should handle on_create with date columnType", async () => {
|
|
1307
|
+
const collections: EntityCollection[] = [
|
|
1308
|
+
{
|
|
1309
|
+
slug: "logs",
|
|
1310
|
+
table: "logs",
|
|
1311
|
+
name: "Logs",
|
|
1312
|
+
properties: {
|
|
1313
|
+
message: { type: "string" },
|
|
1314
|
+
log_date: {
|
|
1315
|
+
type: "date",
|
|
1316
|
+
columnType: "date",
|
|
1317
|
+
autoValue: "on_create"
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
];
|
|
1322
|
+
|
|
1323
|
+
const result = await generateSchema(collections, true);
|
|
1324
|
+
|
|
1325
|
+
// Should use date() column with the default
|
|
1326
|
+
expect(result).toContain("date(\"log_date\"");
|
|
1327
|
+
expect(result).toContain(".default(sql`now()`)");
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
});
|