@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,365 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Casts & Mutators
|
|
3
|
+
description: Translate between raw database values and rich, typed TypeScript values on every read and write.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Casts & Mutators
|
|
7
|
+
|
|
8
|
+
Casts translate between the raw value stored in the database and the typed
|
|
9
|
+
TypeScript value you work with on your model. Declare them inline on `@column()`
|
|
10
|
+
or in a class-level `static casts` map; custom casts give you full control over
|
|
11
|
+
both the read (get) and write (set) transforms.
|
|
12
|
+
|
|
13
|
+
## Basic usage
|
|
14
|
+
|
|
15
|
+
The shorthand string passed to `@column("…")` is a cast alias. Each one resolves
|
|
16
|
+
to a built-in get/set pair:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
// app/models/Post.ts
|
|
20
|
+
import { Model, column, table } from "@zerotal/orm";
|
|
21
|
+
import { Carbon } from "zerotal/carbon";
|
|
22
|
+
|
|
23
|
+
@table("posts")
|
|
24
|
+
export class Post extends Model {
|
|
25
|
+
@column("string") title!: string;
|
|
26
|
+
@column("integer") views!: number;
|
|
27
|
+
@column("float") score!: number;
|
|
28
|
+
@column("boolean") published!: boolean;
|
|
29
|
+
@column("datetime") publishedAt!: Carbon;
|
|
30
|
+
@column("date") birthday?: Date;
|
|
31
|
+
@column("json") meta!: Record<string, unknown>;
|
|
32
|
+
@column("array") tags!: string[];
|
|
33
|
+
@column("text") bio?: string;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Built-in cast shorthands
|
|
38
|
+
|
|
39
|
+
| Shorthand | TypeScript type | Read (DB → model) | Write (model → DB) |
|
|
40
|
+
| --------------------- | --------------- | ------------------------------ | ------------------ |
|
|
41
|
+
| `'string'` / `'text'` | `string` | as-is | as-is |
|
|
42
|
+
| `'integer'` | `number` | `parseInt()` | `parseInt()` |
|
|
43
|
+
| `'float'` | `number` | `parseFloat()` | `parseFloat()` |
|
|
44
|
+
| `'boolean'` | `boolean` | coerces `0`/`1`/`"1"`/`"true"` | writes `1` or `0` |
|
|
45
|
+
| `'datetime'` | `Carbon` | constructs a `Carbon` instance | ISO 8601 string |
|
|
46
|
+
| `'date'` | `Date` | constructs a native `Date` | ISO 8601 string |
|
|
47
|
+
| `'json'` | `unknown` | `JSON.parse()` | `JSON.stringify()` |
|
|
48
|
+
| `'array'` | `unknown[]` | `JSON.parse()` | `JSON.stringify()` |
|
|
49
|
+
| `'encrypted'` | `string` | decrypts under `APP_KEY` | AES-256-GCM |
|
|
50
|
+
| `'encrypted:json'` | `unknown` | decrypts, then `JSON.parse()` | stringify, encrypt |
|
|
51
|
+
|
|
52
|
+
> **Note** — `@column("date")` reads back a native `Date`, while
|
|
53
|
+
> `@column("datetime")` reads back a [Carbon](/docs/carbon) instance. Type the
|
|
54
|
+
> property accordingly.
|
|
55
|
+
|
|
56
|
+
### Scalars in a json column
|
|
57
|
+
|
|
58
|
+
`json` and `array` encode on write and parse on read, in both directions, so a value
|
|
59
|
+
round-trips as the type you gave it — including a bare scalar:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
setting.value = "62812345678"; // stored as "62812345678", read back as a string
|
|
63
|
+
setting.value = "051001"; // a branch code keeps its leading zero
|
|
64
|
+
setting.value = { plan: "pro" }; // objects and arrays as you would expect
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This is worth stating because the obvious alternative is wrong. Skipping the encode for
|
|
68
|
+
values that are already strings looks like it avoids double-encoding, but it makes the
|
|
69
|
+
column hold bare characters — and `JSON.parse("62812345678")` is a **number**. The value's
|
|
70
|
+
type would change between write and read, silently, for some values and not others.
|
|
71
|
+
|
|
72
|
+
If you are reading rows written by an older version that stored bare scalars, a value that
|
|
73
|
+
was a numeric string may come back as a number; coerce on read where it matters.
|
|
74
|
+
|
|
75
|
+
## Advanced cast options
|
|
76
|
+
|
|
77
|
+
### decimal:N — fixed-precision number
|
|
78
|
+
|
|
79
|
+
Reads and writes the value as a string with exactly `N` decimal places. Useful
|
|
80
|
+
for currency, where you want to avoid floating-point drift:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// app/models/Product.ts
|
|
84
|
+
@column({ type: "number", cast: "decimal:2" }) price!: string;
|
|
85
|
+
// DB stores "9.99" — the model reads it back as the string "9.99".
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **Note** — Because both the read and write transforms call `.toFixed(N)`, a
|
|
89
|
+
> `decimal:N` column surfaces as a **string**, not a number. Type the property
|
|
90
|
+
> as `string`.
|
|
91
|
+
|
|
92
|
+
### immutable_datetime — datetime alias
|
|
93
|
+
|
|
94
|
+
Behaves like `'datetime'` on read (constructs a [Carbon](/docs/carbon)) and
|
|
95
|
+
serializes to an ISO 8601 string on write:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// app/models/Booking.ts
|
|
99
|
+
@column({ type: "datetime", cast: "immutable_datetime" }) lockedAt?: Carbon;
|
|
100
|
+
|
|
101
|
+
const tomorrow = booking.lockedAt?.add(1, "day"); // returns a new Carbon
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> **Note** — Every `Carbon` is already immutable: each modifier such as `add()`
|
|
105
|
+
> returns a _new_ instance and never mutates the original. So
|
|
106
|
+
> `immutable_datetime` and `datetime` produce equivalent values — always assign
|
|
107
|
+
> the result of a modifier rather than relying on in-place mutation.
|
|
108
|
+
|
|
109
|
+
### enum — TypeScript enums
|
|
110
|
+
|
|
111
|
+
Stores and retrieves the raw enum value (the underlying string or number);
|
|
112
|
+
TypeScript narrows the property type. The cast itself is a pass-through, so pair
|
|
113
|
+
it with `enumValues` to document the enum:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// app/models/Post.ts
|
|
117
|
+
enum Status {
|
|
118
|
+
Draft = "draft",
|
|
119
|
+
Published = "published",
|
|
120
|
+
Archived = "archived",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@column({ type: "string", cast: "enum", enumValues: Status }) status!: Status;
|
|
124
|
+
|
|
125
|
+
// TypeScript now knows post.status is Status, not string:
|
|
126
|
+
if (post.status === Status.Published) { /* … */ }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### encrypted — ciphertext at rest
|
|
130
|
+
|
|
131
|
+
The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the property
|
|
132
|
+
holds the value you assigned. Nothing in between — your code, validation,
|
|
133
|
+
`$dirty` — has to know:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// app/models/Client.ts
|
|
137
|
+
@column("encrypted", { nullable: true }) idNumber?: string;
|
|
138
|
+
|
|
139
|
+
// Structured values need the :json variant, so the type round-trips:
|
|
140
|
+
@column("encrypted:json", { nullable: true }) medical?: MedicalInfo;
|
|
141
|
+
|
|
142
|
+
// The same thing spelled out. `encrypted` is a cast, not a storage type —
|
|
143
|
+
// `{ type: "encrypted" }` is not a thing:
|
|
144
|
+
@column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The shorthand resolves to `{ type: "text", cast: "encrypted" }`, which is why it is
|
|
148
|
+
worth preferring: it gets the storage type right without you having to remember
|
|
149
|
+
that ciphertext outgrows its plaintext.
|
|
150
|
+
|
|
151
|
+
For several columns at once, list them instead — it means exactly the same thing:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
class Client extends BaseModel {
|
|
155
|
+
static encryptable = ["idNumber", "passportNumber"];
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A column in that list whose `@column({ type })` is `json` encrypts as
|
|
160
|
+
`encrypted:json` automatically, so the structure survives the round trip rather
|
|
161
|
+
than reaching the cipher as `"[object Object]"`.
|
|
162
|
+
|
|
163
|
+
Unlike [`hashable`](/docs/orm/index), this is reversible and does not touch the
|
|
164
|
+
instance: after `save()`, `client.idNumber` still reads as the plaintext you set.
|
|
165
|
+
`$dirty` therefore compares plaintext, and an unchanged column is not rewritten
|
|
166
|
+
with a fresh IV on every unrelated save.
|
|
167
|
+
|
|
168
|
+
> **Danger** — **You cannot query an encrypted column.** Every write draws a new
|
|
169
|
+
> IV, so the same value encrypts to different ciphertext each time and an equality
|
|
170
|
+
> match can never hit. `where()` on one throws `EncryptedColumnError` rather than
|
|
171
|
+
> quietly returning zero rows. If you need lookup, keep a separate hashed column
|
|
172
|
+
> (a blind index) beside it and query that. Sorting and grouping are meaningless
|
|
173
|
+
> for the same reason, and are not guarded.
|
|
174
|
+
|
|
175
|
+
Two more things worth knowing:
|
|
176
|
+
|
|
177
|
+
- **Declare the column as `text`.** A payload is roughly 1.4× the plaintext plus
|
|
178
|
+
28 bytes, so a `VARCHAR(255)` that held the value will not hold its ciphertext.
|
|
179
|
+
`migrate:generate` and `synchronize()` widen an encrypted column to TEXT for you
|
|
180
|
+
— the generated migration says `table.text(...)` — because MySQL outside strict
|
|
181
|
+
mode truncates instead of failing, and a truncated payload never decrypts.
|
|
182
|
+
- **Add them to `hidden`** if the model is serialized to a client. Decryption puts
|
|
183
|
+
the real value back on the instance, and `toJSON()` will include it.
|
|
184
|
+
|
|
185
|
+
**Turning encryption on for a column that already holds data** needs a back-fill
|
|
186
|
+
first: existing plaintext rows are not decryptable, and reading one throws
|
|
187
|
+
`EncryptedColumnError` naming the model and column. Read the rows with the cast
|
|
188
|
+
off, then write them back with it on. The same error covers a rotated `APP_KEY` —
|
|
189
|
+
decrypt with the old key and re-save. Failing the read is deliberate: handing back
|
|
190
|
+
the ciphertext would put an unreadable value where the application expects a real
|
|
191
|
+
one, and re-encrypt it on the next save, losing the original for good.
|
|
192
|
+
|
|
193
|
+
### Custom cast — full get/set control
|
|
194
|
+
|
|
195
|
+
Pass an object with `get` and `set` functions for complete control over
|
|
196
|
+
serialization:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// app/models/Place.ts
|
|
200
|
+
interface GeoPoint { lat: number; lng: number }
|
|
201
|
+
|
|
202
|
+
@column({
|
|
203
|
+
type: "string",
|
|
204
|
+
cast: {
|
|
205
|
+
get: (v: unknown): GeoPoint => JSON.parse(v as string),
|
|
206
|
+
set: (v: unknown): string => JSON.stringify(v),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
location!: GeoPoint;
|
|
210
|
+
|
|
211
|
+
// You now work with a typed object, not a raw string:
|
|
212
|
+
console.log(place.location.lat, place.location.lng);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Reusable casts
|
|
216
|
+
|
|
217
|
+
For a cast you reuse across models, extend the `Cast` base class instead of
|
|
218
|
+
repeating an inline `{ get, set }` object. Put your cast in `app/casts/` and
|
|
219
|
+
pass an instance:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// app/casts/MoneyCast.ts
|
|
223
|
+
import { Cast } from "@zerotal/orm";
|
|
224
|
+
|
|
225
|
+
export class MoneyCast extends Cast<number> {
|
|
226
|
+
get(db: unknown) {
|
|
227
|
+
return Number(db) / 100;
|
|
228
|
+
} // cents → dollars
|
|
229
|
+
set(v: number) {
|
|
230
|
+
return Math.round(v * 100);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// app/models/Invoice.ts
|
|
237
|
+
import { column } from "@zerotal/orm";
|
|
238
|
+
import { MoneyCast } from "../casts/MoneyCast.ts";
|
|
239
|
+
|
|
240
|
+
@column({ cast: new MoneyCast() }) total!: number;
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
For JSON columns the ORM ships ready-made helpers that optionally hydrate the
|
|
244
|
+
parsed value into a class:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
// app/models/Customer.ts
|
|
248
|
+
import { column } from "@zerotal/orm";
|
|
249
|
+
import { json, objectOf, arrayOf } from "@zerotal/orm";
|
|
250
|
+
import { Address } from "../value-objects/Address.ts";
|
|
251
|
+
|
|
252
|
+
@column({ cast: json<Settings>() }) settings!: Settings; // typed plain JSON
|
|
253
|
+
@column({ cast: objectOf(Address) }) billing!: Address; // hydrate one object
|
|
254
|
+
@column({ cast: arrayOf(Address) }) addresses!: Address[]; // hydrate a list
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
> **Tip** — A class passed to `objectOf`/`arrayOf` is hydrated without invoking
|
|
258
|
+
> its constructor (via `Object.assign` on the prototype). Define a static
|
|
259
|
+
> `fromJSON(raw)` on the class to customise how a row is rebuilt.
|
|
260
|
+
|
|
261
|
+
## static casts map
|
|
262
|
+
|
|
263
|
+
An alternative to `@column()` for columns you don't declare directly (e.g. from
|
|
264
|
+
an external schema, a view, or a generated table):
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// app/models/Post.ts
|
|
268
|
+
@table("posts")
|
|
269
|
+
export class Post extends Model {
|
|
270
|
+
static casts = {
|
|
271
|
+
publishedAt: "datetime",
|
|
272
|
+
meta: "json",
|
|
273
|
+
price: "decimal:2",
|
|
274
|
+
active: "boolean",
|
|
275
|
+
} as const;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`static casts` and `@column()` can coexist. Casts are merged up the prototype
|
|
280
|
+
chain, so a subclass inherits its parent's casts without re-declaring them.
|
|
281
|
+
|
|
282
|
+
> **Warning** — When a column is declared in **both** `static casts` and
|
|
283
|
+
> `@column()`, the `static casts` entry wins — it is checked first during
|
|
284
|
+
> hydration. Pick one place to define a column's cast to avoid surprises.
|
|
285
|
+
|
|
286
|
+
### Which should I use?
|
|
287
|
+
|
|
288
|
+
- **`@column("…")` shorthand** — the default. Co-locates the cast with the
|
|
289
|
+
property and gives you the TypeScript type in one place.
|
|
290
|
+
- **`@column({ cast })` object / `Cast` class** — when you need a custom
|
|
291
|
+
transform, a `decimal:N`/`enum` option, or a reusable cast shared by several
|
|
292
|
+
models.
|
|
293
|
+
- **`static casts` map** — when the property isn't declared with `@column()`
|
|
294
|
+
(external/generated schemas) or you want all casts listed in one table.
|
|
295
|
+
|
|
296
|
+
## Reactive JSON casts
|
|
297
|
+
|
|
298
|
+
By default, mutating a nested JSON property directly (e.g. `post.meta.views++`)
|
|
299
|
+
does not mark the column dirty and won't be persisted on the next `save()`.
|
|
300
|
+
Enable `reactiveCasts` to make `json` and `array` columns use a reactive proxy
|
|
301
|
+
that tracks deep mutations:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
// app/models/Post.ts
|
|
305
|
+
@table("posts")
|
|
306
|
+
export class Post extends Model {
|
|
307
|
+
static reactiveCasts = true;
|
|
308
|
+
|
|
309
|
+
@column("json") meta!: Record<string, unknown>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const post = await Post.find(1);
|
|
313
|
+
|
|
314
|
+
// With reactiveCasts = true, this nested mutation IS tracked:
|
|
315
|
+
post.meta.views = (post.meta.views as number) + 1;
|
|
316
|
+
await post.save(); // persists the updated meta
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Without `reactiveCasts`, replace the whole value to ensure dirty tracking:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// in a controller
|
|
323
|
+
post.meta = { ...post.meta, views: (post.meta.views as number) + 1 };
|
|
324
|
+
await post.save();
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
> **Note** — Enable `reactiveCasts` per model. There is no performance cost on
|
|
328
|
+
> models that don't use it.
|
|
329
|
+
|
|
330
|
+
## Cast application order
|
|
331
|
+
|
|
332
|
+
Casts are applied:
|
|
333
|
+
|
|
334
|
+
- **On read** — immediately after the row is hydrated from the database.
|
|
335
|
+
- **On write** — just before the value is sent to the database in `save()`,
|
|
336
|
+
`create()`, or `update()`.
|
|
337
|
+
- **In dirty tracking** — the hydrated (post-read-cast) value is captured as the
|
|
338
|
+
original, so `isDirty()` reflects actual changes, not cast-representation
|
|
339
|
+
differences.
|
|
340
|
+
|
|
341
|
+
## References
|
|
342
|
+
|
|
343
|
+
Custom-cast surface, all exported from `@zerotal/orm`:
|
|
344
|
+
|
|
345
|
+
| Member | Signature | Description |
|
|
346
|
+
| ---------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
|
|
347
|
+
| `Cast<T>` | `abstract class Cast<T> { get(db); set(v) }` | Base class for a reusable custom cast. |
|
|
348
|
+
| `CastContract<T>` | `interface { get(db): T; set(v: T): unknown }` | The shape any `{ get, set }` cast must satisfy. |
|
|
349
|
+
| `json<T>(mapper?)` | `(mapper?: CastMapper<T>) => JsonCast<T>` | Cast a JSON column to a typed object, optionally hydrated. |
|
|
350
|
+
| `objectOf<T>(mapper?)` | `(mapper?: CastMapper<T>) => JsonCast<T>` | Alias of `json`, reads nicely with a class. |
|
|
351
|
+
| `arrayOf<T>(mapper?)` | `(mapper?: CastMapper<T>) => ArrayCast<T>` | Cast a JSON column to an array of typed values. |
|
|
352
|
+
|
|
353
|
+
Cast options accepted by `@column()`:
|
|
354
|
+
|
|
355
|
+
| Option | Type | Description |
|
|
356
|
+
| ------------ | --------------------------------------------------- | ----------------------------------------------- |
|
|
357
|
+
| `cast` | shorthand string, `{ get, set }`, or `CastContract` | The transform applied on read/write. |
|
|
358
|
+
| `enumValues` | `Record<string, string \| number>` | The TS enum object, paired with `cast: "enum"`. |
|
|
359
|
+
|
|
360
|
+
## Next steps
|
|
361
|
+
|
|
362
|
+
- [ORM](/docs/orm/index) — defining models and columns.
|
|
363
|
+
- [Queries](/docs/orm/queries) — the query builder and scopes.
|
|
364
|
+
- [Serialization](/docs/orm/serialization) — control JSON output.
|
|
365
|
+
- [Carbon](/docs/carbon) — the date type behind `datetime` casts.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Factories
|
|
3
|
+
description: Generate realistic model instances on demand for seeders and tests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Factories
|
|
7
|
+
|
|
8
|
+
Factories produce model instances with realistic fake data. Define the default
|
|
9
|
+
shape once, then spin up one record or a thousand — in [seeders](/docs/seeding) or
|
|
10
|
+
[tests](/docs/testing).
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
Factories ship in `@zerotal/testing`. There is no provider or config file —
|
|
15
|
+
import the `Factory` class and start defining:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# in your project root
|
|
19
|
+
bun add @zerotal/testing
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// database/factories/PostFactory.ts
|
|
24
|
+
import { Factory } from "@zerotal/testing";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Defining a factory
|
|
28
|
+
|
|
29
|
+
Generate one with the CLI:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# in your project root
|
|
33
|
+
bun zt make:factory Post
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This writes `database/factories/PostFactory.ts`. A factory is `Factory.define(Model,
|
|
37
|
+
definition)`, where the definition callback receives the built-in [`fake`](#the-fake-helper)
|
|
38
|
+
helper and returns the model's default attributes:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// database/factories/PostFactory.ts
|
|
42
|
+
import { Factory } from "@zerotal/testing";
|
|
43
|
+
import { Post } from "../../app/models/Post.ts";
|
|
44
|
+
|
|
45
|
+
export const PostFactory = Factory.define(Post, (fake) => ({
|
|
46
|
+
title: fake.sentence({ words: 5 }),
|
|
47
|
+
body: fake.paragraph(),
|
|
48
|
+
slug: fake.string(12),
|
|
49
|
+
publishedAt: fake.pastDate(),
|
|
50
|
+
}));
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The definition is **type-safe** against the model's insert payload. Foreign-key
|
|
54
|
+
fields (anything ending in `Id`, e.g. `userId`) are optional in the definition —
|
|
55
|
+
supply them at create time with [`.for()`](#relating-models) or an override.
|
|
56
|
+
|
|
57
|
+
## Creating records
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// in a test or seeder
|
|
61
|
+
// Persist one record → Promise<Post>
|
|
62
|
+
const post = await PostFactory.create();
|
|
63
|
+
|
|
64
|
+
// Override any attribute
|
|
65
|
+
const draft = await PostFactory.create({ publishedAt: null });
|
|
66
|
+
|
|
67
|
+
// Persist many → Promise<Post[]>
|
|
68
|
+
const posts = await PostFactory.count(20).create();
|
|
69
|
+
const five = await PostFactory.count(5).create({ status: "published" });
|
|
70
|
+
|
|
71
|
+
// Insert n without batch mode (also returns Post[])
|
|
72
|
+
const three = await PostFactory.createMany(3);
|
|
73
|
+
|
|
74
|
+
// Build in memory WITHOUT touching the database → Post
|
|
75
|
+
const unsaved = PostFactory.make({ title: "Preview" });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Method | Returns | Touches DB |
|
|
79
|
+
| ----------------------------- | -------------- | ---------- |
|
|
80
|
+
| `create(overrides?)` | `Promise<T>` | Yes |
|
|
81
|
+
| `count(n).create(overrides?)` | `Promise<T[]>` | Yes |
|
|
82
|
+
| `createMany(n, overrides?)` | `Promise<T[]>` | Yes |
|
|
83
|
+
| `make(overrides?)` | `T` | No |
|
|
84
|
+
|
|
85
|
+
Override precedence is: definition defaults → relation FKs → your overrides (last
|
|
86
|
+
wins).
|
|
87
|
+
|
|
88
|
+
> **Tip** — Both `count(n).create()` and `createMany(n)` insert sequentially (not in
|
|
89
|
+
> parallel) so they stay correct on SQLite's single-write `last_insert_rowid()`.
|
|
90
|
+
|
|
91
|
+
## Relating models
|
|
92
|
+
|
|
93
|
+
`.for(parent)` injects the parent's primary key as a foreign key, derived from the
|
|
94
|
+
parent's class name (`User` → `userId`):
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// in a test or seeder
|
|
98
|
+
const user = await UserFactory.create();
|
|
99
|
+
|
|
100
|
+
const post = await PostFactory.for(user).create(); // sets post.userId
|
|
101
|
+
const authored = await PostFactory.for(user, "authorId").create(); // custom FK column
|
|
102
|
+
|
|
103
|
+
// Chain multiple parents
|
|
104
|
+
const comment = await CommentFactory.for(post).for(user).create();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Modifiers
|
|
108
|
+
|
|
109
|
+
All modifiers return a new factory (they don't mutate), so they compose freely and a
|
|
110
|
+
base factory stays reusable.
|
|
111
|
+
|
|
112
|
+
### state
|
|
113
|
+
|
|
114
|
+
Force the created instance into a model state via `forceState()` (bypassing guards
|
|
115
|
+
and transition callbacks — see [Lifecycle & Events](/docs/orm/lifecycle)):
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// in a test or seeder
|
|
119
|
+
const expired = await SubscriptionFactory.state("expired").create();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### afterCreate
|
|
123
|
+
|
|
124
|
+
Run logic after each instance is saved — e.g. attaching related records:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
// in a test or seeder
|
|
128
|
+
const user = await UserFactory.afterCreate(async (u) => {
|
|
129
|
+
await PostFactory.for(u).count(3).create();
|
|
130
|
+
}).create();
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### dispatchEvents
|
|
134
|
+
|
|
135
|
+
Factories suppress model observers and hooks by default. Opt back in when a test
|
|
136
|
+
needs the full lifecycle to fire:
|
|
137
|
+
|
|
138
|
+
> **Note** — Suppressing hooks keeps seeding side-effect free (no logs, emails, or
|
|
139
|
+
> queued jobs). Call `dispatchEvents()` to let observers and hooks run.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// in a test
|
|
143
|
+
// Silent — no "user registered" side effects:
|
|
144
|
+
await UserFactory.count(20).create();
|
|
145
|
+
|
|
146
|
+
// Fire observers/hooks so you can assert a side effect:
|
|
147
|
+
const user = await UserFactory.dispatchEvents().create();
|
|
148
|
+
Queue.assertDispatched(WelcomeEmailJob);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## In seeders
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// database/seeders/DatabaseSeeder.ts
|
|
155
|
+
import { Seeder } from "@zerotal/orm";
|
|
156
|
+
import { UserFactory } from "../factories/UserFactory.ts";
|
|
157
|
+
import { PostFactory } from "../factories/PostFactory.ts";
|
|
158
|
+
|
|
159
|
+
export class DatabaseSeeder extends Seeder {
|
|
160
|
+
async run(): Promise<void> {
|
|
161
|
+
const authors = await UserFactory.count(10).create();
|
|
162
|
+
for (const author of authors) {
|
|
163
|
+
await PostFactory.for(author).count(5).create();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
See [Seeding](/docs/seeding) for running seeders.
|
|
170
|
+
|
|
171
|
+
## In tests
|
|
172
|
+
|
|
173
|
+
Factories are the standard way to arrange database state in a test:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
// in a test
|
|
177
|
+
import { UserFactory } from "../../database/factories/UserFactory.ts";
|
|
178
|
+
|
|
179
|
+
test("an editor can publish", async () => {
|
|
180
|
+
const editor = await UserFactory.state("editor").create();
|
|
181
|
+
const post = await PostFactory.for(editor).create({ status: "draft" });
|
|
182
|
+
// … act and assert
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [Database Testing](/docs/testing/database) for refreshing state between tests.
|
|
187
|
+
|
|
188
|
+
## The fake helper
|
|
189
|
+
|
|
190
|
+
The definition callback's argument is the built-in `fake` generator (also importable
|
|
191
|
+
as `import { fake } from "@zerotal/testing"`). A selection:
|
|
192
|
+
|
|
193
|
+
| Category | Methods |
|
|
194
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------- |
|
|
195
|
+
| Primitives | `number(min?, max?)`, `float(min?, max?, decimals?)`, `boolean(trueWeight?)`, `uuid()`, `string(length?)` |
|
|
196
|
+
| Picking | `pick(arr)`, `sample(arr, n)`, `shuffle(arr)`, `maybe(value, probability?)` |
|
|
197
|
+
| Dates | `date(from?, to?)`, `pastDate(years?)`, `futureDate(years?)`, `isoDate(from?, to?)`, `timestamp()` |
|
|
198
|
+
| People | `firstName()`, `lastName()`, `name()`, `email(opts?)`, `phone()` |
|
|
199
|
+
| Places | `city()`, `province()`, `suburb()`, `streetAddress()`, `postalCode()`, `address()` |
|
|
200
|
+
| Company | `company()`, `jobTitle()`, `department()` |
|
|
201
|
+
| Text | `word()`, `words(n?)`, `sentence(opts?)`, `sentences(n?, opts?)`, `paragraph(opts?)`, `paragraphs(n?, opts?)` |
|
|
202
|
+
| Web | `title()`, `slug(text?)`, `url(opts?)`, `password(opts?)` |
|
|
203
|
+
|
|
204
|
+
You're free to ignore `fake` and use any data source you like inside the definition.
|
|
205
|
+
|
|
206
|
+
## References
|
|
207
|
+
|
|
208
|
+
The full factory surface. Every modifier returns a new factory, so chains compose
|
|
209
|
+
without mutating the base.
|
|
210
|
+
|
|
211
|
+
| Method | Signature | Description |
|
|
212
|
+
| ---------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
213
|
+
| `define` | `Factory.define(Model, (fake) => FactoryPayload<T>): Factory<T>` | Create a reusable factory for a model class. |
|
|
214
|
+
| `create` | `create(overrides?: Partial<InsertPayload<T>>): Promise<T>` | Insert one record and return it. |
|
|
215
|
+
| `createMany` | `createMany(n: number, overrides?): Promise<T[]>` | Insert `n` records sequentially without batch mode. |
|
|
216
|
+
| `make` | `make(overrides?: Partial<InsertPayload<T>>): T` | Build one in-memory instance; does not touch the database. |
|
|
217
|
+
| `count` | `count(n: number): FactoryBatch<T>` | Switch to batch mode; the returned `create()` yields `Promise<T[]>`. |
|
|
218
|
+
| `for` | `for(model: Model, foreignKey?: string): Factory<T>` | Inject a parent's id as a foreign key (default `<model>Id`). |
|
|
219
|
+
| `state` | `state(stateName: string): Factory<T>` | Force created instances into a model state via `forceState()`. |
|
|
220
|
+
| `afterCreate` | `afterCreate(cb: (instance: T) => Promise<void> \| void): Factory<T>` | Run `cb` after each instance is saved. |
|
|
221
|
+
| `dispatchEvents` | `dispatchEvents(): Factory<T>` | Let observers and hooks fire (suppressed by default). |
|
|
222
|
+
|
|
223
|
+
> **Note** — `FactoryBatch` (returned by `count(n)`) mirrors `for`, `state`,
|
|
224
|
+
> `afterCreate`, and `dispatchEvents`, but its `create()` returns `Promise<T[]>`.
|
|
225
|
+
|
|
226
|
+
## Next steps
|
|
227
|
+
|
|
228
|
+
- [Seeding](/docs/seeding) — populating the database for demos and tests.
|
|
229
|
+
- [Database Testing](/docs/testing/database) — resetting state between tests.
|
|
230
|
+
- [Lifecycle & Events](/docs/orm/lifecycle) — model states that `state()` targets.
|
|
231
|
+
- [ORM](/docs/orm) — the models a factory builds.
|