neoorm 0.6.1 → 0.6.3

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.
Files changed (99) hide show
  1. package/README.md +4 -2
  2. package/dist/bin/neoorm.js +28 -1
  3. package/dist/bin/neoorm.js.map +1 -1
  4. package/dist/codegen/emit-models.d.ts +1 -1
  5. package/dist/codegen/emit-models.d.ts.map +1 -1
  6. package/dist/codegen/emit-models.js +27 -15
  7. package/dist/codegen/emit-models.js.map +1 -1
  8. package/dist/codegen/generate.d.ts.map +1 -1
  9. package/dist/codegen/generate.js +13 -3
  10. package/dist/codegen/generate.js.map +1 -1
  11. package/dist/codegen/schema-to-manifest.d.ts +6 -1
  12. package/dist/codegen/schema-to-manifest.d.ts.map +1 -1
  13. package/dist/codegen/schema-to-manifest.js +78 -22
  14. package/dist/codegen/schema-to-manifest.js.map +1 -1
  15. package/dist/docs/pages.d.ts +9 -0
  16. package/dist/docs/pages.d.ts.map +1 -0
  17. package/dist/docs/pages.js +64 -0
  18. package/dist/docs/pages.js.map +1 -0
  19. package/dist/docs/render.d.ts +4 -0
  20. package/dist/docs/render.d.ts.map +1 -0
  21. package/dist/docs/render.js +245 -0
  22. package/dist/docs/render.js.map +1 -0
  23. package/dist/docs/resolve-docs-dir.d.ts +2 -0
  24. package/dist/docs/resolve-docs-dir.d.ts.map +1 -0
  25. package/dist/docs/resolve-docs-dir.js +27 -0
  26. package/dist/docs/resolve-docs-dir.js.map +1 -0
  27. package/dist/docs/server.d.ts +11 -0
  28. package/dist/docs/server.d.ts.map +1 -0
  29. package/dist/docs/server.js +99 -0
  30. package/dist/docs/server.js.map +1 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/runtime/client.d.ts.map +1 -1
  35. package/dist/runtime/client.js +2 -5
  36. package/dist/runtime/client.js.map +1 -1
  37. package/dist/runtime/error-builders.d.ts +14 -0
  38. package/dist/runtime/error-builders.d.ts.map +1 -0
  39. package/dist/runtime/error-builders.js +54 -0
  40. package/dist/runtime/error-builders.js.map +1 -0
  41. package/dist/runtime/error-hints.d.ts +19 -0
  42. package/dist/runtime/error-hints.d.ts.map +1 -0
  43. package/dist/runtime/error-hints.js +181 -0
  44. package/dist/runtime/error-hints.js.map +1 -0
  45. package/dist/runtime/errors.d.ts +5 -0
  46. package/dist/runtime/errors.d.ts.map +1 -1
  47. package/dist/runtime/errors.js +19 -2
  48. package/dist/runtime/errors.js.map +1 -1
  49. package/dist/runtime/pg-error.d.ts.map +1 -1
  50. package/dist/runtime/pg-error.js +86 -0
  51. package/dist/runtime/pg-error.js.map +1 -1
  52. package/dist/runtime/query/compile.d.ts.map +1 -1
  53. package/dist/runtime/query/compile.js +5 -12
  54. package/dist/runtime/query/compile.js.map +1 -1
  55. package/dist/runtime/query/cursor.d.ts.map +1 -1
  56. package/dist/runtime/query/cursor.js +2 -5
  57. package/dist/runtime/query/cursor.js.map +1 -1
  58. package/dist/runtime/query/execute.d.ts.map +1 -1
  59. package/dist/runtime/query/execute.js +28 -9
  60. package/dist/runtime/query/execute.js.map +1 -1
  61. package/dist/runtime/query/group-by.d.ts.map +1 -1
  62. package/dist/runtime/query/group-by.js +3 -7
  63. package/dist/runtime/query/group-by.js.map +1 -1
  64. package/dist/runtime/query/projection.d.ts.map +1 -1
  65. package/dist/runtime/query/projection.js +2 -4
  66. package/dist/runtime/query/projection.js.map +1 -1
  67. package/dist/runtime/query/relation-planner.d.ts.map +1 -1
  68. package/dist/runtime/query/relation-planner.js +6 -1
  69. package/dist/runtime/query/relation-planner.js.map +1 -1
  70. package/dist/runtime/query/strip.d.ts.map +1 -1
  71. package/dist/runtime/query/strip.js +2 -4
  72. package/dist/runtime/query/strip.js.map +1 -1
  73. package/dist/runtime/query/table-index.d.ts +4 -0
  74. package/dist/runtime/query/table-index.d.ts.map +1 -1
  75. package/dist/runtime/query/table-index.js +45 -0
  76. package/dist/runtime/query/table-index.js.map +1 -1
  77. package/dist/runtime/schema-error.d.ts +1 -1
  78. package/dist/runtime/schema-error.d.ts.map +1 -1
  79. package/dist/runtime/schema-error.js +7 -2
  80. package/dist/runtime/schema-error.js.map +1 -1
  81. package/dist/runtime/sqlite-error.d.ts +12 -0
  82. package/dist/runtime/sqlite-error.d.ts.map +1 -0
  83. package/dist/runtime/sqlite-error.js +169 -0
  84. package/dist/runtime/sqlite-error.js.map +1 -0
  85. package/dist/runtime/types.d.ts +8 -0
  86. package/dist/runtime/types.d.ts.map +1 -1
  87. package/docs/cli.md +107 -0
  88. package/docs/configuration.md +51 -0
  89. package/docs/errors.md +72 -0
  90. package/docs/examples.md +498 -0
  91. package/docs/getting-started.md +122 -0
  92. package/docs/migrations.md +67 -0
  93. package/docs/plugins.md +47 -0
  94. package/docs/queries.md +465 -0
  95. package/docs/relations.md +75 -0
  96. package/docs/schema.md +260 -0
  97. package/docs/sqlite.md +105 -0
  98. package/docs/transactions.md +54 -0
  99. package/package.json +3 -1
@@ -0,0 +1,47 @@
1
+ # Plugins
2
+
3
+ ## Built-in `id()` and `timestamps()`
4
+
5
+ ```ts
6
+ import { defineSchema, id, table, timestamps, uuid } from "neoorm/schema";
7
+
8
+ export const schema = defineSchema({
9
+ users: table({
10
+ id: uuid().primary(),
11
+ ...timestamps(),
12
+ }),
13
+ posts: table({
14
+ id: id(), // TEXT PK with {prefix}_{uuid}
15
+ title: text().notNull(),
16
+ }),
17
+ });
18
+ ```
19
+
20
+ ## PostGIS
21
+
22
+ ```ts
23
+ import "neoorm/plugins/postgis";
24
+ import { geometry, point } from "neoorm/plugins/postgis";
25
+
26
+ places: table({
27
+ id: uuid().primary(),
28
+ location: geometry({ subtype: "Point", srid: 4326 }).notNull(),
29
+ boundary: point({ srid: 4326 }),
30
+ }),
31
+ ```
32
+
33
+ Spatial `where` operators: `intersects`, `within`, `dWithin`.
34
+
35
+ ## Citext
36
+
37
+ ```ts
38
+ import { citext, table } from "neoorm/schema";
39
+
40
+ users: table({
41
+ email: citext().notNull().unique(),
42
+ }),
43
+ ```
44
+
45
+ ## Custom plugins
46
+
47
+ See the plugin registry source for `ColumnTypePlugin` and `NeoOrmPlugin` interfaces.
@@ -0,0 +1,465 @@
1
+ # Queries
2
+
3
+ ## Find
4
+
5
+ ```ts
6
+ // By ID (scalar primary key — pass a string)
7
+ const user = await db.users.findById(id);
8
+
9
+ // By composite primary key — pass an object with all PK columns
10
+ const item = await db.items.findById({ tenantId: "t1", itemCode: "c1" });
11
+
12
+ // First match (null if not found)
13
+ const user = await db.users.findFirst({ where: { email: "a@b.com" } });
14
+
15
+ // All matching
16
+ const users = await db.users.findMany({
17
+ where: { published: true },
18
+ take: 20,
19
+ skip: 40,
20
+ });
21
+
22
+ // Unique constraint lookup
23
+ const user = await db.users.findUnique({ where: { slug: "hello" } });
24
+ ```
25
+
26
+ ## Create
27
+
28
+ ```ts
29
+ const user = await db.users.create({ data: { email: "a@b.com" } });
30
+
31
+ // Bulk insert (returns count)
32
+ const count = await db.users.createMany({
33
+ data: [{ email: "a@b.com" }, { email: "b@b.com" }],
34
+ });
35
+
36
+ // Skip unique / primary-key conflicts (`ON CONFLICT DO NOTHING`).
37
+ // Does not ignore CHECK or foreign-key violations.
38
+ const skipped = await db.users.createMany({
39
+ data: [{ email: "a@b.com" }, { email: "a@b.com" }],
40
+ skipDuplicates: true,
41
+ });
42
+
43
+ // Bulk insert with returned rows (serial IDs, UUIDs, defaults materialized)
44
+ const users = await db.users.createManyAndReturn({
45
+ data: [{ email: "a@b.com" }, { email: "b@b.com" }],
46
+ skipDuplicates: true,
47
+ });
48
+ ```
49
+
50
+ ## Update
51
+
52
+ ```ts
53
+ // Single record
54
+ const user = await db.users.update({
55
+ where: { id: userId },
56
+ data: { email: "new@b.com" },
57
+ });
58
+
59
+ // By ID (scalar PK — string; composite PK — object)
60
+ const user = await db.users.updateById(id, { data: { name: "New" } });
61
+ const item = await db.items.updateById(
62
+ { tenantId: "t1", itemCode: "c1" },
63
+ { data: { name: "Updated" } },
64
+ );
65
+
66
+ // Multiple records
67
+ const count = await db.users.updateMany({
68
+ where: { email: { contains: "@old" } },
69
+ data: { status: "archived" },
70
+ });
71
+
72
+ const updated = await db.users.updateManyAndReturn({
73
+ where: { email: { contains: "@old" } },
74
+ data: { status: "archived" },
75
+ });
76
+ ```
77
+
78
+ Numeric columns (`int`, `serial`, `decimal`, `bigint`) also accept `{ increment }`, `{ decrement }`, `{ multiply }`, and `{ set }`. These compile to `col = col ±/* $n` so concurrent updates do not clobber each other. `{ set: value }` is the same assignment as a plain value. `NULL + 1` stays SQL NULL.
79
+
80
+ ```ts
81
+ await db.posts.update({
82
+ where: { id: postId },
83
+ data: { views: { increment: 1 } },
84
+ });
85
+
86
+ await db.posts.updateMany({
87
+ where: { published: true },
88
+ data: { views: { multiply: 2 } },
89
+ });
90
+ ```
91
+
92
+ ## Delete
93
+
94
+ ```ts
95
+ // Returns {} on success, null if no row matched
96
+ await db.users.delete({ where: { id: userId } });
97
+
98
+ // Pass returnDeleted: true to get the deleted row back
99
+ const deleted = await db.users.delete({
100
+ where: { id: userId },
101
+ returnDeleted: true,
102
+ });
103
+
104
+ // Scalar PK — pass a string
105
+ await db.users.deleteById(userId);
106
+
107
+ // Composite PK — pass an object
108
+ await db.items.deleteById({ tenantId: "t1", itemCode: "c1" });
109
+
110
+ const count = await db.users.deleteMany({
111
+ where: { email: { contains: "@spam" } },
112
+ });
113
+
114
+ const deletedRows = await db.users.deleteManyAndReturn({
115
+ where: { email: { contains: "@spam" } },
116
+ });
117
+ ```
118
+
119
+ ## Upsert
120
+
121
+ ```ts
122
+ await db.users.upsert({
123
+ where: { email: "a@b.com" },
124
+ create: { email: "a@b.com", name: "A" },
125
+ update: { name: "A" },
126
+ });
127
+ ```
128
+
129
+ ## Find or create
130
+
131
+ Atomically insert a row or return an existing one when a unique constraint matches. Unlike `upsert`, existing rows are never updated.
132
+
133
+ `where` must identify a unique constraint (same rules as `findUnique` and `upsert`).
134
+
135
+ ```ts
136
+ const { record, created } = await db.tags.findOrCreate({
137
+ where: { slug: "orm" },
138
+ create: { slug: "orm", name: "ORM" },
139
+ });
140
+ // created: true if a new row was inserted, false if an existing row was returned
141
+ ```
142
+
143
+ ## Where clauses
144
+
145
+ ### Column filters
146
+
147
+ Equality shorthand and explicit operators:
148
+
149
+ ```ts
150
+ await db.posts.findMany({ where: { published: true } });
151
+
152
+ await db.posts.findFirst({
153
+ where: {
154
+ title: { contains: "ORM" },
155
+ views: { gte: 100 },
156
+ id: { in: ["post_1", "post_2"] },
157
+ },
158
+ });
159
+ ```
160
+
161
+ | Type | Operators |
162
+ |------|-----------|
163
+ | String | `equals`, `contains`, `startsWith`, `endsWith`, `search`, `in`, `notIn`, `mode` |
164
+ | Numeric / Date | `equals`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn` |
165
+ | JSON | `jsonContains`, `hasKey`, `hasAnyKeys`, `hasAllKeys`, `path` |
166
+ | All nullable | `isNull`, `isNotNull` |
167
+
168
+ `contains`, `startsWith`, and `endsWith` compile to `LIKE`. Pass sibling `mode: "insensitive"` for case-folding (`ILIKE` on Postgres, `LOWER(col) LIKE LOWER($n)` on SQLite). SQLite `LIKE` is ASCII case-insensitive even in default mode.
169
+
170
+ `search` is POSIX regex (`~`, or `~*` with `mode: "insensitive"`). It is PostgreSQL-only; SQLite throws.
171
+
172
+ ```ts
173
+ await db.posts.findMany({
174
+ where: { title: { contains: "orm", mode: "insensitive" } },
175
+ });
176
+
177
+ await db.posts.findMany({
178
+ where: { title: { search: "^Neo", mode: "insensitive" } },
179
+ });
180
+ ```
181
+
182
+ ### Logical combinators
183
+
184
+ ```ts
185
+ await db.users.findMany({
186
+ where: {
187
+ OR: [
188
+ { email: { contains: "@example.com" } },
189
+ { email: { contains: "@test.com" } },
190
+ ],
191
+ NOT: { name: { isNull: true } },
192
+ createdAt: { gte: new Date("2025-01-01") },
193
+ },
194
+ });
195
+ ```
196
+
197
+ ### Relation filters
198
+
199
+ ```ts
200
+ // users who have at least one published post
201
+ await db.users.findMany({
202
+ where: { posts: { some: { published: true } } },
203
+ });
204
+
205
+ // posts whose author has a verified email
206
+ await db.posts.findMany({
207
+ where: { author: { email: { contains: "@" } } },
208
+ });
209
+
210
+ // posts tagged "orm" (many-to-many via junction table)
211
+ await db.posts.findMany({
212
+ where: { tags: { some: { slug: "orm" } } },
213
+ });
214
+ ```
215
+
216
+ | Pattern | Use for |
217
+ |---------|---------|
218
+ | `authorId: "user_1"` | Filter by FK column value |
219
+ | `author: { email: "a@b.c" }` | Filter by related record (to-one) |
220
+ | `posts: { some: { ... } }` | At least one related record matches (to-many) |
221
+ | `posts: { every: { ... } }` | All related records match (to-many) |
222
+ | `posts: { none: { ... } }` | No related records match (to-many) |
223
+
224
+ Relation filters compile to SQL `EXISTS` subqueries, so they work with `findMany`, `count`, `updateMany`, and `deleteMany` without duplicate rows.
225
+
226
+ ### JSON and decimal filters
227
+
228
+ ```ts
229
+ // jsonb — partial / subset match (@> containment)
230
+ await db.posts.findMany({
231
+ where: { metadata: { jsonContains: { featured: true } } },
232
+ });
233
+
234
+ // jsonb — key existence and path filters
235
+ await db.posts.findMany({
236
+ where: {
237
+ metadata: {
238
+ hasKey: "featured",
239
+ hasAnyKeys: ["category", "tags"],
240
+ path: { segments: ["category"], equals: "engineering" },
241
+ },
242
+ },
243
+ });
244
+
245
+ // decimal — compare as strings
246
+ await db.posts.findMany({
247
+ where: { price: { gte: "9.99", lte: "49.99" } },
248
+ });
249
+ ```
250
+
251
+ ### `distinct`
252
+
253
+ PostgreSQL `DISTINCT ON` — `orderBy` must lead with the same columns:
254
+
255
+ ```ts
256
+ await db.users.findMany({
257
+ distinct: ["email"],
258
+ orderBy: { email: "asc" },
259
+ });
260
+ ```
261
+
262
+ Not supported on SQLite (`DISTINCT ON` is PostgreSQL-only) — it throws `distinct is not supported on SQLite`. Use `groupBy({ by: [...] })` or a raw `db.sql` query instead.
263
+
264
+ ## Eager loading with `with`
265
+
266
+ Relations are eagerly loaded in `findMany`, `findFirst`, and `findById` via the `with` option.
267
+
268
+ ### Strategy
269
+
270
+ NeoORM uses a relation-loading planner that picks the fewest round-trips per `with` tree:
271
+
272
+ - **LEFT JOIN** — to-one relations where the parent table owns the foreign key are resolved in the main query. No additional round-trips.
273
+ - **Correlated `json_agg` subquery** — inverse has-many relations (and simple linear nested chains) are embedded in the main `SELECT` as correlated subqueries. Multiple top-level relations can be inlined in the same query alongside JOINs.
274
+ - **Inline `COUNT` subquery** — simple `_count` on has-many relations is embedded in the main `SELECT`.
275
+ - **Batch query** — many-to-many, nested `with` on to-one, sibling-heavy trees, and other complex shapes fall back to batched `WHERE IN (...)` queries (one per relation level). Sibling batch queries at the same level run in parallel.
276
+
277
+ Typical query counts:
278
+
279
+ - `with: { author: true }` → **1** query (JOIN)
280
+ - `with: { posts: true }` → **1** query (`json_agg` subquery)
281
+ - `with: { author: true, comments: true }` → **1** query (JOIN + `json_agg`)
282
+ - `with: { posts: { with: { comments: { with: { author: true } } } } }` on a linear chain → **1** query (nested `json_agg`)
283
+ - M2M or sibling nested relations → **1 + batch levels** (parallel per level)
284
+
285
+ ```ts
286
+ // Single query — author via LEFT JOIN
287
+ const posts = await db.posts.findMany({ with: { author: true } });
288
+
289
+ // Last 5 published comments per post
290
+ const posts = await db.posts.findMany({
291
+ with: {
292
+ comments: {
293
+ where: { published: true },
294
+ orderBy: { createdAt: "desc" },
295
+ take: 5,
296
+ },
297
+ },
298
+ });
299
+
300
+ // Single query — author via JOIN, comments via correlated json_agg
301
+ const posts = await db.posts.findMany({
302
+ with: { author: true, comments: { with: { author: true } } },
303
+ });
304
+ ```
305
+
306
+ To-one includes with `where` load as a batch query: the relation is the matching row, or `null`.
307
+
308
+ ### Selective return types
309
+
310
+ Root `select` and `omit` project the parent row. Relation shapes still come from `with` (including nested `select`). `select` and `omit` cannot be used together.
311
+
312
+ ```ts
313
+ const users = await db.users.findMany({
314
+ select: { id: true, email: true },
315
+ with: { posts: { select: { title: true } } },
316
+ });
317
+ // { id, email, posts: Array<{ title }> }
318
+
319
+ const publicUsers = await db.users.findMany({
320
+ omit: { createdAt: true, updatedAt: true },
321
+ });
322
+ ```
323
+
324
+ Nested relation `select` also narrows the TypeScript return type:
325
+
326
+ ```ts
327
+ const users = await db.users.findMany({
328
+ with: { posts: { select: { title: true } } },
329
+ });
330
+ // users[0].posts[0].title is string; .body is excluded from the type
331
+ ```
332
+
333
+ ### API responses (`strip`)
334
+
335
+ Use query `omit` when you do not want a column fetched from the database. Use `strip` when you need the full row internally (for example login) but want a safe object for the response.
336
+
337
+ Mark sensitive columns in the schema with `.hidden()`:
338
+
339
+ ```ts
340
+ password: text().notNull().hidden(),
341
+ ```
342
+
343
+ Every row returned from queries includes a non-enumerable `.strip()` method:
344
+
345
+ ```ts
346
+ const user = await db.users.findById(id);
347
+ if (!user) return null;
348
+ return user.strip();
349
+ // hidden fields removed; nested `with` relations stripped too
350
+
351
+ const users = await db.users.findMany();
352
+ return users.map((user) => user.strip());
353
+
354
+ return user.strip({ refreshToken: true }); // hidden + extra column keys
355
+ ```
356
+
357
+ `strip()` returns a plain object (no `.strip` method) suitable for JSON responses. `null` and `undefined` pass through unchanged when using the underlying strip helper directly.
358
+
359
+ ## Cursor pagination
360
+
361
+ For feeds, infinite scroll, and large tables, use `paginate` instead of `take`/`skip`. It uses **keyset pagination** on your `orderBy` columns plus the table primary key as a stable tiebreaker.
362
+
363
+ ```ts
364
+ let cursor: { createdAt: Date; id: string } | null = null;
365
+
366
+ for (;;) {
367
+ const page = await db.posts.paginate({
368
+ where: { published: true },
369
+ orderBy: { createdAt: "desc" },
370
+ take: 20,
371
+ ...(cursor ? { after: cursor } : {}),
372
+ with: { author: true },
373
+ });
374
+
375
+ for (const post of page.items) { /* render */ }
376
+
377
+ if (!page.hasMore) break;
378
+ cursor = page.nextCursor;
379
+ }
380
+ ```
381
+
382
+ - `orderBy` is required; scalar `id` is appended automatically when omitted.
383
+ - `take` is the page size; `hasMore` / `hasPrevious` use a `take + 1` probe row (plus whether `after` / `before` was passed).
384
+ - `after` is a typed cursor (`nextCursor` from the previous page). `before` walks the other way (`prevCursor`).
385
+ - `after` and `before` can be combined as exclusive window bounds; pass both on every windowed call.
386
+ - For HTTP APIs, encode cursors with `encodeCursor` / `decodeCursor` from `neoorm`.
387
+ - On feed tables, add a composite index on the sort columns.
388
+
389
+ Walk backward (e.g. “load newer” on a descending feed):
390
+
391
+ ```ts
392
+ const newer = await db.posts.paginate({
393
+ where: { published: true },
394
+ orderBy: { createdAt: "desc" },
395
+ take: 20,
396
+ before: page.prevCursor,
397
+ });
398
+ ```
399
+
400
+ ## Aggregates
401
+
402
+ ### Relation counts in `with`
403
+
404
+ ```ts
405
+ const users = await db.users.findMany({
406
+ with: {
407
+ _count: { posts: true },
408
+ profile: true,
409
+ },
410
+ });
411
+ // users[0]._count.posts === number
412
+ ```
413
+
414
+ Optional per-relation filter: `_count: { posts: { where: { published: true } } }`.
415
+
416
+ ### Table-level `aggregate()`
417
+
418
+ ```ts
419
+ const stats = await db.posts.aggregate({
420
+ where: { published: true },
421
+ _count: { _all: true, authorId: true },
422
+ _avg: { views: true },
423
+ });
424
+ // { _count: { _all: number, authorId: number }, _avg: { views: number | null } }
425
+ ```
426
+
427
+ `_count: true` is still `COUNT(*)` and returns `_count: number`. A field map uses `COUNT(col)` (nulls are skipped) — not `DISTINCT`.
428
+
429
+ Single-table grouped stats use `groupBy()`. Cross-table dashboards still use `db.sql` or `sqlBuilder`.
430
+
431
+ ### `groupBy()`
432
+
433
+ ```ts
434
+ const byAuthor = await db.posts.groupBy({
435
+ by: ["authorId"],
436
+ where: { published: true },
437
+ _count: { _all: true, authorId: true },
438
+ _avg: { views: true },
439
+ having: { _count: { _all: { gte: 5 }, authorId: { gt: 0 } } },
440
+ orderBy: { _count: { _all: "desc" } },
441
+ take: 10,
442
+ });
443
+ // { authorId: string, _count: { _all: number, authorId: number }, _avg: { views: number | null } }[]
444
+ ```
445
+
446
+ Star `_count: true` still works with `having: { _count: { gte: 5 } }` and `orderBy: { _count: "desc" }`.
447
+
448
+ `where` filters rows before grouping. `having` filters groups (aggregates only). `by` alone lists distinct groups — useful on SQLite where `distinct` is not available.
449
+
450
+ ## Count
451
+
452
+ ```ts
453
+ const total = await db.users.count({ where: { active: true } });
454
+ const uniqueEmails = await db.users.count({ distinct: "email" });
455
+ const fieldCounts = await db.users.count({
456
+ select: { _all: true, email: true },
457
+ });
458
+ // { _all: number, email: number }
459
+
460
+ const taken = await db.users.exists({ where: { email: "a@b.com" } });
461
+ ```
462
+
463
+ `count()` returns a number unless `select` is set. `distinct` is `COUNT(DISTINCT col)` on one column and cannot be combined with `select`. Field `select` / `_count` maps are non-null `COUNT(col)`, not distinct.
464
+
465
+ `exists()` runs `SELECT 1 … LIMIT 1` and returns a boolean. Use `count()` when you need the actual number of matches.
@@ -0,0 +1,75 @@
1
+ # Relation writes
2
+
3
+ `create` and `update` accept nested relation writes alongside scalar fields. Relation-only updates are supported (no scalar `SET` required).
4
+
5
+ ## Supported operations
6
+
7
+ | Relation kind | Operations |
8
+ |---------------|------------|
9
+ | **To-one** (outgoing FK) | `connect`, `create`, `disconnect` (nullable FK only) |
10
+ | **One-to-many** (inverse) | `create`, `connect`, `disconnect`, `set`, `delete` |
11
+ | **Many-to-many** | `connect`, `connectOrCreate`, `disconnect`, `set`, `delete` |
12
+
13
+ ## Examples
14
+
15
+ ### To-one on update
16
+
17
+ ```ts
18
+ await db.posts.update({
19
+ where: { id: postId },
20
+ data: { author: { connect: { id: userId } } },
21
+ });
22
+ ```
23
+
24
+ ### Nested create on one-to-many
25
+
26
+ ```ts
27
+ await db.posts.update({
28
+ where: { id: postId },
29
+ data: {
30
+ comments: {
31
+ create: [{ body: "Hi", author: { connect: { id: userId } } }],
32
+ connect: [{ id: commentId }],
33
+ disconnect: [{ id: oldCommentId }], // or `true` to unlink all
34
+ set: [{ id: commentId }], // replace all links
35
+ delete: [{ id: commentId }], // or `true` to delete all linked children
36
+ },
37
+ },
38
+ });
39
+ ```
40
+
41
+ ### Many-to-many on create or update
42
+
43
+ ```ts
44
+ await db.posts.update({
45
+ where: { id: postId },
46
+ data: {
47
+ tags: {
48
+ connect: [{ id: tagId }],
49
+ set: [{ id: tagId }],
50
+ connectOrCreate: [
51
+ { where: { slug: "orm" }, create: { slug: "orm", name: "ORM" } },
52
+ ],
53
+ delete: [{ id: tagId }],
54
+ },
55
+ },
56
+ });
57
+ ```
58
+
59
+ ### Relation writes in updateMany
60
+
61
+ `updateMany` applies scalar `SET` once, then nested writes per matched parent:
62
+
63
+ ```ts
64
+ await db.posts.updateMany({
65
+ where: { published: true },
66
+ data: {
67
+ status: "archived",
68
+ tags: { connect: [{ id: tagId }] },
69
+ },
70
+ });
71
+ ```
72
+
73
+ ## Operation order
74
+
75
+ Within a single relation field, operations run in order: `delete` → `disconnect` → `set` → `connect` / `connectOrCreate` → `create`. Mixing `set` with `connect` or `create` is discouraged — `set` replaces the full link set.