@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,692 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Queries
|
|
3
|
+
description: Read and write model records with a fluent, type-aware query builder.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Queries
|
|
7
|
+
|
|
8
|
+
The query builder is a fluent, type-aware API over your database tables. This page
|
|
9
|
+
covers reading and writing records, the full builder surface, instance methods, and
|
|
10
|
+
query scopes — everything you reach for after [defining a model](/docs/orm).
|
|
11
|
+
|
|
12
|
+
`Model` static methods (`find`, `create`, …) cover the common cases; `Model.query()`
|
|
13
|
+
returns a [`ModelQueryBuilder`](#references) for anything more complex. The same
|
|
14
|
+
fluent API is available on `DB.table()` for unmodelled tables (see
|
|
15
|
+
[Query builder](/docs/query-builder)).
|
|
16
|
+
|
|
17
|
+
## Basic finders
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// in a controller or service
|
|
21
|
+
// By primary key — returns null if not found
|
|
22
|
+
const user = await User.find(1);
|
|
23
|
+
|
|
24
|
+
// By primary key — throws ModelNotFoundError if not found
|
|
25
|
+
const user = await User.findOrFail(1);
|
|
26
|
+
|
|
27
|
+
// By any column — returns null if not found
|
|
28
|
+
const user = await User.findBy("email", "alice@example.com");
|
|
29
|
+
|
|
30
|
+
// Multiple by IDs
|
|
31
|
+
const users = await User.findMany([1, 2, 3]);
|
|
32
|
+
|
|
33
|
+
// All rows
|
|
34
|
+
const users = await User.all();
|
|
35
|
+
|
|
36
|
+
// First matching row — null if not found
|
|
37
|
+
const admin = await User.query().where("role", "admin").first();
|
|
38
|
+
|
|
39
|
+
// First matching row — throws ModelNotFoundError
|
|
40
|
+
const post = await Post.query().where("slug", "hello-world").firstOrFail();
|
|
41
|
+
|
|
42
|
+
// All matching rows
|
|
43
|
+
const admins = await User.query().where("role", "admin").get<User>();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Note** — `findOrFail`, `firstOrFail`, and `findManyOrFail`-style calls throw
|
|
47
|
+
> `ModelNotFoundError` from `@zerotal/orm`, which the HTTP layer renders as a 404.
|
|
48
|
+
|
|
49
|
+
## Create, update, delete
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// in a controller or service
|
|
53
|
+
// INSERT a single record
|
|
54
|
+
const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
55
|
+
|
|
56
|
+
// INSERT multiple records
|
|
57
|
+
const posts = await Post.createMany([{ title: "A" }, { title: "B" }]);
|
|
58
|
+
|
|
59
|
+
// UPDATE via instance
|
|
60
|
+
user.name = "Alice Smith";
|
|
61
|
+
await user.save();
|
|
62
|
+
|
|
63
|
+
// Mass UPDATE — all matching rows
|
|
64
|
+
await User.query().where("role", "guest").update({ role: "user" });
|
|
65
|
+
|
|
66
|
+
// Delete a single row
|
|
67
|
+
await post.delete();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
By default `delete()` removes the row permanently. Opt a model into **soft deletes**
|
|
71
|
+
by composing the `SoftDeletes` mixin — only then do `delete()` set `deleted_at`,
|
|
72
|
+
`forceDelete()`, and `restore()` apply:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// app/models/Post.ts
|
|
76
|
+
import { Model, SoftDeletes, table, column } from "@zerotal/orm";
|
|
77
|
+
|
|
78
|
+
@table("posts")
|
|
79
|
+
export class Post extends Model.using(SoftDeletes) {
|
|
80
|
+
@column() title!: string;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// in a controller or service
|
|
86
|
+
// Soft delete — sets deleted_at; row hidden from default queries
|
|
87
|
+
await post.delete();
|
|
88
|
+
|
|
89
|
+
// Hard delete — bypasses soft deletes, removes the row permanently
|
|
90
|
+
await post.forceDelete();
|
|
91
|
+
|
|
92
|
+
// Restore a soft-deleted row (deleted_at = NULL)
|
|
93
|
+
await post.restore();
|
|
94
|
+
|
|
95
|
+
// Query including soft-deleted rows
|
|
96
|
+
await Post.withTrashed().get();
|
|
97
|
+
|
|
98
|
+
// Only soft-deleted rows
|
|
99
|
+
await Post.onlyTrashed().get();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> **Warning** — `withTrashed()` and `onlyTrashed()` are **static** methods added by
|
|
103
|
+
> the `SoftDeletes` mixin (`Post.withTrashed()`), not chainable off `Post.query()`.
|
|
104
|
+
> A plain `Model` has no soft-delete API at all — `delete()` is permanent.
|
|
105
|
+
|
|
106
|
+
## Upsert
|
|
107
|
+
|
|
108
|
+
`upsert` inserts a row, or updates the named columns when a conflict on `conflictKeys`
|
|
109
|
+
occurs:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
function upsert(
|
|
113
|
+
data: InsertPayload<T>,
|
|
114
|
+
conflictKeys: (keyof T & string)[],
|
|
115
|
+
updateCols?: (keyof T & string)[],
|
|
116
|
+
): Promise<void>;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// in a controller or service
|
|
121
|
+
// Conflict on email → update name and role; omit updateCols to update every column
|
|
122
|
+
await User.upsert(
|
|
123
|
+
{ email: "alice@example.com", name: "Alice", role: "admin" },
|
|
124
|
+
["email"], // conflict columns
|
|
125
|
+
["name", "role"], // columns to update on conflict (optional)
|
|
126
|
+
);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
> **Note** — `upsert` is a `Model` static. For unmodelled tables, perform the
|
|
130
|
+
> insert/update explicitly via `DB.table()` — the raw query builder has no `upsert`
|
|
131
|
+
> helper.
|
|
132
|
+
|
|
133
|
+
## Convenience finders
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// in a controller or service
|
|
137
|
+
// Find or create — returns the existing or newly-created instance
|
|
138
|
+
const user = await User.firstOrCreate(
|
|
139
|
+
{ email: "alice@example.com" }, // search criteria
|
|
140
|
+
{ name: "Alice", role: "user" }, // defaults if creating
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Update existing, or create if not found
|
|
144
|
+
await User.updateOrCreate(
|
|
145
|
+
{ email: "alice@example.com" },
|
|
146
|
+
{ name: "Alice", lastSeenAt: new Date() },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Build a new (unsaved) instance if not found — does not save automatically
|
|
150
|
+
const user = await User.firstOrNew({ email: "alice@example.com" }, { name: "Alice" });
|
|
151
|
+
if (!user.id) await user.save();
|
|
152
|
+
|
|
153
|
+
// Find by PK, build a new (unsaved) instance if not found
|
|
154
|
+
const user = await User.findOrNew(1);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Query builder
|
|
158
|
+
|
|
159
|
+
`Model.query()` returns a `ModelQueryBuilder`. Most methods are also available on
|
|
160
|
+
`DB.table()` for unmodelled raw queries — see [Query builder](/docs/query-builder).
|
|
161
|
+
|
|
162
|
+
### Filtering
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// in a controller or service
|
|
166
|
+
Post.query()
|
|
167
|
+
.where("status", "published")
|
|
168
|
+
.where("views", ">", 100)
|
|
169
|
+
.orWhere("featured", true)
|
|
170
|
+
.whereIn("tag_id", [1, 2, 3])
|
|
171
|
+
.whereNotIn("status", ["draft", "archived"])
|
|
172
|
+
.whereNull("deleted_at")
|
|
173
|
+
.whereNotNull("published_at")
|
|
174
|
+
.whereBetween("views", [100, 1000])
|
|
175
|
+
.whereDate("created_at", "2024-01-01")
|
|
176
|
+
.whereColumn("updated_at", ">", "created_at")
|
|
177
|
+
.whereLike("title", "%zerotal%")
|
|
178
|
+
.whereAny(["title", "body"], "LIKE", "%zerotal%") // match any column
|
|
179
|
+
.whereAll(["title", "body"], "!=", "") // all columns must match
|
|
180
|
+
.whereExists((q) => q.from("comments").whereColumn("comments.post_id", "posts.id"))
|
|
181
|
+
.whereNotExists((q) => /* … */ undefined)
|
|
182
|
+
.whereRaw("LOWER(title) LIKE ?", ["%zerotal%"]);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Selecting
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// in a controller or service
|
|
189
|
+
Post.query().select("id", "title", "slug").get();
|
|
190
|
+
Post.query().selectRaw("COUNT(*) as total, MAX(score) as top").get();
|
|
191
|
+
Post.query().distinct().select("user_id").get();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Ordering and limits
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// in a controller or service
|
|
198
|
+
Post.query().orderBy("published_at", "desc").orderBy("id", "asc").limit(10).offset(20).get();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Joins
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
// in a controller or service
|
|
205
|
+
// Inner join
|
|
206
|
+
Post.query()
|
|
207
|
+
.join("users", "posts.user_id", "=", "users.id")
|
|
208
|
+
.select("posts.*", "users.name as authorName")
|
|
209
|
+
.get();
|
|
210
|
+
|
|
211
|
+
// Left / right / cross joins
|
|
212
|
+
Post.query().leftJoin("comments", "posts.id", "=", "comments.post_id").get();
|
|
213
|
+
Post.query().rightJoin("users", "posts.user_id", "=", "users.id").get();
|
|
214
|
+
Post.query().crossJoin("tags").get();
|
|
215
|
+
|
|
216
|
+
// Subquery join
|
|
217
|
+
Post.query()
|
|
218
|
+
.joinSub(
|
|
219
|
+
DB.table("comments").selectRaw("post_id, COUNT(*) as comment_count").groupBy("post_id"),
|
|
220
|
+
"comment_stats",
|
|
221
|
+
"posts.id",
|
|
222
|
+
"=",
|
|
223
|
+
"comment_stats.post_id",
|
|
224
|
+
)
|
|
225
|
+
.select("posts.*", "comment_stats.comment_count")
|
|
226
|
+
.get();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Grouping and aggregates
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// in a controller or service
|
|
233
|
+
// Terminal aggregates — return a single value
|
|
234
|
+
const total = await Post.query().where("status", "published").count();
|
|
235
|
+
const views = await Post.query().sum("views");
|
|
236
|
+
const avg = await Post.query().avg("score");
|
|
237
|
+
const lowest = await Post.query().min("price");
|
|
238
|
+
const peak = await Post.query().max("price");
|
|
239
|
+
|
|
240
|
+
// GROUP BY + HAVING
|
|
241
|
+
await DB.table("posts")
|
|
242
|
+
.select("user_id")
|
|
243
|
+
.selectRaw("COUNT(*) as total")
|
|
244
|
+
.groupBy("user_id")
|
|
245
|
+
.having("total", ">", 5)
|
|
246
|
+
.get();
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Subquery aggregates on results
|
|
250
|
+
|
|
251
|
+
Load aggregate values alongside model instances without extra queries:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
// in a controller or service
|
|
255
|
+
const posts = await Post.query()
|
|
256
|
+
.withCount("comments")
|
|
257
|
+
.withCount({ comments: (q) => q.where("approved", true) }) // filtered count
|
|
258
|
+
.withSum("comments", "votes")
|
|
259
|
+
.withAvg("comments", "rating")
|
|
260
|
+
.withMin("comments", "created_at")
|
|
261
|
+
.withMax("comments", "created_at")
|
|
262
|
+
.get();
|
|
263
|
+
|
|
264
|
+
posts[0].commentsCount; // number
|
|
265
|
+
posts[0].commentsSum_votes; // number | null
|
|
266
|
+
posts[0].commentsAvg_rating; // number | null
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Relation existence filtering
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// in a controller or service
|
|
273
|
+
// Posts that have at least one comment
|
|
274
|
+
Post.query().has("comments").get();
|
|
275
|
+
|
|
276
|
+
// Posts with 3+ comments
|
|
277
|
+
Post.query().has("comments", ">=", 3).get();
|
|
278
|
+
|
|
279
|
+
// Posts with no comments
|
|
280
|
+
Post.query().doesntHave("comments").get();
|
|
281
|
+
|
|
282
|
+
// Posts with at least one approved comment
|
|
283
|
+
Post.query()
|
|
284
|
+
.whereHas("comments", (q) => q.where("approved", true))
|
|
285
|
+
.get();
|
|
286
|
+
|
|
287
|
+
// Posts without any approved comment
|
|
288
|
+
Post.query()
|
|
289
|
+
.whereDoesntHave("comments", (q) => q.where("approved", true))
|
|
290
|
+
.get();
|
|
291
|
+
|
|
292
|
+
// Filter by relation AND eager-load with the same constraint
|
|
293
|
+
Post.query()
|
|
294
|
+
.withWhereHas("comments", (q) => q.where("approved", true))
|
|
295
|
+
.get();
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Conditional query building
|
|
299
|
+
|
|
300
|
+
Build queries dynamically based on optional inputs without branching `if` statements:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// in a controller
|
|
304
|
+
const posts = await Post.query()
|
|
305
|
+
.when(ctx.query("status"), (q, status) => q.where("status", status))
|
|
306
|
+
.when(ctx.query("author"), (q, author) => q.where("user_id", author))
|
|
307
|
+
.when(ctx.query("q"), (q, term) => q.whereLike("title", `%${term}%`))
|
|
308
|
+
.orderBy("created_at", "desc")
|
|
309
|
+
.paginate(20, ctx.query("page", 1));
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`.when(condition, callback)` only calls the callback when `condition` is truthy,
|
|
313
|
+
making it easy to chain optional filters.
|
|
314
|
+
|
|
315
|
+
### Pessimistic locking
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// in a controller or service
|
|
319
|
+
// Exclusive write lock — SELECT … FOR UPDATE
|
|
320
|
+
await DB.transaction(async (trx) => {
|
|
321
|
+
const user = await User.query().where("id", 1).lockForUpdate().first();
|
|
322
|
+
user.balance -= 100;
|
|
323
|
+
await user.save();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Shared read lock — SELECT … LOCK IN SHARE MODE
|
|
327
|
+
const post = await Post.query().where("id", postId).sharedLock().first();
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
> **Warning** — Row locks only hold inside a transaction. Call `lockForUpdate()` /
|
|
331
|
+
> `sharedLock()` within `DB.transaction()` (see [Database](/docs/database)), or the
|
|
332
|
+
> lock is released the moment the statement returns.
|
|
333
|
+
|
|
334
|
+
## Pagination
|
|
335
|
+
|
|
336
|
+
### Which pagination should I use?
|
|
337
|
+
|
|
338
|
+
| Strategy | Method | Best for |
|
|
339
|
+
| ----------------- | ---------------- | ------------------------------------------------------- |
|
|
340
|
+
| Offset (page no.) | `paginate` | Small/medium tables where users jump to any page |
|
|
341
|
+
| Cursor (last ID) | `cursorPaginate` | Simple "load more" feeds, stable across inserts |
|
|
342
|
+
| Keyset (indexed) | `keysetPaginate` | Infinite scroll and large datasets — scales to any size |
|
|
343
|
+
|
|
344
|
+
### Offset pagination
|
|
345
|
+
|
|
346
|
+
Classic page-number pagination. Best for small-to-medium tables where users jump to
|
|
347
|
+
arbitrary pages:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
// in a controller
|
|
351
|
+
const page = await Post.query()
|
|
352
|
+
.where("status", "published")
|
|
353
|
+
.orderBy("published_at", "desc")
|
|
354
|
+
.paginate(10, pageNumber); // (perPage, page)
|
|
355
|
+
|
|
356
|
+
// page.data — Post[]
|
|
357
|
+
// page.total — total row count
|
|
358
|
+
// page.page — current page number
|
|
359
|
+
// page.perPage — rows per page
|
|
360
|
+
// page.lastPage — last page number
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Cursor pagination
|
|
364
|
+
|
|
365
|
+
Simple, performant pagination using the last-seen ID as a cursor. Stable against
|
|
366
|
+
inserts/deletes between pages:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// in a controller
|
|
370
|
+
const p1 = await Post.query().cursorPaginate({ limit: 20 });
|
|
371
|
+
const p2 = await Post.query().cursorPaginate({ cursor: p1.nextCursor, limit: 20 });
|
|
372
|
+
|
|
373
|
+
// p1.data — Post[]
|
|
374
|
+
// p1.nextCursor — number | null
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Keyset pagination
|
|
378
|
+
|
|
379
|
+
Scales to any table size. Uses an indexed column value as the cursor instead of an
|
|
380
|
+
offset. The best choice for infinite scroll and large datasets:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
// in a controller
|
|
384
|
+
const p1 = await Post.query()
|
|
385
|
+
.where("status", "published")
|
|
386
|
+
.keysetPaginate({ column: "published_at", direction: "desc", limit: 20 });
|
|
387
|
+
|
|
388
|
+
const p2 = await Post.query()
|
|
389
|
+
.where("status", "published")
|
|
390
|
+
.keysetPaginate({ column: "published_at", direction: "desc", limit: 20, cursor: p1.nextCursor });
|
|
391
|
+
|
|
392
|
+
// p1.data — Post[]
|
|
393
|
+
// p1.nextCursor — opaque base64 string | null
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`keysetPaginate` options:
|
|
397
|
+
|
|
398
|
+
| Option | Default | Description |
|
|
399
|
+
| ----------- | ------- | --------------------------------------------------- |
|
|
400
|
+
| `column` | `'id'` | The indexed column to paginate by |
|
|
401
|
+
| `direction` | `'asc'` | `'asc'` or `'desc'` |
|
|
402
|
+
| `limit` | `15` | Rows per page |
|
|
403
|
+
| `cursor` | — | Opaque string from the previous page's `nextCursor` |
|
|
404
|
+
|
|
405
|
+
Non-unique columns automatically get a compound `id` tiebreaker to ensure stable
|
|
406
|
+
ordering.
|
|
407
|
+
|
|
408
|
+
## Chunking and streaming
|
|
409
|
+
|
|
410
|
+
Use these for large datasets to avoid loading thousands of rows into memory at once:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// in a console command or job
|
|
414
|
+
// Process in fixed-size batches
|
|
415
|
+
await Post.query().chunk(100, async (posts) => {
|
|
416
|
+
for (const post of posts) await index(post);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Chunk by primary key — stable even if rows are inserted/deleted mid-run
|
|
420
|
+
await Post.query().chunkById(100, async (posts) => {
|
|
421
|
+
for (const post of posts) await sendEmail(post);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Async generator — pull one row at a time
|
|
425
|
+
for await (const post of Post.query().lazy()) {
|
|
426
|
+
await process(post);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// cursor() is an alias for lazy()
|
|
430
|
+
for await (const post of Post.query().cursor()) {
|
|
431
|
+
await process(post);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Callback per row — simpler than for-await for linear processing
|
|
435
|
+
await Post.query().each(async (post) => {
|
|
436
|
+
await process(post);
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Debugging queries
|
|
441
|
+
|
|
442
|
+
The builder compiles to SQL without executing, so you can inspect exactly what will
|
|
443
|
+
run:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
// in a controller or service
|
|
447
|
+
// Compiled SQL with `?` placeholders (no bindings)
|
|
448
|
+
const sql = Post.query().where("status", "published").toSql();
|
|
449
|
+
console.log(sql); // SELECT * FROM posts WHERE status = ?
|
|
450
|
+
|
|
451
|
+
// SQL plus the bound values
|
|
452
|
+
const { sql, bindings } = Post.query().where("status", "published").toSqlWithBindings();
|
|
453
|
+
console.log(bindings); // ['published']
|
|
454
|
+
|
|
455
|
+
// SQL with bindings inlined — for logging only, NOT safe to execute
|
|
456
|
+
const raw = Post.query().where("status", "published").toRawSql();
|
|
457
|
+
|
|
458
|
+
// Log the compiled SQL + bindings and keep chaining
|
|
459
|
+
Post.query().where("active", 1).dump().get();
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
> **Danger** — `toRawSql()` inlines values into the SQL string and is for logging
|
|
463
|
+
> only. Never feed its output back to the database — it bypasses parameterisation
|
|
464
|
+
> and is vulnerable to SQL injection.
|
|
465
|
+
|
|
466
|
+
Clone a base query to reuse it with different conditions:
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
// in a controller or service
|
|
470
|
+
const base = Post.query().where("active", 1);
|
|
471
|
+
const admins = await base.clone().where("role", "admin").get();
|
|
472
|
+
const editors = await base.clone().where("role", "editor").get();
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Instance methods
|
|
476
|
+
|
|
477
|
+
### Loading and refreshing
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
// in a controller or service
|
|
481
|
+
// Reload a fresh copy from the database (returns a new instance, doesn't mutate)
|
|
482
|
+
const fresh = await post.fresh();
|
|
483
|
+
|
|
484
|
+
// Reload into the same instance (mutates in place)
|
|
485
|
+
await post.refresh();
|
|
486
|
+
|
|
487
|
+
// Lazy-load relations onto an existing instance
|
|
488
|
+
await post.load(["author", "comments"]);
|
|
489
|
+
|
|
490
|
+
// Load relations only if not already loaded
|
|
491
|
+
await post.loadMissing(["author"]);
|
|
492
|
+
|
|
493
|
+
// Load aggregate values onto an instance
|
|
494
|
+
await post.loadCount("comments");
|
|
495
|
+
await post.loadCount(["comments", "likes"]);
|
|
496
|
+
await post.loadSum("comments", "votes");
|
|
497
|
+
await post.loadAvg("comments", "rating");
|
|
498
|
+
await post.loadMin("comments", "score");
|
|
499
|
+
await post.loadMax("comments", "score");
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Dirty tracking
|
|
503
|
+
|
|
504
|
+
Know which fields have changed since the last database read or save:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
// in a controller or service
|
|
508
|
+
post.name = "Changed";
|
|
509
|
+
|
|
510
|
+
post.isDirty(); // true — at least one column changed
|
|
511
|
+
post.isDirty("name"); // true — specifically "name" changed
|
|
512
|
+
post.isDirty("email"); // false — "email" is unchanged
|
|
513
|
+
|
|
514
|
+
// Force a field dirty even if its value hasn't changed:
|
|
515
|
+
post.markDirty("slug");
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
> **Warning** — Dirty means _changed since **this instance** was loaded_, not
|
|
519
|
+
> "differs from the database". If another code path updated the row after your
|
|
520
|
+
> instance was loaded, assigning the value your instance already holds is not
|
|
521
|
+
> dirty — `save()` writes nothing, silently:
|
|
522
|
+
>
|
|
523
|
+
> ```typescript
|
|
524
|
+
> const quote = await Quote.find(id); // status: "DRAFT"
|
|
525
|
+
> await markSent(id); // …loads its own copy, sets status: "SENT"
|
|
526
|
+
> quote.status = "DRAFT"; // matches this instance's value → not dirty
|
|
527
|
+
> await quote.save(); // no UPDATE — the row stays "SENT"
|
|
528
|
+
> ```
|
|
529
|
+
>
|
|
530
|
+
> When an instance may be stale — it crossed a service boundary, or time passed
|
|
531
|
+
> since the load — either `await quote.refresh()` before mutating, or
|
|
532
|
+
> `quote.markDirty("status")` to force the write. Services that accept an id and
|
|
533
|
+
> load their own fresh copy sidestep the problem entirely.
|
|
534
|
+
|
|
535
|
+
### Incrementing and touch
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
// in a controller or service
|
|
539
|
+
await post.increment("views"); // +1
|
|
540
|
+
await post.increment("views", 5); // +5
|
|
541
|
+
await post.decrement("stock", 2); // -2
|
|
542
|
+
|
|
543
|
+
// Update updated_at without changing any other field
|
|
544
|
+
await post.touch();
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Comparison and copying
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
// in a controller or service
|
|
551
|
+
// True if both are the same model class with the same primary key
|
|
552
|
+
post.is(otherPost); // boolean
|
|
553
|
+
post.isNot(otherPost); // boolean
|
|
554
|
+
|
|
555
|
+
// Duplicate the instance — new unsaved record, no id/timestamps
|
|
556
|
+
const copy = post.replicate();
|
|
557
|
+
copy.title = "Copy of " + post.title;
|
|
558
|
+
await copy.save();
|
|
559
|
+
|
|
560
|
+
// Exclude specific columns from the replica
|
|
561
|
+
const copy = post.replicate(["slug", "viewCount"]);
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Saving without updating timestamps
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
// in a controller or service
|
|
568
|
+
await User.withoutTimestamps(async () => {
|
|
569
|
+
user.role = "admin";
|
|
570
|
+
await user.save(); // updated_at is NOT changed
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Query scopes
|
|
575
|
+
|
|
576
|
+
### Named scopes
|
|
577
|
+
|
|
578
|
+
Group reusable query constraints on the model itself with `Model.scope`:
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
// app/models/Post.ts
|
|
582
|
+
import { Model, table } from "@zerotal/orm";
|
|
583
|
+
|
|
584
|
+
@table("posts")
|
|
585
|
+
export class Post extends Model {
|
|
586
|
+
static published = Model.scope((q) => q.whereNotNull("published_at"));
|
|
587
|
+
|
|
588
|
+
static byAuthor = Model.scope((q, userId: number) => q.where("user_id", userId));
|
|
589
|
+
|
|
590
|
+
static recent = Model.scope((q, days = 7) => {
|
|
591
|
+
const cutoff = new Date();
|
|
592
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
593
|
+
q.where("created_at", ">", cutoff.toISOString());
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Apply them via `withScopes()`. The callback receives a proxy whose methods invoke each
|
|
599
|
+
scope in turn — call them as separate statements (the proxy methods return `void`, so
|
|
600
|
+
they do not chain):
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
// in a controller
|
|
604
|
+
const posts = await Post.query()
|
|
605
|
+
.withScopes((s) => {
|
|
606
|
+
s.published();
|
|
607
|
+
s.byAuthor(http.user!.id);
|
|
608
|
+
s.recent(30);
|
|
609
|
+
})
|
|
610
|
+
.orderBy("published_at", "desc")
|
|
611
|
+
.paginate(20, 1);
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### Global scopes
|
|
615
|
+
|
|
616
|
+
Global scopes are applied automatically to every query on the model. Register them in
|
|
617
|
+
a [service provider's](/docs/providers) `onBooting()`:
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
// in AppServiceProvider.onBooting()
|
|
621
|
+
Post.addGlobalScope("tenant", (q) => q.where("tenant_id", currentTenantId()));
|
|
622
|
+
|
|
623
|
+
// Disable for a single query
|
|
624
|
+
Post.query().withoutGlobalScope("tenant").get();
|
|
625
|
+
Post.query().withoutGlobalScopes().get(); // disable all global scopes
|
|
626
|
+
|
|
627
|
+
// Remove permanently (until the next boot)
|
|
628
|
+
Post.removeGlobalScope("tenant");
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Child models inherit all global scopes registered on a parent model.
|
|
632
|
+
|
|
633
|
+
> **Tip** — Global scopes are the backbone of [multi-tenancy](/docs/tenancy): a single
|
|
634
|
+
> `tenant` scope keeps every query partitioned without touching call sites.
|
|
635
|
+
|
|
636
|
+
## References
|
|
637
|
+
|
|
638
|
+
`Model` statics — the entry points for reads and writes:
|
|
639
|
+
|
|
640
|
+
| Method | Signature | Description |
|
|
641
|
+
| ------------------- | -------------------------------------------------------- | ---------------------------------------------------- |
|
|
642
|
+
| `find` | `find(id): Promise<T \| null>` | Fetch by primary key, or `null`. |
|
|
643
|
+
| `findOrFail` | `findOrFail(id): Promise<T>` | Fetch by primary key, or throw `ModelNotFoundError`. |
|
|
644
|
+
| `findBy` | `findBy(column, value): Promise<T \| null>` | Fetch the first row matching a column. |
|
|
645
|
+
| `findMany` | `findMany(ids): Promise<T[]>` | Fetch multiple rows by primary key. |
|
|
646
|
+
| `all` | `all(): Promise<T[]>` | Fetch every row. |
|
|
647
|
+
| `create` | `create(data): Promise<T>` | Insert and return the new instance. |
|
|
648
|
+
| `createMany` | `createMany(rows): Promise<T[]>` | Insert multiple rows. |
|
|
649
|
+
| `upsert` | `upsert(data, conflictKeys, updateCols?): Promise<void>` | Insert, or update on conflict. |
|
|
650
|
+
| `firstOrCreate` | `firstOrCreate(attrs, defaults?): Promise<T>` | Find the first match, or insert it. |
|
|
651
|
+
| `updateOrCreate` | `updateOrCreate(attrs, values): Promise<T>` | Update the match, or insert. |
|
|
652
|
+
| `firstOrNew` | `firstOrNew(attrs, defaults?): Promise<T>` | Find, or build an unsaved instance. |
|
|
653
|
+
| `findOrNew` | `findOrNew(id): Promise<T>` | Find by PK, or build an unsaved instance. |
|
|
654
|
+
| `query` | `query(): ModelQueryBuilder<T>` | Start a fluent query. |
|
|
655
|
+
| `scope` | `scope(fn): (...args) => ScopeApplicator` | Define a reusable named scope. |
|
|
656
|
+
| `addGlobalScope` | `addGlobalScope(name, callback): void` | Register an always-on scope. |
|
|
657
|
+
| `removeGlobalScope` | `removeGlobalScope(name): void` | Remove a global scope until next boot. |
|
|
658
|
+
| `withoutTimestamps` | `withoutTimestamps(cb): Promise<R>` | Run `cb` without touching `updated_at`. |
|
|
659
|
+
|
|
660
|
+
`ModelQueryBuilder` / `QueryBuilder` terminals and helpers:
|
|
661
|
+
|
|
662
|
+
| Method | Signature | Description |
|
|
663
|
+
| --------------------------------------- | ------------------------------------------------------- | ----------------------------------------- |
|
|
664
|
+
| `first` | `first<T>(): Promise<T \| null>` | First matching row, or `null`. |
|
|
665
|
+
| `firstOrFail` | `firstOrFail(): Promise<M>` | First row, or throw `ModelNotFoundError`. |
|
|
666
|
+
| `get` | `get<T>(): Promise<T[]>` | All matching rows. |
|
|
667
|
+
| `update` | `update(data): Promise<void>` | Mass-update matching rows. |
|
|
668
|
+
| `count` / `sum` / `avg` / `min` / `max` | `(column?): Promise<number>` | Terminal aggregates. |
|
|
669
|
+
| `paginate` | `paginate(perPage?, page?): Promise<PaginateResult<T>>` | Offset pagination. |
|
|
670
|
+
| `cursorPaginate` | `cursorPaginate(opts?): Promise<…>` | ID-cursor pagination. |
|
|
671
|
+
| `keysetPaginate` | `keysetPaginate(opts): Promise<…>` | Indexed keyset pagination. |
|
|
672
|
+
| `chunk` | `chunk(size, cb): Promise<void>` | Process rows in fixed batches. |
|
|
673
|
+
| `chunkById` | `chunkById(size, cb): Promise<void>` | Batch by primary key (insert-safe). |
|
|
674
|
+
| `lazy` / `cursor` | `lazy(size?): AsyncGenerator<T>` | Stream one row at a time. |
|
|
675
|
+
| `each` | `each(cb): Promise<void>` | Callback per row. |
|
|
676
|
+
| `when` | `when(condition, cb): this` | Apply `cb` only when truthy. |
|
|
677
|
+
| `lockForUpdate` | `lockForUpdate(): this` | `SELECT … FOR UPDATE` (in a transaction). |
|
|
678
|
+
| `sharedLock` | `sharedLock(): this` | Shared read lock. |
|
|
679
|
+
| `withScopes` | `withScopes(cb): this` | Apply named scopes. |
|
|
680
|
+
| `withoutGlobalScope` | `withoutGlobalScope(...names): this` | Skip named global scopes. |
|
|
681
|
+
| `toSql` | `toSql(): string` | Compiled SQL with `?` placeholders. |
|
|
682
|
+
| `toSqlWithBindings` | `toSqlWithBindings(): { sql, bindings }` | SQL plus bound values. |
|
|
683
|
+
| `toRawSql` | `toRawSql(): string` | SQL with values inlined (logging only). |
|
|
684
|
+
| `clone` | `clone(): this` | Copy the builder to branch conditions. |
|
|
685
|
+
|
|
686
|
+
## Next steps
|
|
687
|
+
|
|
688
|
+
- [ORM](/docs/orm) — model definition, columns, and configuration.
|
|
689
|
+
- [ORM relationships](/docs/orm/relationships) — eager-load and constrain related records.
|
|
690
|
+
- [ORM lifecycle](/docs/orm/lifecycle) — model events and observers around save/delete.
|
|
691
|
+
- [Pagination](/docs/pagination) — render paginated results in views and APIs.
|
|
692
|
+
- [Query builder](/docs/query-builder) — the same fluent API for unmodelled tables.
|