@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,440 @@
1
+ ---
2
+ title: Relationships
3
+ description: Define and query associations between models with relation decorators and eager loading.
4
+ ---
5
+
6
+ # Relationships
7
+
8
+ Relation decorators describe how your models connect — one-to-one, one-to-many, many-to-many, through, and polymorphic — so you can traverse and eager-load associated records without writing JOINs by hand.
9
+
10
+ Zerotal's ORM supports all standard Active Record relationship types, plus polymorphic variants and eager loading with constraints. Relations are declared on the model class with decorators imported from `@zerotal/orm`; see [ORM](/docs/orm/index) for installing and configuring the package.
11
+
12
+ ## One-to-one — @hasOne / @belongsTo
13
+
14
+ A `User` has one `Profile`. The foreign key (`user_id`) lives on the `profiles` table:
15
+
16
+ ```typescript
17
+ // app/models/User.ts
18
+ import { Model, column, table, hasOne, belongsTo } from "@zerotal/orm";
19
+
20
+ @table("users")
21
+ export class User extends Model {
22
+ @column("string") name!: string;
23
+
24
+ @hasOne(() => Profile, { foreignKey: "user_id" })
25
+ profile!: Profile;
26
+ }
27
+
28
+ @table("profiles")
29
+ export class Profile extends Model {
30
+ @column("integer") userId!: number;
31
+ @column("text") bio!: string;
32
+
33
+ @belongsTo(() => User, { foreignKey: "userId" })
34
+ user!: User;
35
+ }
36
+ ```
37
+
38
+ ```typescript
39
+ // in a controller
40
+ const user = await User.query().with("profile").findOrFail(1);
41
+ const profile = user.profile; // Profile — no extra query
42
+
43
+ // Access the inverse
44
+ const found = await Profile.findOrFail(1);
45
+ await found.load(["user"]);
46
+ console.log(found.user.name);
47
+ ```
48
+
49
+ Both `@hasOne` and `@belongsTo` accept a `localKey` (defaults to `"id"`) to override the key the foreign key references.
50
+
51
+ ## One-to-many — @hasMany / @belongsTo
52
+
53
+ A `User` has many `Post`s. The foreign key (`user_id`) lives on the `posts` table:
54
+
55
+ ```typescript
56
+ // app/models/User.ts
57
+ @table("users")
58
+ export class User extends Model {
59
+ @hasMany(() => Post, { foreignKey: "user_id" })
60
+ posts!: Post[];
61
+ }
62
+
63
+ @table("posts")
64
+ export class Post extends Model {
65
+ @column("integer") userId!: number;
66
+
67
+ @belongsTo(() => User, { foreignKey: "userId" })
68
+ author!: User;
69
+ }
70
+ ```
71
+
72
+ ```typescript
73
+ // in a controller
74
+ const user = await User.query().with("posts").findOrFail(1);
75
+ user.posts; // Post[]
76
+
77
+ // Constrained eager load
78
+ const users = await User.query()
79
+ .with("posts", (q) => q.where("status", "published").orderBy("created_at", "desc"))
80
+ .get();
81
+ ```
82
+
83
+ ## associate / dissociate
84
+
85
+ Set or clear a `belongsTo` foreign key without having to know the parent's ID directly:
86
+
87
+ ```typescript
88
+ // in a controller
89
+ post.associate("author", user); // sets post.userId = user.id
90
+ await post.save();
91
+
92
+ post.dissociate("author"); // sets post.userId = null
93
+ await post.save();
94
+ ```
95
+
96
+ > **Note** — `@belongsTo` also accepts `withDefault` (a boolean, an attributes object, or a callback) to return an unsaved default related model instead of `null` when the association is absent.
97
+
98
+ ## Many-to-many — @manyToMany
99
+
100
+ A `Post` belongs to many `Tag`s through a `post_tags` pivot table:
101
+
102
+ ```typescript
103
+ // app/models/Post.ts
104
+ import { Model, column, table, manyToMany, type ManyToMany } from "@zerotal/orm";
105
+
106
+ @table("posts")
107
+ export class Post extends Model {
108
+ @manyToMany(() => Tag, {
109
+ pivotTable: "post_tags",
110
+ pivotForeignKey: "post_id", // FK pointing to Post
111
+ pivotRelatedKey: "tag_id", // FK pointing to Tag
112
+ })
113
+ tags!: ManyToMany<Tag>;
114
+ }
115
+
116
+ @table("tags")
117
+ export class Tag extends Model {
118
+ @column("string") name!: string;
119
+ }
120
+ ```
121
+
122
+ ### Pivot operations
123
+
124
+ ```typescript
125
+ // in a controller
126
+ // Attach one or multiple tags
127
+ await post.tags.attach(tagId);
128
+ await post.tags.attach([1, 2, 3]);
129
+
130
+ // Detach specific tags
131
+ await post.tags.detach(tagId);
132
+
133
+ // Detach all tags
134
+ await post.tags.detach();
135
+
136
+ // Sync — replaces all pivot rows with the given set
137
+ await post.tags.sync([1, 2, 3]);
138
+
139
+ // Toggle — attach if not present, detach if already present
140
+ await post.tags.toggle(tagId);
141
+ ```
142
+
143
+ ### Extra pivot columns
144
+
145
+ If the pivot table has additional columns, declare them with `withPivot`:
146
+
147
+ ```typescript
148
+ // app/models/User.ts
149
+ @manyToMany(() => Role, {
150
+ pivotTable: "user_roles",
151
+ pivotForeignKey: "user_id",
152
+ pivotRelatedKey: "role_id",
153
+ withPivot: ["assigned_at", "assigned_by"],
154
+ })
155
+ roles!: ManyToMany<Role>;
156
+ ```
157
+
158
+ ```typescript
159
+ // in a controller — attach with extra pivot data
160
+ await user.roles.attach(roleId, { assigned_by: adminId, assigned_at: new Date() });
161
+ ```
162
+
163
+ > **Tip** — Pass `withTimestamps: true` to keep `created_at` / `updated_at` maintained on the pivot table during `attach` and `sync`.
164
+
165
+ ## Through relationships — @hasManyThrough / @hasOneThrough
166
+
167
+ Access distant models through an intermediate model. A `Country` has many `Post`s through `User`s:
168
+
169
+ ```typescript
170
+ // app/models/Country.ts
171
+ import { hasManyThrough, hasOneThrough } from "@zerotal/orm";
172
+
173
+ @table("countries")
174
+ export class Country extends Model {
175
+ // Country → User (firstKey: FK on users that points to countries)
176
+ // User → Post (secondKey: FK on posts that points to users)
177
+ @hasManyThrough(() => Post, () => User, {
178
+ firstKey: "country_id", // users.country_id
179
+ secondKey: "user_id", // posts.user_id
180
+ })
181
+ posts!: Post[];
182
+
183
+ @hasOneThrough(() => Post, () => User, {
184
+ firstKey: "country_id",
185
+ secondKey: "user_id",
186
+ })
187
+ latestPost!: Post;
188
+ }
189
+ ```
190
+
191
+ ```typescript
192
+ // in a controller
193
+ const country = await Country.query().with("posts").findOrFail(1);
194
+ country.posts; // Post[] — no manual JOIN required
195
+ ```
196
+
197
+ ## Polymorphic relationships
198
+
199
+ Polymorphic relationships let a single model belong to multiple other models using a type+id pair.
200
+
201
+ ### @morphMany / @morphOne / @morphTo
202
+
203
+ A `Comment` can belong to either a `Post` or a `Video`:
204
+
205
+ ```typescript
206
+ // app/models/Post.ts
207
+ import {
208
+ morphMany,
209
+ morphOne,
210
+ morphTo,
211
+ type MorphMany,
212
+ type MorphOne,
213
+ type MorphTo,
214
+ } from "@zerotal/orm";
215
+
216
+ // Parent side — Post has many Comments (polymorphic)
217
+ @table("posts")
218
+ export class Post extends Model {
219
+ @morphMany(() => Comment, { morphName: "commentable" })
220
+ declare comments: MorphMany<Comment>;
221
+
222
+ @morphOne(() => Image, { morphName: "imageable" })
223
+ declare image: MorphOne<Image>;
224
+ }
225
+
226
+ // Owning side — Comment stores commentable_type + commentable_id
227
+ @table("comments")
228
+ export class Comment extends Model {
229
+ @column("string") declare commentableType: string;
230
+ @column("integer") declare commentableId: number;
231
+
232
+ @morphTo({
233
+ morphMap: {
234
+ Post: () => Post,
235
+ Video: () => Video,
236
+ },
237
+ })
238
+ declare commentable: MorphTo<Post | Video>;
239
+ }
240
+ ```
241
+
242
+ The `morphName` (`"commentable"`) determines the `commentable_type` and `commentable_id` columns. For `@morphTo`, the `morphMap` keys are stored in the `*_type` column; by default the column names derive from the property name (`commentable` → `commentable_type` / `commentable_id`), and you can override them with `morphTypeColumn` / `morphForeignKey`.
243
+
244
+ > **Warning** — Renaming a `morphMap` key without migrating existing `*_type` rows will break every stored polymorphic association. Keep the keys stable — changing one requires a data migration.
245
+
246
+ ```typescript
247
+ // in a controller
248
+ // Eager load polymorphic relations
249
+ const posts = await Post.query().with("comments").get();
250
+ posts[0].comments; // Comment[]
251
+
252
+ const comment = await Comment.query().with("commentable").findOrFail(1);
253
+ comment.commentable; // Post | Video
254
+ ```
255
+
256
+ ### @morphToMany / @morphedByMany — polymorphic many-to-many
257
+
258
+ Share a tagging system across multiple model types through a single `taggables` pivot:
259
+
260
+ ```typescript
261
+ // app/models/Post.ts
262
+ import { morphToMany, morphedByMany, type ManyToMany } from "@zerotal/orm";
263
+
264
+ // Post can be tagged
265
+ @table("posts")
266
+ export class Post extends Model {
267
+ @morphToMany(() => Tag, { morphName: "taggable", relatedPivotKey: "tag_id" })
268
+ tags!: ManyToMany<Tag>;
269
+ }
270
+
271
+ // Video can also be tagged using the same tags table
272
+ @table("videos")
273
+ export class Video extends Model {
274
+ @morphToMany(() => Tag, { morphName: "taggable", relatedPivotKey: "tag_id" })
275
+ tags!: ManyToMany<Tag>;
276
+ }
277
+
278
+ // Inverse — Tag can retrieve all Posts tagged with it
279
+ @table("tags")
280
+ export class Tag extends Model {
281
+ @morphedByMany(() => Post, { morphName: "taggable", parentPivotKey: "tag_id" })
282
+ posts!: ManyToMany<Post>;
283
+ }
284
+ ```
285
+
286
+ The pivot table name defaults to `{morphName}s` (here `taggables`). Its schema:
287
+
288
+ ```sql
289
+ -- migration: create the taggables pivot
290
+ CREATE TABLE taggables (
291
+ tag_id INTEGER NOT NULL,
292
+ taggable_id INTEGER NOT NULL,
293
+ taggable_type TEXT NOT NULL
294
+ );
295
+ ```
296
+
297
+ ## Eager loading
298
+
299
+ Always prefer eager loading over lazy loading in loops — it prevents N+1 queries.
300
+
301
+ > **Tip** — Reach for `.with()` whenever you access a relation across a collection; lazy-loading inside a loop fires one query per row.
302
+
303
+ ```typescript
304
+ // in a controller
305
+ // Single relation
306
+ const posts = await Post.query().with("author").get();
307
+ posts[0].author; // User — no extra query
308
+
309
+ // Multiple relations
310
+ const withBoth = await Post.query().with("author").with("comments").get();
311
+
312
+ // Nested relations (dot notation)
313
+ const nested = await Post.query().with("author.profile").get();
314
+ nested[0].author.profile; // Profile
315
+
316
+ // With constraints — only load approved comments
317
+ const filtered = await Post.query()
318
+ .with("comments", (q) => q.where("approved", true).orderBy("created_at"))
319
+ .get();
320
+
321
+ // Load count alongside models (no separate query)
322
+ const counted = await Post.query().withCount("comments").get<Post & { commentsCount: number }>();
323
+
324
+ counted[0].commentsCount; // number
325
+ ```
326
+
327
+ > **Note** — `withCount("comments")` exposes the count as the camel-cased `commentsCount` property on each result (the underlying `comments_count` SQL alias is converted for you).
328
+
329
+ ### Lazy eager loading on existing instances
330
+
331
+ When you already have a model instance and realise you need a relation:
332
+
333
+ ```typescript
334
+ // in a controller
335
+ const post = await Post.findOrFail(1);
336
+
337
+ // Load a relation that wasn't included in the original query
338
+ await post.load(["author", "comments"]);
339
+
340
+ // Load only if not already loaded (avoids redundant queries)
341
+ await post.loadMissing(["author"]);
342
+ ```
343
+
344
+ ## Relation existence filtering
345
+
346
+ Filter a parent model based on whether its relation exists, without loading the related rows:
347
+
348
+ ```typescript
349
+ // in a controller
350
+ // Posts that have at least one comment
351
+ Post.query().has("comments").get();
352
+
353
+ // Posts with 3 or more comments
354
+ Post.query().has("comments", ">=", 3).get();
355
+
356
+ // Posts with no comments at all
357
+ Post.query().doesntHave("comments").get();
358
+
359
+ // Posts with at least one approved comment
360
+ Post.query()
361
+ .whereHas("comments", (q) => q.where("approved", true))
362
+ .get();
363
+
364
+ // Posts without any approved comment
365
+ Post.query()
366
+ .whereDoesntHave("comments", (q) => q.where("approved", true))
367
+ .get();
368
+
369
+ // Filter by relation AND eager-load with the same constraint in one pass
370
+ Post.query()
371
+ .withWhereHas("comments", (q) => q.where("approved", true))
372
+ .get();
373
+ ```
374
+
375
+ > **Warning** — `has()` / `whereHas()` are not supported for `morphTo` relations — the related table isn't known until each row's `*_type` is read. Use eager loading via `with()` for those instead.
376
+
377
+ ## Choosing the right relationship type
378
+
379
+ Reach for the decorator that matches the shape of your data:
380
+
381
+ | Scenario | Decorator |
382
+ | ------------------------------------ | ----------------------------------------------------- |
383
+ | User has one Profile | `@hasOne` on User, `@belongsTo` on Profile |
384
+ | User has many Posts | `@hasMany` on User, `@belongsTo` on Post |
385
+ | Post belongs to many Tags | `@manyToMany` on both |
386
+ | Country → User → Post | `@hasManyThrough` on Country |
387
+ | Comment belongs to Post **or** Video | `@morphTo` on Comment, `@morphMany` on each parent |
388
+ | Tag applies to Post **and** Video | `@morphToMany` on each model, `@morphedByMany` on Tag |
389
+
390
+ The rule of thumb: put `@belongsTo` on whichever side stores the foreign-key column, and the matching `@hasOne` / `@hasMany` on the other. Use a polymorphic variant only when one side must point at more than one parent type.
391
+
392
+ ## References
393
+
394
+ Relation decorators — imported from `@zerotal/orm`, applied to a model property.
395
+
396
+ | Decorator | Signature | Description |
397
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
398
+ | `@hasOne` | `hasOne(related, { foreignKey, localKey? })` | One related record; FK lives on the related table. |
399
+ | `@hasMany` | `hasMany(related, { foreignKey, localKey? })` | Many related records; FK lives on the related table. |
400
+ | `@belongsTo` | `belongsTo(related, { foreignKey, localKey?, withDefault? })` | Inverse of has-one/has-many; FK lives on this model. |
401
+ | `@manyToMany` | `manyToMany(related, { pivotTable, pivotForeignKey, pivotRelatedKey, localKey?, relatedKey?, withPivot?, withTimestamps? })` | Many-to-many through a pivot table. |
402
+ | `@hasManyThrough` | `hasManyThrough(related, through, { firstKey, secondKey, localKey?, throughLocalKey? })` | Many records across an intermediate model. |
403
+ | `@hasOneThrough` | `hasOneThrough(related, through, { firstKey, secondKey, localKey?, throughLocalKey? })` | Single record across an intermediate model. |
404
+ | `@morphMany` | `morphMany(related, { morphName, localKey? })` | Polymorphic one-to-many (parent side). |
405
+ | `@morphOne` | `morphOne(related, { morphName, localKey? })` | Polymorphic one-to-one (parent side). |
406
+ | `@morphTo` | `morphTo({ morphMap, morphTypeColumn?, morphForeignKey? })` | Owning side; resolves the parent via the `*_type` map. |
407
+ | `@morphToMany` | `morphToMany(related, { morphName, relatedPivotKey, pivotTable?, withPivot?, withTimestamps? })` | Polymorphic many-to-many (owning side). |
408
+ | `@morphedByMany` | `morphedByMany(related, { morphName, parentPivotKey, pivotTable?, withPivot?, withTimestamps? })` | Polymorphic many-to-many (inverse side). |
409
+
410
+ Query and instance methods used with relations.
411
+
412
+ | Method | Signature | Description |
413
+ | ----------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
414
+ | `with` | `with(relation, constraint?)` / `with(relations[])` / `with(map)` | Eager-load a relation (dot notation for nesting; optional constraint). |
415
+ | `withCount` | `withCount(relation \| map)` | Add a `<relation>Count` subquery column to each result. |
416
+ | `has` | `has(relation, operator?, count?)` | Keep parents that have the relation (optionally count-filtered). |
417
+ | `doesntHave` | `doesntHave(relation, callback?)` | Keep parents that lack the relation. |
418
+ | `whereHas` | `whereHas(relation, callback?)` | Keep parents whose relation matches a constraint. |
419
+ | `whereDoesntHave` | `whereDoesntHave(relation, callback?)` | Keep parents whose relation does not match a constraint. |
420
+ | `withWhereHas` | `withWhereHas(relation, callback?)` | Filter by the relation and eager-load it with the same constraint. |
421
+ | `associate` | `associate(relation, model): this` | Set a `belongsTo` foreign key from a related model instance. |
422
+ | `dissociate` | `dissociate(relation): this` | Clear a `belongsTo` foreign key (sets it to `null`). |
423
+ | `load` | `load(relations[]): Promise<this>` | Lazy-load relations onto an existing instance. |
424
+ | `loadMissing` | `loadMissing(relations[]): Promise<this>` | Load relations only if not already loaded. |
425
+
426
+ Pivot collection methods on a `ManyToMany<T>` relation.
427
+
428
+ | Method | Signature | Description |
429
+ | -------- | ----------------------------------------------- | ----------------------------------------------------- |
430
+ | `attach` | `attach(id \| id[], pivotData?): Promise<void>` | Insert pivot rows, optionally with extra column data. |
431
+ | `detach` | `detach(id?\| id[]): Promise<void>` | Delete pivot rows; omit the id to detach all. |
432
+ | `sync` | `sync(ids[]): Promise<void>` | Replace all pivot rows with the given set. |
433
+ | `toggle` | `toggle(id \| id[]): Promise<void>` | Attach missing ids and detach present ones. |
434
+
435
+ ## Next steps
436
+
437
+ - [ORM queries](/docs/orm/queries) — eager loading, `whereHas`, and aggregates in depth.
438
+ - [ORM serialization](/docs/orm/serialization) — control how related models appear in JSON.
439
+ - [ORM lifecycle](/docs/orm/lifecycle) — react to changes on related records.
440
+ - [Migrations](/docs/migrations) — create the pivot and foreign-key columns these relations need.
@@ -0,0 +1,251 @@
1
+ ---
2
+ title: ORM Serialization
3
+ description: Control exactly which model fields reach your JSON API responses.
4
+ ---
5
+
6
+ # Serialization
7
+
8
+ Models serialize to plain JavaScript objects for API responses and `JSON.stringify()`. You control what gets exposed with `hidden`, `visible`, and `appends`, and can customize the output further with per-instance overrides.
9
+
10
+ ## Basic usage
11
+
12
+ Every `Model` has a `toJSON()` method. It returns a plain object you can pass straight to the `json()` response helper — and `JSON.stringify()` calls it for you automatically:
13
+
14
+ ```typescript
15
+ // in a controller
16
+ import { json } from "zerotal";
17
+ import { User } from "../models/User.ts";
18
+
19
+ const user = await User.findOrFail(1);
20
+ json(user.toJSON());
21
+ // { id: 1, name: "Alice", email: "alice@example.com", createdAt: "…" }
22
+ ```
23
+
24
+ ## Class-level configuration
25
+
26
+ Three static properties on the model class shape `toJSON()` for every instance: `hidden`, `visible`, and `appends`.
27
+
28
+ ### static hidden — exclude fields
29
+
30
+ Fields in `hidden` are stripped from `toJSON()` output. Use this to prevent sensitive data from leaking into API responses:
31
+
32
+ ```typescript
33
+ // app/models/User.ts
34
+ import { Model, table, column } from "@zerotal/orm";
35
+
36
+ @table("users")
37
+ export class User extends Model {
38
+ static hidden = ["password", "rememberToken", "twoFactorSecret"];
39
+
40
+ @column("string") name!: string;
41
+ @column("string") email!: string;
42
+ @column("string") password!: string;
43
+ @column("string") rememberToken?: string;
44
+ @column("string") twoFactorSecret?: string;
45
+ }
46
+
47
+ const user = await User.findOrFail(1);
48
+ user.toJSON();
49
+ // { id: 1, name: "Alice", email: "alice@example.com", ... }
50
+ // password, rememberToken, twoFactorSecret are absent
51
+ ```
52
+
53
+ > **Danger** — Password hashes and tokens leak into every API response unless the column is listed in `hidden` (or excluded via `visible`).
54
+
55
+ ### static visible — allowlist fields
56
+
57
+ `visible` takes precedence over `hidden`. When set (non-empty), `toJSON()` includes **only** those keys:
58
+
59
+ ```typescript
60
+ // app/models/User.ts
61
+ @table("users")
62
+ export class User extends Model {
63
+ // Only these fields appear in JSON — everything else is excluded:
64
+ static visible = ["id", "name", "email", "avatarUrl"];
65
+ }
66
+ ```
67
+
68
+ Use `visible` for models that have many internal columns and you want to be explicit about what's safe to expose, rather than listing everything you want to hide.
69
+
70
+ ### static appends — computed accessors
71
+
72
+ Include the result of a getter method in `toJSON()`. The getter runs at serialization time:
73
+
74
+ ```typescript
75
+ // app/models/User.ts
76
+ @table("users")
77
+ export class User extends Model {
78
+ @column("string") firstName!: string;
79
+ @column("string") lastName!: string;
80
+
81
+ static appends = ["fullName", "avatarUrl"];
82
+
83
+ get fullName(): string {
84
+ return `${this.firstName} ${this.lastName}`;
85
+ }
86
+
87
+ get avatarUrl(): string {
88
+ return `https://cdn.example.com/avatars/${this.id}.jpg`;
89
+ }
90
+ }
91
+
92
+ user.toJSON();
93
+ // { id: 1, firstName: "Alice", lastName: "Smith", fullName: "Alice Smith", avatarUrl: "https://…" }
94
+ ```
95
+
96
+ ## Per-instance overrides
97
+
98
+ Adjust what a specific instance exposes without modifying the class definition. Each method mutates the instance and returns `this`, so they chain and can be passed straight to `json()`:
99
+
100
+ ```typescript
101
+ // in a controller
102
+ // Temporarily hide additional fields for this response:
103
+ json(user.makeHidden("email", "phone").toJSON());
104
+
105
+ // Reveal a field that the class hides (e.g. in an admin context):
106
+ json(user.makeVisible("twoFactorSecret").toJSON());
107
+
108
+ // Append a computed accessor for this instance only:
109
+ json(user.append("temporaryToken").toJSON());
110
+
111
+ // Chain multiple overrides:
112
+ json(user.makeHidden("password").makeVisible("phoneVerifiedAt").append("isVerified").toJSON());
113
+ ```
114
+
115
+ > **Note** — `makeVisible()` wins over `hidden`: revealing a key removes it from the effective hidden set, even if the class lists it in `static hidden`.
116
+
117
+ ## toJSON output format
118
+
119
+ `toJSON()` returns a plain `Record<string, unknown>` with:
120
+
121
+ - **camelCase keys** — a `created_at` column is exposed as the `createdAt` property and serialized as such.
122
+ - **Casts applied** — `datetime` columns return ISO strings, `boolean` returns `true`/`false`, etc. See [ORM casts](/docs/orm/casts).
123
+ - **Hidden fields removed** — per `static hidden` and any `makeHidden()` overrides.
124
+ - **Appended accessors included** — per `static appends` and any `append()` overrides.
125
+ - **Loaded relations included** — nested models serialize via their own `toJSON()`.
126
+
127
+ ```typescript
128
+ // in a controller
129
+ const post = await Post.query().with("author").findOrFail(1);
130
+
131
+ post.toJSON();
132
+ // {
133
+ // id: 1,
134
+ // title: "Hello world",
135
+ // status: "published",
136
+ // publishedAt: "2024-01-15T10:30:00.000Z",
137
+ // createdAt: "2024-01-10T08:00:00.000Z",
138
+ // author: { id: 3, name: "Alice", email: "alice@example.com" }
139
+ // }
140
+ ```
141
+
142
+ `toJSON()` is called automatically by `JSON.stringify()` and by the `json()` and `view()` response helpers.
143
+
144
+ ## Serializing collections
145
+
146
+ When you have an array of models, call `toJSON()` on each item or rely on `JSON.stringify()`:
147
+
148
+ ```typescript
149
+ // in a controller
150
+ const posts = await Post.query().where("status", "published").get();
151
+
152
+ // Explicit — map to plain objects first:
153
+ json(posts.map((p) => p.toJSON()));
154
+
155
+ // Implicit — JSON.stringify calls toJSON() on each model automatically:
156
+ json(posts);
157
+ ```
158
+
159
+ ## Serialization and relationships
160
+
161
+ Hidden/visible lists apply per-model and do **not** propagate to nested relations. Each nested model serializes via its own class configuration:
162
+
163
+ ```typescript
164
+ // app/models/User.ts and app/models/Post.ts
165
+ @table("users")
166
+ export class User extends Model {
167
+ static hidden = ["password"]; // only applies to User
168
+ }
169
+
170
+ @table("posts")
171
+ export class Post extends Model {
172
+ // No hidden config — exposes all columns
173
+ }
174
+
175
+ const post = await Post.query().with("author").findOrFail(1);
176
+ post.toJSON();
177
+ // { ..., author: { id: 1, name: "Alice", email: "alice@example.com" } }
178
+ // User.hidden = ["password"] applies to the nested author — password is absent
179
+ ```
180
+
181
+ ## API Resource pattern
182
+
183
+ For fine-grained, per-endpoint serialization, use API Resources instead of class-level `hidden`/`visible`. A `Resource` wraps a model in `this.resource` and lets you shape the output per route without touching the model class. By default the output is wrapped in a `{ data: ... }` envelope:
184
+
185
+ ```typescript
186
+ // app/resources/PostResource.ts
187
+ import { Resource } from "zerotal/http";
188
+ import type { Post } from "../models/Post.ts";
189
+
190
+ export class PostResource extends Resource<Post> {
191
+ toArray(): Record<string, unknown> {
192
+ return {
193
+ id: this.resource.id,
194
+ title: this.resource.title,
195
+ excerpt: this.resource.body.slice(0, 160),
196
+ publishedAt: this.resource.publishedAt?.toISOString(),
197
+ };
198
+ }
199
+ }
200
+ ```
201
+
202
+ Build a single resource with `new PostResource(post)`, then `toJson()` for a plain object or `toResponse()` for a `Response`. Serialize a list with the static `collection()` helper, which takes the resource class first:
203
+
204
+ ```typescript
205
+ // in a controller
206
+ import { json } from "zerotal";
207
+ import { PostResource } from "../resources/PostResource.ts";
208
+
209
+ // One model → { data: { id, title, … } }
210
+ json(new PostResource(post).toJson());
211
+
212
+ // Many models → { data: [ … ] }
213
+ json(PostResource.collection(PostResource, posts));
214
+ ```
215
+
216
+ > **Tip** — Call `Resource.withoutWrapping()` once at boot if you prefer flat responses without the `{ data: ... }` envelope. For paginated results, `ResourceCollection.of(PostResource, paginated)` adds `meta` and `links`.
217
+
218
+ ## Which should I use?
219
+
220
+ | Approach | Reach for it when |
221
+ | ---------------------------------- | ----------------------------------------------------------------------------- |
222
+ | `static hidden` / `static visible` | Globally sensitive fields (passwords, tokens) that should never be exposed. |
223
+ | `makeHidden()` / `makeVisible()` | A one-off tweak for a single response (e.g. an admin endpoint). |
224
+ | `static appends` / `append()` | Adding computed values (full name, URLs) that aren't real columns. |
225
+ | `Resource` | Different shapes per route, or output that diverges from the model's columns. |
226
+
227
+ Use model-level `hidden`/`visible` for globally sensitive fields. Use Resources for per-route shaping.
228
+
229
+ ## References
230
+
231
+ `toJSON()` and the override methods live on every [`Model`](/docs/orm/index); the `Resource` helpers are exported from `zerotal`.
232
+
233
+ | Member | Signature | Description |
234
+ | ----------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------- |
235
+ | `static hidden` | `string[]` | Column/accessor keys excluded from `toJSON()`. |
236
+ | `static visible` | `string[]` | Allow-list; when non-empty, only these keys serialize. |
237
+ | `static appends` | `string[]` | Getter names whose return values are added to `toJSON()`. |
238
+ | `toJSON()` | `toJSON(): Record<string, unknown>` | Serialize the model to a plain object. |
239
+ | `makeHidden()` | `makeHidden(...keys: string[]): this` | Hide extra keys for this instance only. |
240
+ | `makeVisible()` | `makeVisible(...keys: string[]): this` | Reveal hidden keys for this instance only. |
241
+ | `append()` | `append(...keys: string[]): this` | Add computed accessor(s) for this instance only. |
242
+ | `Resource#toArray()` | `toArray(): Record<string, unknown>` | Define the serialized representation (override this). |
243
+ | `Resource#toJson()` | `toJson(): Record<string, unknown>` | Plain object, wrapped in `{ data }` unless wrapping is disabled. |
244
+ | `Resource.collection()` | `collection(ResourceClass, items, meta?): Record<string, unknown>` | Serialize an array of models with a resource class. |
245
+
246
+ ## Next steps
247
+
248
+ - [ORM](/docs/orm/index) — model definition, casts, and configuration.
249
+ - [ORM relationships](/docs/orm/relationships) — load the related models you serialize here.
250
+ - [ORM casts](/docs/orm/casts) — control how column values appear in serialized output.
251
+ - [Responses](/docs/responses) — the `json()` helper that calls `toJSON()`.