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.
- package/README.md +4 -2
- package/dist/bin/neoorm.js +28 -1
- package/dist/bin/neoorm.js.map +1 -1
- package/dist/codegen/emit-models.d.ts +1 -1
- package/dist/codegen/emit-models.d.ts.map +1 -1
- package/dist/codegen/emit-models.js +27 -15
- package/dist/codegen/emit-models.js.map +1 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/generate.js +13 -3
- package/dist/codegen/generate.js.map +1 -1
- package/dist/codegen/schema-to-manifest.d.ts +6 -1
- package/dist/codegen/schema-to-manifest.d.ts.map +1 -1
- package/dist/codegen/schema-to-manifest.js +78 -22
- package/dist/codegen/schema-to-manifest.js.map +1 -1
- package/dist/docs/pages.d.ts +9 -0
- package/dist/docs/pages.d.ts.map +1 -0
- package/dist/docs/pages.js +64 -0
- package/dist/docs/pages.js.map +1 -0
- package/dist/docs/render.d.ts +4 -0
- package/dist/docs/render.d.ts.map +1 -0
- package/dist/docs/render.js +245 -0
- package/dist/docs/render.js.map +1 -0
- package/dist/docs/resolve-docs-dir.d.ts +2 -0
- package/dist/docs/resolve-docs-dir.d.ts.map +1 -0
- package/dist/docs/resolve-docs-dir.js +27 -0
- package/dist/docs/resolve-docs-dir.js.map +1 -0
- package/dist/docs/server.d.ts +11 -0
- package/dist/docs/server.d.ts.map +1 -0
- package/dist/docs/server.js +99 -0
- package/dist/docs/server.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +2 -5
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/error-builders.d.ts +14 -0
- package/dist/runtime/error-builders.d.ts.map +1 -0
- package/dist/runtime/error-builders.js +54 -0
- package/dist/runtime/error-builders.js.map +1 -0
- package/dist/runtime/error-hints.d.ts +19 -0
- package/dist/runtime/error-hints.d.ts.map +1 -0
- package/dist/runtime/error-hints.js +181 -0
- package/dist/runtime/error-hints.js.map +1 -0
- package/dist/runtime/errors.d.ts +5 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +19 -2
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/pg-error.d.ts.map +1 -1
- package/dist/runtime/pg-error.js +86 -0
- package/dist/runtime/pg-error.js.map +1 -1
- package/dist/runtime/query/compile.d.ts.map +1 -1
- package/dist/runtime/query/compile.js +5 -12
- package/dist/runtime/query/compile.js.map +1 -1
- package/dist/runtime/query/cursor.d.ts.map +1 -1
- package/dist/runtime/query/cursor.js +2 -5
- package/dist/runtime/query/cursor.js.map +1 -1
- package/dist/runtime/query/execute.d.ts.map +1 -1
- package/dist/runtime/query/execute.js +28 -9
- package/dist/runtime/query/execute.js.map +1 -1
- package/dist/runtime/query/group-by.d.ts.map +1 -1
- package/dist/runtime/query/group-by.js +3 -7
- package/dist/runtime/query/group-by.js.map +1 -1
- package/dist/runtime/query/projection.d.ts.map +1 -1
- package/dist/runtime/query/projection.js +2 -4
- package/dist/runtime/query/projection.js.map +1 -1
- package/dist/runtime/query/relation-planner.d.ts.map +1 -1
- package/dist/runtime/query/relation-planner.js +6 -1
- package/dist/runtime/query/relation-planner.js.map +1 -1
- package/dist/runtime/query/strip.d.ts.map +1 -1
- package/dist/runtime/query/strip.js +2 -4
- package/dist/runtime/query/strip.js.map +1 -1
- package/dist/runtime/query/table-index.d.ts +4 -0
- package/dist/runtime/query/table-index.d.ts.map +1 -1
- package/dist/runtime/query/table-index.js +45 -0
- package/dist/runtime/query/table-index.js.map +1 -1
- package/dist/runtime/schema-error.d.ts +1 -1
- package/dist/runtime/schema-error.d.ts.map +1 -1
- package/dist/runtime/schema-error.js +7 -2
- package/dist/runtime/schema-error.js.map +1 -1
- package/dist/runtime/sqlite-error.d.ts +12 -0
- package/dist/runtime/sqlite-error.d.ts.map +1 -0
- package/dist/runtime/sqlite-error.js +169 -0
- package/dist/runtime/sqlite-error.js.map +1 -0
- package/dist/runtime/types.d.ts +8 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/docs/cli.md +107 -0
- package/docs/configuration.md +51 -0
- package/docs/errors.md +72 -0
- package/docs/examples.md +498 -0
- package/docs/getting-started.md +122 -0
- package/docs/migrations.md +67 -0
- package/docs/plugins.md +47 -0
- package/docs/queries.md +465 -0
- package/docs/relations.md +75 -0
- package/docs/schema.md +260 -0
- package/docs/sqlite.md +105 -0
- package/docs/transactions.md +54 -0
- package/package.json +3 -1
package/docs/plugins.md
ADDED
|
@@ -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.
|
package/docs/queries.md
ADDED
|
@@ -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.
|