@apisr/drizzle-model 2.0.0 → 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/CHANGELOG.md +22 -0
- package/README.md +433 -0
- package/TODO.md +5 -5
- package/package.json +4 -3
- package/src/core/query/joins.ts +70 -3
- package/src/core/result.ts +12 -6
- package/src/core/runtime.ts +39 -15
- package/src/model/result.ts +5 -2
- package/tests/base/relations.test.ts +593 -0
- package/tests/base/upsert.test.ts +3 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Change log
|
|
2
|
+
|
|
3
|
+
## 2.0.1 | 01-03-2026
|
|
4
|
+
- mark `pg` as peerDep
|
|
5
|
+
- add `CHANGELOG.md`
|
|
6
|
+
- add `relations.test.ts` to test relations
|
|
7
|
+
- add `SimplifyDeep<>` from `type-fest` in queries, for better DX of using relations
|
|
8
|
+
- fix relations `include()` function
|
|
9
|
+
|
|
10
|
+
## 2.0.0 | 28-02-2026
|
|
11
|
+
- add `REAMDE.md`
|
|
12
|
+
- add `count()` function
|
|
13
|
+
- add `Simplify<>` to all queries
|
|
14
|
+
- add `returnFirst()` and make `return()` to return array of rows
|
|
15
|
+
- add `omit()` as transformator after `return()/returnFirst()`
|
|
16
|
+
- remake entire core, better JSDoc, OOP over functional, and much more...
|
|
17
|
+
- remake all tests
|
|
18
|
+
- add `safe()` function
|
|
19
|
+
```ts
|
|
20
|
+
const { data: user, error } = userModel.findFirst().safe();
|
|
21
|
+
```
|
|
22
|
+
- and in overall just make better DX
|
package/README.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# @apisr/drizzle-model
|
|
2
|
+
|
|
3
|
+
> **⚠️ This package is on high development stage! May have bugs**
|
|
4
|
+
|
|
5
|
+
> **⚠️ Requires `drizzle-orm@beta`**
|
|
6
|
+
> This package is built for Drizzle ORM beta versions (`^1.0.0-beta.2-86f844e`).
|
|
7
|
+
|
|
8
|
+
Type-safe, chainable model runtime for **Drizzle ORM**.
|
|
9
|
+
|
|
10
|
+
Build reusable models for tables and relations with a progressive flow:
|
|
11
|
+
|
|
12
|
+
1. **Intent Stage** — declare what you want (`where`, `insert`, `update`, ...)
|
|
13
|
+
2. **Execution Stage** — choose execution (`findMany`, `findFirst`, `return`, `returnFirst`)
|
|
14
|
+
3. **Refinement Stage** — shape the SQL query (`select`, `exclude`, `with`)
|
|
15
|
+
4. **Programmatic Polishing** — post-process the result (`omit`, `raw`, `safe`)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Learning Path (easy → advanced)
|
|
20
|
+
|
|
21
|
+
1. [Install and create your first model](#install-and-first-model)
|
|
22
|
+
2. [Basic reads](#basic-reads)
|
|
23
|
+
3. [Basic writes](#basic-writes)
|
|
24
|
+
4. [Result refinement](#result-refinement)
|
|
25
|
+
5. [Error-safe execution](#error-safe-execution)
|
|
26
|
+
6. [Advanced: model options and extension](#advanced-model-options-and-extension)
|
|
27
|
+
7. [Full API reference](#full-api-reference)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Install and first model
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun add @apisr/drizzle-model drizzle-orm@beta
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { modelBuilder, esc } from "@apisr/drizzle-model";
|
|
39
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
40
|
+
import * as schema from "./schema";
|
|
41
|
+
import { relations } from "./relations";
|
|
42
|
+
|
|
43
|
+
const db = drizzle(process.env.DATABASE_URL!, { schema, relations });
|
|
44
|
+
|
|
45
|
+
const model = modelBuilder({
|
|
46
|
+
db,
|
|
47
|
+
schema,
|
|
48
|
+
// requires DrizzleORM relations v2. See: https://orm.drizzle.team/docs/relations-v1-v2
|
|
49
|
+
relations,
|
|
50
|
+
dialect: "PostgreSQL",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const userModel = model("user", {});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> `drizzle-orm` is a peer dependency.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Basic reads
|
|
61
|
+
|
|
62
|
+
### Find one
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const user = await userModel.findFirst();
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Find many with filter
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const users = await userModel
|
|
72
|
+
.where({ name: esc("Alex") })
|
|
73
|
+
.findMany();
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Count
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const total = await userModel.count();
|
|
80
|
+
const verified = await userModel.where({ isVerified: esc(true) }).count();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Basic writes
|
|
86
|
+
|
|
87
|
+
### Insert
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
await userModel.insert({
|
|
91
|
+
name: "New User",
|
|
92
|
+
email: "new@example.com",
|
|
93
|
+
age: 18,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Update
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const updated = await userModel
|
|
101
|
+
.where({ id: esc(1) })
|
|
102
|
+
.update({ name: "Updated" })
|
|
103
|
+
.returnFirst();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Delete
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
await userModel.where({ id: esc(2) }).delete();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Upsert
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const row = await userModel
|
|
116
|
+
.upsert({
|
|
117
|
+
insert: { name: "Alex", email: "alex@ex.com", age: 20 },
|
|
118
|
+
update: { name: "Alex Updated" },
|
|
119
|
+
target: schema.user.email,
|
|
120
|
+
})
|
|
121
|
+
.returnFirst();
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Result refinement
|
|
127
|
+
|
|
128
|
+
### Query-side refinement (`findMany` / `findFirst` result)
|
|
129
|
+
|
|
130
|
+
#### Loading relations with `.with()`
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// Load related posts for each user
|
|
134
|
+
const users = await userModel
|
|
135
|
+
.findMany()
|
|
136
|
+
.with({ posts: true });
|
|
137
|
+
|
|
138
|
+
// Nested relations
|
|
139
|
+
const users = await userModel
|
|
140
|
+
.findMany()
|
|
141
|
+
.with({
|
|
142
|
+
posts: {
|
|
143
|
+
comments: true,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Multiple relations
|
|
148
|
+
const users = await userModel
|
|
149
|
+
.findMany()
|
|
150
|
+
.with({
|
|
151
|
+
posts: true,
|
|
152
|
+
invitee: true,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Query `where` relations
|
|
156
|
+
const users = await userModel
|
|
157
|
+
.findMany()
|
|
158
|
+
.with({
|
|
159
|
+
posts: postModel.where({
|
|
160
|
+
title: {
|
|
161
|
+
like: "New%"
|
|
162
|
+
}
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### Using `.include()` for type-safe relation values
|
|
168
|
+
|
|
169
|
+
`.include()` is a helper that returns the relation value as-is, used for type-level relation selection:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// Pass to .with()
|
|
173
|
+
const users = await userModel.findMany().with({
|
|
174
|
+
posts: postModel.where({
|
|
175
|
+
title: {
|
|
176
|
+
like: "New%"
|
|
177
|
+
}
|
|
178
|
+
}).include({
|
|
179
|
+
comments: true
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### SQL column selection with `.select()` and `.exclude()`
|
|
185
|
+
|
|
186
|
+
`.select()` and `.exclude()` control which columns appear in the SQL `SELECT` clause — they affect the query itself, not just the result.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// Only fetch id and name columns
|
|
190
|
+
const users = await userModel
|
|
191
|
+
.findMany()
|
|
192
|
+
.select({ id: true, name: true });
|
|
193
|
+
|
|
194
|
+
// Fetch all columns except email
|
|
195
|
+
const users = await userModel
|
|
196
|
+
.findMany()
|
|
197
|
+
.exclude({ email: true });
|
|
198
|
+
|
|
199
|
+
// Combine: start with a whitelist, then drop a field
|
|
200
|
+
const users = await userModel
|
|
201
|
+
.findMany()
|
|
202
|
+
.select({ id: true, name: true, email: true })
|
|
203
|
+
.exclude({ email: true });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
This is equivalent to:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
db.select({ id: schema.user.id, name: schema.user.name }).from(schema.user);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### Combining query refiners
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const users = await userModel
|
|
216
|
+
.findMany()
|
|
217
|
+
.with({ posts: true })
|
|
218
|
+
.select({ id: true, name: true });
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Available query refiners:
|
|
222
|
+
|
|
223
|
+
- `.select(fields)` — SQL SELECT whitelist
|
|
224
|
+
- `.exclude(fields)` — SQL SELECT blacklist
|
|
225
|
+
- `.with(relations)` — load related entities via JOINs
|
|
226
|
+
- `.raw()` — skip format function
|
|
227
|
+
- `.safe()` — wrap in `{ data, error }`
|
|
228
|
+
- `.debug()` — inspect query state
|
|
229
|
+
|
|
230
|
+
### Mutation-side refinement (`insert` / `update` / `delete` / `upsert` result)
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const rows = await userModel
|
|
234
|
+
.insert({ email: "a@b.com", name: "Alex", age: 20 })
|
|
235
|
+
.return();
|
|
236
|
+
|
|
237
|
+
const first = await userModel
|
|
238
|
+
.insert({ email: "b@b.com", name: "Anna", age: 21 })
|
|
239
|
+
.returnFirst();
|
|
240
|
+
|
|
241
|
+
// .omit() removes fields from the result AFTER the query runs (programmatic, not SQL)
|
|
242
|
+
const sanitized = await userModel
|
|
243
|
+
.where({ id: esc(1) })
|
|
244
|
+
.update({ secretField: 999 })
|
|
245
|
+
.returnFirst()
|
|
246
|
+
.omit({ secretField: true });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Available mutation refiners:
|
|
250
|
+
|
|
251
|
+
- `.return(fields?)` — return all rows
|
|
252
|
+
- `.returnFirst(fields?)` — return first row
|
|
253
|
+
- `.omit(fields)` — remove fields from result after query (programmatic, not SQL)
|
|
254
|
+
- `.safe()` — wrap in `{ data, error }`
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Error-safe execution
|
|
259
|
+
|
|
260
|
+
Use `.safe()` when you prefer a result object instead of throw/reject behavior.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const result = await userModel.findMany().safe();
|
|
264
|
+
|
|
265
|
+
if (result.error) {
|
|
266
|
+
console.error(result.error);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(result.data);
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Shape:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
type SafeResult<T> =
|
|
276
|
+
| { data: T; error: undefined }
|
|
277
|
+
| { data: undefined; error: unknown };
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Advanced: model options and extension
|
|
283
|
+
|
|
284
|
+
### `format`
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const userModel = model("user", {
|
|
288
|
+
format(row) {
|
|
289
|
+
const { secretField, ...rest } = row;
|
|
290
|
+
return {
|
|
291
|
+
...rest,
|
|
292
|
+
isVerified: Boolean(rest.isVerified),
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Use `.raw()` to bypass format.
|
|
299
|
+
|
|
300
|
+
### Default `where`
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const activeUsers = model("user", {
|
|
304
|
+
where: { isVerified: esc(true) },
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Custom `methods`
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
const userModel = model("user", {
|
|
312
|
+
methods: {
|
|
313
|
+
async byEmail(email: string) {
|
|
314
|
+
return await userModel.where({ email: esc(email) }).findFirst();
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### `extend()` and `db()`
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const extended = userModel.extend({
|
|
324
|
+
methods: {
|
|
325
|
+
async adults() {
|
|
326
|
+
return await userModel.where({ age: { gte: esc(18) } }).findMany();
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const txUserModel = userModel.db(db);
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Note: when method names conflict during `extend`, existing runtime methods take precedence over newly passed ones.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Full API reference
|
|
339
|
+
|
|
340
|
+
### Model-level methods
|
|
341
|
+
|
|
342
|
+
- Query/lifecycle:
|
|
343
|
+
- `where(value)`
|
|
344
|
+
- `findMany()`
|
|
345
|
+
- `findFirst()`
|
|
346
|
+
- `count()`
|
|
347
|
+
- `include(value)`
|
|
348
|
+
- `extend(options)`
|
|
349
|
+
- `db(dbInstance)`
|
|
350
|
+
- Mutations:
|
|
351
|
+
- `insert(value)`
|
|
352
|
+
- `update(value)`
|
|
353
|
+
- `delete()`
|
|
354
|
+
- `upsert(value)`
|
|
355
|
+
|
|
356
|
+
### Query result methods
|
|
357
|
+
|
|
358
|
+
- `.with(...)`
|
|
359
|
+
- `.select(...)`
|
|
360
|
+
- `.exclude(...)`
|
|
361
|
+
- `.raw()`
|
|
362
|
+
- `.safe()`
|
|
363
|
+
- `.debug()`
|
|
364
|
+
|
|
365
|
+
### Mutation result methods
|
|
366
|
+
|
|
367
|
+
- `.return(...)`
|
|
368
|
+
- `.returnFirst(...)`
|
|
369
|
+
- `.omit(...)`
|
|
370
|
+
- `.safe()`
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Dialect notes
|
|
375
|
+
|
|
376
|
+
- Dialects with native `.returning()` use it for mutation return pipelines.
|
|
377
|
+
- Dialects with ID-only return paths may use dialect-specific fallback behavior.
|
|
378
|
+
- Upsert uses `onConflictDoUpdate` when supported.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Type safety notes
|
|
383
|
+
|
|
384
|
+
- Prefer `esc(...)` for explicit where value/operator expressions.
|
|
385
|
+
- `.select()` and `.exclude()` control SQL SELECT columns and refine result types.
|
|
386
|
+
- `.omit()` removes fields from the result programmatically after the query.
|
|
387
|
+
- `.safe()` wraps result types into `{ data, error }`.
|
|
388
|
+
- `.return()` returns array shape; `.returnFirst()` returns single-row shape.
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Testing
|
|
393
|
+
|
|
394
|
+
Comprehensive tests are available in `tests/base`:
|
|
395
|
+
|
|
396
|
+
- `find.test.ts`
|
|
397
|
+
- `insert.test.ts`
|
|
398
|
+
- `update.test.ts`
|
|
399
|
+
- `delete.test.ts`
|
|
400
|
+
- `upsert.test.ts`
|
|
401
|
+
- `count.test.ts`
|
|
402
|
+
- `safe.test.ts`
|
|
403
|
+
- `relations.test.ts`
|
|
404
|
+
|
|
405
|
+
Run all base tests:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
bun test base
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Troubleshooting
|
|
414
|
+
|
|
415
|
+
### `safe()` returns `{ data: undefined, error }`
|
|
416
|
+
|
|
417
|
+
The underlying operation throws. Re-run without `.safe()` to inspect the raw stack.
|
|
418
|
+
|
|
419
|
+
### `.return()` result shape surprises
|
|
420
|
+
|
|
421
|
+
- `.return()` => array
|
|
422
|
+
- `.returnFirst()` => single object
|
|
423
|
+
- no return chain => dialect/default execution behavior
|
|
424
|
+
|
|
425
|
+
### Relation loading with `.with(...)`
|
|
426
|
+
|
|
427
|
+
Ensure relation metadata is defined with Drizzle `defineRelations` and passed to `modelBuilder({ relations })`.
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## License
|
|
432
|
+
|
|
433
|
+
MIT (follow repository root license if different).
|
package/TODO.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# TODO:
|
|
2
2
|
|
|
3
|
-
- DONE
|
|
3
|
+
- DONE => `count()` function to count rows
|
|
4
4
|
- DONE => Fix types. On insert, and add Simplify<> = for more readable queries.
|
|
5
|
-
- DONE
|
|
6
|
-
- DONE
|
|
7
|
-
- JUST remake a entire core. Manually write code with a few AI changes.
|
|
8
|
-
- DONE
|
|
5
|
+
- DONE => add `returnFirst()` to return first of `return()`
|
|
6
|
+
- DONE => add `omit()` as progammic `exclude()`. The main difference is `omit()` is applied after query. `exclude()` is applied on the query.
|
|
7
|
+
- DONE JUST remake a entire core. Manually write code with a few AI changes.
|
|
8
|
+
- DONE => Add `safe()`:
|
|
9
9
|
```ts
|
|
10
10
|
const { data: user, error } = userModel.findFirst().safe();
|
|
11
11
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apisr/drizzle-model",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"lint": "eslint . --max-warnings 0",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"check-types": "tsc --noEmit"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"drizzle-orm": "^1.0.0-beta.2-86f844e"
|
|
11
|
+
"drizzle-orm": "^1.0.0-beta.2-86f844e",
|
|
12
|
+
"pg": "^8.16.3"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"@repo/eslint-config": "*",
|
|
@@ -31,6 +32,6 @@
|
|
|
31
32
|
},
|
|
32
33
|
"type": "module",
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"
|
|
35
|
+
"type-fest": "^5.4.4"
|
|
35
36
|
}
|
|
36
37
|
}
|
package/src/core/query/joins.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, eq } from "drizzle-orm";
|
|
2
2
|
import type { DialectHelper } from "../dialect.ts";
|
|
3
3
|
import { ProjectionBuilder } from "./projection.ts";
|
|
4
|
+
import { WhereCompiler } from "./where.ts";
|
|
4
5
|
|
|
5
6
|
/** Generic record type used throughout the join executor. */
|
|
6
7
|
type AnyRecord = Record<string, unknown>;
|
|
@@ -45,6 +46,8 @@ export interface JoinNode {
|
|
|
45
46
|
targetTable: AnyRecord;
|
|
46
47
|
/** Name of the target (child) table. */
|
|
47
48
|
targetTableName: string;
|
|
49
|
+
/** Optional compiled WHERE filter for this relation (added to JOIN ON). */
|
|
50
|
+
whereFilter?: unknown;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// ---------------------------------------------------------------------------
|
|
@@ -59,12 +62,16 @@ export interface JoinExecutorConfig {
|
|
|
59
62
|
baseTableName: string;
|
|
60
63
|
/** The Drizzle database instance. */
|
|
61
64
|
db: unknown;
|
|
65
|
+
/** SQL SELECT blacklist for base table columns. */
|
|
66
|
+
exclude?: AnyRecord;
|
|
62
67
|
/** When `true`, only the first result is returned. */
|
|
63
68
|
limitOne?: boolean;
|
|
64
69
|
/** The relations metadata map from Drizzle. */
|
|
65
70
|
relations: Record<string, AnyRecord>;
|
|
66
71
|
/** The full schema map (`{ tableName: drizzleTable }`). */
|
|
67
72
|
schema: Record<string, AnyRecord>;
|
|
73
|
+
/** SQL SELECT whitelist for base table columns. */
|
|
74
|
+
select?: AnyRecord;
|
|
68
75
|
/** An optional compiled SQL where clause. */
|
|
69
76
|
whereSql?: unknown;
|
|
70
77
|
/** The user-supplied `.with()` value describing which relations to load. */
|
|
@@ -91,10 +98,12 @@ export interface JoinExecutorConfig {
|
|
|
91
98
|
export class JoinExecutor {
|
|
92
99
|
private readonly dialect: DialectHelper;
|
|
93
100
|
private readonly projection: ProjectionBuilder;
|
|
101
|
+
private readonly whereCompiler: WhereCompiler;
|
|
94
102
|
|
|
95
103
|
constructor(dialect: DialectHelper) {
|
|
96
104
|
this.dialect = dialect;
|
|
97
105
|
this.projection = new ProjectionBuilder();
|
|
106
|
+
this.whereCompiler = new WhereCompiler();
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
/**
|
|
@@ -126,6 +135,7 @@ export class JoinExecutor {
|
|
|
126
135
|
*/
|
|
127
136
|
private async buildJoinTree(config: JoinExecutorConfig): Promise<JoinNode> {
|
|
128
137
|
const usedAliasKeys = new Set<string>();
|
|
138
|
+
usedAliasKeys.add(`table:${config.baseTableName}`);
|
|
129
139
|
|
|
130
140
|
const root: JoinNode = {
|
|
131
141
|
path: [],
|
|
@@ -180,6 +190,9 @@ export class JoinExecutor {
|
|
|
180
190
|
value: unknown,
|
|
181
191
|
path: string[]
|
|
182
192
|
): Promise<JoinNode> {
|
|
193
|
+
const { whereValue, nestedWith } =
|
|
194
|
+
this.extractRelationDescriptor(value);
|
|
195
|
+
|
|
183
196
|
const relMeta = this.getRelationMeta(
|
|
184
197
|
config.relations,
|
|
185
198
|
currentTableName,
|
|
@@ -200,6 +213,13 @@ export class JoinExecutor {
|
|
|
200
213
|
? await this.dialect.createTableAlias(targetTable, aliasKey)
|
|
201
214
|
: targetTable;
|
|
202
215
|
|
|
216
|
+
const whereFilter = whereValue
|
|
217
|
+
? this.whereCompiler.compile(
|
|
218
|
+
targetAliasTable as AnyRecord,
|
|
219
|
+
whereValue
|
|
220
|
+
)
|
|
221
|
+
: undefined;
|
|
222
|
+
|
|
203
223
|
const node: JoinNode = {
|
|
204
224
|
path: [...path, key],
|
|
205
225
|
key,
|
|
@@ -215,10 +235,13 @@ export class JoinExecutor {
|
|
|
215
235
|
pkField: this.getPrimaryKeyField(targetAliasTable),
|
|
216
236
|
parent,
|
|
217
237
|
children: [],
|
|
238
|
+
whereFilter,
|
|
218
239
|
};
|
|
219
240
|
|
|
220
|
-
if (
|
|
221
|
-
for (const [childKey, childVal] of Object.entries(
|
|
241
|
+
if (nestedWith && typeof nestedWith === "object") {
|
|
242
|
+
for (const [childKey, childVal] of Object.entries(
|
|
243
|
+
nestedWith as AnyRecord
|
|
244
|
+
)) {
|
|
222
245
|
if (
|
|
223
246
|
childVal !== true &&
|
|
224
247
|
(typeof childVal !== "object" || childVal == null)
|
|
@@ -258,8 +281,14 @@ export class JoinExecutor {
|
|
|
258
281
|
root: JoinNode,
|
|
259
282
|
nodes: JoinNode[]
|
|
260
283
|
): Promise<AnyRecord[]> {
|
|
284
|
+
const baseColumns = this.projection.build(
|
|
285
|
+
root.targetAliasTable,
|
|
286
|
+
config.select,
|
|
287
|
+
config.exclude
|
|
288
|
+
).selectMap;
|
|
289
|
+
|
|
261
290
|
const selectMap: AnyRecord = {
|
|
262
|
-
base:
|
|
291
|
+
base: baseColumns,
|
|
263
292
|
};
|
|
264
293
|
|
|
265
294
|
for (const node of nodes) {
|
|
@@ -387,6 +416,10 @@ export class JoinExecutor {
|
|
|
387
416
|
return eq(tgtCol as never, src as never);
|
|
388
417
|
});
|
|
389
418
|
|
|
419
|
+
if (node.whereFilter) {
|
|
420
|
+
parts.push(node.whereFilter as never);
|
|
421
|
+
}
|
|
422
|
+
|
|
390
423
|
return parts.length === 1 ? parts[0] : and(...parts);
|
|
391
424
|
}
|
|
392
425
|
|
|
@@ -496,6 +529,40 @@ export class JoinExecutor {
|
|
|
496
529
|
);
|
|
497
530
|
}
|
|
498
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Extracts relation where clause and nested relations from a `.with()` value.
|
|
534
|
+
*
|
|
535
|
+
* Handles three cases:
|
|
536
|
+
* - `true` → no filter, no nested relations.
|
|
537
|
+
* - A ModelRuntime (has `$model === "model"`) → extract `$where`, no nested.
|
|
538
|
+
* - A model descriptor (`__modelRelation: true`) → extract `whereValue` and `with`.
|
|
539
|
+
* - A plain object → treat as nested relation map.
|
|
540
|
+
*/
|
|
541
|
+
private extractRelationDescriptor(value: unknown): {
|
|
542
|
+
whereValue: unknown;
|
|
543
|
+
nestedWith: unknown;
|
|
544
|
+
} {
|
|
545
|
+
if (value === true || value == null) {
|
|
546
|
+
return { whereValue: undefined, nestedWith: undefined };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (typeof value !== "object") {
|
|
550
|
+
return { whereValue: undefined, nestedWith: undefined };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const rec = value as AnyRecord;
|
|
554
|
+
|
|
555
|
+
if (rec.$model === "model") {
|
|
556
|
+
return { whereValue: rec.$where, nestedWith: undefined };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (rec.__modelRelation === true) {
|
|
560
|
+
return { whereValue: rec.whereValue, nestedWith: rec.with };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { whereValue: undefined, nestedWith: value };
|
|
564
|
+
}
|
|
565
|
+
|
|
499
566
|
// ---------------------------------------------------------------------------
|
|
500
567
|
// Helpers: result grouping internals
|
|
501
568
|
// ---------------------------------------------------------------------------
|
package/src/core/result.ts
CHANGED
|
@@ -22,13 +22,13 @@ export type SafeResult<T> =
|
|
|
22
22
|
|
|
23
23
|
/** Accumulated state for a query operation (findMany / findFirst). */
|
|
24
24
|
export interface QueryState {
|
|
25
|
-
/**
|
|
25
|
+
/** SQL SELECT blacklist — columns to omit from the query. */
|
|
26
26
|
exclude?: AnyRecord;
|
|
27
27
|
/** When `true`, formatting is skipped. */
|
|
28
28
|
raw?: boolean;
|
|
29
29
|
/** When `true`, result is wrapped in `{ data, error }`. */
|
|
30
30
|
safe?: boolean;
|
|
31
|
-
/**
|
|
31
|
+
/** SQL SELECT whitelist — columns to include in the query. */
|
|
32
32
|
select?: AnyRecord;
|
|
33
33
|
/** Compiled where clause. */
|
|
34
34
|
where?: unknown;
|
|
@@ -154,9 +154,12 @@ export class QueryResult<T> extends ThenableResult<T> {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
*
|
|
157
|
+
* Controls which columns appear in the SQL SELECT clause (whitelist).
|
|
158
158
|
*
|
|
159
|
-
*
|
|
159
|
+
* This affects the query itself, not just the result.
|
|
160
|
+
* Equivalent to `db.select({ col: table.col }).from(table)`.
|
|
161
|
+
*
|
|
162
|
+
* @param value - A map of `{ columnName: true }`.
|
|
160
163
|
* @returns A new `QueryResult` with the `.select()` state applied.
|
|
161
164
|
*/
|
|
162
165
|
select(value: AnyRecord): QueryResult<T> {
|
|
@@ -164,9 +167,12 @@ export class QueryResult<T> extends ThenableResult<T> {
|
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
/**
|
|
167
|
-
*
|
|
170
|
+
* Controls which columns are excluded from the SQL SELECT clause (blacklist).
|
|
171
|
+
*
|
|
172
|
+
* This affects the query itself, not just the result.
|
|
173
|
+
* All columns except the listed ones will be fetched.
|
|
168
174
|
*
|
|
169
|
-
* @param value - A map of `{
|
|
175
|
+
* @param value - A map of `{ columnName: true }`.
|
|
170
176
|
* @returns A new `QueryResult` with the `.exclude()` state applied.
|
|
171
177
|
*/
|
|
172
178
|
exclude(value: AnyRecord): QueryResult<T> {
|