sonamu 0.10.7 → 0.10.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +5 -12
- package/dist/bin/cli.js +10 -253
- package/dist/database/base-model.d.ts +1 -0
- package/dist/database/base-model.d.ts.map +1 -1
- package/dist/database/base-model.js +14 -8
- package/dist/database/db.d.ts +2 -1
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +13 -3
- package/dist/database/puri-wrapper.d.ts.map +1 -1
- package/dist/database/puri-wrapper.js +4 -11
- package/dist/database/transaction-context.d.ts +8 -3
- package/dist/database/transaction-context.d.ts.map +1 -1
- package/dist/database/transaction-context.js +12 -7
- package/dist/migration/code-generation.js +2 -2
- package/dist/naite/naite-reporter.d.ts +8 -3
- package/dist/naite/naite-reporter.d.ts.map +1 -1
- package/dist/naite/naite-reporter.js +9 -4
- package/dist/ui-web/assets/{index-D0MHYbxl.js → index-CJf8uJYf.js} +47 -41
- package/dist/ui-web/assets/index-GMMIVGja.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/package.json +3 -3
- package/src/api/decorators.ts +11 -22
- package/src/bin/cli.ts +12 -375
- package/src/database/__tests__/transaction-scope.test.ts +227 -0
- package/src/database/base-model.ts +31 -6
- package/src/database/db.ts +17 -2
- package/src/database/puri-wrapper.ts +5 -22
- package/src/database/transaction-context.ts +14 -6
- package/src/migration/code-generation.ts +1 -1
- package/src/naite/naite-reporter.ts +8 -3
- package/dist/ui-web/assets/index-Dx_JX4aQ.css +0 -1
- package/src/skills/AGENTS.md +0 -108
- package/src/skills/sonamu/SKILL.md +0 -75
- package/src/skills/sonamu-ai-agents/SKILL.md +0 -205
- package/src/skills/sonamu-api/SKILL.md +0 -480
- package/src/skills/sonamu-auth/SKILL.md +0 -327
- package/src/skills/sonamu-auth/references/plugins.md +0 -310
- package/src/skills/sonamu-auth/references/user-id-migration-followups.md +0 -192
- package/src/skills/sonamu-auth/references/user-id-migration.md +0 -455
- package/src/skills/sonamu-config/SKILL.md +0 -203
- package/src/skills/sonamu-config/references/database.md +0 -452
- package/src/skills/sonamu-config/references/environments.md +0 -178
- package/src/skills/sonamu-config/references/server-options.md +0 -400
- package/src/skills/sonamu-entity/SKILL.md +0 -180
- package/src/skills/sonamu-entity/references/creation-workflow.md +0 -581
- package/src/skills/sonamu-entity/references/design-guides.md +0 -243
- package/src/skills/sonamu-entity/references/field-types.md +0 -170
- package/src/skills/sonamu-entity/references/relations-detail.md +0 -245
- package/src/skills/sonamu-entity/references/relations.md +0 -463
- package/src/skills/sonamu-entity/references/subset.md +0 -156
- package/src/skills/sonamu-fixture/SKILL.md +0 -180
- package/src/skills/sonamu-fixture/references/cli-usage.md +0 -439
- package/src/skills/sonamu-fixture/references/cone.md +0 -298
- package/src/skills/sonamu-frontend/SKILL.md +0 -142
- package/src/skills/sonamu-frontend/references/components.md +0 -323
- package/src/skills/sonamu-frontend/references/examples.md +0 -64
- package/src/skills/sonamu-frontend/references/hooks.md +0 -273
- package/src/skills/sonamu-frontend/references/runtime.md +0 -165
- package/src/skills/sonamu-frontend/references/scaffolding.md +0 -439
- package/src/skills/sonamu-i18n/SKILL.md +0 -287
- package/src/skills/sonamu-migration/SKILL.md +0 -316
- package/src/skills/sonamu-naite/SKILL.md +0 -266
- package/src/skills/sonamu-query/SKILL.md +0 -48
- package/src/skills/sonamu-query/references/model-patterns.md +0 -390
- package/src/skills/sonamu-query/references/model.md +0 -366
- package/src/skills/sonamu-query/references/puri.md +0 -413
- package/src/skills/sonamu-query/references/search.md +0 -238
- package/src/skills/sonamu-query/references/upsert.md +0 -324
- package/src/skills/sonamu-tasks/SKILL.md +0 -236
- package/src/skills/sonamu-testing/SKILL.md +0 -251
- package/src/skills/sonamu-testing/references/devrunner.md +0 -405
- package/src/skills/sonamu-testing/references/helpers.md +0 -185
- package/src/skills/sonamu-testing/references/patterns.md +0 -263
- package/src/skills/sonamu-testing/references/pitfalls.md +0 -588
- package/src/skills/sonamu-testing/references/quick-start.md +0 -285
- package/src/skills/sonamu-testing/references/type-safety.md +0 -172
- package/src/skills/sonamu-testing/references/writing-plan.md +0 -375
- package/src/skills/sonamu-vector/SKILL.md +0 -222
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
# Puri — Query Building
|
|
2
|
-
|
|
3
|
-
## Starting a Query
|
|
4
|
-
|
|
5
|
-
```typescript
|
|
6
|
-
// Read
|
|
7
|
-
const users = await this.getPuri("r").table("users").select({ id: "id", name: "username" });
|
|
8
|
-
|
|
9
|
-
// Write
|
|
10
|
-
await this.getPuri("w").table("users").where("id", 1).update({ is_active: false });
|
|
11
|
-
|
|
12
|
-
// Using aliases
|
|
13
|
-
const users = await db.table({ u: "users" }).select({ id: "u.id" });
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
### Where to obtain a Puri instance
|
|
17
|
-
|
|
18
|
-
Route every query — reads and writes alike — through Puri. Do NOT run queries directly on a raw `getDB()` knex handle (the only exceptions are migration files, `db.ts`, tests, and the `.knex` escape hatch below).
|
|
19
|
-
|
|
20
|
-
```typescript
|
|
21
|
-
// Inside a Model
|
|
22
|
-
const rows = await this.getPuri("r").table("users").select({ id: "id" });
|
|
23
|
-
|
|
24
|
-
// Inside a Frame — Frame has no getPuri (only getDB / getUpsertBuilder),
|
|
25
|
-
// so use the associated Model's getPuri
|
|
26
|
-
const rows = await UserModel.getPuri("r").table("users").select({ id: "id" });
|
|
27
|
-
|
|
28
|
-
// Outside a Model (seed / batch / monitoring scripts) — wrap knex in a PuriWrapper
|
|
29
|
-
import { DB, PuriWrapper, UpsertBuilder } from "sonamu";
|
|
30
|
-
const puri = new PuriWrapper(DB.getDB("r"), new UpsertBuilder());
|
|
31
|
-
const rows = await puri.table("users").select({ id: "id" });
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
> `UpsertBuilder` is a required constructor argument even for read-only wrappers; it simply stays unused for pure reads.
|
|
35
|
-
|
|
36
|
-
### Escape hatch: `.knex` for non-entity / framework-internal tables
|
|
37
|
-
|
|
38
|
-
Puri's typed `.table()` only knows registered entity tables. For framework-internal or unregistered tables (e.g. `workflow_runs`), reach the raw knex handle via `puri.knex` instead of calling `DB.getDB()` directly:
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
const puri = new PuriWrapper(DB.getDB("r"), new UpsertBuilder());
|
|
42
|
-
const runs = await puri.knex.table("workflow_runs").where("status", "running").select("*");
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## SELECT
|
|
46
|
-
|
|
47
|
-
> **CRITICAL: `.select()` must always be used with an object argument.**
|
|
48
|
-
>
|
|
49
|
-
> Passing a string argument causes the string to be spread character-by-character, generating incorrect SQL like `select "i" as "0", "d" as "1", ...`.
|
|
50
|
-
> Be especially careful when chaining after an `as any` cast.
|
|
51
|
-
>
|
|
52
|
-
> ```typescript
|
|
53
|
-
> // WRONG — character spread bug
|
|
54
|
-
> db.table("files").select("files.entity_id", "files.file_type");
|
|
55
|
-
>
|
|
56
|
-
> // CORRECT
|
|
57
|
-
> db.table("files").select({ entity_id: "files.entity_id", file_type: "files.file_type" });
|
|
58
|
-
> ```
|
|
59
|
-
|
|
60
|
-
```typescript
|
|
61
|
-
// Basic select
|
|
62
|
-
const users = await db.table("users").select({ id: "id", name: "username" });
|
|
63
|
-
|
|
64
|
-
// All columns
|
|
65
|
-
const users = await db.table("users").selectAll();
|
|
66
|
-
|
|
67
|
-
// Nested objects (auto-converted during hydration)
|
|
68
|
-
db.select({
|
|
69
|
-
id: "users.id",
|
|
70
|
-
parent: {
|
|
71
|
-
id: "parent.id",
|
|
72
|
-
name: "parent.name",
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// Append to existing select
|
|
77
|
-
db.select({ id: "id" }).appendSelect({ name: "username" });
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Static Functions (for SELECT)
|
|
81
|
-
|
|
82
|
-
### Aggregate Functions
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
// COUNT
|
|
86
|
-
const [{ total }] = await db.table("users").select({ total: Puri.count() });
|
|
87
|
-
const [{ cnt }] = await db.table("users").select({ cnt: Puri.count("id") });
|
|
88
|
-
|
|
89
|
-
// SUM / AVG / MAX / MIN
|
|
90
|
-
db.select({
|
|
91
|
-
totalAmount: Puri.sum("amount"),
|
|
92
|
-
avgPrice: Puri.avg("price"),
|
|
93
|
-
maxScore: Puri.max("score"),
|
|
94
|
-
minAge: Puri.min("age"),
|
|
95
|
-
});
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### String Functions
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
db.select({
|
|
102
|
-
fullName: Puri.concat("first_name", "' '", "last_name"),
|
|
103
|
-
upperName: Puri.upper("name"),
|
|
104
|
-
lowerEmail: Puri.lower("email"),
|
|
105
|
-
});
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### Raw SQL Expressions
|
|
109
|
-
|
|
110
|
-
Bind parameters can be passed as the second argument `params`. Do not interpolate values directly into SQL; use params instead.
|
|
111
|
-
|
|
112
|
-
```typescript
|
|
113
|
-
// Without parameters
|
|
114
|
-
db.select({
|
|
115
|
-
custom: Puri.rawString("COALESCE(nickname, username)"),
|
|
116
|
-
total: Puri.rawNumber("price * quantity"),
|
|
117
|
-
isActive: Puri.rawBoolean("status = 'active'"),
|
|
118
|
-
expireAt: Puri.rawDate("created_at + INTERVAL '30 days'"),
|
|
119
|
-
tags: Puri.rawStringArray("string_to_array(tags, ',')"),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Bind with params array (prevents SQL injection)
|
|
123
|
-
db.select({
|
|
124
|
-
score: Puri.rawNumber(
|
|
125
|
-
`word_similarity(?, items.title) * 5 + word_similarity(?, items.tags) * 2`,
|
|
126
|
-
[query, query],
|
|
127
|
-
),
|
|
128
|
-
label: Puri.rawString(`COALESCE(??, ?)`, ["items.name", "Unspecified"]),
|
|
129
|
-
});
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
## WHERE
|
|
133
|
-
|
|
134
|
-
```typescript
|
|
135
|
-
// Basic
|
|
136
|
-
db.where("role", "admin");
|
|
137
|
-
db.where("age", ">=", 18);
|
|
138
|
-
db.where("deleted_at", null); // IS NULL
|
|
139
|
-
db.where("deleted_at", "!=", null); // IS NOT NULL
|
|
140
|
-
|
|
141
|
-
// Multiple conditions (AND)
|
|
142
|
-
db.where("role", "admin").where("is_active", true);
|
|
143
|
-
|
|
144
|
-
// IN / NOT IN
|
|
145
|
-
db.whereIn("role", ["admin", "moderator"]);
|
|
146
|
-
db.whereNotIn("status", ["deleted", "banned"]);
|
|
147
|
-
|
|
148
|
-
// LIKE
|
|
149
|
-
db.where("email", "like", `%${keyword}%`);
|
|
150
|
-
|
|
151
|
-
// Raw WHERE
|
|
152
|
-
db.whereRaw("EXTRACT(YEAR FROM created_at) = ?", [2024]);
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### WHERE Grouping (Parentheses)
|
|
156
|
-
|
|
157
|
-
```typescript
|
|
158
|
-
// (role = 'admin' OR role = 'moderator') AND is_active = true
|
|
159
|
-
db.whereGroup((g) => {
|
|
160
|
-
g.where("role", "admin").orWhere("role", "moderator");
|
|
161
|
-
}).where("is_active", true);
|
|
162
|
-
|
|
163
|
-
// OR group
|
|
164
|
-
db.where("status", "active").orWhereGroup((g) => {
|
|
165
|
-
g.where("role", "admin").where("is_verified", true);
|
|
166
|
-
});
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### JSONB Containment (`@>`)
|
|
170
|
-
|
|
171
|
-
Use `whereJsonSupersetOf()` to require a JSONB column to contain a JSON value. The column and
|
|
172
|
-
containment value are checked against the generated Entity type, and the value is serialized and
|
|
173
|
-
bound internally.
|
|
174
|
-
|
|
175
|
-
```typescript
|
|
176
|
-
db.table("products").whereJsonSupersetOf("metadata", { warranty: 2 });
|
|
177
|
-
|
|
178
|
-
// Aliased columns are supported.
|
|
179
|
-
db.table({ p: "products" }).whereJsonSupersetOf("p.metadata", {
|
|
180
|
-
tags: ["featured"],
|
|
181
|
-
});
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Inside a WHERE group, both AND and OR variants are available:
|
|
185
|
-
|
|
186
|
-
```typescript
|
|
187
|
-
db.whereGroup((g) => {
|
|
188
|
-
g.where("status", "active").orWhereJsonSupersetOf("metadata", {
|
|
189
|
-
tags: ["featured"],
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Do not call `JSON.stringify()` yourself. For other JSONB operators such as `->>`, `->`, and `?`,
|
|
195
|
-
continue to use parameterized `whereRaw()`.
|
|
196
|
-
|
|
197
|
-
## JOIN
|
|
198
|
-
|
|
199
|
-
```typescript
|
|
200
|
-
// INNER JOIN
|
|
201
|
-
db.table("employees")
|
|
202
|
-
.join("users", "employees.user_id", "users.id")
|
|
203
|
-
.select({ empId: "employees.id", userName: "users.username" });
|
|
204
|
-
|
|
205
|
-
// LEFT JOIN
|
|
206
|
-
db.table("employees").leftJoin("departments", "employees.department_id", "departments.id");
|
|
207
|
-
|
|
208
|
-
// Using aliases
|
|
209
|
-
db.table({ e: "employees" })
|
|
210
|
-
.join({ u: "users" }, "e.user_id", "u.id")
|
|
211
|
-
.leftJoin({ d: "departments" }, "e.department_id", "d.id");
|
|
212
|
-
|
|
213
|
-
// Complex JOIN conditions with callback
|
|
214
|
-
db.table("orders").join("products", (j) => {
|
|
215
|
-
j.on("orders.product_id", "products.id").on("orders.store_id", "products.store_id");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// Subquery JOIN
|
|
219
|
-
const subquery = db
|
|
220
|
-
.table("order_items")
|
|
221
|
-
.select({ order_id: "order_id", total: Puri.sum("amount") })
|
|
222
|
-
.groupBy("order_id");
|
|
223
|
-
|
|
224
|
-
db.table("orders")
|
|
225
|
-
.join({ oi: subquery }, "orders.id", "oi.order_id")
|
|
226
|
-
.select({ id: "orders.id", total: "oi.total" });
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Reusing generated JOINs
|
|
230
|
-
|
|
231
|
-
Use `ensureJoin()` or `ensureLeftJoin()` when a Model adds a JOIN that may already have been added
|
|
232
|
-
by a Subset query.
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
db.table("patient_timeline_events")
|
|
236
|
-
.ensureJoin(
|
|
237
|
-
{ patient: "patients" },
|
|
238
|
-
"patient_timeline_events.patient_id",
|
|
239
|
-
"patient.id",
|
|
240
|
-
)
|
|
241
|
-
.where("patient.organization_id", organizationId);
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Puri compares JOINs by alias. If the alias, table, JOIN type, left column, and right column all
|
|
245
|
-
match, the existing JOIN is reused. If the alias is new, the JOIN is added. Reusing an alias with a
|
|
246
|
-
different definition throws an error before SQL execution.
|
|
247
|
-
|
|
248
|
-
Different aliases for the same physical table remain valid:
|
|
249
|
-
|
|
250
|
-
```typescript
|
|
251
|
-
db.table("documents")
|
|
252
|
-
.ensureJoin({ created_by: "users" }, "documents.created_by_id", "created_by.id")
|
|
253
|
-
.ensureJoin({ updated_by: "users" }, "documents.updated_by_id", "updated_by.id");
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
`ensureJoin()` and `ensureLeftJoin()` support table equality JOINs only. Callback and subquery JOINs
|
|
257
|
-
continue to use `join()` or `leftJoin()` and are not considered reusable.
|
|
258
|
-
|
|
259
|
-
## ORDER BY & LIMIT
|
|
260
|
-
|
|
261
|
-
```typescript
|
|
262
|
-
db.orderBy("created_at", "desc").limit(20).offset(40); // Page 3
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### NULLS position & array form
|
|
266
|
-
|
|
267
|
-
`orderBy` has two overloads:
|
|
268
|
-
|
|
269
|
-
1. Single column: `orderBy(column, direction?, nulls?)` — `nulls` is `"first" | "last"`
|
|
270
|
-
2. Array: `orderBy(entries[])` — each entry is a column string, a `Puri.raw*` SQL expression, or an object `{ column, order?, nulls? }`
|
|
271
|
-
|
|
272
|
-
```typescript
|
|
273
|
-
// Single column with NULLS position
|
|
274
|
-
db.orderBy("published_at", "desc", "last");
|
|
275
|
-
|
|
276
|
-
// Array form: multiple sort keys, per-key direction and NULLS
|
|
277
|
-
db.orderBy([
|
|
278
|
-
{ column: "is_pinned", order: "desc" },
|
|
279
|
-
{ column: "published_at", order: "desc", nulls: "last" },
|
|
280
|
-
"title", // bare string defaults to asc
|
|
281
|
-
]);
|
|
282
|
-
|
|
283
|
-
// Sort by a SQL expression
|
|
284
|
-
db.orderBy([Puri.rawNumber("view_count * 2"), { column: "id", order: "desc" }]);
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
## GROUP BY & HAVING
|
|
288
|
-
|
|
289
|
-
```typescript
|
|
290
|
-
db.table("orders")
|
|
291
|
-
.select({
|
|
292
|
-
userId: "user_id",
|
|
293
|
-
total: Puri.sum("amount"),
|
|
294
|
-
count: Puri.count(),
|
|
295
|
-
})
|
|
296
|
-
.groupBy("user_id")
|
|
297
|
-
.having("COUNT(*) > 5");
|
|
298
|
-
|
|
299
|
-
// Column, operator, value form
|
|
300
|
-
db.groupBy("user_id").having("count", ">", 10);
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
## INSERT
|
|
304
|
-
|
|
305
|
-
```typescript
|
|
306
|
-
// Basic INSERT
|
|
307
|
-
await db.table("users").insert({ username: "john", email: "john@test.com" });
|
|
308
|
-
|
|
309
|
-
// RETURNING
|
|
310
|
-
const [{ id }] = await db.table("users").insert({ username: "john" }).returning("id");
|
|
311
|
-
|
|
312
|
-
// Multiple columns RETURNING
|
|
313
|
-
const [row] = await db.table("users").insert({ username: "john" }).returning(["id", "created_at"]);
|
|
314
|
-
|
|
315
|
-
// All columns RETURNING
|
|
316
|
-
const [user] = await db.table("users").insert({ username: "john" }).returning("*");
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### INSERT onConflict (Upsert)
|
|
320
|
-
|
|
321
|
-
```typescript
|
|
322
|
-
// DO NOTHING
|
|
323
|
-
await db.table("users").insert({ id: 1, username: "john" }).onConflict("id"); // or .onConflict("id", "nothing")
|
|
324
|
-
|
|
325
|
-
// DO UPDATE - specific columns only
|
|
326
|
-
await db
|
|
327
|
-
.table("users")
|
|
328
|
-
.insert({ id: 1, username: "john", email: "new@test.com" })
|
|
329
|
-
.onConflict("id", { update: ["username", "email"] });
|
|
330
|
-
|
|
331
|
-
// DO UPDATE - with specified values
|
|
332
|
-
await db
|
|
333
|
-
.table("users")
|
|
334
|
-
.insert({ id: 1, username: "john" })
|
|
335
|
-
.onConflict("id", {
|
|
336
|
-
update: {
|
|
337
|
-
username: "updated_john",
|
|
338
|
-
updated_at: Puri.rawDate("NOW()"),
|
|
339
|
-
},
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
// Composite key conflict
|
|
343
|
-
await db
|
|
344
|
-
.table("user_settings")
|
|
345
|
-
.insert({ user_id: 1, key: "theme", value: "dark" })
|
|
346
|
-
.onConflict(["user_id", "key"], { update: ["value"] });
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
## UPDATE
|
|
350
|
-
|
|
351
|
-
```typescript
|
|
352
|
-
await db.table("users").where("id", 1).update({ username: "updated" });
|
|
353
|
-
|
|
354
|
-
// INCREMENT / DECREMENT
|
|
355
|
-
await db.table("users").where("id", 1).increment("points", 10);
|
|
356
|
-
await db.table("users").where("id", 1).decrement("credit", 100);
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
## DELETE
|
|
360
|
-
|
|
361
|
-
```typescript
|
|
362
|
-
await db.table("users").where("id", 1).delete();
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
## Result Methods
|
|
366
|
-
|
|
367
|
-
| Method | Returns | Description |
|
|
368
|
-
| -------------- | ------------------------- | ------------------------------- |
|
|
369
|
-
| `await query` | `T[]` | Array result (Puri is Thenable) |
|
|
370
|
-
| `first()` | `Promise<T \| undefined>` | First record |
|
|
371
|
-
| `pluck("col")` | `Promise<V[]>` | Array of a specific column only |
|
|
372
|
-
|
|
373
|
-
```typescript
|
|
374
|
-
const users = await db.table("users").select({ id: "id" }); // T[]
|
|
375
|
-
const user = await db.table("users").where("id", 1).first(); // T | undefined
|
|
376
|
-
const ids = await db.table("users").where("role", "admin").pluck("id"); // number[]
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
## Utilities
|
|
380
|
-
|
|
381
|
-
```typescript
|
|
382
|
-
// Inspect query string
|
|
383
|
-
const sql = db.table("users").where("id", 1).toQuery();
|
|
384
|
-
|
|
385
|
-
// Debug log output (prints query to console, then continues chaining)
|
|
386
|
-
await db.table("users").where("id", 1).debug().first();
|
|
387
|
-
|
|
388
|
-
// Clone a query
|
|
389
|
-
const baseQuery = db.table("users").where("is_active", true);
|
|
390
|
-
const query1 = baseQuery.clone().where("role", "admin");
|
|
391
|
-
const query2 = baseQuery.clone().where("role", "user");
|
|
392
|
-
|
|
393
|
-
// Clear parts of a query
|
|
394
|
-
db.clear("select"); // Clear SELECT clause
|
|
395
|
-
db.clear("order"); // Clear ORDER BY
|
|
396
|
-
db.clear("limit"); // Clear LIMIT
|
|
397
|
-
db.clear("offset"); // Clear OFFSET
|
|
398
|
-
|
|
399
|
-
// Remove a specific JOIN
|
|
400
|
-
db.clearJoin("alias");
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
## Transactions
|
|
404
|
-
|
|
405
|
-
```typescript
|
|
406
|
-
await this.getPuri("w").transaction(async (trx) => {
|
|
407
|
-
await trx.table("users").where("id", fromId).decrement("points", amount);
|
|
408
|
-
await trx.table("users").where("id", toId).increment("points", amount);
|
|
409
|
-
await trx.table("point_logs").insert({ from_id: fromId, to_id: toId, amount });
|
|
410
|
-
});
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
---
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
# Puri — Full-Text, Vector, and Fuzzy Search
|
|
2
|
-
|
|
3
|
-
## PostgreSQL Full-Text Search (tsvector)
|
|
4
|
-
|
|
5
|
-
### whereTsSearch
|
|
6
|
-
|
|
7
|
-
```typescript
|
|
8
|
-
// Basic search (websearch_to_tsquery, simple config)
|
|
9
|
-
db.whereTsSearch("search_vector", "search term");
|
|
10
|
-
|
|
11
|
-
// Specify config
|
|
12
|
-
db.whereTsSearch("search_vector", "search term", "korean");
|
|
13
|
-
|
|
14
|
-
// Detailed options
|
|
15
|
-
db.whereTsSearch("search_vector", "search term", {
|
|
16
|
-
parser: "plainto_tsquery", // websearch_to_tsquery | plainto_tsquery | phraseto_tsquery
|
|
17
|
-
config: "korean",
|
|
18
|
-
});
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### tsHighlight (Search Term Highlighting)
|
|
22
|
-
|
|
23
|
-
```typescript
|
|
24
|
-
db.select({
|
|
25
|
-
title: "title",
|
|
26
|
-
highlighted: Puri.tsHighlight("content", "search term"),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Options
|
|
30
|
-
db.select({
|
|
31
|
-
highlighted: Puri.tsHighlight("content", "search term", {
|
|
32
|
-
config: "korean",
|
|
33
|
-
startSel: "<mark>",
|
|
34
|
-
stopSel: "</mark>",
|
|
35
|
-
maxFragments: 3,
|
|
36
|
-
maxWords: 35,
|
|
37
|
-
minWords: 15,
|
|
38
|
-
}),
|
|
39
|
-
});
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### tsRank / tsRankCd (Search Ranking)
|
|
43
|
-
|
|
44
|
-
The first argument must be `Puri.toTsVector()` rather than a column name string.
|
|
45
|
-
|
|
46
|
-
```typescript
|
|
47
|
-
// Must wrap with toTsVector() (required)
|
|
48
|
-
db.select({
|
|
49
|
-
rank: Puri.tsRank(Puri.toTsVector("documents.search_vector"), "search term"),
|
|
50
|
-
})
|
|
51
|
-
.whereTsSearch("documents.search_vector", "search term")
|
|
52
|
-
.orderBy("rank", "desc");
|
|
53
|
-
|
|
54
|
-
// Specify config
|
|
55
|
-
db.select({
|
|
56
|
-
rank: Puri.tsRank(Puri.toTsVector("documents.title", "korean"), "search term"),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Options
|
|
60
|
-
db.select({
|
|
61
|
-
rank: Puri.tsRank(Puri.toTsVector("documents.title"), "search term", {
|
|
62
|
-
normalization: 1, // Document length normalization
|
|
63
|
-
weights: [0.1, 0.2, 0.4, 1.0], // D, C, B, A weights
|
|
64
|
-
}),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// tsRankCd (Cover Density)
|
|
68
|
-
db.select({
|
|
69
|
-
rank: Puri.tsRankCd(Puri.toTsVector("documents.title"), "search term"),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// tsRankCd options
|
|
73
|
-
db.select({
|
|
74
|
-
rank: Puri.tsRankCd(Puri.toTsVector("documents.title"), "search term", {
|
|
75
|
-
parser: "phraseto_tsquery",
|
|
76
|
-
normalization: 16,
|
|
77
|
-
}),
|
|
78
|
-
});
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## PGroonga Full-Text Search
|
|
84
|
-
|
|
85
|
-
### whereSearch
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
// Single column search
|
|
89
|
-
db.whereSearch("title", "search term");
|
|
90
|
-
|
|
91
|
-
// Multi-column search (requires same column composition as index)
|
|
92
|
-
db.whereSearch(["title", "content"], "search term");
|
|
93
|
-
|
|
94
|
-
// Weight options
|
|
95
|
-
db.whereSearch(["title", "content"], "search term", {
|
|
96
|
-
weights: [10, 1], // 10x weight on title
|
|
97
|
-
});
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
### score (Search Score)
|
|
101
|
-
|
|
102
|
-
```typescript
|
|
103
|
-
db.select({
|
|
104
|
-
id: "id",
|
|
105
|
-
title: "title",
|
|
106
|
-
score: Puri.score(),
|
|
107
|
-
})
|
|
108
|
-
.whereSearch("title", "search term")
|
|
109
|
-
.orderBy("score", "desc");
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### highlight (Highlighting)
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
// Single column
|
|
116
|
-
db.select({
|
|
117
|
-
highlighted: Puri.highlight("title", "search term"),
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Multiple columns (returns array)
|
|
121
|
-
db.select({
|
|
122
|
-
highlighted: Puri.highlight(["title", "content"], "search term"),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Array of search terms
|
|
126
|
-
db.select({
|
|
127
|
-
highlighted: Puri.highlight("title", ["term1", "term2"]),
|
|
128
|
-
});
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
## Vector Search (pgvector)
|
|
134
|
-
|
|
135
|
-
### vectorSimilarity
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
const embedding = await getEmbedding("search query");
|
|
139
|
-
|
|
140
|
-
// Default (cosine similarity)
|
|
141
|
-
const results = await db
|
|
142
|
-
.table("documents")
|
|
143
|
-
.select({ id: "id", title: "title" })
|
|
144
|
-
.vectorSimilarity("embedding", embedding);
|
|
145
|
-
// → SELECT *, 1 - (embedding <=> '[...]'::vector) as similarity
|
|
146
|
-
// ORDER BY embedding <=> '[...]'::vector
|
|
147
|
-
|
|
148
|
-
// L2 distance
|
|
149
|
-
db.vectorSimilarity("embedding", embedding, { method: "l2" });
|
|
150
|
-
|
|
151
|
-
// Inner product
|
|
152
|
-
db.vectorSimilarity("embedding", embedding, { method: "inner_product" });
|
|
153
|
-
|
|
154
|
-
// threshold filter
|
|
155
|
-
db.vectorSimilarity("embedding", embedding, {
|
|
156
|
-
method: "cosine",
|
|
157
|
-
threshold: 0.7, // only similarity >= 0.7
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// distinctOn (deduplicate)
|
|
161
|
-
db.vectorSimilarity("embedding", embedding, {
|
|
162
|
-
distinctOn: "document_id", // best similarity per document_id only
|
|
163
|
-
});
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
**Return value**: `similarity` column is automatically added
|
|
167
|
-
|
|
168
|
-
| method | similarity meaning | sort order |
|
|
169
|
-
| ------------- | ------------------------------------ | ---------- |
|
|
170
|
-
| cosine | 1 - distance (higher = more similar) | desc |
|
|
171
|
-
| l2 | distance (lower = more similar) | asc |
|
|
172
|
-
| inner_product | -distance (higher = more similar) | desc |
|
|
173
|
-
|
|
174
|
-
---
|
|
175
|
-
|
|
176
|
-
## pg_trgm Fuzzy Search
|
|
177
|
-
|
|
178
|
-
Requires `CREATE EXTENSION IF NOT EXISTS pg_trgm`. Typically used together with a generated column created via the `searchText` prop and a GIN index.
|
|
179
|
-
|
|
180
|
-
### whereFuzzy — Candidate Filtering
|
|
181
|
-
|
|
182
|
-
The SQL operand order differs per operator:
|
|
183
|
-
|
|
184
|
-
| operator | meaning | SQL |
|
|
185
|
-
| -------------- | ---------------------- | -------------------- |
|
|
186
|
-
| `<%` (default) | word similarity | `'query' <% column` |
|
|
187
|
-
| `%` | similarity | `column % 'query'` |
|
|
188
|
-
| `<<%` | strict word similarity | `'query' <<% column` |
|
|
189
|
-
|
|
190
|
-
```typescript
|
|
191
|
-
puri.whereFuzzy("items.search_text", query);
|
|
192
|
-
puri.whereFuzzy("items.search_text", query, { operator: "%" });
|
|
193
|
-
puri.whereFuzzy("items.search_text", query, { operator: "<<%" });
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
### Similarity Scores — Static Methods
|
|
197
|
-
|
|
198
|
-
Returns `SqlExpression<"number">`, so use it as a score column in select:
|
|
199
|
-
|
|
200
|
-
```typescript
|
|
201
|
-
// Puri.wordSimilarity(column, query) → word_similarity(?, ??)
|
|
202
|
-
// Puri.similarity(column, query) → similarity(??, ?)
|
|
203
|
-
// Puri.strictWordSimilarity(column, query) → strict_word_similarity(?, ??)
|
|
204
|
-
|
|
205
|
-
const results = await this.getPuri("r")
|
|
206
|
-
.table("items")
|
|
207
|
-
.whereFuzzy("items.search_text", query)
|
|
208
|
-
.select({
|
|
209
|
-
id: "items.id",
|
|
210
|
-
title: "items.title_ko",
|
|
211
|
-
score: Puri.rawNumber(
|
|
212
|
-
`word_similarity(?, items.title_ko) * 5 + word_similarity(?, items.title_en) * 3`,
|
|
213
|
-
[query, query],
|
|
214
|
-
),
|
|
215
|
-
})
|
|
216
|
-
.orderByRaw("score DESC");
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Language-specific Characteristics
|
|
220
|
-
|
|
221
|
-
| Language | Suitability | Notes |
|
|
222
|
-
| -------- | ----------- | ----------------------------------------------- |
|
|
223
|
-
| English | Excellent | Word-level splitting + word_similarity |
|
|
224
|
-
| Korean | Good | Performance degrades for 1-2 character searches |
|
|
225
|
-
| Japanese | Good | Performance degrades for 1-2 character searches |
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## Rules
|
|
230
|
-
|
|
231
|
-
- Puri is the standard for BOTH reads and writes in ALL contexts (Model, Frame, scripts) — do NOT run queries on a raw `DB.getDB()` handle (exceptions: migration files, `db.ts`, tests, and the `.knex` escape hatch)
|
|
232
|
-
- Inside a Frame, use the associated Model's `getPuri` (Frame exposes only `getDB` / `getUpsertBuilder`, not `getPuri`)
|
|
233
|
-
- Outside a Model, wrap knex with `new PuriWrapper(DB.getDB(which), new UpsertBuilder())`
|
|
234
|
-
- Use the `puri.knex` escape hatch only for non-entity / framework-internal tables (e.g. `workflow_runs`)
|
|
235
|
-
- MUST use `getPuri("r")` for read queries, `getPuri("w")` for write queries
|
|
236
|
-
- MUST include WHERE condition for UPDATE/DELETE operations
|
|
237
|
-
- MUST use `transaction()` for multiple write operations
|
|
238
|
-
- JSON/JSONB columns are automatically JSON.stringify'd on insert/update
|