@zerotal/arch 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,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.