@querykitjs/prisma-pg 1.0.1 → 2.0.1
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.en.md +181 -11
- package/README.md +181 -11
- package/dist/index.cjs +1 -844
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -57
- package/dist/index.d.ts +160 -57
- package/dist/index.js +1 -837
- package/dist/index.js.map +1 -1
- package/package.json +21 -9
- package/skills/querykit-prisma-pg/SKILL.md +186 -0
package/README.en.md
CHANGED
|
@@ -31,16 +31,35 @@ const { data, meta } = await usersRepository.findList({
|
|
|
31
31
|
- **Soft delete** — automatic when the model has `deletedAt`; `softDelete`/`restore`/`withDeleted`.
|
|
32
32
|
- **Upsert / bulk** — `upsert`, `upsertMany`.
|
|
33
33
|
- **Framework-agnostic** — it knows nothing about HTTP: Express, Hono, NestJS, background jobs, all the same.
|
|
34
|
-
- **Cross-adapter parity** — the same surface and the same results as `@querykitjs/drizzle-pg` and `@querykitjs/mongoose
|
|
34
|
+
- **Cross-adapter parity** — the same surface and the same results as `@querykitjs/drizzle-pg` and `@querykitjs/mongoose`.
|
|
35
35
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
bun add @querykitjs/prisma-pg @prisma/client
|
|
40
|
-
# or: npm i @querykitjs/prisma-pg @prisma/client
|
|
39
|
+
bun add @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
|
|
40
|
+
# or: npm i @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`@prisma/client` (
|
|
43
|
+
`@prisma/client` (`^6.19 || ^7.10`) is a peer dependency; `@prisma/adapter-pg` and `pg` belong to your app (Prisma 7 requires a driver adapter). The package never **imports** your generated client: it reads model metadata off your client instance at runtime and derives types from the delegate you pass in.
|
|
44
|
+
|
|
45
|
+
### Requirements
|
|
46
|
+
|
|
47
|
+
| | Version | Tested (`bun run test:matrix`) |
|
|
48
|
+
| ----------------------- | ------------------ | ------------------------------ |
|
|
49
|
+
| Node.js | `>=22.12` | 24 |
|
|
50
|
+
| `@prisma/client` (peer) | `^6.19 \|\| ^7.10` | 6.19.3, 7.10.1 |
|
|
51
|
+
|
|
52
|
+
Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested.
|
|
53
|
+
|
|
54
|
+
### Prisma 7 and Prisma 6
|
|
55
|
+
|
|
56
|
+
The adapter reads model metadata from the client instance you pass in, so it works the same with either major. Both are tested (`test:matrix`) with the `prisma-client` generator and `@prisma/adapter-pg`.
|
|
57
|
+
|
|
58
|
+
- **Prisma 7** — generator `provider = "prisma-client"` with an `output` (e.g. `"../generated/prisma"`); the datasource `url` lives in `prisma.config.ts`, not in the schema; build the client with a driver adapter as in the Setup below.
|
|
59
|
+
- **Prisma 6.19** — the same generator and adapter work; keep `url = env("DATABASE_URL")` in the schema's `datasource` block. A `prisma-client-js` client created with `new PrismaClient()` keeps working too.
|
|
60
|
+
- **Pitfall — non-`public` schema:** with a driver adapter Prisma ignores a `?schema=` parameter in the connection string, so queries go to `public`. Pass the schema to the adapter: `new PrismaPg({ connectionString }, { schema: "app" })`.
|
|
61
|
+
|
|
62
|
+
**Migrating to Prisma 7:** switch the generator to `prisma-client` with an `output`, import `PrismaClient` from that output, install `@prisma/adapter-pg` + `pg` and pass `adapter`, and move the `url` to `prisma.config.ts`. Nothing changes in your `createRegistry` / repository code.
|
|
44
63
|
|
|
45
64
|
## Setup
|
|
46
65
|
|
|
@@ -48,15 +67,22 @@ bun add @querykitjs/prisma-pg @prisma/client
|
|
|
48
67
|
|
|
49
68
|
```ts
|
|
50
69
|
// db/registry.ts
|
|
51
|
-
import {
|
|
70
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
52
71
|
import { createRegistry } from "@querykitjs/prisma-pg";
|
|
72
|
+
import { PrismaClient } from "../generated/prisma/client"; // `prisma-client` generator output
|
|
53
73
|
|
|
54
|
-
export const prisma = new PrismaClient();
|
|
74
|
+
export const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
|
55
75
|
export const registry = createRegistry(prisma);
|
|
56
76
|
// or with pagination defaults:
|
|
57
77
|
// export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
|
|
58
78
|
```
|
|
59
79
|
|
|
80
|
+
`timestamps` — the createdAt / deletedAt field names (for example in a snake_case project): the default sort and the default `sortable` use that `createdAt`, soft delete (`softDelete`, `restore`, `withDeleted`) uses that `deletedAt`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
export const registry = createRegistry(prisma, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
|
|
84
|
+
```
|
|
85
|
+
|
|
60
86
|
### 2. Repository (per model)
|
|
61
87
|
|
|
62
88
|
A model is taken by its **handle**, the way drizzle-pg takes a table object and mongoose takes a `Model`. Its name (the delegate key, camelCase) works too: `User` → `"user"`, `LegalEntity` → `"legalEntity"`.
|
|
@@ -129,6 +155,39 @@ export class UsersController {
|
|
|
129
155
|
|
|
130
156
|
A validated payload goes into the repository params with **no cast** — the contract is closed through `@querykitjs/core`.
|
|
131
157
|
|
|
158
|
+
## Safe by default — `filterable`, `sortable`, `limits`
|
|
159
|
+
|
|
160
|
+
A client filters and sorts only by the fields the server allows, and only with the operators it allows. A refused request stops with a `QueryKitError` before Prisma is called — whatever `strict` and `onSkippedCondition` say (it is never dropped silently). Option names, defaults and `reason`/`site` are the same as drizzle-pg's.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const usersRepository = registry.repository(prisma.user, {
|
|
164
|
+
forcedColumns: { id: true, name: true, email: true, createdAt: true },
|
|
165
|
+
filterable: {
|
|
166
|
+
email: ["=", "in"],
|
|
167
|
+
createdAt: ["between", ">=", "<="],
|
|
168
|
+
name: ["contains"], // "contains" and its token "%_%" are one operator
|
|
169
|
+
"posts.title": ["="], // a relation field — "relation.field"
|
|
170
|
+
},
|
|
171
|
+
sortable: ["id", "createdAt", "name"],
|
|
172
|
+
limits: { maxInItems: 500 }, // over the registry's createRegistry(..., { limits })
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Option | When not given (default) | When refused |
|
|
177
|
+
| ------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
178
|
+
| `filterable` | `allowedColumns` (else the `forcedColumns` keys, else only the primary key), with `"="` and `"in"` only | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
|
|
179
|
+
| `sortable` | the primary key and `createdAt` (when the table has it); a client-sent `cursorKey` too | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
|
|
180
|
+
| `limits` | core `DEFAULT_QUERY_LIMITS` (depth 5, conditions 30, array 200, string 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
|
|
181
|
+
|
|
182
|
+
- `aggregate`: the `filter` and every `groupBy`/`sum`/`avg`/`min`/`max` column must be in `filterable` (`site: "aggregate"`).
|
|
183
|
+
- `findById`: only the client `filter` is checked; `idKey` is the server's choice.
|
|
184
|
+
- A key the table does **not** have (`nope`) stays `unknown-key` — skipped, or an error under `strict`.
|
|
185
|
+
- Mutation filters (`updateMany`, `deleteMany`, …) count as server code and are not checked.
|
|
186
|
+
- Operator aliases are one: `["="]` also admits `eq`, `["contains"]` also `%_%`. `"*"` admits every operator.
|
|
187
|
+
- The policy applies to **every** read call on the repository — the server's own filters in custom methods too (`findBySlug` needs `slug` in `filterable`). For internal queries on fields you do not open to clients, build a separate, wider repository and never hand it to a route.
|
|
188
|
+
- A relation field is opened or kept closed with a `"relation.field"` key (`posts.secret` not listed ⇒ `forbidden-key`). The adapter does not compile such a key into a filter (it stays `unknown-key`); use `raw()` for a relation predicate.
|
|
189
|
+
- An unbranded raw Prisma `where` object (`{ secret: { startsWith: "a" } }`) can come from a JSON body — `forbidden-key` (R19). Server code brands it with `raw({...})`; ⚠️ a `raw()` node is not checked by `filterable`, never build one from request data.
|
|
190
|
+
|
|
132
191
|
## Advanced filters
|
|
133
192
|
|
|
134
193
|
A filter is a tree of field conditions and logical groups (`and`/`or`/`not`). A flat array is implicit `AND`.
|
|
@@ -161,17 +220,19 @@ await usersRepository.findAll({
|
|
|
161
220
|
|
|
162
221
|
`contains`/`startsWith`/`endsWith` are case-**insensitive** (`mode: "insensitive"`), `like` is case-**sensitive**, `ilike` is insensitive — exactly as in the drizzle-pg adapter.
|
|
163
222
|
|
|
164
|
-
> **LIKE patterns.** Prisma has no raw SQL `LIKE` inside `where`, so the pattern is translated — **exactly**, so any pattern returns the same rows as on drizzle-pg: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, no wildcard → `equals`. Interior `%` and `_` are handled with two identities — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, and for a pattern with no `%`, `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (which pins the length). Only a **malformed** pattern (a dangling trailing `\`) is dropped and reported.
|
|
223
|
+
> **LIKE patterns.** Prisma has no raw SQL `LIKE` inside `where`, so the pattern is translated — **exactly**, so any pattern returns the same rows as on drizzle-pg: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, no wildcard → `equals`. Interior `%` and `_` are handled with two identities — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, and for a pattern with no `%`, `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (which pins the length). Only a **malformed** pattern (a dangling trailing `\`) is dropped and reported.
|
|
165
224
|
|
|
166
225
|
### Raw `where` escape hatch
|
|
167
226
|
|
|
168
|
-
For anything the DSL cannot express (relation predicates, JSON filters), pass a Prisma `where` object as a filter node:
|
|
227
|
+
For anything the DSL cannot express (relation predicates, JSON filters), pass a Prisma `where` object branded with `raw()` as a filter node (R19 — an unbranded object is `forbidden-key`; ⚠️ `raw()` is not checked by `filterable`):
|
|
169
228
|
|
|
170
229
|
```ts
|
|
171
|
-
|
|
230
|
+
import { raw } from "@querykitjs/core";
|
|
231
|
+
|
|
232
|
+
await usersRepository.findAll({ filter: raw({ posts: { some: { title: { contains: "querykit" } } } }) });
|
|
172
233
|
|
|
173
234
|
await usersRepository.findAll({
|
|
174
|
-
filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
|
|
235
|
+
filter: f.or(f.eq("role", "admin"), raw({ posts: { some: { published: true } } })),
|
|
175
236
|
});
|
|
176
237
|
```
|
|
177
238
|
|
|
@@ -219,6 +280,8 @@ Unknown keys are skipped; if no valid sort remains it falls back to `createdAt d
|
|
|
219
280
|
// 1) Offset — with total_items/total_pages
|
|
220
281
|
const list = await usersRepository.findList({ page: 2, perPage: 20 });
|
|
221
282
|
list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
|
|
283
|
+
// withTotal: false — no count query (large tables): perPage + 1 rows, total_items / total_pages = null
|
|
284
|
+
const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
|
|
222
285
|
|
|
223
286
|
// 2) Infinite scroll — no COUNT
|
|
224
287
|
const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
|
|
@@ -230,12 +293,45 @@ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
|
|
|
230
293
|
p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
|
|
231
294
|
```
|
|
232
295
|
|
|
296
|
+
### A stable order across offset pages
|
|
297
|
+
|
|
298
|
+
`findList` and `findInfinite` always end the order on the primary key, in the first key's direction: `sort: [{ key: "age", direction: "desc" }]` → `age` desc, then `id` desc. A row with an equal value cannot repeat or go missing between pages.
|
|
299
|
+
|
|
300
|
+
- The primary key does not count against `maxSortFields` and need not be in `sortable`; whether a client may sort by it does not change. A sort that already holds it gets no second copy.
|
|
301
|
+
- `findAll` and `findOne` keep their order: they do not page.
|
|
302
|
+
- A composite primary key adds every field. A table with no primary key keeps its order and raises no error.
|
|
303
|
+
- On a column with no NULLs the order equals `findCursor`'s. On an optional field `findList` sends `nulls` (first on asc, last on desc) and `findCursor` does not.
|
|
304
|
+
- ⚠️ **Index cost.** An index on `(col)` alone no longer serves this order directly: PostgreSQL may use an incremental sort. On a large table add `@@index([col, id])`; on an optional field the index needs the same NULL order. `findCursor` needs `(col, id)` too.
|
|
305
|
+
|
|
306
|
+
### Cursor: ties and signing
|
|
307
|
+
|
|
308
|
+
- The seek key is `(cursorKey, @id)`: with a non-unique `cursorKey` (for example a shared `createdAt`) no row is skipped or repeated. When `cursorKey` is the primary key itself the seek uses that one field.
|
|
309
|
+
- A token (v2) holds only scalar values (string, number, boolean, ISO date) and they are cast to the field type; an object, an array or a value that does not fit → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (the driver is not called).
|
|
310
|
+
- The registry option `cursorSecret` signs tokens with HMAC-SHA256; an unsigned or altered token → `invalid-value`. Without it tokens are unsigned — a client can build one itself (still only within the allowed `sortable` keys and the filter).
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
export const registry = createRegistry(prisma, { cursorSecret: process.env.CURSOR_SECRET });
|
|
314
|
+
```
|
|
315
|
+
|
|
233
316
|
`perPage`/`limit` are **capped** (`DEFAULT_MAX_PER_PAGE` = 200) — the repository clamps even if validation was bypassed.
|
|
234
317
|
|
|
235
318
|
> Cursor tokens are **byte-identical** to the drizzle-pg and mongoose adapters', so an old token keeps working if the backend swaps ORMs.
|
|
236
319
|
|
|
237
320
|
## Typed `columns` and `with`
|
|
238
321
|
|
|
322
|
+
A client `with` opens only the relations listed in `relations` (task 014, R21):
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const usersRepository = registry.repository("user", {
|
|
326
|
+
relations: { posts: { columns: ["id", "title"], filterable: { published: "*" } } },
|
|
327
|
+
});
|
|
328
|
+
await usersRepository.findAll({ with: { posts: { where: { published: true }, take: 5 } } }); // posts: id, title
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- `columns` is required — a list or an explicit `"*"`. `with: { rel: true }` also selects only those columns.
|
|
332
|
+
- An unlisted relation, column, filter field or nested relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` `relation` or `relation.field`; the driver is not called.
|
|
333
|
+
- Allowed options: `select`, a nested `include` (with `nested`), `where` (its fields in `filterable`), `take` / `skip`, `orderBy` on allowed columns.
|
|
334
|
+
|
|
239
335
|
```ts
|
|
240
336
|
const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
|
|
241
337
|
// ^? { id: number; email: string }[] — `name` is not even in the type
|
|
@@ -255,8 +351,29 @@ A `with` value may be `true` or a Prisma relation config:
|
|
|
255
351
|
await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
|
|
256
352
|
```
|
|
257
353
|
|
|
354
|
+
## Bulk update and delete
|
|
355
|
+
|
|
356
|
+
`updateWhere` / `deleteWhere` are one write (no read before it) and return `{ count }`:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
|
|
360
|
+
await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
|
|
361
|
+
|
|
362
|
+
// returning: { limit } — { count, rows }, at most limit rows (maxLimit, default 200)
|
|
363
|
+
const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
|
|
364
|
+
|
|
365
|
+
// no filter — only when you say so (the scope still applies)
|
|
366
|
+
await usersRepository.deleteWhere({ all: true });
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- An empty filter (`undefined`, `{}`, `[]`) or one whose every condition was skipped (for example only an unknown key) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — the driver is not called, the table is never wiped silently.
|
|
370
|
+
- A mutation filter passes `filterable` and `limits` like a read's (`forbidden-key`, `forbidden-operator`, `limit-exceeded`) — the server's own filters too.
|
|
371
|
+
- `updateWhere` `rows` come from one `updateManyAndReturn` (atomic). `deleteWhere` `rows` are read **just before** the delete (Prisma has no `deleteManyAndReturn`) — not atomic: a row changed in between may or may not be listed.
|
|
372
|
+
|
|
258
373
|
## Transactions
|
|
259
374
|
|
|
375
|
+
> ⚠️ Only **database work** inside the callback. External effects (HTTP calls, messages, email) go through an outbox: the driver may retry the transaction, and an external effect would run again and cannot be rolled back.
|
|
376
|
+
|
|
260
377
|
```ts
|
|
261
378
|
await registry.transaction(async () => {
|
|
262
379
|
const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
|
|
@@ -278,6 +395,12 @@ await mine.create({ title: "New" }); // authorId filled in automatically
|
|
|
278
395
|
|
|
279
396
|
The scope is `AND`ed into every read/write and merged into inserts (scope wins). If a scope key does not resolve on the model, building the repository **throws** — dropping it silently would remove the filter.
|
|
280
397
|
|
|
398
|
+
A scope value may also be a list (task 014): `{ shopId: { in: [1, 2] } }` limits every read and write to those values; an empty list (`{ in: [] }`) matches no row (not an error). `create` fills in the equality scopes only.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const mine = repository.scoped({ shopId: { in: user.shopIds } });
|
|
402
|
+
```
|
|
403
|
+
|
|
281
404
|
## Projection guards
|
|
282
405
|
|
|
283
406
|
```ts
|
|
@@ -318,13 +441,60 @@ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "regio
|
|
|
318
441
|
>
|
|
319
442
|
> **Bulk.** Prisma has no bulk upsert, so `upsertMany` issues sequential `upsert`s (input order preserved). Wrap large imports in `registry.transaction()`.
|
|
320
443
|
|
|
444
|
+
## Values, NULL order and parity
|
|
445
|
+
|
|
446
|
+
All three adapters (drizzle-pg, prisma-pg, mongoose) return the same result (task 021):
|
|
447
|
+
|
|
448
|
+
- **Literal value operators.** `contains`, `startsWith` and `endsWith` match their value literally: `startsWith "%"` finds only rows starting with `%`, `contains "_"` a real `_`. Wildcards belong to `like` / `ilike` / `notLike` only.
|
|
449
|
+
- **Type casting.** Every value is cast to its field type (number, boolean, date, uuid, enum). A mismatch (`age = "abc"`) is `invalid-value`: the condition is dropped and `onSkippedCondition` is called, a `QueryKitError` under `strict`; the driver is not called.
|
|
450
|
+
- **`not`** follows SQL three-valued logic: `not(and(…))` / `not(or(…))` over a NULL field agree on every adapter.
|
|
451
|
+
- **NULL order.** NULL first on `asc`, last on `desc` (MongoDB's order). prisma-pg emits `{ sort, nulls }` only where the field is known to be optional (see Limitations).
|
|
452
|
+
- **A malformed sort item** (`null`, `{}`) is dropped and reported as `invalid-value` (`site: "sort"`), an error under `strict`.
|
|
453
|
+
|
|
454
|
+
## Migrating to 2.0
|
|
455
|
+
|
|
456
|
+
In 2.0 a repository without a policy is **narrow**: `=` and `in` on `allowedColumns` / `forcedColumns` / the `@id` field only, sorting by `@id` and `createdAt`. To bring back the 1.x behaviour for one repository on purpose:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
const open = registry.repository(prisma.user, {
|
|
460
|
+
filterable: { id: "*", name: "*", email: "*", age: "*", createdAt: "*" },
|
|
461
|
+
sortable: ["id", "name", "email", "age", "createdAt"],
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
A raw `where` now needs `raw()` (R19):
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { raw } from "@querykitjs/core";
|
|
469
|
+
|
|
470
|
+
// 1.x — forbidden-key in 2.0:
|
|
471
|
+
// await usersRepository.findAll({ filter: { posts: { some: { published: true } } } });
|
|
472
|
+
|
|
473
|
+
// 2.0:
|
|
474
|
+
await usersRepository.findAll({ filter: raw({ posts: { some: { published: true } } }) });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
- First collect from `onSkippedCondition` logs which fields and operators are in use, then narrow `filterable` to exactly those — `"*"` is a stopgap.
|
|
478
|
+
- New `QueryKitError` reasons: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; new site: `page`. Return them as 400.
|
|
479
|
+
- Pages deeper than offset 10 000 are now refused — use `findCursor`, or raise it with `limits: { maxOffset }`.
|
|
480
|
+
- Cursor tokens are v2 now (task 012): an old (v1) `next_cursor` / `prev_cursor` is refused with `invalid-value` — after the upgrade start paging from the first page again.
|
|
481
|
+
- `updateWhere` / `deleteWhere` return `{ count }` instead of `Row[]` (task 013); for rows pass `{ returning: { limit } }` → `{ count, rows }`.
|
|
482
|
+
- A filterless `updateWhere` / `deleteWhere` is `empty-filter`; write `{ all: true }` for every row. A mutation filter now passes the `filterable` policy.
|
|
483
|
+
- `OffsetMeta.total_items` / `total_pages` are typed `number | null` (`null` with `withTotal: false`).
|
|
484
|
+
- `with` now needs the `relations` allow-list (task 014): on a repository without it `with` is `forbidden-key`, `site: "with"`.
|
|
485
|
+
- A scope value may be `{ in: [...] }`; the registry option `timestamps` names the createdAt / deletedAt fields.
|
|
486
|
+
|
|
321
487
|
## Limitations
|
|
322
488
|
|
|
323
489
|
- `updateById` / `deleteById` / `softDelete` / `restore` require a **single-field `@id`**. Prisma's `update`/`delete` accept only a unique `where`, while querykit's predicate also carries the scope and soft-delete guards. With a composite `@@id`, use `updateWhere`/`deleteWhere`.
|
|
324
490
|
- Filter/sort keys are **Prisma field names** (`createdAt`), not DB columns (`created_at`) — Prisma's own `where` does not accept a `@map`ped name either.
|
|
325
491
|
- Text operators only apply to `String` fields; on any other type the condition is dropped and reported.
|
|
326
492
|
|
|
327
|
-
|
|
493
|
+
These are pinned by `test/contract.ts`, which asserts all three adapters agree on the same payload.
|
|
494
|
+
|
|
495
|
+
- **`_` in `like` / `ilike`.** Prisma has no one-character wildcard: an unescaped `_` is `invalid-value` (an error under strict). Write `\\_` for a literal underscore.
|
|
496
|
+
- **uuid fields.** When the runtime model does not expose `@db.Uuid`, a malformed uuid reaches Postgres (22P02 / P2023) unless the field is declared: `fields: [{ name: "ref", nativeType: "Uuid" }]` — then it is `invalid-value`.
|
|
497
|
+
- **NULL order on Prisma 7.** Prisma 7's runtime model does not say whether a field is optional; `nulls` is emitted only when that is known. Declare it: `registry.repository(prisma.user, { fields: [{ name: "age", isRequired: false }] })` — otherwise that field keeps PostgreSQL's order.
|
|
328
498
|
|
|
329
499
|
## API reference
|
|
330
500
|
|
package/README.md
CHANGED
|
@@ -31,16 +31,35 @@ const { data, meta } = await usersRepository.findList({
|
|
|
31
31
|
- **Soft-delete** — `deletedAt` maydoni bo'lsa avtomatik; `softDelete`/`restore`/`withDeleted`.
|
|
32
32
|
- **Upsert / bulk** — `upsert`, `upsertMany`.
|
|
33
33
|
- **Framework-agnostik** — HTTP'ni bilmaydi: Express, Hono, NestJS, fon vazifalari — hammasiga tushadi.
|
|
34
|
-
- **Cross-adapter parity** — `@querykitjs/drizzle-pg` va `@querykitjs/mongoose` bilan bir xil yuza va bir xil natija
|
|
34
|
+
- **Cross-adapter parity** — `@querykitjs/drizzle-pg` va `@querykitjs/mongoose` bilan bir xil yuza va bir xil natija.
|
|
35
35
|
|
|
36
36
|
## O'rnatish
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
bun add @querykitjs/prisma-pg @prisma/client
|
|
40
|
-
# yoki: npm i @querykitjs/prisma-pg @prisma/client
|
|
39
|
+
bun add @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
|
|
40
|
+
# yoki: npm i @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`@prisma/client` (
|
|
43
|
+
`@prisma/client` (`^6.19 || ^7.10`) — peer dependency; `@prisma/adapter-pg` va `pg` — ilovangizning bog'liqligi (Prisma 7 driver adapter talab qiladi). Paket generatsiya qilingan client'ni **import qilmaydi**: model metadata'sini ish vaqtida sizning client instansiyangizdan o'qiydi, tiplarni esa siz uzatgan delegate'dan chiqaradi.
|
|
44
|
+
|
|
45
|
+
### Talablar
|
|
46
|
+
|
|
47
|
+
| | Versiya | Tekshiriladi (`bun run test:matrix`) |
|
|
48
|
+
| ----------------------- | ------------------ | ------------------------------------ |
|
|
49
|
+
| Node.js | `>=22.12` | 24 |
|
|
50
|
+
| `@prisma/client` (peer) | `^6.19 \|\| ^7.10` | 6.19.3, 7.10.1 |
|
|
51
|
+
|
|
52
|
+
Node 20 endi qo'llab-quvvatlanmaydi (`engines`: `>=22.12`). Peer diapazoni — oxirgi ikki major; ikkala cheti testda.
|
|
53
|
+
|
|
54
|
+
### Prisma 7 va Prisma 6
|
|
55
|
+
|
|
56
|
+
Adapter model metadata'sini siz bergan client instansiyasidan o'qiydi, shuning uchun ikkala major bilan bir xil ishlaydi. Ikkalasi ham `prisma-client` generatori va `@prisma/adapter-pg` bilan testda (`test:matrix`).
|
|
57
|
+
|
|
58
|
+
- **Prisma 7** — generator `provider = "prisma-client"` va `output` (masalan `"../generated/prisma"`); datasource `url` sxemada emas, `prisma.config.ts` da; client quyidagi Sozlash'dagidek driver adapter bilan yaratiladi.
|
|
59
|
+
- **Prisma 6.19** — xuddi shu generator va adapter ishlaydi; sxemaning `datasource` blokida `url = env("DATABASE_URL")` qoladi. `prisma-client-js` bilan `new PrismaClient()` orqali yaratilgan client ham ishlashda davom etadi.
|
|
60
|
+
- **Tuzoq — `public` bo'lmagan schema:** driver adapter bilan Prisma connection string'dagi `?schema=` parametrini e'tiborsiz qoldiradi va so'rovlar `public`ga ketadi. Schema'ni adapterga bering: `new PrismaPg({ connectionString }, { schema: "app" })`.
|
|
61
|
+
|
|
62
|
+
**Prisma 7 ga o'tish:** generatorni `output` bilan `prisma-client` ga almashtiring, `PrismaClient`ni shu chiqishdan import qiling, `@prisma/adapter-pg` + `pg` o'rnatib `adapter` bering, `url`ni `prisma.config.ts` ga ko'chiring. `createRegistry` / repository kodingizda hech narsa o'zgarmaydi.
|
|
44
63
|
|
|
45
64
|
## Sozlash
|
|
46
65
|
|
|
@@ -48,15 +67,22 @@ bun add @querykitjs/prisma-pg @prisma/client
|
|
|
48
67
|
|
|
49
68
|
```ts
|
|
50
69
|
// db/registry.ts
|
|
51
|
-
import {
|
|
70
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
52
71
|
import { createRegistry } from "@querykitjs/prisma-pg";
|
|
72
|
+
import { PrismaClient } from "../generated/prisma/client"; // `prisma-client` generator chiqishi
|
|
53
73
|
|
|
54
|
-
export const prisma = new PrismaClient();
|
|
74
|
+
export const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
|
55
75
|
export const registry = createRegistry(prisma);
|
|
56
76
|
// yoki pagination default'larini sozlab:
|
|
57
77
|
// export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
|
|
58
78
|
```
|
|
59
79
|
|
|
80
|
+
`timestamps` — createdAt / deletedAt maydon nomlari (masalan snake_case loyihada): default sort va default `sortable` shu `createdAt`ni, soft-delete (`softDelete`, `restore`, `withDeleted`) shu `deletedAt`ni ishlatadi.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
export const registry = createRegistry(prisma, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
|
|
84
|
+
```
|
|
85
|
+
|
|
60
86
|
### 2. Repository (model bo'yicha)
|
|
61
87
|
|
|
62
88
|
Model **handle** bilan olinadi — `drizzle-pg` jadval obyektini, `mongoose` esa `Model`ni qabul qilgani kabi. Nom (delegate kaliti, camelCase) ham qabul qilinadi: `User` → `"user"`, `LegalEntity` → `"legalEntity"`.
|
|
@@ -129,6 +155,39 @@ export class UsersController {
|
|
|
129
155
|
|
|
130
156
|
Validatsiyalangan payload repository params'iga **cast'siz** tushadi — kontrakt `@querykitjs/core` orqali yopilgan.
|
|
131
157
|
|
|
158
|
+
## Safe by default — `filterable`, `sortable`, `limits`
|
|
159
|
+
|
|
160
|
+
Mijoz faqat server ruxsat bergan maydon bo'yicha, faqat ruxsat bergan operator bilan filtrlaydi va saralaydi. Ruxsatsiz so'rov Prisma'ga yetmasdan `QueryKitError` bilan to'xtaydi — `strict` va `onSkippedCondition`dan qat'i nazar (jim tashlanmaydi). Opsiya nomlari, default'lar, `reason`/`site` — drizzle-pg bilan bir xil.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const usersRepository = registry.repository(prisma.user, {
|
|
164
|
+
forcedColumns: { id: true, name: true, email: true, createdAt: true },
|
|
165
|
+
filterable: {
|
|
166
|
+
email: ["=", "in"],
|
|
167
|
+
createdAt: ["between", ">=", "<="],
|
|
168
|
+
name: ["contains"], // "contains" va uning tokeni "%_%" — bitta operator
|
|
169
|
+
"posts.title": ["="], // relation maydoni — "relation.field"
|
|
170
|
+
},
|
|
171
|
+
sortable: ["id", "createdAt", "name"],
|
|
172
|
+
limits: { maxInItems: 500 }, // registry createRegistry(..., { limits }) ustidan
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Opsiya | Berilmasa (default) | Rad etilsa |
|
|
177
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
178
|
+
| `filterable` | `allowedColumns` (yo'q bo'lsa `forcedColumns` kalitlari, u ham yo'q bo'lsa faqat primary key), faqat `"="` va `"in"` | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
|
|
179
|
+
| `sortable` | primary key va `createdAt` (jadvalda bo'lsa); mijoz yuborgan `cursorKey` ham shu ro'yxatdan | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
|
|
180
|
+
| `limits` | core `DEFAULT_QUERY_LIMITS` (chuqurlik 5, shartlar 30, massiv 200, satr 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
|
|
181
|
+
|
|
182
|
+
- `aggregate`: `filter` va har `groupBy`/`sum`/`avg`/`min`/`max` ustuni `filterable` ichida bo'lishi shart (`site: "aggregate"`).
|
|
183
|
+
- `findById`: faqat mijoz `filter`i tekshiriladi; `idKey` — server tanlovi.
|
|
184
|
+
- Jadvalda **yo'q** kalit (`nope`) avvalgidek `unknown-key` — skip yoki `strict`da xato.
|
|
185
|
+
- Mutatsiya metodlari (`updateMany`, `deleteMany`, …) filtri server kodi deb hisoblanadi va tekshirilmaydi.
|
|
186
|
+
- Operator aliaslari bitta: `["="]` `eq`ni ham, `["contains"]` `%_%`ni ham ochadi. `"*"` — barcha operatorlar.
|
|
187
|
+
- Siyosat repository'ning **har** o'qish chaqiruviga qo'llanadi — custom metodlardagi server filtrlari ham (`findBySlug` uchun `slug` `filterable`da). Mijozga ochilmaydigan maydon bo'yicha ichki so'rov uchun alohida, kengroq repository yarating va uni route'ga bermang.
|
|
188
|
+
- Relation maydoni `"relation.field"` kaliti bilan yopiladi yoki ochiladi (`posts.secret` — ro'yxatda bo'lmasa `forbidden-key`). Adapter bunday kalitni filtr sifatida kompilyatsiya qilmaydi (avvalgidek `unknown-key`); relation predikati uchun `raw()` ishlating.
|
|
189
|
+
- Brendsiz xom Prisma `where` obyekti (`{ secret: { startsWith: "a" } }`) JSON'dan kelishi mumkin — `forbidden-key` (R19). Server kodi `raw({...})` bilan belgilaydi; ⚠️ `raw()` tuguni `filterable` bilan tekshirilmaydi, uni so'rov ma'lumotidan qurmang.
|
|
190
|
+
|
|
132
191
|
## Advanced filterlar
|
|
133
192
|
|
|
134
193
|
Filter — field shartlari va mantiqiy guruhlar (`and`/`or`/`not`) daraxti. Flat massiv implicit `AND`.
|
|
@@ -161,17 +220,19 @@ await usersRepository.findAll({
|
|
|
161
220
|
|
|
162
221
|
`contains`/`startsWith`/`endsWith` — **case-insensitive** (`mode: "insensitive"`), `like` — case-sensitive, `ilike` — insensitive. Bu drizzle-pg adapteri bilan aynan bir xil.
|
|
163
222
|
|
|
164
|
-
> **LIKE pattern'lari.** Prisma `where` ichida xom SQL `LIKE` yo'q, shuning uchun pattern tarjima qilinadi — lekin **aniq**, ya'ni har qanday pattern drizzle-pg bilan bir xil qatorlarni qaytaradi: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, wildcard'siz → `equals`. Ichki `%` va `_` uchun ikkita ayniyat ishlatiladi — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, va `%`siz pattern uchun `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (uzunlikni qadaydi). Faqat **buzuq** pattern (oxirida osilib qolgan `\`) tashlanadi va reportlanadi.
|
|
223
|
+
> **LIKE pattern'lari.** Prisma `where` ichida xom SQL `LIKE` yo'q, shuning uchun pattern tarjima qilinadi — lekin **aniq**, ya'ni har qanday pattern drizzle-pg bilan bir xil qatorlarni qaytaradi: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, wildcard'siz → `equals`. Ichki `%` va `_` uchun ikkita ayniyat ishlatiladi — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, va `%`siz pattern uchun `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (uzunlikni qadaydi). Faqat **buzuq** pattern (oxirida osilib qolgan `\`) tashlanadi va reportlanadi.
|
|
165
224
|
|
|
166
225
|
### Xom `where` escape-hatch
|
|
167
226
|
|
|
168
|
-
DSL ifodalay olmaydigan narsalar (relation predikatlari, `mode`, JSON filtrlari) uchun
|
|
227
|
+
DSL ifodalay olmaydigan narsalar (relation predikatlari, `mode`, JSON filtrlari) uchun Prisma `where` obyekti `raw()` bilan belgilanib tugun sifatida beriladi (R19 — brendsiz obyekt `forbidden-key`; ⚠️ `raw()` `filterable`dan o'tmaydi):
|
|
169
228
|
|
|
170
229
|
```ts
|
|
171
|
-
|
|
230
|
+
import { raw } from "@querykitjs/core";
|
|
231
|
+
|
|
232
|
+
await usersRepository.findAll({ filter: raw({ posts: { some: { title: { contains: "querykit" } } } }) });
|
|
172
233
|
|
|
173
234
|
await usersRepository.findAll({
|
|
174
|
-
filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
|
|
235
|
+
filter: f.or(f.eq("role", "admin"), raw({ posts: { some: { published: true } } })),
|
|
175
236
|
});
|
|
176
237
|
```
|
|
177
238
|
|
|
@@ -219,6 +280,8 @@ Noma'lum kalit skip qilinadi; yaroqli sort qolmasa `createdAt desc`, u ham bo'lm
|
|
|
219
280
|
// 1) Offset — total_items/total_pages bilan
|
|
220
281
|
const list = await usersRepository.findList({ page: 2, perPage: 20 });
|
|
221
282
|
list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
|
|
283
|
+
// withTotal: false — count so'ralmaydi (katta jadval): perPage + 1 qator, total_items / total_pages = null
|
|
284
|
+
const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
|
|
222
285
|
|
|
223
286
|
// 2) Infinite-scroll — COUNT'siz
|
|
224
287
|
const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
|
|
@@ -230,12 +293,45 @@ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
|
|
|
230
293
|
p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
|
|
231
294
|
```
|
|
232
295
|
|
|
296
|
+
### Offset sahifalarda barqaror tartib
|
|
297
|
+
|
|
298
|
+
`findList` va `findInfinite` tartibi doim primary key bilan tugaydi, birinchi kalit yo'nalishida: `sort: [{ key: "age", direction: "desc" }]` → `age` desc, keyin `id` desc. Teng qiymatli qator sahifalar orasida takrorlanmaydi va tushib qolmaydi.
|
|
299
|
+
|
|
300
|
+
- Primary key `maxSortFields` ga sanalmaydi va `sortable` da bo'lishi shart emas; mijoz u bo'yicha saralay olishi o'zgarmaydi. Sort'da allaqachon bo'lsa, qayta qo'shilmaydi.
|
|
301
|
+
- `findAll` va `findOne` tartibi o'zgarmaydi: ular sahifalamaydi.
|
|
302
|
+
- Composite primary key'da hamma maydon qo'shiladi. Primary key bo'lmasa, tartib avvalgidek va xato yo'q.
|
|
303
|
+
- NULL bo'lmagan ustunda tartib `findCursor` tartibi bilan bir xil. Optional maydonda `findList` `nulls` yozadi (asc'da first, desc'da last), `findCursor` yozmaydi.
|
|
304
|
+
- ⚠️ **Indeks narxi.** Faqat `(col)` indeksi bu tartibga to'g'ridan-to'g'ri xizmat qilmaydi: PostgreSQL incremental sort ishlatishi mumkin. Katta jadvalda `@@index([col, id])` qo'ying; optional maydonda indeks ham o'sha NULL tartibida bo'lishi kerak. `findCursor` ga ham `(col, id)` kerak.
|
|
305
|
+
|
|
306
|
+
### Cursor: teng qiymatlar va imzo
|
|
307
|
+
|
|
308
|
+
- Seek kaliti `(cursorKey, @id)`: `cursorKey` unikal bo'lmasa ham (masalan bir xil `createdAt`) hech bir qator tushib qolmaydi va takrorlanmaydi. `cursorKey` primary key'ning o'zi bo'lsa — bitta maydonli seek.
|
|
309
|
+
- Token (v2) ichida faqat skalyar qiymat (satr, son, boolean, ISO sana) bo'ladi va maydon tipiga cast qilinadi; obyekt, massiv yoki mos kelmaydigan qiymat → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (drayver chaqirilmaydi).
|
|
310
|
+
- Registry opsiyasi `cursorSecret` token'ni HMAC-SHA256 bilan imzolaydi; imzosiz yoki o'zgartirilgan token → `invalid-value`. Usiz token imzosiz — mijoz uni o'zi yasay oladi (faqat ruxsat etilgan `sortable` kalit va filter doirasida).
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
export const registry = createRegistry(prisma, { cursorSecret: process.env.CURSOR_SECRET });
|
|
314
|
+
```
|
|
315
|
+
|
|
233
316
|
`perPage`/`limit` **cheklangan** (`DEFAULT_MAX_PER_PAGE` = 200) — validatsiya chetlab o'tilsa ham repository o'zi clamp qiladi.
|
|
234
317
|
|
|
235
318
|
> Cursor tokeni `drizzle-pg` va `mongoose` adapterlari bilan **bayt-ma-bayt bir xil** — backend ORM'i almashsa ham eski token ishlayveradi.
|
|
236
319
|
|
|
237
320
|
## Tipli `columns` va `with`
|
|
238
321
|
|
|
322
|
+
Mijoz `with` bilan faqat `relations`da yozilgan relation'larni ochadi (task 014, R21):
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const usersRepository = registry.repository("user", {
|
|
326
|
+
relations: { posts: { columns: ["id", "title"], filterable: { published: "*" } } },
|
|
327
|
+
});
|
|
328
|
+
await usersRepository.findAll({ with: { posts: { where: { published: true }, take: 5 } } }); // posts: id, title
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- `columns` majburiy — ro'yxat yoki aniq `"*"`. `with: { rel: true }` ham faqat shu ustunlarni tanlaydi.
|
|
332
|
+
- Ro'yxatda yo'q relation, ustun, filtr maydoni yoki ichki relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` — `relation` yoki `relation.field`; drayver chaqirilmaydi.
|
|
333
|
+
- Ruxsat etilgan opsiyalar: `select`, ichki `include` (`nested` bilan), `where` (maydonlari `filterable`da), `take` / `skip`, ruxsat etilgan ustunlar bo'yicha `orderBy`.
|
|
334
|
+
|
|
239
335
|
```ts
|
|
240
336
|
const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
|
|
241
337
|
// ^? { id: number; email: string }[] — `name` tipda ham yo'q
|
|
@@ -255,8 +351,29 @@ const both = await usersRepository.findAll({ columns: { id: true }, with: { post
|
|
|
255
351
|
await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
|
|
256
352
|
```
|
|
257
353
|
|
|
354
|
+
## Ommaviy yangilash va o'chirish
|
|
355
|
+
|
|
356
|
+
`updateWhere` / `deleteWhere` — bitta yozish so'rovi (oldin o'qish yo'q), natija `{ count }`:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
|
|
360
|
+
await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
|
|
361
|
+
|
|
362
|
+
// returning: { limit } — `{ count, rows }`, ko'pi bilan limit ta qator (maxLimit, default 200)
|
|
363
|
+
const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
|
|
364
|
+
|
|
365
|
+
// filtrsiz — faqat aniq ko'rsatilsa (scope baribir qo'llanadi)
|
|
366
|
+
await usersRepository.deleteWhere({ all: true });
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- Bo'sh filtr (`undefined`, `{}`, `[]`) yoki hamma sharti tashlab yuborilgan filtr (masalan faqat noma'lum kalit) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — drayver chaqirilmaydi, jadval jim o'chmaydi.
|
|
370
|
+
- Mutatsiya filtri o'qishdagidek `filterable` va `limits`dan o'tadi (`forbidden-key`, `forbidden-operator`, `limit-exceeded`). Server'ning o'z filtri ham.
|
|
371
|
+
- `updateWhere` `rows` — bitta `updateManyAndReturn` natijasi (atomar). `deleteWhere` `rows` — o'chirishdan **oldin** o'qiladi (Prisma'da `deleteManyAndReturn` yo'q) — atomar emas: orada o'zgargan qator ro'yxatda bo'lishi yoki bo'lmasligi mumkin.
|
|
372
|
+
|
|
258
373
|
## Tranzaksiyalar
|
|
259
374
|
|
|
375
|
+
> ⚠️ Callback ichida **faqat DB amallari**. Tashqi amallar (HTTP, xabar, email) — outbox orqali: drayver tranzaksiyani qayta urinishi mumkin, tashqi amal esa takrorlanadi va qaytarib bo'lmaydi.
|
|
376
|
+
|
|
260
377
|
```ts
|
|
261
378
|
await registry.transaction(async () => {
|
|
262
379
|
const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
|
|
@@ -278,6 +395,12 @@ await mine.create({ title: "Yangi" }); // authorId avtomatik qo'yiladi
|
|
|
278
395
|
|
|
279
396
|
Scope har bir o'qish/yozishga `AND` bilan qo'shiladi va insert'larga singdiriladi (scope ustun keladi). Scope kaliti model'da topilmasa repository **yaratilishida** xato beradi — jimgina tushib qolsa filtr yo'qolardi.
|
|
280
397
|
|
|
398
|
+
Scope qiymati ro'yxat ham bo'lishi mumkin (task 014): `{ shopId: { in: [1, 2] } }` — har o'qish va yozish shu qiymatlar bilan cheklanadi; bo'sh ro'yxat (`{ in: [] }`) — hech qanday qator (xato emas). `create` faqat tenglik scope'larini to'ldiradi.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const mine = repository.scoped({ shopId: { in: user.shopIds } });
|
|
402
|
+
```
|
|
403
|
+
|
|
281
404
|
## Projeksiya guard'lari
|
|
282
405
|
|
|
283
406
|
```ts
|
|
@@ -318,13 +441,60 @@ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "regio
|
|
|
318
441
|
>
|
|
319
442
|
> **Bulk.** Prisma'da bulk upsert yo'q — `upsertMany` ketma-ket `upsert` qiladi (kirish tartibi saqlanadi). Katta importlar uchun `registry.transaction()` ichida chaqiring.
|
|
320
443
|
|
|
444
|
+
## Qiymatlar, NULL tartibi va bir xillik
|
|
445
|
+
|
|
446
|
+
Uchala adapter (drizzle-pg, prisma-pg, mongoose) bir xil natija beradi (task 021):
|
|
447
|
+
|
|
448
|
+
- **Literal qiymat operatorlari.** `contains`, `startsWith`, `endsWith` qiymatni harfma-harf qidiradi: `startsWith "%"` faqat `%` bilan boshlanuvchi qatorlar, `contains "_"` — haqiqiy `_`. Wildcard faqat `like` / `ilike` / `notLike` da.
|
|
449
|
+
- **Tip cast'i.** Har qiymat maydon tipiga keltiriladi (son, boolean, sana, uuid, enum). Mos kelmasa (`age = "abc"`) — `invalid-value`: shart tashlanadi va `onSkippedCondition` chaqiriladi, `strict` da `QueryKitError`; drayver chaqirilmaydi.
|
|
450
|
+
- **`not`** — SQL uch qiymatli mantiq: NULL maydon bo'yicha `not(and(…))` / `not(or(…))` hamma adapterda bir xil.
|
|
451
|
+
- **NULL tartibi.** `asc` — NULL birinchi, `desc` — NULL oxirida (MongoDB tartibi). prisma-pg `{ sort, nulls }` ni faqat maydon ixtiyoriy ekani ma'lum bo'lsa qo'yadi (pastdagi Cheklovlar).
|
|
452
|
+
- **Buzuq sort elementi** (`null`, `{}`) — tashlanadi va `invalid-value` (`site: "sort"`) bilan xabar qilinadi, `strict` da xato.
|
|
453
|
+
|
|
454
|
+
## Migrating to 2.0
|
|
455
|
+
|
|
456
|
+
2.0 da siyosatsiz repository **tor**: faqat `allowedColumns` / `forcedColumns` / `@id` maydoni bo'yicha `=` va `in`, sort — `@id` va `createdAt`. 1.x xulqini bitta repository uchun ataylab qaytarish:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
const open = registry.repository(prisma.user, {
|
|
460
|
+
filterable: { id: "*", name: "*", email: "*", age: "*", createdAt: "*" },
|
|
461
|
+
sortable: ["id", "name", "email", "age", "createdAt"],
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Xom `where` endi `raw()` bilan (R19):
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { raw } from "@querykitjs/core";
|
|
469
|
+
|
|
470
|
+
// 1.x — 2.0 da forbidden-key:
|
|
471
|
+
// await usersRepository.findAll({ filter: { posts: { some: { published: true } } } });
|
|
472
|
+
|
|
473
|
+
// 2.0:
|
|
474
|
+
await usersRepository.findAll({ filter: raw({ posts: { some: { published: true } } }) });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
- Avval `onSkippedCondition` loglaridan ishlatilayotgan maydon/operatorlarni yig'ing, keyin `filterable`ni aynan shularga toraytiring — `"*"` vaqtinchalik yo'l.
|
|
478
|
+
- `QueryKitError`ning yangi `reason`lari: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; yangi `site`: `page`. Ularni 400 qilib qaytaring.
|
|
479
|
+
- Offset 10 000 dan chuqur sahifalar endi rad etiladi — `findCursor` ishlating yoki `limits: { maxOffset }` bilan oshiring.
|
|
480
|
+
- Cursor token formati v2 (task 012): eski (v1) `next_cursor` / `prev_cursor` endi `invalid-value` bilan rad etiladi — yangilashdan keyin paginatsiyani birinchi sahifadan boshlang.
|
|
481
|
+
- `updateWhere` / `deleteWhere` endi `Row[]` emas, `{ count }` qaytaradi (task 013); qatorlar kerak bo'lsa `{ returning: { limit } }` → `{ count, rows }`.
|
|
482
|
+
- Filtrsiz `updateWhere` / `deleteWhere` → `empty-filter`; hamma qatorlar uchun `{ all: true }` yozing. Mutatsiya filtri endi `filterable` siyosatidan o'tadi.
|
|
483
|
+
- `OffsetMeta.total_items` / `total_pages` tipi `number | null` (`withTotal: false` bilan `null`).
|
|
484
|
+
- `with` endi faqat `relations` allow-list bilan ishlaydi (task 014): ro'yxatsiz repository'da `with` → `forbidden-key`, `site: "with"`.
|
|
485
|
+
- Scope qiymati `{ in: [...] }` bo'lishi mumkin; `timestamps` registry opsiyasi createdAt / deletedAt nomlarini beradi.
|
|
486
|
+
|
|
321
487
|
## Cheklovlar
|
|
322
488
|
|
|
323
489
|
- `updateById` / `deleteById` / `softDelete` / `restore` — **bitta maydonli `@id`** talab qiladi. Prisma `update`/`delete` faqat unique `where` qabul qiladi, querykit predikati esa scope va soft-delete guard'ini ham olib yuradi. Kompozit `@@id` bo'lsa `updateWhere`/`deleteWhere` ishlating.
|
|
324
490
|
- Filter/sort kalitlari — **Prisma maydon nomi** (`createdAt`), DB ustun nomi (`created_at`) emas. Prisma `where`ning o'zi ham `@map`langan nomni qabul qilmaydi.
|
|
325
491
|
- Matn operatorlari faqat `String` maydonlarda; boshqa tipda shart tashlanadi va reportlanadi.
|
|
326
492
|
|
|
327
|
-
|
|
493
|
+
Bu farqlar `test/contract.ts` bilan qadalgan — uchala adapter bir xil payloaddan bir xil natija berishi shu yerda tekshiriladi.
|
|
494
|
+
|
|
495
|
+
- **`like` / `ilike` da `_`.** Prisma'da bitta belgili wildcard yo'q: qochirilmagan `_` → `invalid-value` (strict'da xato). Literal `_` uchun `\\_` yozing.
|
|
496
|
+
- **uuid maydonlari.** Runtime model `@db.Uuid` ni bermasa, noto'g'ri uuid Postgres'ga yetadi (22P02 / P2023) — maydonni e'lon qiling: `fields: [{ name: "ref", nativeType: "Uuid" }]`, shunda `invalid-value`.
|
|
497
|
+
- **NULL tartibi Prisma 7 da.** Prisma 7 runtime modeli maydon ixtiyoriymi-yo'qligini bermaydi; `nulls` faqat ma'lum bo'lsa qo'yiladi. Ixtiyoriy maydonni e'lon qiling: `registry.repository(prisma.user, { fields: [{ name: "age", isRequired: false }] })` — aks holda o'sha maydonda PostgreSQL tartibi.
|
|
328
498
|
|
|
329
499
|
## API ma'lumotnoma
|
|
330
500
|
|