@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- 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 +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -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/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- 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/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- 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 +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
import { EntityCollection, Relation } from "@rebasepro/types";
|
|
2
|
+
import { generateSchema } from "../src/schema/generate-drizzle-schema-logic";
|
|
3
|
+
import { sanitizeRelation } from "@rebasepro/common";
|
|
4
|
+
|
|
5
|
+
const mockAuthorCollection: EntityCollection = {
|
|
6
|
+
name: "Author",
|
|
7
|
+
slug: "author",
|
|
8
|
+
table: "authors",
|
|
9
|
+
properties: {
|
|
10
|
+
id: {
|
|
11
|
+
type: "number"
|
|
12
|
+
},
|
|
13
|
+
name: {
|
|
14
|
+
type: "string"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
idField: "id"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockPostCollection: EntityCollection = {
|
|
21
|
+
name: "Post",
|
|
22
|
+
slug: "posts",
|
|
23
|
+
table: "posts",
|
|
24
|
+
properties: {
|
|
25
|
+
id: {
|
|
26
|
+
type: "number"
|
|
27
|
+
},
|
|
28
|
+
title: {
|
|
29
|
+
type: "string"
|
|
30
|
+
},
|
|
31
|
+
author_id: {
|
|
32
|
+
type: "number"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
idField: "id"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const mockTagCollection: EntityCollection = {
|
|
39
|
+
name: "Tag",
|
|
40
|
+
slug: "tags",
|
|
41
|
+
table: "tags",
|
|
42
|
+
properties: {
|
|
43
|
+
id: {
|
|
44
|
+
type: "string"
|
|
45
|
+
},
|
|
46
|
+
name: {
|
|
47
|
+
type: "string"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
idField: "id"
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("sanitizeRelation", () => {
|
|
54
|
+
|
|
55
|
+
it("should generate a default relationName if not provided", () => {
|
|
56
|
+
const relation: Partial<Relation> = {
|
|
57
|
+
target: () => mockPostCollection,
|
|
58
|
+
cardinality: "one"
|
|
59
|
+
};
|
|
60
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
61
|
+
expect(normalized.relationName).toBe("posts");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --- Belongs-To (cardinality: 'one', direction: 'owning') ---
|
|
65
|
+
describe("Belongs-To (one-to-one/many-to-one)", () => {
|
|
66
|
+
it("should generate default localKey for a simple belongs-to relation", () => {
|
|
67
|
+
const relation: Partial<Relation> = {
|
|
68
|
+
relationName: "post",
|
|
69
|
+
target: () => mockPostCollection,
|
|
70
|
+
cardinality: "one"
|
|
71
|
+
};
|
|
72
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
73
|
+
expect(normalized.localKey).toEqual("post_id");
|
|
74
|
+
expect(normalized.direction).toEqual("owning");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should use provided `localKey` for a belongs-to relation", () => {
|
|
78
|
+
const relation: Partial<Relation> = {
|
|
79
|
+
relationName: "post",
|
|
80
|
+
target: () => mockPostCollection,
|
|
81
|
+
cardinality: "one",
|
|
82
|
+
localKey: "custom_post_fk"
|
|
83
|
+
};
|
|
84
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
85
|
+
expect(normalized.localKey).toEqual("custom_post_fk");
|
|
86
|
+
expect(normalized.direction).toEqual("owning");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- Inverse One-to-One (cardinality: 'one', direction: 'inverse') ---
|
|
91
|
+
describe("Inverse One-to-One", () => {
|
|
92
|
+
it("should generate default foreignKeyOnTarget for an inverse one-to-one relation", () => {
|
|
93
|
+
const relation: Partial<Relation> = {
|
|
94
|
+
relationName: "profile",
|
|
95
|
+
target: () => mockPostCollection,
|
|
96
|
+
cardinality: "one",
|
|
97
|
+
direction: "inverse"
|
|
98
|
+
};
|
|
99
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
100
|
+
expect(normalized.foreignKeyOnTarget).toEqual("author_id");
|
|
101
|
+
expect(normalized.direction).toEqual("inverse");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should use provided `foreignKeyOnTarget` for an inverse one-to-one relation", () => {
|
|
105
|
+
const relation: Partial<Relation> = {
|
|
106
|
+
relationName: "profile",
|
|
107
|
+
target: () => mockPostCollection,
|
|
108
|
+
cardinality: "one",
|
|
109
|
+
direction: "inverse",
|
|
110
|
+
foreignKeyOnTarget: "custom_author_fk"
|
|
111
|
+
};
|
|
112
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
113
|
+
expect(normalized.foreignKeyOnTarget).toEqual("custom_author_fk");
|
|
114
|
+
expect(normalized.direction).toEqual("inverse");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should work with inverseRelationName property", () => {
|
|
118
|
+
const relation: Partial<Relation> = {
|
|
119
|
+
relationName: "profile",
|
|
120
|
+
target: () => mockPostCollection,
|
|
121
|
+
cardinality: "one",
|
|
122
|
+
direction: "inverse",
|
|
123
|
+
inverseRelationName: "author"
|
|
124
|
+
};
|
|
125
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
126
|
+
expect(normalized.foreignKeyOnTarget).toEqual("author_id");
|
|
127
|
+
expect(normalized.inverseRelationName).toEqual("author");
|
|
128
|
+
expect(normalized.direction).toEqual("inverse");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- Has-Many (cardinality: 'many', direction: 'inverse') ---
|
|
133
|
+
describe("Has-Many (one-to-many)", () => {
|
|
134
|
+
it("should generate default foreignKeyOnTarget for a simple has-many relation", () => {
|
|
135
|
+
const relation: Partial<Relation> = {
|
|
136
|
+
relationName: "posts",
|
|
137
|
+
target: () => mockPostCollection,
|
|
138
|
+
cardinality: "many",
|
|
139
|
+
direction: "inverse"
|
|
140
|
+
};
|
|
141
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
142
|
+
expect(normalized.foreignKeyOnTarget).toEqual("author_id");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should use provided `foreignKeyOnTarget` for a has-many relation", () => {
|
|
146
|
+
const relation: Partial<Relation> = {
|
|
147
|
+
relationName: "posts",
|
|
148
|
+
target: () => mockPostCollection,
|
|
149
|
+
cardinality: "many",
|
|
150
|
+
direction: "inverse",
|
|
151
|
+
foreignKeyOnTarget: "writer_id"
|
|
152
|
+
};
|
|
153
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
154
|
+
expect(normalized.foreignKeyOnTarget).toEqual("writer_id");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- Many-To-Many (cardinality: 'many', through) ---
|
|
159
|
+
describe("Many-To-Many", () => {
|
|
160
|
+
it("should use provided `through` for a many-to-many relation", () => {
|
|
161
|
+
const relation: Partial<Relation> = {
|
|
162
|
+
relationName: "tags",
|
|
163
|
+
target: () => mockTagCollection,
|
|
164
|
+
cardinality: "many",
|
|
165
|
+
through: {
|
|
166
|
+
table: "posts_tags",
|
|
167
|
+
sourceColumn: "post_id",
|
|
168
|
+
targetColumn: "tag_id"
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const normalized = sanitizeRelation(relation, mockPostCollection);
|
|
172
|
+
expect(normalized.through).toEqual({
|
|
173
|
+
table: "posts_tags",
|
|
174
|
+
sourceColumn: "post_id",
|
|
175
|
+
targetColumn: "tag_id"
|
|
176
|
+
});
|
|
177
|
+
expect(normalized.direction).toEqual("owning");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// --- Fallback/Default Behavior ---
|
|
182
|
+
describe("Fallback Behavior", () => {
|
|
183
|
+
it("should fallback to has-many for ambiguous 'many' without direction or through", () => {
|
|
184
|
+
const relation: Partial<Relation> = {
|
|
185
|
+
relationName: "posts",
|
|
186
|
+
target: () => mockPostCollection,
|
|
187
|
+
cardinality: "many"
|
|
188
|
+
};
|
|
189
|
+
const normalized = sanitizeRelation(relation, mockAuthorCollection);
|
|
190
|
+
// Should default to has-many (inverse) behavior
|
|
191
|
+
expect(normalized.direction).toEqual("inverse");
|
|
192
|
+
expect(normalized.foreignKeyOnTarget).toEqual("author_id");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should handle 'one' with 'owning' direction", () => {
|
|
196
|
+
const relation: Partial<Relation> = {
|
|
197
|
+
relationName: "author",
|
|
198
|
+
target: () => mockAuthorCollection,
|
|
199
|
+
cardinality: "one",
|
|
200
|
+
direction: "owning" // Changed from "inverse"
|
|
201
|
+
};
|
|
202
|
+
const normalized = sanitizeRelation(relation, mockPostCollection);
|
|
203
|
+
expect(normalized.localKey).toEqual("author_id");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
/**
|
|
208
|
+
* Comprehensive test suite for complex relation scenarios
|
|
209
|
+
* This tests all the production use cases developers might implement
|
|
210
|
+
*/
|
|
211
|
+
describe("Comprehensive Relations Test Suite", () => {
|
|
212
|
+
|
|
213
|
+
const cleanSchema = (schema: string) => {
|
|
214
|
+
return schema
|
|
215
|
+
.replace(/\/\/.*$/gm, "")
|
|
216
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
217
|
+
.replace(/\n{2,}/g, "\n")
|
|
218
|
+
.replace(/\s+/g, " ")
|
|
219
|
+
.trim();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
describe("Many-to-Many Relations", () => {
|
|
223
|
+
it("should handle many-to-many with a through table", async () => {
|
|
224
|
+
const authorsCollection: EntityCollection = {
|
|
225
|
+
slug: "authors",
|
|
226
|
+
table: "authors",
|
|
227
|
+
name: "Authors",
|
|
228
|
+
properties: {
|
|
229
|
+
name: { type: "string" },
|
|
230
|
+
books: { type: "relation",
|
|
231
|
+
relationName: "books" }
|
|
232
|
+
},
|
|
233
|
+
relations: [
|
|
234
|
+
{
|
|
235
|
+
relationName: "books",
|
|
236
|
+
target: () => booksCollection,
|
|
237
|
+
cardinality: "many",
|
|
238
|
+
direction: "owning",
|
|
239
|
+
through: {
|
|
240
|
+
table: "author_books",
|
|
241
|
+
sourceColumn: "author_id",
|
|
242
|
+
targetColumn: "book_id"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const booksCollection: EntityCollection = {
|
|
249
|
+
slug: "books",
|
|
250
|
+
table: "books",
|
|
251
|
+
name: "Books",
|
|
252
|
+
properties: {
|
|
253
|
+
title: { type: "string" }
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = await generateSchema([authorsCollection, booksCollection]);
|
|
258
|
+
const cleanResult = cleanSchema(result);
|
|
259
|
+
|
|
260
|
+
// Should create junction table
|
|
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\" }) }));");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should handle a 4-table many-to-many chain with joinPath", async () => {
|
|
268
|
+
const usersCollection: EntityCollection = {
|
|
269
|
+
slug: "users",
|
|
270
|
+
table: "users",
|
|
271
|
+
name: "Users",
|
|
272
|
+
properties: {
|
|
273
|
+
name: { type: "string" },
|
|
274
|
+
permissions: { type: "relation",
|
|
275
|
+
relationName: "permissions" }
|
|
276
|
+
},
|
|
277
|
+
relations: [
|
|
278
|
+
{
|
|
279
|
+
relationName: "permissions",
|
|
280
|
+
target: () => permissionsCollection,
|
|
281
|
+
cardinality: "many",
|
|
282
|
+
joinPath: [
|
|
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" } }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const rolesCollection: EntityCollection = {
|
|
301
|
+
slug: "roles",
|
|
302
|
+
table: "roles",
|
|
303
|
+
name: "Roles",
|
|
304
|
+
properties: { name: { type: "string" } }
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const permissionsCollection: EntityCollection = {
|
|
308
|
+
slug: "permissions",
|
|
309
|
+
table: "permissions",
|
|
310
|
+
name: "Permissions",
|
|
311
|
+
properties: { name: { type: "string" } }
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const result = await generateSchema([usersCollection, rolesCollection, permissionsCollection]);
|
|
315
|
+
const cleanResult = cleanSchema(result);
|
|
316
|
+
|
|
317
|
+
// joinPath relations use existing user-controlled tables - no views or Drizzle relations generated
|
|
318
|
+
expect(cleanResult).not.toContain("view_users_to_permissions");
|
|
319
|
+
expect(cleanResult).not.toContain("viewUsersToPermissions");
|
|
320
|
+
|
|
321
|
+
// Should generate basic table definitions for users, roles, and permissions
|
|
322
|
+
expect(cleanResult).toContain("export const users = pgTable(\"users\"");
|
|
323
|
+
expect(cleanResult).toContain("export const roles = pgTable(\"roles\"");
|
|
324
|
+
expect(cleanResult).toContain("export const permissions = pgTable(\"permissions\"");
|
|
325
|
+
|
|
326
|
+
// No Drizzle relations generated for joinPath relations
|
|
327
|
+
expect(cleanResult).not.toContain("usersRelations");
|
|
328
|
+
|
|
329
|
+
// No SQL view generation comments
|
|
330
|
+
expect(result).not.toContain("CREATE OR REPLACE VIEW");
|
|
331
|
+
expect(result).not.toContain("SQL VIEWS FOR COMPLEX RELATIONS");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("Owning Relations", () => {
|
|
336
|
+
it("should generate owning one-to-one relations", async () => {
|
|
337
|
+
const authorsCollection: EntityCollection = {
|
|
338
|
+
slug: "authors",
|
|
339
|
+
table: "authors",
|
|
340
|
+
name: "Authors",
|
|
341
|
+
properties: {
|
|
342
|
+
name: { type: "string" },
|
|
343
|
+
profile: { type: "relation",
|
|
344
|
+
relationName: "profile" }
|
|
345
|
+
},
|
|
346
|
+
relations: [
|
|
347
|
+
{
|
|
348
|
+
relationName: "profile",
|
|
349
|
+
target: () => profilesCollection,
|
|
350
|
+
cardinality: "one",
|
|
351
|
+
direction: "inverse",
|
|
352
|
+
inverseRelationName: "author"
|
|
353
|
+
}
|
|
354
|
+
]
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const profilesCollection: EntityCollection = {
|
|
358
|
+
slug: "profiles",
|
|
359
|
+
table: "profiles",
|
|
360
|
+
name: "Profiles",
|
|
361
|
+
properties: {
|
|
362
|
+
bio: { type: "string" },
|
|
363
|
+
author: { type: "relation",
|
|
364
|
+
relationName: "author" }
|
|
365
|
+
},
|
|
366
|
+
relations: [
|
|
367
|
+
{
|
|
368
|
+
relationName: "author",
|
|
369
|
+
target: () => authorsCollection,
|
|
370
|
+
cardinality: "one",
|
|
371
|
+
localKey: "author_id"
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const result = await generateSchema([authorsCollection, profilesCollection]);
|
|
377
|
+
const cleanResult = cleanSchema(result);
|
|
378
|
+
|
|
379
|
+
// Should create FK on profiles table
|
|
380
|
+
expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
|
|
381
|
+
|
|
382
|
+
// Should create owning relation on profiles
|
|
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
|
+
|
|
385
|
+
// Should create inverse relation on authors (this was previously missing)
|
|
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\" }) }));");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("should generate owning one-to-many relations", async () => {
|
|
390
|
+
const categoriesCollection: EntityCollection = {
|
|
391
|
+
slug: "categories",
|
|
392
|
+
table: "categories",
|
|
393
|
+
name: "Categories",
|
|
394
|
+
properties: {
|
|
395
|
+
name: { type: "string" }
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const postsCollection: EntityCollection = {
|
|
400
|
+
slug: "posts",
|
|
401
|
+
table: "posts",
|
|
402
|
+
name: "Posts",
|
|
403
|
+
properties: {
|
|
404
|
+
title: { type: "string" },
|
|
405
|
+
category: { type: "relation",
|
|
406
|
+
relationName: "category" }
|
|
407
|
+
},
|
|
408
|
+
relations: [
|
|
409
|
+
{
|
|
410
|
+
relationName: "category",
|
|
411
|
+
target: () => categoriesCollection,
|
|
412
|
+
cardinality: "one",
|
|
413
|
+
localKey: "category_id"
|
|
414
|
+
}
|
|
415
|
+
]
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const result = await generateSchema([categoriesCollection, postsCollection]);
|
|
419
|
+
const cleanResult = cleanSchema(result);
|
|
420
|
+
|
|
421
|
+
// Should create FK on posts table
|
|
422
|
+
expect(cleanResult).toContain("category_id: varchar(\"category_id\").references(() => categories.id, { onDelete: \"set null\" })");
|
|
423
|
+
// Should create owning relation on posts
|
|
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\" }) }));");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("Mixed Relation Types", () => {
|
|
429
|
+
it("should handle collections with multiple relations", async () => {
|
|
430
|
+
const authorsCollection: EntityCollection = {
|
|
431
|
+
slug: "authors",
|
|
432
|
+
table: "authors",
|
|
433
|
+
name: "Authors",
|
|
434
|
+
properties: {
|
|
435
|
+
name: { type: "string" },
|
|
436
|
+
publisher: { type: "relation",
|
|
437
|
+
relationName: "publisher" }
|
|
438
|
+
},
|
|
439
|
+
relations: [
|
|
440
|
+
{
|
|
441
|
+
relationName: "publisher",
|
|
442
|
+
target: () => publishersCollection,
|
|
443
|
+
cardinality: "one",
|
|
444
|
+
localKey: "publisher_id"
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const publishersCollection: EntityCollection = {
|
|
450
|
+
slug: "publishers",
|
|
451
|
+
table: "publishers",
|
|
452
|
+
name: "Publishers",
|
|
453
|
+
properties: {
|
|
454
|
+
name: { type: "string" }
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const booksCollection: EntityCollection = {
|
|
459
|
+
slug: "books",
|
|
460
|
+
table: "books",
|
|
461
|
+
name: "Books",
|
|
462
|
+
properties: {
|
|
463
|
+
title: { type: "string" },
|
|
464
|
+
author: { type: "relation",
|
|
465
|
+
relationName: "author" }
|
|
466
|
+
},
|
|
467
|
+
relations: [{
|
|
468
|
+
relationName: "author",
|
|
469
|
+
target: () => authorsCollection,
|
|
470
|
+
cardinality: "one",
|
|
471
|
+
localKey: "author_id"
|
|
472
|
+
}]
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const result = await generateSchema([authorsCollection, publishersCollection, booksCollection]);
|
|
476
|
+
const cleanResult = cleanSchema(result);
|
|
477
|
+
|
|
478
|
+
// Check owning relation from author to publisher
|
|
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\" })");
|
|
481
|
+
|
|
482
|
+
// Check owning relation from book to author
|
|
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\" })");
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("Complex Multi-Column Relations", () => {
|
|
489
|
+
it("should handle multi-column foreign keys", async () => {
|
|
490
|
+
const ordersCollection: EntityCollection = {
|
|
491
|
+
slug: "orders",
|
|
492
|
+
table: "orders",
|
|
493
|
+
name: "Orders",
|
|
494
|
+
properties: {
|
|
495
|
+
customer_code: { type: "string" },
|
|
496
|
+
region_id: { type: "number",
|
|
497
|
+
validation: { integer: true } },
|
|
498
|
+
customer: { type: "relation",
|
|
499
|
+
relationName: "customer" }
|
|
500
|
+
},
|
|
501
|
+
relations: [
|
|
502
|
+
{
|
|
503
|
+
relationName: "customer",
|
|
504
|
+
target: () => customersCollection,
|
|
505
|
+
cardinality: "many",
|
|
506
|
+
joinPath: [
|
|
507
|
+
{ table: "customers",
|
|
508
|
+
on: { from: ["customer_code", "region_id"],
|
|
509
|
+
to: ["code", "region_id"] } }
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
]
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const customersCollection: EntityCollection = {
|
|
516
|
+
slug: "customers",
|
|
517
|
+
table: "customers",
|
|
518
|
+
name: "Customers",
|
|
519
|
+
properties: {
|
|
520
|
+
code: { type: "string" },
|
|
521
|
+
region_id: { type: "number",
|
|
522
|
+
validation: { integer: true } },
|
|
523
|
+
name: { type: "string" }
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const result = await generateSchema([ordersCollection, customersCollection]);
|
|
528
|
+
const cleanResult = cleanSchema(result);
|
|
529
|
+
|
|
530
|
+
// joinPath relations use existing user-controlled tables - no views generated
|
|
531
|
+
expect(cleanResult).not.toContain("view_orders_to_customers");
|
|
532
|
+
expect(cleanResult).not.toContain("viewOrdersToCustomers");
|
|
533
|
+
|
|
534
|
+
// Should generate basic table definitions
|
|
535
|
+
expect(cleanResult).toContain("export const orders = pgTable(\"orders\"");
|
|
536
|
+
expect(cleanResult).toContain("export const customers = pgTable(\"customers\"");
|
|
537
|
+
|
|
538
|
+
// Should include the multi-column properties in the tables
|
|
539
|
+
expect(cleanResult).toContain("customer_code: varchar(\"customer_code\")");
|
|
540
|
+
expect(cleanResult).toContain("region_id: integer(\"region_id\")");
|
|
541
|
+
expect(cleanResult).toContain("code: varchar(\"code\")");
|
|
542
|
+
|
|
543
|
+
// No Drizzle relations generated for joinPath relations
|
|
544
|
+
expect(cleanResult).not.toContain("ordersRelations");
|
|
545
|
+
|
|
546
|
+
// No SQL view generation
|
|
547
|
+
expect(result).not.toContain("CREATE OR REPLACE VIEW");
|
|
548
|
+
expect(result).not.toContain("SQL VIEWS FOR COMPLEX RELATIONS");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("Edge Cases and Production Scenarios", () => {
|
|
553
|
+
it("should handle self-referencing many-to-many", async () => {
|
|
554
|
+
const usersCollection: EntityCollection = {
|
|
555
|
+
slug: "users",
|
|
556
|
+
table: "users",
|
|
557
|
+
name: "Users",
|
|
558
|
+
properties: {
|
|
559
|
+
name: { type: "string" },
|
|
560
|
+
friends: { type: "relation",
|
|
561
|
+
relationName: "friends" }
|
|
562
|
+
},
|
|
563
|
+
relations: [
|
|
564
|
+
{
|
|
565
|
+
relationName: "friends",
|
|
566
|
+
target: () => usersCollection,
|
|
567
|
+
cardinality: "many",
|
|
568
|
+
direction: "owning",
|
|
569
|
+
through: {
|
|
570
|
+
table: "user_friends",
|
|
571
|
+
sourceColumn: "user_id",
|
|
572
|
+
targetColumn: "friend_id"
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
]
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const result = await generateSchema([usersCollection]);
|
|
579
|
+
const cleanResult = cleanSchema(result);
|
|
580
|
+
|
|
581
|
+
// Should handle self-referencing relations
|
|
582
|
+
expect(cleanResult).toContain("export const userFriends = pgTable(\"user_friends\"");
|
|
583
|
+
expect(cleanResult).toContain("user_id: varchar(\"user_id\").notNull().references(() => users.id, { onDelete: \"cascade\" })");
|
|
584
|
+
expect(cleanResult).toContain("friend_id: varchar(\"friend_id\").notNull().references(() => users.id, { onDelete: \"cascade\" })");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("should handle mixed ID types in relations", async () => {
|
|
588
|
+
const productsCollection: EntityCollection = {
|
|
589
|
+
slug: "products",
|
|
590
|
+
table: "products",
|
|
591
|
+
name: "Products",
|
|
592
|
+
properties: {
|
|
593
|
+
sku: { type: "string",
|
|
594
|
+
isId: true },
|
|
595
|
+
name: { type: "string" },
|
|
596
|
+
categories: { type: "relation",
|
|
597
|
+
relationName: "categories" }
|
|
598
|
+
},
|
|
599
|
+
relations: [
|
|
600
|
+
{
|
|
601
|
+
relationName: "categories",
|
|
602
|
+
target: () => categoriesCollection,
|
|
603
|
+
cardinality: "many",
|
|
604
|
+
direction: "owning",
|
|
605
|
+
through: {
|
|
606
|
+
table: "product_categories",
|
|
607
|
+
sourceColumn: "product_sku",
|
|
608
|
+
targetColumn: "category_id"
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
]
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const categoriesCollection: EntityCollection = {
|
|
615
|
+
slug: "categories",
|
|
616
|
+
table: "categories",
|
|
617
|
+
name: "Categories",
|
|
618
|
+
properties: {
|
|
619
|
+
name: { type: "string" }
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const result = await generateSchema([productsCollection, categoriesCollection]);
|
|
624
|
+
const cleanResult = cleanSchema(result);
|
|
625
|
+
|
|
626
|
+
// The primary key should be sku
|
|
627
|
+
expect(cleanResult).toContain("sku: varchar(\"sku\").primaryKey()");
|
|
628
|
+
expect(cleanResult).not.toContain("id: serial(\"id\").primaryKey()");
|
|
629
|
+
expect(cleanResult).toContain("product_sku: varchar(\"product_sku\").notNull().references(() => products.sku, { onDelete: \"cascade\" })");
|
|
630
|
+
expect(cleanResult).toContain("category_id: varchar(\"category_id\").notNull().references(() => categories.id, { onDelete: \"cascade\" })");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("should handle circular references", async () => {
|
|
634
|
+
const aCollection: EntityCollection = {
|
|
635
|
+
slug: "a_entities",
|
|
636
|
+
table: "a_entities",
|
|
637
|
+
name: "A Entities",
|
|
638
|
+
properties: {
|
|
639
|
+
name: { type: "string" },
|
|
640
|
+
b_entities: { type: "relation",
|
|
641
|
+
relationName: "b_entities" }
|
|
642
|
+
},
|
|
643
|
+
relations: [
|
|
644
|
+
{
|
|
645
|
+
relationName: "b_entities",
|
|
646
|
+
target: () => bCollection,
|
|
647
|
+
cardinality: "many",
|
|
648
|
+
direction: "inverse",
|
|
649
|
+
foreignKeyOnTarget: "a_entity_id"
|
|
650
|
+
}
|
|
651
|
+
]
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const bCollection: EntityCollection = {
|
|
655
|
+
slug: "b_entities",
|
|
656
|
+
table: "b_entities",
|
|
657
|
+
name: "B Entities",
|
|
658
|
+
properties: {
|
|
659
|
+
name: { type: "string" },
|
|
660
|
+
a_entity: { type: "relation",
|
|
661
|
+
relationName: "a_entity" }
|
|
662
|
+
},
|
|
663
|
+
relations: [
|
|
664
|
+
{
|
|
665
|
+
relationName: "a_entity",
|
|
666
|
+
target: () => aCollection,
|
|
667
|
+
cardinality: "one",
|
|
668
|
+
direction: "owning",
|
|
669
|
+
localKey: "a_entity_id"
|
|
670
|
+
}
|
|
671
|
+
]
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const result = await generateSchema([aCollection, bCollection]);
|
|
675
|
+
const cleanResult = cleanSchema(result);
|
|
676
|
+
|
|
677
|
+
// Should handle circular references without infinite loops
|
|
678
|
+
// The 'owning' relation on bCollection should correctly generate the FK
|
|
679
|
+
expect(cleanResult).toContain("export const aEntities = pgTable(\"a_entities\"");
|
|
680
|
+
expect(cleanResult).toContain("export const bEntities = pgTable(\"b_entities\"");
|
|
681
|
+
expect(cleanResult).toContain("a_entity_id: varchar(\"a_entity_id\").references(() => aEntities.id, { onDelete: \"set null\" })");
|
|
682
|
+
// Check that both drizzle relations are generated
|
|
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\" })");
|
|
685
|
+
});
|
|
686
|
+
});
|
|
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
|
+
});
|