@gallopsystems/agent-skills 1.6.0 → 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/README.md +0 -1
- package/package.json +1 -1
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +134 -5
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +44 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +44 -1
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +26 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/full-text-search.ts +115 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +52 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/locking.ts +94 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +59 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/set-operations.ts +97 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/window-functions.ts +294 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +1 -3
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +1 -1
- package/plugins/vue-nuxt/skills/vue-nuxt/SKILL.md +8 -4
- package/plugins/{nuxt-nitro-api/skills/nuxt-nitro-api → vue-nuxt/skills/vue-nuxt}/formatters.md +2 -1
- /package/plugins/{nuxt-nitro-api/skills/nuxt-nitro-api → vue-nuxt/skills/vue-nuxt}/page-structure.md +0 -0
package/README.md
CHANGED
|
@@ -95,7 +95,6 @@ Covers:
|
|
|
95
95
|
- useFetch vs $fetch vs useAsyncData
|
|
96
96
|
- Type inference (don't add manual types!)
|
|
97
97
|
- nuxt-auth-utils (OAuth, WebAuthn, middleware)
|
|
98
|
-
- Page structure (keep pages thin)
|
|
99
98
|
- Composables vs utils
|
|
100
99
|
- SSR + localStorage patterns
|
|
101
100
|
- Deep linking (URL params sync)
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
+
*/
|
|
@@ -31,9 +31,7 @@ For detailed patterns, see these topic-focused reference files:
|
|
|
31
31
|
- [auth-patterns.md](./auth-patterns.md) - nuxt-auth-utils, OAuth, WebAuthn, middleware
|
|
32
32
|
- [error-handling.md](./error-handling.md) - createError (fatal), error.vue, clearError, NuxtErrorBoundary
|
|
33
33
|
- [state-management.md](./state-management.md) - useState (never a module-scope ref), clearNuxtState, callOnce
|
|
34
|
-
- [page-structure.md](./page-structure.md) - Keep pages thin, components do the work
|
|
35
34
|
- [composables-utils.md](./composables-utils.md) - composables vs utils, runtimeConfig public/private, runWithContext
|
|
36
|
-
- [formatters.md](./formatters.md) - Centralize currency/date/number formatters in useFormatters, never inline
|
|
37
35
|
- [ssr-client.md](./ssr-client.md) - SSR + localStorage, hydration, useRequestURL/Headers, useCookie, VueUse
|
|
38
36
|
- [deep-linking.md](./deep-linking.md) - URL params sync with filters and useFetch
|
|
39
37
|
- [caching.md](./caching.md) - defineCachedFunction/EventHandler, SWR, per-key invalidation (Nitro v2)
|
|
@@ -62,7 +60,7 @@ Working examples from a Nuxt project:
|
|
|
62
60
|
2. **Use h3 validation** - `getValidatedQuery()`, `readValidatedBody()` with Zod schemas
|
|
63
61
|
3. **Composables for context, utils for pure functions** - Composables access Nuxt context, utils are pure
|
|
64
62
|
4. **SSR-safe code** - Guard browser APIs with `import.meta.client` or `onMounted`
|
|
65
|
-
5. **Keep pages thin** - Pages = layout + route params + components. Components own data fetching and logic.
|
|
63
|
+
5. **Keep pages thin** - Pages = layout + route params + components. Components own data fetching and logic. (Page composition + display formatting now live in the `vue-nuxt` skill.)
|
|
66
64
|
|
|
67
65
|
## Auto-Imports Quick Reference
|
|
68
66
|
|
|
@@ -82,7 +82,7 @@ export const usePermissions = () => {
|
|
|
82
82
|
> **Formatters belong in one shared place.** The examples below show util *placement*,
|
|
83
83
|
> not where to call formatters from. Never define a currency/date/number formatter inline
|
|
84
84
|
> at the call site — centralize them in `useFormatters` or a shared util, and prefer
|
|
85
|
-
> VueUse / date-fns over hand-rolling. See
|
|
85
|
+
> VueUse / date-fns over hand-rolling. See the `vue-nuxt` skill's `formatters.md`.
|
|
86
86
|
|
|
87
87
|
```typescript
|
|
88
88
|
// utils/formatting.ts
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vue-nuxt
|
|
3
|
-
description: Author Vue 3 components inside a Nuxt 4 app. Covers Nuxt auto-import rules, component authoring (props/emits/withDefaults/generics), v-model/defineModel, reactivity, when watch is a code smell, and Vue-shaped template idioms.
|
|
3
|
+
description: Author Vue 3 components inside a Nuxt 4 app. Covers Nuxt auto-import rules, component authoring (props/emits/withDefaults/generics), v-model/defineModel, slots, composables, reactivity, when watch is a code smell, page structure, display formatting, and Vue-shaped template idioms.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Vue-in-Nuxt component authoring
|
|
7
7
|
|
|
8
8
|
Patterns for writing Vue 3 `<script setup>` components inside a Nuxt 4 app. This
|
|
9
9
|
is the **frontend authoring** slice — the data layer (`useFetch`/`$fetch`, SSR
|
|
10
|
-
storage, hydration, `definePageMeta`/auth
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
storage, hydration, `definePageMeta`/auth) lives in the `nuxt-nitro-api` skill;
|
|
11
|
+
Volt/PrimeVue styling and dark mode live in `volt-primevue`. This skill
|
|
12
|
+
cross-links to those rather than restating them.
|
|
13
13
|
|
|
14
14
|
## When to Use This Skill
|
|
15
15
|
|
|
@@ -19,6 +19,8 @@ storage, hydration, `definePageMeta`/auth, formatters) lives in the
|
|
|
19
19
|
- Wiring `v-model` on a component
|
|
20
20
|
- Designing a component's content API — props vs slots, named/scoped slots
|
|
21
21
|
- Authoring a composable — argument shape, what to return, cleanup
|
|
22
|
+
- Structuring a page — thin pages, components own the data + logic
|
|
23
|
+
- Formatting display values (currency/date/number) consistently
|
|
22
24
|
- Anything reactivity-shaped: `computed` vs `watch`, prop→state sync, DOM measurement
|
|
23
25
|
- You see `watch` and want to know if it should be something else
|
|
24
26
|
|
|
@@ -32,6 +34,8 @@ storage, hydration, `definePageMeta`/auth, formatters) lives in the
|
|
|
32
34
|
- [reactivity.md](./reactivity.md) — `ref` over `reactive`, `useTemplateRef`, pure computeds, mutate-don't-reassign, DOM-measure + `ResizeObserver`, `shallowRef`, watch-getter prop sync, `:key` remount, listener cleanup
|
|
33
35
|
- [watch.md](./watch.md) — **`watch` is the escape hatch, not the default**: when it's right, and the four smell shapes (with refactors) found auditing 159 real watchers
|
|
34
36
|
- [template-idioms.md](./template-idioms.md) — duplicate-`@keyup` TS error, `:deep()`/`:slotted()`/`:global()`, click-outside marker class, `NuxtLink`/thin `app.vue`, `useHead`, `v-bind` shorthand, `useId`, `<Teleport>`/`<KeepAlive>`, `v-memo`/`v-once`, file-input reset
|
|
37
|
+
- [page-structure.md](./page-structure.md) — keep pages thin: route-param parsing + layout in the page, data/logic/forms in components
|
|
38
|
+
- [formatters.md](./formatters.md) — never inline a currency/date/number formatter; centralize in `useFormatters`, prefer Intl/date-fns
|
|
35
39
|
|
|
36
40
|
## Core Principles
|
|
37
41
|
|
package/plugins/{nuxt-nitro-api/skills/nuxt-nitro-api → vue-nuxt/skills/vue-nuxt}/formatters.md
RENAMED
|
@@ -84,7 +84,8 @@ let a blanket "convert all date fields to Date" transform touch DATE columns.
|
|
|
84
84
|
|
|
85
85
|
## Where to put the formatters
|
|
86
86
|
|
|
87
|
-
Pick the location by what the formatter needs (
|
|
87
|
+
Pick the location by what the formatter needs (the composable-vs-util decision
|
|
88
|
+
tree lives in the `nuxt-nitro-api` skill's `composables-utils.md`):
|
|
88
89
|
|
|
89
90
|
- **Needs Nuxt/Vue context** (e.g. reads locale/currency from `useRuntimeConfig()`,
|
|
90
91
|
`useI18n()`, or user preferences) → **composable** `useFormatters` in
|
/package/plugins/{nuxt-nitro-api/skills/nuxt-nitro-api → vue-nuxt/skills/vue-nuxt}/page-structure.md
RENAMED
|
File without changes
|