@rudderjs/orm 1.9.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/boost/skills/orm-models/SKILL.md +24 -251
- package/boost/skills/orm-models/rules/crud-and-observers.md +130 -0
- package/boost/skills/orm-models/rules/defining-models.md +137 -0
- package/boost/skills/orm-models/rules/factories.md +73 -0
- package/boost/skills/orm-models/rules/querying.md +117 -0
- package/boost/skills/orm-models/rules/resources.md +111 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -298,6 +298,45 @@ await user!.loadMissing('profile', 'posts')
|
|
|
298
298
|
|
|
299
299
|
---
|
|
300
300
|
|
|
301
|
+
## Vector search
|
|
302
|
+
|
|
303
|
+
`whereVectorSimilarTo(column, query, opts?)` runs a pgvector similarity query on the column. The terminal call (`get`/`first`/`all`) routes through raw SQL because pgvector ops (`<=>`, `<->`, `<#>`) can't be expressed in the fluent select APIs of either adapter, but everything else — `where(...)` chains, `limit`, `offset`, hydration, casts — composes normally.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const matches = await Document
|
|
307
|
+
.where('tenantId', tenantId)
|
|
308
|
+
.whereVectorSimilarTo('embedding', queryEmbedding, {
|
|
309
|
+
metric: 'cosine', // 'cosine' (default) | 'euclidean' | 'inner_product'
|
|
310
|
+
minSimilarity: 0.75, // optional — 1 - distance threshold
|
|
311
|
+
limit: 10,
|
|
312
|
+
})
|
|
313
|
+
.selectVectorDistance('embedding', queryEmbedding, 'distance') // optional projected column
|
|
314
|
+
.get()
|
|
315
|
+
|
|
316
|
+
// matches: Document[] with .distance set when selectVectorDistance() is used
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**`query`** is either a `number[]` (pre-computed embedding) or a string — when a string, the adapter calls the registered embedder (see `@rudderjs/ai`'s `similaritySearch` lift) to produce the vector. Without a registered embedder, passing a string throws `MissingEmbedderError`.
|
|
320
|
+
|
|
321
|
+
**Requirements** — Postgres + the pgvector extension, and a `vector(N)` column on the table:
|
|
322
|
+
|
|
323
|
+
```sql
|
|
324
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
325
|
+
ALTER TABLE documents ADD COLUMN embedding vector(1536);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Helper: `pnpm rudder make:migration <name> --vector` scaffolds a migration with the `CREATE EXTENSION` + column add already filled in.
|
|
329
|
+
|
|
330
|
+
**Limitations (v1):**
|
|
331
|
+
|
|
332
|
+
- `whereGroup` / `orWhereGroup` / `whereHas` cannot wrap a `whereVectorSimilarTo` call — the raw-SQL path can't be nested inside a fluent subquery.
|
|
333
|
+
- `.with(...)` and `withCount` / `withSum` / etc. don't compose with vector queries yet.
|
|
334
|
+
- `orderBy` is redundant (vector queries always order by similarity) and throws if combined.
|
|
335
|
+
- `count()` alongside `whereVectorSimilarTo` is not supported.
|
|
336
|
+
- pgvector-only — SQLite/MySQL throw `VectorStorageUnsupportedError`.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
301
340
|
## Route model binding
|
|
302
341
|
|
|
303
342
|
Models opt into route binding by exposing `static routeKey` (defaults to `'id'`) and `static findForRoute(value)`. The router's `router.bind(name, ModelClass)` API picks them up:
|
|
@@ -364,9 +403,23 @@ class Post extends Model {
|
|
|
364
403
|
| `'encrypted'` | Decrypts string | Encrypts string |
|
|
365
404
|
| `'encrypted:array'` | Decrypts + parses JSON | Encrypts JSON |
|
|
366
405
|
| `'encrypted:object'` | Decrypts + parses JSON | Encrypts JSON |
|
|
406
|
+
| `vector({ dimensions })` | `number[]` from pgvector text | `number[]` → pgvector text literal |
|
|
367
407
|
|
|
368
408
|
Encrypted casts require `@rudderjs/crypt` to be installed.
|
|
369
409
|
|
|
410
|
+
The `vector` cast is a factory — call it with the column's dimension so writes validate (`VectorDimensionMismatchError` on bad length, `NaN`, or `Infinity` before the SQL ever runs). Pair with `whereVectorSimilarTo()` for similarity search — see [Vector search](#vector-search) below.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
import { Model, vector } from '@rudderjs/orm'
|
|
414
|
+
|
|
415
|
+
class Document extends Model {
|
|
416
|
+
static override casts = {
|
|
417
|
+
embedding: vector({ dimensions: 1536 }),
|
|
418
|
+
}
|
|
419
|
+
declare embedding: number[]
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
370
423
|
### Custom cast classes
|
|
371
424
|
|
|
372
425
|
```ts
|
|
@@ -1,267 +1,40 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orm-models
|
|
3
3
|
description: Creating Eloquent-style models, queries, relationships, casts, factories, and API resources in RudderJS
|
|
4
|
+
license: MIT
|
|
5
|
+
appliesTo:
|
|
6
|
+
- '@rudderjs/orm'
|
|
7
|
+
- '@rudderjs/orm-prisma'
|
|
8
|
+
trigger: creating or editing a `Model` class under `app/Models/`, writing queries / relationships, defining casts / accessors / factories, or building `JsonResource` API resources
|
|
9
|
+
skip: a route handler that only reads a model — controller-views is enough
|
|
10
|
+
metadata:
|
|
11
|
+
author: rudderjs
|
|
4
12
|
---
|
|
5
13
|
|
|
6
14
|
# ORM Models
|
|
7
15
|
|
|
8
16
|
## When to use this skill
|
|
9
17
|
|
|
10
|
-
Load
|
|
18
|
+
Load when you're creating or editing a `Model` class under `app/Models/`, writing queries, defining casts / accessors / factories, or building `JsonResource` API resources. For depth, open the rule file matching your task.
|
|
11
19
|
|
|
12
|
-
##
|
|
20
|
+
## Quick Reference
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
| Task | Open |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Define a model — `static table`, `fillable`, `hidden`, `casts`, soft deletes, decorators, custom casts | `rules/defining-models.md` |
|
|
25
|
+
| Query the database — `find` / `where` / `paginate`, eager loading, scopes, soft-delete filters | `rules/querying.md` |
|
|
26
|
+
| CRUD + observers — `create` / `update` / `delete`, observer lifecycle, atomic counters | `rules/crud-and-observers.md` |
|
|
27
|
+
| Test data — `ModelFactory`, `sequence`, states, `.make()` vs `.create()` | `rules/factories.md` |
|
|
28
|
+
| API output — `JsonResource`, `ResourceCollection`, `when` / `whenLoaded` / `mergeWhen` | `rules/resources.md` |
|
|
20
29
|
|
|
21
|
-
##
|
|
30
|
+
## Key concepts (load once)
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export class Post extends Model {
|
|
29
|
-
static table = 'posts'
|
|
30
|
-
static fillable = ['title', 'body', 'authorId']
|
|
31
|
-
static hidden = ['internalNotes']
|
|
32
|
-
|
|
33
|
-
static casts = {
|
|
34
|
-
isPublished: 'boolean' as const,
|
|
35
|
-
publishedAt: 'datetime' as const,
|
|
36
|
-
metadata: 'json' as const,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
static attributes = {
|
|
40
|
-
excerpt: Attribute.make({
|
|
41
|
-
get: (_, attrs) => String(attrs['body'] ?? '').slice(0, 200),
|
|
42
|
-
}),
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
static appends = ['excerpt']
|
|
46
|
-
}
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### 2. Use decorator syntax (alternative)
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
import { Model, Hidden, Cast, Appends, Attribute } from '@rudderjs/orm'
|
|
53
|
-
|
|
54
|
-
export class User extends Model {
|
|
55
|
-
static fillable = ['name', 'email', 'password']
|
|
56
|
-
|
|
57
|
-
@Hidden password = ''
|
|
58
|
-
@Cast('boolean') isAdmin = false
|
|
59
|
-
@Cast('date') createdAt = new Date()
|
|
60
|
-
@Appends fullName = ''
|
|
61
|
-
|
|
62
|
-
static attributes = {
|
|
63
|
-
fullName: Attribute.make({
|
|
64
|
-
get: (_, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
|
|
65
|
-
}),
|
|
66
|
-
password: Attribute.make({
|
|
67
|
-
set: async (v) => {
|
|
68
|
-
const { hash } = await import('bcrypt')
|
|
69
|
-
return hash(String(v), 10)
|
|
70
|
-
},
|
|
71
|
-
}),
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### 3. Query the database
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
// Basic queries
|
|
80
|
-
const user = await User.find(1)
|
|
81
|
-
const users = await User.all()
|
|
82
|
-
const first = await User.first()
|
|
83
|
-
const total = await User.count()
|
|
84
|
-
|
|
85
|
-
// Filtered queries
|
|
86
|
-
const admins = await User.where('isAdmin', true).all()
|
|
87
|
-
const page = await User.paginate(1, 15) // { data, total, page, perPage, lastPage }
|
|
88
|
-
|
|
89
|
-
// Chained query builder
|
|
90
|
-
const results = await User.query()
|
|
91
|
-
.where('role', 'admin')
|
|
92
|
-
.where('active', true)
|
|
93
|
-
.orderBy('createdAt', 'desc')
|
|
94
|
-
.limit(10)
|
|
95
|
-
.all()
|
|
96
|
-
|
|
97
|
-
// Eager loading
|
|
98
|
-
const posts = await Post.with('author', 'comments').all()
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### 4. CRUD operations
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
// Create -- triggers creating/created observers
|
|
105
|
-
const post = await Post.create({ title: 'Hello', body: 'World', authorId: 1 })
|
|
106
|
-
|
|
107
|
-
// Update -- triggers updating/updated observers
|
|
108
|
-
const updated = await Post.update(post.id, { title: 'Updated' })
|
|
109
|
-
|
|
110
|
-
// Delete -- triggers deleting/deleted observers
|
|
111
|
-
await Post.delete(post.id)
|
|
112
|
-
|
|
113
|
-
// Soft delete (when softDeletes = true)
|
|
114
|
-
await Post.delete(post.id) // sets deletedAt
|
|
115
|
-
await Post.restore(post.id) // clears deletedAt
|
|
116
|
-
await Post.forceDelete(post.id) // permanent removal
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### 5. Scopes
|
|
120
|
-
|
|
121
|
-
```ts
|
|
122
|
-
export class Post extends Model {
|
|
123
|
-
static globalScopes = {
|
|
124
|
-
published: (q) => q.where('isPublished', true),
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
static scopes = {
|
|
128
|
-
byAuthor: (q, authorId: number) => q.where('authorId', authorId),
|
|
129
|
-
recent: (q) => q.orderBy('createdAt', 'desc').limit(10),
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Usage
|
|
134
|
-
const posts = await Post.query().scope('byAuthor', 1).scope('recent').all()
|
|
135
|
-
const allPosts = await Post.query().withoutGlobalScope('published').all()
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
### 6. Observers
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
import type { ModelObserver } from '@rudderjs/orm'
|
|
142
|
-
|
|
143
|
-
class PostObserver implements ModelObserver {
|
|
144
|
-
creating(data: Record<string, unknown>) {
|
|
145
|
-
data['slug'] = slugify(String(data['title']))
|
|
146
|
-
return data
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
deleted(id: string | number) {
|
|
150
|
-
console.log(`Post ${id} was deleted`)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
Post.observe(PostObserver)
|
|
155
|
-
|
|
156
|
-
// Or inline listeners
|
|
157
|
-
Post.on('creating', (data) => { data['slug'] = slugify(data['title']); return data })
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
### 7. Custom casts
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
import type { CastUsing } from '@rudderjs/orm'
|
|
164
|
-
|
|
165
|
-
class MoneyCast implements CastUsing {
|
|
166
|
-
get(key: string, value: unknown): number {
|
|
167
|
-
return Number(value) / 100 // stored as cents
|
|
168
|
-
}
|
|
169
|
-
set(key: string, value: unknown): number {
|
|
170
|
-
return Math.round(Number(value) * 100)
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
class Product extends Model {
|
|
175
|
-
static casts = { price: MoneyCast }
|
|
176
|
-
// Or decorator: @Cast(MoneyCast) price = 0
|
|
177
|
-
}
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
### 8. Model factories
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
import { ModelFactory, sequence } from '@rudderjs/orm'
|
|
184
|
-
|
|
185
|
-
class UserFactory extends ModelFactory<{ name: string; email: string; role: string }> {
|
|
186
|
-
protected modelClass = User
|
|
187
|
-
|
|
188
|
-
definition() {
|
|
189
|
-
return {
|
|
190
|
-
name: 'Alice',
|
|
191
|
-
email: sequence(i => `user${i}@example.com`),
|
|
192
|
-
role: 'user',
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
protected states() {
|
|
197
|
-
return {
|
|
198
|
-
admin: () => ({ role: 'admin' }),
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Usage
|
|
204
|
-
const user = await UserFactory.new().create()
|
|
205
|
-
const admin = await UserFactory.new().state('admin').create()
|
|
206
|
-
const users = await UserFactory.new().create(5) // 5 persisted
|
|
207
|
-
const dtos = await UserFactory.new().make(3) // 3 in-memory only
|
|
208
|
-
const custom = await UserFactory.new().with(() => ({ name: 'Bob' })).create()
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
### 9. API resources
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
import { JsonResource, ResourceCollection } from '@rudderjs/orm'
|
|
215
|
-
|
|
216
|
-
class UserResource extends JsonResource<User> {
|
|
217
|
-
toArray() {
|
|
218
|
-
return {
|
|
219
|
-
id: this.resource.id,
|
|
220
|
-
name: this.resource.name,
|
|
221
|
-
email: this.resource.email,
|
|
222
|
-
admin: this.when(this.resource.role === 'admin', true),
|
|
223
|
-
posts: this.whenLoaded('posts', PostResource.collection(this.resource.posts)),
|
|
224
|
-
...this.mergeWhen(this.resource.isAdmin, {
|
|
225
|
-
permissions: this.resource.permissions,
|
|
226
|
-
}),
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Single resource
|
|
232
|
-
res.json(new UserResource(user).toArray())
|
|
233
|
-
|
|
234
|
-
// Collection with pagination
|
|
235
|
-
const collection = UserResource.collection(users, { total: 100, page: 1, perPage: 15 })
|
|
236
|
-
res.json(await collection.toResponse())
|
|
237
|
-
// -> { data: [...], meta: { total: 100, page: 1, perPage: 15 } }
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
### 10. Instance serialization controls
|
|
241
|
-
|
|
242
|
-
```ts
|
|
243
|
-
const user = await User.find(1)
|
|
244
|
-
user.makeVisible('password') // show normally-hidden field
|
|
245
|
-
user.makeHidden('email') // hide for this instance
|
|
246
|
-
user.setVisible(['id', 'name']) // allowlist override
|
|
247
|
-
|
|
248
|
-
// On collections
|
|
249
|
-
const collection = ModelCollection.wrap(await User.all())
|
|
250
|
-
collection.makeHidden(['email'])
|
|
251
|
-
collection.modelKeys() // [1, 2, 3]
|
|
252
|
-
collection.find(2) // user with id 2
|
|
253
|
-
collection.except([1]) // all except id 1
|
|
254
|
-
```
|
|
32
|
+
- **Adapter pattern** — `ModelRegistry.set(adapter)` plugs in the DB driver. `@rudderjs/orm` has no runtime DB dependency.
|
|
33
|
+
- **Hydration** — every read (`find`/`first`/`all`/`where(...).get()`/`paginate`) returns Model instances, not plain records. Use `Model.hydrate(record)` to wrap external data (cached JSON, fixtures).
|
|
34
|
+
- **Mass assignment** — `static fillable` (allowlist) / `static guarded` (denylist; `['*']` locks all) drop keys outside policy on `create` / `update` / `fill`. `instance.forceFill` bypasses.
|
|
35
|
+
- **Observers** — `Model.create/update/delete` fire lifecycle events. `query().create()` bypasses them. `increment` / `decrement` deliberately do **not** fire — pure data-plane operations.
|
|
36
|
+
- **Built-in casts**: `'string'`, `'integer'`, `'float'`, `'boolean'`, `'date'`, `'datetime'`, `'json'`, `'array'`, `'collection'`, `'encrypted'`, `'encrypted:array'`, `'encrypted:object'`. Encrypted casts need `@rudderjs/crypt`.
|
|
255
37
|
|
|
256
38
|
## Examples
|
|
257
39
|
|
|
258
|
-
See `playground/app/Models/User.ts` for a working model and `playground/routes/console.ts` for seeding
|
|
259
|
-
|
|
260
|
-
## Common pitfalls
|
|
261
|
-
|
|
262
|
-
- **No adapter registered**: `ModelRegistry.getAdapter()` throws if no database provider is in the provider list. Ensure `DatabaseServiceProvider` boots before any model usage.
|
|
263
|
-
- **Observers vs query builder**: `Model.create()` / `Model.update()` / `Model.delete()` fire observer events. Using `Model.query().create()` directly bypasses observers.
|
|
264
|
-
- **Built-in casts**: Available types are `'string'`, `'integer'`, `'float'`, `'boolean'`, `'date'`, `'datetime'`, `'json'`, `'array'`, `'collection'`, `'encrypted'`, `'encrypted:array'`, `'encrypted:object'`. Encrypted casts require `@rudderjs/crypt`.
|
|
265
|
-
- **Visible vs hidden**: When `visible` is set (allowlist), `hidden` is ignored. Only one should be used per model.
|
|
266
|
-
- **sequence() in factories**: The `sequence()` helper returns a callable. Inside `definition()`, return it directly -- the factory resolves callables automatically.
|
|
267
|
-
- **Prisma schema**: Models map to Prisma models. Run `pnpm exec prisma generate` after schema changes and `pnpm exec prisma db push` to sync the DB.
|
|
40
|
+
See `playground/app/Models/User.ts` for a working model and `playground/routes/console.ts` for factory-based seeding.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# CRUD and Observers
|
|
2
|
+
|
|
3
|
+
## Create / update / delete
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// Create — triggers creating / created observer events
|
|
7
|
+
const post = await Post.create({ title: 'Hello', body: 'World', authorId: 1 })
|
|
8
|
+
|
|
9
|
+
// Update — triggers updating / updated
|
|
10
|
+
const updated = await Post.update(post.id, { title: 'Hello v2' })
|
|
11
|
+
|
|
12
|
+
// Delete — triggers deleting / deleted
|
|
13
|
+
await Post.delete(post.id)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Mass assignment (`fillable` / `guarded`) filters keys on all three. To bypass: `instance.forceFill(data)` or set properties directly and call `instance.save()`.
|
|
17
|
+
|
|
18
|
+
## firstOrCreate / updateOrCreate
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const tag = await Tag.firstOrCreate({ name: 'rust' }, { description: 'systems language' })
|
|
22
|
+
// SELECT, then INSERT if not found
|
|
23
|
+
|
|
24
|
+
const user = await User.updateOrCreate({ email }, { name, lastSeenAt: new Date() })
|
|
25
|
+
// SELECT, then INSERT or UPDATE
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
⚠️ Lookup keys (the first arg) go through `create()`, so they need to be **fillable** too — otherwise the lookup column isn't set on the new row.
|
|
29
|
+
|
|
30
|
+
## Atomic counters
|
|
31
|
+
|
|
32
|
+
For columns that change in concurrent writes (view counts, balances):
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
await Post.increment(postId, 'viewCount') // viewCount = viewCount + 1
|
|
36
|
+
await Post.decrement(postId, 'stock', 5) // stock = stock - 5
|
|
37
|
+
|
|
38
|
+
// Instance variant merges the resolved value back
|
|
39
|
+
await post.increment('viewCount') // post.viewCount reflects the update
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Observers do NOT fire** on increment/decrement — they're pure data-plane. If you need observer hooks, read the row, set the resolved value, and call `Model.update()` instead.
|
|
43
|
+
|
|
44
|
+
## Observers
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import type { ModelObserver } from '@rudderjs/orm'
|
|
48
|
+
|
|
49
|
+
class PostObserver implements ModelObserver {
|
|
50
|
+
creating(data: Record<string, unknown>) {
|
|
51
|
+
data['slug'] = slugify(String(data['title']))
|
|
52
|
+
return data
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
deleted(id: string | number) {
|
|
56
|
+
console.log(`Post ${id} was deleted`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Post.observe(PostObserver)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Inline listeners are also supported:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
Post.on('creating', (data) => {
|
|
67
|
+
data['slug'] = slugify(String(data['title']))
|
|
68
|
+
return data
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Events fired by lifecycle: `retrieved`, `creating` → `created`, `updating` → `updated`, `saving` → `saved` (on both create + update), `deleting` → `deleted`, `restoring` → `restored`.
|
|
73
|
+
|
|
74
|
+
## Soft delete + restore
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
class Post extends Model { static softDeletes = true }
|
|
78
|
+
|
|
79
|
+
await Post.delete(id) // sets deletedAt
|
|
80
|
+
await Post.restore(id) // clears deletedAt — fires restoring / restored
|
|
81
|
+
await Post.forceDelete(id) // hard delete — fires deleting / deleted with no soft-delete
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Pitfalls
|
|
85
|
+
|
|
86
|
+
❌ **Don't** rely on observers firing for `query().create()`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
await Post.query().create({ title }) // bypasses observers
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
✅ **Do** use the static method for observer-aware writes:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
await Post.create({ title }) // fires creating / created
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
❌ **Don't** expect observers on counter updates:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
class Post extends Model {
|
|
102
|
+
static observe = class {
|
|
103
|
+
updated(post: Post) { /* won't fire for increment() */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
✅ **Do** read + write through `update()` if you need hooks:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const post = await Post.find(id)
|
|
112
|
+
await Post.update(id, { viewCount: post.viewCount + 1 }) // fires updating / updated
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
❌ **Don't** rely on lookup keys being set if they're not fillable:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
class Tag extends Model {
|
|
119
|
+
static fillable = ['description'] // no 'name'
|
|
120
|
+
}
|
|
121
|
+
await Tag.firstOrCreate({ name: 'rust' }, { description: 'lang' }) // name dropped → new row missing name
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
✅ **Do** include lookup keys in `fillable`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
class Tag extends Model {
|
|
128
|
+
static fillable = ['name', 'description']
|
|
129
|
+
}
|
|
130
|
+
```
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Defining Models
|
|
2
|
+
|
|
3
|
+
## Basic shape
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Model } from '@rudderjs/orm'
|
|
7
|
+
|
|
8
|
+
export class Post extends Model {
|
|
9
|
+
static table = 'posts'
|
|
10
|
+
static fillable = ['title', 'body', 'authorId']
|
|
11
|
+
static hidden = ['internalNotes']
|
|
12
|
+
|
|
13
|
+
static casts = {
|
|
14
|
+
isPublished: 'boolean' as const,
|
|
15
|
+
publishedAt: 'datetime' as const,
|
|
16
|
+
metadata: 'json' as const,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Table defaults to lowercase class name + `'s'`. Override with `static table = 'my_table'`.
|
|
22
|
+
Primary key defaults to `'id'`. Override with `static primaryKey = 'uuid'`.
|
|
23
|
+
|
|
24
|
+
## Decorator syntax (alternative)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { Model, Hidden, Cast, Appends } from '@rudderjs/orm'
|
|
28
|
+
|
|
29
|
+
export class User extends Model {
|
|
30
|
+
static fillable = ['name', 'email', 'password']
|
|
31
|
+
|
|
32
|
+
@Hidden password = ''
|
|
33
|
+
@Cast('boolean') isAdmin = false
|
|
34
|
+
@Cast('date') createdAt = new Date()
|
|
35
|
+
@Appends fullName = ''
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pick **one style per model** — mixing decorators and `static` config for the same fields is confusing.
|
|
40
|
+
|
|
41
|
+
## Accessors and mutators
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { Model, Attribute } from '@rudderjs/orm'
|
|
45
|
+
|
|
46
|
+
export class User extends Model {
|
|
47
|
+
static attributes = {
|
|
48
|
+
fullName: Attribute.make({
|
|
49
|
+
get: (_v, attrs) => `${attrs['firstName']} ${attrs['lastName']}`,
|
|
50
|
+
}),
|
|
51
|
+
password: Attribute.make({
|
|
52
|
+
set: async (v) => (await import('bcrypt')).hash(String(v), 10),
|
|
53
|
+
}),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static appends = ['fullName'] // include in toJSON output
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Mutators (`set`) run on `Model.create` / `Model.update` / `instance.fill`. Accessors (`get`) run on serialization. Appended attributes need a matching `get` definition, otherwise the field is missing from output.
|
|
61
|
+
|
|
62
|
+
## Custom casts
|
|
63
|
+
|
|
64
|
+
For domain types (money, vectors, custom serialization):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import type { CastUsing } from '@rudderjs/orm'
|
|
68
|
+
|
|
69
|
+
class MoneyCast implements CastUsing {
|
|
70
|
+
get(_key: string, value: unknown): number { return Number(value) / 100 }
|
|
71
|
+
set(_key: string, value: unknown): number { return Math.round(Number(value) * 100) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Product extends Model {
|
|
75
|
+
static casts = { price: MoneyCast }
|
|
76
|
+
// or decorator: @Cast(MoneyCast) price = 0
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Soft deletes
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
export class Post extends Model {
|
|
84
|
+
static softDeletes = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await Post.delete(id) // sets deletedAt
|
|
88
|
+
await Post.restore(id) // clears deletedAt
|
|
89
|
+
await Post.forceDelete(id) // hard delete
|
|
90
|
+
await Post.query().withTrashed().all() // include soft-deleted
|
|
91
|
+
await Post.query().onlyTrashed().all() // only soft-deleted
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Pitfalls
|
|
95
|
+
|
|
96
|
+
❌ **Don't** set `static visible` AND `static hidden` on the same model:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
static visible = ['id', 'name']
|
|
100
|
+
static hidden = ['password'] // silently ignored when `visible` is set
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
✅ **Do** pick one. `visible` is a strict allowlist; `hidden` is a denylist.
|
|
104
|
+
|
|
105
|
+
❌ **Don't** type a relation field that shadows the runtime-installed accessor:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
class Post extends Model {
|
|
109
|
+
tags!: () => string[] // class field shadows the prototype method
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
✅ **Do** type the explicit override:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
class Post extends Model {
|
|
117
|
+
tags() { return Model.morphToMany(this, 'tags') }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
❌ **Don't** assume the SQL table name is the Prisma delegate:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
class OAuthClient extends Model {
|
|
125
|
+
static table = 'oauth_clients' // wrong — that's the @@map'd SQL name
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
✅ **Do** use the Prisma client delegate (camelCase of model name):
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
class OAuthClient extends Model {
|
|
133
|
+
static table = 'oAuthClient' // the Prisma delegate
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Error: `[RudderJS ORM] Prisma has no delegate for table "oauth_clients"` means you used the SQL name by mistake.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Model Factories
|
|
2
|
+
|
|
3
|
+
## Basic shape
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { ModelFactory, sequence } from '@rudderjs/orm'
|
|
7
|
+
import { User } from '../app/Models/User.js'
|
|
8
|
+
|
|
9
|
+
class UserFactory extends ModelFactory<{ name: string; email: string; role: string }> {
|
|
10
|
+
protected modelClass = User
|
|
11
|
+
|
|
12
|
+
definition() {
|
|
13
|
+
return {
|
|
14
|
+
name: 'Alice',
|
|
15
|
+
email: sequence(i => `user${i}@example.com`),
|
|
16
|
+
role: 'user',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected states() {
|
|
21
|
+
return {
|
|
22
|
+
admin: () => ({ role: 'admin' }),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const one = await UserFactory.new().create() // 1 row, persisted
|
|
32
|
+
const five = await UserFactory.new().create(5) // 5 rows
|
|
33
|
+
const dtos = await UserFactory.new().make(3) // 3 in-memory only
|
|
34
|
+
const admin = await UserFactory.new().state('admin').create()
|
|
35
|
+
const custom = await UserFactory.new().with(() => ({ name: 'Bob' })).create()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`.make()` does not write to the DB — useful for testing serialization, validation, or non-persisting code paths.
|
|
39
|
+
`.create()` writes via `Model.create()`, so observers / mutators / mass assignment all apply.
|
|
40
|
+
|
|
41
|
+
## sequence()
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
email: sequence(i => `user${i}@example.com`)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Inside `definition()`, return the call directly — the factory resolves callables for you. Each generated row gets the next index.
|
|
48
|
+
|
|
49
|
+
## Pitfalls
|
|
50
|
+
|
|
51
|
+
❌ **Don't** call `sequence(...)` outside `definition()`:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const s = sequence(i => i)
|
|
55
|
+
class UserFactory extends ModelFactory<{ n: number }> {
|
|
56
|
+
definition() { return { n: s() } } // shared sequence across factory instances
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
✅ **Do** return the sequence callable from `definition()`:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
definition() { return { n: sequence(i => i) } } // fresh per factory instance
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
❌ **Don't** assume `.make()` ran mutators:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const draft = await UserFactory.new().make()
|
|
70
|
+
// password mutator did NOT run because there's no save() — but accessors DID run on serialization
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
✅ **Do** call `.create()` when you need mutator side effects (password hashing, slug generation, etc.).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Querying
|
|
2
|
+
|
|
3
|
+
## Single-row reads
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const user = await User.find(1) // by primary key
|
|
7
|
+
const first = await User.first() // first row
|
|
8
|
+
const total = await User.count() // SELECT count(*)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Filtered reads
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const admins = await User.where('role', 'admin').all()
|
|
15
|
+
const recent = await User.where('createdAt', '>', oneWeekAgo).orderBy('createdAt', 'desc').limit(10).all()
|
|
16
|
+
const page = await User.paginate(1, 15)
|
|
17
|
+
// { data, total, page, perPage, lastPage }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`where` accepts `(column, value)` (defaults to `=`), `(column, op, value)`, or a callback for grouped predicates.
|
|
21
|
+
|
|
22
|
+
## Eager loading
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const posts = await Post.with('author', 'comments').all()
|
|
26
|
+
const user = await User.with({ posts: q => q.where('isPublished', true) }).find(1)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Whole-row eager loading is handled natively by the adapter (Prisma `include`, Drizzle `with`).
|
|
30
|
+
|
|
31
|
+
## Aggregate eager loading
|
|
32
|
+
|
|
33
|
+
Stays portable across adapters:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const users = await User.withCount('posts').all() // posts_count column
|
|
37
|
+
const authors = await Post.withSum('viewCount', 'views').all() // posts_sum_views
|
|
38
|
+
const post = await Post.find(1).then(p => p.loadCount('comments')) // per-instance
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`withCount` on `belongsTo` and `morphTo` throws — you can't count something there's exactly one of (or whose target table is dynamic).
|
|
42
|
+
|
|
43
|
+
## Scopes
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export class Post extends Model {
|
|
47
|
+
static globalScopes = {
|
|
48
|
+
published: (q) => q.where('isPublished', true), // ALWAYS applied
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static scopes = {
|
|
52
|
+
byAuthor: (q, authorId: number) => q.where('authorId', authorId),
|
|
53
|
+
recent: (q) => q.orderBy('createdAt', 'desc').limit(10),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const posts = await Post.query().scope('byAuthor', 1).scope('recent').all()
|
|
58
|
+
const allPosts = await Post.query().withoutGlobalScope('published').all()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Relation predicates (`whereHas`)
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Users who have at least one published post
|
|
65
|
+
const authors = await User.query()
|
|
66
|
+
.whereHas('posts', q => q.where('isPublished', true))
|
|
67
|
+
.all()
|
|
68
|
+
|
|
69
|
+
// Users who have no comments
|
|
70
|
+
const lurkers = await User.query().whereDoesntHave('comments').all()
|
|
71
|
+
|
|
72
|
+
// Eager-load with the same constraint
|
|
73
|
+
const data = await User.query()
|
|
74
|
+
.withWhereHas('posts', q => q.where('isPublished', true))
|
|
75
|
+
.all()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Pitfalls
|
|
79
|
+
|
|
80
|
+
❌ **Don't** use `eq(col, null)` for null checks:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// drizzle-orm
|
|
84
|
+
qb.where(eq(users.deletedAt, null)) // never matches anything
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
✅ **Do** use `isNull` / `isNotNull`:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
qb.where(isNull(users.deletedAt))
|
|
91
|
+
qb.where(isNotNull(users.deletedAt))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
❌ **Don't** assume `assert.deepStrictEqual(result, plainObject)` holds since hydration shipped:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const user = await User.find(1)
|
|
98
|
+
assert.deepStrictEqual(user, { id: 1, name: 'Alice' }) // ❌ prototype mismatch
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
✅ **Do** compare via spread or assert `instanceof`:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
assert.deepStrictEqual({ ...user }, { id: 1, name: 'Alice' })
|
|
105
|
+
assert.ok(user instanceof User)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
❌ **Don't** use `morphTo` with `whereHas` — the related table is dynamic.
|
|
109
|
+
|
|
110
|
+
✅ **Do** filter on the morph columns directly:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
await Comment.query()
|
|
114
|
+
.where('commentableType', 'Post')
|
|
115
|
+
.where('commentableId', 1)
|
|
116
|
+
.all()
|
|
117
|
+
```
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# API Resources
|
|
2
|
+
|
|
3
|
+
`JsonResource` is the controller-friendly way to shape model output for an HTTP response — without leaking columns that shouldn't reach the client.
|
|
4
|
+
|
|
5
|
+
## Single resource
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { JsonResource } from '@rudderjs/orm'
|
|
9
|
+
|
|
10
|
+
class UserResource extends JsonResource<User> {
|
|
11
|
+
toArray() {
|
|
12
|
+
return {
|
|
13
|
+
id: this.resource.id,
|
|
14
|
+
name: this.resource.name,
|
|
15
|
+
email: this.resource.email,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// In a route handler
|
|
21
|
+
res.json(new UserResource(user).toArray())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Conditional fields
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
class UserResource extends JsonResource<User> {
|
|
28
|
+
toArray() {
|
|
29
|
+
return {
|
|
30
|
+
id: this.resource.id,
|
|
31
|
+
name: this.resource.name,
|
|
32
|
+
|
|
33
|
+
// Include only when condition is true
|
|
34
|
+
admin: this.when(this.resource.role === 'admin', true),
|
|
35
|
+
|
|
36
|
+
// Include only when the relation was eager-loaded
|
|
37
|
+
posts: this.whenLoaded('posts', PostResource.collection(this.resource.posts)),
|
|
38
|
+
|
|
39
|
+
// Merge multiple fields conditionally
|
|
40
|
+
...this.mergeWhen(this.resource.isAdmin, {
|
|
41
|
+
permissions: this.resource.permissions,
|
|
42
|
+
lastLogin: this.resource.lastLoginAt,
|
|
43
|
+
}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`whenLoaded` is the canonical guard against N+1 — the field stays absent if the caller didn't `.with('posts')`.
|
|
50
|
+
|
|
51
|
+
## Collections
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const users = await User.with('posts').all()
|
|
55
|
+
|
|
56
|
+
const collection = UserResource.collection(users, {
|
|
57
|
+
total: 100,
|
|
58
|
+
page: 1,
|
|
59
|
+
perPage: 15,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
res.json(await collection.toResponse())
|
|
63
|
+
// {
|
|
64
|
+
// data: [...],
|
|
65
|
+
// meta: { total: 100, page: 1, perPage: 15 }
|
|
66
|
+
// }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For a paginated query:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const page = await User.paginate(1, 15)
|
|
73
|
+
const collection = UserResource.collection(page.data, {
|
|
74
|
+
total: page.total,
|
|
75
|
+
page: page.page,
|
|
76
|
+
perPage: page.perPage,
|
|
77
|
+
lastPage: page.lastPage,
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Pitfalls
|
|
82
|
+
|
|
83
|
+
❌ **Don't** assume `whenLoaded` works without `.with()`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const user = await User.find(1) // posts NOT loaded
|
|
87
|
+
const res = new UserResource(user).toArray()
|
|
88
|
+
// res.posts is omitted — that's correct, but the caller may have expected it
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
✅ **Do** eager-load when the resource needs the relation:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const user = await User.with('posts').find(1)
|
|
95
|
+
const res = new UserResource(user).toArray()
|
|
96
|
+
// res.posts is present
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
❌ **Don't** use `when` for relations:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
posts: this.when(this.resource.posts !== undefined, PostResource.collection(this.resource.posts))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
✅ **Do** use `whenLoaded` — it's the relation-aware variant:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
posts: this.whenLoaded('posts', PostResource.collection(this.resource.posts))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
❌ **Don't** mutate `this.resource` inside `toArray()` — observers / mutators won't fire and you risk a stale state on the next access. Compute derived values and return them; don't write to the model.
|