@rebasepro/server-postgresql 0.0.1-canary.6e26b67 → 0.0.1-canary.892f711
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 +184 -186
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +184 -186
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +6 -0
- package/dist/types/src/controllers/auth.d.ts +2 -2
- package/dist/types/src/controllers/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +11 -10
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +4 -6
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +2 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +71 -87
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +4 -0
- package/package.json +5 -5
- package/src/PostgresBackendDriver.ts +9 -0
- package/src/cli.ts +2 -2
- package/src/schema/doctor.ts +14 -2
- package/src/schema/generate-drizzle-schema-logic.ts +24 -30
- package/src/schema/introspect-db-logic.ts +33 -30
- package/src/services/EntityPersistService.ts +7 -1
- package/test/generate-drizzle-schema.test.ts +214 -0
- package/test/introspect-db-generation.test.ts +27 -5
- package/test/relations.test.ts +4 -4
- 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
|
@@ -281,6 +281,7 @@ export function generateCollectionFile(
|
|
|
281
281
|
const imports = new Set<string>(['import { PostgresCollection } from "@rebasepro/types";']);
|
|
282
282
|
|
|
283
283
|
let propsOutput = ``;
|
|
284
|
+
let relationsOutput = ``;
|
|
284
285
|
const propertiesOrder: string[] = [];
|
|
285
286
|
|
|
286
287
|
// Detect composite primary keys
|
|
@@ -289,7 +290,8 @@ export function generateCollectionFile(
|
|
|
289
290
|
// Map columns
|
|
290
291
|
for (const col of meta.columns) {
|
|
291
292
|
// Skip foreign keys since we handle them as relations
|
|
292
|
-
if
|
|
293
|
+
// Exception: Do not skip if it's part of the primary key!
|
|
294
|
+
if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
|
|
293
295
|
|
|
294
296
|
propertiesOrder.push(col.column_name);
|
|
295
297
|
|
|
@@ -302,22 +304,22 @@ export function generateCollectionFile(
|
|
|
302
304
|
|
|
303
305
|
const colNameLower = col.column_name.toLowerCase();
|
|
304
306
|
|
|
305
|
-
// Enum values — generate real
|
|
307
|
+
// Enum values — generate real enum from the PG enum
|
|
306
308
|
if (isEnumColumn && colEnumValues) {
|
|
307
309
|
const enumEntries = colEnumValues
|
|
308
310
|
.map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
|
|
309
311
|
.join(", ");
|
|
310
|
-
extra = `\n
|
|
312
|
+
extra = `\n enum: [${enumEntries}],`;
|
|
311
313
|
}
|
|
312
314
|
|
|
313
315
|
// Date auto-value heuristics
|
|
314
316
|
if (propType === "date") {
|
|
315
317
|
if (colNameLower === "created_at" || colNameLower === "createdat") {
|
|
316
|
-
extra = `\n autoValue: "on_create",\n readOnly: true,\n
|
|
318
|
+
extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
|
|
317
319
|
} else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
|
|
318
|
-
extra = `\n autoValue: "on_update",\n readOnly: true,\n
|
|
320
|
+
extra = `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
|
|
319
321
|
} else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
|
|
320
|
-
extra = `\n autoValue: "on_create",\n readOnly: true,`;
|
|
322
|
+
extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
|
|
321
323
|
}
|
|
322
324
|
}
|
|
323
325
|
|
|
@@ -330,7 +332,7 @@ export function generateCollectionFile(
|
|
|
330
332
|
// We'll just call mapPgType on the baseType
|
|
331
333
|
innerType = mapPgType(baseType);
|
|
332
334
|
}
|
|
333
|
-
extra = `\n of: { type: "${innerType}" },`;
|
|
335
|
+
extra = `\n of: { name: "${humanize(col.column_name)} Item", type: "${innerType}" },`;
|
|
334
336
|
} else if (propType === "map") {
|
|
335
337
|
extra = `\n keyValue: true,`;
|
|
336
338
|
}
|
|
@@ -370,6 +372,7 @@ export function generateCollectionFile(
|
|
|
370
372
|
propsOutput += `
|
|
371
373
|
${col.column_name}: {
|
|
372
374
|
name: "${humanName}",
|
|
375
|
+
columnName: "${col.column_name}",
|
|
373
376
|
type: "${propType}",${extra}
|
|
374
377
|
},`;
|
|
375
378
|
}
|
|
@@ -378,7 +381,12 @@ export function generateCollectionFile(
|
|
|
378
381
|
for (const fk of meta.fks) {
|
|
379
382
|
const targetTableName = fk.foreign_table_name;
|
|
380
383
|
if (!joinTables.has(targetTableName)) {
|
|
381
|
-
|
|
384
|
+
let relName = fk.column_name.replace(/_id$/, "");
|
|
385
|
+
if (meta.pks.includes(fk.column_name) && relName === fk.column_name) {
|
|
386
|
+
// If the FK is also the PK and its name doesn't imply a relation (like "id"),
|
|
387
|
+
// use the target table name to avoid conflicting with the PK property.
|
|
388
|
+
relName = targetTableName;
|
|
389
|
+
}
|
|
382
390
|
// Push the relation property key, not the FK column name
|
|
383
391
|
propertiesOrder.push(relName);
|
|
384
392
|
|
|
@@ -401,21 +409,19 @@ export function generateCollectionFile(
|
|
|
401
409
|
}
|
|
402
410
|
|
|
403
411
|
// Map Inverse Relations (1-to-many where OTHER table points to THIS table)
|
|
412
|
+
// These go into the `relations` array so they render as subcollection tabs.
|
|
404
413
|
const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
|
|
405
414
|
for (const fk of inverseFks) {
|
|
406
415
|
const sourceTableName = fk.table_name;
|
|
407
|
-
propertiesOrder.push(sourceTableName);
|
|
408
416
|
|
|
409
417
|
const targetCollectionCamel = toCollectionVarName(sourceTableName);
|
|
410
418
|
imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
|
|
411
419
|
|
|
412
420
|
const inverseRelName = fk.column_name.replace(/_id$/, "");
|
|
413
|
-
const relHumanName = humanize(sourceTableName);
|
|
414
421
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
type: "relation",
|
|
422
|
+
relationsOutput += `
|
|
423
|
+
{
|
|
424
|
+
relationName: "${sourceTableName}",
|
|
419
425
|
target: () => ${targetCollectionCamel},
|
|
420
426
|
cardinality: "many",
|
|
421
427
|
direction: "inverse",
|
|
@@ -425,6 +431,7 @@ export function generateCollectionFile(
|
|
|
425
431
|
}
|
|
426
432
|
|
|
427
433
|
// Map Many-to-Many Relations (Join Tables)
|
|
434
|
+
// These also go into the `relations` array so they render as subcollection tabs.
|
|
428
435
|
const relatedJoinTables = Array.from(joinTables).filter((jt) => {
|
|
429
436
|
const jtMeta = tablesMap.get(jt);
|
|
430
437
|
return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
|
|
@@ -444,15 +451,10 @@ export function generateCollectionFile(
|
|
|
444
451
|
const otherFk = selfRefFks[1];
|
|
445
452
|
|
|
446
453
|
const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
|
|
447
|
-
propertiesOrder.push(relPropName);
|
|
448
|
-
|
|
449
|
-
// Self-ref: import is the same collection (use a lazy reference)
|
|
450
|
-
const relHumanName = humanize(otherFk.column_name.replace(/_id$/, ""));
|
|
451
454
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
type: "relation",
|
|
455
|
+
relationsOutput += `
|
|
456
|
+
{
|
|
457
|
+
relationName: "${relPropName}",
|
|
456
458
|
target: () => ${tableName}Collection,
|
|
457
459
|
cardinality: "many",
|
|
458
460
|
direction: "owning",
|
|
@@ -469,7 +471,6 @@ export function generateCollectionFile(
|
|
|
469
471
|
|
|
470
472
|
if (otherFk) {
|
|
471
473
|
const targetTableName = otherFk.foreign_table_name;
|
|
472
|
-
propertiesOrder.push(targetTableName);
|
|
473
474
|
|
|
474
475
|
const targetCollectionCamel = toCollectionVarName(targetTableName);
|
|
475
476
|
imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
|
|
@@ -478,19 +479,17 @@ export function generateCollectionFile(
|
|
|
478
479
|
const direction = tableName < targetTableName ? "owning" : "inverse";
|
|
479
480
|
|
|
480
481
|
const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
|
|
481
|
-
const relHumanName = humanize(targetTableName);
|
|
482
482
|
|
|
483
483
|
let throughCode = "";
|
|
484
484
|
if (direction === "owning" && thisFk) {
|
|
485
|
-
throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }
|
|
485
|
+
throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
|
|
486
486
|
} else if (direction === "inverse") {
|
|
487
487
|
throughCode = `\n // Make sure the target collection configures the 'through' property.`;
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
type: "relation",
|
|
490
|
+
relationsOutput += `
|
|
491
|
+
{
|
|
492
|
+
relationName: "${targetTableName}",
|
|
494
493
|
target: () => ${targetCollectionCamel},
|
|
495
494
|
cardinality: "many",
|
|
496
495
|
direction: "${direction}",${throughCode}
|
|
@@ -498,6 +497,10 @@ export function generateCollectionFile(
|
|
|
498
497
|
}
|
|
499
498
|
}
|
|
500
499
|
|
|
500
|
+
const relationsBlock = relationsOutput
|
|
501
|
+
? `\n relations: [${relationsOutput}\n ],`
|
|
502
|
+
: "";
|
|
503
|
+
|
|
501
504
|
const fileContent = `${Array.from(imports).join("\n")}
|
|
502
505
|
|
|
503
506
|
const ${tableName}Collection: PostgresCollection = {
|
|
@@ -508,7 +511,7 @@ const ${tableName}Collection: PostgresCollection = {
|
|
|
508
511
|
icon: "${icon}",
|
|
509
512
|
group: "App",
|
|
510
513
|
properties: {${propsOutput}
|
|
511
|
-
}
|
|
514
|
+
},${relationsBlock}
|
|
512
515
|
propertiesOrder: ${JSON.stringify(propertiesOrder, null, 8).replace(/]$/, " ]")}
|
|
513
516
|
};
|
|
514
517
|
|
|
@@ -111,7 +111,7 @@ export class EntityPersistService {
|
|
|
111
111
|
targetColumnName = relation.localKey;
|
|
112
112
|
} else if (relation.foreignKeyOnTarget) {
|
|
113
113
|
targetColumnName = relation.foreignKeyOnTarget;
|
|
114
|
-
} else if (relation.joinPath && relation.joinPath.length
|
|
114
|
+
} else if (relation.joinPath && relation.joinPath.length === 1) {
|
|
115
115
|
const targetTableName = getTableName(targetCollection);
|
|
116
116
|
const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
|
|
117
117
|
|
|
@@ -123,6 +123,12 @@ export class EntityPersistService {
|
|
|
123
123
|
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
|
|
124
124
|
targetColumnName = targetColumnNames[0];
|
|
125
125
|
}
|
|
126
|
+
} else if (relation.joinPath && relation.joinPath.length > 1) {
|
|
127
|
+
// For multi-hop relations (like many-to-many through a junction table),
|
|
128
|
+
// there is no direct foreign key on the target table pointing to the parent.
|
|
129
|
+
// The relationship is managed via the junction table.
|
|
130
|
+
// We shouldn't inject the parent ID directly into the target entity payload.
|
|
131
|
+
break;
|
|
126
132
|
} else {
|
|
127
133
|
throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
|
|
128
134
|
}
|
|
@@ -666,6 +666,53 @@ using: "{is_locked} = false" }
|
|
|
666
666
|
expect(result).toContain('as: "restrictive"');
|
|
667
667
|
});
|
|
668
668
|
|
|
669
|
+
it("should enable RLS on every table even without any security rules", async () => {
|
|
670
|
+
const collections: EntityCollection[] = [{
|
|
671
|
+
slug: "public_data",
|
|
672
|
+
table: "public_data",
|
|
673
|
+
name: "Public Data",
|
|
674
|
+
properties: { title: { type: "string" } }
|
|
675
|
+
// No securityRules defined — table should still have .enableRLS()
|
|
676
|
+
}];
|
|
677
|
+
|
|
678
|
+
const result = await generateSchema(collections);
|
|
679
|
+
expect(result).toContain(".enableRLS()");
|
|
680
|
+
// No policies should be generated
|
|
681
|
+
expect(result).not.toContain("pgPolicy(");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("should enable RLS on tables that do have security rules", async () => {
|
|
685
|
+
const collections: EntityCollection[] = [{
|
|
686
|
+
slug: "secure_data",
|
|
687
|
+
table: "secure_data",
|
|
688
|
+
name: "Secure Data",
|
|
689
|
+
properties: { title: { type: "string" } },
|
|
690
|
+
securityRules: [
|
|
691
|
+
{ operation: "select", access: "public" }
|
|
692
|
+
]
|
|
693
|
+
}];
|
|
694
|
+
|
|
695
|
+
const result = await generateSchema(collections);
|
|
696
|
+
expect(result).toContain(".enableRLS()");
|
|
697
|
+
expect(result).toContain("pgPolicy(");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("should fall back to deny-all (sql`false`) when no USING clause can be generated", async () => {
|
|
701
|
+
const collections: EntityCollection[] = [{
|
|
702
|
+
slug: "deny_test",
|
|
703
|
+
table: "deny_test",
|
|
704
|
+
name: "Deny Test",
|
|
705
|
+
properties: { title: { type: "string" } },
|
|
706
|
+
securityRules: [
|
|
707
|
+
{ operation: "select" }
|
|
708
|
+
// No access, ownerField, or using — should produce sql`false`
|
|
709
|
+
]
|
|
710
|
+
}];
|
|
711
|
+
|
|
712
|
+
const result = await generateSchema(collections);
|
|
713
|
+
expect(result).toContain("sql`false`");
|
|
714
|
+
});
|
|
715
|
+
|
|
669
716
|
});
|
|
670
717
|
});
|
|
671
718
|
// V2 improvements tests
|
|
@@ -986,3 +1033,170 @@ isId: true }
|
|
|
986
1033
|
expect(cleanResult).toContain("user_name: varchar(\"user_name\").primaryKey()");
|
|
987
1034
|
});
|
|
988
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
|
+
});
|
|
@@ -285,22 +285,40 @@ describe("generateCollectionFile", () => {
|
|
|
285
285
|
});
|
|
286
286
|
|
|
287
287
|
describe("inverse relation (other table -> this table)", () => {
|
|
288
|
-
it("generates a one-to-many inverse relation", () => {
|
|
288
|
+
it("generates a one-to-many inverse relation in the relations array", () => {
|
|
289
289
|
const allFks: ForeignKeyRow[] = [mkFk("comments", "post_id", "posts")];
|
|
290
290
|
const meta = makeSimpleTable("posts", [
|
|
291
291
|
mkCol("posts", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
|
|
292
292
|
]);
|
|
293
293
|
const result = generateCollectionFile("posts", meta, allFks, new Set(), new Map([["posts", meta]]), new Map());
|
|
294
294
|
expect(result).toContain('import commentsCollection from "./comments"');
|
|
295
|
+
// Should be in the relations array, not as an inline property
|
|
296
|
+
expect(result).toContain('relations: [');
|
|
297
|
+
expect(result).toContain('relationName: "comments"');
|
|
295
298
|
expect(result).toContain('cardinality: "many"');
|
|
296
299
|
expect(result).toContain('direction: "inverse"');
|
|
297
300
|
expect(result).toContain('inverseRelationName: "post"');
|
|
298
301
|
expect(result).toContain('foreignKeyOnTarget: "post_id"');
|
|
302
|
+
// Should NOT appear as an inline property with type: "relation"
|
|
303
|
+
const propsSection = result.split('properties:')[1].split('relations:')[0];
|
|
304
|
+
expect(propsSection).not.toContain('"relation"');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("does NOT include inverse relations in propertiesOrder", () => {
|
|
308
|
+
const allFks: ForeignKeyRow[] = [mkFk("comments", "post_id", "posts")];
|
|
309
|
+
const meta = makeSimpleTable("posts", [
|
|
310
|
+
mkCol("posts", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
|
|
311
|
+
]);
|
|
312
|
+
const result = generateCollectionFile("posts", meta, allFks, new Set(), new Map([["posts", meta]]), new Map());
|
|
313
|
+
const orderMatch = result.match(/propertiesOrder:\s*(\[[\s\S]*?\])/);
|
|
314
|
+
expect(orderMatch).toBeTruthy();
|
|
315
|
+
const orderBlock = orderMatch![1];
|
|
316
|
+
expect(orderBlock).not.toContain('"comments"');
|
|
299
317
|
});
|
|
300
318
|
});
|
|
301
319
|
|
|
302
320
|
describe("many-to-many relations", () => {
|
|
303
|
-
it("generates owning M2M with through config
|
|
321
|
+
it("generates owning M2M with through config in relations array", () => {
|
|
304
322
|
const jtFks: ForeignKeyRow[] = [
|
|
305
323
|
mkFk("articles_tags", "article_id", "articles"),
|
|
306
324
|
mkFk("articles_tags", "tag_id", "tags"),
|
|
@@ -317,13 +335,15 @@ describe("generateCollectionFile", () => {
|
|
|
317
335
|
const joinTables = new Set(["articles_tags"]);
|
|
318
336
|
|
|
319
337
|
const result = generateCollectionFile("articles", articlesMeta, [], joinTables, tablesMap, new Map());
|
|
338
|
+
expect(result).toContain('relations: [');
|
|
339
|
+
expect(result).toContain('relationName: "tags"');
|
|
320
340
|
expect(result).toContain('direction: "owning"');
|
|
321
341
|
expect(result).toContain('table: "articles_tags"');
|
|
322
342
|
expect(result).toContain('sourceColumn: "article_id"');
|
|
323
343
|
expect(result).toContain('targetColumn: "tag_id"');
|
|
324
344
|
});
|
|
325
345
|
|
|
326
|
-
it("generates inverse M2M
|
|
346
|
+
it("generates inverse M2M in relations array", () => {
|
|
327
347
|
const jtFks: ForeignKeyRow[] = [
|
|
328
348
|
mkFk("articles_tags", "article_id", "articles"),
|
|
329
349
|
mkFk("articles_tags", "tag_id", "tags"),
|
|
@@ -340,12 +360,13 @@ describe("generateCollectionFile", () => {
|
|
|
340
360
|
const joinTables = new Set(["articles_tags"]);
|
|
341
361
|
|
|
342
362
|
const result = generateCollectionFile("tags", tagsMeta, [], joinTables, tablesMap, new Map());
|
|
363
|
+
expect(result).toContain('relations: [');
|
|
343
364
|
expect(result).toContain('direction: "inverse"');
|
|
344
365
|
});
|
|
345
366
|
});
|
|
346
367
|
|
|
347
368
|
describe("self-referencing M2M", () => {
|
|
348
|
-
it("generates self-ref M2M with _via_
|
|
369
|
+
it("generates self-ref M2M with _via_ relation name in relations array", () => {
|
|
349
370
|
const jtFks: ForeignKeyRow[] = [
|
|
350
371
|
mkFk("user_friends", "user_id", "users"),
|
|
351
372
|
mkFk("user_friends", "friend_id", "users"),
|
|
@@ -362,7 +383,8 @@ describe("generateCollectionFile", () => {
|
|
|
362
383
|
const joinTables = new Set(["user_friends"]);
|
|
363
384
|
|
|
364
385
|
const result = generateCollectionFile("users", usersMeta, [], joinTables, tablesMap, new Map());
|
|
365
|
-
expect(result).toContain(
|
|
386
|
+
expect(result).toContain('relations: [');
|
|
387
|
+
expect(result).toContain('relationName: "users_via_friend"');
|
|
366
388
|
expect(result).toContain('table: "user_friends"');
|
|
367
389
|
expect(result).toContain('sourceColumn: "user_id"');
|
|
368
390
|
expect(result).toContain('targetColumn: "friend_id"');
|
package/test/relations.test.ts
CHANGED
|
@@ -382,8 +382,8 @@ relationName: "author" }
|
|
|
382
382
|
// Should create owning relation on profiles
|
|
383
383
|
expect(cleanResult).toContain("export const profilesRelations = drizzleRelations(profiles, ({ one, many }) => ({ \"author\": one(authors, { fields: [profiles.author_id], references: [authors.id], relationName: \"profiles_author_id\" }) }));");
|
|
384
384
|
|
|
385
|
-
// Should create inverse relation on authors
|
|
386
|
-
expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, {
|
|
385
|
+
// Should create inverse relation on authors — inverse side has NO fields/references
|
|
386
|
+
expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, { relationName: \"profiles_author_id\" }) }));");
|
|
387
387
|
});
|
|
388
388
|
|
|
389
389
|
it("should generate owning one-to-many relations", async () => {
|
|
@@ -818,9 +818,9 @@ relationName: "user" }
|
|
|
818
818
|
`"user": one(users, { fields: [profiles.user_id], references: [users.id], relationName: \"${expectedSharedName}\" })`
|
|
819
819
|
);
|
|
820
820
|
|
|
821
|
-
// Inverse side (users → profiles)
|
|
821
|
+
// Inverse side (users → profiles) — no fields/references, paired by relationName only
|
|
822
822
|
expect(cleanResult).toContain(
|
|
823
|
-
`"profile": one(profiles, {
|
|
823
|
+
`"profile": one(profiles, { relationName: \"${expectedSharedName}\" })`
|
|
824
824
|
);
|
|
825
825
|
|
|
826
826
|
// Both must match
|