@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,465 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Query Builder
|
|
3
|
+
description: Build parameterized SQL with a fluent, model-free chain that runs only when you call a terminal method.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Query Builder
|
|
7
|
+
|
|
8
|
+
The query builder is Zerotal's fluent, model-free interface for building SQL. You
|
|
9
|
+
get one by calling `DB.table(name)` — every clause method returns the builder, so
|
|
10
|
+
calls chain, and nothing runs until you call a terminal like `get()`, `first()`,
|
|
11
|
+
or `count()`. Values are always parameterized, never interpolated.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// in a controller or service
|
|
15
|
+
import { DB } from "@zerotal/orm";
|
|
16
|
+
|
|
17
|
+
const builder = DB.table("posts"); // a QueryBuilder bound to the "posts" table
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Note** — The same builder powers the [ORM](/docs/orm/queries) — `Model.query()`
|
|
21
|
+
> returns a `ModelQueryBuilder` that adds relations, casts, and model hydration on top
|
|
22
|
+
> of everything here. For transactions, raw SQL, replicas, and N+1 detection, see
|
|
23
|
+
> [Database](/docs/database).
|
|
24
|
+
|
|
25
|
+
## Getting Started
|
|
26
|
+
|
|
27
|
+
The query builder ships with `@zerotal/orm`. If you have the
|
|
28
|
+
[database](/docs/database) set up there is nothing further to install:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { DB } from "@zerotal/orm";
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## When to reach for it
|
|
35
|
+
|
|
36
|
+
The query builder sits between two neighbours, and picking the right one keeps your
|
|
37
|
+
code both safe and readable:
|
|
38
|
+
|
|
39
|
+
- **Use a model (`Post.query()`)** when you're working with a table that has a model
|
|
40
|
+
and you want relations, casts, accessors, or hydrated instances back. This is the
|
|
41
|
+
default for domain logic — you get typed records and lifecycle hooks.
|
|
42
|
+
- **Use the query builder (`DB.table()`)** for tables without a model (pivot tables,
|
|
43
|
+
reporting views, ad-hoc joins), for bulk writes where you don't need model events,
|
|
44
|
+
and for read-heavy aggregate or analytics queries where hydrating models is wasted
|
|
45
|
+
work. It returns plain rows, so it's lighter.
|
|
46
|
+
- **Drop to raw SQL** (`whereRaw`, `selectRaw`, or `DB.raw` — see
|
|
47
|
+
[Database](/docs/database)) only for the slice a clause method can't express. Keep
|
|
48
|
+
the rest of the query fluent so you don't lose parameterization.
|
|
49
|
+
|
|
50
|
+
Because nothing executes until a terminal method, you can build a query up across
|
|
51
|
+
several lines, branches, or helper functions and pass the builder around freely — it's
|
|
52
|
+
just a description of a query until you `await` it.
|
|
53
|
+
|
|
54
|
+
## Selecting columns
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// in a controller or service
|
|
58
|
+
DB.table("posts").select("id", "title", "created_at");
|
|
59
|
+
DB.table("posts").distinct().select("status");
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With no `select()`, all columns (`*`) are returned. For computed columns use
|
|
63
|
+
`selectRaw("price * quantity AS revenue")` — the expression is injected verbatim,
|
|
64
|
+
so build it only from trusted constants.
|
|
65
|
+
|
|
66
|
+
## Where clauses
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// in a controller or service
|
|
70
|
+
DB.table("posts").where("status", "published"); // column = value
|
|
71
|
+
DB.table("posts").where("views", ">", 1000); // explicit operator
|
|
72
|
+
DB.table("posts").where("title", "like", "%bun%");
|
|
73
|
+
|
|
74
|
+
DB.table("posts").where("status", "published").orWhere("featured", true); // OR
|
|
75
|
+
|
|
76
|
+
DB.table("users").whereIn("id", [1, 2, 3]);
|
|
77
|
+
DB.table("users").whereNotIn("role", ["banned", "guest"]);
|
|
78
|
+
DB.table("posts").whereNull("deleted_at");
|
|
79
|
+
DB.table("posts").whereNotNull("published_at");
|
|
80
|
+
DB.table("posts").whereBetween("views", [100, 1000]);
|
|
81
|
+
DB.table("posts").whereLike("title", "%release%");
|
|
82
|
+
DB.table("users").whereJson("preferences->theme", "dark"); // JSON column path
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Method | SQL |
|
|
86
|
+
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
87
|
+
| `where(col, value)` | `col = ?` |
|
|
88
|
+
| `where(col, op, value)` | `col <op> ?` |
|
|
89
|
+
| `orWhere(...)` | `OR …` |
|
|
90
|
+
| `whereIn(col, values)` / `whereNotIn(col, values)` | `col IN (…)` / `NOT IN` |
|
|
91
|
+
| `whereNull(col)` / `whereNotNull(col)` | `col IS NULL` / `IS NOT NULL` |
|
|
92
|
+
| `whereBetween(col, [a, b])` | `col BETWEEN ? AND ?` |
|
|
93
|
+
| `whereLike(col, pattern)` | `col LIKE ?` |
|
|
94
|
+
| `whereColumn(a, op, b)` | `a <op> b` — compare two columns |
|
|
95
|
+
| `whereJson(col, value)` | match a JSON column (see [Database](/docs/database#json-column-queries)) |
|
|
96
|
+
|
|
97
|
+
Most filters have an `orX` partner (`orWhereIn`, `orWhereNull`, `orWhereLike`, …)
|
|
98
|
+
that joins with `OR` instead of `AND`, plus date helpers (`whereDate`, `whereMonth`,
|
|
99
|
+
`whereYear`) and subquery filters (`whereExists`, `whereNotExists`). For anything the
|
|
100
|
+
fluent methods don't cover, drop to `whereRaw(sql, bindings)` — bindings keep it
|
|
101
|
+
parameterized.
|
|
102
|
+
|
|
103
|
+
> **Warning** — `whereBetween`, `whereLike`, `whereColumn`, and `whereJson` validate
|
|
104
|
+
> the column name as a SQL identifier and throw on anything outside
|
|
105
|
+
> `[a-zA-Z_][a-zA-Z0-9_.]*`. Never pass a user-controlled string as a column name.
|
|
106
|
+
|
|
107
|
+
## Joins
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// in a controller or service
|
|
111
|
+
DB.table("posts")
|
|
112
|
+
.join("users", "posts.user_id", "=", "users.id")
|
|
113
|
+
.leftJoin("comments", "comments.post_id", "=", "posts.id")
|
|
114
|
+
.select("posts.title", "users.name");
|
|
115
|
+
|
|
116
|
+
DB.table("a").crossJoin("b");
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`join`, `leftJoin`, and `rightJoin` take `(table, first, operator, second)`;
|
|
120
|
+
`crossJoin` takes just the table. To join a derived table, use `joinSub(builder,
|
|
121
|
+
alias, first, operator, second)`.
|
|
122
|
+
|
|
123
|
+
## Ordering, grouping, limits
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// in a controller or service
|
|
127
|
+
DB.table("posts").orderBy("created_at", "desc");
|
|
128
|
+
DB.table("posts").orderByDesc("created_at"); // shorthand for the line above
|
|
129
|
+
DB.table("posts").latest(); // orderBy("created_at", "desc")
|
|
130
|
+
DB.table("posts").oldest(); // orderBy("created_at", "asc")
|
|
131
|
+
DB.table("posts").oldest("published_at");
|
|
132
|
+
DB.table("posts").inRandomOrder(); // RANDOM() / RAND()
|
|
133
|
+
|
|
134
|
+
DB.table("posts").groupBy("user_id").having("post_count", ">", 5);
|
|
135
|
+
|
|
136
|
+
DB.table("posts").limit(10).offset(20);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`latest()` / `oldest()` default to the `created_at` column; pass a column name to
|
|
140
|
+
order by something else. `reorder(col?, dir?)` clears all existing `ORDER BY`
|
|
141
|
+
clauses and optionally applies a fresh one.
|
|
142
|
+
|
|
143
|
+
## Conditional clauses — when
|
|
144
|
+
|
|
145
|
+
Apply clauses only when a condition is truthy — handy for optional filters without
|
|
146
|
+
breaking the chain:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// in a controller or service
|
|
150
|
+
DB.table("posts")
|
|
151
|
+
.when(status, (q, value) => q.where("status", value))
|
|
152
|
+
.when(search, (q, value) => q.whereLike("title", `%${value}%`))
|
|
153
|
+
.orderBy("created_at", "desc");
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The callback receives the builder and the (truthy) condition value.
|
|
157
|
+
|
|
158
|
+
## Retrieving results
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// in a controller or service
|
|
162
|
+
const rows = await DB.table("posts").where("status", "published").get();
|
|
163
|
+
const row = await DB.table("posts").where("id", 1).first(); // first row or null
|
|
164
|
+
|
|
165
|
+
const title = await DB.table("posts").where("id", 1).value("title"); // single column
|
|
166
|
+
const ids = await DB.table("posts").pluck("id"); // column → array
|
|
167
|
+
const map = await DB.table("posts").pluck("title", "id"); // keyed by a column
|
|
168
|
+
|
|
169
|
+
const has = await DB.table("posts").where("user_id", userId).exists(); // boolean
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Pass a row type to `get<T>()` / `first<T>()` for typed results:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// in a controller or service
|
|
176
|
+
const rows = await DB.table("posts").get<{ id: number; title: string }>();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
> **Tip** — `sole<T>()` returns the one matching row and throws if zero or more than
|
|
180
|
+
> one row matches — a guard against accidentally acting on the wrong record.
|
|
181
|
+
|
|
182
|
+
### Aggregates
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// in a controller or service
|
|
186
|
+
await DB.table("posts").count();
|
|
187
|
+
await DB.table("posts").where("status", "published").sum("views");
|
|
188
|
+
await DB.table("posts").avg("rating");
|
|
189
|
+
await DB.table("posts").min("created_at");
|
|
190
|
+
await DB.table("posts").max("views");
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
For paged result sets, see [Pagination](/docs/pagination).
|
|
194
|
+
|
|
195
|
+
## Streaming large tables
|
|
196
|
+
|
|
197
|
+
For result sets too large to hold in memory, page through them instead of calling
|
|
198
|
+
`get()`:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// in a command or job
|
|
202
|
+
await DB.table("posts").chunk(500, async (rows, page) => {
|
|
203
|
+
for (const row of rows) await archive(row);
|
|
204
|
+
// return false to stop early
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
for await (const row of DB.table("posts").lazy()) {
|
|
208
|
+
await process(row); // one row at a time
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
> **Tip** — Prefer `chunkById()` / `lazyById()` when rows may be inserted or deleted
|
|
213
|
+
> during iteration: they page by an incrementing key instead of `OFFSET`, so they
|
|
214
|
+
> never skip or repeat a row.
|
|
215
|
+
|
|
216
|
+
## Writing rows
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
// in a controller or service
|
|
220
|
+
// INSERT
|
|
221
|
+
await DB.table("post_tags").insert({ post_id: 1, tag_id: 3 });
|
|
222
|
+
|
|
223
|
+
// UPDATE — scope with where() first
|
|
224
|
+
await DB.table("posts").where("id", 1).update({ status: "published" });
|
|
225
|
+
|
|
226
|
+
// DELETE
|
|
227
|
+
await DB.table("sessions").where("user_id", userId).delete();
|
|
228
|
+
|
|
229
|
+
// Atomic counters
|
|
230
|
+
await DB.table("posts").where("id", 1).increment("views"); // +1
|
|
231
|
+
await DB.table("posts").where("id", 1).increment("views", 10); // +10
|
|
232
|
+
await DB.table("accounts").where("id", 1).decrement("balance", 100);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Use `updateOrInsert(attributes, values)` to update a matching row or insert a merged
|
|
236
|
+
one if none exists; it returns `true` when a row was inserted.
|
|
237
|
+
|
|
238
|
+
> **Danger** — `update()` and `delete()` apply to **every** matching row — always set
|
|
239
|
+
> your `where()` constraints first, or you will overwrite or wipe the whole table.
|
|
240
|
+
|
|
241
|
+
## Locking
|
|
242
|
+
|
|
243
|
+
Inside a [transaction](/docs/database#transactions), lock the selected rows:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// in a controller or service
|
|
247
|
+
await DB.transaction(async (trx) => {
|
|
248
|
+
const row = await trx.table("accounts").where("id", 1).lockForUpdate().first();
|
|
249
|
+
// … exclusive lock held until the transaction commits
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Method | SQL |
|
|
254
|
+
| ----------------- | ------------------------------ |
|
|
255
|
+
| `lockForUpdate()` | `FOR UPDATE` — exclusive |
|
|
256
|
+
| `sharedLock()` | `FOR SHARE` — shared read lock |
|
|
257
|
+
|
|
258
|
+
> **Note** — Row locks are no-ops on SQLite, which has no `FOR UPDATE` / `FOR SHARE`.
|
|
259
|
+
> `sharedLock()` emits `LOCK IN SHARE MODE` on MySQL and `FOR SHARE` elsewhere.
|
|
260
|
+
|
|
261
|
+
## Debugging
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
// in a controller or service
|
|
265
|
+
DB.table("posts").where("status", "published").toSql(); // SQL with ? placeholders
|
|
266
|
+
DB.table("posts").where("status", "published").toRawSql(); // values inlined (logging only)
|
|
267
|
+
DB.table("posts").where("id", 1).dump(); // log SQL + bindings, keep chaining
|
|
268
|
+
await DB.table("posts").where("status", "published").explain(); // EXPLAIN / EXPLAIN QUERY PLAN
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
> **Warning** — `toRawSql()` inlines bindings for readability and is **not** safe to
|
|
272
|
+
> execute. Use it for logging only — `toSql()` plus `toSqlWithBindings()` give you the
|
|
273
|
+
> parameterized form.
|
|
274
|
+
|
|
275
|
+
## Recipes
|
|
276
|
+
|
|
277
|
+
A few patterns that come up constantly, shown end to end.
|
|
278
|
+
|
|
279
|
+
### A filtered, sorted, paginated listing
|
|
280
|
+
|
|
281
|
+
The bread and butter of any index page or list endpoint. `when()` lets every filter
|
|
282
|
+
be optional without a tangle of `if` statements, and `paginate()` returns the rows
|
|
283
|
+
plus the page metadata in one call:
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
// in a controller — req.query holds the optional filters
|
|
287
|
+
const posts = await DB.table("posts")
|
|
288
|
+
.when(req.query.status, (q, status) => q.where("status", status))
|
|
289
|
+
.when(req.query.search, (q, term) => q.whereLike("title", `%${term}%`))
|
|
290
|
+
.when(req.query.author, (q, id) => q.where("user_id", id))
|
|
291
|
+
.latest() // newest first
|
|
292
|
+
.paginate(20, req.query.page ?? 1);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Each `when()` only fires when its value is truthy, so an empty filter is simply
|
|
296
|
+
skipped. See [Pagination](/docs/pagination) for the shape of the returned object.
|
|
297
|
+
|
|
298
|
+
### Idempotent pivot / settings writes
|
|
299
|
+
|
|
300
|
+
For join tables and key/value rows, `updateOrInsert` avoids the "check then insert"
|
|
301
|
+
race — it updates the row matching the first argument, or inserts the two merged if
|
|
302
|
+
none exists:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
await DB.table("user_settings").updateOrInsert(
|
|
306
|
+
{ user_id: userId, key: "theme" }, // how to find the row
|
|
307
|
+
{ value: "dark" }, // what to set
|
|
308
|
+
);
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### A lightweight report without models
|
|
312
|
+
|
|
313
|
+
When you only need numbers, skip model hydration entirely and let the database do the
|
|
314
|
+
aggregation:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
const byAuthor = await DB.table("posts")
|
|
318
|
+
.select("user_id")
|
|
319
|
+
.selectRaw("COUNT(*) AS post_count")
|
|
320
|
+
.where("status", "published")
|
|
321
|
+
.groupBy("user_id")
|
|
322
|
+
.having("post_count", ">", 5)
|
|
323
|
+
.orderByDesc("post_count")
|
|
324
|
+
.get<{ user_id: number; post_count: number }>();
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Backfilling a large table safely
|
|
328
|
+
|
|
329
|
+
Never load a big table with `get()`. Page through it with `chunkById`, which walks an
|
|
330
|
+
incrementing key so concurrent inserts or deletes can't make it skip or repeat rows:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
// in a command or job
|
|
334
|
+
await DB.table("posts")
|
|
335
|
+
.whereNull("slug")
|
|
336
|
+
.chunkById(500, async (rows) => {
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
await DB.table("posts")
|
|
339
|
+
.where("id", row.id)
|
|
340
|
+
.update({ slug: slugify(row.title) });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Guarding against the wrong row
|
|
346
|
+
|
|
347
|
+
When exactly one row should match — looking a user up by email, say — `sole()` turns
|
|
348
|
+
"zero or many matches" into a thrown error instead of a silent bug:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const user = await DB.table("users").where("email", email).sole();
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Testing
|
|
355
|
+
|
|
356
|
+
Set your suite up once as described in [Testing](/docs/testing). The builder
|
|
357
|
+
gives you two ways to test a query, and they answer different questions.
|
|
358
|
+
|
|
359
|
+
**Assert the SQL when the shape is the point.** `toSql()` returns the statement
|
|
360
|
+
with placeholders and `toSqlWithBindings()` adds the values, so a complex
|
|
361
|
+
condition can be pinned without touching the database:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// tests/queries/ActiveSubscribers.test.ts
|
|
365
|
+
import { test, expect } from "bun:test";
|
|
366
|
+
import { DB } from "@zerotal/orm";
|
|
367
|
+
|
|
368
|
+
test("only active, non-trial subscribers are selected", () => {
|
|
369
|
+
const { sql, bindings } = DB.table("users")
|
|
370
|
+
.where("status", "active")
|
|
371
|
+
.whereNull("trial_ends_at")
|
|
372
|
+
.toSqlWithBindings();
|
|
373
|
+
|
|
374
|
+
expect(sql).toContain('where "status" = ?');
|
|
375
|
+
expect(sql).toContain('"trial_ends_at" is null');
|
|
376
|
+
expect(bindings).toEqual(["active"]);
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
This catches the mistake that matters most in a query builder — a condition
|
|
381
|
+
silently dropped by a mis-chained `orWhere` — without needing rows to prove it.
|
|
382
|
+
|
|
383
|
+
**Assert the rows when the result is the point.** Arrange with factories and run
|
|
384
|
+
the query for real:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// tests/queries/ActiveSubscribers.test.ts
|
|
388
|
+
test("excludes users still in trial", async () => {
|
|
389
|
+
await UserFactory.create({ status: "active", trialEndsAt: null });
|
|
390
|
+
await UserFactory.create({ status: "active", trialEndsAt: new Date() });
|
|
391
|
+
|
|
392
|
+
const rows = await DB.table("users").where("status", "active").whereNull("trial_ends_at").get();
|
|
393
|
+
|
|
394
|
+
expect(rows).toHaveLength(1);
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
> **Warning** — `toRawSql()` inlines the bindings for display. It is for reading
|
|
399
|
+
> in a log or a failure message, never for executing: a value with a quote in it
|
|
400
|
+
> produces a statement you do not want to run.
|
|
401
|
+
|
|
402
|
+
## References
|
|
403
|
+
|
|
404
|
+
### Filtering
|
|
405
|
+
|
|
406
|
+
| Method | Signature | Description |
|
|
407
|
+
| ---------------------------------------- | ------------------------------------------------- | ---------------------------------------------- |
|
|
408
|
+
| `where` | `where(col, value)` / `where(col, op, value)` | `AND` equality or operator clause. |
|
|
409
|
+
| `orWhere` | `orWhere(col, value)` / `orWhere(col, op, value)` | Same, joined with `OR`. |
|
|
410
|
+
| `whereIn` / `whereNotIn` | `whereIn(col, values[])` | `col IN (…)` / `NOT IN`. |
|
|
411
|
+
| `whereNull` / `whereNotNull` | `whereNull(col)` | `IS NULL` / `IS NOT NULL`. |
|
|
412
|
+
| `whereBetween` / `whereNotBetween` | `whereBetween(col, [a, b])` | Range bounds (inclusive). |
|
|
413
|
+
| `whereColumn` | `whereColumn(a, op?, b)` | Compare two columns. |
|
|
414
|
+
| `whereLike` / `whereNotLike` | `whereLike(col, pattern)` | `LIKE` / `NOT LIKE`. |
|
|
415
|
+
| `whereDate` / `whereMonth` / `whereYear` | `whereDate(col, op?, value)` | Match a date part. |
|
|
416
|
+
| `whereExists` / `whereNotExists` | `whereExists((q) => …)` | Correlated `EXISTS` subquery. |
|
|
417
|
+
| `whereJson` | `whereJson("col->path", value)` | JSON path equality (PostgreSQL/MySQL). |
|
|
418
|
+
| `whereRaw` / `orWhereRaw` | `whereRaw(sql, bindings[])` | Raw clause with parameter bindings. |
|
|
419
|
+
| `when` | `when(condition, (q, value) => …)` | Apply clauses only when `condition` is truthy. |
|
|
420
|
+
|
|
421
|
+
### Shaping
|
|
422
|
+
|
|
423
|
+
| Method | Signature | Description |
|
|
424
|
+
| --------------------------------- | ---------------------------------------------- | ----------------------------------------------- |
|
|
425
|
+
| `select` | `select(...columns)` | Choose columns (default `*`). |
|
|
426
|
+
| `selectRaw` | `selectRaw(expression)` | Add a raw SELECT expression. |
|
|
427
|
+
| `distinct` | `distinct()` | Emit `SELECT DISTINCT`. |
|
|
428
|
+
| `join` / `leftJoin` / `rightJoin` | `join(table, first, op, second)` | Add a join. |
|
|
429
|
+
| `crossJoin` | `crossJoin(table)` | Cartesian join. |
|
|
430
|
+
| `joinSub` | `joinSub(sub, alias, first, op, second)` | Join a derived table. |
|
|
431
|
+
| `union` / `unionAll` | `union(otherBuilder)` | Combine with another query. |
|
|
432
|
+
| `orderBy` / `orderByDesc` | `orderBy(col, dir?)` | Sort rows. |
|
|
433
|
+
| `latest` / `oldest` | `latest(col = "created_at")` | Sort by a timestamp column. |
|
|
434
|
+
| `inRandomOrder` | `inRandomOrder()` | Random row order. |
|
|
435
|
+
| `reorder` | `reorder(col?, dir?)` | Clear existing `ORDER BY`, optionally re-apply. |
|
|
436
|
+
| `groupBy` / `having` | `groupBy(...cols)` / `having(col, op?, value)` | Group and filter groups. |
|
|
437
|
+
| `limit` / `offset` | `limit(n)` / `offset(n)` | Slice the result set. |
|
|
438
|
+
|
|
439
|
+
### Terminals
|
|
440
|
+
|
|
441
|
+
| Method | Signature | Description |
|
|
442
|
+
| ------------------------------------------ | ----------------------------------- | --------------------------------------------------- |
|
|
443
|
+
| `get` | `get<T>(): Promise<T[]>` | Run the query, return all rows. |
|
|
444
|
+
| `first` | `first<T>(): Promise<T \| null>` | First matching row or `null`. |
|
|
445
|
+
| `sole` | `sole<T>(): Promise<T>` | The one row; throws on zero or many. |
|
|
446
|
+
| `value` | `value<V>(col): Promise<V \| null>` | Single column of the first row. |
|
|
447
|
+
| `pluck` | `pluck<V>(col, key?)` | Array of one column, or object keyed by `key`. |
|
|
448
|
+
| `exists` / `doesntExist` | `exists(): Promise<boolean>` | Whether any row matches. |
|
|
449
|
+
| `count` / `sum` / `avg` / `min` / `max` | `count(): Promise<number>` | Aggregates. |
|
|
450
|
+
| `insert` | `insert(data): Promise<void>` | Insert a row. |
|
|
451
|
+
| `update` | `update(data): Promise<void>` | Update matching rows. |
|
|
452
|
+
| `updateOrInsert` | `updateOrInsert(attrs, values?)` | Update a match or insert; returns `true` on insert. |
|
|
453
|
+
| `delete` | `delete(): Promise<void>` | Delete matching rows. |
|
|
454
|
+
| `increment` / `decrement` | `increment(col, amount = 1)` | Atomic counter update. |
|
|
455
|
+
| `chunk` / `chunkById` | `chunk(size, (rows, page) => …)` | Page through a large table. |
|
|
456
|
+
| `lazy` / `lazyById` / `cursor` / `each` | `lazy<T>(size = 1000)` | Stream rows one at a time. |
|
|
457
|
+
| `clone` | `clone(): this` | Deep-copy the builder (used by pagination). |
|
|
458
|
+
| `toSql` / `toSqlWithBindings` / `toRawSql` | `toSql(): string` | Inspect the compiled SQL. |
|
|
459
|
+
| `dump` / `dd` / `explain` | `dump(): this` | Log SQL/bindings or run `EXPLAIN`. |
|
|
460
|
+
|
|
461
|
+
## Next steps
|
|
462
|
+
|
|
463
|
+
- [Database](/docs/database) — transactions, raw SQL, replicas, multiple connections, N+1 detection.
|
|
464
|
+
- [Pagination](/docs/pagination) — `paginate()`, `simplePaginate()`, and cursor pagination.
|
|
465
|
+
- [ORM Queries](/docs/orm/queries) — the model query builder layered on top of this.
|