@davidtkramer/convex-relations 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -78
- package/dist/index.d.ts +92 -48
- package/dist/index.js +330 -142
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,49 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img width="120" height="120" alt="image" src="https://github.com/user-attachments/assets/05619751-ea3a-4dd9-9bd7-3c231bd11d83" />
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<h1>Convex Relations</h1>
|
|
5
|
+
</div>
|
|
4
6
|
|
|
5
|
-
`convex-relations` is a server-side query facade for Convex. It
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
batch loading with `.in(...)`, and arbitrary computed fields with `compute(...)`.
|
|
9
|
-
|
|
10
|
-
It is designed for Convex query and mutation code, not frontend query clients.
|
|
11
|
-
|
|
12
|
-
## Table of Contents
|
|
13
|
-
|
|
14
|
-
- [Installation](#installation)
|
|
15
|
-
- [Example](#example)
|
|
16
|
-
- [Equivalent Convex Code](#equivalent-convex-code)
|
|
17
|
-
- [Quick Start](#quick-start)
|
|
18
|
-
- [Core Concepts](#core-concepts)
|
|
19
|
-
- [API](#api)
|
|
20
|
-
- [Table Access Patterns](#table-access-patterns)
|
|
21
|
-
- [Relation Expansion with `with(...)`](#relation-expansion-with-with)
|
|
22
|
-
- [Join Table Traversal with `via(...)`](#join-table-traversal-with-via)
|
|
23
|
-
- [Terminals](#terminals)
|
|
24
|
-
- [Error Semantics](#error-semantics)
|
|
25
|
-
- [Performance Characteristics](#performance-characteristics)
|
|
26
|
-
- [Comparison to `convex-helpers/server/relationships`](#comparison-to-convex-helpersserverrelationships)
|
|
27
|
-
- [Type Notes](#type-notes)
|
|
28
|
-
- [License](#license)
|
|
29
|
-
|
|
30
|
-
## Installation
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
npm install @davidtkramer/convex-relations
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
pnpm add @davidtkramer/convex-relations
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
bun add @davidtkramer/convex-relations
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
yarn add @davidtkramer/convex-relations
|
|
46
|
-
```
|
|
7
|
+
`convex-relations` is a server-side query facade for Convex. It lets you write
|
|
8
|
+
data loading code as a typed result tree instead of manually coordinating
|
|
9
|
+
lookups, parallelization, and response shaping by hand.
|
|
47
10
|
|
|
48
11
|
## Example
|
|
49
12
|
|
|
@@ -67,8 +30,7 @@ export const getPost = query({
|
|
|
67
30
|
}))
|
|
68
31
|
.take(10),
|
|
69
32
|
categories: ctx.q.categories
|
|
70
|
-
.
|
|
71
|
-
.byPostId(post._id)
|
|
33
|
+
.through(ctx.q.postCategories.byPostId(post._id), "categoryId")
|
|
72
34
|
.many(),
|
|
73
35
|
}))
|
|
74
36
|
.unique();
|
|
@@ -87,18 +49,18 @@ export const getPost = query({
|
|
|
87
49
|
});
|
|
88
50
|
```
|
|
89
51
|
|
|
90
|
-
This example shows
|
|
52
|
+
This example shows the core model:
|
|
91
53
|
|
|
92
|
-
- table-scoped access through `q.posts`, `q.comments`, `q.categories`
|
|
93
|
-
-
|
|
54
|
+
- table-scoped access through `q.posts`, `q.comments`, and `q.categories`
|
|
55
|
+
- indexes as first-class query methods like `.bySlug(...)` and `.byPostId(...)`
|
|
94
56
|
- nested relation expansion with `.with(...)` inside `.with(...)`
|
|
95
|
-
-
|
|
96
|
-
- parallel nested loading
|
|
97
|
-
- a final strongly typed result from one expression
|
|
57
|
+
- reference traversal with `.through(...)`
|
|
58
|
+
- parallel nested loading within each `with(...)`
|
|
59
|
+
- a final strongly typed, API-ready result from one expression
|
|
98
60
|
|
|
99
61
|
## Equivalent Convex Code
|
|
100
62
|
|
|
101
|
-
Without `convex-relations`, you end up assembling the same result shape by hand:
|
|
63
|
+
Without `convex-relations`, you end up assembling the same result shape by hand (or by agent 😉):
|
|
102
64
|
|
|
103
65
|
```ts
|
|
104
66
|
const post = await ctx.db
|
|
@@ -150,7 +112,40 @@ That works, but you are responsible for:
|
|
|
150
112
|
- remembering to manually `Promise.all(...)` nested relationships
|
|
151
113
|
- traversing join tables by hand
|
|
152
114
|
- assembling the final tree shape yourself for API responses
|
|
153
|
-
|
|
115
|
+
|
|
116
|
+
## Table of Contents
|
|
117
|
+
|
|
118
|
+
- [Installation](#installation)
|
|
119
|
+
- [Quick Start](#quick-start)
|
|
120
|
+
- [Core Concepts](#core-concepts)
|
|
121
|
+
- [API](#api)
|
|
122
|
+
- [Table Access Patterns](#table-access-patterns)
|
|
123
|
+
- [Relation Expansion with `with(...)`](#relation-expansion-with-with)
|
|
124
|
+
- [Reference Traversal with `through(...)`](#reference-traversal-with-through)
|
|
125
|
+
- [Terminals](#terminals)
|
|
126
|
+
- [Error Semantics](#error-semantics)
|
|
127
|
+
- [Performance Characteristics](#performance-characteristics)
|
|
128
|
+
- [Comparison to `convex-helpers/server/relationships`](#comparison-to-convex-helpersserverrelationships)
|
|
129
|
+
- [Type Notes](#type-notes)
|
|
130
|
+
- [License](#license)
|
|
131
|
+
|
|
132
|
+
## Installation
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install @davidtkramer/convex-relations
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pnpm add @davidtkramer/convex-relations
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
bun add @davidtkramer/convex-relations
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
yarn add @davidtkramer/convex-relations
|
|
148
|
+
```
|
|
154
149
|
|
|
155
150
|
## Quick Start
|
|
156
151
|
|
|
@@ -220,13 +215,47 @@ comments: defineTable({
|
|
|
220
215
|
const author = await ctx.q.authors.bySlug("ada-lovelace").unique();
|
|
221
216
|
const comments = await ctx.q.comments.byPostId(postId).many();
|
|
222
217
|
const approvedComments = await ctx.q.comments
|
|
223
|
-
.byPostIdAndStatus(
|
|
218
|
+
.byPostIdAndStatus(postId, "approved")
|
|
224
219
|
.many();
|
|
225
220
|
```
|
|
226
221
|
|
|
227
|
-
Single-field indexes accept a scalar. Compound indexes accept
|
|
228
|
-
|
|
229
|
-
|
|
222
|
+
Single-field indexes accept a scalar. Compound indexes accept positional
|
|
223
|
+
arguments in index order. Zero-argument calls give you the indexed range so you
|
|
224
|
+
can filter, sort, paginate, or take a subset.
|
|
225
|
+
|
|
226
|
+
### `with(...)` builds nested result shapes
|
|
227
|
+
|
|
228
|
+
`with(...)` lets you attach additional fields to every document in a query. The
|
|
229
|
+
callback receives the current document and returns an object whose values are
|
|
230
|
+
other query nodes or `compute(...)` calls.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const post = await ctx.q.posts
|
|
234
|
+
.bySlug("hello-world")
|
|
235
|
+
.with((post) => ({
|
|
236
|
+
author: ctx.q.authors.find(post.authorId),
|
|
237
|
+
comments: ctx.q.comments.byPostId(post._id).take(10),
|
|
238
|
+
}))
|
|
239
|
+
.unique();
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
That returns a single object shaped like:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
{
|
|
246
|
+
...post,
|
|
247
|
+
author,
|
|
248
|
+
comments,
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
This is the core idea behind the library: compose the data you want as a tree,
|
|
253
|
+
and `convex-relations` resolves and assembles that tree for you.
|
|
254
|
+
|
|
255
|
+
Within a single `with(...)`, sibling fields are resolved in parallel. If you
|
|
256
|
+
attach both `author` and `comments`, those branches start loading at the same
|
|
257
|
+
time. Nested `with(...)` calls preserve that behavior recursively, so each level
|
|
258
|
+
of the result tree parallelizes across its sibling fields.
|
|
230
259
|
|
|
231
260
|
## API
|
|
232
261
|
|
|
@@ -291,16 +320,16 @@ Single-field indexes accept a scalar:
|
|
|
291
320
|
const author = await q.authors.bySlug("ada-lovelace").unique();
|
|
292
321
|
```
|
|
293
322
|
|
|
294
|
-
Compound indexes accept
|
|
323
|
+
Compound indexes accept leading positional arguments:
|
|
295
324
|
|
|
296
325
|
```ts
|
|
297
326
|
const comments = await q.comments
|
|
298
|
-
.
|
|
327
|
+
.byPostIdAndStatus(postId)
|
|
299
328
|
.order("desc")
|
|
300
329
|
.take(20);
|
|
301
330
|
|
|
302
331
|
const exactOrPrefix = await q.comments
|
|
303
|
-
.
|
|
332
|
+
.byPostIdAndStatus(postId, "approved")
|
|
304
333
|
.many();
|
|
305
334
|
```
|
|
306
335
|
|
|
@@ -309,8 +338,8 @@ const exactOrPrefix = await q.comments
|
|
|
309
338
|
You can also pass Convex's index selector callback:
|
|
310
339
|
|
|
311
340
|
```ts
|
|
312
|
-
const
|
|
313
|
-
.
|
|
341
|
+
const approvedComments = await q.comments
|
|
342
|
+
.byPostIdAndStatus((q) => q.eq("postId", postId).eq("status", "approved"))
|
|
314
343
|
.many();
|
|
315
344
|
```
|
|
316
345
|
|
|
@@ -362,25 +391,27 @@ const post = await q.posts
|
|
|
362
391
|
|
|
363
392
|
Each `with(...)` stage sees fields added by earlier stages.
|
|
364
393
|
|
|
365
|
-
##
|
|
394
|
+
## Reference Traversal with `through(...)`
|
|
366
395
|
|
|
367
|
-
Use `
|
|
396
|
+
Use `through(sourceQuery, foreignKeyField)` when another query already produces
|
|
397
|
+
rows that point at the table you want.
|
|
368
398
|
|
|
369
399
|
Given `postCategories { postId, categoryId }`, you can fetch categories for a post:
|
|
370
400
|
|
|
371
401
|
```ts
|
|
372
402
|
const categories = await q.categories
|
|
373
|
-
.
|
|
374
|
-
.byPostId(postId)
|
|
403
|
+
.through(q.postCategories.byPostId(postId), "categoryId")
|
|
375
404
|
.many();
|
|
376
405
|
```
|
|
377
406
|
|
|
378
|
-
|
|
407
|
+
This is essentially syntactic sugar for "run the source query, extract ids from
|
|
408
|
+
that field, then load the target rows for you."
|
|
409
|
+
|
|
410
|
+
You can also attach the source row with `withSource(...)`:
|
|
379
411
|
|
|
380
412
|
```ts
|
|
381
413
|
const categories = await q.categories
|
|
382
|
-
.
|
|
383
|
-
.byPostId(postId)
|
|
414
|
+
.through(q.postCategories.byPostId(postId).order("desc"), "categoryId")
|
|
384
415
|
.withSource("link")
|
|
385
416
|
.many();
|
|
386
417
|
|
|
@@ -388,7 +419,38 @@ categories[0]?.link.postId;
|
|
|
388
419
|
categories[0]?.link.categoryId;
|
|
389
420
|
```
|
|
390
421
|
|
|
391
|
-
This is useful when the
|
|
422
|
+
This is useful when the source table stores metadata like ordering, role, or
|
|
423
|
+
timestamps.
|
|
424
|
+
|
|
425
|
+
`through(...)` is not limited to join tables. Any compatible source query works:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
const author = await q.authors
|
|
429
|
+
.through(q.posts.bySlug("hello-world"), "authorId")
|
|
430
|
+
.withSource("post")
|
|
431
|
+
.unique();
|
|
432
|
+
|
|
433
|
+
author.post.slug;
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Source-query shaping lives inside the `through(...)` argument:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
const tags = await q.tags
|
|
440
|
+
.through(
|
|
441
|
+
q.postTags
|
|
442
|
+
.byPostId(postId)
|
|
443
|
+
.filter((query) => query.eq(query.field("kind"), "primary"))
|
|
444
|
+
.order("desc")
|
|
445
|
+
.take(10),
|
|
446
|
+
"tagId",
|
|
447
|
+
)
|
|
448
|
+
.withSource("link")
|
|
449
|
+
.many();
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
After `through(...)`, you can keep shaping the target result with `with(...)`
|
|
453
|
+
and `withSource(...)`, then choose a terminal like `many()` or `first()`.
|
|
392
454
|
|
|
393
455
|
## Terminals
|
|
394
456
|
|
|
@@ -447,7 +509,6 @@ const page = await q.posts.byAuthorId(authorId).paginate({
|
|
|
447
509
|
- `unique()` also throws if there are multiple matches
|
|
448
510
|
- `first()` throws if there is no match
|
|
449
511
|
- `findOrNull()`, `uniqueOrNull()`, and `firstOrNull()` return `null` instead
|
|
450
|
-
- `via(...).unique()` normalizes its duplicate error to include the target table and join index
|
|
451
512
|
|
|
452
513
|
## Performance Characteristics
|
|
453
514
|
|
|
@@ -461,8 +522,7 @@ const post = await q.posts.find(postId).with((post) => ({
|
|
|
461
522
|
comments: q.comments.byPostId(post._id).take(10),
|
|
462
523
|
categoryCount: compute(async () => {
|
|
463
524
|
const categories = await q.categories
|
|
464
|
-
.
|
|
465
|
-
.byPostId(post._id)
|
|
525
|
+
.through(q.postCategories.byPostId(post._id), "categoryId")
|
|
466
526
|
.many();
|
|
467
527
|
return categories.length;
|
|
468
528
|
}),
|
|
@@ -489,7 +549,9 @@ q.posts
|
|
|
489
549
|
|
|
490
550
|
The second stage waits for the first stage, because it depends on fields added earlier.
|
|
491
551
|
|
|
492
|
-
`
|
|
552
|
+
`through(...)` currently resolves target documents by fetching the source rows
|
|
553
|
+
first, then loading each target document individually. This is correct and
|
|
554
|
+
predictable, but it is not a single batched join at the database level.
|
|
493
555
|
|
|
494
556
|
### Practical guidance
|
|
495
557
|
|
|
@@ -507,8 +569,7 @@ This:
|
|
|
507
569
|
|
|
508
570
|
```ts
|
|
509
571
|
const categories = await q.categories
|
|
510
|
-
.
|
|
511
|
-
.byPostId(postId)
|
|
572
|
+
.through(q.postCategories.byPostId(postId), "categoryId")
|
|
512
573
|
.many();
|
|
513
574
|
```
|
|
514
575
|
|
|
@@ -532,7 +593,7 @@ but also composes naturally with `with(...)`, `take(...)`, `firstOrNull()`, and
|
|
|
532
593
|
- Table names, `_id` types, index names, and compound index prefixes are inferred
|
|
533
594
|
- Invalid table names and invalid index names are rejected at compile time
|
|
534
595
|
- Scalar shorthand is only allowed for single-field indexes
|
|
535
|
-
- Compound indexes
|
|
596
|
+
- Compound indexes use leading positional arguments
|
|
536
597
|
|
|
537
598
|
## License
|
|
538
599
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GenericDataModel, TableNamesInDataModel, DocumentByName,
|
|
1
|
+
import { GenericDataModel, TableNamesInDataModel, DocumentByName, GenericTableInfo, FilterBuilder, ExpressionOrValue, NamedTableInfo, IndexNames, IndexRangeBuilder, NamedIndex, IndexRange, GenericDatabaseReader } from 'convex/server';
|
|
2
2
|
import { GenericId } from 'convex/values';
|
|
3
3
|
|
|
4
4
|
type Simplify<T> = {
|
|
@@ -8,28 +8,41 @@ type AppTable<DataModel extends GenericDataModel> = TableNamesInDataModel<DataMo
|
|
|
8
8
|
type AppDoc<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = DocumentByName<DataModel, Table>;
|
|
9
9
|
type UserIndex<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = Exclude<IndexNames<NamedTableInfo<DataModel, Table>>, 'by_creation_time'> & string;
|
|
10
10
|
type RawIndexFields<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = NamedIndex<NamedTableInfo<DataModel, Table>, IndexName>;
|
|
11
|
-
type
|
|
12
|
-
|
|
13
|
-
infer Field extends string,
|
|
11
|
+
type StripCreationTime<Fields extends readonly string[]> = Fields extends readonly [
|
|
12
|
+
...infer Rest extends readonly string[],
|
|
14
13
|
'_creationTime'
|
|
14
|
+
] ? Rest : Fields;
|
|
15
|
+
type UserIndexFields<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = StripCreationTime<RawIndexFields<DataModel, Table, IndexName>>;
|
|
16
|
+
type SingleIndexField<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = UserIndexFields<DataModel, Table, IndexName> extends readonly [
|
|
17
|
+
infer Field extends string
|
|
15
18
|
] ? Field : never;
|
|
16
|
-
type
|
|
19
|
+
type TuplePrefixArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Fields extends readonly string[], Seen extends readonly string[] = [], SeenValues extends readonly unknown[] = []> = Fields extends readonly [
|
|
17
20
|
infer Head extends string,
|
|
18
21
|
...infer Tail extends readonly string[]
|
|
19
|
-
] ?
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
] ? [...SeenValues, AppDoc<DataModel, Table>[Head]] | TuplePrefixArgs<DataModel, Table, Tail, [
|
|
23
|
+
...Seen,
|
|
24
|
+
Head
|
|
25
|
+
], [
|
|
26
|
+
...SeenValues,
|
|
27
|
+
AppDoc<DataModel, Table>[Head]
|
|
28
|
+
]> : never;
|
|
29
|
+
type PositionalIndexArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = TuplePrefixArgs<DataModel, Table, UserIndexFields<DataModel, Table, IndexName>>;
|
|
30
|
+
type RootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = PositionalIndexArgs<DataModel, Table, IndexName> | (SingleIndexField<DataModel, Table, IndexName> extends never ? never : AppDoc<DataModel, Table>[SingleIndexField<DataModel, Table, IndexName>]);
|
|
31
|
+
type StrictRootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>, Value extends RootIndexValueArg<DataModel, Table, IndexName>> = Value;
|
|
25
32
|
type TableIndexName<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = UserIndex<DataModel, Table> | 'by_id';
|
|
26
|
-
type
|
|
27
|
-
type
|
|
33
|
+
type UserIndexArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = SingleIndexField<DataModel, Table, IndexName> extends never ? PositionalIndexArgs<DataModel, Table, IndexName> : AppDoc<DataModel, Table>[SingleIndexField<DataModel, Table, IndexName>];
|
|
34
|
+
type TableIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>> = IndexName extends 'by_id' ? GenericId<Table> : IndexName extends UserIndex<DataModel, Table> ? UserIndexArg<DataModel, Table, IndexName> : never;
|
|
35
|
+
type StrictTableIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>, Value extends TableIndexValueArg<DataModel, Table, IndexName>> = Value;
|
|
36
|
+
type TableIndexInvocationArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>> = IndexName extends 'by_id' ? [GenericId<Table>] : IndexName extends UserIndex<DataModel, Table> ? PositionalIndexArgs<DataModel, Table, IndexName> : never;
|
|
28
37
|
type IdTargetTable<DataModel extends GenericDataModel, Value> = Value extends GenericId<infer Table extends AppTable<DataModel>> ? Table : never;
|
|
29
|
-
type JoinTargetTable<DataModel extends GenericDataModel, JoinTable extends AppTable<DataModel>, TargetField extends Extract<keyof AppDoc<DataModel, JoinTable>, string>> = IdTargetTable<DataModel, AppDoc<DataModel, JoinTable>[TargetField]>;
|
|
30
38
|
type QueryNode<Output> = PromiseLike<Output> & {
|
|
31
39
|
readonly _executeRoot: () => Promise<Output>;
|
|
32
40
|
};
|
|
41
|
+
type TableInfo<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = NamedTableInfo<DataModel, Table>;
|
|
42
|
+
type QueryPlanHandle<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
|
|
43
|
+
readonly _plan: QueryPlan;
|
|
44
|
+
readonly _table: Table;
|
|
45
|
+
};
|
|
33
46
|
type WithSpec = Record<string, QueryNode<any>>;
|
|
34
47
|
type WithBuilder<ParentItem, Spec extends WithSpec | undefined = WithSpec | undefined> = (parent: ParentItem) => Spec;
|
|
35
48
|
type AnyWithBuilder<ParentItem> = WithBuilder<ParentItem, WithSpec | undefined>;
|
|
@@ -49,81 +62,92 @@ type PaginatedResult<Item> = {
|
|
|
49
62
|
isDone: boolean;
|
|
50
63
|
continueCursor: string;
|
|
51
64
|
};
|
|
52
|
-
type QueryFilter = (q:
|
|
53
|
-
type
|
|
54
|
-
type
|
|
65
|
+
type QueryFilter<TableInfo extends GenericTableInfo> = (q: FilterBuilder<TableInfo>) => ExpressionOrValue<boolean>;
|
|
66
|
+
type QueryModifier = (query: any) => any;
|
|
67
|
+
type IndexSelector<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = (q: IndexRangeBuilder<AppDoc<DataModel, Table>, NamedIndex<TableInfo<DataModel, Table>, IndexName>>) => IndexRange;
|
|
68
|
+
type SingleNodeKind<Nullable extends boolean> = Nullable extends true ? 'nullableSingle' : 'single';
|
|
69
|
+
type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<Item, SingleNodeKind<Nullable>>;
|
|
70
|
+
type ExpandableSingleQueryBuilder<Item, Nullable extends boolean> = SingleQueryBuilder<Item, Nullable> & {
|
|
55
71
|
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ExpandableSingleQueryBuilder<ExpandWith<Item, Builder>, Nullable>;
|
|
56
72
|
};
|
|
57
|
-
type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item>;
|
|
58
73
|
type UniqueQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
|
|
59
74
|
type UniqueOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
|
|
60
75
|
type FirstQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
|
|
61
76
|
type FirstOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
|
|
62
77
|
type FindQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, false>;
|
|
63
78
|
type FindOrNullQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, true>;
|
|
64
|
-
type ManyQueryBuilder<Item> = QueryNode<Item[]>;
|
|
65
|
-
type BatchQueryBuilder<Item> =
|
|
66
|
-
type
|
|
79
|
+
type ManyQueryBuilder<Item> = QueryNode<Item[]> & ThroughNodeHandle<Item, 'many'>;
|
|
80
|
+
type BatchQueryBuilder<Item> = ManyQueryBuilder<Item>;
|
|
81
|
+
type ThroughSourceNodeKind = 'many' | 'single' | 'nullableSingle';
|
|
82
|
+
type ThroughNodeHandle<SourceItem, Kind extends ThroughSourceNodeKind> = {
|
|
83
|
+
readonly _throughSourceKind: Kind;
|
|
84
|
+
readonly _throughSourceType?: SourceItem;
|
|
85
|
+
};
|
|
86
|
+
type AnyManySourceNode<SourceItem> = QueryNode<SourceItem[]> & ThroughNodeHandle<SourceItem, 'many'>;
|
|
87
|
+
type AnySingleSourceNode<SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? SourceItem | null : SourceItem> & ThroughNodeHandle<SourceItem, SingleNodeKind<Nullable>>;
|
|
88
|
+
type ThroughSourceField<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem> = {
|
|
89
|
+
[Field in Extract<keyof SourceItem, string>]: IdTargetTable<DataModel, SourceItem[Field]> extends TargetTable ? Field : never;
|
|
90
|
+
}[Extract<keyof SourceItem, string>];
|
|
91
|
+
type ManyThroughQueryBuilder<Item, SourceItem = unknown> = QueryNode<Item[]> & ThroughNodeHandle<SourceItem, 'many'> & {
|
|
92
|
+
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ManyThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem>;
|
|
93
|
+
withSource<const SourceKey extends string>(key: SourceKey): ManyThroughQueryBuilder<AttachSource<Item, SourceItem, SourceKey>, SourceItem>;
|
|
94
|
+
};
|
|
95
|
+
type SingleThroughQueryBuilder<Item, SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<SourceItem, Nullable extends true ? 'nullableSingle' : 'single'> & {
|
|
96
|
+
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): SingleThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem, Nullable>;
|
|
97
|
+
withSource<const SourceKey extends string>(key: SourceKey): SingleThroughQueryBuilder<AttachSource<Item, SourceItem, SourceKey>, SourceItem, Nullable>;
|
|
98
|
+
};
|
|
67
99
|
type TableQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
|
|
68
100
|
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
|
|
69
101
|
order(direction: 'asc' | 'desc'): TableQueryFacade<DataModel, Table, Item>;
|
|
70
|
-
filter(filterer: QueryFilter): TableQueryFacade<DataModel, Table, Item>;
|
|
102
|
+
filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableQueryFacade<DataModel, Table, Item>;
|
|
71
103
|
unique(): UniqueQueryBuilder<Item>;
|
|
72
104
|
uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
|
|
73
105
|
first(): FirstQueryBuilder<Item>;
|
|
74
106
|
firstOrNull(): FirstOrNullQueryBuilder<Item>;
|
|
75
|
-
take(count: number):
|
|
107
|
+
take(count: number): ManyQueryBuilder<Item>;
|
|
76
108
|
paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
|
|
77
109
|
many(): ManyQueryBuilder<Item>;
|
|
78
|
-
}
|
|
110
|
+
} & QueryPlanHandle<DataModel, Table>;
|
|
79
111
|
type TableRangeQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
|
|
80
112
|
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableRangeQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
|
|
81
113
|
order(direction: 'asc' | 'desc'): TableRangeQueryFacade<DataModel, Table, Item>;
|
|
82
|
-
filter(filterer: QueryFilter): TableRangeQueryFacade<DataModel, Table, Item>;
|
|
114
|
+
filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableRangeQueryFacade<DataModel, Table, Item>;
|
|
83
115
|
unique(): UniqueQueryBuilder<Item>;
|
|
84
116
|
uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
|
|
85
117
|
first(): FirstQueryBuilder<Item>;
|
|
86
118
|
firstOrNull(): FirstOrNullQueryBuilder<Item>;
|
|
87
|
-
take(count: number):
|
|
119
|
+
take(count: number): ManyQueryBuilder<Item>;
|
|
88
120
|
paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
|
|
89
121
|
many(): ManyQueryBuilder<Item>;
|
|
90
|
-
}
|
|
122
|
+
} & QueryPlanHandle<DataModel, Table>;
|
|
91
123
|
type TableBatchQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
|
|
92
124
|
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableBatchQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
|
|
93
125
|
many(): BatchQueryBuilder<Item>;
|
|
94
126
|
};
|
|
95
|
-
type
|
|
96
|
-
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder):
|
|
97
|
-
|
|
98
|
-
filter(filterer: QueryFilter): ViaQueryFacade<DataModel, TargetTable, JoinTable, Item>;
|
|
99
|
-
withSource<const SourceKey extends string>(key: SourceKey): ViaQueryFacade<DataModel, TargetTable, JoinTable, AttachSource<Item, AppDoc<DataModel, JoinTable>, SourceKey>>;
|
|
127
|
+
type ThroughQueryFacade<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem, Item = AppDoc<DataModel, TargetTable>> = {
|
|
128
|
+
with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ThroughQueryFacade<DataModel, TargetTable, SourceItem, ExpandWith<Item, Builder>>;
|
|
129
|
+
withSource<const SourceKey extends string>(key: SourceKey): ThroughQueryFacade<DataModel, TargetTable, SourceItem, AttachSource<Item, SourceItem, SourceKey>>;
|
|
100
130
|
unique(): UniqueQueryBuilder<Item>;
|
|
101
131
|
uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
|
|
102
132
|
first(): FirstQueryBuilder<Item>;
|
|
103
133
|
firstOrNull(): FirstOrNullQueryBuilder<Item>;
|
|
104
|
-
|
|
105
|
-
paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
|
|
106
|
-
many(): ManyViaQueryBuilder<Item>;
|
|
107
|
-
};
|
|
108
|
-
type ValidViaTargetField<DataModel extends GenericDataModel, JoinTable extends AppTable<DataModel>, TargetTable extends AppTable<DataModel>> = {
|
|
109
|
-
[Field in Extract<keyof AppDoc<DataModel, JoinTable>, string>]: JoinTargetTable<DataModel, JoinTable, Field> extends TargetTable ? Field : never;
|
|
110
|
-
}[Extract<keyof AppDoc<DataModel, JoinTable>, string>];
|
|
111
|
-
type ViaIndexNamespace<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, JoinTable extends AppTable<DataModel>> = {
|
|
112
|
-
[IndexName in UserIndex<DataModel, JoinTable>]: {
|
|
113
|
-
(): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
|
|
114
|
-
<const Value extends RootIndexValueArg<DataModel, JoinTable, IndexName>>(value: StrictRootIndexValueArg<DataModel, JoinTable, IndexName, Value>): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
|
|
115
|
-
(selector: IndexSelector): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
|
|
116
|
-
};
|
|
134
|
+
many(): ManyQueryBuilder<Item>;
|
|
117
135
|
};
|
|
136
|
+
type ThroughCollectionSource<DataModel extends GenericDataModel, SourceTable extends AppTable<DataModel>, SourceItem = AppDoc<DataModel, SourceTable>> = TableQueryFacade<DataModel, SourceTable, SourceItem> | TableRangeQueryFacade<DataModel, SourceTable, SourceItem>;
|
|
118
137
|
type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
|
|
119
138
|
find<const Id extends GenericId<Table>>(id: Id): FindQueryBuilder<AppDoc<DataModel, Table>>;
|
|
120
139
|
findOrNull<const Id extends GenericId<Table>>(id: Id): FindOrNullQueryBuilder<AppDoc<DataModel, Table>>;
|
|
121
140
|
in<const Id extends GenericId<Table>>(ids: Id[]): TableBatchQueryFacade<DataModel, Table>;
|
|
122
|
-
|
|
141
|
+
through: {
|
|
142
|
+
<const SourceTable extends AppTable<DataModel>, SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: ThroughCollectionSource<DataModel, SourceTable, SourceItem>, targetField: TargetField): ThroughQueryFacade<DataModel, Table, SourceItem>;
|
|
143
|
+
<SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnyManySourceNode<SourceItem>, targetField: TargetField): ManyThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem>;
|
|
144
|
+
<SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, false>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, false>;
|
|
145
|
+
<SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, true>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, true>;
|
|
146
|
+
};
|
|
123
147
|
} & TableRangeQueryFacade<DataModel, Table> & {
|
|
124
148
|
[IndexName in TableIndexName<DataModel, Table>]: {
|
|
125
|
-
(selector: IndexSelector): TableQueryFacade<DataModel, Table>;
|
|
126
|
-
<const
|
|
149
|
+
(selector: IndexName extends UserIndex<DataModel, Table> ? IndexSelector<DataModel, Table, IndexName> : never): TableQueryFacade<DataModel, Table>;
|
|
150
|
+
<const Args extends TableIndexInvocationArgs<DataModel, Table, IndexName>>(...args: Args): TableQueryFacade<DataModel, Table>;
|
|
127
151
|
(): TableRangeQueryFacade<DataModel, Table>;
|
|
128
152
|
in<const Value extends TableIndexValueArg<DataModel, Table, IndexName>>(values: StrictTableIndexValueArg<DataModel, Table, IndexName, Value>[]): TableBatchQueryFacade<DataModel, Table>;
|
|
129
153
|
};
|
|
@@ -131,6 +155,26 @@ type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<D
|
|
|
131
155
|
type QueryFacade<DataModel extends GenericDataModel> = {
|
|
132
156
|
[Table in AppTable<DataModel>]: TableNamespace<DataModel, Table>;
|
|
133
157
|
};
|
|
158
|
+
type QuerySourcePlan = {
|
|
159
|
+
kind: 'id';
|
|
160
|
+
table: string;
|
|
161
|
+
id: GenericId<any>;
|
|
162
|
+
} | {
|
|
163
|
+
kind: 'query';
|
|
164
|
+
table: string;
|
|
165
|
+
index?: string;
|
|
166
|
+
selector?: unknown;
|
|
167
|
+
} | {
|
|
168
|
+
kind: 'batch';
|
|
169
|
+
table: string;
|
|
170
|
+
index: string;
|
|
171
|
+
values: unknown[];
|
|
172
|
+
};
|
|
173
|
+
type QueryPlan = {
|
|
174
|
+
source: QuerySourcePlan;
|
|
175
|
+
modifiers: QueryModifier[];
|
|
176
|
+
expanders: AnyWithBuilder<any>[];
|
|
177
|
+
};
|
|
134
178
|
declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>): QueryFacade<DataModel>;
|
|
135
179
|
declare function compute<Output = unknown>(load: () => Promise<Output> | Output): QueryNode<Output>;
|
|
136
180
|
|
package/dist/index.js
CHANGED
|
@@ -52,13 +52,11 @@ function withExpander(plan, expander) {
|
|
|
52
52
|
expanders: [...plan.expanders, expander]
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
-
function withSourceKey(plan, sourceKey) {
|
|
56
|
-
return {
|
|
57
|
-
...plan,
|
|
58
|
-
sourceKey
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
55
|
function normalizeIndexValues(index, value) {
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
const fieldNames = inferFieldNamesFromIndex(index);
|
|
58
|
+
return Object.fromEntries(fieldNames.map((field, index2) => [field, value[index2]]));
|
|
59
|
+
}
|
|
62
60
|
if (isPlainObject(value)) {
|
|
63
61
|
return value;
|
|
64
62
|
}
|
|
@@ -77,8 +75,14 @@ function applyIndexValues(query, values) {
|
|
|
77
75
|
return current;
|
|
78
76
|
}
|
|
79
77
|
function inferFieldNameFromIndex(index) {
|
|
78
|
+
return inferFieldNamesFromIndex(index)[0];
|
|
79
|
+
}
|
|
80
|
+
function inferFieldNamesFromIndex(index) {
|
|
81
|
+
if (index === "by_id") {
|
|
82
|
+
return ["_id"];
|
|
83
|
+
}
|
|
80
84
|
if (index.startsWith("by") && index.length > 2) {
|
|
81
|
-
return `${
|
|
85
|
+
return index.slice(2).split("And").map((part) => `${part[0].toLowerCase()}${part.slice(1)}`);
|
|
82
86
|
}
|
|
83
87
|
throw new Error(`Cannot infer field name from index ${index}`);
|
|
84
88
|
}
|
|
@@ -101,49 +105,61 @@ function createIndexedQuery(db, table, index, selector) {
|
|
|
101
105
|
(q) => applyIndexValues(q, normalizeIndexValues(index, selector))
|
|
102
106
|
);
|
|
103
107
|
}
|
|
104
|
-
function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
function sourceDescription(plan) {
|
|
109
|
+
switch (plan.source.kind) {
|
|
110
|
+
case "id":
|
|
111
|
+
return `${plan.source.table} with id ${plan.source.id}`;
|
|
112
|
+
case "batch":
|
|
113
|
+
return `${plan.source.table} via ${plan.source.index}`;
|
|
114
|
+
case "query":
|
|
115
|
+
return plan.source.index ? `${plan.source.table} with index ${plan.source.index}` : plan.source.table;
|
|
108
116
|
}
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
}
|
|
118
|
+
async function resolveThroughPair(db, targetField, source) {
|
|
119
|
+
const id = source[targetField];
|
|
120
|
+
if (!id) {
|
|
121
|
+
return null;
|
|
111
122
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
(q) => applyIndexValues(q, normalizeIndexValues(index, selector))
|
|
115
|
-
);
|
|
123
|
+
const target = await db.get(id);
|
|
124
|
+
return target ? { source, target } : null;
|
|
116
125
|
}
|
|
117
|
-
async function
|
|
126
|
+
async function collectThroughPairs(db, targetField, sourceItems) {
|
|
118
127
|
const pairs = await Promise.all(
|
|
119
|
-
|
|
120
|
-
const id = link[targetField];
|
|
121
|
-
const doc = id ? await db.get(id) : null;
|
|
122
|
-
return doc ? { doc, link } : null;
|
|
123
|
-
})
|
|
128
|
+
sourceItems.map(async (source) => await resolveThroughPair(db, targetField, source))
|
|
124
129
|
);
|
|
125
130
|
return pairs.filter((pair) => pair !== null);
|
|
126
131
|
}
|
|
127
|
-
async function
|
|
132
|
+
async function* iterateDecoratedSourceItems(db, plan) {
|
|
133
|
+
if (plan.source.kind !== "query") {
|
|
134
|
+
for (const item of await executeMany(db, plan)) {
|
|
135
|
+
yield item;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const runtime = createPlanRuntime(db, plan);
|
|
140
|
+
const source = plan.source;
|
|
141
|
+
const query = buildQuery(
|
|
142
|
+
() => createIndexedQuery(db, source.table, source.index, source.selector),
|
|
143
|
+
plan.modifiers
|
|
144
|
+
);
|
|
145
|
+
for await (const rawItem of query) {
|
|
146
|
+
yield await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function collectThroughPairsUntil(db, targetField, sourceItems, count) {
|
|
128
150
|
const pairs = [];
|
|
129
|
-
for await (const
|
|
130
|
-
const
|
|
131
|
-
if (!
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
pairs.push(
|
|
151
|
+
for await (const source of sourceItems) {
|
|
152
|
+
const pair = await resolveThroughPair(db, targetField, source);
|
|
153
|
+
if (!pair) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
pairs.push(pair);
|
|
135
157
|
if (pairs.length >= count) {
|
|
136
158
|
break;
|
|
137
159
|
}
|
|
138
160
|
}
|
|
139
161
|
return pairs;
|
|
140
162
|
}
|
|
141
|
-
function normalizeViaUniqueError(error, targetTable, joinTable, index) {
|
|
142
|
-
if (error instanceof Error && error.message === "unique() returned more than one result") {
|
|
143
|
-
return new Error(`Expected unique ${targetTable} via ${joinTable}.${index}`);
|
|
144
|
-
}
|
|
145
|
-
return error;
|
|
146
|
-
}
|
|
147
163
|
function createPlanRuntime(db, plan) {
|
|
148
164
|
const source = plan.source;
|
|
149
165
|
switch (source.kind) {
|
|
@@ -188,64 +204,10 @@ function createPlanRuntime(db, plan) {
|
|
|
188
204
|
}
|
|
189
205
|
};
|
|
190
206
|
}
|
|
191
|
-
case "via": {
|
|
192
|
-
const runQuery = () => buildQuery(
|
|
193
|
-
() => createViaQuery(db, source.joinTable, source.index, source.selector),
|
|
194
|
-
plan.modifiers
|
|
195
|
-
);
|
|
196
|
-
return {
|
|
197
|
-
unique: async () => {
|
|
198
|
-
const pairs = await collectViaPairsUntil(
|
|
199
|
-
db,
|
|
200
|
-
source.targetField,
|
|
201
|
-
runQuery(),
|
|
202
|
-
2
|
|
203
|
-
);
|
|
204
|
-
if (pairs.length > 1) {
|
|
205
|
-
throw new Error("unique() returned more than one result");
|
|
206
|
-
}
|
|
207
|
-
return pairs[0] ?? null;
|
|
208
|
-
},
|
|
209
|
-
first: async () => {
|
|
210
|
-
const pairs = await collectViaPairsUntil(
|
|
211
|
-
db,
|
|
212
|
-
source.targetField,
|
|
213
|
-
runQuery(),
|
|
214
|
-
1
|
|
215
|
-
);
|
|
216
|
-
return pairs[0] ?? null;
|
|
217
|
-
},
|
|
218
|
-
many: async () => await collectViaPairs(db, source.targetField, await runQuery().collect()),
|
|
219
|
-
take: async (count) => await collectViaPairs(db, source.targetField, await runQuery().take(count)),
|
|
220
|
-
paginate: async (opts) => {
|
|
221
|
-
const result = await runQuery().paginate(opts);
|
|
222
|
-
return {
|
|
223
|
-
page: await collectViaPairs(db, source.targetField, result.page),
|
|
224
|
-
isDone: result.isDone,
|
|
225
|
-
continueCursor: result.continueCursor
|
|
226
|
-
};
|
|
227
|
-
},
|
|
228
|
-
mapOne: async (rawItem) => rawItem.doc,
|
|
229
|
-
mapMany: async (rawItems) => rawItems.map((pair) => pair.doc),
|
|
230
|
-
missingMessages: {
|
|
231
|
-
unique: `Could not find ${source.targetTable} via ${source.joinTable}.${source.index}`,
|
|
232
|
-
first: `Could not find first ${source.targetTable} via ${source.joinTable}.${source.index}`
|
|
233
|
-
},
|
|
234
|
-
normalizeUniqueError: (error) => normalizeViaUniqueError(
|
|
235
|
-
error,
|
|
236
|
-
source.targetTable,
|
|
237
|
-
source.joinTable,
|
|
238
|
-
source.index
|
|
239
|
-
)
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
207
|
}
|
|
243
208
|
}
|
|
244
209
|
async function decorateItem(plan, rawItem, item) {
|
|
245
210
|
let output = item;
|
|
246
|
-
if (plan.source.kind === "via" && plan.sourceKey) {
|
|
247
|
-
output = { ...output, [plan.sourceKey]: rawItem.link };
|
|
248
|
-
}
|
|
249
211
|
if (plan.expanders.length > 0) {
|
|
250
212
|
output = await applyExpanders(output, plan.expanders);
|
|
251
213
|
}
|
|
@@ -253,12 +215,6 @@ async function decorateItem(plan, rawItem, item) {
|
|
|
253
215
|
}
|
|
254
216
|
async function decorateItems(plan, rawItems, items) {
|
|
255
217
|
let output = items;
|
|
256
|
-
if (plan.source.kind === "via" && plan.sourceKey) {
|
|
257
|
-
output = output.map((item, index) => ({
|
|
258
|
-
...item,
|
|
259
|
-
[plan.sourceKey]: rawItems[index].link
|
|
260
|
-
}));
|
|
261
|
-
}
|
|
262
218
|
if (plan.expanders.length > 0) {
|
|
263
219
|
output = await applyExpandersToMany(output, plan.expanders);
|
|
264
220
|
}
|
|
@@ -342,10 +298,219 @@ async function executePaginate(db, plan, opts) {
|
|
|
342
298
|
continueCursor: result?.continueCursor ?? opts.cursor ?? ""
|
|
343
299
|
};
|
|
344
300
|
}
|
|
301
|
+
function createSingleQueryBuilder(executeRoot, nullable) {
|
|
302
|
+
return {
|
|
303
|
+
...createQueryNode(executeRoot),
|
|
304
|
+
_throughSourceKind: nullable ? "nullableSingle" : "single"
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function createManyQueryBuilder(executeRoot) {
|
|
308
|
+
return {
|
|
309
|
+
...createQueryNode(executeRoot),
|
|
310
|
+
_throughSourceKind: "many"
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function decorateThroughItem(expanders, sourceKey, pair) {
|
|
314
|
+
let output = pair.target;
|
|
315
|
+
if (sourceKey) {
|
|
316
|
+
output = { ...output, [sourceKey]: pair.source };
|
|
317
|
+
}
|
|
318
|
+
if (expanders.length > 0) {
|
|
319
|
+
output = await applyExpanders(output, expanders);
|
|
320
|
+
}
|
|
321
|
+
return output;
|
|
322
|
+
}
|
|
323
|
+
async function decorateThroughItems(expanders, sourceKey, pairs) {
|
|
324
|
+
let output = pairs.map(
|
|
325
|
+
({ source, target }) => sourceKey ? { ...target, [sourceKey]: source } : target
|
|
326
|
+
);
|
|
327
|
+
if (expanders.length > 0) {
|
|
328
|
+
output = await applyExpandersToMany(output, expanders);
|
|
329
|
+
}
|
|
330
|
+
return output;
|
|
331
|
+
}
|
|
332
|
+
async function executeThroughCollectionMany(db, plan) {
|
|
333
|
+
const sourceItems = await executeMany(db, plan.sourcePlan);
|
|
334
|
+
const pairs = await collectThroughPairs(
|
|
335
|
+
db,
|
|
336
|
+
plan.targetField,
|
|
337
|
+
sourceItems
|
|
338
|
+
);
|
|
339
|
+
return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
|
|
340
|
+
}
|
|
341
|
+
async function executeThroughCollectionFirst(db, plan) {
|
|
342
|
+
const pair = (await collectThroughPairsUntil(
|
|
343
|
+
db,
|
|
344
|
+
plan.targetField,
|
|
345
|
+
iterateDecoratedSourceItems(db, plan.sourcePlan),
|
|
346
|
+
1
|
|
347
|
+
))[0];
|
|
348
|
+
if (!pair) {
|
|
349
|
+
throw new Error(`Could not find first ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
|
|
350
|
+
}
|
|
351
|
+
return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
|
|
352
|
+
}
|
|
353
|
+
async function executeThroughCollectionFirstOrNull(db, plan) {
|
|
354
|
+
const pair = (await collectThroughPairsUntil(
|
|
355
|
+
db,
|
|
356
|
+
plan.targetField,
|
|
357
|
+
iterateDecoratedSourceItems(db, plan.sourcePlan),
|
|
358
|
+
1
|
|
359
|
+
))[0];
|
|
360
|
+
return pair ? await decorateThroughItem(plan.expanders, plan.sourceKey, pair) : null;
|
|
361
|
+
}
|
|
362
|
+
async function executeThroughCollectionUnique(db, plan) {
|
|
363
|
+
const pairs = await collectThroughPairsUntil(
|
|
364
|
+
db,
|
|
365
|
+
plan.targetField,
|
|
366
|
+
iterateDecoratedSourceItems(db, plan.sourcePlan),
|
|
367
|
+
2
|
|
368
|
+
);
|
|
369
|
+
if (pairs.length > 1) {
|
|
370
|
+
throw new Error("unique() returned more than one result");
|
|
371
|
+
}
|
|
372
|
+
const pair = pairs[0];
|
|
373
|
+
if (!pair) {
|
|
374
|
+
throw new Error(`Could not find ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
|
|
375
|
+
}
|
|
376
|
+
return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
|
|
377
|
+
}
|
|
378
|
+
async function executeThroughCollectionUniqueOrNull(db, plan) {
|
|
379
|
+
const pairs = await collectThroughPairsUntil(
|
|
380
|
+
db,
|
|
381
|
+
plan.targetField,
|
|
382
|
+
iterateDecoratedSourceItems(db, plan.sourcePlan),
|
|
383
|
+
2
|
|
384
|
+
);
|
|
385
|
+
if (pairs.length > 1) {
|
|
386
|
+
throw new Error("unique() returned more than one result");
|
|
387
|
+
}
|
|
388
|
+
return pairs[0] ? await decorateThroughItem(plan.expanders, plan.sourceKey, pairs[0]) : null;
|
|
389
|
+
}
|
|
390
|
+
async function executeThroughManyNode(db, plan) {
|
|
391
|
+
const sourceItems = await plan.sourceNode._executeRoot();
|
|
392
|
+
const pairs = await collectThroughPairs(
|
|
393
|
+
db,
|
|
394
|
+
plan.targetField,
|
|
395
|
+
sourceItems
|
|
396
|
+
);
|
|
397
|
+
return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
|
|
398
|
+
}
|
|
399
|
+
async function executeThroughSingleNode(db, plan, nullable) {
|
|
400
|
+
const sourceItem = await plan.sourceNode._executeRoot();
|
|
401
|
+
if (sourceItem == null) {
|
|
402
|
+
if (nullable) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
throw new Error(`Could not find ${plan.targetTable} through source query`);
|
|
406
|
+
}
|
|
407
|
+
const pair = await resolveThroughPair(
|
|
408
|
+
db,
|
|
409
|
+
plan.targetField,
|
|
410
|
+
sourceItem
|
|
411
|
+
);
|
|
412
|
+
if (!pair) {
|
|
413
|
+
if (nullable) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`Could not find ${plan.targetTable} through source query`);
|
|
417
|
+
}
|
|
418
|
+
return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
|
|
419
|
+
}
|
|
420
|
+
function withThroughCollectionExpander(plan, expander) {
|
|
421
|
+
return {
|
|
422
|
+
...plan,
|
|
423
|
+
expanders: [...plan.expanders, expander]
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function withThroughCollectionSourceKey(plan, sourceKey) {
|
|
427
|
+
return {
|
|
428
|
+
...plan,
|
|
429
|
+
sourceKey
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function withThroughNodeExpander(plan, expander) {
|
|
433
|
+
return {
|
|
434
|
+
...plan,
|
|
435
|
+
expanders: [...plan.expanders, expander]
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function withThroughNodeSourceKey(plan, sourceKey) {
|
|
439
|
+
return {
|
|
440
|
+
...plan,
|
|
441
|
+
sourceKey
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function createThroughCollectionFacade(db, plan) {
|
|
445
|
+
return {
|
|
446
|
+
with(withBuilder) {
|
|
447
|
+
return createThroughCollectionFacade(db, withThroughCollectionExpander(plan, withBuilder));
|
|
448
|
+
},
|
|
449
|
+
withSource(key) {
|
|
450
|
+
return createThroughCollectionFacade(db, withThroughCollectionSourceKey(plan, key));
|
|
451
|
+
},
|
|
452
|
+
unique() {
|
|
453
|
+
return createSingleQueryBuilder(
|
|
454
|
+
async () => await executeThroughCollectionUnique(db, plan),
|
|
455
|
+
false
|
|
456
|
+
);
|
|
457
|
+
},
|
|
458
|
+
uniqueOrNull() {
|
|
459
|
+
return createSingleQueryBuilder(
|
|
460
|
+
async () => await executeThroughCollectionUniqueOrNull(db, plan),
|
|
461
|
+
true
|
|
462
|
+
);
|
|
463
|
+
},
|
|
464
|
+
first() {
|
|
465
|
+
return createSingleQueryBuilder(
|
|
466
|
+
async () => await executeThroughCollectionFirst(db, plan),
|
|
467
|
+
false
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
firstOrNull() {
|
|
471
|
+
return createSingleQueryBuilder(
|
|
472
|
+
async () => await executeThroughCollectionFirstOrNull(db, plan),
|
|
473
|
+
true
|
|
474
|
+
);
|
|
475
|
+
},
|
|
476
|
+
many() {
|
|
477
|
+
return createManyQueryBuilder(
|
|
478
|
+
async () => await executeThroughCollectionMany(db, plan)
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function createThroughManyQueryBuilder(db, plan) {
|
|
484
|
+
return {
|
|
485
|
+
...createQueryNode(async () => await executeThroughManyNode(db, plan)),
|
|
486
|
+
_throughSourceKind: "many",
|
|
487
|
+
with(withBuilder) {
|
|
488
|
+
return createThroughManyQueryBuilder(
|
|
489
|
+
db,
|
|
490
|
+
withThroughNodeExpander(plan, withBuilder)
|
|
491
|
+
);
|
|
492
|
+
},
|
|
493
|
+
withSource(key) {
|
|
494
|
+
return createThroughManyQueryBuilder(db, withThroughNodeSourceKey(plan, key));
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function createThroughSingleQueryBuilder(db, plan, nullable) {
|
|
499
|
+
return {
|
|
500
|
+
...createQueryNode(async () => await executeThroughSingleNode(db, plan, nullable)),
|
|
501
|
+
_throughSourceKind: nullable ? "nullableSingle" : "single",
|
|
502
|
+
with(withBuilder) {
|
|
503
|
+
return createThroughSingleQueryBuilder(db, withThroughNodeExpander(plan, withBuilder), nullable);
|
|
504
|
+
},
|
|
505
|
+
withSource(key) {
|
|
506
|
+
return createThroughSingleQueryBuilder(db, withThroughNodeSourceKey(plan, key), nullable);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
345
510
|
function createExpandableSingleFromPlan(db, plan, nullable) {
|
|
346
511
|
const execute = async () => nullable ? await executeFindOrNull(db, plan) : await executeFind(db, plan);
|
|
347
512
|
return {
|
|
348
|
-
...
|
|
513
|
+
...createSingleQueryBuilder(execute, nullable),
|
|
349
514
|
with(withBuilder) {
|
|
350
515
|
return createExpandableSingleFromPlan(
|
|
351
516
|
db,
|
|
@@ -364,12 +529,14 @@ function createBatchFacade(db, plan) {
|
|
|
364
529
|
);
|
|
365
530
|
},
|
|
366
531
|
many() {
|
|
367
|
-
return
|
|
532
|
+
return createManyQueryBuilder(async () => await executeMany(db, plan));
|
|
368
533
|
}
|
|
369
534
|
};
|
|
370
535
|
}
|
|
371
536
|
function createCollectionFacade(db, plan) {
|
|
372
537
|
const facade = {
|
|
538
|
+
_plan: plan,
|
|
539
|
+
_table: plan.source.table,
|
|
373
540
|
with(withBuilder) {
|
|
374
541
|
return createCollectionFacade(
|
|
375
542
|
db,
|
|
@@ -389,30 +556,33 @@ function createCollectionFacade(db, plan) {
|
|
|
389
556
|
);
|
|
390
557
|
},
|
|
391
558
|
unique() {
|
|
392
|
-
return
|
|
559
|
+
return createSingleQueryBuilder(async () => await executeUnique(db, plan), false);
|
|
393
560
|
},
|
|
394
561
|
uniqueOrNull() {
|
|
395
|
-
return
|
|
562
|
+
return createSingleQueryBuilder(
|
|
563
|
+
async () => await executeUniqueOrNull(db, plan),
|
|
564
|
+
true
|
|
565
|
+
);
|
|
396
566
|
},
|
|
397
567
|
first() {
|
|
398
|
-
return
|
|
568
|
+
return createSingleQueryBuilder(async () => await executeFirst(db, plan), false);
|
|
399
569
|
},
|
|
400
570
|
firstOrNull() {
|
|
401
|
-
return
|
|
571
|
+
return createSingleQueryBuilder(
|
|
572
|
+
async () => await executeFirstOrNull(db, plan),
|
|
573
|
+
true
|
|
574
|
+
);
|
|
402
575
|
},
|
|
403
576
|
take(count) {
|
|
404
|
-
return executeTake(db, plan, count);
|
|
577
|
+
return createManyQueryBuilder(async () => await executeTake(db, plan, count));
|
|
405
578
|
},
|
|
406
579
|
paginate(opts) {
|
|
407
580
|
return executePaginate(db, plan, opts);
|
|
408
581
|
},
|
|
409
582
|
many() {
|
|
410
|
-
return
|
|
583
|
+
return createManyQueryBuilder(async () => await executeMany(db, plan));
|
|
411
584
|
}
|
|
412
585
|
};
|
|
413
|
-
if (plan.source.kind === "via") {
|
|
414
|
-
facade.withSource = (key) => createCollectionFacade(db, withSourceKey(plan, key));
|
|
415
|
-
}
|
|
416
586
|
return facade;
|
|
417
587
|
}
|
|
418
588
|
function createIdPlan(table, id) {
|
|
@@ -438,15 +608,23 @@ function createBatchPlan(table, index, values) {
|
|
|
438
608
|
values
|
|
439
609
|
});
|
|
440
610
|
}
|
|
441
|
-
function
|
|
442
|
-
return
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
611
|
+
function isCollectionSource(value) {
|
|
612
|
+
return typeof value === "object" && value !== null && "_plan" in value && "_table" in value;
|
|
613
|
+
}
|
|
614
|
+
function isManySourceNode(value) {
|
|
615
|
+
return typeof value === "object" && value !== null && "_executeRoot" in value && value._throughSourceKind === "many";
|
|
616
|
+
}
|
|
617
|
+
function isSingleSourceNode(value) {
|
|
618
|
+
return typeof value === "object" && value !== null && "_executeRoot" in value && (value._throughSourceKind === "single" || value._throughSourceKind === "nullableSingle");
|
|
619
|
+
}
|
|
620
|
+
function normalizeIndexSelectorArgs(args) {
|
|
621
|
+
if (args.length === 0) {
|
|
622
|
+
return void 0;
|
|
623
|
+
}
|
|
624
|
+
if (args.length === 1) {
|
|
625
|
+
return args[0];
|
|
626
|
+
}
|
|
627
|
+
return args;
|
|
450
628
|
}
|
|
451
629
|
function createTableNamespace(db, table) {
|
|
452
630
|
const rootFacade = createCollectionFacade(db, createQueryPlan(table));
|
|
@@ -461,8 +639,36 @@ function createTableNamespace(db, table) {
|
|
|
461
639
|
in(ids) {
|
|
462
640
|
return createBatchFacade(db, createBatchPlan(table, "by_id", ids));
|
|
463
641
|
},
|
|
464
|
-
|
|
465
|
-
|
|
642
|
+
through(sourceQuery, targetField) {
|
|
643
|
+
if (isCollectionSource(sourceQuery)) {
|
|
644
|
+
return createThroughCollectionFacade(db, {
|
|
645
|
+
targetTable: table,
|
|
646
|
+
targetField,
|
|
647
|
+
sourcePlan: sourceQuery._plan,
|
|
648
|
+
expanders: []
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (isManySourceNode(sourceQuery)) {
|
|
652
|
+
return createThroughManyQueryBuilder(db, {
|
|
653
|
+
targetTable: table,
|
|
654
|
+
targetField,
|
|
655
|
+
sourceNode: sourceQuery,
|
|
656
|
+
expanders: []
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (isSingleSourceNode(sourceQuery)) {
|
|
660
|
+
return createThroughSingleQueryBuilder(
|
|
661
|
+
db,
|
|
662
|
+
{
|
|
663
|
+
targetTable: table,
|
|
664
|
+
targetField,
|
|
665
|
+
sourceNode: sourceQuery,
|
|
666
|
+
expanders: []
|
|
667
|
+
},
|
|
668
|
+
sourceQuery._throughSourceKind === "nullableSingle"
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
throw new Error("through() requires a query facade or lazy query node");
|
|
466
672
|
}
|
|
467
673
|
};
|
|
468
674
|
return new Proxy(target, {
|
|
@@ -473,9 +679,13 @@ function createTableNamespace(db, table) {
|
|
|
473
679
|
if (RESERVED_PROMISE_KEYS.has(prop) || prop === "all") {
|
|
474
680
|
return void 0;
|
|
475
681
|
}
|
|
476
|
-
const indexMethod = ((
|
|
682
|
+
const indexMethod = ((...args) => createCollectionFacade(
|
|
477
683
|
db,
|
|
478
|
-
createQueryPlan(
|
|
684
|
+
createQueryPlan(
|
|
685
|
+
table,
|
|
686
|
+
prop,
|
|
687
|
+
normalizeIndexSelectorArgs(args)
|
|
688
|
+
)
|
|
479
689
|
));
|
|
480
690
|
indexMethod.in = (values) => createBatchFacade(
|
|
481
691
|
db,
|
|
@@ -485,28 +695,6 @@ function createTableNamespace(db, table) {
|
|
|
485
695
|
}
|
|
486
696
|
});
|
|
487
697
|
}
|
|
488
|
-
function createViaNamespace(db, targetTable, joinTable, targetField) {
|
|
489
|
-
return new Proxy(
|
|
490
|
-
{},
|
|
491
|
-
{
|
|
492
|
-
get(_target, prop) {
|
|
493
|
-
if (typeof prop !== "string" || RESERVED_PROMISE_KEYS.has(prop)) {
|
|
494
|
-
return void 0;
|
|
495
|
-
}
|
|
496
|
-
return (value) => createCollectionFacade(
|
|
497
|
-
db,
|
|
498
|
-
createViaPlan(
|
|
499
|
-
targetTable,
|
|
500
|
-
joinTable,
|
|
501
|
-
targetField,
|
|
502
|
-
prop,
|
|
503
|
-
value
|
|
504
|
-
)
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
698
|
function createQueryFacade(db) {
|
|
511
699
|
return new Proxy(
|
|
512
700
|
{},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidtkramer/convex-relations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Typed query facade helpers for Convex backends",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"convex-test": "^0.0.41",
|
|
43
43
|
"tsup": "^8.5.0",
|
|
44
44
|
"typescript": "^5.9.2",
|
|
45
|
+
"typical-data": "^0.5.0",
|
|
45
46
|
"vitest": "^4.0.15"
|
|
46
47
|
},
|
|
47
48
|
"packageManager": "pnpm@9.15.9"
|