create-phoenixjs 0.1.4 → 0.1.5
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/package.json +1 -1
- package/template/config/database.ts +13 -1
- package/template/database/migrations/2024_01_01_000000_create_test_users_cli_table.ts +16 -0
- package/template/database/migrations/20260108164611_TestCliMigration.ts +16 -0
- package/template/database/migrations/2026_01_08_16_46_11_CreateTestMigrationsTable.ts +21 -0
- package/template/framework/cli/artisan.ts +12 -0
- package/template/framework/database/DatabaseManager.ts +133 -0
- package/template/framework/database/connection/Connection.ts +71 -0
- package/template/framework/database/connection/ConnectionFactory.ts +30 -0
- package/template/framework/database/connection/PostgresConnection.ts +159 -0
- package/template/framework/database/console/MakeMigrationCommand.ts +58 -0
- package/template/framework/database/console/MigrateCommand.ts +32 -0
- package/template/framework/database/console/MigrateResetCommand.ts +31 -0
- package/template/framework/database/console/MigrateRollbackCommand.ts +31 -0
- package/template/framework/database/console/MigrateStatusCommand.ts +38 -0
- package/template/framework/database/migrations/DatabaseMigrationRepository.ts +122 -0
- package/template/framework/database/migrations/Migration.ts +5 -0
- package/template/framework/database/migrations/MigrationRepository.ts +46 -0
- package/template/framework/database/migrations/Migrator.ts +249 -0
- package/template/framework/database/migrations/index.ts +4 -0
- package/template/framework/database/orm/BelongsTo.ts +246 -0
- package/template/framework/database/orm/BelongsToMany.ts +570 -0
- package/template/framework/database/orm/Builder.ts +160 -0
- package/template/framework/database/orm/EagerLoadingBuilder.ts +324 -0
- package/template/framework/database/orm/HasMany.ts +303 -0
- package/template/framework/database/orm/HasManyThrough.ts +282 -0
- package/template/framework/database/orm/HasOne.ts +201 -0
- package/template/framework/database/orm/HasOneThrough.ts +281 -0
- package/template/framework/database/orm/Model.ts +1766 -0
- package/template/framework/database/orm/Relation.ts +342 -0
- package/template/framework/database/orm/Scope.ts +14 -0
- package/template/framework/database/orm/SoftDeletes.ts +160 -0
- package/template/framework/database/orm/index.ts +54 -0
- package/template/framework/database/orm/scopes/SoftDeletingScope.ts +58 -0
- package/template/framework/database/pagination/LengthAwarePaginator.ts +55 -0
- package/template/framework/database/pagination/Paginator.ts +110 -0
- package/template/framework/database/pagination/index.ts +2 -0
- package/template/framework/database/query/Builder.ts +918 -0
- package/template/framework/database/query/DB.ts +139 -0
- package/template/framework/database/query/grammars/Grammar.ts +430 -0
- package/template/framework/database/query/grammars/PostgresGrammar.ts +224 -0
- package/template/framework/database/query/grammars/index.ts +6 -0
- package/template/framework/database/query/index.ts +8 -0
- package/template/framework/database/query/types.ts +196 -0
- package/template/framework/database/schema/Blueprint.ts +478 -0
- package/template/framework/database/schema/Schema.ts +149 -0
- package/template/framework/database/schema/SchemaBuilder.ts +152 -0
- package/template/framework/database/schema/grammars/PostgresSchemaGrammar.ts +293 -0
- package/template/framework/database/schema/grammars/index.ts +5 -0
- package/template/framework/database/schema/index.ts +9 -0
- package/template/package.json +4 -1
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { DB } from '../query/DB';
|
|
2
|
+
import { Relation } from './Relation';
|
|
3
|
+
import type { Model } from './Model';
|
|
4
|
+
import type { Binding, InsertValues, UpdateValues } from '../query/types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BelongsToMany Relationship - Represents a many-to-many relationship with a pivot table.
|
|
8
|
+
*
|
|
9
|
+
* Uses an intermediate (pivot) table to connect two models.
|
|
10
|
+
*
|
|
11
|
+
* Example Schema:
|
|
12
|
+
* - users table: id, name, email
|
|
13
|
+
* - roles table: id, name
|
|
14
|
+
* - role_user table (pivot): user_id, role_id, assigned_at (optional extra column)
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* ```typescript
|
|
18
|
+
* class User extends Model {
|
|
19
|
+
* roles() {
|
|
20
|
+
* return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id');
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* class Role extends Model {
|
|
25
|
+
* users() {
|
|
26
|
+
* return this.belongsToMany(User, 'role_user', 'role_id', 'user_id');
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* // Get user's roles
|
|
31
|
+
* const user = await User.find(1);
|
|
32
|
+
* const roles = await user.roles().get();
|
|
33
|
+
*
|
|
34
|
+
* // Attach a role
|
|
35
|
+
* await user.roles().attach(1);
|
|
36
|
+
* await user.roles().attach([1, 2, 3]);
|
|
37
|
+
*
|
|
38
|
+
* // Detach a role
|
|
39
|
+
* await user.roles().detach(1);
|
|
40
|
+
* await user.roles().detach(); // Detach all
|
|
41
|
+
*
|
|
42
|
+
* // Sync roles (attach new, detach missing)
|
|
43
|
+
* await user.roles().sync([1, 2, 3]);
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @template TRelated - The type of the related model
|
|
47
|
+
*/
|
|
48
|
+
export class BelongsToMany<TRelated extends Model> extends Relation<TRelated> {
|
|
49
|
+
/**
|
|
50
|
+
* The pivot table name.
|
|
51
|
+
* Example: 'role_user'
|
|
52
|
+
*/
|
|
53
|
+
protected table: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The foreign key of the parent model on the pivot table.
|
|
57
|
+
* Example: 'user_id' on role_user table
|
|
58
|
+
*/
|
|
59
|
+
protected foreignPivotKey: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The foreign key of the related model on the pivot table.
|
|
63
|
+
* Example: 'role_id' on role_user table
|
|
64
|
+
*/
|
|
65
|
+
protected relatedPivotKey: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The primary key on the parent model.
|
|
69
|
+
* Example: 'id' on users table
|
|
70
|
+
*/
|
|
71
|
+
protected parentKey: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The primary key on the related model.
|
|
75
|
+
* Example: 'id' on roles table
|
|
76
|
+
*/
|
|
77
|
+
protected relatedKey: string;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extra pivot columns to include in results.
|
|
81
|
+
*/
|
|
82
|
+
protected pivotColumns: string[] = [];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a new BelongsToMany relationship instance.
|
|
86
|
+
*
|
|
87
|
+
* @param parent - The parent model instance
|
|
88
|
+
* @param related - An instance of the related model class
|
|
89
|
+
* @param table - The pivot table name (e.g., 'role_user')
|
|
90
|
+
* @param foreignPivotKey - The foreign key for the parent on the pivot table (e.g., 'user_id')
|
|
91
|
+
* @param relatedPivotKey - The foreign key for the related model on the pivot table (e.g., 'role_id')
|
|
92
|
+
* @param parentKey - The primary key on the parent model (e.g., 'id')
|
|
93
|
+
* @param relatedKey - The primary key on the related model (e.g., 'id')
|
|
94
|
+
*/
|
|
95
|
+
constructor(
|
|
96
|
+
parent: Model,
|
|
97
|
+
related: TRelated,
|
|
98
|
+
table: string,
|
|
99
|
+
foreignPivotKey: string,
|
|
100
|
+
relatedPivotKey: string,
|
|
101
|
+
parentKey: string,
|
|
102
|
+
relatedKey: string
|
|
103
|
+
) {
|
|
104
|
+
super(parent, related);
|
|
105
|
+
|
|
106
|
+
this.table = table;
|
|
107
|
+
this.foreignPivotKey = foreignPivotKey;
|
|
108
|
+
this.relatedPivotKey = relatedPivotKey;
|
|
109
|
+
this.parentKey = parentKey;
|
|
110
|
+
this.relatedKey = relatedKey;
|
|
111
|
+
|
|
112
|
+
// Re-add constraints now that keys are set
|
|
113
|
+
this.reinitializeConstraints();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Re-initialize constraints after keys are set.
|
|
118
|
+
*/
|
|
119
|
+
private reinitializeConstraints(): void {
|
|
120
|
+
this.query = this.newQuery();
|
|
121
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
122
|
+
const relatedTable = this.related.getTable();
|
|
123
|
+
|
|
124
|
+
// Add the join to the pivot table
|
|
125
|
+
this.query.join(
|
|
126
|
+
this.table,
|
|
127
|
+
`${relatedTable}.${this.relatedKey}`,
|
|
128
|
+
'=',
|
|
129
|
+
`${this.table}.${this.relatedPivotKey}`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Constrain by parent's key
|
|
133
|
+
if (parentKeyValue !== undefined && parentKeyValue !== null) {
|
|
134
|
+
this.query.where(`${this.table}.${this.foreignPivotKey}`, '=', parentKeyValue as Binding);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Add the constraints for the BelongsToMany relationship.
|
|
140
|
+
* Called by parent constructor - keys may not be set yet.
|
|
141
|
+
*/
|
|
142
|
+
public addConstraints(): void {
|
|
143
|
+
// Constraints are added in reinitializeConstraints after keys are set
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the results of the BelongsToMany relationship.
|
|
148
|
+
*
|
|
149
|
+
* Returns an array of related models. If no related models exist,
|
|
150
|
+
* returns an empty array (never null).
|
|
151
|
+
*
|
|
152
|
+
* @returns Array of related models
|
|
153
|
+
*/
|
|
154
|
+
public async getResults(): Promise<TRelated[]> {
|
|
155
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
156
|
+
|
|
157
|
+
// If parent doesn't have a key value, return empty array
|
|
158
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return this.getAll();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Include extra pivot columns in the results.
|
|
167
|
+
*
|
|
168
|
+
* Usage:
|
|
169
|
+
* ```typescript
|
|
170
|
+
* const roles = await user.roles().withPivot('assigned_at', 'created_by').get();
|
|
171
|
+
* // Each role will have role.pivot.assigned_at available
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @param columns - Column names to include from the pivot table
|
|
175
|
+
* @returns This relation for chaining
|
|
176
|
+
*/
|
|
177
|
+
public withPivot(...columns: string[]): this {
|
|
178
|
+
this.pivotColumns.push(...columns);
|
|
179
|
+
|
|
180
|
+
// Add pivot columns to select
|
|
181
|
+
for (const column of columns) {
|
|
182
|
+
this.query.addSelect(`${this.table}.${column}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Add a WHERE clause on a pivot table column.
|
|
190
|
+
*
|
|
191
|
+
* Usage:
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const activeRoles = await user.roles()
|
|
194
|
+
* .wherePivot('active', true)
|
|
195
|
+
* .get();
|
|
196
|
+
* ```
|
|
197
|
+
*
|
|
198
|
+
* @param column - The pivot column to filter on
|
|
199
|
+
* @param operatorOrValue - The operator or value
|
|
200
|
+
* @param value - The value if operator provided
|
|
201
|
+
* @returns This relation for chaining
|
|
202
|
+
*/
|
|
203
|
+
public wherePivot(
|
|
204
|
+
column: string,
|
|
205
|
+
operatorOrValue: string | number | boolean | null,
|
|
206
|
+
value?: string | number | boolean | null
|
|
207
|
+
): this {
|
|
208
|
+
const qualifiedColumn = `${this.table}.${column}`;
|
|
209
|
+
this.query.where(qualifiedColumn, operatorOrValue, value);
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Add a WHERE IN clause on a pivot table column.
|
|
215
|
+
*
|
|
216
|
+
* @param column - The pivot column to filter on
|
|
217
|
+
* @param values - Array of values to match
|
|
218
|
+
* @returns This relation for chaining
|
|
219
|
+
*/
|
|
220
|
+
public wherePivotIn(column: string, values: (string | number | boolean | null)[]): this {
|
|
221
|
+
const qualifiedColumn = `${this.table}.${column}`;
|
|
222
|
+
this.query.whereIn(qualifiedColumn, values);
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Attach one or more related models by their IDs.
|
|
228
|
+
*
|
|
229
|
+
* This creates records in the pivot table linking the parent to the related models.
|
|
230
|
+
*
|
|
231
|
+
* Usage:
|
|
232
|
+
* ```typescript
|
|
233
|
+
* // Attach a single role
|
|
234
|
+
* await user.roles().attach(1);
|
|
235
|
+
*
|
|
236
|
+
* // Attach multiple roles
|
|
237
|
+
* await user.roles().attach([1, 2, 3]);
|
|
238
|
+
*
|
|
239
|
+
* // Attach with extra pivot data
|
|
240
|
+
* await user.roles().attach(1, { assigned_at: new Date() });
|
|
241
|
+
* await user.roles().attach([1, 2], { assigned_by: 'admin' });
|
|
242
|
+
* ```
|
|
243
|
+
*
|
|
244
|
+
* @param ids - A single ID or array of IDs to attach
|
|
245
|
+
* @param attributes - Optional extra attributes for the pivot record
|
|
246
|
+
*/
|
|
247
|
+
public async attach(
|
|
248
|
+
ids: number | string | (number | string)[],
|
|
249
|
+
attributes: Record<string, Binding> = {}
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
252
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
253
|
+
throw new Error('Cannot attach: parent model has no primary key value');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const idsArray = Array.isArray(ids) ? ids : [ids];
|
|
257
|
+
|
|
258
|
+
if (idsArray.length === 0) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Create pivot records
|
|
263
|
+
const records: InsertValues[] = idsArray.map((id) => ({
|
|
264
|
+
[this.foreignPivotKey]: parentKeyValue as Binding,
|
|
265
|
+
[this.relatedPivotKey]: id as Binding,
|
|
266
|
+
...attributes
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
await DB.table(this.table).insert(records);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Detach one or more related models by their IDs.
|
|
274
|
+
*
|
|
275
|
+
* This removes records from the pivot table.
|
|
276
|
+
*
|
|
277
|
+
* Usage:
|
|
278
|
+
* ```typescript
|
|
279
|
+
* // Detach a single role
|
|
280
|
+
* await user.roles().detach(1);
|
|
281
|
+
*
|
|
282
|
+
* // Detach multiple roles
|
|
283
|
+
* await user.roles().detach([1, 2, 3]);
|
|
284
|
+
*
|
|
285
|
+
* // Detach all roles
|
|
286
|
+
* await user.roles().detach();
|
|
287
|
+
* ```
|
|
288
|
+
*
|
|
289
|
+
* @param ids - Optional ID or array of IDs to detach. If not provided, detaches all.
|
|
290
|
+
* @returns The number of detached records
|
|
291
|
+
*/
|
|
292
|
+
public async detach(ids?: number | string | (number | string)[]): Promise<number> {
|
|
293
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
294
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
295
|
+
throw new Error('Cannot detach: parent model has no primary key value');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let query = DB.table(this.table)
|
|
299
|
+
.where(this.foreignPivotKey, '=', parentKeyValue as Binding);
|
|
300
|
+
|
|
301
|
+
if (ids !== undefined) {
|
|
302
|
+
const idsArray = Array.isArray(ids) ? ids : [ids];
|
|
303
|
+
if (idsArray.length > 0) {
|
|
304
|
+
query = query.whereIn(this.relatedPivotKey, idsArray);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return query.delete();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Sync the relationship with the given IDs.
|
|
313
|
+
*
|
|
314
|
+
* This will attach any IDs that are not currently attached,
|
|
315
|
+
* and detach any IDs that are not in the given list.
|
|
316
|
+
*
|
|
317
|
+
* Usage:
|
|
318
|
+
* ```typescript
|
|
319
|
+
* // After this, user will have exactly roles 1, 2, 3
|
|
320
|
+
* await user.roles().sync([1, 2, 3]);
|
|
321
|
+
* ```
|
|
322
|
+
*
|
|
323
|
+
* @param ids - Array of IDs that should be attached
|
|
324
|
+
* @returns Object with attached and detached IDs
|
|
325
|
+
*/
|
|
326
|
+
public async sync(ids: (number | string)[]): Promise<{
|
|
327
|
+
attached: (number | string)[];
|
|
328
|
+
detached: (number | string)[];
|
|
329
|
+
}> {
|
|
330
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
331
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
332
|
+
throw new Error('Cannot sync: parent model has no primary key value');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Get currently attached IDs
|
|
336
|
+
const currentPivotRecords = await DB.table(this.table)
|
|
337
|
+
.where(this.foreignPivotKey, '=', parentKeyValue as Binding)
|
|
338
|
+
.get<Record<string, unknown>>();
|
|
339
|
+
|
|
340
|
+
const currentIds = currentPivotRecords.map(
|
|
341
|
+
(record) => record[this.relatedPivotKey] as number | string
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Determine what to attach and detach
|
|
345
|
+
const toAttach = ids.filter((id) => !currentIds.includes(id));
|
|
346
|
+
const toDetach = currentIds.filter((id) => !ids.includes(id));
|
|
347
|
+
|
|
348
|
+
// Perform operations
|
|
349
|
+
if (toAttach.length > 0) {
|
|
350
|
+
await this.attach(toAttach);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (toDetach.length > 0) {
|
|
354
|
+
await this.detach(toDetach);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
attached: toAttach,
|
|
359
|
+
detached: toDetach
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Toggle the attachment of the given IDs.
|
|
365
|
+
*
|
|
366
|
+
* If an ID is currently attached, it will be detached.
|
|
367
|
+
* If an ID is not attached, it will be attached.
|
|
368
|
+
*
|
|
369
|
+
* Usage:
|
|
370
|
+
* ```typescript
|
|
371
|
+
* await user.roles().toggle([1, 2, 3]);
|
|
372
|
+
* ```
|
|
373
|
+
*
|
|
374
|
+
* @param ids - Array of IDs to toggle
|
|
375
|
+
* @returns Object with attached and detached IDs
|
|
376
|
+
*/
|
|
377
|
+
public async toggle(ids: (number | string)[]): Promise<{
|
|
378
|
+
attached: (number | string)[];
|
|
379
|
+
detached: (number | string)[];
|
|
380
|
+
}> {
|
|
381
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
382
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
383
|
+
throw new Error('Cannot toggle: parent model has no primary key value');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Get currently attached IDs
|
|
387
|
+
const currentPivotRecords = await DB.table(this.table)
|
|
388
|
+
.where(this.foreignPivotKey, '=', parentKeyValue as Binding)
|
|
389
|
+
.get<Record<string, unknown>>();
|
|
390
|
+
|
|
391
|
+
const currentIds = currentPivotRecords.map(
|
|
392
|
+
(record) => record[this.relatedPivotKey] as number | string
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Determine what to attach and detach
|
|
396
|
+
const toAttach = ids.filter((id) => !currentIds.includes(id));
|
|
397
|
+
const toDetach = ids.filter((id) => currentIds.includes(id));
|
|
398
|
+
|
|
399
|
+
// Perform operations
|
|
400
|
+
if (toAttach.length > 0) {
|
|
401
|
+
await this.attach(toAttach);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (toDetach.length > 0) {
|
|
405
|
+
await this.detach(toDetach);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
attached: toAttach,
|
|
410
|
+
detached: toDetach
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Update an existing pivot record.
|
|
416
|
+
*
|
|
417
|
+
* Usage:
|
|
418
|
+
* ```typescript
|
|
419
|
+
* await user.roles().updateExistingPivot(1, { active: false });
|
|
420
|
+
* ```
|
|
421
|
+
*
|
|
422
|
+
* @param id - The related model's ID
|
|
423
|
+
* @param attributes - Attributes to update on the pivot record
|
|
424
|
+
* @returns Number of affected rows
|
|
425
|
+
*/
|
|
426
|
+
public async updateExistingPivot(
|
|
427
|
+
id: number | string,
|
|
428
|
+
attributes: UpdateValues
|
|
429
|
+
): Promise<number> {
|
|
430
|
+
const parentKeyValue = this.parent.getAttribute(this.parentKey);
|
|
431
|
+
if (parentKeyValue === undefined || parentKeyValue === null) {
|
|
432
|
+
throw new Error('Cannot update pivot: parent model has no primary key value');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return DB.table(this.table)
|
|
436
|
+
.where(this.foreignPivotKey, '=', parentKeyValue as Binding)
|
|
437
|
+
.where(this.relatedPivotKey, '=', id as Binding)
|
|
438
|
+
.update(attributes);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Get the pivot table name.
|
|
443
|
+
*
|
|
444
|
+
* @returns The pivot table name
|
|
445
|
+
*/
|
|
446
|
+
public getPivotTable(): string {
|
|
447
|
+
return this.table;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get the foreign pivot key name.
|
|
452
|
+
*
|
|
453
|
+
* @returns The foreign pivot key column name
|
|
454
|
+
*/
|
|
455
|
+
public getForeignPivotKeyName(): string {
|
|
456
|
+
return this.foreignPivotKey;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get the related pivot key name.
|
|
461
|
+
*
|
|
462
|
+
* @returns The related pivot key column name
|
|
463
|
+
*/
|
|
464
|
+
public getRelatedPivotKeyName(): string {
|
|
465
|
+
return this.relatedPivotKey;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get the fully qualified foreign pivot key name.
|
|
470
|
+
*
|
|
471
|
+
* @returns The qualified column name
|
|
472
|
+
*/
|
|
473
|
+
public getQualifiedForeignPivotKeyName(): string {
|
|
474
|
+
return `${this.table}.${this.foreignPivotKey}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Get the fully qualified related pivot key name.
|
|
479
|
+
*
|
|
480
|
+
* @returns The qualified column name
|
|
481
|
+
*/
|
|
482
|
+
public getQualifiedRelatedPivotKeyName(): string {
|
|
483
|
+
return `${this.table}.${this.relatedPivotKey}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ==========================================
|
|
487
|
+
// Eager Loading Methods
|
|
488
|
+
// ==========================================
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Initialize the relation for eager loading.
|
|
492
|
+
* Creates a fresh query with the pivot join but without parent-specific constraints.
|
|
493
|
+
*/
|
|
494
|
+
public override initializeForEagerLoading(): void {
|
|
495
|
+
this.query = this.newQuery();
|
|
496
|
+
const relatedTable = this.related.getTable();
|
|
497
|
+
|
|
498
|
+
// Explicitly select all columns from related table to preserve them when adding rawSelect
|
|
499
|
+
this.query.select(`${relatedTable}.*`);
|
|
500
|
+
|
|
501
|
+
// Add the join to the pivot table (required for BelongsToMany)
|
|
502
|
+
this.query.join(
|
|
503
|
+
this.table,
|
|
504
|
+
`${relatedTable}.${this.relatedKey}`,
|
|
505
|
+
'=',
|
|
506
|
+
`${this.table}.${this.relatedPivotKey}`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Add constraints for eager loading.
|
|
512
|
+
* Uses WHERE IN on the pivot table to fetch all related models for multiple parents at once.
|
|
513
|
+
*
|
|
514
|
+
* @param models - Array of parent models to load relations for
|
|
515
|
+
*/
|
|
516
|
+
public addEagerConstraints(models: Model[]): void {
|
|
517
|
+
// Collect all parent key values
|
|
518
|
+
const keys: unknown[] = [];
|
|
519
|
+
for (const model of models) {
|
|
520
|
+
const key = model.getAttribute(this.parentKey);
|
|
521
|
+
if (key !== undefined && key !== null) {
|
|
522
|
+
keys.push(key);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Remove duplicates
|
|
527
|
+
const uniqueKeys = [...new Set(keys)];
|
|
528
|
+
|
|
529
|
+
// Add WHERE IN constraint on pivot table's foreign key
|
|
530
|
+
if (uniqueKeys.length > 0) {
|
|
531
|
+
this.query.whereIn(
|
|
532
|
+
`${this.table}.${this.foreignPivotKey}`,
|
|
533
|
+
uniqueKeys as (string | number | boolean)[]
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Also select the foreign pivot key to enable matching (use selectRaw for proper alias)
|
|
538
|
+
this.query.selectRaw(`${this.table}.${this.foreignPivotKey} as pivot_foreign_key`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Match the eagerly loaded results to their parent models.
|
|
543
|
+
* For BelongsToMany, each parent gets an array of related models.
|
|
544
|
+
*
|
|
545
|
+
* @param models - Array of parent models
|
|
546
|
+
* @param results - Array of related models that were loaded
|
|
547
|
+
* @param relation - The name of the relationship being matched
|
|
548
|
+
*/
|
|
549
|
+
public match(models: Model[], results: TRelated[], relation: string): void {
|
|
550
|
+
// Build a dictionary for O(n) grouping: parentKey value -> array of related models
|
|
551
|
+
// Use String conversion for consistent key comparison across types
|
|
552
|
+
const dictionary = new Map<string, TRelated[]>();
|
|
553
|
+
for (const result of results) {
|
|
554
|
+
// Get the foreign pivot key from the result (added as pivot_foreign_key)
|
|
555
|
+
const pivotForeignKeyValue = String(result.getAttribute('pivot_foreign_key'));
|
|
556
|
+
if (!dictionary.has(pivotForeignKeyValue)) {
|
|
557
|
+
dictionary.set(pivotForeignKeyValue, []);
|
|
558
|
+
}
|
|
559
|
+
dictionary.get(pivotForeignKeyValue)!.push(result);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Match each parent model with its array of related models
|
|
563
|
+
for (const model of models) {
|
|
564
|
+
const parentKeyValue = String(model.getAttribute(this.parentKey));
|
|
565
|
+
const matches = dictionary.get(parentKeyValue) || [];
|
|
566
|
+
model.setRelation(relation, matches);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Builder as QueryBuilder } from '../query/Builder';
|
|
2
|
+
import { Model } from './Model';
|
|
3
|
+
import { Scope } from './Scope';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Eloquent Builder
|
|
7
|
+
*
|
|
8
|
+
* Extends the basic Query Builder to add ORM-specific features
|
|
9
|
+
* like Scopes, Model hydration, and Soft Deletes support.
|
|
10
|
+
*/
|
|
11
|
+
export class Builder<T extends Model = Model> extends QueryBuilder {
|
|
12
|
+
/**
|
|
13
|
+
* The model being queried.
|
|
14
|
+
*/
|
|
15
|
+
protected model!: T;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The applied global scopes.
|
|
19
|
+
*/
|
|
20
|
+
protected scopes: Map<string, Scope> = new Map();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The scopes that should be removed.
|
|
24
|
+
*/
|
|
25
|
+
protected removedScopes: string[] = [];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a new Eloquent Builder instance.
|
|
29
|
+
*/
|
|
30
|
+
constructor(query: QueryBuilder) {
|
|
31
|
+
super(query['connection'], query['grammar'], query['query'].table);
|
|
32
|
+
// Copy query state
|
|
33
|
+
this.query = query['query'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set the model instance for the builder.
|
|
38
|
+
*/
|
|
39
|
+
public setModel(model: T): this {
|
|
40
|
+
this.model = model;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the model instance.
|
|
46
|
+
*/
|
|
47
|
+
public getModel(): T {
|
|
48
|
+
return this.model;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register a new global scope.
|
|
53
|
+
*/
|
|
54
|
+
public withGlobalScope(identifier: string, scope: Scope): this {
|
|
55
|
+
this.scopes.set(identifier, scope);
|
|
56
|
+
if (scope.extend) {
|
|
57
|
+
scope.extend(this);
|
|
58
|
+
}
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register a custom macro.
|
|
64
|
+
*/
|
|
65
|
+
public macro(name: string, callback: Function): void {
|
|
66
|
+
(this as any)[name] = callback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove a registered global scope.
|
|
71
|
+
*/
|
|
72
|
+
public withoutGlobalScope(scope: Scope | string | Function): this {
|
|
73
|
+
if (typeof scope === 'string') {
|
|
74
|
+
this.removedScopes.push(scope);
|
|
75
|
+
this.scopes.delete(scope);
|
|
76
|
+
} else if (typeof scope === 'function') {
|
|
77
|
+
// Find by constructor
|
|
78
|
+
for (const [key, s] of this.scopes.entries()) {
|
|
79
|
+
if (s.constructor === scope) {
|
|
80
|
+
this.removedScopes.push(key);
|
|
81
|
+
this.scopes.delete(key);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// Find by instance
|
|
87
|
+
for (const [key, s] of this.scopes.entries()) {
|
|
88
|
+
if (s.constructor.name === scope.constructor.name) {
|
|
89
|
+
this.removedScopes.push(key);
|
|
90
|
+
this.scopes.delete(key);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove all or passed global scopes.
|
|
101
|
+
*/
|
|
102
|
+
public withoutGlobalScopes(scopes: string[] | null = null): this {
|
|
103
|
+
if (!scopes) {
|
|
104
|
+
this.scopes.clear();
|
|
105
|
+
} else {
|
|
106
|
+
for (const scope of scopes) {
|
|
107
|
+
this.withoutGlobalScope(scope);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Apply the scopes to the query builder instance.
|
|
115
|
+
*/
|
|
116
|
+
public applyScopes(): this {
|
|
117
|
+
if (this.scopes.size === 0) {
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const [identifier, scope] of this.scopes.entries()) {
|
|
122
|
+
if (!this.removedScopes.includes(identifier)) {
|
|
123
|
+
scope.apply(this, this.model);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* execute the query as a "select" statement.
|
|
132
|
+
* Overridden to hydrate models.
|
|
133
|
+
*/
|
|
134
|
+
public async get<R = any>(): Promise<R[]> {
|
|
135
|
+
const builder = this.applyScopes();
|
|
136
|
+
|
|
137
|
+
const results = await super.get();
|
|
138
|
+
|
|
139
|
+
if (results.length > 0) {
|
|
140
|
+
return this.model['hydrateModels'](results) as unknown as R[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return results as R[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Execute the query and return the first result.
|
|
148
|
+
* Overridden to hydrate model.
|
|
149
|
+
*/
|
|
150
|
+
public async first<R = any>(): Promise<R | null> {
|
|
151
|
+
const builder = this.applyScopes();
|
|
152
|
+
const result = await super.first();
|
|
153
|
+
|
|
154
|
+
if (result) {
|
|
155
|
+
return this.model['hydrateModel'](result) as unknown as R;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|