@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/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
- ## Querying
113
+ ## Query builder
114
114
 
115
- Start a query with `db(table)`, chain constraints, and finish with a terminal
116
- method (`get`, `first`, `count`, `exists`):
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").where("active", true).orderBy("name").get();
122
- await db("users").where("id", 1).first(); // row | null
123
- await db("users").where("age", ">", 18).count();
124
- await db("posts").whereIn("id", [1, 2, 3]).get();
125
- await db("posts").whereNull("deleted_at").limit(20).offset(40).get();
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
- Constraint methods return the builder, so they chain in any order; the query
135
- isn't sent until you call a terminal method. Multiple `where` calls combine with
136
- `AND`; `orWhere` joins with `OR`.
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
- More `where` clauses and ordering shortcuts:
146
+ For large sets, `chunk` pages through without loading everything (return `false`
147
+ to stop early):
139
148
 
140
149
  ```ts
141
- await db("posts").whereBetween("views", [10, 100]).get();
142
- await db("posts").whereNotIn("id", [4, 5]).get();
143
- await db("posts").whereLike("title", "%keel%").get();
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
- Joins, grouping, and conditional/raw clauses:
155
+ ### Aggregates
149
156
 
150
157
  ```ts
151
- await db("posts")
152
- .join("users", "posts.user_id", "users.id") // also leftJoin(...)
153
- .select("posts.title", "users.name")
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
- await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
157
- await db("users").distinct().select("country").pluck("country");
207
+ ### Ordering, grouping, limit & offset
158
208
 
159
- await db("events").whereColumn("updated_at", ">", "created_at").get();
160
- await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
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
- await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
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
- ## Aggregates, single values, and pagination
227
+ ### Joins
166
228
 
167
229
  ```ts
168
- await db("orders").where("paid", true).sum("total"); // number
169
- await db("orders").avg("total");
170
- await db("orders").min("total");
171
- await db("orders").max("total");
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
- await db("users").where("id", 1).value("email"); // one column, first row
174
- await db("posts").pluck("title"); // string[] of one column
240
+ ### Conditional clauses
175
241
 
176
- const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage }
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
- `paginate(page, perPage)` runs a `COUNT` then a `LIMIT`/`OFFSET` query and returns
180
- a `Paginated<T>` with the page and the metadata to render pager controls.
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
- ## Writing
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").where("id", id).delete();
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
- Everything is parameterized — values become bindings, never string-interpolated
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
- Counters, bulk upserts, and paged iteration:
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").where("id", id).increment("views"); // += 1
198
- await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
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
- // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
201
- await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
202
- await db("logs").insertOrIgnore({ key, value }); // skip duplicates
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
- // Process a large table a page at a time (return false to stop early).
205
- await db("users").orderBy("id").chunk(500, async (rows) => {
206
- for (const row of rows) await process(row);
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
- > **Guard your writes.** `update()` and `delete()` apply to every row that
211
- > matches the current `where` clause — with no `where`, that's the whole table.
212
- > Always scope a write with `where` unless you truly mean to touch every row.
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`