@gallopsystems/agent-skills 1.6.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gallopsystems/agent-skills",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Gallop Systems Claude Code skills, symlinked into .claude/skills on install.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -19,15 +19,19 @@ Use this skill when:
19
19
 
20
20
  For detailed examples, see these topic-focused reference files:
21
21
 
22
- - [select-where.ts](references/select-where.ts) - Basic SELECT patterns, WHERE clauses, AND/OR conditions
23
- - [joins.ts](references/joins.ts) - Simple joins, callback joins, subquery joins, cross joins
24
- - [aggregations.ts](references/aggregations.ts) - COUNT, SUM, AVG, GROUP BY, HAVING
22
+ - [select-where.ts](references/select-where.ts) - Basic SELECT patterns, WHERE clauses, AND/OR, BETWEEN, ANY
23
+ - [joins.ts](references/joins.ts) - Simple joins, callback joins, subquery joins, cross joins, lateral joins
24
+ - [aggregations.ts](references/aggregations.ts) - COUNT, SUM, AVG, GROUP BY, HAVING, ROLLUP/CUBE/GROUPING SETS
25
+ - [window-functions.ts](references/window-functions.ts) - ROW_NUMBER/RANK, LAG/LEAD, windowed aggregates, frames
25
26
  - [orderby-pagination.ts](references/orderby-pagination.ts) - ORDER BY, NULLS handling, DISTINCT, pagination
26
- - [ctes.ts](references/ctes.ts) - Common Table Expressions, multiple CTEs, recursive CTEs
27
+ - [ctes.ts](references/ctes.ts) - Common Table Expressions, multiple CTEs, recursive CTEs, MATERIALIZED
28
+ - [set-operations.ts](references/set-operations.ts) - UNION, INTERSECT, EXCEPT (and *All variants)
27
29
  - [json-arrays.ts](references/json-arrays.ts) - JSONB handling, array columns, jsonBuildObject, jsonAgg
28
30
  - [relations.ts](references/relations.ts) - jsonArrayFrom, jsonObjectFrom for nested data
31
+ - [full-text-search.ts](references/full-text-search.ts) - tsvector/tsquery matching with @@, ranking
32
+ - [locking.ts](references/locking.ts) - FOR UPDATE/SHARE, SKIP LOCKED, NOWAIT, job-queue pattern
29
33
  - [mutations.ts](references/mutations.ts) - INSERT, UPDATE, DELETE, UPSERT, INSERT FROM SELECT
30
- - [expressions.ts](references/expressions.ts) - CASE, $if, subqueries, eb.val/lit/not, standalone expressionBuilder
34
+ - [expressions.ts](references/expressions.ts) - CASE, $if, subqueries, eb.val/lit/not, standalone eb, dynamic refs
31
35
 
32
36
  ## Core Principles
33
37
 
@@ -177,6 +181,9 @@ const user = await db.selectFrom("user").selectAll()
177
181
  .where("name", "like", "%search%")
178
182
  .where("deleted_at", "is", null)
179
183
 
184
+ // BETWEEN - use eb.between(), NOT the "between" operator (see Pitfall #8)
185
+ .where((eb) => eb.between("age", 18, 65))
186
+
180
187
  // Multiple conditions (chained = AND)
181
188
  .where("is_active", "=", true)
182
189
  .where("role", "=", "admin")
@@ -750,6 +757,115 @@ await db
750
757
  .executeTakeFirst();
751
758
  ```
752
759
 
760
+ ## Window Functions
761
+
762
+ Two builders cover almost everything. See [window-functions.ts](references/window-functions.ts) for the full set.
763
+
764
+ ```typescript
765
+ // Named window functions (no dedicated helper): eb.fn.agg<T>("NAME", [args])
766
+ .select((eb) => [
767
+ eb.fn.agg<number>("ROW_NUMBER")
768
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
769
+ .as("rank"),
770
+ // LAG/LEAD args go in the array; wrap literals in sql.lit()
771
+ eb.fn.agg<number | null>("LAG", ["total_amount", sql.lit(1)])
772
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
773
+ .as("prev_amount"),
774
+ ])
775
+
776
+ // Windowed aggregates: .over() on sum/count/avg/min/max
777
+ .select((eb) => [
778
+ eb.fn.sum<number>("total_amount").over((ob) => ob.orderBy("created_at")).as("running_total"),
779
+ eb.fn.avg<number>("price").over().as("grand_avg"), // empty OVER ()
780
+ // .filterWhere() and .distinct() compose with .over()
781
+ eb.fn.countAll<number>().filterWhere("status", "=", "completed")
782
+ .over((ob) => ob.partitionBy("user_id")).as("completed_for_user"),
783
+ ])
784
+ ```
785
+
786
+ **Filtering on a window result** (e.g. `row_number = 1`) needs a CTE/subquery — windows are computed after `WHERE`, so rank in a CTE then filter the outer query.
787
+
788
+ **Window frames (`ROWS`/`RANGE BETWEEN`) require raw SQL.** Kysely's `OverBuilder` exposes only `partitionBy`/`orderBy` — there is no frame node in its AST at all (true through 0.28, 0.29, and `main`), and `.over()` rejects a raw `sql` argument. Frame support is the open feature request [kysely-org/kysely#505](https://github.com/kysely-org/kysely/issues/505). Write the frame as raw SQL but keep column refs typed via `eb.ref()`:
789
+
790
+ ```typescript
791
+ // Only the function name + frame keywords are raw; columns stay validated.
792
+ sql<number>`avg(${eb.ref("total_amount")}) over (
793
+ order by ${eb.ref("created_at")} rows between 2 preceding and current row
794
+ )`.as("moving_avg_3")
795
+ ```
796
+
797
+ ## Set Operations (UNION / INTERSECT / EXCEPT)
798
+
799
+ Combine two compatible queries. Plain forms dedupe; `*All` forms keep duplicates (and are cheaper). See [set-operations.ts](references/set-operations.ts).
800
+
801
+ ```typescript
802
+ db.selectFrom("order").select("user_id")
803
+ .except(db.selectFrom("review").select("user_id")) // orders, never reviewed
804
+ .execute();
805
+ // .union/.unionAll, .intersect/.intersectAll, .except/.exceptAll
806
+ // Both branches must select matching columns/names — align with `as` aliases.
807
+ ```
808
+
809
+ ## LATERAL Joins (PostgreSQL)
810
+
811
+ A `LATERAL` subquery can reference earlier tables via `whereRef` and runs per outer row — the best tool for **top-N-per-group with a per-row LIMIT**. Use `*Lateral` + `join.onTrue()`. See [joins.ts](references/joins.ts).
812
+
813
+ ```typescript
814
+ // Each user's 3 most recent orders
815
+ db.selectFrom("user as u")
816
+ .innerJoinLateral(
817
+ (eb) => eb.selectFrom("order as o")
818
+ .select(["o.id", "o.total_amount", "o.created_at"])
819
+ .whereRef("o.user_id", "=", "u.id")
820
+ .orderBy("o.created_at", "desc").limit(3).as("recent"),
821
+ (join) => join.onTrue()
822
+ )
823
+ .select(["u.email", "recent.id", "recent.total_amount"])
824
+ .execute();
825
+ // leftJoinLateral / crossJoinLateral also exist.
826
+ ```
827
+
828
+ ## Row Locking (FOR UPDATE / SKIP LOCKED)
829
+
830
+ Pessimistic locks for read-modify-write and job queues (run inside a transaction). See [locking.ts](references/locking.ts).
831
+
832
+ ```typescript
833
+ // Job-queue worker: grab the next pending jobs, skipping rows other workers hold
834
+ db.selectFrom("job").selectAll()
835
+ .where("status", "=", "pending")
836
+ .orderBy("created_at").limit(10)
837
+ .forUpdate().skipLocked()
838
+ .execute();
839
+ // Lock strength: forKeyShare < forShare < forNoKeyUpdate < forUpdate
840
+ // Wait behavior: .skipLocked() (skip) or .noWait() (error instead of blocking)
841
+ ```
842
+
843
+ ## Full-Text Search (PostgreSQL)
844
+
845
+ `@@` is a real Kysely operator — keep it as the operator and put the FTS functions on each side as `sql` fragments. See [full-text-search.ts](references/full-text-search.ts).
846
+
847
+ ```typescript
848
+ db.selectFrom("document").selectAll()
849
+ .where(
850
+ sql`to_tsvector('english', ${sql.ref("body")})`,
851
+ "@@",
852
+ sql`websearch_to_tsquery('english', ${userInput})`,
853
+ )
854
+ .execute();
855
+ // A stored tsvector column can be the typed LHS directly:
856
+ // .where("search_vector", "@@", sql`plainto_tsquery('english', ${userInput})`)
857
+ ```
858
+
859
+ ## Advanced Grouping (ROLLUP / CUBE / GROUPING SETS)
860
+
861
+ No builder exists — pass the grouping spec to `.groupBy()` as a `sql` fragment; the SELECT list stays typed. See [aggregations.ts](references/aggregations.ts).
862
+
863
+ ```typescript
864
+ .groupBy(sql`rollup("status")`) // hierarchical subtotals
865
+ .groupBy(sql`cube("order_id", "product_id")`) // all combinations
866
+ .groupBy(sql`grouping sets (("status"), ("user_id"), ())`) // explicit sets
867
+ ```
868
+
753
869
  ## Migrations
754
870
 
755
871
  ### Configuration (kysely.config.ts)
@@ -980,6 +1096,19 @@ Now DATE columns return strings like `"2025-01-01"` and the frontend can parse/f
980
1096
 
981
1097
  **Note**: This applies to DATE columns only. TIMESTAMPTZ columns already handle timezones correctly by storing UTC and converting on read.
982
1098
 
1099
+ ### 8. Don't Use the `between` String Operator — It Emits Invalid SQL
1100
+
1101
+ The `"between"` operator looks like it should work but compiles to a tuple, which is a PostgreSQL syntax error:
1102
+
1103
+ ```typescript
1104
+ // WRONG - compiles to: "age" between ($1, $2) -> Postgres syntax error
1105
+ .where("age", "between", [18, 65])
1106
+
1107
+ // RIGHT - use the expression-builder helpers
1108
+ .where((eb) => eb.between("age", 18, 65)) // "age" between $1 and $2
1109
+ .where((eb) => eb.betweenSymmetric("age", 65, 18)) // swaps bounds if needed
1110
+ ```
1111
+
983
1112
  ## PostgreSQL Helpers Summary
984
1113
 
985
1114
  All helpers from `kysely/helpers/postgres`:
@@ -3,6 +3,7 @@
3
3
  * COUNT, SUM, AVG, GROUP BY, HAVING
4
4
  */
5
5
  import { db } from "./db";
6
+ import { sql } from "kysely";
6
7
 
7
8
  // ============================================
8
9
  // BASIC AGGREGATIONS
@@ -109,6 +110,44 @@ const groupsWithNoUnsigned = await db
109
110
  )
110
111
  .execute();
111
112
 
113
+ // ============================================
114
+ // ADVANCED GROUPING (ROLLUP / CUBE / GROUPING SETS)
115
+ // ============================================
116
+
117
+ // Kysely has no builder for these grouping constructs, so pass them to
118
+ // .groupBy() as a sql`` fragment. The SELECT list stays fully typed; only the
119
+ // grouping spec is raw. Rows with NULL in a grouped column are the subtotals.
120
+
121
+ // ROLLUP — hierarchical subtotals + grand total
122
+ // (type, region) -> (type) -> ()
123
+ const rollup = await db
124
+ .selectFrom("order")
125
+ .select((eb) => [
126
+ "status",
127
+ eb.fn.sum("total_amount").as("total"),
128
+ ])
129
+ .groupBy(sql`rollup("status")`)
130
+ .execute();
131
+ // SQL: group by rollup("status")
132
+
133
+ // CUBE — all combinations of the grouped columns
134
+ const cube = await db
135
+ .selectFrom("order_item")
136
+ .select((eb) => [
137
+ "order_id",
138
+ "product_id",
139
+ eb.fn.sum("quantity").as("qty"),
140
+ ])
141
+ .groupBy(sql`cube("order_id", "product_id")`)
142
+ .execute();
143
+
144
+ // GROUPING SETS — pick exactly which groupings to compute
145
+ const sets = await db
146
+ .selectFrom("order")
147
+ .select((eb) => ["status", "user_id", eb.fn.sum("total_amount").as("total")])
148
+ .groupBy(sql`grouping sets (("status"), ("user_id"), ())`)
149
+ .execute();
150
+
112
151
  // ============================================
113
152
  // AGGREGATE FUNCTIONS REFERENCE
114
153
  // ============================================
@@ -164,4 +203,9 @@ const productStats = await db
164
203
  - Replaces CASE WHEN ... inside aggregates for conditional aggregation
165
204
  - Works as the first argument to .having() for filtered aggregate conditions
166
205
  - Example: eb.fn.count("id").filterWhere("status", "=", "active").as("active_count")
206
+
207
+ 6. ROLLUP / CUBE / GROUPING SETS (no builder):
208
+ - Pass to .groupBy() as a sql`` fragment: .groupBy(sql`rollup("a", "b")`)
209
+ - SELECT list stays typed; subtotal rows have NULL in the rolled-up columns
210
+ - Windowed aggregates (sum/count/... .over()) live in window-functions.ts
167
211
  */
@@ -132,6 +132,44 @@ const categoryHierarchy = await db
132
132
  .selectAll()
133
133
  .execute();
134
134
 
135
+ // ============================================
136
+ // MATERIALIZED / NOT MATERIALIZED (PostgreSQL)
137
+ // ============================================
138
+
139
+ // PostgreSQL can inline a CTE into the outer query (so the planner optimizes
140
+ // across the boundary) or materialize it once into a temp result. Force either
141
+ // with a name-builder callback as the first arg to .with():
142
+ // (cte) => cte("name").materialized() -> WITH "name" AS MATERIALIZED (...)
143
+ // (cte) => cte("name").notMaterialized() -> WITH "name" AS NOT MATERIALIZED (...)
144
+
145
+ // MATERIALIZED: compute the CTE once and reuse it (good when it's expensive and
146
+ // referenced multiple times, or to deliberately create an optimization fence).
147
+ const expensiveOnce = await db
148
+ .with(
149
+ (cte) => cte("active_products").materialized(),
150
+ (db) =>
151
+ db
152
+ .selectFrom("product")
153
+ .select(["id", "name", "price"])
154
+ .where("is_active", "=", true)
155
+ )
156
+ .selectFrom("active_products")
157
+ .selectAll()
158
+ .execute();
159
+ // SQL: with "active_products" as materialized (select ...) select * from ...
160
+
161
+ // NOT MATERIALIZED: let the planner inline it (often lets index conditions from
162
+ // the outer query push down into the CTE).
163
+ const inlined = await db
164
+ .with(
165
+ (cte) => cte("recent_orders").notMaterialized(),
166
+ (db) =>
167
+ db.selectFrom("order").select(["id", "user_id"]).where("status", "=", "pending")
168
+ )
169
+ .selectFrom("recent_orders")
170
+ .selectAll()
171
+ .execute();
172
+
135
173
  // ============================================
136
174
  // KEY PATTERNS SUMMARY
137
175
  // ============================================
@@ -156,7 +194,12 @@ const categoryHierarchy = await db
156
194
  - Base case UNION ALL recursive case
157
195
  - Recursive case joins to the CTE itself
158
196
 
159
- 5. When to use CTEs:
197
+ 5. MATERIALIZED / NOT MATERIALIZED (PostgreSQL):
198
+ - First arg becomes a callback: (cte) => cte("name").materialized()
199
+ - materialized(): compute once (expensive + reused, or optimization fence)
200
+ - notMaterialized(): let the planner inline it (push predicates down)
201
+
202
+ 6. When to use CTEs:
160
203
  - Complex multi-step aggregations
161
204
  - Reusing a subquery multiple times
162
205
  - Breaking down complex logic
@@ -240,6 +240,27 @@ const filteredProducts = await db
240
240
  .$if(conditions.length > 0, (qb) => qb.where((eb) => eb.and(conditions)))
241
241
  .execute();
242
242
 
243
+ // ============================================
244
+ // DYNAMIC COLUMN REFERENCES (db.dynamic)
245
+ // ============================================
246
+
247
+ // When a column name is only known at runtime (e.g. a user-chosen sort field),
248
+ // db.dynamic.ref() injects it as a properly-quoted identifier — NOT as a value,
249
+ // and without dropping to sql``. Validate the name against an allowlist first;
250
+ // dynamic refs bypass the compile-time column check.
251
+ const sortable = ["first_name", "last_name", "email"] as const;
252
+ type Sortable = (typeof sortable)[number];
253
+
254
+ async function getUsersSortedBy(column: Sortable) {
255
+ const { ref } = db.dynamic;
256
+ return db
257
+ .selectFrom("user")
258
+ .select(["id", "email"])
259
+ .orderBy(ref(column)) // runtime column, still quoted as an identifier
260
+ .execute();
261
+ }
262
+ // ref("first_name") -> "first_name" (an identifier), never a bound parameter
263
+
243
264
  // ============================================
244
265
  // KEY PATTERNS SUMMARY
245
266
  // ============================================
@@ -269,4 +290,9 @@ const filteredProducts = await db
269
290
  6. Expression<SqlBool>[] arrays:
270
291
  - Build conditions dynamically
271
292
  - Combine with eb.and([...]) or eb.or([...])
293
+
294
+ 7. Dynamic column references: db.dynamic.ref(name)
295
+ - For runtime-chosen columns (e.g. sort fields)
296
+ - Emits a quoted identifier, not a bound value
297
+ - Bypasses the compile-time column check — allowlist the name first
272
298
  */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Full-Text Search (PostgreSQL)
3
+ * tsvector / tsquery matching with the @@ operator.
4
+ *
5
+ * Kysely has no dedicated FTS helpers, but @@ IS in its operator allowlist, so
6
+ * you build the document (to_tsvector / a tsvector column) and the query
7
+ * (to_tsquery family) with sql`` fragments and let @@ stay a typed operator.
8
+ * Keep columns type-checked via sql.ref(); only the FTS functions are raw.
9
+ *
10
+ * tsquery builders:
11
+ * to_tsquery - operator syntax: 'cat & dog', 'cat | dog', 'cat & !dog'
12
+ * plainto_tsquery - plain words, ANDed together (user-friendly)
13
+ * websearch_to_tsquery - Google-style: quotes for phrases, - to exclude
14
+ */
15
+ import { db } from "./db";
16
+ import { sql } from "kysely";
17
+
18
+ // ============================================
19
+ // MATCH ON A COMPUTED tsvector
20
+ // ============================================
21
+
22
+ // to_tsvector(...) @@ to_tsquery(...). @@ stays a real operator, so the LHS and
23
+ // RHS are the only raw parts; the column ref is validated by sql.ref().
24
+ const matches = await db
25
+ .selectFrom("document")
26
+ .selectAll()
27
+ .where(
28
+ sql`to_tsvector('english', ${sql.ref("body")})`,
29
+ "@@",
30
+ sql`to_tsquery('english', ${sql.lit("cat & dog")})`
31
+ )
32
+ .execute();
33
+ // SQL: where to_tsvector('english', "body") @@ to_tsquery('english', 'cat & dog')
34
+
35
+ // plainto_tsquery — turn free-text user input into an ANDed query safely
36
+ const userSearch = async (term: string) =>
37
+ db
38
+ .selectFrom("document")
39
+ .selectAll()
40
+ .where(
41
+ sql`to_tsvector('english', ${sql.ref("body")})`,
42
+ "@@",
43
+ sql`plainto_tsquery('english', ${term})`
44
+ )
45
+ .execute();
46
+
47
+ // websearch_to_tsquery — Google-style syntax ("phrase", -exclude, OR)
48
+ const webSearch = async (term: string) =>
49
+ db
50
+ .selectFrom("document")
51
+ .selectAll()
52
+ .where(
53
+ sql`to_tsvector('english', ${sql.ref("body")})`,
54
+ "@@",
55
+ sql`websearch_to_tsquery('english', ${term})`
56
+ )
57
+ .execute();
58
+
59
+ // ============================================
60
+ // MATCH ON A STORED tsvector COLUMN (preferred)
61
+ // ============================================
62
+
63
+ // Production setups store a tsvector column (often a GENERATED column with a
64
+ // GIN index) instead of computing to_tsvector at query time. Then @@ works
65
+ // directly against the column name — fully typed on the left.
66
+ const fastMatches = await db
67
+ .selectFrom("document")
68
+ .selectAll()
69
+ .where("search_vector", "@@", sql`to_tsquery('english', ${sql.lit("cat")})`)
70
+ .execute();
71
+ // SQL: where "search_vector" @@ to_tsquery('english', 'cat')
72
+
73
+ // ============================================
74
+ // RELEVANCE RANKING (ts_rank)
75
+ // ============================================
76
+
77
+ // ts_rank has no helper — use sql<number> and order by the alias.
78
+ const ranked = await db
79
+ .selectFrom("document")
80
+ .select((eb) => [
81
+ "id",
82
+ "title",
83
+ sql<number>`ts_rank(${eb.ref(
84
+ "search_vector"
85
+ )}, websearch_to_tsquery('english', ${"cat dog"}))`.as("rank"),
86
+ ])
87
+ .where(
88
+ "search_vector",
89
+ "@@",
90
+ sql`websearch_to_tsquery('english', ${"cat dog"})`
91
+ )
92
+ .orderBy("rank", "desc")
93
+ .limit(20)
94
+ .execute();
95
+
96
+ // ============================================
97
+ // KEY PATTERNS SUMMARY
98
+ // ============================================
99
+
100
+ /*
101
+ 1. @@ is a real Kysely operator — keep it as the operator and put the FTS
102
+ functions on either side as sql`` fragments. Don't wrap the whole predicate.
103
+
104
+ 2. Validate columns with sql.ref(); a stored tsvector column can be the typed
105
+ LHS directly (where("search_vector", "@@", ...)).
106
+
107
+ 3. Query builders by input source:
108
+ - to_tsquery: you control operator syntax (& | !).
109
+ - plainto_tsquery: untrusted plain words, ANDed.
110
+ - websearch_to_tsquery: Google-style user input.
111
+
112
+ 4. Rank with ts_rank/ts_rank_cd via sql<number>, then orderBy the alias.
113
+ For performance, store a tsvector column with a GIN index rather than
114
+ computing to_tsvector() per query.
115
+ */
@@ -183,6 +183,53 @@ const usersWithSummary = await db
183
183
  .where("u.role", "=", "admin")
184
184
  .execute();
185
185
 
186
+ // ============================================
187
+ // LATERAL JOINS (PostgreSQL)
188
+ // ============================================
189
+
190
+ // A LATERAL subquery can reference columns from earlier tables in the FROM
191
+ // clause via whereRef — the subquery runs once per outer row. Use the
192
+ // *Lateral methods + join.onTrue() (the join condition lives inside the
193
+ // subquery, so the ON is just "true").
194
+
195
+ // Top-N-per-group with a per-row LIMIT — the classic LATERAL use case.
196
+ // Get each user's 3 most recent orders.
197
+ const usersWithRecentOrders = await db
198
+ .selectFrom("user as u")
199
+ .innerJoinLateral(
200
+ (eb) =>
201
+ eb
202
+ .selectFrom("order as o")
203
+ .select(["o.id", "o.total_amount", "o.created_at"])
204
+ .whereRef("o.user_id", "=", "u.id") // references the outer row
205
+ .orderBy("o.created_at", "desc")
206
+ .limit(3)
207
+ .as("recent"),
208
+ (join) => join.onTrue()
209
+ )
210
+ .select(["u.email", "recent.id", "recent.total_amount", "recent.created_at"])
211
+ .execute();
212
+ // SQL: from "user" as "u" inner join lateral (... where "o"."user_id" = "u"."id"
213
+ // order by "o"."created_at" desc limit $1) as "recent" on true
214
+
215
+ // leftJoinLateral keeps outer rows even when the subquery returns nothing.
216
+ const usersWithMaybeOrder = await db
217
+ .selectFrom("user as u")
218
+ .leftJoinLateral(
219
+ (eb) =>
220
+ eb
221
+ .selectFrom("order as o")
222
+ .select(["o.id", "o.total_amount"])
223
+ .whereRef("o.user_id", "=", "u.id")
224
+ .orderBy("o.created_at", "desc")
225
+ .limit(1)
226
+ .as("latest"),
227
+ (join) => join.onTrue()
228
+ )
229
+ .select(["u.email", "latest.id", "latest.total_amount"]) // null if no orders
230
+ .execute();
231
+ // crossJoinLateral also exists (no ON clause at all).
232
+
186
233
  // ============================================
187
234
  // KEY PATTERNS SUMMARY
188
235
  // ============================================
@@ -203,4 +250,9 @@ const usersWithSummary = await db
203
250
 
204
251
  4. Cross joins: join.on(sql`true`, "=", sql`true`)
205
252
  - For joining unrelated data (like CTE summaries)
253
+
254
+ 5. Lateral joins: .innerJoinLateral((eb) => eb...whereRef(outer).as("x"),
255
+ (join) => join.onTrue())
256
+ - Subquery can reference outer rows; runs per row.
257
+ - Best tool for top-N-per-group with a per-row LIMIT.
206
258
  */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Row Locking (SELECT ... FOR ...)
3
+ * Pessimistic locks for read-modify-write and job-queue patterns.
4
+ *
5
+ * Lock strength (weakest -> strongest):
6
+ * .forKeyShare() -> FOR KEY SHARE
7
+ * .forShare() -> FOR SHARE
8
+ * .forNoKeyUpdate() -> FOR NO KEY UPDATE
9
+ * .forUpdate() -> FOR UPDATE
10
+ *
11
+ * Wait behavior (chain after a lock):
12
+ * .skipLocked() -> SKIP LOCKED (ignore already-locked rows)
13
+ * .noWait() -> NOWAIT (error instead of blocking)
14
+ */
15
+ import { db } from "./db";
16
+
17
+ // ============================================
18
+ // BASIC LOCKS
19
+ // ============================================
20
+
21
+ // FOR UPDATE — lock the selected rows until the transaction ends.
22
+ // Run inside a transaction; the lock is released on commit/rollback.
23
+ const lockedOrder = await db
24
+ .selectFrom("order")
25
+ .selectAll()
26
+ .where("id", "=", 1)
27
+ .forUpdate()
28
+ .executeTakeFirst();
29
+ // SQL: select * from "order" where "id" = $1 for update
30
+
31
+ // FOR SHARE — allow concurrent reads-with-lock, block writers.
32
+ const sharedRow = await db
33
+ .selectFrom("product")
34
+ .selectAll()
35
+ .where("id", "=", 1)
36
+ .forShare()
37
+ .executeTakeFirst();
38
+
39
+ // ============================================
40
+ // JOB QUEUE: FOR UPDATE SKIP LOCKED
41
+ // ============================================
42
+
43
+ // The canonical worker pattern: each worker grabs the next available job and
44
+ // SKIP LOCKED steps over rows other workers already hold — no blocking, no
45
+ // double-processing. Pair with a RETURNING update or do the work in the same tx.
46
+ const nextJobs = await db
47
+ .selectFrom("job")
48
+ .selectAll()
49
+ .where("status", "=", "pending")
50
+ .orderBy("created_at")
51
+ .limit(10)
52
+ .forUpdate()
53
+ .skipLocked()
54
+ .execute();
55
+ // SQL: ... for update skip locked
56
+
57
+ // NOWAIT — fail fast instead of waiting for a contended lock
58
+ const grabOrFail = await db
59
+ .selectFrom("order")
60
+ .selectAll()
61
+ .where("id", "=", 1)
62
+ .forUpdate()
63
+ .noWait()
64
+ .executeTakeFirst();
65
+ // SQL: ... for update nowait
66
+
67
+ // ============================================
68
+ // WEAKER UPDATE LOCKS
69
+ // ============================================
70
+
71
+ // FOR NO KEY UPDATE — like FOR UPDATE but doesn't block FK reference inserts.
72
+ // FOR KEY SHARE — weakest; blocks only key-changing updates/deletes.
73
+ const noKeyLock = await db
74
+ .selectFrom("user")
75
+ .selectAll()
76
+ .where("id", "=", 1)
77
+ .forNoKeyUpdate()
78
+ .executeTakeFirst();
79
+
80
+ // ============================================
81
+ // KEY PATTERNS SUMMARY
82
+ // ============================================
83
+
84
+ /*
85
+ 1. Always lock inside a transaction — locks release at commit/rollback.
86
+
87
+ 2. Job queue: .forUpdate().skipLocked() with .limit() + ORDER BY.
88
+ Workers never block each other and never grab the same row.
89
+
90
+ 3. .noWait() throws on contention; .skipLocked() silently skips — pick per use.
91
+
92
+ 4. Lock strength: forKeyShare < forShare < forNoKeyUpdate < forUpdate.
93
+ Prefer the weakest lock that still prevents your race.
94
+ */
@@ -121,6 +121,59 @@ const complexFilter = await db
121
121
  )
122
122
  .execute();
123
123
 
124
+ // ============================================
125
+ // BETWEEN — use eb.between(), NOT the "between" operator
126
+ // ============================================
127
+
128
+ // CORRECT: eb.between(col, lo, hi) / eb.betweenSymmetric(col, lo, hi)
129
+ const midPriced = await db
130
+ .selectFrom("product")
131
+ .selectAll()
132
+ .where((eb) => eb.between("price", "50", "100"))
133
+ .execute();
134
+ // SQL: where "price" between $1 and $2
135
+
136
+ // betweenSymmetric handles bounds given in either order (swaps if lo > hi)
137
+ const inRange = await db
138
+ .selectFrom("product")
139
+ .selectAll()
140
+ .where((eb) => eb.betweenSymmetric("price", "100", "50"))
141
+ .execute();
142
+ // SQL: where "price" between symmetric $1 and $2
143
+
144
+ // GOTCHA: the string-operator form generates INVALID SQL — do NOT use it.
145
+ // .where("price", "between", ["50", "100"])
146
+ // compiles to: "price" between ($1, $2) -- a tuple, which is a Postgres
147
+ // syntax error ("between (a, b)" is not "between a and b"). Always use
148
+ // eb.between() / eb.betweenSymmetric() instead.
149
+
150
+ // ============================================
151
+ // ANY (array / subquery membership)
152
+ // ============================================
153
+
154
+ // value = ANY(array_column) — type-safe with eb.fn.any
155
+ const taggedPremium = await db
156
+ .selectFrom("product")
157
+ .selectAll()
158
+ .where((eb) => eb(eb.val("premium"), "=", eb.fn.any("tags")))
159
+ .execute();
160
+ // SQL: where $1 = any("tags")
161
+
162
+ // value = ANY(subquery)
163
+ const ownersOfDogs = await db
164
+ .selectFrom("user")
165
+ .selectAll()
166
+ .where((eb) =>
167
+ eb(
168
+ eb.val("dog"),
169
+ "=",
170
+ eb.fn.any(
171
+ eb.selectFrom("pet").select("species").whereRef("pet.owner_id", "=", "user.id")
172
+ )
173
+ )
174
+ )
175
+ .execute();
176
+
124
177
  // ============================================
125
178
  // KEY PATTERNS SUMMARY
126
179
  // ============================================
@@ -143,4 +196,10 @@ const complexFilter = await db
143
196
  4. eb() inside where callbacks
144
197
  - eb("column", "=", value) creates comparison
145
198
  - Returns Expression<SqlBool> for composability
199
+
200
+ 5. BETWEEN: use eb.between(col, lo, hi) / eb.betweenSymmetric(...)
201
+ - The "between" string operator emits invalid SQL ("between (a, b)") — avoid.
202
+
203
+ 6. ANY: eb(eb.val(x), "=", eb.fn.any("array_col" | subquery))
204
+ - Membership test against an array column or a subquery result.
146
205
  */
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Set Operations (UNION / INTERSECT / EXCEPT)
3
+ * Combine the rows of two compatible queries.
4
+ *
5
+ * .union(q) / .unionAll(q) -> UNION / UNION ALL
6
+ * .intersect(q) / .intersectAll(q) -> INTERSECT / INTERSECT ALL
7
+ * .except(q) / .exceptAll(q) -> EXCEPT / EXCEPT ALL
8
+ *
9
+ * The plain forms remove duplicates; the *All forms keep them (and are cheaper).
10
+ * Both sides must select the same number of columns with compatible types and
11
+ * matching output names — align them with `as` aliases when they differ.
12
+ */
13
+ import { db } from "./db";
14
+
15
+ // ============================================
16
+ // UNION — distinct rows from both queries
17
+ // ============================================
18
+
19
+ // Align column names with aliases so both branches produce the same shape.
20
+ const contacts = await db
21
+ .selectFrom("user")
22
+ .select(["id", "email as contact"])
23
+ .union(db.selectFrom("supplier").select(["id", "contact_email as contact"]))
24
+ .execute();
25
+ // SQL: select "id", "email" as "contact" from "user"
26
+ // union select "id", "contact_email" as "contact" from "supplier"
27
+
28
+ // UNION ALL — keep duplicates (faster; no dedup pass)
29
+ const allEvents = await db
30
+ .selectFrom("order")
31
+ .select(["id", "created_at"])
32
+ .unionAll(db.selectFrom("review").select(["id", "created_at"]))
33
+ .execute();
34
+
35
+ // ============================================
36
+ // INTERSECT — rows present in BOTH queries
37
+ // ============================================
38
+
39
+ const usersWhoAreAlsoReviewers = await db
40
+ .selectFrom("order")
41
+ .select("user_id")
42
+ .intersect(db.selectFrom("review").select("user_id"))
43
+ .execute();
44
+
45
+ // INTERSECT ALL keeps duplicate matches
46
+ const repeated = await db
47
+ .selectFrom("order")
48
+ .select("user_id")
49
+ .intersectAll(db.selectFrom("review").select("user_id"))
50
+ .execute();
51
+
52
+ // ============================================
53
+ // EXCEPT — rows in the FIRST query but not the second
54
+ // ============================================
55
+
56
+ // Users who placed an order but never wrote a review.
57
+ const ordersWithoutReviews = await db
58
+ .selectFrom("order")
59
+ .select("user_id")
60
+ .except(db.selectFrom("review").select("user_id"))
61
+ .execute();
62
+
63
+ const ordersWithoutReviewsKeepDupes = await db
64
+ .selectFrom("order")
65
+ .select("user_id")
66
+ .exceptAll(db.selectFrom("review").select("user_id"))
67
+ .execute();
68
+
69
+ // ============================================
70
+ // ORDERING THE COMBINED RESULT
71
+ // ============================================
72
+
73
+ // orderBy/limit after a set op apply to the whole combined result.
74
+ const recentCombined = await db
75
+ .selectFrom("order")
76
+ .select(["id", "created_at"])
77
+ .unionAll(db.selectFrom("review").select(["id", "created_at"]))
78
+ .orderBy("created_at", "desc")
79
+ .limit(20)
80
+ .execute();
81
+
82
+ // ============================================
83
+ // KEY PATTERNS SUMMARY
84
+ // ============================================
85
+
86
+ /*
87
+ 1. Both branches must select the same columns (count + compatible types) with
88
+ matching output names — use `as` to line them up.
89
+
90
+ 2. Plain forms dedupe; *All forms keep duplicates and skip the dedup pass.
91
+ Reach for unionAll/intersectAll/exceptAll unless you actually need DISTINCT.
92
+
93
+ 3. EXCEPT/INTERSECT are set-difference / set-intersection on whole rows.
94
+
95
+ 4. orderBy/limit chained after the set op apply to the combined result, not the
96
+ individual branches.
97
+ */
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Window Functions
3
+ * Ranking, value, and aggregate functions over OVER() windows.
4
+ *
5
+ * Two builders cover almost everything:
6
+ * - eb.fn.agg<T>("NAME", [args]).over(...) for named window functions
7
+ * (ROW_NUMBER, RANK, LAG, ...) that have no dedicated helper
8
+ * - eb.fn.sum/count/avg/min/max(col).over(...) for windowed aggregates
9
+ *
10
+ * The OVER() body is built with a callback: (ob) => ob.partitionBy(...).orderBy(...)
11
+ * Call .over() with no callback for an empty OVER ().
12
+ *
13
+ * The one thing the builder CANNOT express is a frame clause
14
+ * (ROWS/RANGE/GROUPS BETWEEN) — see WINDOW FRAMES below.
15
+ */
16
+ import { db } from "./db";
17
+ import { sql } from "kysely";
18
+
19
+ // ============================================
20
+ // RANKING FUNCTIONS
21
+ // ============================================
22
+
23
+ // ROW_NUMBER / RANK / DENSE_RANK — use eb.fn.agg (no dedicated helper)
24
+ // Rank products by price within each category.
25
+ const rankedProducts = await db
26
+ .selectFrom("product")
27
+ .select((eb) => [
28
+ "id",
29
+ "name",
30
+ "category_id",
31
+ eb.fn
32
+ .agg<number>("ROW_NUMBER")
33
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
34
+ .as("row_num"),
35
+ eb.fn
36
+ .agg<number>("RANK")
37
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
38
+ .as("price_rank"),
39
+ eb.fn
40
+ .agg<number>("DENSE_RANK")
41
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
42
+ .as("dense_rank"),
43
+ ])
44
+ .execute();
45
+ // SQL: ROW_NUMBER() over(partition by "category_id" order by "price" desc) as "row_num"
46
+
47
+ // NTILE / PERCENT_RANK / CUME_DIST — NTILE takes an argument (use sql.lit)
48
+ const productBuckets = await db
49
+ .selectFrom("product")
50
+ .select((eb) => [
51
+ "id",
52
+ eb.fn
53
+ .agg<number>("NTILE", [sql.lit(4)])
54
+ .over((ob) => ob.orderBy("price", "desc"))
55
+ .as("price_quartile"),
56
+ eb.fn
57
+ .agg<number>("PERCENT_RANK")
58
+ .over((ob) => ob.orderBy("price", "desc"))
59
+ .as("pct_rank"),
60
+ ])
61
+ .execute();
62
+ // SQL: NTILE(4) over(order by "price" desc) as "price_quartile"
63
+
64
+ // ============================================
65
+ // VALUE FUNCTIONS (LAG / LEAD / FIRST_VALUE / NTH_VALUE)
66
+ // ============================================
67
+
68
+ // LAG / LEAD — args: (column, offset?, default?). Wrap literals in sql.lit().
69
+ // Type the result yourself; nullable unless you supply a default.
70
+ const orderTrends = await db
71
+ .selectFrom("order")
72
+ .select((eb) => [
73
+ "id",
74
+ "created_at",
75
+ "total_amount",
76
+ eb.fn
77
+ .agg<number | null>("LAG", ["total_amount", sql.lit(1)])
78
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
79
+ .as("prev_amount"),
80
+ eb.fn
81
+ .agg<number | null>("LEAD", ["total_amount", sql.lit(1)])
82
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
83
+ .as("next_amount"),
84
+ // With a default value (no nulls): LAG(total_amount, 1, 0)
85
+ eb.fn
86
+ .agg<number>("LAG", ["total_amount", sql.lit(1), sql.lit(0)])
87
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
88
+ .as("prev_amount_or_zero"),
89
+ ])
90
+ .execute();
91
+ // SQL: LAG("total_amount", 1, 0) over(partition by "user_id" order by "created_at") as "prev_amount_or_zero"
92
+
93
+ // FIRST_VALUE / NTH_VALUE
94
+ const categoryExtremes = await db
95
+ .selectFrom("product")
96
+ .select((eb) => [
97
+ "id",
98
+ "category_id",
99
+ eb.fn
100
+ .agg<number>("FIRST_VALUE", ["price"])
101
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
102
+ .as("highest_in_category"),
103
+ eb.fn
104
+ .agg<number | null>("NTH_VALUE", ["price", sql.lit(2)])
105
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
106
+ .as("second_highest"),
107
+ ])
108
+ .execute();
109
+
110
+ // ============================================
111
+ // AGGREGATE WINDOWS (sum/count/avg/min/max .over())
112
+ // ============================================
113
+
114
+ // Empty OVER () — aggregate over the whole result set, no collapsing of rows
115
+ const withGrandTotal = await db
116
+ .selectFrom("order")
117
+ .select((eb) => [
118
+ "id",
119
+ "total_amount",
120
+ eb.fn.sum<number>("total_amount").over().as("grand_total"),
121
+ ])
122
+ .execute();
123
+ // SQL: sum("total_amount") over() as "grand_total"
124
+
125
+ // Running total — ORDER BY without a frame defaults to
126
+ // "RANGE UNBOUNDED PRECEDING -> CURRENT ROW" (cumulative)
127
+ const runningTotals = await db
128
+ .selectFrom("order")
129
+ .select((eb) => [
130
+ "id",
131
+ "created_at",
132
+ "total_amount",
133
+ eb.fn
134
+ .sum<number>("total_amount")
135
+ .over((ob) => ob.orderBy("created_at"))
136
+ .as("running_total"),
137
+ ])
138
+ .execute();
139
+ // SQL: sum("total_amount") over(order by "created_at") as "running_total"
140
+
141
+ // Partition total + percent-of-partition (mix a window into a raw expression)
142
+ const sharePerCategory = await db
143
+ .selectFrom("product")
144
+ .select((eb) => [
145
+ "id",
146
+ "category_id",
147
+ "price",
148
+ eb.fn
149
+ .sum<number>("price")
150
+ .over((ob) => ob.partitionBy("category_id"))
151
+ .as("category_total"),
152
+ sql<number>`${eb.ref("price")} * 100.0 / ${eb.fn
153
+ .sum("price")
154
+ .over((ob) => ob.partitionBy("category_id"))}`.as("pct_of_category"),
155
+ ])
156
+ .execute();
157
+
158
+ // ============================================
159
+ // FILTER + DISTINCT IN WINDOWS (PostgreSQL)
160
+ // ============================================
161
+
162
+ // .filterWhere() composes with .over() — conditional windowed aggregation
163
+ const activeCounts = await db
164
+ .selectFrom("order")
165
+ .select((eb) => [
166
+ "id",
167
+ "user_id",
168
+ eb.fn
169
+ .countAll<number>()
170
+ .filterWhere("status", "=", "completed")
171
+ .over((ob) => ob.partitionBy("user_id"))
172
+ .as("completed_orders_for_user"),
173
+ ])
174
+ .execute();
175
+ // SQL: count(*) filter(where "status" = $1) over(partition by "user_id")
176
+
177
+ // COUNT(DISTINCT ...) OVER (...)
178
+ const distinctStatuses = await db
179
+ .selectFrom("order")
180
+ .select((eb) => [
181
+ "id",
182
+ "user_id",
183
+ eb.fn
184
+ .count<number>("status")
185
+ .distinct()
186
+ .over((ob) => ob.partitionBy("user_id"))
187
+ .as("distinct_statuses_for_user"),
188
+ ])
189
+ .execute();
190
+ // SQL: count(distinct "status") over(partition by "user_id")
191
+
192
+ // ============================================
193
+ // WINDOW FRAMES (ROWS/RANGE BETWEEN) — RAW SQL REQUIRED
194
+ // ============================================
195
+
196
+ // Kysely's OverBuilder exposes ONLY partitionBy + orderBy. There is no frame
197
+ // node in its AST at all (confirmed through 0.28, 0.29, and main), and
198
+ // .over() rejects a raw sql argument. Frame support is the open feature
199
+ // request kysely-org/kysely#505. So a frame clause must be written as raw SQL.
200
+ //
201
+ // You do NOT lose all type safety: interpolate eb.ref() for columns (validated)
202
+ // and sql.lit() for bounds, and type the result with sql<T>. Only the function
203
+ // name and the frame keywords are raw text.
204
+
205
+ // Moving average over the last 3 rows
206
+ const movingAverage = await db
207
+ .selectFrom("order")
208
+ .select((eb) => [
209
+ "id",
210
+ "created_at",
211
+ "total_amount",
212
+ sql<number>`avg(${eb.ref("total_amount")}) over (
213
+ order by ${eb.ref("created_at")}
214
+ rows between 2 preceding and current row
215
+ )`.as("moving_avg_3"),
216
+ ])
217
+ .execute();
218
+ // SQL: avg("total_amount") over ( order by "created_at" rows between 2 preceding and current row )
219
+
220
+ // Cumulative sum with an explicit frame
221
+ const cumulative = await db
222
+ .selectFrom("order")
223
+ .select((eb) => [
224
+ "id",
225
+ sql<number>`sum(${eb.ref("total_amount")}) over (
226
+ partition by ${eb.ref("user_id")}
227
+ order by ${eb.ref("created_at")}
228
+ rows between unbounded preceding and current row
229
+ )`.as("cumulative_sum"),
230
+ ])
231
+ .execute();
232
+
233
+ // ============================================
234
+ // COMMON PATTERNS
235
+ // ============================================
236
+
237
+ // Top-N per group: rank in a CTE, then filter in the outer query.
238
+ // (You can't filter on a window alias in the same SELECT's WHERE — windows are
239
+ // computed after WHERE — so the CTE/subquery wrapper is required.)
240
+ const top3ProductsPerCategory = await db
241
+ .with("ranked", (db) =>
242
+ db
243
+ .selectFrom("product")
244
+ .select((eb) => [
245
+ "id",
246
+ "name",
247
+ "category_id",
248
+ "price",
249
+ eb.fn
250
+ .agg<number>("ROW_NUMBER")
251
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
252
+ .as("rn"),
253
+ ])
254
+ )
255
+ .selectFrom("ranked")
256
+ .selectAll()
257
+ .where("rn", "<=", 3)
258
+ .execute();
259
+
260
+ // Percent of total (empty OVER () as the denominator)
261
+ const pctOfTotal = await db
262
+ .selectFrom("product")
263
+ .select((eb) => [
264
+ "id",
265
+ "price",
266
+ sql<number>`round(${eb.ref("price")} * 100.0 / ${eb.fn
267
+ .sum("price")
268
+ .over()}, 2)`.as("pct_of_total"),
269
+ ])
270
+ .execute();
271
+
272
+ // ============================================
273
+ // KEY PATTERNS SUMMARY
274
+ // ============================================
275
+
276
+ /*
277
+ 1. Named window functions: eb.fn.agg<T>("ROW_NUMBER").over((ob) => ...)
278
+ - ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST
279
+ - LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
280
+ - Function arguments go in the array; wrap literals in sql.lit().
281
+
282
+ 2. Windowed aggregates: eb.fn.sum/count/avg/min/max(col).over((ob) => ...)
283
+ - .over() with no callback => OVER () over the whole result set.
284
+ - .filterWhere(...) and .distinct() compose with .over().
285
+
286
+ 3. OVER body: (ob) => ob.partitionBy(col | [cols]).orderBy(col, "desc")
287
+
288
+ 4. Frames (ROWS/RANGE BETWEEN) are NOT supported by the builder (issue #505).
289
+ Write the window as raw sql, keeping eb.ref() for columns and sql<T> for the
290
+ result type. Only the function name + frame keywords are raw.
291
+
292
+ 5. Filtering on a window result (e.g. row_number = 1) needs a CTE/subquery:
293
+ windows are computed after WHERE, so wrap and filter in the outer query.
294
+ */