@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- 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()`.
|