ng-qubee 3.4.0 → 3.5.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 +3 -2
- package/fesm2022/ng-qubee.mjs +582 -105
- package/fesm2022/ng-qubee.mjs.map +1 -1
- package/package.json +3 -1
- package/types/ng-qubee.d.ts +50 -20
package/fesm2022/ng-qubee.mjs
CHANGED
|
@@ -74,6 +74,7 @@ class PaginatedCollection {
|
|
|
74
74
|
*/
|
|
75
75
|
var DriverEnum;
|
|
76
76
|
(function (DriverEnum) {
|
|
77
|
+
DriverEnum["DRF"] = "drf";
|
|
77
78
|
DriverEnum["JSON_API"] = "json-api";
|
|
78
79
|
DriverEnum["LARAVEL"] = "laravel";
|
|
79
80
|
DriverEnum["NESTJS"] = "nestjs";
|
|
@@ -124,6 +125,35 @@ class ResponseOptions {
|
|
|
124
125
|
this.total = options.total || 'total';
|
|
125
126
|
}
|
|
126
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Pre-configured ResponseOptions for the Django REST Framework (DRF) driver
|
|
130
|
+
*
|
|
131
|
+
* DRF's `PageNumberPagination` envelope is `{ count, next, previous,
|
|
132
|
+
* results }`, with no body field naming the current page, per-page, or
|
|
133
|
+
* last-page. The strategy parses those from the `next` / `previous`
|
|
134
|
+
* URLs, so the corresponding key paths default to empty strings; the
|
|
135
|
+
* strategy ignores `options.currentPage`, `options.perPage`,
|
|
136
|
+
* `options.lastPage`, `options.from`, `options.to`, `options.path`,
|
|
137
|
+
* `options.firstPageUrl`, and `options.lastPageUrl`.
|
|
138
|
+
*/
|
|
139
|
+
class DrfResponseOptions extends ResponseOptions {
|
|
140
|
+
constructor(options) {
|
|
141
|
+
super({
|
|
142
|
+
currentPage: options.currentPage || '',
|
|
143
|
+
data: options.data || 'results',
|
|
144
|
+
firstPageUrl: options.firstPageUrl || '',
|
|
145
|
+
from: options.from || '',
|
|
146
|
+
lastPage: options.lastPage || '',
|
|
147
|
+
lastPageUrl: options.lastPageUrl || '',
|
|
148
|
+
nextPageUrl: options.nextPageUrl || 'next',
|
|
149
|
+
path: options.path || '',
|
|
150
|
+
perPage: options.perPage || '',
|
|
151
|
+
prevPageUrl: options.prevPageUrl || 'previous',
|
|
152
|
+
to: options.to || '',
|
|
153
|
+
total: options.total || 'count'
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
127
157
|
/**
|
|
128
158
|
* Pre-configured ResponseOptions for the JSON:API driver
|
|
129
159
|
*
|
|
@@ -199,15 +229,85 @@ class StrapiResponseOptions extends ResponseOptions {
|
|
|
199
229
|
}
|
|
200
230
|
}
|
|
201
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Enum representing the available filter operators for explicit operator
|
|
234
|
+
* filters
|
|
235
|
+
*
|
|
236
|
+
* NestJS encodes these with the `$` prefix at the wire level
|
|
237
|
+
* (`filter.field=$operator:value`); PostgREST translates them to its own
|
|
238
|
+
* prefix notation (`col=eq.val`, `col=is.null`, etc.). The enum values are
|
|
239
|
+
* intentionally the NestJS form; each driver's request strategy is
|
|
240
|
+
* responsible for mapping them into its own shape.
|
|
241
|
+
*
|
|
242
|
+
* `FTS`, `PLFTS`, `PHFTS`, `WFTS` are PostgREST-native full-text search
|
|
243
|
+
* variants; they throw `UnsupportedFilterOperatorError` on every other
|
|
244
|
+
* driver that does not recognise them.
|
|
245
|
+
*
|
|
246
|
+
* @see https://github.com/ppetzold/nestjs-paginate
|
|
247
|
+
* @see https://postgrest.org/en/stable/api.html#operators
|
|
248
|
+
*/
|
|
249
|
+
var FilterOperatorEnum;
|
|
250
|
+
(function (FilterOperatorEnum) {
|
|
251
|
+
FilterOperatorEnum["BTW"] = "$btw";
|
|
252
|
+
FilterOperatorEnum["CONTAINS"] = "$contains";
|
|
253
|
+
FilterOperatorEnum["EQ"] = "$eq";
|
|
254
|
+
FilterOperatorEnum["FTS"] = "$fts";
|
|
255
|
+
FilterOperatorEnum["GT"] = "$gt";
|
|
256
|
+
FilterOperatorEnum["GTE"] = "$gte";
|
|
257
|
+
FilterOperatorEnum["ILIKE"] = "$ilike";
|
|
258
|
+
FilterOperatorEnum["IN"] = "$in";
|
|
259
|
+
FilterOperatorEnum["LT"] = "$lt";
|
|
260
|
+
FilterOperatorEnum["LTE"] = "$lte";
|
|
261
|
+
FilterOperatorEnum["NOT"] = "$not";
|
|
262
|
+
FilterOperatorEnum["NULL"] = "$null";
|
|
263
|
+
FilterOperatorEnum["PHFTS"] = "$phfts";
|
|
264
|
+
FilterOperatorEnum["PLFTS"] = "$plfts";
|
|
265
|
+
FilterOperatorEnum["SW"] = "$sw";
|
|
266
|
+
FilterOperatorEnum["WFTS"] = "$wfts";
|
|
267
|
+
})(FilterOperatorEnum || (FilterOperatorEnum = {}));
|
|
268
|
+
|
|
202
269
|
var SortEnum;
|
|
203
270
|
(function (SortEnum) {
|
|
204
271
|
SortEnum["ASC"] = "asc";
|
|
205
272
|
SortEnum["DESC"] = "desc";
|
|
206
273
|
})(SortEnum || (SortEnum = {}));
|
|
207
274
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Thrown when a filter operator receives a value array of the wrong shape
|
|
277
|
+
*
|
|
278
|
+
* Some operators have arity or type constraints that the library enforces
|
|
279
|
+
* at call time so misuse fails loudly instead of silently emitting invalid
|
|
280
|
+
* server requests:
|
|
281
|
+
*
|
|
282
|
+
* - `BTW` requires exactly two values (min, max).
|
|
283
|
+
* - `NULL` requires exactly one boolean value (`true` for `IS NULL`,
|
|
284
|
+
* `false` for `IS NOT NULL`).
|
|
285
|
+
*
|
|
286
|
+
* Operators with looser shape rules leave validation to the server; this
|
|
287
|
+
* error is reserved for cases where the library itself can detect the
|
|
288
|
+
* problem unambiguously from the call site.
|
|
289
|
+
*/
|
|
290
|
+
class InvalidFilterOperatorValueError extends Error {
|
|
291
|
+
/**
|
|
292
|
+
* @param operator - The operator that rejected the values
|
|
293
|
+
* @param reason - Short human-readable explanation of the constraint
|
|
294
|
+
*/
|
|
295
|
+
constructor(operator, reason) {
|
|
296
|
+
super(`Invalid values for filter operator ${operator}: ${reason}`);
|
|
297
|
+
this.name = 'InvalidFilterOperatorValueError';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Error thrown when filter operators are attempted with a driver that does not support them
|
|
303
|
+
*
|
|
304
|
+
* Filter operators are only supported by the NestJS driver.
|
|
305
|
+
* Use `addFilter()` for Spatie implicit equality filters.
|
|
306
|
+
*/
|
|
307
|
+
class UnsupportedFilterOperatorError extends Error {
|
|
308
|
+
constructor() {
|
|
309
|
+
super('Filter operators are only supported by the NestJS driver. Use addFilter() for Spatie.');
|
|
310
|
+
this.name = 'UnsupportedFilterOperatorError';
|
|
211
311
|
}
|
|
212
312
|
}
|
|
213
313
|
|
|
@@ -317,6 +417,428 @@ class AbstractRequestStrategy {
|
|
|
317
417
|
}
|
|
318
418
|
}
|
|
319
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Request strategy for the Django REST Framework (DRF) driver
|
|
422
|
+
*
|
|
423
|
+
* Generates URIs in DRF's flat query-parameter format, augmented by
|
|
424
|
+
* django-filter's double-underscore lookup convention:
|
|
425
|
+
*
|
|
426
|
+
* - Simple filters: `field=value` (multi-value collapses to `field__in=v1,v2`)
|
|
427
|
+
* - Operator filters: `field__lookup=value` (translated from
|
|
428
|
+
* `FilterOperatorEnum` — `GTE`→`__gte`, `ILIKE`→`__icontains`,
|
|
429
|
+
* `BTW`→`__range`, `NULL`→`__isnull`, etc.)
|
|
430
|
+
* - Ordering: `ordering=-field1,field2` (`-` prefix = DESC)
|
|
431
|
+
* - Search: `search=term` (DRF's SearchFilter)
|
|
432
|
+
* - Pagination: `page=N&page_size=M` (PageNumberPagination)
|
|
433
|
+
*
|
|
434
|
+
* `ordering` and `page_size` are DRF-idiomatic param names and are
|
|
435
|
+
* intentionally not configurable via `QueryBuilderOptions` — same
|
|
436
|
+
* precedent as PostgREST's `order` and `offset`. PostgREST's full-text
|
|
437
|
+
* search operators (`FTS`, `PHFTS`, `PLFTS`, `WFTS`) and the generic
|
|
438
|
+
* `NOT` have no django-filter equivalent and throw
|
|
439
|
+
* `UnsupportedFilterOperatorError`.
|
|
440
|
+
*
|
|
441
|
+
* @see https://www.django-rest-framework.org/api-guide/filtering/
|
|
442
|
+
* @see https://django-filter.readthedocs.io/
|
|
443
|
+
*/
|
|
444
|
+
class DrfRequestStrategy extends AbstractRequestStrategy {
|
|
445
|
+
/**
|
|
446
|
+
* Simple filters, operator filters (django-filter lookups), sorts, and
|
|
447
|
+
* global search — no per-model fields, no relation includes, no flat
|
|
448
|
+
* select (django-restql adds it but is not core DRF)
|
|
449
|
+
*/
|
|
450
|
+
capabilities = {
|
|
451
|
+
fields: false,
|
|
452
|
+
filters: true,
|
|
453
|
+
includes: false,
|
|
454
|
+
operatorFilters: true,
|
|
455
|
+
search: true,
|
|
456
|
+
select: false,
|
|
457
|
+
sort: true
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* DRF-native names of the three hardcoded query keys
|
|
461
|
+
*
|
|
462
|
+
* `ordering` and `page_size` are DRF/django-filter conventions and are
|
|
463
|
+
* intentionally not configurable through `QueryBuilderOptions`. `page`
|
|
464
|
+
* matches the default `QueryBuilderOptions.page`, and `search` matches
|
|
465
|
+
* the default `QueryBuilderOptions.search`, so those flow through the
|
|
466
|
+
* shared options object.
|
|
467
|
+
*/
|
|
468
|
+
static _orderingKey = 'ordering';
|
|
469
|
+
static _pageSizeKey = 'page_size';
|
|
470
|
+
/**
|
|
471
|
+
* Emit DRF-format query-string segments in canonical order:
|
|
472
|
+
* filters → operator filters → ordering → search → pagination
|
|
473
|
+
*
|
|
474
|
+
* @param state - The current query builder state
|
|
475
|
+
* @param options - The query parameter key name configuration
|
|
476
|
+
* @returns Ordered query-string fragments
|
|
477
|
+
*/
|
|
478
|
+
parts(state, options) {
|
|
479
|
+
const out = [];
|
|
480
|
+
this._appendFilters(state, out);
|
|
481
|
+
this._appendOperatorFilters(state, out);
|
|
482
|
+
this._appendOrdering(state, out);
|
|
483
|
+
this._appendSearch(state, options, out);
|
|
484
|
+
this._appendPagination(state, options, out);
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Append simple filter parameters
|
|
489
|
+
*
|
|
490
|
+
* Single-value filters emit `field=value` (django-filter's default
|
|
491
|
+
* exact match). Multi-value filters collapse to django-filter's
|
|
492
|
+
* `field__in=v1,v2,v3` form, which is the idiomatic way to express
|
|
493
|
+
* "value in list" in DRF.
|
|
494
|
+
*
|
|
495
|
+
* @param state - The current query builder state
|
|
496
|
+
* @param out - The accumulator the caller joins into the URI
|
|
497
|
+
*/
|
|
498
|
+
_appendFilters(state, out) {
|
|
499
|
+
const keys = Object.keys(state.filters);
|
|
500
|
+
if (!keys.length) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
keys.forEach(key => {
|
|
504
|
+
const values = state.filters[key];
|
|
505
|
+
if (!values.length) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (values.length === 1) {
|
|
509
|
+
out.push(`${key}=${values[0]}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
out.push(`${key}__in=${values.join(',')}`);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Append operator-filter parameters as `field__lookup=value`
|
|
517
|
+
*
|
|
518
|
+
* Maps each `FilterOperatorEnum` value to a django-filter lookup
|
|
519
|
+
* suffix. `BTW` expands to `field__range=min,max`; `NULL` emits
|
|
520
|
+
* `field__isnull=true|false`; the generic `NOT` and PostgREST-only
|
|
521
|
+
* FTS operators are unsupported.
|
|
522
|
+
*
|
|
523
|
+
* @param state - The current query builder state
|
|
524
|
+
* @param out - The accumulator the caller joins into the URI
|
|
525
|
+
* @throws {InvalidFilterOperatorValueError} If `BTW` does not receive
|
|
526
|
+
* exactly two values, or `NULL` does not receive exactly one boolean
|
|
527
|
+
* @throws {UnsupportedFilterOperatorError} If the operator has no
|
|
528
|
+
* django-filter equivalent
|
|
529
|
+
*/
|
|
530
|
+
_appendOperatorFilters(state, out) {
|
|
531
|
+
if (!state.operatorFilters.length) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
state.operatorFilters.forEach((filter) => {
|
|
535
|
+
const [suffix, value] = this._formatOperatorPayload(filter);
|
|
536
|
+
const key = suffix ? `${filter.field}__${suffix}` : filter.field;
|
|
537
|
+
out.push(`${key}=${value}`);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Append `ordering=-field1,field2` (django's `-` prefix = DESC)
|
|
542
|
+
*
|
|
543
|
+
* @param state - The current query builder state
|
|
544
|
+
* @param out - The accumulator the caller joins into the URI
|
|
545
|
+
*/
|
|
546
|
+
_appendOrdering(state, out) {
|
|
547
|
+
if (!state.sorts.length) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const pairs = state.sorts.map(sort => `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`);
|
|
551
|
+
out.push(`${DrfRequestStrategy._orderingKey}=${pairs.join(',')}`);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Append `page=N&page_size=M`
|
|
555
|
+
*
|
|
556
|
+
* `page` follows `options.page` (default `page`, matching DRF); the
|
|
557
|
+
* size key is hardcoded to DRF's idiomatic `page_size`.
|
|
558
|
+
*
|
|
559
|
+
* @param state - The current query builder state
|
|
560
|
+
* @param options - The query parameter key name configuration
|
|
561
|
+
* @param out - The accumulator the caller joins into the URI
|
|
562
|
+
*/
|
|
563
|
+
_appendPagination(state, options, out) {
|
|
564
|
+
out.push(`${options.page}=${state.page}`);
|
|
565
|
+
out.push(`${DrfRequestStrategy._pageSizeKey}=${state.limit}`);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Append `search=term` when a search term is set
|
|
569
|
+
*
|
|
570
|
+
* @param state - The current query builder state
|
|
571
|
+
* @param options - The query parameter key name configuration
|
|
572
|
+
* @param out - The accumulator the caller joins into the URI
|
|
573
|
+
*/
|
|
574
|
+
_appendSearch(state, options, out) {
|
|
575
|
+
if (!state.search) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
out.push(`${options.search}=${state.search}`);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Translate a `FilterOperatorEnum` operator filter into a
|
|
582
|
+
* `[suffix, serializedValue]` pair
|
|
583
|
+
*
|
|
584
|
+
* The suffix is appended to the field name with a `__` separator on the
|
|
585
|
+
* wire side (`field__gte=18`). The empty string means "no suffix" —
|
|
586
|
+
* django-filter's implicit `exact` lookup.
|
|
587
|
+
*
|
|
588
|
+
* Mapping:
|
|
589
|
+
* - `EQ` → `''` (no suffix; default exact match)
|
|
590
|
+
* - `GT`/`GTE`/`LT`/`LTE`/`CONTAINS` → identity (lowercased name)
|
|
591
|
+
* - `ILIKE` → `icontains` (closest case-insensitive analog)
|
|
592
|
+
* - `IN` → `in` with comma-joined values
|
|
593
|
+
* - `SW` → `startswith`
|
|
594
|
+
* - `BTW` → `range` with comma-joined `[min, max]` (arity-checked)
|
|
595
|
+
* - `NULL` → `isnull` with boolean value (arity- and type-checked)
|
|
596
|
+
* - `NOT` → `UnsupportedFilterOperatorError` (no generic negation in
|
|
597
|
+
* django-filter; use `__exclude` on the queryset instead)
|
|
598
|
+
* - `FTS`/`PLFTS`/`PHFTS`/`WFTS` → `UnsupportedFilterOperatorError`
|
|
599
|
+
*
|
|
600
|
+
* @param filter - The operator filter to translate
|
|
601
|
+
* @returns A `[lookupSuffix, serializedValue]` tuple
|
|
602
|
+
* @throws {InvalidFilterOperatorValueError} If `BTW` does not receive
|
|
603
|
+
* exactly two values, or `NULL` does not receive exactly one boolean
|
|
604
|
+
* @throws {UnsupportedFilterOperatorError} If the operator has no
|
|
605
|
+
* django-filter equivalent
|
|
606
|
+
*/
|
|
607
|
+
_formatOperatorPayload(filter) {
|
|
608
|
+
const { operator, values } = filter;
|
|
609
|
+
const first = values[0];
|
|
610
|
+
switch (operator) {
|
|
611
|
+
case FilterOperatorEnum.EQ: return ['', String(first)];
|
|
612
|
+
case FilterOperatorEnum.GT: return ['gt', String(first)];
|
|
613
|
+
case FilterOperatorEnum.GTE: return ['gte', String(first)];
|
|
614
|
+
case FilterOperatorEnum.LT: return ['lt', String(first)];
|
|
615
|
+
case FilterOperatorEnum.LTE: return ['lte', String(first)];
|
|
616
|
+
case FilterOperatorEnum.CONTAINS: return ['contains', String(first)];
|
|
617
|
+
case FilterOperatorEnum.ILIKE: return ['icontains', String(first)];
|
|
618
|
+
case FilterOperatorEnum.IN: return ['in', values.join(',')];
|
|
619
|
+
case FilterOperatorEnum.SW: return ['startswith', String(first)];
|
|
620
|
+
case FilterOperatorEnum.BTW: {
|
|
621
|
+
if (values.length !== 2) {
|
|
622
|
+
throw new InvalidFilterOperatorValueError(operator, 'BTW requires exactly 2 values (min, max)');
|
|
623
|
+
}
|
|
624
|
+
return ['range', values.join(',')];
|
|
625
|
+
}
|
|
626
|
+
case FilterOperatorEnum.NULL: {
|
|
627
|
+
if (values.length !== 1 || typeof first !== 'boolean') {
|
|
628
|
+
throw new InvalidFilterOperatorValueError(operator, 'NULL requires exactly 1 boolean value (true → IS NULL, false → IS NOT NULL)');
|
|
629
|
+
}
|
|
630
|
+
return ['isnull', String(first)];
|
|
631
|
+
}
|
|
632
|
+
case FilterOperatorEnum.NOT:
|
|
633
|
+
case FilterOperatorEnum.FTS:
|
|
634
|
+
case FilterOperatorEnum.PHFTS:
|
|
635
|
+
case FilterOperatorEnum.PLFTS:
|
|
636
|
+
case FilterOperatorEnum.WFTS:
|
|
637
|
+
throw new UnsupportedFilterOperatorError();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Response strategy for the Django REST Framework (DRF) driver
|
|
644
|
+
*
|
|
645
|
+
* Parses DRF `PageNumberPagination` responses:
|
|
646
|
+
*
|
|
647
|
+
* ```json
|
|
648
|
+
* {
|
|
649
|
+
* "count": 100,
|
|
650
|
+
* "next": "http://api.example.com/items/?page=3",
|
|
651
|
+
* "previous": "http://api.example.com/items/?page=1",
|
|
652
|
+
* "results": [...]
|
|
653
|
+
* }
|
|
654
|
+
* ```
|
|
655
|
+
*
|
|
656
|
+
* DRF emits no `current_page` field in the body, so this strategy
|
|
657
|
+
* **derives** the current page (and the page size) by inspecting the
|
|
658
|
+
* `next` / `previous` URLs:
|
|
659
|
+
*
|
|
660
|
+
* - `previous === null` → current page is **1**.
|
|
661
|
+
* - `previous` set but has no `?page=N` param → DRF omits `page=1` from
|
|
662
|
+
* URLs when the previous page is the first, so we infer **2**.
|
|
663
|
+
* - `previous` has `?page=N` → current page is **N + 1**.
|
|
664
|
+
*
|
|
665
|
+
* Similarly, `perPage` is parsed from any `?page_size=N` query param on
|
|
666
|
+
* `next` or `previous`, and `lastPage` is computed as
|
|
667
|
+
* `ceil(count / perPage)`. When `perPage` cannot be discovered (e.g. on a
|
|
668
|
+
* single-page response that emits both URLs as `null`), `perPage` and
|
|
669
|
+
* `lastPage` are left undefined.
|
|
670
|
+
*
|
|
671
|
+
* Key paths are resolved through `DrfResponseOptions`, which defaults
|
|
672
|
+
* `data → 'results'`, `total → 'count'`, `nextPageUrl → 'next'`,
|
|
673
|
+
* `prevPageUrl → 'previous'`. The current-page / per-page / last-page
|
|
674
|
+
* paths default to empty strings — the strategy ignores `options` for
|
|
675
|
+
* those slots and uses URL inspection instead.
|
|
676
|
+
*
|
|
677
|
+
* @see https://www.django-rest-framework.org/api-guide/pagination/#pagenumberpagination
|
|
678
|
+
*/
|
|
679
|
+
class DrfResponseStrategy {
|
|
680
|
+
/**
|
|
681
|
+
* Parse a DRF pagination response into a PaginatedCollection
|
|
682
|
+
*
|
|
683
|
+
* @param response - The raw API response body
|
|
684
|
+
* @param options - The response key name configuration
|
|
685
|
+
* @returns A typed PaginatedCollection instance
|
|
686
|
+
*/
|
|
687
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
688
|
+
paginate(response, options) {
|
|
689
|
+
const data = response[options.data];
|
|
690
|
+
const total = response[options.total];
|
|
691
|
+
const prevPageUrl = (response[options.prevPageUrl] ?? null);
|
|
692
|
+
const nextPageUrl = (response[options.nextPageUrl] ?? null);
|
|
693
|
+
const currentPage = this._deriveCurrentPage(prevPageUrl);
|
|
694
|
+
const perPage = this._derivePerPage(nextPageUrl, prevPageUrl);
|
|
695
|
+
const lastPage = this._deriveLastPage(total, perPage);
|
|
696
|
+
const from = this._deriveFrom(currentPage, perPage);
|
|
697
|
+
const to = this._deriveTo(currentPage, perPage, total);
|
|
698
|
+
return new PaginatedCollection(data, currentPage, from, to, total, perPage, prevPageUrl ?? undefined, nextPageUrl ?? undefined, lastPage, undefined, undefined);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Derive the current page number from the `previous` URL
|
|
702
|
+
*
|
|
703
|
+
* - `null` → page 1
|
|
704
|
+
* - URL without `?page=N` → page 2 (DRF omits `page=1` from URLs)
|
|
705
|
+
* - URL with `?page=N` → N + 1
|
|
706
|
+
*
|
|
707
|
+
* @param prevPageUrl - The `previous` link from the DRF response, or null
|
|
708
|
+
* @returns The current page number
|
|
709
|
+
*/
|
|
710
|
+
_deriveCurrentPage(prevPageUrl) {
|
|
711
|
+
if (prevPageUrl === null) {
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
const prevPage = this._extractPageParam(prevPageUrl);
|
|
715
|
+
return prevPage === undefined ? 2 : prevPage + 1;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Derive `from` as the 1-indexed offset of the first item on this page
|
|
719
|
+
*
|
|
720
|
+
* @param currentPage - The current page number
|
|
721
|
+
* @param perPage - The page size (may be undefined)
|
|
722
|
+
* @returns The 1-indexed `from` index, or undefined when perPage is unknown
|
|
723
|
+
*/
|
|
724
|
+
_deriveFrom(currentPage, perPage) {
|
|
725
|
+
if (!perPage) {
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
return (currentPage - 1) * perPage + 1;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Derive the last page number as `ceil(total / perPage)`
|
|
732
|
+
*
|
|
733
|
+
* Both inputs must be defined; an empty result set (`total === 0`)
|
|
734
|
+
* yields `lastPage = 0` which the caller treats as "no useful info"
|
|
735
|
+
* and skips the sync to `NestService.lastPage`.
|
|
736
|
+
*
|
|
737
|
+
* @param total - The total item count
|
|
738
|
+
* @param perPage - The page size
|
|
739
|
+
* @returns The last page number, or undefined when either input is missing
|
|
740
|
+
*/
|
|
741
|
+
_deriveLastPage(total, perPage) {
|
|
742
|
+
if (total === undefined || perPage === undefined || perPage <= 0) {
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
return Math.ceil(total / perPage);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Derive `perPage` by parsing `?page_size=N` from any available URL
|
|
749
|
+
*
|
|
750
|
+
* Tries `next` first (page 1 always has a `next` URL with `page_size`
|
|
751
|
+
* if any non-default size was requested), then falls back to
|
|
752
|
+
* `previous`. Returns undefined when neither URL contains the param —
|
|
753
|
+
* the consumer is then on a single-page result with the server's
|
|
754
|
+
* default page size, which is not introspectable from the body alone.
|
|
755
|
+
*
|
|
756
|
+
* @param nextPageUrl - The `next` link from the response, or null
|
|
757
|
+
* @param prevPageUrl - The `previous` link from the response, or null
|
|
758
|
+
* @returns The page size, or undefined
|
|
759
|
+
*/
|
|
760
|
+
_derivePerPage(nextPageUrl, prevPageUrl) {
|
|
761
|
+
return this._extractPageSizeParam(nextPageUrl) ?? this._extractPageSizeParam(prevPageUrl);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Derive `to` as the 1-indexed offset of the last item on this page
|
|
765
|
+
*
|
|
766
|
+
* Clamped to `total` so the last page does not report past the end.
|
|
767
|
+
*
|
|
768
|
+
* @param currentPage - The current page number
|
|
769
|
+
* @param perPage - The page size (may be undefined)
|
|
770
|
+
* @param total - The total item count (may be undefined)
|
|
771
|
+
* @returns The 1-indexed `to` index, or undefined when inputs insufficient
|
|
772
|
+
*/
|
|
773
|
+
_deriveTo(currentPage, perPage, total) {
|
|
774
|
+
if (perPage === undefined || total === undefined) {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
return Math.min(currentPage * perPage, total);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Extract the `page` query parameter from a DRF pagination URL
|
|
781
|
+
*
|
|
782
|
+
* Returns the integer value of `?page=N`, or undefined when the URL is
|
|
783
|
+
* malformed or has no `page` param (which, by DRF convention, means
|
|
784
|
+
* page 1 — the caller infers the semantics).
|
|
785
|
+
*
|
|
786
|
+
* @param url - The URL to parse
|
|
787
|
+
* @returns The integer page value, or undefined
|
|
788
|
+
*/
|
|
789
|
+
_extractPageParam(url) {
|
|
790
|
+
const raw = this._extractQueryParam(url, 'page');
|
|
791
|
+
if (raw === undefined) {
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|
|
794
|
+
const parsed = Number.parseInt(raw, 10);
|
|
795
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Extract the `page_size` query parameter from a DRF pagination URL
|
|
799
|
+
*
|
|
800
|
+
* @param url - The URL to parse (or null)
|
|
801
|
+
* @returns The integer page-size value, or undefined
|
|
802
|
+
*/
|
|
803
|
+
_extractPageSizeParam(url) {
|
|
804
|
+
if (url === null) {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
const raw = this._extractQueryParam(url, 'page_size');
|
|
808
|
+
if (raw === undefined) {
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
const parsed = Number.parseInt(raw, 10);
|
|
812
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Extract a single query parameter from a URL via the WHATWG URL parser
|
|
816
|
+
*
|
|
817
|
+
* Returns undefined when the URL is unparseable (relative URL without a
|
|
818
|
+
* base, or malformed) or when the parameter is absent.
|
|
819
|
+
*
|
|
820
|
+
* @param url - The URL to parse
|
|
821
|
+
* @param name - The query-parameter name to look up
|
|
822
|
+
* @returns The raw string value of the parameter, or undefined
|
|
823
|
+
*/
|
|
824
|
+
_extractQueryParam(url, name) {
|
|
825
|
+
try {
|
|
826
|
+
const parsed = new URL(url);
|
|
827
|
+
const value = parsed.searchParams.get(name);
|
|
828
|
+
return value === null ? undefined : value;
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
class UnselectableModelError extends Error {
|
|
837
|
+
constructor(model) {
|
|
838
|
+
super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
320
842
|
/**
|
|
321
843
|
* Request strategy for the JSON:API driver
|
|
322
844
|
*
|
|
@@ -625,6 +1147,39 @@ class LaravelRequestStrategy extends AbstractRequestStrategy {
|
|
|
625
1147
|
}
|
|
626
1148
|
}
|
|
627
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* Base class for response strategies whose pagination metadata is a flat
|
|
1152
|
+
* key-value envelope on the response body
|
|
1153
|
+
*
|
|
1154
|
+
* Laravel's stock pagination and Spatie's `QueryBuilder` both emit the
|
|
1155
|
+
* same flat shape — `{ data, current_page, total, per_page, from, to,
|
|
1156
|
+
* next_page_url, prev_page_url, first_page_url, last_page, last_page_url
|
|
1157
|
+
* }` — and both response strategies were duplicating the byte-identical
|
|
1158
|
+
* `new PaginatedCollection(response[options.X], ...)` body before this
|
|
1159
|
+
* base existed. Concrete classes now extend and provide only the
|
|
1160
|
+
* docstring describing their driver's specific shape (see
|
|
1161
|
+
* `LaravelResponseStrategy`, `SpatieResponseStrategy`).
|
|
1162
|
+
*
|
|
1163
|
+
* Drivers whose pagination metadata is a nested envelope (JSON:API,
|
|
1164
|
+
* NestJS, Strapi) extend `AbstractDotPathResponseStrategy` instead.
|
|
1165
|
+
* Drivers whose metadata comes from HTTP headers (PostgREST) or is
|
|
1166
|
+
* derived from response URLs (DRF) implement `IResponseStrategy`
|
|
1167
|
+
* directly.
|
|
1168
|
+
*/
|
|
1169
|
+
class AbstractFlatResponseStrategy {
|
|
1170
|
+
/**
|
|
1171
|
+
* Parse a flat-envelope pagination response into a PaginatedCollection
|
|
1172
|
+
*
|
|
1173
|
+
* @param response - The raw API response object
|
|
1174
|
+
* @param options - The response key name configuration
|
|
1175
|
+
* @returns A typed PaginatedCollection instance
|
|
1176
|
+
*/
|
|
1177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1178
|
+
paginate(response, options) {
|
|
1179
|
+
return new PaginatedCollection(response[options.data], response[options.currentPage], response[options.from], response[options.to], response[options.total], response[options.perPage], response[options.prevPageUrl], response[options.nextPageUrl], response[options.lastPage], response[options.firstPageUrl], response[options.lastPageUrl]);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
628
1183
|
/**
|
|
629
1184
|
* Response strategy for the Laravel (pagination-only) driver
|
|
630
1185
|
*
|
|
@@ -637,22 +1192,20 @@ class LaravelRequestStrategy extends AbstractRequestStrategy {
|
|
|
637
1192
|
* "per_page": 15,
|
|
638
1193
|
* "from": 1,
|
|
639
1194
|
* "to": 15,
|
|
640
|
-
* ...
|
|
1195
|
+
* "next_page_url": "...",
|
|
1196
|
+
* "prev_page_url": "...",
|
|
1197
|
+
* "first_page_url": "...",
|
|
1198
|
+
* "last_page": 7,
|
|
1199
|
+
* "last_page_url": "..."
|
|
641
1200
|
* }
|
|
642
1201
|
* ```
|
|
1202
|
+
*
|
|
1203
|
+
* The traversal algorithm (flat `response[options.X]` lookups) is
|
|
1204
|
+
* inherited from `AbstractFlatResponseStrategy`; this class exists so
|
|
1205
|
+
* `DriverEnum.LARAVEL` resolves to a distinct identity at the DI layer
|
|
1206
|
+
* even though the parsing logic is shared with Spatie.
|
|
643
1207
|
*/
|
|
644
|
-
class LaravelResponseStrategy {
|
|
645
|
-
/**
|
|
646
|
-
* Parse a flat Laravel pagination response into a PaginatedCollection
|
|
647
|
-
*
|
|
648
|
-
* @param response - The raw API response object
|
|
649
|
-
* @param options - The response key name configuration
|
|
650
|
-
* @returns A typed PaginatedCollection instance
|
|
651
|
-
*/
|
|
652
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
653
|
-
paginate(response, options) {
|
|
654
|
-
return new PaginatedCollection(response[options.data], response[options.currentPage], response[options.from], response[options.to], response[options.total], response[options.perPage], response[options.prevPageUrl], response[options.nextPageUrl], response[options.lastPage], response[options.firstPageUrl], response[options.lastPageUrl]);
|
|
655
|
-
}
|
|
1208
|
+
class LaravelResponseStrategy extends AbstractFlatResponseStrategy {
|
|
656
1209
|
}
|
|
657
1210
|
|
|
658
1211
|
/**
|
|
@@ -847,43 +1400,6 @@ class NestjsRequestStrategy extends AbstractRequestStrategy {
|
|
|
847
1400
|
class NestjsResponseStrategy extends AbstractDotPathResponseStrategy {
|
|
848
1401
|
}
|
|
849
1402
|
|
|
850
|
-
/**
|
|
851
|
-
* Enum representing the available filter operators for explicit operator
|
|
852
|
-
* filters
|
|
853
|
-
*
|
|
854
|
-
* NestJS encodes these with the `$` prefix at the wire level
|
|
855
|
-
* (`filter.field=$operator:value`); PostgREST translates them to its own
|
|
856
|
-
* prefix notation (`col=eq.val`, `col=is.null`, etc.). The enum values are
|
|
857
|
-
* intentionally the NestJS form; each driver's request strategy is
|
|
858
|
-
* responsible for mapping them into its own shape.
|
|
859
|
-
*
|
|
860
|
-
* `FTS`, `PLFTS`, `PHFTS`, `WFTS` are PostgREST-native full-text search
|
|
861
|
-
* variants; they throw `UnsupportedFilterOperatorError` on every other
|
|
862
|
-
* driver that does not recognise them.
|
|
863
|
-
*
|
|
864
|
-
* @see https://github.com/ppetzold/nestjs-paginate
|
|
865
|
-
* @see https://postgrest.org/en/stable/api.html#operators
|
|
866
|
-
*/
|
|
867
|
-
var FilterOperatorEnum;
|
|
868
|
-
(function (FilterOperatorEnum) {
|
|
869
|
-
FilterOperatorEnum["BTW"] = "$btw";
|
|
870
|
-
FilterOperatorEnum["CONTAINS"] = "$contains";
|
|
871
|
-
FilterOperatorEnum["EQ"] = "$eq";
|
|
872
|
-
FilterOperatorEnum["FTS"] = "$fts";
|
|
873
|
-
FilterOperatorEnum["GT"] = "$gt";
|
|
874
|
-
FilterOperatorEnum["GTE"] = "$gte";
|
|
875
|
-
FilterOperatorEnum["ILIKE"] = "$ilike";
|
|
876
|
-
FilterOperatorEnum["IN"] = "$in";
|
|
877
|
-
FilterOperatorEnum["LT"] = "$lt";
|
|
878
|
-
FilterOperatorEnum["LTE"] = "$lte";
|
|
879
|
-
FilterOperatorEnum["NOT"] = "$not";
|
|
880
|
-
FilterOperatorEnum["NULL"] = "$null";
|
|
881
|
-
FilterOperatorEnum["PHFTS"] = "$phfts";
|
|
882
|
-
FilterOperatorEnum["PLFTS"] = "$plfts";
|
|
883
|
-
FilterOperatorEnum["SW"] = "$sw";
|
|
884
|
-
FilterOperatorEnum["WFTS"] = "$wfts";
|
|
885
|
-
})(FilterOperatorEnum || (FilterOperatorEnum = {}));
|
|
886
|
-
|
|
887
1403
|
/**
|
|
888
1404
|
* Enum representing the wire-level pagination mechanism
|
|
889
1405
|
*
|
|
@@ -902,32 +1418,6 @@ var PaginationModeEnum;
|
|
|
902
1418
|
PaginationModeEnum["RANGE"] = "range";
|
|
903
1419
|
})(PaginationModeEnum || (PaginationModeEnum = {}));
|
|
904
1420
|
|
|
905
|
-
/**
|
|
906
|
-
* Thrown when a filter operator receives a value array of the wrong shape
|
|
907
|
-
*
|
|
908
|
-
* Some operators have arity or type constraints that the library enforces
|
|
909
|
-
* at call time so misuse fails loudly instead of silently emitting invalid
|
|
910
|
-
* server requests:
|
|
911
|
-
*
|
|
912
|
-
* - `BTW` requires exactly two values (min, max).
|
|
913
|
-
* - `NULL` requires exactly one boolean value (`true` for `IS NULL`,
|
|
914
|
-
* `false` for `IS NOT NULL`).
|
|
915
|
-
*
|
|
916
|
-
* Operators with looser shape rules leave validation to the server; this
|
|
917
|
-
* error is reserved for cases where the library itself can detect the
|
|
918
|
-
* problem unambiguously from the call site.
|
|
919
|
-
*/
|
|
920
|
-
class InvalidFilterOperatorValueError extends Error {
|
|
921
|
-
/**
|
|
922
|
-
* @param operator - The operator that rejected the values
|
|
923
|
-
* @param reason - Short human-readable explanation of the constraint
|
|
924
|
-
*/
|
|
925
|
-
constructor(operator, reason) {
|
|
926
|
-
super(`Invalid values for filter operator ${operator}: ${reason}`);
|
|
927
|
-
this.name = 'InvalidFilterOperatorValueError';
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
1421
|
/**
|
|
932
1422
|
* Request strategy for the PostgREST driver
|
|
933
1423
|
*
|
|
@@ -1442,7 +1932,8 @@ class SpatieRequestStrategy extends AbstractRequestStrategy {
|
|
|
1442
1932
|
/**
|
|
1443
1933
|
* Response strategy for the Spatie Query Builder driver
|
|
1444
1934
|
*
|
|
1445
|
-
* Parses flat Laravel pagination responses
|
|
1935
|
+
* Parses flat Laravel-style pagination responses (Spatie's Query Builder
|
|
1936
|
+
* is built on Laravel's pagination):
|
|
1446
1937
|
* ```json
|
|
1447
1938
|
* {
|
|
1448
1939
|
* "data": [...],
|
|
@@ -1455,33 +1946,14 @@ class SpatieRequestStrategy extends AbstractRequestStrategy {
|
|
|
1455
1946
|
* }
|
|
1456
1947
|
* ```
|
|
1457
1948
|
*
|
|
1458
|
-
*
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
* Parse a flat Laravel pagination response into a PaginatedCollection
|
|
1463
|
-
*
|
|
1464
|
-
* @param response - The raw API response object
|
|
1465
|
-
* @param options - The response key name configuration
|
|
1466
|
-
* @returns A typed PaginatedCollection instance
|
|
1467
|
-
*/
|
|
1468
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1469
|
-
paginate(response, options) {
|
|
1470
|
-
return new PaginatedCollection(response[options.data], response[options.currentPage], response[options.from], response[options.to], response[options.total], response[options.perPage], response[options.prevPageUrl], response[options.nextPageUrl], response[options.lastPage], response[options.firstPageUrl], response[options.lastPageUrl]);
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/**
|
|
1475
|
-
* Error thrown when filter operators are attempted with a driver that does not support them
|
|
1949
|
+
* The traversal algorithm (flat `response[options.X]` lookups) is
|
|
1950
|
+
* inherited from `AbstractFlatResponseStrategy`; this class exists so
|
|
1951
|
+
* `DriverEnum.SPATIE` resolves to a distinct identity at the DI layer
|
|
1952
|
+
* even though the parsing logic is shared with the plain Laravel driver.
|
|
1476
1953
|
*
|
|
1477
|
-
*
|
|
1478
|
-
* Use `addFilter()` for Spatie implicit equality filters.
|
|
1954
|
+
* @see https://spatie.be/docs/laravel-query-builder
|
|
1479
1955
|
*/
|
|
1480
|
-
class
|
|
1481
|
-
constructor() {
|
|
1482
|
-
super('Filter operators are only supported by the NestJS driver. Use addFilter() for Spatie.');
|
|
1483
|
-
this.name = 'UnsupportedFilterOperatorError';
|
|
1484
|
-
}
|
|
1956
|
+
class SpatieResponseStrategy extends AbstractFlatResponseStrategy {
|
|
1485
1957
|
}
|
|
1486
1958
|
|
|
1487
1959
|
/**
|
|
@@ -1764,6 +2236,11 @@ class StrapiResponseStrategy extends AbstractDotPathResponseStrategy {
|
|
|
1764
2236
|
* `switch` blocks.
|
|
1765
2237
|
*/
|
|
1766
2238
|
const DRIVERS = {
|
|
2239
|
+
[DriverEnum.DRF]: {
|
|
2240
|
+
createRequestStrategy: () => new DrfRequestStrategy(),
|
|
2241
|
+
createResponseStrategy: () => new DrfResponseStrategy(),
|
|
2242
|
+
createResponseOptions: (config) => new DrfResponseOptions(config)
|
|
2243
|
+
},
|
|
1767
2244
|
[DriverEnum.JSON_API]: {
|
|
1768
2245
|
createRequestStrategy: () => new JsonApiRequestStrategy(),
|
|
1769
2246
|
createResponseStrategy: () => new JsonApiResponseStrategy(),
|