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.
@@ -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
- class UnselectableModelError extends Error {
209
- constructor(model) {
210
- super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
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
- * @see https://spatie.be/docs/laravel-query-builder
1459
- */
1460
- class SpatieResponseStrategy {
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
- * Filter operators are only supported by the NestJS driver.
1478
- * Use `addFilter()` for Spatie implicit equality filters.
1954
+ * @see https://spatie.be/docs/laravel-query-builder
1479
1955
  */
1480
- class UnsupportedFilterOperatorError extends Error {
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(),