outlet-orm 6.5.0 → 7.0.0

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.
@@ -0,0 +1,555 @@
1
+ # Outlet ORM - Relations & Eager Loading
2
+
3
+ [← Back to Index](SKILL.md) | [Previous: Queries](QUERIES.md) | [Next: Migrations →](MIGRATIONS.md)
4
+
5
+ > 📘 **TypeScript**: Use typed relationships like`HasOneRelation<Profile>`,`HasManyRelation<Post>`. See [TYPESCRIPT.md](TYPESCRIPT.md#relationships-typedes)
6
+
7
+ ---
8
+
9
+ ## Naming Conventions
10
+
11
+ ### Tables
12
+ - Singular or plural:`user`or`users`
13
+ - Pivot tables: alphabetical order`role_user`(not`user_role`)
14
+
15
+ ### Foreign Keys
16
+ - Format:`{model}_id`(e.g.,`user_id`,`post_id`)
17
+
18
+ ### Polymorphic Columns
19
+ - Type:`{name}_type`(e.g.,`commentable_type`)
20
+ - ID:`{name}_id`(e.g.,`commentable_id`)
21
+
22
+ ### Relation Methods
23
+
24
+ | Type | Naming | Example |
25
+ |------|--------|---------|
26
+ |`belongsTo`| singular |`user()`,`category()`|
27
+ |`hasOne`| singular |`profile()`|
28
+ |`hasMany`| plural |`posts()`,`comments()`|
29
+ |`belongsToMany`| plural |`tags()`,`roles()`|
30
+
31
+
32
+ ---
33
+
34
+ ## Relation Types Overview
35
+
36
+ | Relation | Description | Example |
37
+ |----------|-------------|---------|
38
+ |`hasOne`| One-to-One | User → Profile |
39
+ |`hasMany`| One-to-Many | User → Posts |
40
+ |`belongsTo`| Inverse of hasOne/hasMany | Post → User |
41
+ |`belongsToMany`| Many-to-Many | User ↔ Roles |
42
+ |`hasManyThrough`| One-to-Many via intermediate | Country → Posts via Users |
43
+ |`hasOneThrough`| One-to-One via intermediate | Supplier → UserHistory via User |
44
+ |`morphOne`| Polymorphic One-to-One | Post → Image |
45
+ |`morphMany`| Polymorphic One-to-Many | Post → Comments |
46
+ |`morphTo`| Polymorphic inverse | Comment → (Post\|Video) |
47
+
48
+ ---
49
+
50
+ ## Has One (One-to-One)
51
+
52
+ A user has one profile.
53
+
54
+ ```javascript
55
+ const { Model } = require('outlet-orm');
56
+
57
+ class Profile extends Model {
58
+ static table = 'profiles';
59
+
60
+ user() {
61
+ return this.belongsTo(User, 'user_id');
62
+ }
63
+ }
64
+
65
+ class User extends Model {
66
+ static table = 'users';
67
+
68
+ profile() {
69
+ return this.hasOne(Profile, 'user_id');
70
+ }
71
+ }
72
+
73
+ // Usage
74
+ const user = await User.find(1);
75
+ const profile = await user.profile().get();
76
+
77
+ // With eager loading
78
+ const user = await User.with('profile').find(1);
79
+ console.log(user.relationships.profile);
80
+ ```
81
+
82
+ **Parameters:**
83
+ -`hasOne(RelatedModel, foreignKey, localKey)`
84
+ -`foreignKey`: default =`{model}_id`
85
+ -`localKey`: default =`id`
86
+
87
+ ---
88
+
89
+ ## Has Many (One-to-Many)
90
+
91
+ A user has many posts.
92
+
93
+ ```javascript
94
+ class Post extends Model {
95
+ static table = 'posts';
96
+
97
+ author() {
98
+ return this.belongsTo(User, 'user_id');
99
+ }
100
+ }
101
+
102
+ class User extends Model {
103
+ static table = 'users';
104
+
105
+ posts() {
106
+ return this.hasMany(Post, 'user_id');
107
+ }
108
+ }
109
+
110
+ // Usage
111
+ const user = await User.find(1);
112
+ const posts = await user.posts().get();
113
+
114
+ // With eager loading
115
+ const user = await User.with('posts').find(1);
116
+ console.log(user.relationships.posts); // Array of posts
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Belongs To (Inverse)
122
+
123
+ A post belongs to a user.
124
+
125
+ ```javascript
126
+ class User extends Model {
127
+ static table = 'users';
128
+ }
129
+
130
+ class Post extends Model {
131
+ static table = 'posts';
132
+
133
+ author() {
134
+ return this.belongsTo(User, 'user_id');
135
+ }
136
+ }
137
+
138
+ // Usage
139
+ const post = await Post.find(1);
140
+ const author = await post.author().get();
141
+
142
+ // With eager loading
143
+ const post = await Post.with('author').find(1);
144
+ console.log(post.relationships.author);
145
+ ```
146
+
147
+ **Parameters:**
148
+ -`belongsTo(RelatedModel, foreignKey, ownerKey)`
149
+ -`foreignKey`: FK on current model
150
+ -`ownerKey`: default =`id`
151
+
152
+ ---
153
+
154
+ ## Belongs To Many (Many-to-Many)
155
+
156
+ Users and roles with pivot table.
157
+
158
+ ```sql
159
+ -- Tables
160
+ users (id, name, email)
161
+ roles (id, name)
162
+ role_user (user_id, role_id) -- Pivot table
163
+ ```
164
+
165
+ ```javascript
166
+ class Role extends Model {
167
+ static table = 'roles';
168
+
169
+ users() {
170
+ return this.belongsToMany(User, 'role_user', 'role_id', 'user_id');
171
+ }
172
+ }
173
+
174
+ class User extends Model {
175
+ static table = 'users';
176
+
177
+ roles() {
178
+ return this.belongsToMany(
179
+ Role,
180
+ 'role_user', // Pivot table
181
+ 'user_id', // FK to User
182
+ 'role_id' // FK to Role
183
+ );
184
+ }
185
+ }
186
+
187
+ // Usage
188
+ const user = await User.find(1);
189
+ const roles = await user.roles().get();
190
+
191
+ // Pivot methods
192
+ await user.roles().attach([1, 2]); // Attach roles
193
+ await user.roles().attach(3); // Attach single
194
+ await user.roles().detach(2); // Detach role
195
+ await user.roles().detach(); // Detach all
196
+ await user.roles().sync([1, 3, 4]); // Sync (replace all)
197
+
198
+ // Access pivot data
199
+ const roles = await user.roles().get();
200
+ roles.forEach(role => {
201
+ console.log(role.pivot); // { user_id: 1, role_id: 2 }
202
+ });
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Has Many Through
208
+
209
+ Access remote relationships via intermediate model.
210
+
211
+ ```sql
212
+ -- Country -> User -> Post
213
+ countries (id, name)
214
+ users (id, country_id, name)
215
+ posts (id, user_id, title)
216
+ ```
217
+
218
+ ```javascript
219
+ class Country extends Model {
220
+ static table = 'countries';
221
+
222
+ posts() {
223
+ return this.hasManyThrough(
224
+ Post, // Final model
225
+ User, // Intermediate model
226
+ 'country_id', // FK on User
227
+ 'user_id', // FK on Post
228
+ 'id', // Local key on Country
229
+ 'id' // Local key on User
230
+ );
231
+ }
232
+ }
233
+
234
+ // Get all posts from French users
235
+ const france = await Country.with('posts').where('name', 'France').first();
236
+ console.log(france.relationships.posts);
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Has One Through
242
+
243
+ One-to-one via intermediate.
244
+
245
+ ```javascript
246
+ class Mechanic extends Model {
247
+ static table = 'mechanics';
248
+
249
+ carOwner() {
250
+ return this.hasOneThrough(
251
+ Owner,
252
+ Car,
253
+ 'mechanic_id',
254
+ 'car_id',
255
+ 'id',
256
+ 'id'
257
+ );
258
+ }
259
+ }
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Polymorphic Relations
265
+
266
+ ### Morph One (Polymorphic One-to-One)
267
+
268
+ An image can belong to a User or a Post.
269
+
270
+ ```sql
271
+ images (id, url, imageable_type, imageable_id)
272
+ -- imageable_type: 'User' or 'Post'
273
+ -- imageable_id: corresponding ID
274
+ ```
275
+
276
+ ```javascript
277
+ // Configure morph map
278
+ Model.setMorphMap({
279
+ 'User': User,
280
+ 'Post': Post
281
+ });
282
+
283
+ class User extends Model {
284
+ static table = 'users';
285
+
286
+ image() {
287
+ return this.morphOne(Image, 'imageable');
288
+ }
289
+ }
290
+
291
+ class Post extends Model {
292
+ static table = 'posts';
293
+
294
+ image() {
295
+ return this.morphOne(Image, 'imageable');
296
+ }
297
+ }
298
+
299
+ class Image extends Model {
300
+ static table = 'images';
301
+
302
+ imageable() {
303
+ return this.morphTo('imageable');
304
+ }
305
+ }
306
+
307
+ // Usage
308
+ const user = await User.with('image').find(1);
309
+ console.log(user.relationships.image);
310
+
311
+ const image = await Image.with('imageable').find(1);
312
+ console.log(image.relationships.imageable); // User or Post
313
+ ```
314
+
315
+ ### Morph Many (Polymorphic One-to-Many)
316
+
317
+ Comments on Posts and Videos.
318
+
319
+ ```javascript
320
+ Model.setMorphMap({
321
+ 'posts': Post,
322
+ 'videos': Video
323
+ });
324
+
325
+ class Post extends Model {
326
+ static table = 'posts';
327
+
328
+ comments() {
329
+ return this.morphMany(Comment, 'commentable');
330
+ }
331
+ }
332
+
333
+ class Video extends Model {
334
+ static table = 'videos';
335
+
336
+ comments() {
337
+ return this.morphMany(Comment, 'commentable');
338
+ }
339
+ }
340
+
341
+ class Comment extends Model {
342
+ static table = 'comments';
343
+
344
+ commentable() {
345
+ return this.morphTo('commentable');
346
+ }
347
+ }
348
+
349
+ // Usage
350
+ const post = await Post.with('comments').find(1);
351
+ console.log(post.relationships.comments);
352
+
353
+ const comment = await Comment.with('commentable').find(1);
354
+ console.log(comment.relationships.commentable); // Post or Video
355
+ ```
356
+
357
+ ---
358
+
359
+ ## Eager Loading
360
+
361
+ ### Basic Eager Loading
362
+
363
+ ```javascript
364
+ // Single relation
365
+ const users = await User.with('posts').get();
366
+
367
+ // Multiple relationships
368
+ const users = await User.with('posts', 'profile', 'roles').get();
369
+
370
+ // Access loaded relationships
371
+ users.forEach(user => {
372
+ console.log(user.relationships.posts);
373
+ console.log(user.relationships.profile);
374
+ console.log(user.relationships.roles);
375
+ });
376
+ ```
377
+
378
+ ### Nested Relations (Dot Notation)
379
+
380
+ ```javascript
381
+ // Load nested relationships
382
+ const users = await User.with('posts.comments.author').get();
383
+
384
+ // Combined
385
+ const users = await User.with('profile', 'posts.comments').get();
386
+ ```
387
+
388
+ ### Eager Loading with Constraints
389
+
390
+ ```javascript
391
+ const users = await User.with({
392
+ posts: (query) => query
393
+ .where('status', 'published')
394
+ .orderBy('created_at', 'desc')
395
+ .limit(5)
396
+ }).get();
397
+ ```
398
+
399
+ ### Load on Existing Instance
400
+
401
+ ```javascript
402
+ const user = await User.find(1);
403
+
404
+ // Load single relation
405
+ await user.load('posts');
406
+
407
+ // Load multiple
408
+ await user.load('posts', 'profile');
409
+ await user.load(['roles', 'posts.comments']);
410
+
411
+ // Access
412
+ console.log(user.relationships.posts);
413
+ ```
414
+
415
+ ---
416
+
417
+ ## Relational Filters
418
+
419
+ ### whereHas (Filter by Relation)
420
+
421
+ Get users that have at least one published post:
422
+
423
+ ```javascript
424
+ const authors = await User
425
+ .whereHas('posts', (query) => {
426
+ query.where('status', 'published');
427
+ })
428
+ .get();
429
+ ```
430
+
431
+ ### has (Relation Count Filter)
432
+
433
+ ```javascript
434
+ // Users with at least 1 post
435
+ const withPosts = await User.has('posts').get();
436
+
437
+ // Users with at least 10 posts
438
+ const prolific = await User.has('posts', '>=', 10).get();
439
+
440
+ // Users with exactly 5 posts
441
+ const exact = await User.has('posts', '=', 5).get();
442
+ ```
443
+
444
+ ### whereDoesntHave (No Relation)
445
+
446
+ ```javascript
447
+ // Users without any posts
448
+ const noPosts = await User.whereDoesntHave('posts').get();
449
+
450
+ // Users without published posts
451
+ const noPublished = await User
452
+ .whereDoesntHave('posts', (q) => q.where('status', 'published'))
453
+ .get();
454
+ ```
455
+
456
+ ### withCount (Relation Count)
457
+
458
+ ```javascript
459
+ const users = await User.withCount('posts').get();
460
+
461
+ users.forEach(user => {
462
+ console.log(user.getAttribute('posts_count'));
463
+ });
464
+ ```
465
+
466
+ ---
467
+
468
+ ## Automatic Relations Detection
469
+
470
+ The`outlet-convert`CLI automatically detects relationships from your SQL schema.
471
+
472
+ ### Detection Rules
473
+
474
+ | Pattern | Detected Relation |
475
+ |---------|-------------------|
476
+ | Column`*_id`with FK |`belongsTo()`|
477
+ | FK referencing this table (non-unique) |`hasMany()`|
478
+ | FK referencing this table (UNIQUE) |`hasOne()`|
479
+ | Pivot table (2 FKs only) |`belongsToMany()`|
480
+ | Self-referencing FK | Recursive relation |
481
+
482
+ ### Example: Auto-Generated
483
+
484
+ **SQL:**
485
+ ```sql
486
+ CREATE TABLE profiles (
487
+ id INT PRIMARY KEY,
488
+ user_id INT UNIQUE, -- UNIQUE = hasOne
489
+ FOREIGN KEY (user_id) REFERENCES users(id)
490
+ );
491
+ ```
492
+
493
+ **Generated User.js:**
494
+ ```javascript
495
+ class User extends Model {
496
+ profile() {
497
+ return this.hasOne(Profile, 'user_id'); // Detected from UNIQUE
498
+ }
499
+ }
500
+ ```
501
+
502
+ ### Recursive Relations
503
+
504
+ ```sql
505
+ CREATE TABLE categories (
506
+ id INT PRIMARY KEY,
507
+ parent_id INT,
508
+ FOREIGN KEY (parent_id) REFERENCES categories(id)
509
+ );
510
+ ```
511
+
512
+ **Auto-Generated:**
513
+ ```javascript
514
+ class Category extends Model {
515
+ parent() {
516
+ return this.belongsTo(Category, 'parent_id');
517
+ }
518
+
519
+ children() {
520
+ return this.hasMany(Category, 'parent_id');
521
+ }
522
+ }
523
+ ```
524
+
525
+ ---
526
+
527
+ ## Relations Methods Summary
528
+
529
+ | Method | Description |
530
+ |--------|-------------|
531
+ |`hasOne(Model, fk, lk)`| One-to-One |
532
+ |`hasMany(Model, fk, lk)`| One-to-Many |
533
+ |`belongsTo(Model, fk, ok)`| Inverse relation |
534
+ |`belongsToMany(Model, pivot, fk, rk)`| Many-to-Many |
535
+ |`hasManyThrough(Model, Through, fk1, fk2)`| Via intermediate |
536
+ |`hasOneThrough(Model, Through, fk1, fk2)`| One via intermediate |
537
+ |`morphOne(Model, name)`| Polymorphic One-to-One |
538
+ |`morphMany(Model, name)`| Polymorphic One-to-Many |
539
+ |`morphTo(name)`| Polymorphic inverse |
540
+ |`with(...relationships)`| Eager load |
541
+ |`load(...relationships)`| Load on instance |
542
+ |`whereHas(rel, cb)`| Filter by relation |
543
+ |`has(rel, op, count)`| Relation count filter |
544
+ |`whereDoesntHave(rel)`| Filter by no relation |
545
+ |`withCount(rel)`| Add relation count |
546
+ |`attach(ids)`| Attach (many-to-many) |
547
+ |`detach(ids?)`| Detach (many-to-many) |
548
+ |`sync(ids)`| Sync (many-to-many) |
549
+
550
+ ---
551
+
552
+ ## Next Steps
553
+
554
+ - [Migrations & Schema Builder →](MIGRATIONS.md)
555
+ - [Advanced Features →](ADVANCED.md)