@shaferllc/keel 0.81.1 → 0.82.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/dist/core/database.d.ts +83 -0
- package/dist/core/database.js +248 -4
- package/dist/core/index.d.ts +1 -1
- package/docs/ai-manifest.json +13 -1
- package/docs/changelog.md +2095 -0
- package/docs/database.md +233 -59
- package/llms-full.txt +233 -59
- package/llms.txt +1 -0
- package/package.json +1 -1
package/docs/database.md
CHANGED
|
@@ -110,106 +110,218 @@ An unregistered connection name doesn't fail when you *build* a query, only when
|
|
|
110
110
|
it runs — so a misconfigured name surfaces as a rejected read/write, not a
|
|
111
111
|
construction-time throw.
|
|
112
112
|
|
|
113
|
-
##
|
|
113
|
+
## Query builder
|
|
114
114
|
|
|
115
|
-
Start a query with `db(table)`, chain constraints
|
|
116
|
-
method
|
|
115
|
+
Start a query with `db(table)`, chain constraints (they return the builder, so
|
|
116
|
+
order doesn't matter), and finish with a terminal method. Nothing hits the
|
|
117
|
+
database until a terminal runs. Every value becomes a **binding**, never
|
|
118
|
+
string-interpolated SQL — the builder is injection-safe by construction. It's
|
|
119
|
+
driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and
|
|
120
|
+
Postgres.
|
|
117
121
|
|
|
118
122
|
```ts
|
|
119
123
|
import { db } from "@shaferllc/keel/core";
|
|
120
124
|
|
|
121
|
-
await db("users")
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
await db("orders")
|
|
128
|
-
.select("id", "total")
|
|
129
|
-
.where("status", "paid")
|
|
130
|
-
.orWhere("status", "shipped")
|
|
125
|
+
const active = await db("users")
|
|
126
|
+
.where("active", true)
|
|
127
|
+
.where("age", ">", 18)
|
|
128
|
+
.orderBy("name")
|
|
129
|
+
.limit(20)
|
|
131
130
|
.get();
|
|
132
131
|
```
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
### Retrieving results
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
await db("users").get(); // Row[]
|
|
137
|
+
await db("users").where("id", 1).first(); // Row | null
|
|
138
|
+
await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
|
|
139
|
+
await db("users").find(1); // by primary key (default "id")
|
|
140
|
+
await db("users").where("email", e).sole(); // exactly one, else throws
|
|
141
|
+
await db("users").where("id", 1).value("email"); // one column of the first row
|
|
142
|
+
await db("posts").pluck("title"); // string[] of one column
|
|
143
|
+
await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"
|
|
144
|
+
```
|
|
137
145
|
|
|
138
|
-
|
|
146
|
+
For large sets, `chunk` pages through without loading everything (return `false`
|
|
147
|
+
to stop early):
|
|
139
148
|
|
|
140
149
|
```ts
|
|
141
|
-
await db("
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await db("posts").latest().get(); // ORDER BY created_at DESC
|
|
145
|
-
await db("posts").oldest("published_at").get();
|
|
150
|
+
await db("users").orderBy("id").chunk(500, async (rows) => {
|
|
151
|
+
for (const row of rows) await process(row);
|
|
152
|
+
});
|
|
146
153
|
```
|
|
147
154
|
|
|
148
|
-
|
|
155
|
+
### Aggregates
|
|
149
156
|
|
|
150
157
|
```ts
|
|
151
|
-
await db("
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
await db("orders").count();
|
|
159
|
+
await db("orders").where("paid", true).sum("total");
|
|
160
|
+
await db("orders").avg("total"); // also min(col), max(col)
|
|
161
|
+
await db("users").where("email", e).exists(); // boolean
|
|
162
|
+
await db("users").where("banned", true).doesntExist();
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Selects
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
db("users").select("id", "email");
|
|
169
|
+
db("users").select("id").addSelect("email"); // append, don't replace
|
|
170
|
+
db("orders").selectRaw("SUM(total) AS revenue");
|
|
171
|
+
db("users").distinct().select("country");
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Where clauses
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
db("users").where("votes", 100); // = is the default operator
|
|
178
|
+
db("users").where("votes", ">=", 100);
|
|
179
|
+
db("users").where("name", "like", "T%");
|
|
180
|
+
|
|
181
|
+
db("users").where("votes", 100).orWhere("name", "John");
|
|
182
|
+
db("users").whereNot("status", "cancelled");
|
|
183
|
+
|
|
184
|
+
db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
|
|
185
|
+
db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
|
|
186
|
+
db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
|
|
187
|
+
db("posts").whereLike("title", "%keel%");
|
|
188
|
+
db("events").whereColumn("updated_at", ">", "created_at"); // column vs column
|
|
189
|
+
db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`,
|
|
193
|
+
`orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`,
|
|
194
|
+
`orWhereNotIn`.
|
|
195
|
+
|
|
196
|
+
**Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set
|
|
197
|
+
of conditions — the way to express `A AND (B OR C)`:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
await db("users")
|
|
201
|
+
.where("active", true)
|
|
202
|
+
.where((q) => q.where("role", "admin").orWhere("role", "owner"))
|
|
154
203
|
.get();
|
|
204
|
+
// … WHERE active = ? AND (role = ? OR role = ?)
|
|
205
|
+
```
|
|
155
206
|
|
|
156
|
-
|
|
157
|
-
await db("users").distinct().select("country").pluck("country");
|
|
207
|
+
### Ordering, grouping, limit & offset
|
|
158
208
|
|
|
159
|
-
|
|
160
|
-
|
|
209
|
+
```ts
|
|
210
|
+
db("users").orderBy("name").orderByDesc("created_at");
|
|
211
|
+
db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC)
|
|
212
|
+
db("posts").orderByRaw("LENGTH(title) DESC");
|
|
213
|
+
db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND()
|
|
214
|
+
db("users").reorder("name"); // clear existing ordering, then set
|
|
215
|
+
|
|
216
|
+
db("orders")
|
|
217
|
+
.select("user_id")
|
|
218
|
+
.selectRaw("SUM(total) AS spent")
|
|
219
|
+
.groupBy("user_id")
|
|
220
|
+
.having("spent", ">", 1000) // also havingRaw(...), havingBetween(...)
|
|
221
|
+
.get();
|
|
161
222
|
|
|
162
|
-
|
|
223
|
+
db("users").limit(10).offset(20); // take(10)/skip(20) are aliases
|
|
224
|
+
db("users").forPage(3, 15); // page 3, 15 per page
|
|
163
225
|
```
|
|
164
226
|
|
|
165
|
-
|
|
227
|
+
### Joins
|
|
166
228
|
|
|
167
229
|
```ts
|
|
168
|
-
await db("
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
230
|
+
await db("posts")
|
|
231
|
+
.join("users", "posts.user_id", "users.id") // INNER JOIN on equality
|
|
232
|
+
.leftJoin("images", "images.post_id", "posts.id")
|
|
233
|
+
.select("posts.title", "users.name")
|
|
234
|
+
.get();
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`rightJoin` and `crossJoin` round out the set. Joins with several `ON`
|
|
238
|
+
conditions aren't modelled — use `whereRaw` or a view.
|
|
172
239
|
|
|
173
|
-
|
|
174
|
-
await db("posts").pluck("title"); // string[] of one column
|
|
240
|
+
### Conditional clauses
|
|
175
241
|
|
|
176
|
-
|
|
242
|
+
`when` / `unless` apply a callback based on a runtime value, so you build a query
|
|
243
|
+
without breaking the chain into `if`s. The callback receives the value:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
await db("users")
|
|
247
|
+
.when(search, (q, term) => q.whereLike("name", `%${term}%`))
|
|
248
|
+
.unless(includeArchived, (q) => q.whereNull("archived_at"))
|
|
249
|
+
.get();
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Inserts
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
await db("users").insert({ email, name });
|
|
256
|
+
const id = await db("users").insertGetId({ email, name }); // new primary key
|
|
257
|
+
await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts
|
|
258
|
+
await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update
|
|
177
259
|
```
|
|
178
260
|
|
|
179
|
-
`
|
|
180
|
-
|
|
261
|
+
`upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default:
|
|
262
|
+
everything not in `uniqueBy`) on a conflict — dialect-aware (`ON CONFLICT` /
|
|
263
|
+
`ON DUPLICATE KEY UPDATE`).
|
|
181
264
|
|
|
182
|
-
|
|
265
|
+
### Updates
|
|
183
266
|
|
|
184
267
|
```ts
|
|
185
|
-
const id = await db("users").insertGetId({ email, name });
|
|
186
268
|
await db("users").where("id", id).update({ name: "Grace" });
|
|
187
|
-
await db("users").
|
|
269
|
+
await db("users").updateOrInsert({ email }, { name }); // update match, else insert
|
|
270
|
+
await db("posts").where("id", id).increment("views"); // += 1
|
|
271
|
+
await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
|
|
272
|
+
await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once
|
|
188
273
|
```
|
|
189
274
|
|
|
190
|
-
|
|
191
|
-
SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
|
|
192
|
-
`insertGetId` unwraps it to just the new id.
|
|
275
|
+
### Deletes
|
|
193
276
|
|
|
194
|
-
|
|
277
|
+
```ts
|
|
278
|
+
await db("sessions").where("expires_at", "<", now).delete();
|
|
279
|
+
await db("cache").truncate(); // empty the table (DELETE on sqlite)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
> **Guard your writes.** `update()`, `delete()`, and the increments apply to
|
|
283
|
+
> every row matching the current `where` clause — with none, that's the whole
|
|
284
|
+
> table. Scope every write unless you truly mean to touch every row.
|
|
285
|
+
|
|
286
|
+
### Pagination
|
|
195
287
|
|
|
196
288
|
```ts
|
|
197
|
-
await db("posts").
|
|
198
|
-
|
|
289
|
+
const page = await db("posts").latest().paginate(2, 15);
|
|
290
|
+
// { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query
|
|
199
291
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
292
|
+
const feed = await db("posts").latest().simplePaginate(2, 15);
|
|
293
|
+
// { data, perPage, currentPage, hasMore } — no COUNT; one extra row tells hasMore
|
|
294
|
+
```
|
|
203
295
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
296
|
+
### Pessimistic locking
|
|
297
|
+
|
|
298
|
+
Inside a [transaction](#transactions), lock the selected rows against concurrent
|
|
299
|
+
writes. No-ops on sqlite (which locks the whole database anyway):
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
await transaction(async () => {
|
|
303
|
+
const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
|
|
304
|
+
await db("accounts").where("id", id).update({ balance: row.balance - 10 });
|
|
207
305
|
});
|
|
306
|
+
// sharedLock() takes a read lock (FOR SHARE) instead.
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Debugging
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?"
|
|
313
|
+
db("users").where("active", true).getBindings(); // [true]
|
|
314
|
+
db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder
|
|
315
|
+
db("users").where("active", true).dd(); // logs and throws (dump-and-die)
|
|
208
316
|
```
|
|
209
317
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
318
|
+
### Not (yet) modelled
|
|
319
|
+
|
|
320
|
+
Kept out on purpose, to stay driver-agnostic and honest about what compiles
|
|
321
|
+
everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`),
|
|
322
|
+
the `whereDate`/`whereMonth`/… date-function family (no portable form across
|
|
323
|
+
dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw
|
|
324
|
+
`connection().select(sql)`, or a database view when you need them.
|
|
213
325
|
|
|
214
326
|
## Transactions
|
|
215
327
|
|
|
@@ -656,6 +768,68 @@ Insert one or more rows, skipping any that violate a unique constraint
|
|
|
656
768
|
Process results a page at a time so a large table never loads at once. Return
|
|
657
769
|
`false` from the callback to stop early. Pair with `orderBy` for a stable order.
|
|
658
770
|
|
|
771
|
+
#### `addSelect(...columns)` · `selectRaw(sql)`
|
|
772
|
+
|
|
773
|
+
Append columns to the SELECT list without replacing it; `selectRaw` appends a raw
|
|
774
|
+
expression (`selectRaw("SUM(total) AS revenue")`).
|
|
775
|
+
|
|
776
|
+
#### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])`
|
|
777
|
+
|
|
778
|
+
Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`,
|
|
779
|
+
`orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`,
|
|
780
|
+
`orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison;
|
|
781
|
+
`whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to
|
|
782
|
+
`where`/`orWhere` groups its conditions in parentheses.
|
|
783
|
+
|
|
784
|
+
#### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()`
|
|
785
|
+
|
|
786
|
+
Descending order; clear existing ordering (optionally setting a new one); random
|
|
787
|
+
order (dialect-aware `RANDOM()`/`RAND()`).
|
|
788
|
+
|
|
789
|
+
#### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])`
|
|
790
|
+
|
|
791
|
+
Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`.
|
|
792
|
+
|
|
793
|
+
#### `take(n)` · `skip(n)` · `forPage(page, perPage?)`
|
|
794
|
+
|
|
795
|
+
Aliases for `limit`/`offset`, and limit+offset for a 1-based page.
|
|
796
|
+
|
|
797
|
+
#### `rightJoin(...)` · `crossJoin(table)`
|
|
798
|
+
|
|
799
|
+
`RIGHT JOIN` on an equality; `CROSS JOIN`.
|
|
800
|
+
|
|
801
|
+
#### `unless(condition, then, otherwise?)`
|
|
802
|
+
|
|
803
|
+
The inverse of `when` — runs `then` only when `condition` is falsy.
|
|
804
|
+
|
|
805
|
+
#### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)`
|
|
806
|
+
|
|
807
|
+
Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation
|
|
808
|
+
of `exists`; and join one column's values into a string.
|
|
809
|
+
|
|
810
|
+
#### `simplePaginate(page?, perPage?)`
|
|
811
|
+
|
|
812
|
+
`simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>`
|
|
813
|
+
|
|
814
|
+
A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than
|
|
815
|
+
`paginate` for "load more" UIs.
|
|
816
|
+
|
|
817
|
+
#### `lockForUpdate()` · `sharedLock()`
|
|
818
|
+
|
|
819
|
+
Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on
|
|
820
|
+
sqlite.
|
|
821
|
+
|
|
822
|
+
#### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)`
|
|
823
|
+
|
|
824
|
+
Update the first match or insert `{ ...match, ...values }`; empty the table
|
|
825
|
+
(`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is
|
|
826
|
+
an array — each by 1 — or a `{ column: amount }` map).
|
|
827
|
+
|
|
828
|
+
#### `toSql()` · `getBindings()` · `dump()` · `dd()`
|
|
829
|
+
|
|
830
|
+
The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs
|
|
831
|
+
them and returns the builder; `dd` logs and throws.
|
|
832
|
+
|
|
659
833
|
### Interfaces & types
|
|
660
834
|
|
|
661
835
|
#### `Connection`
|