zod-paginate 1.10.2 → 2.0.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 +134 -34
- package/dist/paginate.d.ts +16 -4
- package/dist/paginate.js +70 -30
- package/dist/paginate.js.map +1 -1
- package/dist/select.d.ts +10 -2
- package/dist/select.js +9 -1
- package/dist/select.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ It is designed for Node.js HTTP stacks where query parameters arrive as strings
|
|
|
18
18
|
- **Filter DSL** with `$` operators and **nested AND/OR grouping**.
|
|
19
19
|
- **Response validation** — `responseSchema` is a generic schema covering all possible responses based on your config; `validatorSchema(parsed.select)` validates outgoing data projected to the actual requested `select`. `z.infer<typeof responseSchema>` gives you **key autocompletion** narrowed to configured `selectable` paths.
|
|
20
20
|
- **Discriminated union support** — `z.discriminatedUnion()` and `z.union()` as `dataSchema`, with compile-time and runtime discriminator enforcement.
|
|
21
|
+
- **Decorative fields** — mark computed/manual fields as selectable-only (excluded from sort & filter)
|
|
21
22
|
- **Standalone `select()`** utility for field-projection-only use cases.
|
|
22
23
|
- Compatible with **OpenAPI tooling** ([zod-openapi](https://github.com/samchungy/zod-openapi) etc.).
|
|
23
24
|
|
|
@@ -105,7 +106,7 @@ const parsed = queryParamsSchema().parse({
|
|
|
105
106
|
page: "2",
|
|
106
107
|
sortBy: "createdAt:DESC",
|
|
107
108
|
select: "id,status",
|
|
108
|
-
"filter
|
|
109
|
+
"filter[status]": "$ilike:act",
|
|
109
110
|
});
|
|
110
111
|
|
|
111
112
|
console.log(parsed.pagination);
|
|
@@ -167,6 +168,7 @@ Returns:
|
|
|
167
168
|
|---|---:|---|
|
|
168
169
|
| `dataSchema` | `z.ZodObject` \| `z.ZodDiscriminatedUnion` \| `z.ZodUnion` | Zod schema representing one data item. |
|
|
169
170
|
| `selectable` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths supported). |
|
|
171
|
+
| `decorative?` | `string[]` (typed paths) | Subset of `selectable`. Fields that are added manually (not from DB). Included in `decorativeFields` output. |
|
|
170
172
|
| `defaultSelect` | `field[] \| "*"` | **Required.** Default when `select` is missing. `"*"` expands to `selectable`. |
|
|
171
173
|
| `responseType` | `"many" \| "one"` | Shape of `data` in the response (default: `"many"`). |
|
|
172
174
|
|
|
@@ -271,6 +273,7 @@ Returns:
|
|
|
271
273
|
| `paginationType` | `"LIMIT_OFFSET"` \| `"CURSOR"` | Pagination mode. |
|
|
272
274
|
| `dataSchema` | `z.ZodObject` \| `z.ZodDiscriminatedUnion` \| `z.ZodUnion` | Zod schema for one data item (used for projection + cursor inference). |
|
|
273
275
|
| `selectable` | `string[]` (typed paths) | **Required.** Allowlist of selectable fields (dot paths). Enables `select`. |
|
|
276
|
+
| `decorative?` | `string[]` (typed paths) | Subset of `selectable`. Fields added manually (not from DB). Cannot be sorted or filtered. Included in `decorativeSelect` output. |
|
|
274
277
|
| `sortable?` | `string[]` (typed paths) | Allowlist of sortable fields. Enables `sortBy`. |
|
|
275
278
|
| `filterable?` | object | Allowlist of filterable fields and allowed operators + field type. |
|
|
276
279
|
| `defaultSortBy?` | `{ property, direction }[]` | Default sort if `sortBy` missing/empty. |
|
|
@@ -423,23 +426,23 @@ contextSchema.parse({
|
|
|
423
426
|
|
|
424
427
|
## Filters
|
|
425
428
|
|
|
426
|
-
Filters use query keys with
|
|
429
|
+
Filters use query keys with bracket notation `filter[<field>]=<dsl>` where `<field>` is a dot-path (e.g. `meta.score`). Configure which fields and operators are allowed via the `filterable` option.
|
|
427
430
|
|
|
428
431
|
### Operators
|
|
429
432
|
|
|
430
433
|
| Operator | Meaning | Value format | Example |
|
|
431
434
|
|---|---|---|---|
|
|
432
|
-
| `$eq` | equals | number / string / ISO date | `filter
|
|
433
|
-
| `$null` | is null | _(no value)_ | `filter
|
|
434
|
-
| `$in` | in list | comma-separated | `filter
|
|
435
|
-
| `$contains` | contains values | comma-separated | `filter
|
|
436
|
-
| `$gt` | greater than | number or ISO date | `filter
|
|
437
|
-
| `$gte` | greater than or equal | number or ISO date | `filter
|
|
438
|
-
| `$lt` | less than | number or ISO date | `filter
|
|
439
|
-
| `$lte` | less than or equal | number or ISO date | `filter
|
|
440
|
-
| `$btw` | between (inclusive) | `a,b` (same type) | `filter
|
|
441
|
-
| `$ilike` | case-insensitive contains | string | `filter
|
|
442
|
-
| `$sw` | starts with | string | `filter
|
|
435
|
+
| `$eq` | equals | number / string / ISO date | `filter[status]=$eq:active` |
|
|
436
|
+
| `$null` | is null | _(no value)_ | `filter[deletedAt]=$null` |
|
|
437
|
+
| `$in` | in list | comma-separated | `filter[status]=$in:active,pending` |
|
|
438
|
+
| `$contains` | contains values | comma-separated | `filter[tags]=$contains:typescript,zod` |
|
|
439
|
+
| `$gt` | greater than | number or ISO date | `filter[id]=$gt:100` |
|
|
440
|
+
| `$gte` | greater than or equal | number or ISO date | `filter[createdAt]=$gte:2025-01-01` |
|
|
441
|
+
| `$lt` | less than | number or ISO date | `filter[id]=$lt:500` |
|
|
442
|
+
| `$lte` | less than or equal | number or ISO date | `filter[id]=$lte:500` |
|
|
443
|
+
| `$btw` | between (inclusive) | `a,b` (same type) | `filter[id]=$btw:10,100` |
|
|
444
|
+
| `$ilike` | case-insensitive contains | string | `filter[name]=$ilike:john` |
|
|
445
|
+
| `$sw` | starts with | string | `filter[name]=$sw:Jon` |
|
|
443
446
|
|
|
444
447
|
If the filter value does **not** start with `$`, it is interpreted as `$eq:<value>`.
|
|
445
448
|
|
|
@@ -448,20 +451,20 @@ If the filter value does **not** start with `$`, it is interpreted as `$eq:<valu
|
|
|
448
451
|
Prefix any operator with `$not:` to negate the condition:
|
|
449
452
|
|
|
450
453
|
```txt
|
|
451
|
-
filter
|
|
452
|
-
filter
|
|
454
|
+
filter[deletedAt]=$not:$null
|
|
455
|
+
filter[status]=$not:$eq:active
|
|
453
456
|
```
|
|
454
457
|
|
|
455
458
|
### Multiple conditions for the same field
|
|
456
459
|
|
|
457
|
-
|
|
460
|
+
Repeat the same query param key to pass multiple conditions:
|
|
458
461
|
|
|
459
462
|
```txt
|
|
460
|
-
filter
|
|
463
|
+
filter[id]=$gt:10&filter[id]=$lt:100
|
|
461
464
|
```
|
|
462
465
|
|
|
463
466
|
```ts
|
|
464
|
-
{ "filter
|
|
467
|
+
{ "filter[id]": ["$gt:10", "$lt:100"] }
|
|
465
468
|
```
|
|
466
469
|
|
|
467
470
|
Runtime validation enforces: field allowlist (`filterable`), operator allowlist per field (`ops`), and value type compatibility.
|
|
@@ -475,31 +478,35 @@ Groups let you build nested AND/OR boolean logic.
|
|
|
475
478
|
Prefix any filter DSL with `$g:<groupId>:`:
|
|
476
479
|
|
|
477
480
|
```txt
|
|
478
|
-
filter
|
|
481
|
+
filter[status]=$g:1:$eq:active
|
|
479
482
|
```
|
|
480
483
|
|
|
481
484
|
Within a group, the **first** condition cannot have `$and`/`$or`. Following conditions may be prefixed with `$and` or `$or`.
|
|
482
485
|
|
|
483
|
-
### Group tree definitions: `group
|
|
486
|
+
### Group tree definitions: `group`
|
|
484
487
|
|
|
485
|
-
Define parent-child relationships between groups:
|
|
488
|
+
Define parent-child relationships between groups using the repeated `group` query parameter. Each entry has the format `id:key=value,key=value`.
|
|
486
489
|
|
|
487
|
-
|
|
488
|
-
- `group.<id>.join` — how this group joins its parent (`$and` or `$or`)
|
|
489
|
-
- `group.<id>.op` — default join for this group's children (optional)
|
|
490
|
+
Available keys:
|
|
490
491
|
|
|
491
|
-
|
|
492
|
+
- `parent` — parent group id (integer string)
|
|
493
|
+
- `join` — how this group joins its parent (`$and` or `$or`)
|
|
494
|
+
- `op` — default join for this group's children (optional)
|
|
495
|
+
|
|
496
|
+
Rules: root group id is always `"0"`. `parent` and `join` are forbidden on group `0`. Cycles are rejected. Child groups are resolved in numeric order.
|
|
492
497
|
|
|
493
498
|
**Example:** `(status == active OR status == postponed) AND (id > 10)`
|
|
494
499
|
|
|
500
|
+
```txt
|
|
501
|
+
?filter[status]=$g:1:$eq:active&filter[status]=$g:1:$or:$eq:postponed&filter[id]=$g:2:$gt:10&group=1:parent=0&group=2:parent=0,join=$and
|
|
502
|
+
```
|
|
503
|
+
|
|
495
504
|
```ts
|
|
496
505
|
const parsed = queryParamsSchema().parse({
|
|
497
|
-
"filter
|
|
498
|
-
"filter
|
|
506
|
+
"filter[status]": ["$g:1:$eq:active", "$g:1:$or:$eq:postponed"],
|
|
507
|
+
"filter[id]": "$g:2:$gt:10",
|
|
499
508
|
|
|
500
|
-
"
|
|
501
|
-
"group.2.parent": "0",
|
|
502
|
-
"group.2.join": "$and",
|
|
509
|
+
group: ["1:parent=0", "2:parent=0,join=$and"],
|
|
503
510
|
});
|
|
504
511
|
|
|
505
512
|
// parsed.pagination.filters
|
|
@@ -629,6 +636,99 @@ schema.safeParse({ data: [{ id: 1, type: "video", bitrate: 320 }] });
|
|
|
629
636
|
// → fails validation
|
|
630
637
|
```
|
|
631
638
|
|
|
639
|
+
## Decorative fields
|
|
640
|
+
|
|
641
|
+
Some fields in your schema may be computed or added manually at runtime (e.g. image URLs, aggregated values) rather than stored in and fetched from the database. Marking them as `decorative` tells zod-paginate that they are **selectable** but must **not be sorted or filtered**.
|
|
642
|
+
|
|
643
|
+
### Configuration
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { z } from "zod";
|
|
647
|
+
import { paginate } from "zod-paginate";
|
|
648
|
+
|
|
649
|
+
const ShowSummary = z.object({
|
|
650
|
+
id: z.number(),
|
|
651
|
+
title: z.string(),
|
|
652
|
+
urlAlias: z.string(),
|
|
653
|
+
image: z.string(),
|
|
654
|
+
genres: z.string().nullable(),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const PublicFeed = z.object({
|
|
658
|
+
id: z.number(),
|
|
659
|
+
title: z.string(),
|
|
660
|
+
shows: z.array(ShowSummary),
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const { queryParamsSchema } = paginate({
|
|
664
|
+
paginationType: "LIMIT_OFFSET",
|
|
665
|
+
dataSchema: PublicFeed,
|
|
666
|
+
selectable: ["id", "title", "shows.id", "shows.title", "shows.image", "shows.genres"],
|
|
667
|
+
decorative: ["shows.image"], // ← added at runtime, not from DB
|
|
668
|
+
sortable: ["id", "title"], // "shows.image" here would be a type error
|
|
669
|
+
filterable: {
|
|
670
|
+
id: { type: "number", ops: ["$eq"] },
|
|
671
|
+
// "shows.image" here would be a type error
|
|
672
|
+
},
|
|
673
|
+
defaultSelect: "*",
|
|
674
|
+
defaultLimit: 20,
|
|
675
|
+
maxLimit: 100,
|
|
676
|
+
});
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Parsed output
|
|
680
|
+
|
|
681
|
+
The parsed payload includes `decorativeSelect` (or `decorativeFields` for `select()`), containing only the decorative fields that were actually requested:
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
const parsed = queryParamsSchema().parse({ select: "*" });
|
|
685
|
+
|
|
686
|
+
parsed.pagination.select;
|
|
687
|
+
// → ["id", "title", "shows.id", "shows.title", "shows.image", "shows.genres"]
|
|
688
|
+
|
|
689
|
+
parsed.pagination.decorativeSelect;
|
|
690
|
+
// → ["shows.image"]
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
When none of the requested fields are decorative, `decorativeSelect` is `undefined`.
|
|
694
|
+
|
|
695
|
+
### Usage in adapters
|
|
696
|
+
|
|
697
|
+
Your adapter can use `decorativeSelect` to skip those fields when building the database query:
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
const { select, decorativeSelect } = parsed.pagination;
|
|
701
|
+
|
|
702
|
+
// Only query real DB fields
|
|
703
|
+
const dbFields = select?.filter(f => !decorativeSelect?.includes(f));
|
|
704
|
+
// → ["id", "title", "shows.id", "shows.title", "shows.genres"]
|
|
705
|
+
|
|
706
|
+
const rows = await db.query(dbFields);
|
|
707
|
+
|
|
708
|
+
// Then enrich with decorative fields manually
|
|
709
|
+
const data = rows.map(row => ({
|
|
710
|
+
...row,
|
|
711
|
+
shows: row.shows.map(s => ({ ...s, image: buildImageUrl(s) })),
|
|
712
|
+
}));
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### With `select()` standalone
|
|
716
|
+
|
|
717
|
+
The same option works with `select()` — the output uses `decorativeFields` instead:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
const { queryParamsSchema } = select({
|
|
721
|
+
dataSchema: ShowSummary,
|
|
722
|
+
selectable: ["id", "title", "urlAlias", "image", "genres"],
|
|
723
|
+
decorative: ["image"],
|
|
724
|
+
defaultSelect: "*",
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const parsed = queryParamsSchema().parse({ select: "id,title,image" });
|
|
728
|
+
parsed.select.fields; // → ["id", "title", "image"]
|
|
729
|
+
parsed.select.decorativeFields; // → ["image"]
|
|
730
|
+
```
|
|
731
|
+
|
|
632
732
|
## Extending `queryParamsSchema`
|
|
633
733
|
|
|
634
734
|
Both `select()` and `paginate()` support extending `queryParamsSchema` with additional fields:
|
|
@@ -659,7 +759,7 @@ Extra fields are validated together — errors from both sides are collected in
|
|
|
659
759
|
### LIMIT/OFFSET
|
|
660
760
|
|
|
661
761
|
```txt
|
|
662
|
-
?limit=20&page=1&select=id,status,createdAt&sortBy=createdAt:DESC&filter
|
|
762
|
+
?limit=20&page=1&select=id,status,createdAt&sortBy=createdAt:DESC&filter[status]=$ilike:act&filter[id]=$gt:10
|
|
663
763
|
```
|
|
664
764
|
|
|
665
765
|
```ts
|
|
@@ -668,8 +768,8 @@ const parsed = queryParamsSchema().parse({
|
|
|
668
768
|
page: "1",
|
|
669
769
|
select: "id,status,createdAt",
|
|
670
770
|
sortBy: "createdAt:DESC",
|
|
671
|
-
"filter
|
|
672
|
-
"filter
|
|
771
|
+
"filter[status]": "$ilike:act",
|
|
772
|
+
"filter[id]": "$gt:10",
|
|
673
773
|
});
|
|
674
774
|
|
|
675
775
|
// parsed.pagination
|
|
@@ -777,7 +877,7 @@ export function select<
|
|
|
777
877
|
| `SelectConfig<TSchema, TSelectable>` | Configuration for `select()` |
|
|
778
878
|
| `SelectResult<TSchema, TSelectable, TResponseType?>` | Return type of `select()`. `TResponseType` narrows `validatorSchema` return and `responseType` property. |
|
|
779
879
|
| `SelectQueryParams<TSchema, TSelectable>` | Parsed output of `select()` — `{ select: SelectQueryPayload }` |
|
|
780
|
-
| `SelectQueryPayload<TSchema, TSelectable, TResponseType?>` | Inner select payload — `{ fields, responseType }`. Passed to `validatorSchema()`. |
|
|
880
|
+
| `SelectQueryPayload<TSchema, TSelectable, TResponseType?>` | Inner select payload — `{ fields, decorativeFields?, responseType }`. Passed to `validatorSchema()`. |
|
|
781
881
|
| `SelectOneQueryPayload<TSchema, TSelectable?>` | Shorthand for `SelectQueryPayload<…, 'one'>` |
|
|
782
882
|
| `SelectManyQueryPayload<TSchema, TSelectable?>` | Shorthand for `SelectQueryPayload<…, 'many'>` |
|
|
783
883
|
| `SelectResponse<TSchema, TSelect, TResponseType?>` | Response type: `{ data: … }` — array when `'many'`, single object when `'one'` |
|
package/dist/paginate.d.ts
CHANGED
|
@@ -163,9 +163,11 @@ export interface CommonQueryConfigFromSchema<TSchema extends DataSchema, TSelect
|
|
|
163
163
|
dataSchema: TSchema;
|
|
164
164
|
/** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
|
|
165
165
|
selectable: readonly TSelectable[];
|
|
166
|
+
/** Fields that are decorative (added manually, not from DB). Cannot be sorted or filtered. Subset of selectable. */
|
|
167
|
+
decorative?: readonly AllowedPath<TSchema>[];
|
|
166
168
|
/** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
|
|
167
169
|
sortable?: readonly AllowedPath<TSchema>[];
|
|
168
|
-
/** Map of filterable fields to their allowed type and operators. Enables the `filter
|
|
170
|
+
/** Map of filterable fields to their allowed type and operators. Enables the `filter[*]` query parameters. */
|
|
169
171
|
filterable?: Partial<{
|
|
170
172
|
[P in AllowedPath<TSchema>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
|
|
171
173
|
}>;
|
|
@@ -195,6 +197,8 @@ export interface LimitOffsetPaginationPayload<TSchema extends DataSchema> {
|
|
|
195
197
|
page?: number;
|
|
196
198
|
sortBy?: SortItemTyped<TSchema>[];
|
|
197
199
|
select?: AllowedPath<TSchema>[];
|
|
200
|
+
/** Subset of `select` that are decorative (not from DB, added manually). */
|
|
201
|
+
decorativeSelect?: AllowedPath<TSchema>[];
|
|
198
202
|
filters?: WhereNode;
|
|
199
203
|
}
|
|
200
204
|
/**
|
|
@@ -208,6 +212,8 @@ export interface CursorPaginationPayload<TSchema extends DataSchema> {
|
|
|
208
212
|
cursorProperty: AllowedPath<TSchema>;
|
|
209
213
|
sortBy?: SortItemTyped<TSchema>[];
|
|
210
214
|
select?: AllowedPath<TSchema>[];
|
|
215
|
+
/** Subset of `select` that are decorative (not from DB, added manually). */
|
|
216
|
+
decorativeSelect?: AllowedPath<TSchema>[];
|
|
211
217
|
filters?: WhereNode;
|
|
212
218
|
}
|
|
213
219
|
export type PaginationType = 'LIMIT_OFFSET' | 'CURSOR';
|
|
@@ -309,13 +315,15 @@ export declare function paginate<TSchema extends DataSchema, const TSelectable e
|
|
|
309
315
|
}[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & LimitOffsetPaginationConfig & {
|
|
310
316
|
/** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
|
|
311
317
|
selectable: NoDuplicates<TSelectable> & EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
|
|
318
|
+
/** Fields that are decorative (added manually, not from DB). Cannot be sorted or filtered. */
|
|
319
|
+
decorative?: readonly NoInfer<TSelectable[number]>[];
|
|
312
320
|
/** Default fields returned when `select` is omitted. Use `"*"` to select all. */
|
|
313
321
|
defaultSelect: NoDuplicates<TDefaultSelect> | '*';
|
|
314
322
|
/** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
|
|
315
323
|
sortable?: NoDuplicates<TSortable>;
|
|
316
324
|
/** Default sort order applied when `sortBy` is omitted from the query. */
|
|
317
325
|
defaultSortBy?: NoDuplicateProperties<TDefaultSortBy>;
|
|
318
|
-
/** Map of filterable fields to their allowed type and operators. Enables the `filter
|
|
326
|
+
/** Map of filterable fields to their allowed type and operators. Enables the `filter[*]` query parameters. */
|
|
319
327
|
filterable?: Partial<{
|
|
320
328
|
[P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
|
|
321
329
|
}>;
|
|
@@ -329,13 +337,15 @@ export declare function paginate<TSchema extends DataSchema, const TSelectable e
|
|
|
329
337
|
}[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & CursorPaginationConfig<InferData<TSchema>> & {
|
|
330
338
|
/** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
|
|
331
339
|
selectable: NoDuplicates<TSelectable> & EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
|
|
340
|
+
/** Fields that are decorative (added manually, not from DB). Cannot be sorted or filtered. */
|
|
341
|
+
decorative?: readonly NoInfer<TSelectable[number]>[];
|
|
332
342
|
/** Default fields returned when `select` is omitted. Use `"*"` to select all. */
|
|
333
343
|
defaultSelect: NoDuplicates<TDefaultSelect> | '*';
|
|
334
344
|
/** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
|
|
335
345
|
sortable?: NoDuplicates<TSortable>;
|
|
336
346
|
/** Default sort order applied when `sortBy` is omitted from the query. */
|
|
337
347
|
defaultSortBy?: NoDuplicateProperties<TDefaultSortBy>;
|
|
338
|
-
/** Map of filterable fields to their allowed type and operators. Enables the `filter
|
|
348
|
+
/** Map of filterable fields to their allowed type and operators. Enables the `filter[*]` query parameters. */
|
|
339
349
|
filterable?: Partial<{
|
|
340
350
|
[P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
|
|
341
351
|
}>;
|
|
@@ -349,13 +359,15 @@ export declare function paginate<TSchema extends DataSchema, const TSelectable e
|
|
|
349
359
|
}[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & {
|
|
350
360
|
/** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
|
|
351
361
|
selectable: NoDuplicates<TSelectable> & EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
|
|
362
|
+
/** Fields that are decorative (added manually, not from DB). Cannot be sorted or filtered. */
|
|
363
|
+
decorative?: readonly NoInfer<TSelectable[number]>[];
|
|
352
364
|
/** Default fields returned when `select` is omitted. Use `"*"` to select all. */
|
|
353
365
|
defaultSelect: NoDuplicates<TDefaultSelect> | '*';
|
|
354
366
|
/** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
|
|
355
367
|
sortable?: NoDuplicates<TSortable>;
|
|
356
368
|
/** Default sort order applied when `sortBy` is omitted from the query. */
|
|
357
369
|
defaultSortBy?: NoDuplicateProperties<TDefaultSortBy>;
|
|
358
|
-
/** Map of filterable fields to their allowed type and operators. Enables the `filter
|
|
370
|
+
/** Map of filterable fields to their allowed type and operators. Enables the `filter[*]` query parameters. */
|
|
359
371
|
filterable?: Partial<{
|
|
360
372
|
[P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
|
|
361
373
|
}>;
|
package/dist/paginate.js
CHANGED
|
@@ -174,41 +174,55 @@ const WhereNodeSchema = zod_1.z
|
|
|
174
174
|
});
|
|
175
175
|
function extractGroupDefs(q) {
|
|
176
176
|
const defs = {};
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
177
|
+
const raw = q.group;
|
|
178
|
+
if (raw === undefined || raw === null)
|
|
179
|
+
return defs;
|
|
180
|
+
let entries;
|
|
181
|
+
if (Array.isArray(raw)) {
|
|
182
|
+
entries = raw.filter((x) => typeof x === 'string');
|
|
183
|
+
}
|
|
184
|
+
else if (typeof raw === 'string') {
|
|
185
|
+
entries = [raw];
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
entries = [];
|
|
189
|
+
}
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
// Format: "id:key=value,key=value"
|
|
192
|
+
const colonIdx = entry.indexOf(':');
|
|
193
|
+
if (colonIdx === -1)
|
|
183
194
|
continue;
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const parsedId = exports.IntegerStringSchema.safeParse(
|
|
195
|
+
const idRaw = entry.slice(0, colonIdx).trim();
|
|
196
|
+
const propsRaw = entry.slice(colonIdx + 1).trim();
|
|
197
|
+
const parsedId = exports.IntegerStringSchema.safeParse(idRaw);
|
|
187
198
|
if (!parsedId.success)
|
|
188
199
|
continue;
|
|
189
200
|
const id = parsedId.data;
|
|
190
|
-
const first = Array.isArray(v) ? v[0] : v;
|
|
191
|
-
const valueStr = typeof first === 'string' ? first : '';
|
|
192
201
|
const current = defs[id] ?? {};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
for (const pair of propsRaw.split(',')) {
|
|
203
|
+
const eqIdx = pair.indexOf('=');
|
|
204
|
+
if (eqIdx === -1)
|
|
205
|
+
continue;
|
|
206
|
+
const prop = pair.slice(0, eqIdx).trim();
|
|
207
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
208
|
+
if (prop === 'parent') {
|
|
209
|
+
current.parent = exports.IntegerStringSchema.parse(value);
|
|
210
|
+
}
|
|
211
|
+
else if (prop === 'join') {
|
|
212
|
+
current.join = exports.CombinatorSchema.parse(value);
|
|
213
|
+
}
|
|
214
|
+
else if (prop === 'op') {
|
|
215
|
+
current.op = exports.CombinatorSchema.parse(value);
|
|
216
|
+
}
|
|
204
217
|
}
|
|
218
|
+
defs[id] = current;
|
|
205
219
|
}
|
|
206
220
|
return defs;
|
|
207
221
|
}
|
|
208
222
|
function validateGroupDefs(defs) {
|
|
209
223
|
const root = defs[ROOT_GROUP_ID];
|
|
210
224
|
if (root && (root.parent !== undefined || root.join !== undefined)) {
|
|
211
|
-
throw new Error(`
|
|
225
|
+
throw new Error(`Group "0" can only define "op". "parent" and "join" are not allowed on root group "0".`);
|
|
212
226
|
}
|
|
213
227
|
for (const [id, def] of Object.entries(defs)) {
|
|
214
228
|
if (id === ROOT_GROUP_ID)
|
|
@@ -367,7 +381,7 @@ function assertSameKind(a, b, ctx) {
|
|
|
367
381
|
throw new Error(`$btw bounds must be same type (both number or both date) for ${ctx}`);
|
|
368
382
|
}
|
|
369
383
|
}
|
|
370
|
-
/** Parse a single "filter
|
|
384
|
+
/** Parse a single "filter[field]" DSL string into a Condition. */
|
|
371
385
|
function parseSingleCondition(raw) {
|
|
372
386
|
const parts = raw.split(':');
|
|
373
387
|
let group = ROOT_GROUP_ID;
|
|
@@ -444,9 +458,10 @@ function parseSingleCondition(raw) {
|
|
|
444
458
|
function extractAndNormalizeRawFilters(q) {
|
|
445
459
|
const result = {};
|
|
446
460
|
for (const [k, v] of Object.entries(q)) {
|
|
447
|
-
|
|
461
|
+
const match = /^filter\[([^\]]+)\]$/.exec(k);
|
|
462
|
+
if (!match)
|
|
448
463
|
continue;
|
|
449
|
-
const field =
|
|
464
|
+
const field = (match[1] ?? '').trim();
|
|
450
465
|
if (!field)
|
|
451
466
|
continue;
|
|
452
467
|
const rawList = toStringArrayFromQueryString(v);
|
|
@@ -729,6 +744,7 @@ function paginate(config) {
|
|
|
729
744
|
defaultSelect: config.defaultSelect === '*' ? '*' : Array.from(config.defaultSelect, String),
|
|
730
745
|
};
|
|
731
746
|
const allowedSelectable = new Set(selectableStrings);
|
|
747
|
+
const decorativeSet = new Set((config.decorative ?? []).map(String));
|
|
732
748
|
const allowedSortable = new Set();
|
|
733
749
|
for (const f of config.sortable ?? [])
|
|
734
750
|
allowedSortable.add(`${f}`);
|
|
@@ -754,9 +770,9 @@ function paginate(config) {
|
|
|
754
770
|
/*
|
|
755
771
|
* Build the root ZodObject with explicit named properties so that
|
|
756
772
|
* OpenAPI tooling (zod-openapi, fastify-zod-openapi) can introspect
|
|
757
|
-
* the query parameters.
|
|
758
|
-
*
|
|
759
|
-
* in the piped baseSchema below.
|
|
773
|
+
* the query parameters. Filter fields use bracket notation (filter[field])
|
|
774
|
+
* and group uses a repeated "group" param. The actual validation/transforms
|
|
775
|
+
* happen in the piped baseSchema below.
|
|
760
776
|
*/
|
|
761
777
|
const rootShape = {
|
|
762
778
|
limit: zod_1.z
|
|
@@ -801,6 +817,26 @@ function paginate(config) {
|
|
|
801
817
|
example: defaultSelectDesc,
|
|
802
818
|
});
|
|
803
819
|
}
|
|
820
|
+
// Add filter[field] entries and group param only when filterable is configured
|
|
821
|
+
if (config.filterable) {
|
|
822
|
+
for (const [field, def] of Object.entries(filterable)) {
|
|
823
|
+
const ops = def.ops.join(', ');
|
|
824
|
+
rootShape[`filter[${field}]`] = zod_1.z
|
|
825
|
+
.union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())])
|
|
826
|
+
.optional()
|
|
827
|
+
.meta({
|
|
828
|
+
description: `Filter on "${field}" (${def.type}). Supported operators: ${ops}. Format: "$op:value"`,
|
|
829
|
+
example: `${def.ops[0]}:value`,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
rootShape.group = zod_1.z
|
|
833
|
+
.union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())])
|
|
834
|
+
.optional()
|
|
835
|
+
.meta({
|
|
836
|
+
description: 'Group definitions for complex filter logic. Format: "id:key=value,key=value". Keys: parent, join ($and/$or), op ($and/$or)',
|
|
837
|
+
example: '1:parent=0,join=$and',
|
|
838
|
+
});
|
|
839
|
+
}
|
|
804
840
|
const baseQueryParamsSchema = zod_1.z
|
|
805
841
|
.object(rootShape)
|
|
806
842
|
.catchall(zod_1.z.unknown())
|
|
@@ -982,7 +1018,7 @@ function paginate(config) {
|
|
|
982
1018
|
ctx.addIssue({
|
|
983
1019
|
code: 'custom',
|
|
984
1020
|
path: ['groupDefs'],
|
|
985
|
-
message: `group
|
|
1021
|
+
message: `group is not allowed without any filter[*]`,
|
|
986
1022
|
});
|
|
987
1023
|
}
|
|
988
1024
|
else if (hasAnyFilter) {
|
|
@@ -1009,6 +1045,7 @@ function paginate(config) {
|
|
|
1009
1045
|
? { filters: buildWhereAstWithGroups(val.rawFilters, val.groupDefs) }
|
|
1010
1046
|
: {};
|
|
1011
1047
|
if (config.paginationType === 'LIMIT_OFFSET') {
|
|
1048
|
+
const decorativeSelect = select?.filter((f) => decorativeSet.has(f));
|
|
1012
1049
|
return {
|
|
1013
1050
|
pagination: {
|
|
1014
1051
|
type: 'LIMIT_OFFSET',
|
|
@@ -1016,6 +1053,7 @@ function paginate(config) {
|
|
|
1016
1053
|
page: val.page,
|
|
1017
1054
|
sortBy,
|
|
1018
1055
|
select,
|
|
1056
|
+
...(decorativeSelect && decorativeSelect.length > 0 ? { decorativeSelect } : {}),
|
|
1019
1057
|
...maybeFilters,
|
|
1020
1058
|
},
|
|
1021
1059
|
};
|
|
@@ -1025,6 +1063,7 @@ function paginate(config) {
|
|
|
1025
1063
|
if (val.cursor !== undefined) {
|
|
1026
1064
|
cursor = coerceCursorFromProperty(config.dataSchema, config.cursorProperty, val.cursor);
|
|
1027
1065
|
}
|
|
1066
|
+
const decorativeSelect = select?.filter((f) => decorativeSet.has(f));
|
|
1028
1067
|
return {
|
|
1029
1068
|
pagination: {
|
|
1030
1069
|
type: 'CURSOR',
|
|
@@ -1033,6 +1072,7 @@ function paginate(config) {
|
|
|
1033
1072
|
cursorProperty: config.cursorProperty,
|
|
1034
1073
|
sortBy,
|
|
1035
1074
|
select,
|
|
1075
|
+
...(decorativeSelect && decorativeSelect.length > 0 ? { decorativeSelect } : {}),
|
|
1036
1076
|
...maybeFilters,
|
|
1037
1077
|
},
|
|
1038
1078
|
};
|