@querykitjs/prisma-pg 1.0.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 ADDED
@@ -0,0 +1,362 @@
1
+ <div align="right">
2
+
3
+ [English](./README.en.md) · **O'zbekcha**
4
+
5
+ </div>
6
+
7
+ # @querykitjs/prisma-pg
8
+
9
+ > Prisma (PostgreSQL) uchun **advanced filtering** va **moslashuvchan paginatsiya** beruvchi repository qatlami.
10
+
11
+ Prisma bilan qo'lda yozib chiqiladigan ko'p qismlarni — murakkab filtrlar, uch xil paginatsiya, tranzaksiya, RBAC scope, soft-delete, bulk/agregatsiya — bitta tipli repository ostiga jamlaydi. `columns`/`with` bo'yicha natija tipi **avtomatik** aniqlanadi.
12
+
13
+ ```ts
14
+ const { data, meta } = await usersRepository.findList({
15
+ page: 2,
16
+ perPage: 20,
17
+ filter: f.and(f.eq("status", "active"), f.gte("age", 18)),
18
+ sort: [{ key: "createdAt", direction: "desc" }],
19
+ });
20
+ // ^? data: User[] meta: { total_items, total_pages, has_next, ... }
21
+ ```
22
+
23
+ ## Imkoniyatlar
24
+
25
+ - **Advanced filterlar** — nested `and`/`or`/`not`, 27 operator, xom Prisma `where` escape-hatch, `f` yordamchilari.
26
+ - **3 xil paginatsiya** — offset (`findList`), infinite-scroll (`findInfinite`), cursor/keyset (`findCursor`).
27
+ - **`columns` + `with` inference** — tanlangan maydonlar va relation'lar qo'lda generic yozmasdan tiplanadi.
28
+ - **Multi-field sort**, `count`, `exists`, `aggregate` (count/sum/avg/min/max + groupBy).
29
+ - **Tranzaksiyalar** — `registry.transaction()`, ichidagi repolar avtomatik ulanadi.
30
+ - **Scoped repository** — RBAC/multi-tenancy uchun doimiy base filter + create default.
31
+ - **Soft-delete** — `deletedAt` maydoni bo'lsa avtomatik; `softDelete`/`restore`/`withDeleted`.
32
+ - **Upsert / bulk** — `upsert`, `upsertMany`.
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 ([PARITY.md](./PARITY.md)).
35
+
36
+ ## O'rnatish
37
+
38
+ ```bash
39
+ bun add @querykitjs/prisma-pg @prisma/client
40
+ # yoki: npm i @querykitjs/prisma-pg @prisma/client
41
+ ```
42
+
43
+ `@prisma/client` (>=5) — peer dependency. 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
+ ## Sozlash
46
+
47
+ ### 1. Registry (bir marta)
48
+
49
+ ```ts
50
+ // db/registry.ts
51
+ import { PrismaClient } from "@prisma/client";
52
+ import { createRegistry } from "@querykitjs/prisma-pg";
53
+
54
+ export const prisma = new PrismaClient();
55
+ export const registry = createRegistry(prisma);
56
+ // yoki pagination default'larini sozlab:
57
+ // export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
58
+ ```
59
+
60
+ ### 2. Repository (model bo'yicha)
61
+
62
+ 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"`.
63
+
64
+ ```ts
65
+ // db/users.repository.ts
66
+ import { prisma, registry } from "./registry";
67
+
68
+ export const usersRepository = registry.repository(prisma.user);
69
+ // …yoki nomi bilan: registry.repository("user") — ikkalasi bir xil
70
+
71
+ // custom metodlar bilan:
72
+ export const postsRepository = registry.repository("post", base => ({
73
+ findBySlug: (slug: string) => base.findOne({ filter: [{ key: "slug", value: slug }] }),
74
+ }));
75
+
76
+ // repository sozlamalari bilan (RBAC / projeksiya):
77
+ export const safeUsers = registry.repository("user", {
78
+ forcedColumns: { id: true, name: true, email: true }, // parol hech qachon chiqmaydi
79
+ });
80
+ ```
81
+
82
+ ## Framework-agnostik: Express, Hono, NestJS
83
+
84
+ Bu paket **HTTP'ni bilmaydi**. Request'ni parse qilish va validatsiya app tomonda (`@querykitjs/zod` yoki `@querykitjs/class-validator`), repository esa hamma joyda bir xil chaqiriladi.
85
+
86
+ **Express**
87
+
88
+ ```ts
89
+ import express from "express";
90
+ import { offsetParamsSchema } from "@querykitjs/zod";
91
+ import { usersRepository } from "./db/users.repository";
92
+
93
+ const app = express();
94
+ app.post("/users/list", express.json(), async (req, res) => {
95
+ const params = offsetParamsSchema.parse(req.body);
96
+ res.json(await usersRepository.findList(params));
97
+ });
98
+ ```
99
+
100
+ **Hono**
101
+
102
+ ```ts
103
+ import { Hono } from "hono";
104
+ import { offsetParamsSchema } from "@querykitjs/zod";
105
+ import { usersRepository } from "./db/users.repository";
106
+
107
+ const app = new Hono();
108
+ app.post("/users/list", async c => {
109
+ const params = offsetParamsSchema.parse(await c.req.json());
110
+ return c.json(await usersRepository.findList(params));
111
+ });
112
+ ```
113
+
114
+ **NestJS**
115
+
116
+ ```ts
117
+ import { Body, Controller, Post } from "@nestjs/common";
118
+ import { OffsetParamsDto } from "@querykitjs/class-validator";
119
+ import { usersRepository } from "./db/users.repository";
120
+
121
+ @Controller("users")
122
+ export class UsersController {
123
+ @Post("list")
124
+ list(@Body() params: OffsetParamsDto) {
125
+ return usersRepository.findList(params);
126
+ }
127
+ }
128
+ ```
129
+
130
+ Validatsiyalangan payload repository params'iga **cast'siz** tushadi — kontrakt `@querykitjs/core` orqali yopilgan.
131
+
132
+ ## Advanced filterlar
133
+
134
+ Filter — field shartlari va mantiqiy guruhlar (`and`/`or`/`not`) daraxti. Flat massiv implicit `AND`.
135
+
136
+ ```ts
137
+ import { createFilters } from "@querykitjs/prisma-pg";
138
+ const f = createFilters<typeof prisma.user>(); // maydon-nomi autocomplete
139
+
140
+ await usersRepository.findAll({
141
+ filter: f.and(f.eq("status", "active"), f.or(f.gte("age", 18), f.in("role", ["admin", "owner"])), f.not(f.isNull("deletedAt"))),
142
+ });
143
+
144
+ // obyekt shakli (yordamchisiz):
145
+ await usersRepository.findAll({
146
+ filter: [
147
+ { key: "status", operation: "=", value: "active" },
148
+ { key: "name", operation: "%_%", value: "ali" },
149
+ ],
150
+ });
151
+ ```
152
+
153
+ ### Operatorlar
154
+
155
+ | Guruh | Operatorlar |
156
+ | ---------- | -------------------------------------------------------------------------------- |
157
+ | Taqqoslash | `=`/`eq`, `!=`/`ne`, `>`/`gt`, `>=`/`gte`, `<`/`lt`, `<=`/`lte` |
158
+ | Matn | `contains`/`%_%`, `startsWith`/`%_`, `endsWith`/`_%`, `like`, `ilike`, `notLike` |
159
+ | To'plam | `in`, `notIn`, `between`, `notBetween` |
160
+ | NULL | `isNull`, `isNotNull` |
161
+
162
+ `contains`/`startsWith`/`endsWith` — **case-insensitive** (`mode: "insensitive"`), `like` — case-sensitive, `ilike` — insensitive. Bu drizzle-pg adapteri bilan aynan bir xil.
163
+
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. Batafsil: [PARITY.md](./PARITY.md) §5.1.
165
+
166
+ ### Xom `where` escape-hatch
167
+
168
+ DSL ifodalay olmaydigan narsalar (relation predikatlari, `mode`, JSON filtrlari) uchun to'g'ridan-to'g'ri Prisma `where` obyekti tugun sifatida beriladi:
169
+
170
+ ```ts
171
+ await usersRepository.findAll({ filter: { posts: { some: { title: { contains: "querykit" } } } } });
172
+
173
+ await usersRepository.findAll({
174
+ filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
175
+ });
176
+ ```
177
+
178
+ ### Noma'lum kalitlar: kuzatish va qattiq rejim
179
+
180
+ Noma'lum filter/sort kaliti **jimgina tashlanadi** (legacy parity). Bu esa natijani kengaytiradi, shuning uchun uni ko'rinadigan qilish mumkin:
181
+
182
+ ```ts
183
+ // 1-qadam: kuzatish (xulq o'zgarmaydi)
184
+ createRegistry(prisma, { onSkippedCondition: info => logger.warn({ querykit: info }, "shart tashlab yuborildi") });
185
+
186
+ // 2-qadam: log tozalanganda — qattiq rejim
187
+ createRegistry(prisma, { strict: true });
188
+ ```
189
+
190
+ ```ts
191
+ import { QueryKitError } from "@querykitjs/core";
192
+
193
+ try {
194
+ return await usersRepository.findList(params);
195
+ } catch (err) {
196
+ if (err instanceof QueryKitError) return res.status(400).json({ error: err.message, info: err.info });
197
+ throw err;
198
+ }
199
+ ```
200
+
201
+ `scope` va `cursorKey` kaliti **har doim** fatal (`strict`dan qat'i nazar) — ular serverning o'z ishi, jimgina tashlash xavfsizlik nuqsoni bo'lardi.
202
+
203
+ ## Multi-field sort
204
+
205
+ ```ts
206
+ await usersRepository.findAll({
207
+ sort: [
208
+ { key: "role", direction: "asc" },
209
+ { key: "createdAt", direction: "desc" },
210
+ ],
211
+ });
212
+ ```
213
+
214
+ Noma'lum kalit skip qilinadi; yaroqli sort qolmasa `createdAt desc`, u ham bo'lmasa `<id> desc` — paginatsiya barqaror bo'lishi uchun.
215
+
216
+ ## Paginatsiya — 3 strategiya
217
+
218
+ ```ts
219
+ // 1) Offset — total_items/total_pages bilan
220
+ const list = await usersRepository.findList({ page: 2, perPage: 20 });
221
+ list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
222
+
223
+ // 2) Infinite-scroll — COUNT'siz
224
+ const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
225
+ feed.meta; // { limit, offset, count, has_more, next_offset }
226
+
227
+ // 3) Cursor (keyset) — insert'larga barqaror
228
+ const p1 = await postsRepository.findCursor({ limit: 20, order: "desc" });
229
+ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cursor });
230
+ p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
231
+ ```
232
+
233
+ `perPage`/`limit` **cheklangan** (`DEFAULT_MAX_PER_PAGE` = 200) — validatsiya chetlab o'tilsa ham repository o'zi clamp qiladi.
234
+
235
+ > Cursor tokeni `drizzle-pg` va `mongoose` adapterlari bilan **bayt-ma-bayt bir xil** — backend ORM'i almashsa ham eski token ishlayveradi.
236
+
237
+ ## Tipli `columns` va `with`
238
+
239
+ ```ts
240
+ const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
241
+ // ^? { id: number; email: string }[] — `name` tipda ham yo'q
242
+
243
+ const withPosts = await usersRepository.findAll({ with: { posts: true } });
244
+ withPosts[0].posts[0].title; // relation tiplangan
245
+
246
+ const both = await usersRepository.findAll({ columns: { id: true }, with: { posts: true } });
247
+ // ^? { id: number; posts: Post[] }[]
248
+ ```
249
+
250
+ > ⚠️ Prisma bitta darajada `select` va `include`ni **birga qabul qilmaydi**. Adapter buni o'zi hal qiladi: ikkalasi berilsa relation'lar bitta `select` ichiga joylanadi. Siz farqni sezmaysiz.
251
+
252
+ `with` qiymati `true` yoki Prisma relation konfiguratsiyasi bo'lishi mumkin:
253
+
254
+ ```ts
255
+ await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
256
+ ```
257
+
258
+ ## Tranzaksiyalar
259
+
260
+ ```ts
261
+ await registry.transaction(async () => {
262
+ const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
263
+ await postsRepository.create({ title: "Salom", authorId: user.id });
264
+ });
265
+ ```
266
+
267
+ Ichidagi repositorylar ambient kontekst (AsyncLocalStorage) orqali avtomatik tranzaksiya client'ida ishlaydi — qo'lda `tx` uzatish shart emas. Xato tashlansa hammasi rollback.
268
+
269
+ > ⚠️ Prisma savepoint bermaydi, shuning uchun **ichma-ich** `transaction()` chaqirilsa mavjud tranzaksiya qayta ishlatiladi (yangi ochilmaydi). `mongoose` adapteri ham shunday; `drizzle-pg` esa haqiqiy savepoint yaratadi.
270
+
271
+ ## Scoped repository (RBAC / multi-tenancy)
272
+
273
+ ```ts
274
+ const mine = postsRepository.scoped({ authorId: user.id });
275
+ await mine.findList({ page: 1 }); // faqat shu foydalanuvchi postlari
276
+ await mine.create({ title: "Yangi" }); // authorId avtomatik qo'yiladi
277
+ ```
278
+
279
+ 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
+
281
+ ## Projeksiya guard'lari
282
+
283
+ ```ts
284
+ registry.repository("user", { forcedColumns: { id: true, name: true } }); // client `columns`i e'tiborsiz
285
+ registry.repository("user", { allowedColumns: ["id", "name", "email"] }); // client tanlovi kesiladi
286
+ ```
287
+
288
+ Kesishma bo'sh bo'lsa natija **to'liq qator emas**, allowlist'ning o'zi bo'ladi. Guard `aggregate`ga ham tegishli, va `cursorKey` orqali taqiqlangan maydonni o'qib olishning oldi olingan.
289
+
290
+ > ⚠️ Guard'lar faqat **o'qish** metodlariga ta'sir qiladi. Yozish metodlari tip kontrakti bo'yicha to'liq `Row` qaytaradi — natijani clientga berishdan oldin `findById` bilan qayta o'qing.
291
+
292
+ ## Soft-delete
293
+
294
+ Model'da `deletedAt` maydoni bo'lsa avtomatik yoqiladi:
295
+
296
+ ```ts
297
+ await postsRepository.softDelete(id); // deletedAt = now()
298
+ await postsRepository.findAll({}); // o'chirilganlar chiqmaydi
299
+ await postsRepository.findAll({ withDeleted: true }); // hammasi
300
+ await postsRepository.restore(id); // deletedAt = null
301
+ ```
302
+
303
+ `updatedAt` maydoni bo'lsa har bir update'da yangilanadi (`@updatedAt` bo'lsa buni Prisma o'zi qiladi — adapter aralashmaydi).
304
+
305
+ ## Upsert, bulk va agregatsiya
306
+
307
+ ```ts
308
+ await usersRepository.upsert({ email: "a@b.com", name: "Ali" }, { target: "email" });
309
+ await usersRepository.upsertMany(rows, { target: "externalId" });
310
+
311
+ await postsRepository.aggregate({ count: true, groupBy: "authorId" });
312
+ // → [{ authorId: 1, count: 12 }, …]
313
+ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "region" });
314
+ // → [{ region: "TAS", sum_amount: 900, avg_amount: 75 }, …]
315
+ ```
316
+
317
+ > **Compound unique.** Ko'p maydonli `target` Prisma'ning generatsiya qilingan nomi bilan ishlaydi: `{ target: ["a", "b"] }` → `where: { a_b: { a, b } }`. `@@unique(name: "...")` bilan nomlangan constraint uchun xom client'dan foydalaning.
318
+ >
319
+ > **Bulk.** Prisma'da bulk upsert yo'q — `upsertMany` ketma-ket `upsert` qiladi (kirish tartibi saqlanadi). Katta importlar uchun `registry.transaction()` ichida chaqiring.
320
+
321
+ ## Cheklovlar
322
+
323
+ - `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
+ - 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
+ - Matn operatorlari faqat `String` maydonlarda; boshqa tipda shart tashlanadi va reportlanadi.
326
+
327
+ To'liq ro'yxat va uchala adapter solishtiruvi: [PARITY.md](./PARITY.md).
328
+
329
+ ## API ma'lumotnoma
330
+
331
+ | Metod | Tavsif |
332
+ | -------------------------------------------- | ------------------------------------- |
333
+ | `findAll(params?)` | Barcha mos qatorlar |
334
+ | `findOne(params?)` / `findById(id, params?)` | Bitta qator yoki `undefined` |
335
+ | `findList(params?)` | Offset paginatsiya — `{ data, meta }` |
336
+ | `findInfinite(params?)` | Infinite-scroll — `{ data, meta }` |
337
+ | `findCursor(params?)` | Cursor/keyset — `{ data, meta }` |
338
+ | `count(filter?)` / `exists(filter?)` | Soni / mavjudligi |
339
+ | `create` / `createMany` | Yaratish |
340
+ | `upsert` / `upsertMany` | Insert yoki conflict'da update |
341
+ | `updateById` / `updateWhere` | Yangilash (+ `updatedAt` bump) |
342
+ | `deleteById` / `deleteWhere` | Butunlay o'chirish |
343
+ | `softDelete` / `restore` | Yumshoq o'chirish / tiklash |
344
+ | `aggregate(spec)` | count/sum/avg/min/max + groupBy |
345
+ | `scoped(scope)` | Doimiy scope bilan yangi repository |
346
+
347
+ `createRegistry(prisma, options?)` — `defaultPerPage`, `defaultLimit`, `maxPerPage`, `maxLimit`, `strict`, `onSkippedCondition`.
348
+
349
+ ## Ishlab chiqish
350
+
351
+ ```bash
352
+ bun run build
353
+ bun run typecheck
354
+ bun run test:generate # fixture Prisma client (DB kerak emas)
355
+ bun run test:query # kompilyator + parity testlari (DB kerak emas)
356
+ bun run test:types # tip inference testi
357
+ DATABASE_URL=postgres://user:pass@localhost:5432/db bun run test:smoke
358
+ ```
359
+
360
+ ## Litsenziya
361
+
362
+ MIT