@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e
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/common/src/collections/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index.es.js +1254 -591
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1254 -591
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
- package/dist/server-postgresql/src/auth/services.d.ts +7 -3
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/server-postgresql/src/connection.d.ts +34 -1
- package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
- package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
- package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
- package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
- package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/package.json +88 -89
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +63 -79
- package/src/PostgresBootstrapper.ts +7 -8
- package/src/auth/ensure-tables.ts +158 -86
- package/src/auth/services.ts +109 -50
- package/src/cli.ts +259 -16
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/connection.ts +70 -48
- package/src/data-transformer.ts +155 -116
- package/src/databasePoolManager.ts +6 -5
- package/src/history/HistoryService.ts +3 -12
- package/src/interfaces.ts +3 -3
- package/src/schema/auth-schema.ts +26 -3
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +204 -57
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +5 -5
- package/src/services/EntityFetchService.ts +317 -188
- package/src/services/EntityPersistService.ts +15 -17
- package/src/services/RelationService.ts +299 -37
- package/src/services/entity-helpers.ts +39 -13
- package/src/services/entityService.ts +11 -9
- package/src/services/realtimeService.ts +58 -29
- package/src/utils/drizzle-conditions.ts +25 -24
- package/src/websocket.ts +52 -21
- package/test/auth-services.test.ts +131 -39
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +22 -12
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/entityService.errors.test.ts +31 -16
- package/test/entityService.relations.test.ts +155 -59
- package/test/entityService.subcollection-search.test.ts +107 -57
- package/test/entityService.test.ts +105 -47
- package/test/generate-drizzle-schema.test.ts +262 -69
- package/test/historyService.test.ts +31 -16
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +260 -168
- package/test/realtimeService.test.ts +70 -39
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +492 -39
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +2 -2
- package/test_find_changed.mjs +3 -1
- package/test_hash.js +14 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +5 -5
package/test/relations.test.ts
CHANGED
|
@@ -212,9 +212,9 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
212
212
|
|
|
213
213
|
const cleanSchema = (schema: string) => {
|
|
214
214
|
return schema
|
|
215
|
-
.replace(/\/\/.*$/gm,
|
|
216
|
-
.replace(/\/\*[\s\S]*?\*\//g,
|
|
217
|
-
.replace(/\n{2,}/g,
|
|
215
|
+
.replace(/\/\/.*$/gm, "")
|
|
216
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
217
|
+
.replace(/\n{2,}/g, "\n")
|
|
218
218
|
.replace(/\s+/g, " ")
|
|
219
219
|
.trim();
|
|
220
220
|
};
|
|
@@ -227,7 +227,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
227
227
|
name: "Authors",
|
|
228
228
|
properties: {
|
|
229
229
|
name: { type: "string" },
|
|
230
|
-
books: { type: "relation",
|
|
230
|
+
books: { type: "relation",
|
|
231
|
+
relationName: "books" }
|
|
231
232
|
},
|
|
232
233
|
relations: [
|
|
233
234
|
{
|
|
@@ -257,10 +258,10 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
257
258
|
const cleanResult = cleanSchema(result);
|
|
258
259
|
|
|
259
260
|
// Should create junction table
|
|
260
|
-
expect(cleanResult).toContain(
|
|
261
|
-
expect(cleanResult).toContain(
|
|
262
|
-
expect(cleanResult).toContain(
|
|
263
|
-
expect(cleanResult).toContain(
|
|
261
|
+
expect(cleanResult).toContain("export const authorBooks = pgTable(\"author_books\"");
|
|
262
|
+
expect(cleanResult).toContain("author_id: varchar(\"author_id\").notNull().references(() => authors.id, { onDelete: \"cascade\" })");
|
|
263
|
+
expect(cleanResult).toContain("book_id: varchar(\"book_id\").notNull().references(() => books.id, { onDelete: \"cascade\" })");
|
|
264
|
+
expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"books\": many(authorBooks, { relationName: \"books\" }) }));");
|
|
264
265
|
});
|
|
265
266
|
|
|
266
267
|
it("should handle a 4-table many-to-many chain with joinPath", async () => {
|
|
@@ -270,7 +271,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
270
271
|
name: "Users",
|
|
271
272
|
properties: {
|
|
272
273
|
name: { type: "string" },
|
|
273
|
-
permissions: { type: "relation",
|
|
274
|
+
permissions: { type: "relation",
|
|
275
|
+
relationName: "permissions" }
|
|
274
276
|
},
|
|
275
277
|
relations: [
|
|
276
278
|
{
|
|
@@ -278,10 +280,18 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
278
280
|
target: () => permissionsCollection,
|
|
279
281
|
cardinality: "many",
|
|
280
282
|
joinPath: [
|
|
281
|
-
{ table: "user_roles",
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{ table: "
|
|
283
|
+
{ table: "user_roles",
|
|
284
|
+
on: { from: "id",
|
|
285
|
+
to: "user_id" } },
|
|
286
|
+
{ table: "roles",
|
|
287
|
+
on: { from: "role_id",
|
|
288
|
+
to: "id" } },
|
|
289
|
+
{ table: "role_permissions",
|
|
290
|
+
on: { from: "id",
|
|
291
|
+
to: "role_id" } },
|
|
292
|
+
{ table: "permissions",
|
|
293
|
+
on: { from: "permission_id",
|
|
294
|
+
to: "id" } }
|
|
285
295
|
]
|
|
286
296
|
}
|
|
287
297
|
]
|
|
@@ -330,7 +340,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
330
340
|
name: "Authors",
|
|
331
341
|
properties: {
|
|
332
342
|
name: { type: "string" },
|
|
333
|
-
profile: { type: "relation",
|
|
343
|
+
profile: { type: "relation",
|
|
344
|
+
relationName: "profile" }
|
|
334
345
|
},
|
|
335
346
|
relations: [
|
|
336
347
|
{
|
|
@@ -349,7 +360,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
349
360
|
name: "Profiles",
|
|
350
361
|
properties: {
|
|
351
362
|
bio: { type: "string" },
|
|
352
|
-
author: { type: "relation",
|
|
363
|
+
author: { type: "relation",
|
|
364
|
+
relationName: "author" }
|
|
353
365
|
},
|
|
354
366
|
relations: [
|
|
355
367
|
{
|
|
@@ -365,13 +377,13 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
365
377
|
const cleanResult = cleanSchema(result);
|
|
366
378
|
|
|
367
379
|
// Should create FK on profiles table
|
|
368
|
-
expect(cleanResult).toContain(
|
|
380
|
+
expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
|
|
369
381
|
|
|
370
382
|
// Should create owning relation on profiles
|
|
371
|
-
expect(cleanResult).toContain(
|
|
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\" }) }));");
|
|
372
384
|
|
|
373
385
|
// Should create inverse relation on authors (this was previously missing)
|
|
374
|
-
expect(cleanResult).toContain(
|
|
386
|
+
expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, { fields: [authors.id], references: [profiles.author_id], relationName: \"profiles_author_id\" }) }));");
|
|
375
387
|
});
|
|
376
388
|
|
|
377
389
|
it("should generate owning one-to-many relations", async () => {
|
|
@@ -380,7 +392,7 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
380
392
|
table: "categories",
|
|
381
393
|
name: "Categories",
|
|
382
394
|
properties: {
|
|
383
|
-
name: { type: "string" }
|
|
395
|
+
name: { type: "string" }
|
|
384
396
|
}
|
|
385
397
|
};
|
|
386
398
|
|
|
@@ -390,7 +402,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
390
402
|
name: "Posts",
|
|
391
403
|
properties: {
|
|
392
404
|
title: { type: "string" },
|
|
393
|
-
category: { type: "relation",
|
|
405
|
+
category: { type: "relation",
|
|
406
|
+
relationName: "category" }
|
|
394
407
|
},
|
|
395
408
|
relations: [
|
|
396
409
|
{
|
|
@@ -406,9 +419,9 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
406
419
|
const cleanResult = cleanSchema(result);
|
|
407
420
|
|
|
408
421
|
// Should create FK on posts table
|
|
409
|
-
expect(cleanResult).toContain(
|
|
422
|
+
expect(cleanResult).toContain("category_id: varchar(\"category_id\").references(() => categories.id, { onDelete: \"set null\" })");
|
|
410
423
|
// Should create owning relation on posts
|
|
411
|
-
expect(cleanResult).toContain(
|
|
424
|
+
expect(cleanResult).toContain("export const postsRelations = drizzleRelations(posts, ({ one, many }) => ({ \"category\": one(categories, { fields: [posts.category_id], references: [categories.id], relationName: \"posts_category_id\" }) }));");
|
|
412
425
|
});
|
|
413
426
|
});
|
|
414
427
|
|
|
@@ -420,7 +433,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
420
433
|
name: "Authors",
|
|
421
434
|
properties: {
|
|
422
435
|
name: { type: "string" },
|
|
423
|
-
publisher: { type: "relation",
|
|
436
|
+
publisher: { type: "relation",
|
|
437
|
+
relationName: "publisher" }
|
|
424
438
|
},
|
|
425
439
|
relations: [
|
|
426
440
|
{
|
|
@@ -447,7 +461,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
447
461
|
name: "Books",
|
|
448
462
|
properties: {
|
|
449
463
|
title: { type: "string" },
|
|
450
|
-
author: { type: "relation",
|
|
464
|
+
author: { type: "relation",
|
|
465
|
+
relationName: "author" }
|
|
451
466
|
},
|
|
452
467
|
relations: [{
|
|
453
468
|
relationName: "author",
|
|
@@ -461,12 +476,12 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
461
476
|
const cleanResult = cleanSchema(result);
|
|
462
477
|
|
|
463
478
|
// Check owning relation from author to publisher
|
|
464
|
-
expect(cleanResult).toContain(
|
|
465
|
-
expect(cleanResult).toContain(
|
|
479
|
+
expect(cleanResult).toContain("publisher_id: varchar(\"publisher_id\").references(() => publishers.id, { onDelete: \"set null\" })");
|
|
480
|
+
expect(cleanResult).toContain("\"publisher\": one(publishers, { fields: [authors.publisher_id], references: [publishers.id], relationName: \"authors_publisher_id\" })");
|
|
466
481
|
|
|
467
482
|
// Check owning relation from book to author
|
|
468
|
-
expect(cleanResult).toContain(
|
|
469
|
-
expect(cleanResult).toContain(
|
|
483
|
+
expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
|
|
484
|
+
expect(cleanResult).toContain("\"author\": one(authors, { fields: [books.author_id], references: [authors.id], relationName: \"books_author_id\" })");
|
|
470
485
|
});
|
|
471
486
|
});
|
|
472
487
|
|
|
@@ -478,8 +493,10 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
478
493
|
name: "Orders",
|
|
479
494
|
properties: {
|
|
480
495
|
customer_code: { type: "string" },
|
|
481
|
-
region_id: { type: "number",
|
|
482
|
-
|
|
496
|
+
region_id: { type: "number",
|
|
497
|
+
validation: { integer: true } },
|
|
498
|
+
customer: { type: "relation",
|
|
499
|
+
relationName: "customer" }
|
|
483
500
|
},
|
|
484
501
|
relations: [
|
|
485
502
|
{
|
|
@@ -487,7 +504,9 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
487
504
|
target: () => customersCollection,
|
|
488
505
|
cardinality: "many",
|
|
489
506
|
joinPath: [
|
|
490
|
-
{ table: "customers",
|
|
507
|
+
{ table: "customers",
|
|
508
|
+
on: { from: ["customer_code", "region_id"],
|
|
509
|
+
to: ["code", "region_id"] } }
|
|
491
510
|
]
|
|
492
511
|
}
|
|
493
512
|
]
|
|
@@ -499,7 +518,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
499
518
|
name: "Customers",
|
|
500
519
|
properties: {
|
|
501
520
|
code: { type: "string" },
|
|
502
|
-
region_id: { type: "number",
|
|
521
|
+
region_id: { type: "number",
|
|
522
|
+
validation: { integer: true } },
|
|
503
523
|
name: { type: "string" }
|
|
504
524
|
}
|
|
505
525
|
};
|
|
@@ -537,7 +557,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
537
557
|
name: "Users",
|
|
538
558
|
properties: {
|
|
539
559
|
name: { type: "string" },
|
|
540
|
-
friends: { type: "relation",
|
|
560
|
+
friends: { type: "relation",
|
|
561
|
+
relationName: "friends" }
|
|
541
562
|
},
|
|
542
563
|
relations: [
|
|
543
564
|
{
|
|
@@ -569,9 +590,11 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
569
590
|
table: "products",
|
|
570
591
|
name: "Products",
|
|
571
592
|
properties: {
|
|
572
|
-
sku: { type: "string",
|
|
593
|
+
sku: { type: "string",
|
|
594
|
+
isId: true },
|
|
573
595
|
name: { type: "string" },
|
|
574
|
-
categories: { type: "relation",
|
|
596
|
+
categories: { type: "relation",
|
|
597
|
+
relationName: "categories" }
|
|
575
598
|
},
|
|
576
599
|
relations: [
|
|
577
600
|
{
|
|
@@ -614,7 +637,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
614
637
|
name: "A Entities",
|
|
615
638
|
properties: {
|
|
616
639
|
name: { type: "string" },
|
|
617
|
-
b_entities: { type: "relation",
|
|
640
|
+
b_entities: { type: "relation",
|
|
641
|
+
relationName: "b_entities" }
|
|
618
642
|
},
|
|
619
643
|
relations: [
|
|
620
644
|
{
|
|
@@ -633,7 +657,8 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
633
657
|
name: "B Entities",
|
|
634
658
|
properties: {
|
|
635
659
|
name: { type: "string" },
|
|
636
|
-
a_entity: { type: "relation",
|
|
660
|
+
a_entity: { type: "relation",
|
|
661
|
+
relationName: "a_entity" }
|
|
637
662
|
},
|
|
638
663
|
relations: [
|
|
639
664
|
{
|
|
@@ -655,8 +680,436 @@ describe("Comprehensive Relations Test Suite", () => {
|
|
|
655
680
|
expect(cleanResult).toContain("export const bEntities = pgTable(\"b_entities\"");
|
|
656
681
|
expect(cleanResult).toContain("a_entity_id: varchar(\"a_entity_id\").references(() => aEntities.id, { onDelete: \"set null\" })");
|
|
657
682
|
// Check that both drizzle relations are generated
|
|
658
|
-
expect(cleanResult).toContain("
|
|
659
|
-
expect(cleanResult).toContain("
|
|
683
|
+
expect(cleanResult).toContain("\"b_entities\": many(bEntities, { relationName: \"b_entities_a_entity_id\" })");
|
|
684
|
+
expect(cleanResult).toContain("\"a_entity\": one(aEntities, { fields: [bEntities.a_entity_id], references: [aEntities.id], relationName: \"b_entities_a_entity_id\" })");
|
|
660
685
|
});
|
|
661
686
|
});
|
|
662
687
|
});
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Regression tests for https://github.com/rebasepro/rebase/issues/XXX
|
|
691
|
+
* Ensures both sides of an owning/inverse relation emit the same `relationName`.
|
|
692
|
+
*/
|
|
693
|
+
describe("Shared relationName regression", () => {
|
|
694
|
+
const cleanSchema = (schema: string) => {
|
|
695
|
+
return schema
|
|
696
|
+
.replace(/\/\/.*$/gm, "")
|
|
697
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
698
|
+
.replace(/\n{2,}/g, "\n")
|
|
699
|
+
.replace(/\s+/g, " ")
|
|
700
|
+
.trim();
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Helper that extracts all `relationName: "..."` values from generated schema output.
|
|
705
|
+
*/
|
|
706
|
+
const extractRelationNames = (schema: string): string[] => {
|
|
707
|
+
const matches = schema.match(/relationName:\s*"([^"]+)"/g) ?? [];
|
|
708
|
+
return matches.map(m => m.replace(/relationName:\s*"/, "").replace(/"$/, ""));
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
it("should emit identical relationName for one-to-many owning + inverse pair", async () => {
|
|
712
|
+
const companiesCollection: EntityCollection = {
|
|
713
|
+
slug: "companies",
|
|
714
|
+
table: "companies",
|
|
715
|
+
name: "Companies",
|
|
716
|
+
properties: {
|
|
717
|
+
name: { type: "string" }
|
|
718
|
+
},
|
|
719
|
+
relations: [
|
|
720
|
+
{
|
|
721
|
+
relationName: "jobs",
|
|
722
|
+
target: () => jobsCollection,
|
|
723
|
+
cardinality: "many",
|
|
724
|
+
direction: "inverse",
|
|
725
|
+
foreignKeyOnTarget: "company_id"
|
|
726
|
+
}
|
|
727
|
+
]
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const jobsCollection: EntityCollection = {
|
|
731
|
+
slug: "jobs",
|
|
732
|
+
table: "jobs",
|
|
733
|
+
name: "Jobs",
|
|
734
|
+
properties: {
|
|
735
|
+
title: { type: "string" },
|
|
736
|
+
company: { type: "relation",
|
|
737
|
+
relationName: "company" }
|
|
738
|
+
},
|
|
739
|
+
relations: [
|
|
740
|
+
{
|
|
741
|
+
relationName: "company",
|
|
742
|
+
target: () => companiesCollection,
|
|
743
|
+
cardinality: "one",
|
|
744
|
+
direction: "owning",
|
|
745
|
+
localKey: "company_id"
|
|
746
|
+
}
|
|
747
|
+
]
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const result = await generateSchema([companiesCollection, jobsCollection]);
|
|
751
|
+
const cleanResult = cleanSchema(result);
|
|
752
|
+
|
|
753
|
+
// Both sides must use the same deterministic name: jobs_company_id
|
|
754
|
+
const expectedSharedName = "jobs_company_id";
|
|
755
|
+
|
|
756
|
+
// Owning side (jobs → companies)
|
|
757
|
+
expect(cleanResult).toContain(
|
|
758
|
+
`"company": one(companies, { fields: [jobs.company_id], references: [companies.id], relationName: \"${expectedSharedName}\" })`
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
// Inverse side (companies → jobs)
|
|
762
|
+
expect(cleanResult).toContain(
|
|
763
|
+
`"jobs": many(jobs, { relationName: \"${expectedSharedName}\" })`
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
// Verify there are exactly 2 occurrences of the shared name
|
|
767
|
+
const allNames = extractRelationNames(result);
|
|
768
|
+
const matchingNames = allNames.filter(n => n === expectedSharedName);
|
|
769
|
+
expect(matchingNames).toHaveLength(2);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("should emit identical relationName for one-to-one owning + inverse pair", async () => {
|
|
773
|
+
const usersCollection: EntityCollection = {
|
|
774
|
+
slug: "users",
|
|
775
|
+
table: "users",
|
|
776
|
+
name: "Users",
|
|
777
|
+
properties: {
|
|
778
|
+
name: { type: "string" }
|
|
779
|
+
},
|
|
780
|
+
relations: [
|
|
781
|
+
{
|
|
782
|
+
relationName: "profile",
|
|
783
|
+
target: () => profilesCollection,
|
|
784
|
+
cardinality: "one",
|
|
785
|
+
direction: "inverse",
|
|
786
|
+
foreignKeyOnTarget: "user_id"
|
|
787
|
+
}
|
|
788
|
+
]
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const profilesCollection: EntityCollection = {
|
|
792
|
+
slug: "profiles",
|
|
793
|
+
table: "profiles",
|
|
794
|
+
name: "Profiles",
|
|
795
|
+
properties: {
|
|
796
|
+
bio: { type: "string" },
|
|
797
|
+
user: { type: "relation",
|
|
798
|
+
relationName: "user" }
|
|
799
|
+
},
|
|
800
|
+
relations: [
|
|
801
|
+
{
|
|
802
|
+
relationName: "user",
|
|
803
|
+
target: () => usersCollection,
|
|
804
|
+
cardinality: "one",
|
|
805
|
+
direction: "owning",
|
|
806
|
+
localKey: "user_id"
|
|
807
|
+
}
|
|
808
|
+
]
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const result = await generateSchema([usersCollection, profilesCollection]);
|
|
812
|
+
const cleanResult = cleanSchema(result);
|
|
813
|
+
|
|
814
|
+
const expectedSharedName = "profiles_user_id";
|
|
815
|
+
|
|
816
|
+
// Owning side (profiles → users)
|
|
817
|
+
expect(cleanResult).toContain(
|
|
818
|
+
`"user": one(users, { fields: [profiles.user_id], references: [users.id], relationName: \"${expectedSharedName}\" })`
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
// Inverse side (users → profiles)
|
|
822
|
+
expect(cleanResult).toContain(
|
|
823
|
+
`"profile": one(profiles, { fields: [users.id], references: [profiles.user_id], relationName: \"${expectedSharedName}\" })`
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
// Both must match
|
|
827
|
+
const allNames = extractRelationNames(result);
|
|
828
|
+
const matchingNames = allNames.filter(n => n === expectedSharedName);
|
|
829
|
+
expect(matchingNames).toHaveLength(2);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it("should emit different shared names for multiple relations between same tables", async () => {
|
|
833
|
+
const companiesCollection: EntityCollection = {
|
|
834
|
+
slug: "companies",
|
|
835
|
+
table: "companies",
|
|
836
|
+
name: "Companies",
|
|
837
|
+
properties: { name: { type: "string" } },
|
|
838
|
+
relations: [
|
|
839
|
+
{
|
|
840
|
+
relationName: "employees",
|
|
841
|
+
target: () => peopleCollection,
|
|
842
|
+
cardinality: "many",
|
|
843
|
+
direction: "inverse",
|
|
844
|
+
foreignKeyOnTarget: "employer_id"
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
relationName: "founders",
|
|
848
|
+
target: () => peopleCollection,
|
|
849
|
+
cardinality: "many",
|
|
850
|
+
direction: "inverse",
|
|
851
|
+
foreignKeyOnTarget: "startup_id"
|
|
852
|
+
}
|
|
853
|
+
]
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const peopleCollection: EntityCollection = {
|
|
857
|
+
slug: "people",
|
|
858
|
+
table: "people",
|
|
859
|
+
name: "People",
|
|
860
|
+
properties: {
|
|
861
|
+
name: { type: "string" },
|
|
862
|
+
employer: { type: "relation",
|
|
863
|
+
relationName: "employer" },
|
|
864
|
+
startup: { type: "relation",
|
|
865
|
+
relationName: "startup" }
|
|
866
|
+
},
|
|
867
|
+
relations: [
|
|
868
|
+
{
|
|
869
|
+
relationName: "employer",
|
|
870
|
+
target: () => companiesCollection,
|
|
871
|
+
cardinality: "one",
|
|
872
|
+
direction: "owning",
|
|
873
|
+
localKey: "employer_id"
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
relationName: "startup",
|
|
877
|
+
target: () => companiesCollection,
|
|
878
|
+
cardinality: "one",
|
|
879
|
+
direction: "owning",
|
|
880
|
+
localKey: "startup_id"
|
|
881
|
+
}
|
|
882
|
+
]
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const result = await generateSchema([companiesCollection, peopleCollection]);
|
|
886
|
+
const allNames = extractRelationNames(result);
|
|
887
|
+
|
|
888
|
+
// Each pair should have a distinct shared name
|
|
889
|
+
const employerNames = allNames.filter(n => n === "people_employer_id");
|
|
890
|
+
const startupNames = allNames.filter(n => n === "people_startup_id");
|
|
891
|
+
expect(employerNames).toHaveLength(2);
|
|
892
|
+
expect(startupNames).toHaveLength(2);
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Regression tests for duplicate relation emission.
|
|
898
|
+
*
|
|
899
|
+
* Bug: resolveCollectionRelations used to add slug/snake_case alias entries
|
|
900
|
+
* for every relation. When the schema generator iterated the dictionary, it
|
|
901
|
+
* emitted multiple one() definitions with the same `relationName`, causing
|
|
902
|
+
* Drizzle ORM to throw:
|
|
903
|
+
* "There are multiple relations with name 'jobs_company_id' in table 'jobs'"
|
|
904
|
+
*
|
|
905
|
+
* Also, property-based entries (e.g. `company_id: { type: "relation", relationName: "company" }`)
|
|
906
|
+
* duplicated explicit relation entries because the deduplication only compared
|
|
907
|
+
* property key vs relation key — not the underlying relationName.
|
|
908
|
+
*
|
|
909
|
+
* This suite covers both scenarios.
|
|
910
|
+
*/
|
|
911
|
+
describe("Duplicate relation deduplication regression", () => {
|
|
912
|
+
const cleanSchema = (schema: string) => {
|
|
913
|
+
return schema
|
|
914
|
+
.replace(/\/\/.*$/gm, "")
|
|
915
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
916
|
+
.replace(/\n{2,}/g, "\n")
|
|
917
|
+
.replace(/\s+/g, " ")
|
|
918
|
+
.trim();
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const extractRelationNames = (schema: string): string[] => {
|
|
922
|
+
const matches = schema.match(/relationName:\s*"([^"]+)"/g) ?? [];
|
|
923
|
+
return matches.map(m => m.replace(/relationName:\s*"/, "").replace(/"$/, ""));
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Count how many one() definitions exist for a specific relation key pattern.
|
|
928
|
+
* This matches `"<key>": one(` in the generated schema.
|
|
929
|
+
*/
|
|
930
|
+
const countOneEntries = (schema: string, keyPattern: string): number => {
|
|
931
|
+
const regex = new RegExp(`"${keyPattern}":\\s*one\\(`, "g");
|
|
932
|
+
return (schema.match(regex) ?? []).length;
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
it("should emit exactly one one() per FK when explicit relation + property share the same FK", async () => {
|
|
936
|
+
// This models the exact Sustentalent scenario:
|
|
937
|
+
// - Explicit relation: { relationName: "company", localKey: "company_id", ... }
|
|
938
|
+
// - Property: { company_id: { type: "relation", relationName: "company" } }
|
|
939
|
+
// Both reference the same FK `company_id`, but under different keys.
|
|
940
|
+
const companiesCollection: EntityCollection = {
|
|
941
|
+
slug: "companies",
|
|
942
|
+
table: "companies",
|
|
943
|
+
name: "Companies",
|
|
944
|
+
properties: {
|
|
945
|
+
name: { type: "string" }
|
|
946
|
+
},
|
|
947
|
+
relations: [
|
|
948
|
+
{
|
|
949
|
+
relationName: "jobs",
|
|
950
|
+
target: () => jobsCollection,
|
|
951
|
+
cardinality: "many",
|
|
952
|
+
direction: "inverse",
|
|
953
|
+
foreignKeyOnTarget: "company_id"
|
|
954
|
+
}
|
|
955
|
+
]
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const jobsCollection: EntityCollection = {
|
|
959
|
+
slug: "jobs",
|
|
960
|
+
table: "jobs",
|
|
961
|
+
name: "Jobs",
|
|
962
|
+
properties: {
|
|
963
|
+
title: { type: "string" },
|
|
964
|
+
// Property referencing the same FK as the explicit relation
|
|
965
|
+
company: {
|
|
966
|
+
type: "relation",
|
|
967
|
+
relationName: "company"
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
relations: [
|
|
971
|
+
{
|
|
972
|
+
relationName: "company",
|
|
973
|
+
target: () => companiesCollection,
|
|
974
|
+
cardinality: "one",
|
|
975
|
+
direction: "owning",
|
|
976
|
+
localKey: "company_id"
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const result = await generateSchema([companiesCollection, jobsCollection]);
|
|
982
|
+
const cleanResult = cleanSchema(result);
|
|
983
|
+
|
|
984
|
+
// The jobs table should have exactly ONE one() entry for company_id
|
|
985
|
+
const jobsRelationNames = extractRelationNames(result);
|
|
986
|
+
const companyIdRelNames = jobsRelationNames.filter(n => n === "jobs_company_id");
|
|
987
|
+
|
|
988
|
+
// Exactly 2: one on the owning side (jobs), one on the inverse side (companies)
|
|
989
|
+
expect(companyIdRelNames).toHaveLength(2);
|
|
990
|
+
|
|
991
|
+
// There must be no duplicate one() definitions within jobsRelations
|
|
992
|
+
const jobsRelationsBlock = result.match(/export const jobsRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
|
|
993
|
+
const oneEntriesInJobs = (jobsRelationsBlock.match(/:\s*one\(/g) ?? []).length;
|
|
994
|
+
expect(oneEntriesInJobs).toBe(1);
|
|
995
|
+
|
|
996
|
+
// The companies table should have exactly ONE many() entry for jobs
|
|
997
|
+
const companiesRelationsBlock = result.match(/export const companiesRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
|
|
998
|
+
const manyEntriesInCompanies = (companiesRelationsBlock.match(/:\s*many\(/g) ?? []).length;
|
|
999
|
+
expect(manyEntriesInCompanies).toBe(1);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("should not create aliases when relation key contains underscores", async () => {
|
|
1003
|
+
// Verify that resolving a collection with a snake_case relation name
|
|
1004
|
+
// does NOT produce slug-variant alias entries in the generated schema
|
|
1005
|
+
const parentCollection: EntityCollection = {
|
|
1006
|
+
slug: "departments",
|
|
1007
|
+
table: "departments",
|
|
1008
|
+
name: "Departments",
|
|
1009
|
+
properties: {
|
|
1010
|
+
name: { type: "string" }
|
|
1011
|
+
},
|
|
1012
|
+
relations: [
|
|
1013
|
+
{
|
|
1014
|
+
relationName: "team_members",
|
|
1015
|
+
target: () => memberCollection,
|
|
1016
|
+
cardinality: "many",
|
|
1017
|
+
direction: "inverse",
|
|
1018
|
+
foreignKeyOnTarget: "department_id"
|
|
1019
|
+
}
|
|
1020
|
+
]
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const memberCollection: EntityCollection = {
|
|
1024
|
+
slug: "team-members",
|
|
1025
|
+
table: "team_members",
|
|
1026
|
+
name: "Team Members",
|
|
1027
|
+
properties: {
|
|
1028
|
+
name: { type: "string" },
|
|
1029
|
+
department: {
|
|
1030
|
+
type: "relation",
|
|
1031
|
+
relationName: "department"
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
relations: [
|
|
1035
|
+
{
|
|
1036
|
+
relationName: "department",
|
|
1037
|
+
target: () => parentCollection,
|
|
1038
|
+
cardinality: "one",
|
|
1039
|
+
direction: "owning",
|
|
1040
|
+
localKey: "department_id"
|
|
1041
|
+
}
|
|
1042
|
+
]
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const result = await generateSchema([parentCollection, memberCollection]);
|
|
1046
|
+
|
|
1047
|
+
// team_members table should have exactly one one() definition
|
|
1048
|
+
const teamMembersRelBlock = result.match(/export const teamMembersRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
|
|
1049
|
+
const oneEntries = (teamMembersRelBlock.match(/:\s*one\(/g) ?? []).length;
|
|
1050
|
+
expect(oneEntries).toBe(1);
|
|
1051
|
+
|
|
1052
|
+
// No duplicate relation names anywhere
|
|
1053
|
+
const allNames = extractRelationNames(result);
|
|
1054
|
+
const nameCountMap = new Map<string, number>();
|
|
1055
|
+
for (const name of allNames) {
|
|
1056
|
+
nameCountMap.set(name, (nameCountMap.get(name) ?? 0) + 1);
|
|
1057
|
+
}
|
|
1058
|
+
// Every relation name should appear exactly twice (once per side)
|
|
1059
|
+
for (const [name, count] of nameCountMap) {
|
|
1060
|
+
expect(count).toBeLessThanOrEqual(2);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it("should handle multiple different relations to the same target without duplicates", async () => {
|
|
1065
|
+
// Two separate FKs from one table to the same target table
|
|
1066
|
+
const usersCollection: EntityCollection = {
|
|
1067
|
+
slug: "users",
|
|
1068
|
+
table: "users",
|
|
1069
|
+
name: "Users",
|
|
1070
|
+
properties: { name: { type: "string" } },
|
|
1071
|
+
relations: []
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const messagesCollection: EntityCollection = {
|
|
1075
|
+
slug: "messages",
|
|
1076
|
+
table: "messages",
|
|
1077
|
+
name: "Messages",
|
|
1078
|
+
properties: {
|
|
1079
|
+
content: { type: "string" },
|
|
1080
|
+
sender: { type: "relation",
|
|
1081
|
+
relationName: "sender" },
|
|
1082
|
+
recipient: { type: "relation",
|
|
1083
|
+
relationName: "recipient" }
|
|
1084
|
+
},
|
|
1085
|
+
relations: [
|
|
1086
|
+
{
|
|
1087
|
+
relationName: "sender",
|
|
1088
|
+
target: () => usersCollection,
|
|
1089
|
+
cardinality: "one",
|
|
1090
|
+
direction: "owning",
|
|
1091
|
+
localKey: "sender_id"
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
relationName: "recipient",
|
|
1095
|
+
target: () => usersCollection,
|
|
1096
|
+
cardinality: "one",
|
|
1097
|
+
direction: "owning",
|
|
1098
|
+
localKey: "recipient_id"
|
|
1099
|
+
}
|
|
1100
|
+
]
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const result = await generateSchema([usersCollection, messagesCollection]);
|
|
1104
|
+
|
|
1105
|
+
// messages table should have exactly TWO one() entries (one per FK)
|
|
1106
|
+
const messagesRelBlock = result.match(/export const messagesRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
|
|
1107
|
+
const oneEntries = (messagesRelBlock.match(/:\s*one\(/g) ?? []).length;
|
|
1108
|
+
expect(oneEntries).toBe(2);
|
|
1109
|
+
|
|
1110
|
+
// The two must have DIFFERENT relationName values
|
|
1111
|
+
const namesInMessages = extractRelationNames(messagesRelBlock);
|
|
1112
|
+
expect(namesInMessages).toHaveLength(2);
|
|
1113
|
+
expect(namesInMessages[0]).not.toBe(namesInMessages[1]);
|
|
1114
|
+
});
|
|
1115
|
+
});
|