@famgia/omnify 1.0.10 → 1.0.11

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/README.md CHANGED
@@ -217,7 +217,7 @@ properties:
217
217
 
218
218
  ### Associations (Relationships)
219
219
 
220
- Define relationships between schemas. See [full documentation](https://github.com/ecsol/omnify-ts/blob/main/packages/omnify/docs/associations.md) for details.
220
+ Define relationships between schemas. See [full documentation](https://cdn.jsdelivr.net/npm/@famgia/omnify/docs/associations.md) for details.
221
221
 
222
222
  ```yaml
223
223
  # schemas/post.yaml
@@ -0,0 +1,760 @@
1
+ # Associations (Relationships)
2
+
3
+ Omnify supports full relational database associations with bidirectional mappings, cascading operations, and automatic foreign key generation.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Relationship Types](#relationship-types)
9
+ - [ManyToOne (BelongsTo)](#manytoone-belongsto)
10
+ - [OneToMany (HasMany)](#onetomany-hasmany)
11
+ - [OneToOne](#onetoone)
12
+ - [ManyToMany (BelongsToMany)](#manytomany-belongstomany)
13
+ - [Association Properties](#association-properties)
14
+ - [Bidirectional Relationships](#bidirectional-relationships)
15
+ - [Cascading Operations](#cascading-operations)
16
+ - [Self-Referencing Associations](#self-referencing-associations)
17
+ - [Polymorphic Associations](#polymorphic-associations)
18
+ - [Pivot Tables](#pivot-tables)
19
+ - [Generated Output](#generated-output)
20
+ - [Best Practices](#best-practices)
21
+ - [Common Patterns](#common-patterns)
22
+
23
+ ---
24
+
25
+ ## Overview
26
+
27
+ Associations are defined within the `properties` section using `type: Association`:
28
+
29
+ ```yaml
30
+ properties:
31
+ author:
32
+ type: Association
33
+ relation: ManyToOne
34
+ target: User
35
+ ```
36
+
37
+ This generates:
38
+ - **Laravel Migration**: Foreign key column (`author_id`) with constraint
39
+ - **TypeScript**: Type with foreign key field
40
+ - **Validation**: Checks that target schema exists
41
+
42
+ ---
43
+
44
+ ## Relationship Types
45
+
46
+ ### ManyToOne (BelongsTo)
47
+
48
+ The most common relationship. Creates a foreign key column on this table.
49
+
50
+ ```yaml
51
+ # schemas/Post.yaml
52
+ name: Post
53
+ properties:
54
+ title:
55
+ type: String
56
+
57
+ # A post belongs to one author
58
+ author:
59
+ type: Association
60
+ relation: ManyToOne
61
+ target: User
62
+ ```
63
+
64
+ **Generated Migration:**
65
+ ```php
66
+ Schema::create('posts', function (Blueprint $table) {
67
+ $table->id();
68
+ $table->string('title');
69
+ $table->unsignedBigInteger('author_id');
70
+
71
+ $table->foreign('author_id')
72
+ ->references('id')
73
+ ->on('users');
74
+ });
75
+ ```
76
+
77
+ **Generated TypeScript:**
78
+ ```typescript
79
+ interface Post {
80
+ id: number;
81
+ title: string;
82
+ author_id: number;
83
+ }
84
+ ```
85
+
86
+ ### OneToMany (HasMany)
87
+
88
+ Inverse side of ManyToOne. Does NOT create a column (the FK is on the other table).
89
+
90
+ ```yaml
91
+ # schemas/User.yaml
92
+ name: User
93
+ properties:
94
+ name:
95
+ type: String
96
+
97
+ # A user has many posts
98
+ posts:
99
+ type: Association
100
+ relation: OneToMany
101
+ target: Post
102
+ inversedBy: author # Links to Post.author
103
+ ```
104
+
105
+ **Generated Migration:** No column added (FK is on `posts` table)
106
+
107
+ **Generated TypeScript:**
108
+ ```typescript
109
+ interface User {
110
+ id: number;
111
+ name: string;
112
+ // posts relationship accessed via ORM, not as direct field
113
+ }
114
+ ```
115
+
116
+ ### OneToOne
117
+
118
+ Creates a unique foreign key. Use `owningSide: true` on the table that should have the FK column.
119
+
120
+ ```yaml
121
+ # schemas/User.yaml
122
+ name: User
123
+ properties:
124
+ name:
125
+ type: String
126
+
127
+ profile:
128
+ type: Association
129
+ relation: OneToOne
130
+ target: Profile
131
+ owningSide: true # User table has profile_id
132
+ ```
133
+
134
+ ```yaml
135
+ # schemas/Profile.yaml
136
+ name: Profile
137
+ properties:
138
+ bio:
139
+ type: Text
140
+
141
+ user:
142
+ type: Association
143
+ relation: OneToOne
144
+ target: User
145
+ mappedBy: profile # Inverse side
146
+ ```
147
+
148
+ **Generated Migration (users table):**
149
+ ```php
150
+ $table->unsignedBigInteger('profile_id')->unique();
151
+ $table->foreign('profile_id')->references('id')->on('profiles');
152
+ ```
153
+
154
+ ### ManyToMany (BelongsToMany)
155
+
156
+ Creates a pivot table. Define on both sides with `inversedBy`.
157
+
158
+ ```yaml
159
+ # schemas/Post.yaml
160
+ name: Post
161
+ properties:
162
+ title:
163
+ type: String
164
+
165
+ tags:
166
+ type: Association
167
+ relation: ManyToMany
168
+ target: Tag
169
+ inversedBy: posts
170
+ ```
171
+
172
+ ```yaml
173
+ # schemas/Tag.yaml
174
+ name: Tag
175
+ properties:
176
+ name:
177
+ type: String
178
+
179
+ posts:
180
+ type: Association
181
+ relation: ManyToMany
182
+ target: Post
183
+ inversedBy: tags
184
+ ```
185
+
186
+ **Generated Pivot Migration:**
187
+ ```php
188
+ Schema::create('post_tag', function (Blueprint $table) {
189
+ $table->unsignedBigInteger('post_id');
190
+ $table->unsignedBigInteger('tag_id');
191
+
192
+ $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
193
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
194
+
195
+ $table->primary(['post_id', 'tag_id']);
196
+ });
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Association Properties
202
+
203
+ | Property | Type | Required | Description |
204
+ |----------|------|----------|-------------|
205
+ | `type` | `'Association'` | Yes | Must be `'Association'` |
206
+ | `relation` | `string` | Yes | `OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany` |
207
+ | `target` | `string` | Yes | Target schema name (PascalCase) |
208
+ | `inversedBy` | `string` | No | Property name on target that maps back (owner defines) |
209
+ | `mappedBy` | `string` | No | Property name on target that owns the relationship |
210
+ | `onDelete` | `string` | No | `CASCADE`, `SET NULL`, `RESTRICT`, `NO ACTION` |
211
+ | `onUpdate` | `string` | No | `CASCADE`, `SET NULL`, `RESTRICT`, `NO ACTION` |
212
+ | `owningSide` | `boolean` | No | For OneToOne, marks which side has the FK |
213
+ | `nullable` | `boolean` | No | Allow NULL for optional relationships |
214
+ | `joinTable` | `string` | No | Custom pivot table name for ManyToMany |
215
+
216
+ ---
217
+
218
+ ## Bidirectional Relationships
219
+
220
+ For bidirectional relationships, use `inversedBy` on the owning side and `mappedBy` on the inverse side:
221
+
222
+ ```yaml
223
+ # Post (owning side - has the FK)
224
+ author:
225
+ type: Association
226
+ relation: ManyToOne
227
+ target: User
228
+ inversedBy: posts # "User.posts points back to me"
229
+
230
+ # User (inverse side)
231
+ posts:
232
+ type: Association
233
+ relation: OneToMany
234
+ target: Post
235
+ mappedBy: author # "Post.author is the owning side"
236
+ ```
237
+
238
+ **Rules:**
239
+ - `ManyToOne` is always the owning side (has FK column)
240
+ - `OneToMany` is always the inverse side (no FK column)
241
+ - For `OneToOne`, specify `owningSide: true` on one side
242
+ - For `ManyToMany`, either side can have `inversedBy`
243
+
244
+ ---
245
+
246
+ ## Cascading Operations
247
+
248
+ Control what happens when referenced records are deleted or updated:
249
+
250
+ ### onDelete Options
251
+
252
+ ```yaml
253
+ author:
254
+ type: Association
255
+ relation: ManyToOne
256
+ target: User
257
+ onDelete: CASCADE # Delete post when user is deleted
258
+ ```
259
+
260
+ | Value | Behavior |
261
+ |-------|----------|
262
+ | `CASCADE` | Delete related records automatically |
263
+ | `SET NULL` | Set FK to NULL (requires `nullable: true`) |
264
+ | `RESTRICT` | Prevent deletion if references exist |
265
+ | `NO ACTION` | Database default (usually same as RESTRICT) |
266
+
267
+ ### onUpdate Options
268
+
269
+ ```yaml
270
+ author:
271
+ type: Association
272
+ relation: ManyToOne
273
+ target: User
274
+ onUpdate: CASCADE # Update FK when user ID changes
275
+ ```
276
+
277
+ ### Common Patterns
278
+
279
+ ```yaml
280
+ # Required relationship - prevent orphaned records
281
+ author:
282
+ type: Association
283
+ relation: ManyToOne
284
+ target: User
285
+ onDelete: RESTRICT
286
+
287
+ # Optional relationship - allow NULL
288
+ reviewer:
289
+ type: Association
290
+ relation: ManyToOne
291
+ target: User
292
+ nullable: true
293
+ onDelete: SET NULL
294
+
295
+ # Cascade delete - clean up related data
296
+ comments:
297
+ type: Association
298
+ relation: OneToMany
299
+ target: Comment
300
+ inversedBy: post
301
+ # Note: onDelete is set on the owning side (Comment.post)
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Self-Referencing Associations
307
+
308
+ Schemas can reference themselves for hierarchical data:
309
+
310
+ ```yaml
311
+ # schemas/Category.yaml
312
+ name: Category
313
+ properties:
314
+ name:
315
+ type: String
316
+
317
+ # Parent category (nullable for root categories)
318
+ parent:
319
+ type: Association
320
+ relation: ManyToOne
321
+ target: Category
322
+ nullable: true
323
+ onDelete: SET NULL
324
+
325
+ # Child categories
326
+ children:
327
+ type: Association
328
+ relation: OneToMany
329
+ target: Category
330
+ inversedBy: parent
331
+ ```
332
+
333
+ **Generated Migration:**
334
+ ```php
335
+ Schema::create('categories', function (Blueprint $table) {
336
+ $table->id();
337
+ $table->string('name');
338
+ $table->unsignedBigInteger('parent_id')->nullable();
339
+
340
+ $table->foreign('parent_id')
341
+ ->references('id')
342
+ ->on('categories')
343
+ ->onDelete('set null');
344
+ });
345
+ ```
346
+
347
+ ---
348
+
349
+ ## Polymorphic Associations
350
+
351
+ For polymorphic relationships (one field pointing to multiple tables):
352
+
353
+ ```yaml
354
+ # schemas/Comment.yaml
355
+ name: Comment
356
+ properties:
357
+ body:
358
+ type: Text
359
+
360
+ # Can comment on Posts, Videos, or Photos
361
+ commentable:
362
+ type: Polymorphic
363
+ targets:
364
+ - Post
365
+ - Video
366
+ - Photo
367
+ ```
368
+
369
+ **Generated Migration:**
370
+ ```php
371
+ $table->string('commentable_type');
372
+ $table->unsignedBigInteger('commentable_id');
373
+ $table->index(['commentable_type', 'commentable_id']);
374
+ ```
375
+
376
+ ---
377
+
378
+ ## Pivot Tables
379
+
380
+ ### Auto-generated Pivot Tables
381
+
382
+ ManyToMany relationships automatically generate pivot tables:
383
+
384
+ ```yaml
385
+ # Post has many Tags
386
+ tags:
387
+ type: Association
388
+ relation: ManyToMany
389
+ target: Tag
390
+ ```
391
+
392
+ Default pivot table name: `post_tag` (alphabetical order, snake_case)
393
+
394
+ ### Custom Pivot Table Name
395
+
396
+ ```yaml
397
+ tags:
398
+ type: Association
399
+ relation: ManyToMany
400
+ target: Tag
401
+ joinTable: article_tags # Custom name
402
+ ```
403
+
404
+ ### Pivot Table with Extra Columns
405
+
406
+ Create a dedicated schema with `id: false`:
407
+
408
+ ```yaml
409
+ # schemas/PostTag.yaml
410
+ name: PostTag
411
+ options:
412
+ id: false # No auto-increment ID
413
+ timestamps: true # Add created_at, updated_at
414
+
415
+ properties:
416
+ post:
417
+ type: Association
418
+ relation: ManyToOne
419
+ target: Post
420
+ onDelete: CASCADE
421
+
422
+ tag:
423
+ type: Association
424
+ relation: ManyToOne
425
+ target: Tag
426
+ onDelete: CASCADE
427
+
428
+ order:
429
+ type: Int
430
+ default: 0
431
+
432
+ featured:
433
+ type: Boolean
434
+ default: false
435
+ ```
436
+
437
+ **Generated Migration:**
438
+ ```php
439
+ Schema::create('post_tags', function (Blueprint $table) {
440
+ $table->unsignedBigInteger('post_id');
441
+ $table->unsignedBigInteger('tag_id');
442
+ $table->integer('order')->default(0);
443
+ $table->boolean('featured')->default(false);
444
+ $table->timestamps();
445
+
446
+ $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
447
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
448
+
449
+ $table->primary(['post_id', 'tag_id']);
450
+ });
451
+ ```
452
+
453
+ ---
454
+
455
+ ## Generated Output
456
+
457
+ ### Laravel Migration Example
458
+
459
+ ```yaml
460
+ # schemas/Comment.yaml
461
+ name: Comment
462
+ properties:
463
+ body:
464
+ type: Text
465
+
466
+ post:
467
+ type: Association
468
+ relation: ManyToOne
469
+ target: Post
470
+ onDelete: CASCADE
471
+
472
+ author:
473
+ type: Association
474
+ relation: ManyToOne
475
+ target: User
476
+ nullable: true
477
+ onDelete: SET NULL
478
+
479
+ options:
480
+ timestamps: true
481
+ ```
482
+
483
+ **Generated:**
484
+ ```php
485
+ Schema::create('comments', function (Blueprint $table) {
486
+ $table->id();
487
+ $table->text('body');
488
+ $table->unsignedBigInteger('post_id');
489
+ $table->unsignedBigInteger('author_id')->nullable();
490
+ $table->timestamps();
491
+
492
+ $table->foreign('post_id')
493
+ ->references('id')
494
+ ->on('posts')
495
+ ->onDelete('cascade');
496
+
497
+ $table->foreign('author_id')
498
+ ->references('id')
499
+ ->on('users')
500
+ ->onDelete('set null');
501
+ });
502
+ ```
503
+
504
+ ### TypeScript Types Example
505
+
506
+ ```typescript
507
+ interface Comment {
508
+ id: number;
509
+ body: string;
510
+ post_id: number;
511
+ author_id: number | null;
512
+ created_at: string;
513
+ updated_at: string;
514
+ }
515
+ ```
516
+
517
+ ---
518
+
519
+ ## Best Practices
520
+
521
+ ### 1. Always Define Both Sides
522
+
523
+ Define relationships on both schemas for clarity and tooling support:
524
+
525
+ ```yaml
526
+ # Post.yaml
527
+ author:
528
+ type: Association
529
+ relation: ManyToOne
530
+ target: User
531
+ inversedBy: posts
532
+
533
+ # User.yaml
534
+ posts:
535
+ type: Association
536
+ relation: OneToMany
537
+ target: Post
538
+ mappedBy: author
539
+ ```
540
+
541
+ ### 2. Use Appropriate Cascade Actions
542
+
543
+ | Scenario | onDelete | Why |
544
+ |----------|----------|-----|
545
+ | Comments on a Post | `CASCADE` | Delete comments when post is deleted |
546
+ | Posts by a User | `RESTRICT` | Prevent user deletion if they have posts |
547
+ | Optional reviewer | `SET NULL` | Keep record, just remove the reference |
548
+
549
+ ### 3. Name Associations Clearly
550
+
551
+ ```yaml
552
+ # Good - clear intent
553
+ author:
554
+ type: Association
555
+ relation: ManyToOne
556
+ target: User
557
+
558
+ createdBy:
559
+ type: Association
560
+ relation: ManyToOne
561
+ target: User
562
+
563
+ assignedTo:
564
+ type: Association
565
+ relation: ManyToOne
566
+ target: User
567
+ nullable: true
568
+
569
+ # Bad - ambiguous
570
+ user1:
571
+ type: Association
572
+ relation: ManyToOne
573
+ target: User
574
+ ```
575
+
576
+ ### 4. Consider Query Patterns
577
+
578
+ Place FK on the table you'll query most often:
579
+
580
+ ```yaml
581
+ # If you often query "posts by user", this is correct:
582
+ # Post.author -> User (FK on posts table)
583
+
584
+ # If you often query "user's current session", consider:
585
+ # User.currentSession -> Session (FK on users table)
586
+ ```
587
+
588
+ ---
589
+
590
+ ## Common Patterns
591
+
592
+ ### Blog System
593
+
594
+ ```yaml
595
+ # User.yaml
596
+ name: User
597
+ properties:
598
+ name:
599
+ type: String
600
+ posts:
601
+ type: Association
602
+ relation: OneToMany
603
+ target: Post
604
+ inversedBy: author
605
+ comments:
606
+ type: Association
607
+ relation: OneToMany
608
+ target: Comment
609
+ inversedBy: author
610
+
611
+ # Post.yaml
612
+ name: Post
613
+ properties:
614
+ title:
615
+ type: String
616
+ content:
617
+ type: Text
618
+ author:
619
+ type: Association
620
+ relation: ManyToOne
621
+ target: User
622
+ mappedBy: posts
623
+ onDelete: RESTRICT
624
+ comments:
625
+ type: Association
626
+ relation: OneToMany
627
+ target: Comment
628
+ inversedBy: post
629
+ tags:
630
+ type: Association
631
+ relation: ManyToMany
632
+ target: Tag
633
+ inversedBy: posts
634
+
635
+ # Comment.yaml
636
+ name: Comment
637
+ properties:
638
+ body:
639
+ type: Text
640
+ post:
641
+ type: Association
642
+ relation: ManyToOne
643
+ target: Post
644
+ onDelete: CASCADE
645
+ author:
646
+ type: Association
647
+ relation: ManyToOne
648
+ target: User
649
+ nullable: true
650
+ onDelete: SET NULL
651
+
652
+ # Tag.yaml
653
+ name: Tag
654
+ properties:
655
+ name:
656
+ type: String
657
+ unique: true
658
+ posts:
659
+ type: Association
660
+ relation: ManyToMany
661
+ target: Post
662
+ inversedBy: tags
663
+ ```
664
+
665
+ ### E-commerce Order System
666
+
667
+ ```yaml
668
+ # Order.yaml
669
+ name: Order
670
+ properties:
671
+ orderNumber:
672
+ type: String
673
+ unique: true
674
+ customer:
675
+ type: Association
676
+ relation: ManyToOne
677
+ target: User
678
+ onDelete: RESTRICT
679
+ items:
680
+ type: Association
681
+ relation: OneToMany
682
+ target: OrderItem
683
+ inversedBy: order
684
+
685
+ # OrderItem.yaml
686
+ name: OrderItem
687
+ properties:
688
+ quantity:
689
+ type: Int
690
+ price:
691
+ type: Decimal
692
+ precision: 10
693
+ scale: 2
694
+ order:
695
+ type: Association
696
+ relation: ManyToOne
697
+ target: Order
698
+ onDelete: CASCADE
699
+ product:
700
+ type: Association
701
+ relation: ManyToOne
702
+ target: Product
703
+ onDelete: RESTRICT
704
+ ```
705
+
706
+ ### Multi-tenant System
707
+
708
+ ```yaml
709
+ # Tenant.yaml
710
+ name: Tenant
711
+ properties:
712
+ name:
713
+ type: String
714
+ users:
715
+ type: Association
716
+ relation: OneToMany
717
+ target: User
718
+ inversedBy: tenant
719
+ projects:
720
+ type: Association
721
+ relation: OneToMany
722
+ target: Project
723
+ inversedBy: tenant
724
+
725
+ # User.yaml
726
+ name: User
727
+ properties:
728
+ name:
729
+ type: String
730
+ tenant:
731
+ type: Association
732
+ relation: ManyToOne
733
+ target: Tenant
734
+ onDelete: CASCADE
735
+
736
+ # Project.yaml
737
+ name: Project
738
+ properties:
739
+ name:
740
+ type: String
741
+ tenant:
742
+ type: Association
743
+ relation: ManyToOne
744
+ target: Tenant
745
+ onDelete: CASCADE
746
+ members:
747
+ type: Association
748
+ relation: ManyToMany
749
+ target: User
750
+ inversedBy: projects
751
+ ```
752
+
753
+ ---
754
+
755
+ ## See Also
756
+
757
+ - [Schema Format](./schema-format.md)
758
+ - [Property Types](./property-types.md)
759
+ - [Migration Generation](./migrations.md)
760
+ - [TypeScript Generation](./typescript.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@famgia/omnify",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Schema-driven database migration system with TypeScript types and Laravel migrations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,11 +19,12 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "bin",
22
+ "docs",
22
23
  "README.md"
23
24
  ],
24
25
  "dependencies": {
25
- "@famgia/omnify-core": "0.0.7",
26
26
  "@famgia/omnify-cli": "0.0.9",
27
+ "@famgia/omnify-core": "0.0.7",
27
28
  "@famgia/omnify-types": "0.0.7",
28
29
  "@famgia/omnify-laravel": "0.0.9",
29
30
  "@famgia/omnify-atlas": "0.0.7"