@apisr/drizzle-model 2.0.0 → 2.0.2
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 +26 -0
- package/README.md +471 -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/query/operations.ts +77 -39
- package/src/model/result.ts +5 -2
- package/tests/base/esc-chainable.test.ts +159 -0
- package/tests/base/relations.test.ts +593 -0
- package/tests/base/upsert.test.ts +3 -3
- package/tests/snippets/x-2.ts +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Change log
|
|
2
|
+
|
|
3
|
+
## 2.0.2 | 01-0-2026
|
|
4
|
+
- add `esc.*()` operations
|
|
5
|
+
- update README.md with new `esc.*()`
|
|
6
|
+
|
|
7
|
+
## 2.0.1 | 01-03-2026
|
|
8
|
+
- mark `pg` as peerDep
|
|
9
|
+
- add `CHANGELOG.md`
|
|
10
|
+
- add `relations.test.ts` to test relations
|
|
11
|
+
- add `SimplifyDeep<>` from `type-fest` in queries, for better DX of using relations
|
|
12
|
+
- fix relations `include()` function
|
|
13
|
+
|
|
14
|
+
## 2.0.0 | 28-02-2026
|
|
15
|
+
- add `REAMDE.md`
|
|
16
|
+
- add `count()` function
|
|
17
|
+
- add `Simplify<>` to all queries
|
|
18
|
+
- add `returnFirst()` and make `return()` to return array of rows
|
|
19
|
+
- add `omit()` as transformator after `return()/returnFirst()`
|
|
20
|
+
- remake entire core, better JSDoc, OOP over functional, and much more...
|
|
21
|
+
- remake all tests
|
|
22
|
+
- add `safe()` function
|
|
23
|
+
```ts
|
|
24
|
+
const { data: user, error } = userModel.findFirst().safe();
|
|
25
|
+
```
|
|
26
|
+
- and in overall just make better DX
|
package/README.md
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
### Using `esc()` for explicit where expressions
|
|
385
|
+
|
|
386
|
+
The `esc()` function provides three ways to specify comparison operators:
|
|
387
|
+
|
|
388
|
+
**1. Implicit equality (simplest):**
|
|
389
|
+
```ts
|
|
390
|
+
where({ name: esc("Alex") })
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**2. Explicit operator (Drizzle-style):**
|
|
394
|
+
```ts
|
|
395
|
+
import { gte } from "drizzle-orm";
|
|
396
|
+
where({ age: esc(gte, 18) })
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**3. Chainable methods (recommended):**
|
|
400
|
+
```ts
|
|
401
|
+
where({ name: esc.like("%Alex%") })
|
|
402
|
+
where({ age: esc.gte(18) })
|
|
403
|
+
where({ status: esc.in(["active", "pending"]) })
|
|
404
|
+
where({ price: esc.between(10, 100) })
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Available chainable methods:**
|
|
408
|
+
- `esc.eq(value)` — equality
|
|
409
|
+
- `esc.not(value)` — inequality
|
|
410
|
+
- `esc.gt(value)` — greater than
|
|
411
|
+
- `esc.gte(value)` — greater than or equal
|
|
412
|
+
- `esc.lt(value)` — less than
|
|
413
|
+
- `esc.lte(value)` — less than or equal
|
|
414
|
+
- `esc.like(pattern)` — SQL LIKE pattern matching
|
|
415
|
+
- `esc.ilike(pattern)` — case-insensitive LIKE
|
|
416
|
+
- `esc.in(values)` — value in array
|
|
417
|
+
- `esc.nin(values)` — value not in array
|
|
418
|
+
- `esc.between(min, max)` — value between range
|
|
419
|
+
- `esc.notBetween(min, max)` — value not between range
|
|
420
|
+
|
|
421
|
+
### Other type safety features
|
|
422
|
+
|
|
423
|
+
- `.select()` and `.exclude()` control SQL SELECT columns and refine result types.
|
|
424
|
+
- `.omit()` removes fields from the result programmatically after the query.
|
|
425
|
+
- `.safe()` wraps result types into `{ data, error }`.
|
|
426
|
+
- `.return()` returns array shape; `.returnFirst()` returns single-row shape.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Testing
|
|
431
|
+
|
|
432
|
+
Comprehensive tests are available in `tests/base`:
|
|
433
|
+
|
|
434
|
+
- `find.test.ts`
|
|
435
|
+
- `insert.test.ts`
|
|
436
|
+
- `update.test.ts`
|
|
437
|
+
- `delete.test.ts`
|
|
438
|
+
- `upsert.test.ts`
|
|
439
|
+
- `count.test.ts`
|
|
440
|
+
- `safe.test.ts`
|
|
441
|
+
- `relations.test.ts`
|
|
442
|
+
|
|
443
|
+
Run all base tests:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
bun test base
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Troubleshooting
|
|
452
|
+
|
|
453
|
+
### `safe()` returns `{ data: undefined, error }`
|
|
454
|
+
|
|
455
|
+
The underlying operation throws. Re-run without `.safe()` to inspect the raw stack.
|
|
456
|
+
|
|
457
|
+
### `.return()` result shape surprises
|
|
458
|
+
|
|
459
|
+
- `.return()` => array
|
|
460
|
+
- `.returnFirst()` => single object
|
|
461
|
+
- no return chain => dialect/default execution behavior
|
|
462
|
+
|
|
463
|
+
### Relation loading with `.with(...)`
|
|
464
|
+
|
|
465
|
+
Ensure relation metadata is defined with Drizzle `defineRelations` and passed to `modelBuilder({ relations })`.
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## License
|
|
470
|
+
|
|
471
|
+
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.2",
|
|
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
|
// ---------------------------------------------------------------------------
|