ng-qubee 3.1.0 → 3.3.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.
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, NgModule, makeEnvironmentProviders } from '@angular/core';
3
- import { BehaviorSubject, filter, throwError } from 'rxjs';
2
+ import { signal, computed, Injectable, InjectionToken, Inject, makeEnvironmentProviders, NgModule } from '@angular/core';
4
3
  import * as qs from 'qs';
4
+ import { BehaviorSubject, filter, throwError } from 'rxjs';
5
5
 
6
6
  class KeyNotFoundError extends Error {
7
7
  constructor(key) {
@@ -77,6 +77,7 @@ var DriverEnum;
77
77
  DriverEnum["JSON_API"] = "json-api";
78
78
  DriverEnum["LARAVEL"] = "laravel";
79
79
  DriverEnum["NESTJS"] = "nestjs";
80
+ DriverEnum["POSTGREST"] = "postgrest";
80
81
  DriverEnum["SPATIE"] = "spatie";
81
82
  })(DriverEnum || (DriverEnum = {}));
82
83
 
@@ -171,1977 +172,2573 @@ class NestjsResponseOptions extends ResponseOptions {
171
172
  }
172
173
  }
173
174
 
174
- /**
175
- * Error thrown when per-model field selection is attempted with a driver that does not support it
176
- *
177
- * Per-model field selection is only supported by the Spatie driver.
178
- * Use `addSelect()` for NestJS flat field selection.
179
- */
180
- class UnsupportedFieldSelectionError extends Error {
181
- constructor() {
182
- super('Per-model field selection is only supported by the Spatie driver. Use addSelect() for NestJS.');
183
- this.name = 'UnsupportedFieldSelectionError';
184
- }
185
- }
186
-
187
- /**
188
- * Error thrown when filters are attempted with a driver that does not support them
189
- *
190
- * Filters are only supported by the Spatie and NestJS drivers.
191
- */
192
- class UnsupportedFilterError extends Error {
193
- constructor() {
194
- super('Filters are only supported by the Spatie and NestJS drivers.');
195
- this.name = 'UnsupportedFilterError';
196
- }
197
- }
198
-
199
- /**
200
- * Error thrown when filter operators are attempted with a driver that does not support them
201
- *
202
- * Filter operators are only supported by the NestJS driver.
203
- * Use `addFilter()` for Spatie implicit equality filters.
204
- */
205
- class UnsupportedFilterOperatorError extends Error {
206
- constructor() {
207
- super('Filter operators are only supported by the NestJS driver. Use addFilter() for Spatie.');
208
- this.name = 'UnsupportedFilterOperatorError';
209
- }
210
- }
175
+ var SortEnum;
176
+ (function (SortEnum) {
177
+ SortEnum["ASC"] = "asc";
178
+ SortEnum["DESC"] = "desc";
179
+ })(SortEnum || (SortEnum = {}));
211
180
 
212
- /**
213
- * Error thrown when includes are attempted with a driver that does not support them
214
- *
215
- * Includes are only supported by the Spatie driver.
216
- */
217
- class UnsupportedIncludesError extends Error {
218
- constructor() {
219
- super('Includes are only supported by the Spatie driver.');
220
- this.name = 'UnsupportedIncludesError';
181
+ class UnselectableModelError extends Error {
182
+ constructor(model) {
183
+ super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
221
184
  }
222
185
  }
223
186
 
224
187
  /**
225
- * Error thrown when search is attempted with a driver that does not support it
188
+ * Thrown when a limit value does not satisfy the active driver's constraints
226
189
  *
227
- * Search is only supported by the NestJS driver.
190
+ * Validation is driver-scoped: most drivers require an integer `>= 1`, while
191
+ * the NestJS driver additionally accepts `-1` as a "fetch all items" sentinel
192
+ * (as documented by nestjs-paginate). The message is tailored accordingly so
193
+ * the caller understands which values are permitted.
228
194
  */
229
- class UnsupportedSearchError extends Error {
230
- constructor() {
231
- super('Search is only supported by the NestJS driver.');
232
- this.name = 'UnsupportedSearchError';
195
+ class InvalidLimitError extends Error {
196
+ /**
197
+ * @param limit - The rejected limit value
198
+ * @param allowFetchAll - Whether the active driver accepts `-1` (fetch all)
199
+ */
200
+ constructor(limit, allowFetchAll = false) {
201
+ const allowed = allowFetchAll
202
+ ? 'a positive integer greater than 0, or -1 to fetch all items'
203
+ : 'a positive integer greater than 0';
204
+ super(`Invalid limit value: Limit must be ${allowed}. Received: ${limit}`);
205
+ this.name = 'InvalidLimitError';
233
206
  }
234
207
  }
235
208
 
236
209
  /**
237
- * Error thrown when flat field selection is attempted with a driver that does not support it
210
+ * Base class for request strategies
238
211
  *
239
- * Flat field selection is only supported by the NestJS driver.
240
- * Use `addFields()` for Spatie per-model field selection.
241
- */
242
- class UnsupportedSelectError extends Error {
243
- constructor() {
244
- super('Flat field selection is only supported by the NestJS driver. Use addFields() for Spatie.');
245
- this.name = 'UnsupportedSelectError';
246
- }
247
- }
248
-
249
- /**
250
- * Error thrown when sorts are attempted with a driver that does not support them
212
+ * Concentrates the glue every concrete strategy used to copy: the
213
+ * resource-required guard, the `?`/`&` URL composition, and the default
214
+ * positive-integer `validateLimit`. Concrete strategies override only
215
+ * the parts that differ — the per-driver wire format goes into a single
216
+ * `protected parts(state, options): string[]` method that returns the
217
+ * ordered query-string segments the base then joins.
251
218
  *
252
- * Sorts are only supported by the Spatie and NestJS drivers.
219
+ * Drivers that need a non-default `validateLimit` (e.g. NestJS, which
220
+ * accepts `-1` as a fetch-all sentinel) override that method directly.
253
221
  */
254
- class UnsupportedSortError extends Error {
255
- constructor() {
256
- super('Sorts are only supported by the Spatie and NestJS drivers.');
257
- this.name = 'UnsupportedSortError';
222
+ class AbstractRequestStrategy {
223
+ /**
224
+ * Compose the full request URI from the given state
225
+ *
226
+ * Template method: validates the resource, computes the base path,
227
+ * delegates the per-driver query-string segments to `parts(...)`, and
228
+ * joins them with the conventional `?`/`&` separators.
229
+ *
230
+ * @param state - The current query builder state
231
+ * @param options - The query parameter key name configuration
232
+ * @returns The composed URI string
233
+ * @throws Error if the resource is not set
234
+ */
235
+ buildUri(state, options) {
236
+ this.assertResource(state);
237
+ const segments = this.parts(state, options);
238
+ return this.join(this.baseUri(state), segments);
258
239
  }
259
- }
260
-
261
- /**
262
- * Resolved query parameter key names with defaults applied
263
- *
264
- * Maps logical query concepts to the actual query parameter names
265
- * used in the generated URI. Unset values fall back to defaults.
266
- */
267
- class QueryBuilderOptions {
268
- appends;
269
- fields;
270
- filters;
271
- includes;
272
- limit;
273
- page;
274
- search;
275
- select;
276
- sort;
277
- sortBy;
278
- constructor(options) {
279
- this.appends = options.appends || 'append';
280
- this.fields = options.fields || 'fields';
281
- this.filters = options.filters || 'filter';
282
- this.includes = options.includes || 'include';
283
- this.limit = options.limit || 'limit';
284
- this.page = options.page || 'page';
285
- this.search = options.search || 'search';
286
- this.select = options.select || 'select';
287
- this.sort = options.sort || 'sort';
288
- this.sortBy = options.sortBy || 'sortBy';
240
+ /**
241
+ * Validate that a limit value is acceptable for this driver
242
+ *
243
+ * Default policy: positive integer. Drivers that recognise a sentinel
244
+ * (NestJS treats `-1` as "fetch all") override this method.
245
+ *
246
+ * @param limit - The limit value to validate
247
+ * @throws {InvalidLimitError} If the value is not a positive integer
248
+ */
249
+ validateLimit(limit) {
250
+ if (Number.isInteger(limit) && limit >= 1) {
251
+ return;
252
+ }
253
+ throw new InvalidLimitError(limit);
289
254
  }
290
- }
291
-
292
- class NgQubeeService {
293
- _nestService;
294
255
  /**
295
- * The active pagination driver
256
+ * Throw if the resource is not set on the state
257
+ *
258
+ * Centralises the message that was previously copy-pasted across four
259
+ * of the five concrete strategies.
260
+ *
261
+ * @param state - The current query builder state
262
+ * @throws Error if `state.resource` is empty
296
263
  */
297
- _driver;
264
+ assertResource(state) {
265
+ if (!state.resource) {
266
+ throw new Error('Set the resource property BEFORE adding filters or calling the url() / get() methods');
267
+ }
268
+ }
298
269
  /**
299
- * Resolved query parameter key name options
270
+ * Compute the base path (no query string)
271
+ *
272
+ * @param state - The current query builder state
273
+ * @returns The base URI without the query separator (e.g. `/users` or `https://api.example.com/users`)
300
274
  */
301
- _options;
275
+ baseUri(state) {
276
+ return state.baseUrl ? `${state.baseUrl}/${state.resource}` : `/${state.resource}`;
277
+ }
302
278
  /**
303
- * The request strategy that builds URIs for the active driver
279
+ * Glue the base URI and the per-driver query-string segments
280
+ *
281
+ * Returns the bare base when no segments were emitted (e.g. PostgREST
282
+ * in RANGE mode with no filters), otherwise joins with `?` + `&`.
283
+ *
284
+ * @param base - The base URI from `_baseUri`
285
+ * @param segments - The query-string fragments from `parts(...)`
286
+ * @returns The full URI
304
287
  */
305
- _requestStrategy;
288
+ join(base, segments) {
289
+ return segments.length ? `${base}?${segments.join('&')}` : base;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Request strategy for the JSON:API driver
295
+ *
296
+ * Generates URIs in the JSON:API format:
297
+ * - Fields: `fields[articles]=title,body&fields[people]=name`
298
+ * - Filters: `filter[status]=active`
299
+ * - Includes: `include=author,comments.author`
300
+ * - Pagination: `page[number]=1&page[size]=15`
301
+ * - Sort: `sort=-created_at,name` (- prefix = DESC)
302
+ *
303
+ * @see https://jsonapi.org/format/
304
+ */
305
+ class JsonApiRequestStrategy extends AbstractRequestStrategy {
306
306
  /**
307
- * Internal BehaviorSubject that holds the latest generated URI
307
+ * Filters, sorts, includes, per-model fields same shape as Spatie
308
+ * but with bracket-style pagination
308
309
  */
309
- _uri$ = new BehaviorSubject('');
310
+ capabilities = {
311
+ fields: true,
312
+ filters: true,
313
+ includes: true,
314
+ operatorFilters: false,
315
+ search: false,
316
+ select: false,
317
+ sort: true
318
+ };
310
319
  /**
311
- * Observable that emits non-empty generated URIs
320
+ * Emit JSON:API-format query-string segments in canonical order:
321
+ * include → fields → filters → pagination → sort
322
+ *
323
+ * @param state - The current query builder state
324
+ * @param options - The query parameter key name configuration
325
+ * @returns Ordered query-string fragments
312
326
  */
313
- uri$ = this._uri$.asObservable().pipe(filter(uri => !!uri));
314
- constructor(_nestService, requestStrategy, driver, options = {}) {
315
- this._nestService = _nestService;
316
- this._driver = driver;
317
- this._options = new QueryBuilderOptions(options);
318
- this._requestStrategy = requestStrategy;
327
+ parts(state, options) {
328
+ const out = [];
329
+ this._appendIncludes(state, options, out);
330
+ this._appendFields(state, options, out);
331
+ this._appendFilters(state, options, out);
332
+ this._appendPagination(state, options, out);
333
+ this._appendSort(state, options, out);
334
+ return out;
319
335
  }
320
336
  /**
321
- * Assert that the active driver is one of the allowed drivers
337
+ * Append per-type field selection in bracket notation
322
338
  *
323
- * @param allowed - The allowed drivers
324
- * @param error - The error to throw if the driver is not allowed
325
- * @throws The provided error if the active driver is not in the allowed list
339
+ * @param state - The current query builder state
340
+ * @param options - The query parameter key name configuration
341
+ * @param out - The accumulator the caller joins into the URI
342
+ * @throws Error if the resource is missing from the fields object
343
+ * @throws UnselectableModelError if a field type is not the resource or in includes
326
344
  */
327
- _assertDriver(allowed, error) {
328
- if (!allowed.includes(this._driver)) {
329
- throw error;
345
+ _appendFields(state, options, out) {
346
+ if (!Object.keys(state.fields).length) {
347
+ return;
348
+ }
349
+ if (!(state.resource in state.fields)) {
350
+ throw new Error(`Key ${state.resource} is missing in the fields object`);
330
351
  }
352
+ const grouped = {};
353
+ for (const type in state.fields) {
354
+ if (!state.fields.hasOwnProperty(type)) {
355
+ continue;
356
+ }
357
+ if (type !== state.resource && !state.includes.includes(type)) {
358
+ throw new UnselectableModelError(type);
359
+ }
360
+ grouped[`${options.fields}[${type}]`] = state.fields[type].join(',');
361
+ }
362
+ out.push(qs.stringify(grouped, { encode: false }));
331
363
  }
332
364
  /**
333
- * Add fields to the select statement for the given model (JSON:API and Spatie only)
365
+ * Append filter parameters in bracket notation: `filter[key]=value`
334
366
  *
335
- * @param model - Model that holds the fields
336
- * @param fields - Fields to select
337
- * @returns {this}
338
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
367
+ * @param state - The current query builder state
368
+ * @param options - The query parameter key name configuration
369
+ * @param out - The accumulator the caller joins into the URI
339
370
  */
340
- addFields(model, fields) {
341
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
342
- if (!fields.length) {
343
- return this;
371
+ _appendFilters(state, options, out) {
372
+ const keys = Object.keys(state.filters);
373
+ if (!keys.length) {
374
+ return;
344
375
  }
345
- this._nestService.addFields({ [model]: fields });
346
- return this;
376
+ const wrapper = {
377
+ [options.filters]: keys.reduce((acc, key) => {
378
+ return Object.assign(acc, { [key]: state.filters[key].join(',') });
379
+ }, {})
380
+ };
381
+ out.push(qs.stringify(wrapper, { encode: false }));
347
382
  }
348
383
  /**
349
- * Add a filter with the given value(s) (JSON:API, NestJS, and Spatie)
350
- *
351
- * Produces: `filter[field]=value` (JSON:API / Spatie) or `filter.field=value` (NestJS)
384
+ * Append include parameter as `include=author,comments.author`
352
385
  *
353
- * @param {string} field - Name of the field to filter
354
- * @param {(string | number | boolean)[]} values - The needle(s)
355
- * @returns {this}
356
- * @throws {UnsupportedFilterError} If the active driver does not support filters
386
+ * @param state - The current query builder state
387
+ * @param options - The query parameter key name configuration
388
+ * @param out - The accumulator the caller joins into the URI
357
389
  */
358
- addFilter(field, ...values) {
359
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedFilterError());
360
- if (!values.length) {
361
- return this;
390
+ _appendIncludes(state, options, out) {
391
+ if (!state.includes.length) {
392
+ return;
362
393
  }
363
- this._nestService.addFilters({
364
- [field]: values
365
- });
366
- return this;
394
+ out.push(`${options.includes}=${state.includes}`);
367
395
  }
368
396
  /**
369
- * Add a filter with an explicit operator (NestJS only)
397
+ * Append JSON:API bracket pagination as `page[number]=1&page[size]=15`
370
398
  *
371
- * Produces: `filter.field=$operator:value`
399
+ * `qs.stringify` already returns the two segments joined with `&`, so we
400
+ * push the whole string as one accumulator entry — `_join` will glue
401
+ * it onto the rest with the same separator.
372
402
  *
373
- * @param {string} field - Name of the field to filter
374
- * @param {FilterOperatorEnum} operator - The filter operator to apply
375
- * @param {(string | number | boolean)[]} values - The value(s) for the filter
376
- * @returns {this}
377
- * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
403
+ * @param state - The current query builder state
404
+ * @param options - The query parameter key name configuration
405
+ * @param out - The accumulator the caller joins into the URI
378
406
  */
379
- addFilterOperator(field, operator, ...values) {
380
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
381
- if (!values.length) {
382
- return this;
383
- }
384
- this._nestService.addOperatorFilters([{ field, operator, values }]);
385
- return this;
407
+ _appendPagination(state, options, out) {
408
+ const pagination = qs.stringify({ [options.page]: { number: state.page, size: state.limit } }, { encode: false });
409
+ out.push(pagination);
386
410
  }
387
411
  /**
388
- * Add related entities to include in the request (JSON:API and Spatie only)
412
+ * Append sort parameter as `sort=-field1,field2` (`-` prefix = DESC)
389
413
  *
390
- * @param {string[]} models - Models to include
391
- * @returns {this}
392
- * @throws {UnsupportedIncludesError} If the active driver does not support includes
414
+ * @param state - The current query builder state
415
+ * @param options - The query parameter key name configuration
416
+ * @param out - The accumulator the caller joins into the URI
393
417
  */
394
- addIncludes(...models) {
395
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedIncludesError());
396
- if (!models.length) {
397
- return this;
418
+ _appendSort(state, options, out) {
419
+ if (!state.sorts.length) {
420
+ return;
398
421
  }
399
- this._nestService.addIncludes(models);
400
- return this;
422
+ const pairs = state.sorts.map(sort => `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`);
423
+ out.push(`${options.sort}=${pairs.join(',')}`);
401
424
  }
425
+ }
426
+
427
+ /**
428
+ * Base class for response strategies whose pagination metadata lives at
429
+ * dot-notation paths inside the response body
430
+ *
431
+ * JSON:API and NestJS share an identical body-traversal algorithm: the
432
+ * total / current-page / etc. live at nested keys like `meta.total`, and
433
+ * `from`/`to` are either present directly or must be derived from
434
+ * `currentPage` × `perPage`. Both strategies were duplicating this
435
+ * verbatim before this base existed; concrete classes now extend and
436
+ * provide only the docstring describing their driver's specific path
437
+ * conventions (see `JsonApiResponseStrategy`, `NestjsResponseStrategy`).
438
+ *
439
+ * Drivers whose pagination metadata travels via HTTP headers (PostgREST)
440
+ * or whose body has a flat shape with no dot paths (Laravel, Spatie) do
441
+ * not extend this class — they implement `IResponseStrategy` directly.
442
+ */
443
+ class AbstractDotPathResponseStrategy {
402
444
  /**
403
- * Add flat field selection (NestJS only)
445
+ * Parse a nested-envelope pagination response into a PaginatedCollection
404
446
  *
405
- * Produces: `select=col1,col2`
406
- *
407
- * @param {string[]} fields - Fields to select
408
- * @returns {this}
409
- * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
447
+ * @param response - The raw API response object
448
+ * @param options - The response key name configuration (dot-notation paths supported)
449
+ * @returns A typed PaginatedCollection instance
410
450
  */
411
- addSelect(...fields) {
412
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
413
- if (!fields.length) {
414
- return this;
415
- }
416
- this._nestService.addSelect(fields);
417
- return this;
451
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
452
+ paginate(response, options) {
453
+ const data = this.resolve(response, options.data);
454
+ const currentPage = this.resolve(response, options.currentPage);
455
+ const total = this.resolve(response, options.total);
456
+ const perPage = this.resolve(response, options.perPage);
457
+ const lastPage = this.resolve(response, options.lastPage);
458
+ // Compute from/to if not directly available
459
+ const from = this.resolveFrom(response, options, currentPage, perPage);
460
+ const to = this.resolveTo(response, options, currentPage, perPage, total);
461
+ const prevPageUrl = this.resolve(response, options.prevPageUrl);
462
+ const nextPageUrl = this.resolve(response, options.nextPageUrl);
463
+ const firstPageUrl = this.resolve(response, options.firstPageUrl);
464
+ const lastPageUrl = this.resolve(response, options.lastPageUrl);
465
+ return new PaginatedCollection(data, currentPage, from, to, total, perPage, prevPageUrl, nextPageUrl, lastPage, firstPageUrl, lastPageUrl);
418
466
  }
419
467
  /**
420
- * Add a field with a sort criteria (JSON:API, NestJS, and Spatie)
468
+ * Resolve a value from a response object using a dot-notation path
421
469
  *
422
- * @param field - Field to use for sorting
423
- * @param {SortEnum} order - A value from the SortEnum enumeration
424
- * @returns {this}
425
- * @throws {UnsupportedSortError} If the active driver does not support sorts
470
+ * Supports both flat keys (`'data'`) and nested paths (`'meta.totalItems'`).
471
+ *
472
+ * @param response - The raw response object
473
+ * @param path - The dot-notation path to resolve
474
+ * @returns The resolved value, or undefined if any segment is missing
426
475
  */
427
- addSort(field, order) {
428
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedSortError());
429
- this._nestService.addSort({
430
- field,
431
- order
432
- });
433
- return this;
476
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
477
+ resolve(response, path) {
478
+ return path.split('.').reduce((obj, key) => obj?.[key], response);
434
479
  }
435
480
  /**
436
- * Delete selected fields for the given models in the current query builder state (JSON:API and Spatie only)
481
+ * Resolve the "from" index value
437
482
  *
438
- * ```
439
- * ngQubeeService.deleteFields({
440
- * users: ['email', 'password'],
441
- * address: ['zipcode']
442
- * });
443
- * ```
483
+ * If `options.from` resolves to a value in the response, use it.
484
+ * Otherwise compute `(currentPage - 1) * perPage + 1` when both are known.
444
485
  *
445
- * @param {IFields} fields - Object mapping model names to field arrays to remove
446
- * @returns {this}
447
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
486
+ * @param response - The raw response object
487
+ * @param options - The response key name configuration
488
+ * @param currentPage - The current page number
489
+ * @param perPage - The number of items per page
490
+ * @returns The "from" index, or `undefined` when neither path nor inputs suffice
448
491
  */
449
- deleteFields(fields) {
450
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
451
- this._nestService.deleteFields(fields);
452
- return this;
492
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
493
+ resolveFrom(response, options, currentPage, perPage) {
494
+ const direct = this.resolve(response, options.from);
495
+ if (direct !== undefined) {
496
+ return direct;
497
+ }
498
+ if (currentPage && perPage) {
499
+ return (currentPage - 1) * perPage + 1;
500
+ }
501
+ return undefined;
453
502
  }
454
503
  /**
455
- * Delete selected fields for the given model in the current query builder state (JSON:API and Spatie only)
504
+ * Resolve the "to" index value
456
505
  *
457
- * ```
458
- * ngQubeeService.deleteFieldsByModel('users', 'email', 'password');
459
- * ```
506
+ * If `options.to` resolves to a value in the response, use it.
507
+ * Otherwise compute `Math.min(currentPage * perPage, total)` when all
508
+ * three are known.
460
509
  *
461
- * @param model - Model that holds the fields
462
- * @param {string[]} fields - Fields to delete from the state
463
- * @returns {this}
464
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
510
+ * @param response - The raw response object
511
+ * @param options - The response key name configuration
512
+ * @param currentPage - The current page number
513
+ * @param perPage - The number of items per page
514
+ * @param total - The total number of items
515
+ * @returns The "to" index, or `undefined` when neither path nor inputs suffice
465
516
  */
466
- deleteFieldsByModel(model, ...fields) {
467
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
468
- if (!fields.length) {
469
- return this;
517
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
518
+ resolveTo(response, options, currentPage, perPage, total) {
519
+ const direct = this.resolve(response, options.to);
520
+ if (direct !== undefined) {
521
+ return direct;
470
522
  }
471
- this._nestService.deleteFields({
472
- [model]: fields
473
- });
474
- return this;
523
+ if (currentPage && perPage && total) {
524
+ return Math.min(currentPage * perPage, total);
525
+ }
526
+ return undefined;
475
527
  }
528
+ }
529
+
530
+ /**
531
+ * Response strategy for the JSON:API driver
532
+ *
533
+ * Parses JSON:API pagination responses:
534
+ * ```json
535
+ * {
536
+ * "data": [...],
537
+ * "meta": {
538
+ * "current-page": 1,
539
+ * "per-page": 10,
540
+ * "total": 100,
541
+ * "page-count": 10,
542
+ * "from": 1,
543
+ * "to": 10
544
+ * },
545
+ * "links": {
546
+ * "first": "url",
547
+ * "prev": "url",
548
+ * "next": "url",
549
+ * "last": "url"
550
+ * }
551
+ * }
552
+ * ```
553
+ *
554
+ * Default key paths are configured in `JsonApiResponseOptions`. The
555
+ * traversal algorithm (dot-notation resolution + computed `from`/`to`) is
556
+ * inherited from `AbstractDotPathResponseStrategy`; this class exists so
557
+ * `DriverEnum.JSON_API` resolves to a distinct identity at the DI layer
558
+ * even though the parsing logic is shared with NestJS.
559
+ *
560
+ * @see https://jsonapi.org/format/
561
+ */
562
+ class JsonApiResponseStrategy extends AbstractDotPathResponseStrategy {
563
+ }
564
+
565
+ /**
566
+ * Request strategy for the Laravel (pagination-only) driver
567
+ *
568
+ * Generates simple pagination URIs:
569
+ * - `/{resource}?limit=N&page=N`
570
+ *
571
+ * Filters, sorts, fields, includes, search, and select in state are ignored.
572
+ */
573
+ class LaravelRequestStrategy extends AbstractRequestStrategy {
574
+ /**
575
+ * Pagination-only driver — no filtering, sorting, or column selection
576
+ */
577
+ capabilities = {
578
+ fields: false,
579
+ filters: false,
580
+ includes: false,
581
+ operatorFilters: false,
582
+ search: false,
583
+ select: false,
584
+ sort: false
585
+ };
476
586
  /**
477
- * Remove given filters from the query builder state (JSON:API, NestJS, and Spatie)
587
+ * Emit only the pagination params; filters/sorts/etc. are ignored
478
588
  *
479
- * @param {string[]} filters - Filters to remove
480
- * @returns {this}
481
- * @throws {UnsupportedFilterError} If the active driver does not support filters
589
+ * @param state - The current query builder state
590
+ * @param options - The query parameter key name configuration
591
+ * @returns The two pagination query-string fragments
482
592
  */
483
- deleteFilters(...filters) {
484
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedFilterError());
485
- if (!filters.length) {
486
- return this;
487
- }
488
- this._nestService.deleteFilters(...filters);
489
- return this;
593
+ parts(state, options) {
594
+ return [
595
+ `${options.limit}=${state.limit}`,
596
+ `${options.page}=${state.page}`
597
+ ];
490
598
  }
599
+ }
600
+
601
+ /**
602
+ * Response strategy for the Laravel (pagination-only) driver
603
+ *
604
+ * Parses flat Laravel pagination responses:
605
+ * ```json
606
+ * {
607
+ * "data": [...],
608
+ * "current_page": 1,
609
+ * "total": 100,
610
+ * "per_page": 15,
611
+ * "from": 1,
612
+ * "to": 15,
613
+ * ...
614
+ * }
615
+ * ```
616
+ */
617
+ class LaravelResponseStrategy {
491
618
  /**
492
- * Remove selected related models from the query builder state (JSON:API and Spatie only)
619
+ * Parse a flat Laravel pagination response into a PaginatedCollection
493
620
  *
494
- * @param {string[]} includes - Models to remove
495
- * @returns {this}
496
- * @throws {UnsupportedIncludesError} If the active driver does not support includes
621
+ * @param response - The raw API response object
622
+ * @param options - The response key name configuration
623
+ * @returns A typed PaginatedCollection instance
497
624
  */
498
- deleteIncludes(...includes) {
499
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedIncludesError());
500
- if (!includes.length) {
501
- return this;
502
- }
503
- this._nestService.deleteIncludes(...includes);
504
- return this;
625
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
626
+ paginate(response, options) {
627
+ 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]);
505
628
  }
629
+ }
630
+
631
+ /**
632
+ * Request strategy for the NestJS (nestjs-paginate) driver
633
+ *
634
+ * Generates URIs in the NestJS paginate format:
635
+ * - Simple filters: `filter.field=value`
636
+ * - Operator filters: `filter.field=$operator:value`
637
+ * - Sorts: `sortBy=field1:DESC,field2:ASC`
638
+ * - Select: `select=col1,col2`
639
+ * - Search: `search=term`
640
+ * - Pagination: `limit=N&page=N`
641
+ *
642
+ * @see https://github.com/ppetzold/nestjs-paginate
643
+ */
644
+ class NestjsRequestStrategy extends AbstractRequestStrategy {
506
645
  /**
507
- * Remove operator filters by field name (NestJS only)
646
+ * Filters, operator filters, sorts, flat select, global search — no
647
+ * per-model fields, no includes
648
+ */
649
+ capabilities = {
650
+ fields: false,
651
+ filters: true,
652
+ includes: false,
653
+ operatorFilters: true,
654
+ search: true,
655
+ select: true,
656
+ sort: true
657
+ };
658
+ /**
659
+ * Validate that the given limit is accepted by nestjs-paginate
508
660
  *
509
- * @param {string[]} fields - Field names of operator filters to remove
510
- * @returns {this}
511
- * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
661
+ * Accepts any integer `>= 1` as a page size, plus `-1` which nestjs-paginate
662
+ * interprets as "fetch all items" (server must opt-in via `maxLimit: -1`).
663
+ *
664
+ * @param limit - The limit value to validate
665
+ * @throws {InvalidLimitError} If the value is not an integer, or is 0, or is a negative number other than -1
512
666
  */
513
- deleteOperatorFilters(...fields) {
514
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
515
- if (!fields.length) {
516
- return this;
667
+ validateLimit(limit) {
668
+ if (Number.isInteger(limit) && (limit === -1 || limit >= 1)) {
669
+ return;
517
670
  }
518
- this._nestService.deleteOperatorFilters(...fields);
519
- return this;
671
+ throw new InvalidLimitError(limit, true);
520
672
  }
521
673
  /**
522
- * Remove search term from the query builder state (NestJS only)
674
+ * Emit NestJS-format query-string segments in canonical order:
675
+ * filters → operator filters → sortBy → select → search → limit → page
523
676
  *
524
- * @returns {this}
525
- * @throws {UnsupportedSearchError} If the active driver does not support search
677
+ * @param state - The current query builder state
678
+ * @param options - The query parameter key name configuration
679
+ * @returns Ordered query-string fragments
526
680
  */
527
- deleteSearch() {
528
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
529
- this._nestService.deleteSearch();
530
- return this;
681
+ parts(state, options) {
682
+ const out = [];
683
+ this._appendFilters(state, options, out);
684
+ this._appendOperatorFilters(state, options, out);
685
+ this._appendSort(state, options, out);
686
+ this._appendSelect(state, options, out);
687
+ this._appendSearch(state, options, out);
688
+ this._appendLimit(state, options, out);
689
+ this._appendPage(state, options, out);
690
+ return out;
531
691
  }
532
692
  /**
533
- * Remove flat field selections from the query builder state (NestJS only)
693
+ * Append simple filter parameters as `filter.field=value1,value2`
534
694
  *
535
- * @param {string[]} fields - Fields to remove from selection
536
- * @returns {this}
537
- * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
695
+ * @param state - The current query builder state
696
+ * @param options - The query parameter key name configuration
697
+ * @param out - The accumulator the caller joins into the URI
538
698
  */
539
- deleteSelect(...fields) {
540
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
541
- if (!fields.length) {
542
- return this;
699
+ _appendFilters(state, options, out) {
700
+ const keys = Object.keys(state.filters);
701
+ if (!keys.length) {
702
+ return;
543
703
  }
544
- this._nestService.deleteSelect(...fields);
545
- return this;
704
+ keys.forEach(key => {
705
+ const values = state.filters[key].join(',');
706
+ out.push(`${options.filters}.${key}=${values}`);
707
+ });
546
708
  }
547
709
  /**
548
- * Remove sort rules from the query builder state (JSON:API, NestJS, and Spatie)
710
+ * Append the limit parameter
549
711
  *
550
- * @param sorts - Fields used for sorting to remove
551
- * @returns {this}
552
- * @throws {UnsupportedSortError} If the active driver does not support sorts
712
+ * @param state - The current query builder state
713
+ * @param options - The query parameter key name configuration
714
+ * @param out - The accumulator the caller joins into the URI
553
715
  */
554
- deleteSorts(...sorts) {
555
- this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedSortError());
556
- this._nestService.deleteSorts(...sorts);
557
- return this;
558
- }
559
- /**
560
- * Generate a URI accordingly to the given data and active driver
561
- *
562
- * @returns {Observable<string>} An observable that emits the generated URI
563
- */
564
- generateUri() {
565
- try {
566
- this._uri$.next(this._requestStrategy.buildUri(this._nestService.nest(), this._options));
567
- return this.uri$;
568
- }
569
- catch (error) {
570
- return throwError(() => error);
571
- }
716
+ _appendLimit(state, options, out) {
717
+ out.push(`${options.limit}=${state.limit}`);
572
718
  }
573
719
  /**
574
- * Clear the current state and reset the Query Builder to a fresh, clean condition
720
+ * Append operator-filter parameters as `filter.field=$op:value`
575
721
  *
576
- * @returns {this}
577
- */
578
- reset() {
579
- this._nestService.reset();
580
- return this;
581
- }
582
- /**
583
- * Set the base URL to use for composing the address
722
+ * Groups by field; multi-value operators ($in, $btw) join values with commas.
584
723
  *
585
- * @param {string} baseUrl - The base URL
586
- * @returns {this}
724
+ * @param state - The current query builder state
725
+ * @param options - The query parameter key name configuration
726
+ * @param out - The accumulator the caller joins into the URI
587
727
  */
588
- setBaseUrl(baseUrl) {
589
- this._nestService.baseUrl = baseUrl;
590
- return this;
728
+ _appendOperatorFilters(state, options, out) {
729
+ if (!state.operatorFilters.length) {
730
+ return;
731
+ }
732
+ state.operatorFilters.forEach((opFilter) => {
733
+ const values = opFilter.values.join(',');
734
+ out.push(`${options.filters}.${opFilter.field}=${opFilter.operator}:${values}`);
735
+ });
591
736
  }
592
737
  /**
593
- * Set the items per page number
738
+ * Append the page parameter
594
739
  *
595
- * Validation is delegated to the active request strategy because the
596
- * accepted range is driver-specific: nestjs-paginate additionally accepts
597
- * `-1` as a "fetch all" sentinel, while Laravel, Spatie, and JSON:API
598
- * require a positive integer.
599
- *
600
- * @param limit - Number of items per page (or `-1` to fetch all, NestJS only)
601
- * @returns {this}
602
- * @throws {import('../errors/invalid-limit.error').InvalidLimitError} If the value is not accepted by the active driver
740
+ * @param state - The current query builder state
741
+ * @param options - The query parameter key name configuration
742
+ * @param out - The accumulator the caller joins into the URI
603
743
  */
604
- setLimit(limit) {
605
- this._requestStrategy.validateLimit(limit);
606
- this._nestService.limit = limit;
607
- return this;
744
+ _appendPage(state, options, out) {
745
+ out.push(`${options.page}=${state.page}`);
608
746
  }
609
747
  /**
610
- * Set the page that the backend will use to paginate the result set
748
+ * Append the search parameter as `search=term`
611
749
  *
612
- * @param page - Page number
613
- * @returns {this}
750
+ * @param state - The current query builder state
751
+ * @param options - The query parameter key name configuration
752
+ * @param out - The accumulator the caller joins into the URI
614
753
  */
615
- setPage(page) {
616
- this._nestService.page = page;
617
- return this;
754
+ _appendSearch(state, options, out) {
755
+ if (!state.search) {
756
+ return;
757
+ }
758
+ out.push(`${options.search}=${state.search}`);
618
759
  }
619
760
  /**
620
- * Set the API resource to run the query against
761
+ * Append the select parameter as `select=col1,col2`
621
762
  *
622
- * @param {string} resource - Resource name (e.g. 'users' produces /users)
623
- * @returns {this}
763
+ * @param state - The current query builder state
764
+ * @param options - The query parameter key name configuration
765
+ * @param out - The accumulator the caller joins into the URI
624
766
  */
625
- setResource(resource) {
626
- this._nestService.resource = resource;
627
- return this;
767
+ _appendSelect(state, options, out) {
768
+ if (!state.select.length) {
769
+ return;
770
+ }
771
+ out.push(`${options.select}=${state.select.join(',')}`);
628
772
  }
629
773
  /**
630
- * Set the search term for full-text search (NestJS only)
631
- *
632
- * Produces: `search=term`
774
+ * Append sort parameter as `sortBy=field1:DESC,field2:ASC`
633
775
  *
634
- * @param {string} search - The search term
635
- * @returns {this}
636
- * @throws {UnsupportedSearchError} If the active driver does not support search
776
+ * @param state - The current query builder state
777
+ * @param options - The query parameter key name configuration
778
+ * @param out - The accumulator the caller joins into the URI
637
779
  */
638
- setSearch(search) {
639
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
640
- this._nestService.setSearch(search);
641
- return this;
780
+ _appendSort(state, options, out) {
781
+ if (!state.sorts.length) {
782
+ return;
783
+ }
784
+ const pairs = state.sorts.map(sort => `${sort.field}:${sort.order === SortEnum.DESC ? 'DESC' : 'ASC'}`);
785
+ out.push(`${options.sortBy}=${pairs.join(',')}`);
642
786
  }
643
787
  }
644
788
 
645
789
  /**
646
- * Error thrown when an invalid resource name is provided
790
+ * Response strategy for the NestJS (nestjs-paginate) driver
647
791
  *
648
- * Resource name must be a non-empty string.
792
+ * Parses nested NestJS pagination responses:
793
+ * ```json
794
+ * {
795
+ * "data": [...],
796
+ * "meta": {
797
+ * "currentPage": 1,
798
+ * "totalItems": 100,
799
+ * "itemsPerPage": 10,
800
+ * "totalPages": 10
801
+ * },
802
+ * "links": {
803
+ * "first": "url",
804
+ * "previous": "url",
805
+ * "next": "url",
806
+ * "last": "url",
807
+ * "current": "url"
808
+ * }
809
+ * }
810
+ * ```
811
+ *
812
+ * Default key paths are configured in `NestjsResponseOptions`. The
813
+ * traversal algorithm (dot-notation resolution + computed `from`/`to`) is
814
+ * inherited from `AbstractDotPathResponseStrategy`; this class exists so
815
+ * `DriverEnum.NESTJS` resolves to a distinct identity at the DI layer
816
+ * even though the parsing logic is shared with JSON:API.
817
+ *
818
+ * @see https://github.com/ppetzold/nestjs-paginate
649
819
  */
650
- class InvalidResourceNameError extends Error {
651
- constructor(resource) {
652
- super(`Invalid resource name: Resource name must be a non-empty string. Received: ${JSON.stringify(resource)}`);
653
- this.name = 'InvalidResourceNameError';
654
- }
820
+ class NestjsResponseStrategy extends AbstractDotPathResponseStrategy {
655
821
  }
656
822
 
657
- class InvalidPageNumberError extends Error {
658
- constructor(page) {
659
- super(`Invalid page number: Page must be a positive integer greater than 0. Received: ${page}`);
660
- this.name = 'InvalidPageNumberError';
823
+ /**
824
+ * Enum representing the available filter operators for explicit operator
825
+ * filters
826
+ *
827
+ * NestJS encodes these with the `$` prefix at the wire level
828
+ * (`filter.field=$operator:value`); PostgREST translates them to its own
829
+ * prefix notation (`col=eq.val`, `col=is.null`, etc.). The enum values are
830
+ * intentionally the NestJS form; each driver's request strategy is
831
+ * responsible for mapping them into its own shape.
832
+ *
833
+ * `FTS`, `PLFTS`, `PHFTS`, `WFTS` are PostgREST-native full-text search
834
+ * variants; they throw `UnsupportedFilterOperatorError` on every other
835
+ * driver that does not recognise them.
836
+ *
837
+ * @see https://github.com/ppetzold/nestjs-paginate
838
+ * @see https://postgrest.org/en/stable/api.html#operators
839
+ */
840
+ var FilterOperatorEnum;
841
+ (function (FilterOperatorEnum) {
842
+ FilterOperatorEnum["BTW"] = "$btw";
843
+ FilterOperatorEnum["CONTAINS"] = "$contains";
844
+ FilterOperatorEnum["EQ"] = "$eq";
845
+ FilterOperatorEnum["FTS"] = "$fts";
846
+ FilterOperatorEnum["GT"] = "$gt";
847
+ FilterOperatorEnum["GTE"] = "$gte";
848
+ FilterOperatorEnum["ILIKE"] = "$ilike";
849
+ FilterOperatorEnum["IN"] = "$in";
850
+ FilterOperatorEnum["LT"] = "$lt";
851
+ FilterOperatorEnum["LTE"] = "$lte";
852
+ FilterOperatorEnum["NOT"] = "$not";
853
+ FilterOperatorEnum["NULL"] = "$null";
854
+ FilterOperatorEnum["PHFTS"] = "$phfts";
855
+ FilterOperatorEnum["PLFTS"] = "$plfts";
856
+ FilterOperatorEnum["SW"] = "$sw";
857
+ FilterOperatorEnum["WFTS"] = "$wfts";
858
+ })(FilterOperatorEnum || (FilterOperatorEnum = {}));
859
+
860
+ /**
861
+ * Enum representing the wire-level pagination mechanism
862
+ *
863
+ * `QUERY` (default) — the request strategy emits `limit` and `offset` (or
864
+ * equivalent) query parameters on the URL.
865
+ *
866
+ * `RANGE` — the request strategy omits URL-based pagination and the
867
+ * consumer instead applies HTTP request headers returned by
868
+ * `NgQubeeService.paginationHeaders()`. Currently honoured only by the
869
+ * PostgREST driver, which maps it to `Range-Unit: items` + `Range: 0-9`.
870
+ * Other drivers ignore the setting.
871
+ */
872
+ var PaginationModeEnum;
873
+ (function (PaginationModeEnum) {
874
+ PaginationModeEnum["QUERY"] = "query";
875
+ PaginationModeEnum["RANGE"] = "range";
876
+ })(PaginationModeEnum || (PaginationModeEnum = {}));
877
+
878
+ /**
879
+ * Thrown when a filter operator receives a value array of the wrong shape
880
+ *
881
+ * Some operators have arity or type constraints that the library enforces
882
+ * at call time so misuse fails loudly instead of silently emitting invalid
883
+ * server requests:
884
+ *
885
+ * - `BTW` requires exactly two values (min, max).
886
+ * - `NULL` requires exactly one boolean value (`true` for `IS NULL`,
887
+ * `false` for `IS NOT NULL`).
888
+ *
889
+ * Operators with looser shape rules leave validation to the server; this
890
+ * error is reserved for cases where the library itself can detect the
891
+ * problem unambiguously from the call site.
892
+ */
893
+ class InvalidFilterOperatorValueError extends Error {
894
+ /**
895
+ * @param operator - The operator that rejected the values
896
+ * @param reason - Short human-readable explanation of the constraint
897
+ */
898
+ constructor(operator, reason) {
899
+ super(`Invalid values for filter operator ${operator}: ${reason}`);
900
+ this.name = 'InvalidFilterOperatorValueError';
661
901
  }
662
902
  }
663
903
 
664
- const INITIAL_STATE = {
665
- baseUrl: '',
666
- fields: {},
667
- filters: {},
668
- includes: [],
669
- limit: 15,
670
- operatorFilters: [],
671
- page: 1,
672
- resource: '',
673
- search: '',
674
- select: [],
675
- sorts: []
676
- };
677
- class NestService {
904
+ /**
905
+ * Request strategy for the PostgREST driver
906
+ *
907
+ * PostgREST auto-generates REST APIs from PostgreSQL schemas and is the
908
+ * backbone of Supabase's data API. This strategy produces URIs in
909
+ * PostgREST's native query-string format:
910
+ *
911
+ * - Filters: `col=eq.val` (single value) / `col=in.(v1,v2,v3)` (multi-value)
912
+ * - Order: `order=col1.asc,col2.desc`
913
+ * - Select: `select=col1,col2`
914
+ * - Pagination: `limit=N&offset=M` (offset derived from state.page)
915
+ *
916
+ * The `order` and `offset` query-parameter names are PostgREST conventions
917
+ * and are intentionally not configurable via `QueryBuilderOptions` (see
918
+ * issue #50 MVP scope). `limit`, `select`, and `filters` (per-column name)
919
+ * honour the existing option keys.
920
+ *
921
+ * @see https://postgrest.org/en/stable/api.html
922
+ * @see https://supabase.com/docs/reference/javascript/select
923
+ */
924
+ class PostgrestRequestStrategy extends AbstractRequestStrategy {
678
925
  /**
679
- * Private writable signal that holds the Query Builder state
680
- *
681
- * @type {IQueryBuilderState}
926
+ * Filters, operator filters (incl. FTS), sorts, flat select — no
927
+ * per-model fields, no JSON:API/Spatie-style includes, no global
928
+ * search (per-column FTS via the operator family covers it)
682
929
  */
683
- _nest = signal(this._clone(INITIAL_STATE), ...(ngDevMode ? [{ debugName: "_nest" }] : []));
930
+ capabilities = {
931
+ fields: false,
932
+ filters: true,
933
+ includes: false,
934
+ operatorFilters: true,
935
+ search: false,
936
+ select: true,
937
+ sort: true
938
+ };
939
+ static _offsetKey = 'offset';
940
+ static _orderKey = 'order';
684
941
  /**
685
- * A computed signal that makes readonly the writable signal _nest
942
+ * Active pagination mode
686
943
  *
687
- * @type {Signal<IQueryBuilderState>}
944
+ * QUERY (default) → URL emits limit/offset.
945
+ * RANGE → URL omits them; `buildPaginationHeaders()` returns the
946
+ * `Range-Unit` / `Range` HTTP headers instead.
688
947
  */
689
- nest = computed(() => this._clone(this._nest()), ...(ngDevMode ? [{ debugName: "nest" }] : []));
690
- constructor() {
691
- // Nothing to see here
948
+ _paginationMode;
949
+ /**
950
+ * @param paginationMode - Wire-level pagination mechanism. Defaults to
951
+ * `PaginationModeEnum.QUERY`; `provideNgQubee` wires this from
952
+ * `IConfig.pagination`.
953
+ */
954
+ constructor(paginationMode = PaginationModeEnum.QUERY) {
955
+ super();
956
+ this._paginationMode = paginationMode;
692
957
  }
693
958
  /**
694
- * Set the base URL for the API
959
+ * Compute `Range-Unit` / `Range` HTTP headers for RANGE pagination mode
695
960
  *
696
- * @param {string} baseUrl - The base URL to prepend to generated URIs
697
- * @example
698
- * service.baseUrl = 'https://api.example.com';
961
+ * In QUERY mode this returns `null` so `NgQubeeService.paginationHeaders()`
962
+ * conveys "no headers needed" to the consumer. In RANGE mode the method
963
+ * converts the 1-indexed `state.page` + `state.limit` into PostgREST's
964
+ * 0-indexed inclusive range (`from = (page - 1) * limit`,
965
+ * `to = from + limit - 1`) and returns both header values.
966
+ *
967
+ * @param state - The current query builder state
968
+ * @returns `{ 'Range-Unit': 'items', 'Range': 'from-to' }` or `null`
699
969
  */
700
- set baseUrl(baseUrl) {
701
- this._nest.update(nest => ({
702
- ...nest,
703
- baseUrl
704
- }));
970
+ buildPaginationHeaders(state) {
971
+ if (this._paginationMode !== PaginationModeEnum.RANGE) {
972
+ return null;
973
+ }
974
+ const from = (state.page - 1) * state.limit;
975
+ const to = from + state.limit - 1;
976
+ /* eslint-disable @typescript-eslint/naming-convention */
977
+ return {
978
+ 'Range-Unit': 'items',
979
+ 'Range': `${from}-${to}`
980
+ };
981
+ /* eslint-enable @typescript-eslint/naming-convention */
705
982
  }
706
983
  /**
707
- * Set the limit for paginated results
984
+ * Emit PostgREST-format query-string segments in canonical order:
985
+ * filters → operator filters → order → select → (limit + offset in
986
+ * QUERY mode only — RANGE mode passes pagination via headers instead)
708
987
  *
709
- * This setter performs a raw state write. Validation of the value is the
710
- * responsibility of the active request strategy and is enforced upstream
711
- * by `NgQubeeService.setLimit()`, because the accepted range depends on
712
- * the driver (e.g. nestjs-paginate accepts `-1` for "fetch all").
988
+ * @param state - The current query builder state
989
+ * @param options - The query parameter key name configuration
990
+ * @returns Ordered query-string fragments
991
+ */
992
+ parts(state, options) {
993
+ const out = [];
994
+ this._appendFilters(state, out);
995
+ this._appendOperatorFilters(state, out);
996
+ this._appendOrder(state, out);
997
+ this._appendSelect(state, options, out);
998
+ if (this._paginationMode === PaginationModeEnum.QUERY) {
999
+ this._appendLimit(state, options, out);
1000
+ this._appendOffset(state, out);
1001
+ }
1002
+ return out;
1003
+ }
1004
+ /**
1005
+ * Append filter parameters in PostgREST format
713
1006
  *
714
- * @param {number} limit - The number of items per page
715
- * @example
716
- * service.limit = 25;
1007
+ * Every filter is operator-prefixed (PostgREST has no implicit equality):
1008
+ * a single value yields `col=eq.val`; multiple values collapse into
1009
+ * PostgREST's native IN-list syntax `col=in.(v1,v2,v3)`.
1010
+ *
1011
+ * @param state - The current query builder state
1012
+ * @param out - The accumulator the caller joins into the URI
717
1013
  */
718
- set limit(limit) {
719
- this._nest.update(nest => ({
720
- ...nest,
721
- limit
722
- }));
1014
+ _appendFilters(state, out) {
1015
+ const keys = Object.keys(state.filters);
1016
+ if (!keys.length) {
1017
+ return;
1018
+ }
1019
+ keys.forEach(key => {
1020
+ const values = state.filters[key];
1021
+ if (!values.length) {
1022
+ return;
1023
+ }
1024
+ // single-value → eq.<val>
1025
+ // multi-value → in.(v1,v2,v3)
1026
+ const rhs = values.length === 1
1027
+ ? `eq.${values[0]}`
1028
+ : `in.(${values.join(',')})`;
1029
+ out.push(`${key}=${rhs}`);
1030
+ });
723
1031
  }
724
1032
  /**
725
- * Set the page number for pagination
726
- * Must be a positive integer greater than 0
1033
+ * Append the limit parameter
727
1034
  *
728
- * @param {number} page - The page number to fetch
729
- * @throws {InvalidPageNumberError} If page is not a positive integer
730
- * @example
731
- * service.page = 2;
1035
+ * @param state - The current query builder state
1036
+ * @param options - The query parameter key name configuration
1037
+ * @param out - The accumulator the caller joins into the URI
732
1038
  */
733
- set page(page) {
734
- this._validatePageNumber(page);
735
- this._nest.update(nest => ({
736
- ...nest,
737
- page
738
- }));
1039
+ _appendLimit(state, options, out) {
1040
+ out.push(`${options.limit}=${state.limit}`);
739
1041
  }
740
1042
  /**
741
- * Set the resource name for the query
742
- * Must be a non-empty string
1043
+ * Append the offset parameter, derived from state.page
743
1044
  *
744
- * @param {string} resource - The API resource name (e.g., 'users', 'posts')
745
- * @throws {InvalidResourceNameError} If resource is not a non-empty string
746
- * @example
747
- * service.resource = 'users';
1045
+ * PostgREST uses offset-based pagination, not page-based. The offset is
1046
+ * computed as `(page - 1) * limit`. Omitted when offset would be 0
1047
+ * (i.e. page 1) since PostgREST defaults to offset=0 anyway and dropping
1048
+ * it keeps the URI shorter.
1049
+ *
1050
+ * @param state - The current query builder state
1051
+ * @param out - The accumulator the caller joins into the URI
748
1052
  */
749
- set resource(resource) {
750
- this._validateResourceName(resource);
751
- this._nest.update(nest => ({
752
- ...nest,
753
- resource
754
- }));
755
- }
756
- _clone(obj) {
757
- return JSON.parse(JSON.stringify(obj));
1053
+ _appendOffset(state, out) {
1054
+ const offset = (state.page - 1) * state.limit;
1055
+ if (offset <= 0) {
1056
+ return;
1057
+ }
1058
+ out.push(`${PostgrestRequestStrategy._offsetKey}=${offset}`);
758
1059
  }
759
1060
  /**
760
- * Validates that the page number is a positive integer
1061
+ * Append explicit operator filters
761
1062
  *
762
- * @param {number} page - The page number to validate
763
- * @throws {InvalidPageNumberError} If page is not a positive integer
764
- * @private
1063
+ * Maps each `FilterOperatorEnum` value to PostgREST's prefix-operator
1064
+ * syntax. `BTW` expands to two query params (`gte` + `lte`); `NULL`
1065
+ * emits `is.null` / `is.not.null` based on the boolean value; `NOT`
1066
+ * picks its inner operator by arity (`not.eq.val` for single values,
1067
+ * `not.in.(v1,v2)` for multi-value).
1068
+ *
1069
+ * @param state - The current query builder state
1070
+ * @param out - The accumulator the caller joins into the URI
1071
+ * @throws {InvalidFilterOperatorValueError} If `BTW` does not receive exactly 2 values, or `NULL` does not receive exactly 1 boolean
765
1072
  */
766
- _validatePageNumber(page) {
767
- if (!Number.isInteger(page) || page < 1) {
768
- throw new InvalidPageNumberError(page);
1073
+ _appendOperatorFilters(state, out) {
1074
+ if (!state.operatorFilters.length) {
1075
+ return;
769
1076
  }
1077
+ state.operatorFilters.forEach(filter => {
1078
+ // BTW expands to two segments: col=gte.min and col=lte.max
1079
+ if (filter.operator === FilterOperatorEnum.BTW) {
1080
+ this._appendBetweenFilter(filter, out);
1081
+ return;
1082
+ }
1083
+ const rhs = this._formatOperatorRhs(filter);
1084
+ out.push(`${filter.field}=${rhs}`);
1085
+ });
770
1086
  }
771
1087
  /**
772
- * Validates that the resource name is a non-empty string
1088
+ * Append a `BTW` operator filter as two PostgREST segments
773
1089
  *
774
- * @param {string} resource - The resource name to validate
775
- * @throws {InvalidResourceNameError} If resource is not a non-empty string
776
- * @private
1090
+ * Produces: `col=gte.min` and `col=lte.max`. Values must be exactly
1091
+ * `[min, max]`.
1092
+ *
1093
+ * @param filter - The operator filter carrying the BTW bounds
1094
+ * @param out - The accumulator the caller joins into the URI
1095
+ * @throws {InvalidFilterOperatorValueError} If values.length !== 2
777
1096
  */
778
- _validateResourceName(resource) {
779
- if (!resource || typeof resource !== 'string' || resource.trim().length === 0) {
780
- throw new InvalidResourceNameError(resource);
1097
+ _appendBetweenFilter(filter, out) {
1098
+ if (filter.values.length !== 2) {
1099
+ throw new InvalidFilterOperatorValueError(filter.operator, 'BTW requires exactly 2 values (min, max)');
1100
+ }
1101
+ const [min, max] = filter.values;
1102
+ out.push(`${filter.field}=gte.${min}`);
1103
+ out.push(`${filter.field}=lte.${max}`);
1104
+ }
1105
+ /**
1106
+ * Build the right-hand-side of a PostgREST filter param for the given operator
1107
+ *
1108
+ * Kept as a separate helper so each operator's shape is visible in one
1109
+ * place and the dispatch is exhaustively typed against
1110
+ * `FilterOperatorEnum`.
1111
+ *
1112
+ * @param filter - The operator filter (field, operator, values)
1113
+ * @returns The PostgREST-formatted value portion (right of the `=` sign)
1114
+ * @throws {InvalidFilterOperatorValueError} If NULL receives a non-boolean or wrong arity
1115
+ */
1116
+ _formatOperatorRhs(filter) {
1117
+ const { operator, values } = filter;
1118
+ const first = values[0];
1119
+ switch (operator) {
1120
+ case FilterOperatorEnum.EQ: return `eq.${first}`;
1121
+ case FilterOperatorEnum.GT: return `gt.${first}`;
1122
+ case FilterOperatorEnum.GTE: return `gte.${first}`;
1123
+ case FilterOperatorEnum.LT: return `lt.${first}`;
1124
+ case FilterOperatorEnum.LTE: return `lte.${first}`;
1125
+ case FilterOperatorEnum.ILIKE: return `ilike.${first}`;
1126
+ case FilterOperatorEnum.IN: return `in.(${values.join(',')})`;
1127
+ case FilterOperatorEnum.SW: return `like.${first}*`;
1128
+ case FilterOperatorEnum.CONTAINS: return `ilike.%${first}%`;
1129
+ case FilterOperatorEnum.FTS: return `fts.${first}`;
1130
+ case FilterOperatorEnum.PLFTS: return `plfts.${first}`;
1131
+ case FilterOperatorEnum.PHFTS: return `phfts.${first}`;
1132
+ case FilterOperatorEnum.WFTS: return `wfts.${first}`;
1133
+ case FilterOperatorEnum.NOT:
1134
+ return values.length === 1
1135
+ ? `not.eq.${first}`
1136
+ : `not.in.(${values.join(',')})`;
1137
+ case FilterOperatorEnum.NULL: {
1138
+ if (values.length !== 1 || typeof first !== 'boolean') {
1139
+ throw new InvalidFilterOperatorValueError(operator, 'NULL requires exactly 1 boolean value (true → IS NULL, false → IS NOT NULL)');
1140
+ }
1141
+ return first ? 'is.null' : 'is.not.null';
1142
+ }
1143
+ // BTW is dispatched by _appendOperatorFilters; falling through would be a bug
1144
+ case FilterOperatorEnum.BTW:
1145
+ throw new InvalidFilterOperatorValueError(operator, 'BTW should be dispatched to _appendBetweenFilter — this indicates a bug');
781
1146
  }
782
1147
  }
783
1148
  /**
784
- * Add selectable fields for the given model to the request
785
- * Automatically prevents duplicate fields for each model
1149
+ * Append the order parameter as `order=col1.asc,col2.desc`
786
1150
  *
787
- * @param {IFields} fields - Object mapping model names to arrays of field names
788
- * @return {void}
789
- * @example
790
- * service.addFields({ users: ['id', 'email', 'username'] });
791
- * service.addFields({ posts: ['title', 'content'] });
1151
+ * @param state - The current query builder state
1152
+ * @param out - The accumulator the caller joins into the URI
792
1153
  */
793
- addFields(fields) {
794
- this._nest.update(nest => {
795
- const mergedFields = { ...nest.fields };
796
- Object.keys(fields).forEach(model => {
797
- const existingFields = mergedFields[model] || [];
798
- const newFields = fields[model];
799
- // Use Set to prevent duplicates
800
- const uniqueFields = Array.from(new Set([...existingFields, ...newFields]));
801
- mergedFields[model] = uniqueFields;
802
- });
803
- return {
804
- ...nest,
805
- fields: mergedFields
806
- };
807
- });
1154
+ _appendOrder(state, out) {
1155
+ if (!state.sorts.length) {
1156
+ return;
1157
+ }
1158
+ const pairs = state.sorts.map(sort => `${sort.field}.${sort.order === SortEnum.DESC ? 'desc' : 'asc'}`);
1159
+ out.push(`${PostgrestRequestStrategy._orderKey}=${pairs.join(',')}`);
808
1160
  }
809
1161
  /**
810
- * Add filters to the request
811
- * Automatically prevents duplicate filter values for each filter key
1162
+ * Append the select parameter as `select=col1,col2`
812
1163
  *
813
- * @param {IFilters} filters - Object mapping filter keys to arrays of values
814
- * @return {void}
815
- * @example
816
- * service.addFilters({ id: [1, 2, 3] });
817
- * service.addFilters({ status: ['active', 'pending'] });
1164
+ * PostgREST uses a `select` query param for column pruning, matching
1165
+ * NestJS semantics.
1166
+ *
1167
+ * @param state - The current query builder state
1168
+ * @param options - The query parameter key name configuration
1169
+ * @param out - The accumulator the caller joins into the URI
818
1170
  */
819
- addFilters(filters) {
820
- this._nest.update(nest => {
821
- const mergedFilters = { ...nest.filters };
822
- Object.keys(filters).forEach(key => {
823
- const existingValues = mergedFilters[key] || [];
824
- const newValues = filters[key];
825
- // Use Set to prevent duplicates
826
- const uniqueValues = Array.from(new Set([...existingValues, ...newValues]));
827
- mergedFilters[key] = uniqueValues;
828
- });
829
- return {
830
- ...nest,
831
- filters: mergedFilters
832
- };
833
- });
1171
+ _appendSelect(state, options, out) {
1172
+ if (!state.select.length) {
1173
+ return;
1174
+ }
1175
+ out.push(`${options.select}=${state.select.join(',')}`);
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Read a header value by name from a `HeaderBag`, regardless of whether the
1181
+ * bag exposes a `.get()` accessor or plain property access.
1182
+ *
1183
+ * @param bag - The header bag to read from
1184
+ * @param name - The header name (case-sensitivity follows the underlying bag)
1185
+ * @returns The header value, or `null` if absent or the bag itself is falsy
1186
+ */
1187
+ function readHeader(bag, name) {
1188
+ if (!bag) {
1189
+ return null;
1190
+ }
1191
+ const accessor = bag;
1192
+ if (typeof accessor.get === 'function') {
1193
+ return accessor.get(name);
1194
+ }
1195
+ const value = bag[name];
1196
+ return value ?? null;
1197
+ }
1198
+
1199
+ /**
1200
+ * Response strategy for the PostgREST driver
1201
+ *
1202
+ * PostgREST (and Supabase, which wraps it) returns a bare array body for
1203
+ * collection endpoints. Pagination metadata is carried in the
1204
+ * `Content-Range` HTTP response header, e.g. `0-9/50` meaning "items 0–9
1205
+ * out of 50 total". Consumers opt into totals by sending the
1206
+ * `Prefer: count=exact` request header.
1207
+ *
1208
+ * This strategy expects the consumer to pass the array body as `response`
1209
+ * (or a plain object with `response[options.data]` pointing at the array)
1210
+ * and the response headers via the optional `headers` bag. See
1211
+ * `PaginationService.paginate()` for the call-site shape.
1212
+ *
1213
+ * @see https://postgrest.org/en/stable/references/api/pagination_count.html
1214
+ */
1215
+ class PostgrestResponseStrategy {
1216
+ static _contentRangeHeader = 'Content-Range';
1217
+ static _contentRangeRegex = /^(\d+)-(\d+)\/(\*|\d+)$/;
1218
+ /**
1219
+ * Parse a PostgREST response into a typed PaginatedCollection
1220
+ *
1221
+ * @param response - The raw response. Either the array body directly, or
1222
+ * an object with the array at `response[options.data]`.
1223
+ * @param options - The response key configuration (only `options.data` is
1224
+ * consulted; all pagination metadata comes from the Content-Range header).
1225
+ * @param headers - Optional HTTP response headers. The `Content-Range`
1226
+ * header drives page/total derivation; omission is tolerated and yields
1227
+ * a collection with `undefined` bounds (auto-sync will leave
1228
+ * `isLastPageKnown` at `false`).
1229
+ * @returns A typed PaginatedCollection instance
1230
+ */
1231
+ paginate(response, options, headers) {
1232
+ // Body may be a bare array or an envelope with the array at options.data
1233
+ const data = (Array.isArray(response) ? response : response[options.data]);
1234
+ // Header-driven pagination metadata
1235
+ const contentRange = readHeader(headers, PostgrestResponseStrategy._contentRangeHeader);
1236
+ const { from, to, total } = this._parseContentRange(contentRange);
1237
+ // Per-page can only be derived from the from/to range; fall back to undefined
1238
+ const perPage = (from !== undefined && to !== undefined) ? (to - from + 1) : undefined;
1239
+ // Page is 1-based in ng-qubee state; PostgREST reports 0-based indices
1240
+ const page = (perPage && from !== undefined) ? Math.floor(from / perPage) + 1 : 1;
1241
+ const lastPage = (total !== undefined && perPage) ? Math.ceil(total / perPage) : undefined;
1242
+ // Library convention: from/to are 1-indexed and inclusive; PostgREST emits 0-indexed
1243
+ const fromOneIndexed = from !== undefined ? from + 1 : undefined;
1244
+ const toOneIndexed = to !== undefined ? to + 1 : undefined;
1245
+ // PostgREST does not emit page URLs, so prev/next/first/last URLs stay undefined
1246
+ return new PaginatedCollection(data, page, fromOneIndexed, toOneIndexed, total, perPage, undefined, undefined, lastPage);
1247
+ }
1248
+ /**
1249
+ * Extract `{from, to, total}` from a PostgREST `Content-Range` value
1250
+ *
1251
+ * Expected format: `<from>-<to>/<total|*>`. Any shape mismatch returns
1252
+ * an empty object; `*` as the total yields `total: undefined`.
1253
+ *
1254
+ * @param value - Raw header value (possibly null/undefined)
1255
+ * @returns Parsed integers; missing fields indicate an unparseable header
1256
+ */
1257
+ _parseContentRange(value) {
1258
+ if (!value) {
1259
+ return {};
1260
+ }
1261
+ const match = value.trim().match(PostgrestResponseStrategy._contentRangeRegex);
1262
+ if (!match) {
1263
+ return {};
1264
+ }
1265
+ const from = parseInt(match[1], 10);
1266
+ const to = parseInt(match[2], 10);
1267
+ const total = match[3] === '*' ? undefined : parseInt(match[3], 10);
1268
+ return { from, to, total };
834
1269
  }
1270
+ }
1271
+
1272
+ /**
1273
+ * Request strategy for the Spatie Query Builder driver
1274
+ *
1275
+ * Generates URIs in the Spatie format:
1276
+ * - Fields: `fields[model]=col1,col2`
1277
+ * - Filters: `filter[field]=value`
1278
+ * - Includes: `include=model1,model2`
1279
+ * - Sorts: `sort=-field1,field2` (- prefix = DESC)
1280
+ * - Pagination: `limit=N&page=N`
1281
+ *
1282
+ * @see https://spatie.be/docs/laravel-query-builder
1283
+ */
1284
+ class SpatieRequestStrategy extends AbstractRequestStrategy {
835
1285
  /**
836
- * Add resources to include with the request
837
- * Automatically prevents duplicate includes
1286
+ * Filters, sorts, includes, per-model fields no operators, no flat
1287
+ * select, no global search
1288
+ */
1289
+ capabilities = {
1290
+ fields: true,
1291
+ filters: true,
1292
+ includes: true,
1293
+ operatorFilters: false,
1294
+ search: false,
1295
+ select: false,
1296
+ sort: true
1297
+ };
1298
+ /**
1299
+ * Emit Spatie-format query-string segments in canonical order:
1300
+ * include → fields → filters → limit → page → sort
838
1301
  *
839
- * @param {string[]} includes - Array of resource names to include in the response
840
- * @return {void}
841
- * @example
842
- * service.addIncludes(['profile', 'posts']);
843
- * service.addIncludes(['comments']);
1302
+ * @param state - The current query builder state
1303
+ * @param options - The query parameter key name configuration
1304
+ * @returns Ordered query-string fragments
844
1305
  */
845
- addIncludes(includes) {
846
- this._nest.update(nest => {
847
- // Use Set to prevent duplicates
848
- const uniqueIncludes = Array.from(new Set([...nest.includes, ...includes]));
849
- return {
850
- ...nest,
851
- includes: uniqueIncludes
852
- };
853
- });
1306
+ parts(state, options) {
1307
+ const out = [];
1308
+ this._appendIncludes(state, options, out);
1309
+ this._appendFields(state, options, out);
1310
+ this._appendFilters(state, options, out);
1311
+ this._appendLimit(state, options, out);
1312
+ this._appendPage(state, options, out);
1313
+ this._appendSort(state, options, out);
1314
+ return out;
854
1315
  }
855
1316
  /**
856
- * Add filters with explicit operators (NestJS only)
857
- * Automatically prevents duplicate operator filters for the same field + operator combination
1317
+ * Append per-model field selection in bracket notation
858
1318
  *
859
- * @param {IOperatorFilter[]} filters - Array of operator filter configurations
860
- * @return {void}
861
- * @example
862
- * import { FilterOperatorEnum } from 'ng-qubee';
863
- * service.addOperatorFilters([{ field: 'age', operator: FilterOperatorEnum.GTE, values: [18] }]);
1319
+ * Validates that each field model exists either as the main resource
1320
+ * or in the includes list.
1321
+ *
1322
+ * @param state - The current query builder state
1323
+ * @param options - The query parameter key name configuration
1324
+ * @param out - The accumulator the caller joins into the URI
1325
+ * @throws Error if the resource is required but not set
1326
+ * @throws UnselectableModelError if a field model is not in resource or includes
864
1327
  */
865
- addOperatorFilters(filters) {
866
- this._nest.update(nest => {
867
- const merged = [...nest.operatorFilters];
868
- filters.forEach(newFilter => {
869
- const existingIdx = merged.findIndex(f => f.field === newFilter.field && f.operator === newFilter.operator);
870
- if (existingIdx > -1) {
871
- const existingValues = merged[existingIdx].values;
872
- merged[existingIdx] = {
873
- ...merged[existingIdx],
874
- values: Array.from(new Set([...existingValues, ...newFilter.values]))
875
- };
876
- }
877
- else {
878
- merged.push({ ...newFilter });
879
- }
880
- });
881
- return {
882
- ...nest,
883
- operatorFilters: merged
884
- };
885
- });
1328
+ _appendFields(state, options, out) {
1329
+ if (!Object.keys(state.fields).length) {
1330
+ return;
1331
+ }
1332
+ if (!(state.resource in state.fields)) {
1333
+ throw new Error(`Key ${state.resource} is missing in the fields object`);
1334
+ }
1335
+ const grouped = {};
1336
+ for (const model in state.fields) {
1337
+ if (!state.fields.hasOwnProperty(model)) {
1338
+ continue;
1339
+ }
1340
+ if (model !== state.resource && !state.includes.includes(model)) {
1341
+ throw new UnselectableModelError(model);
1342
+ }
1343
+ grouped[`${options.fields}[${model}]`] = state.fields[model].join(',');
1344
+ }
1345
+ out.push(qs.stringify(grouped, { encode: false }));
886
1346
  }
887
1347
  /**
888
- * Add flat field selection columns (NestJS only)
889
- * Automatically prevents duplicate select fields
1348
+ * Append filter parameters in bracket notation: `filter[key]=value`
890
1349
  *
891
- * @param {string[]} fields - Array of column names to select
892
- * @return {void}
893
- * @example
894
- * service.addSelect(['id', 'name', 'email']);
1350
+ * @param state - The current query builder state
1351
+ * @param options - The query parameter key name configuration
1352
+ * @param out - The accumulator the caller joins into the URI
895
1353
  */
896
- addSelect(fields) {
897
- this._nest.update(nest => {
898
- const uniqueSelect = Array.from(new Set([...nest.select, ...fields]));
899
- return {
900
- ...nest,
901
- select: uniqueSelect
902
- };
903
- });
1354
+ _appendFilters(state, options, out) {
1355
+ const keys = Object.keys(state.filters);
1356
+ if (!keys.length) {
1357
+ return;
1358
+ }
1359
+ const wrapper = {
1360
+ [options.filters]: keys.reduce((acc, key) => {
1361
+ return Object.assign(acc, { [key]: state.filters[key].join(',') });
1362
+ }, {})
1363
+ };
1364
+ out.push(qs.stringify(wrapper, { encode: false }));
904
1365
  }
905
1366
  /**
906
- * Add a field that should be used for sorting data
1367
+ * Append include parameter as `include=model1,model2`
907
1368
  *
908
- * @param {ISort} sort - Sort configuration with field name and order (ASC/DESC)
909
- * @return {void}
910
- * @example
911
- * import { SortEnum } from 'ng-qubee';
912
- * service.addSort({ field: 'created_at', order: SortEnum.DESC });
913
- * service.addSort({ field: 'name', order: SortEnum.ASC });
1369
+ * @param state - The current query builder state
1370
+ * @param options - The query parameter key name configuration
1371
+ * @param out - The accumulator the caller joins into the URI
914
1372
  */
915
- addSort(sort) {
916
- this._nest.update(nest => ({
917
- ...nest,
918
- sorts: [...nest.sorts, sort]
919
- }));
1373
+ _appendIncludes(state, options, out) {
1374
+ if (!state.includes.length) {
1375
+ return;
1376
+ }
1377
+ out.push(`${options.includes}=${state.includes}`);
920
1378
  }
921
1379
  /**
922
- * Remove fields for the given model
923
- * Uses deep cloning to prevent mutations to the original state
1380
+ * Append the limit parameter
924
1381
  *
925
- * @param {IFields} fields - Object mapping model names to arrays of field names to remove
926
- * @return {void}
927
- * @example
928
- * service.deleteFields({ users: ['email'] });
929
- * service.deleteFields({ posts: ['content', 'body'] });
1382
+ * @param state - The current query builder state
1383
+ * @param options - The query parameter key name configuration
1384
+ * @param out - The accumulator the caller joins into the URI
930
1385
  */
931
- deleteFields(fields) {
932
- // Deep clone the fields object to prevent mutations
933
- const f = this._clone(this._nest().fields);
934
- Object.keys(fields).forEach(k => {
935
- if (!(k in f)) {
936
- return;
937
- }
938
- f[k] = f[k].filter(v => !fields[k].includes(v));
939
- });
940
- this._nest.update(nest => ({
941
- ...nest,
942
- fields: f
943
- }));
1386
+ _appendLimit(state, options, out) {
1387
+ out.push(`${options.limit}=${state.limit}`);
944
1388
  }
945
1389
  /**
946
- * Remove filters from the request
947
- * Uses deep cloning to prevent mutations to the original state
1390
+ * Append the page parameter
948
1391
  *
949
- * @param {...string[]} filters - Filter keys to remove
950
- * @return {void}
951
- * @example
952
- * service.deleteFilters('id');
953
- * service.deleteFilters('status', 'type');
1392
+ * @param state - The current query builder state
1393
+ * @param options - The query parameter key name configuration
1394
+ * @param out - The accumulator the caller joins into the URI
954
1395
  */
955
- deleteFilters(...filters) {
956
- // Deep clone the filters object to prevent mutations
957
- const f = this._clone(this._nest().filters);
958
- filters.forEach(k => delete f[k]);
959
- this._nest.update(nest => ({
960
- ...nest,
961
- filters: f
962
- }));
1396
+ _appendPage(state, options, out) {
1397
+ out.push(`${options.page}=${state.page}`);
963
1398
  }
964
1399
  /**
965
- * Remove includes from the request
1400
+ * Append sort parameter as `sort=-field1,field2` (`-` prefix = DESC)
966
1401
  *
967
- * @param {...string[]} includes - Include names to remove
968
- * @return {void}
969
- * @example
970
- * service.deleteIncludes('profile');
971
- * service.deleteIncludes('posts', 'comments');
1402
+ * @param state - The current query builder state
1403
+ * @param options - The query parameter key name configuration
1404
+ * @param out - The accumulator the caller joins into the URI
972
1405
  */
973
- deleteIncludes(...includes) {
974
- this._nest.update(nest => ({
975
- ...nest,
976
- includes: nest.includes.filter(v => !includes.includes(v))
977
- }));
1406
+ _appendSort(state, options, out) {
1407
+ if (!state.sorts.length) {
1408
+ return;
1409
+ }
1410
+ const pairs = state.sorts.map(sort => `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`);
1411
+ out.push(`${options.sort}=${pairs.join(',')}`);
978
1412
  }
1413
+ }
1414
+
1415
+ /**
1416
+ * Response strategy for the Spatie Query Builder driver
1417
+ *
1418
+ * Parses flat Laravel pagination responses:
1419
+ * ```json
1420
+ * {
1421
+ * "data": [...],
1422
+ * "current_page": 1,
1423
+ * "total": 100,
1424
+ * "per_page": 15,
1425
+ * "from": 1,
1426
+ * "to": 15,
1427
+ * ...
1428
+ * }
1429
+ * ```
1430
+ *
1431
+ * @see https://spatie.be/docs/laravel-query-builder
1432
+ */
1433
+ class SpatieResponseStrategy {
979
1434
  /**
980
- * Remove operator filters by field name (NestJS only)
1435
+ * Parse a flat Laravel pagination response into a PaginatedCollection
981
1436
  *
982
- * @param {...string[]} fields - Field names of operator filters to remove
983
- * @return {void}
984
- * @example
985
- * service.deleteOperatorFilters('age');
986
- * service.deleteOperatorFilters('price', 'quantity');
1437
+ * @param response - The raw API response object
1438
+ * @param options - The response key name configuration
1439
+ * @returns A typed PaginatedCollection instance
987
1440
  */
988
- deleteOperatorFilters(...fields) {
989
- this._nest.update(nest => ({
990
- ...nest,
991
- operatorFilters: nest.operatorFilters.filter(f => !fields.includes(f.field))
992
- }));
1441
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1442
+ paginate(response, options) {
1443
+ 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]);
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Driver registry — single source of truth for what each `DriverEnum`
1449
+ * value resolves to
1450
+ *
1451
+ * `Record<DriverEnum, IDriverDefinition>` gives compile-time
1452
+ * exhaustiveness: adding a new value to `DriverEnum` fails to compile
1453
+ * until its definition is added here. `provideNgQubee` looks up the
1454
+ * definition by driver and calls the three factories — no more parallel
1455
+ * `switch` blocks.
1456
+ */
1457
+ const DRIVERS = {
1458
+ [DriverEnum.JSON_API]: {
1459
+ createRequestStrategy: () => new JsonApiRequestStrategy(),
1460
+ createResponseStrategy: () => new JsonApiResponseStrategy(),
1461
+ createResponseOptions: (config) => new JsonApiResponseOptions(config)
1462
+ },
1463
+ [DriverEnum.LARAVEL]: {
1464
+ createRequestStrategy: () => new LaravelRequestStrategy(),
1465
+ createResponseStrategy: () => new LaravelResponseStrategy(),
1466
+ createResponseOptions: (config) => new ResponseOptions(config)
1467
+ },
1468
+ [DriverEnum.NESTJS]: {
1469
+ createRequestStrategy: () => new NestjsRequestStrategy(),
1470
+ createResponseStrategy: () => new NestjsResponseStrategy(),
1471
+ createResponseOptions: (config) => new NestjsResponseOptions(config)
1472
+ },
1473
+ [DriverEnum.POSTGREST]: {
1474
+ createRequestStrategy: (mode) => new PostgrestRequestStrategy(mode),
1475
+ createResponseStrategy: () => new PostgrestResponseStrategy(),
1476
+ createResponseOptions: (config) => new ResponseOptions(config)
1477
+ },
1478
+ [DriverEnum.SPATIE]: {
1479
+ createRequestStrategy: () => new SpatieRequestStrategy(),
1480
+ createResponseStrategy: () => new SpatieResponseStrategy(),
1481
+ createResponseOptions: (config) => new ResponseOptions(config)
1482
+ }
1483
+ };
1484
+
1485
+ /**
1486
+ * Resolved query parameter key names with defaults applied
1487
+ *
1488
+ * Maps logical query concepts to the actual query parameter names
1489
+ * used in the generated URI. Unset values fall back to defaults.
1490
+ */
1491
+ class QueryBuilderOptions {
1492
+ appends;
1493
+ fields;
1494
+ filters;
1495
+ includes;
1496
+ limit;
1497
+ page;
1498
+ search;
1499
+ select;
1500
+ sort;
1501
+ sortBy;
1502
+ constructor(options) {
1503
+ this.appends = options.appends || 'append';
1504
+ this.fields = options.fields || 'fields';
1505
+ this.filters = options.filters || 'filter';
1506
+ this.includes = options.includes || 'include';
1507
+ this.limit = options.limit || 'limit';
1508
+ this.page = options.page || 'page';
1509
+ this.search = options.search || 'search';
1510
+ this.select = options.select || 'select';
1511
+ this.sort = options.sort || 'sort';
1512
+ this.sortBy = options.sortBy || 'sortBy';
1513
+ }
1514
+ }
1515
+
1516
+ /**
1517
+ * Error thrown when an invalid resource name is provided
1518
+ *
1519
+ * Resource name must be a non-empty string.
1520
+ */
1521
+ class InvalidResourceNameError extends Error {
1522
+ constructor(resource) {
1523
+ super(`Invalid resource name: Resource name must be a non-empty string. Received: ${JSON.stringify(resource)}`);
1524
+ this.name = 'InvalidResourceNameError';
1525
+ }
1526
+ }
1527
+
1528
+ class InvalidPageNumberError extends Error {
1529
+ constructor(page) {
1530
+ super(`Invalid page number: Page must be a positive integer greater than 0. Received: ${page}`);
1531
+ this.name = 'InvalidPageNumberError';
1532
+ }
1533
+ }
1534
+
1535
+ const INITIAL_STATE = {
1536
+ baseUrl: '',
1537
+ fields: {},
1538
+ filters: {},
1539
+ includes: [],
1540
+ isLastPageKnown: false,
1541
+ lastPage: 1,
1542
+ limit: 15,
1543
+ operatorFilters: [],
1544
+ page: 1,
1545
+ resource: '',
1546
+ search: '',
1547
+ select: [],
1548
+ sorts: []
1549
+ };
1550
+ class NestService {
1551
+ /**
1552
+ * Private writable signal that holds the Query Builder state
1553
+ *
1554
+ * @type {IQueryBuilderState}
1555
+ */
1556
+ _nest = signal(this._clone(INITIAL_STATE), ...(ngDevMode ? [{ debugName: "_nest" }] : []));
1557
+ /**
1558
+ * A computed signal that makes readonly the writable signal _nest
1559
+ *
1560
+ * @type {Signal<IQueryBuilderState>}
1561
+ */
1562
+ nest = computed(() => this._clone(this._nest()), ...(ngDevMode ? [{ debugName: "nest" }] : []));
1563
+ constructor() {
1564
+ // Nothing to see here
993
1565
  }
994
1566
  /**
995
- * Remove the search term from the state (NestJS only)
1567
+ * Set the base URL for the API
996
1568
  *
997
- * @return {void}
1569
+ * @param {string} baseUrl - The base URL to prepend to generated URIs
998
1570
  * @example
999
- * service.deleteSearch();
1571
+ * service.baseUrl = 'https://api.example.com';
1000
1572
  */
1001
- deleteSearch() {
1573
+ set baseUrl(baseUrl) {
1002
1574
  this._nest.update(nest => ({
1003
1575
  ...nest,
1004
- search: ''
1576
+ baseUrl
1005
1577
  }));
1006
1578
  }
1007
1579
  /**
1008
- * Remove flat field selections from the state (NestJS only)
1580
+ * Set the limit for paginated results
1009
1581
  *
1010
- * @param {...string[]} fields - Field names to remove from selection
1011
- * @return {void}
1582
+ * This setter performs a raw state write. Validation of the value is the
1583
+ * responsibility of the active request strategy and is enforced upstream
1584
+ * by `NgQubeeService.setLimit()`, because the accepted range depends on
1585
+ * the driver (e.g. nestjs-paginate accepts `-1` for "fetch all").
1586
+ *
1587
+ * @param {number} limit - The number of items per page
1012
1588
  * @example
1013
- * service.deleteSelect('email');
1014
- * service.deleteSelect('name', 'email');
1589
+ * service.limit = 25;
1015
1590
  */
1016
- deleteSelect(...fields) {
1591
+ set limit(limit) {
1017
1592
  this._nest.update(nest => ({
1018
1593
  ...nest,
1019
- select: nest.select.filter(f => !fields.includes(f))
1594
+ limit
1020
1595
  }));
1021
1596
  }
1022
1597
  /**
1023
- * Remove sorts from the request by field name
1598
+ * Set the page number for pagination
1599
+ * Must be a positive integer greater than 0
1024
1600
  *
1025
- * @param {...string[]} sorts - Field names of sorts to remove
1026
- * @return {void}
1601
+ * @param {number} page - The page number to fetch
1602
+ * @throws {InvalidPageNumberError} If page is not a positive integer
1027
1603
  * @example
1028
- * service.deleteSorts('created_at');
1029
- * service.deleteSorts('name', 'created_at');
1604
+ * service.page = 2;
1030
1605
  */
1031
- deleteSorts(...sorts) {
1032
- const s = [...this._nest().sorts];
1033
- sorts.forEach(field => {
1034
- const p = s.findIndex(sort => sort.field === field);
1035
- if (p > -1) {
1036
- s.splice(p, 1);
1037
- }
1038
- });
1606
+ set page(page) {
1607
+ this._validatePageNumber(page);
1039
1608
  this._nest.update(nest => ({
1040
1609
  ...nest,
1041
- sorts: s
1610
+ page
1042
1611
  }));
1043
1612
  }
1044
1613
  /**
1045
- * Set the full-text search term (NestJS only)
1614
+ * Set the resource name for the query
1615
+ * Must be a non-empty string
1046
1616
  *
1047
- * @param {string} search - The search term
1048
- * @return {void}
1617
+ * @param {string} resource - The API resource name (e.g., 'users', 'posts')
1618
+ * @throws {InvalidResourceNameError} If resource is not a non-empty string
1049
1619
  * @example
1050
- * service.setSearch('john doe');
1620
+ * service.resource = 'users';
1051
1621
  */
1052
- setSearch(search) {
1622
+ set resource(resource) {
1623
+ this._validateResourceName(resource);
1053
1624
  this._nest.update(nest => ({
1054
1625
  ...nest,
1055
- search
1626
+ resource
1056
1627
  }));
1057
1628
  }
1629
+ _clone(obj) {
1630
+ return JSON.parse(JSON.stringify(obj));
1631
+ }
1058
1632
  /**
1059
- * Reset the query builder state to initial values
1060
- * Clears all fields, filters, includes, sorts, and resets pagination
1633
+ * Validates that the page number is a positive integer
1061
1634
  *
1062
- * @return {void}
1063
- * @example
1064
- * service.reset();
1635
+ * @param {number} page - The page number to validate
1636
+ * @throws {InvalidPageNumberError} If page is not a positive integer
1637
+ * @private
1065
1638
  */
1066
- reset() {
1067
- this._nest.update(_ => this._clone(INITIAL_STATE));
1639
+ _validatePageNumber(page) {
1640
+ if (!Number.isInteger(page) || page < 1) {
1641
+ throw new InvalidPageNumberError(page);
1642
+ }
1068
1643
  }
1069
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1070
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService });
1071
- }
1072
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, decorators: [{
1073
- type: Injectable
1074
- }], ctorParameters: () => [] });
1075
-
1076
- class PaginationService {
1077
- /**
1078
- * Resolved response key name options
1079
- */
1080
- _options;
1081
1644
  /**
1082
- * The response strategy that parses responses for the active driver
1645
+ * Validates that the resource name is a non-empty string
1646
+ *
1647
+ * @param {string} resource - The resource name to validate
1648
+ * @throws {InvalidResourceNameError} If resource is not a non-empty string
1649
+ * @private
1083
1650
  */
1084
- _responseStrategy;
1085
- constructor(responseStrategy, options = {}) {
1086
- this._options = new ResponseOptions(options);
1087
- this._responseStrategy = responseStrategy;
1651
+ _validateResourceName(resource) {
1652
+ if (!resource || typeof resource !== 'string' || resource.trim().length === 0) {
1653
+ throw new InvalidResourceNameError(resource);
1654
+ }
1088
1655
  }
1089
1656
  /**
1090
- * Transform a raw API response into a typed PaginatedCollection
1657
+ * Add selectable fields for the given model to the request
1658
+ * Automatically prevents duplicate fields for each model
1091
1659
  *
1092
- * Delegates to the active driver's response strategy for parsing.
1660
+ * @param {IFields} fields - Object mapping model names to arrays of field names
1661
+ * @return {void}
1662
+ * @example
1663
+ * service.addFields({ users: ['id', 'email', 'username'] });
1664
+ * service.addFields({ posts: ['title', 'content'] });
1665
+ */
1666
+ addFields(fields) {
1667
+ this._nest.update(nest => {
1668
+ const mergedFields = { ...nest.fields };
1669
+ Object.keys(fields).forEach(model => {
1670
+ const existingFields = mergedFields[model] || [];
1671
+ const newFields = fields[model];
1672
+ // Use Set to prevent duplicates
1673
+ const uniqueFields = Array.from(new Set([...existingFields, ...newFields]));
1674
+ mergedFields[model] = uniqueFields;
1675
+ });
1676
+ return {
1677
+ ...nest,
1678
+ fields: mergedFields
1679
+ };
1680
+ });
1681
+ }
1682
+ /**
1683
+ * Add filters to the request
1684
+ * Automatically prevents duplicate filter values for each filter key
1093
1685
  *
1094
- * @param response - The raw API response object
1095
- * @returns A typed PaginatedCollection instance
1686
+ * @param {IFilters} filters - Object mapping filter keys to arrays of values
1687
+ * @return {void}
1688
+ * @example
1689
+ * service.addFilters({ id: [1, 2, 3] });
1690
+ * service.addFilters({ status: ['active', 'pending'] });
1096
1691
  */
1097
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1098
- paginate(response) {
1099
- return this._responseStrategy.paginate(response, this._options);
1692
+ addFilters(filters) {
1693
+ this._nest.update(nest => {
1694
+ const mergedFilters = { ...nest.filters };
1695
+ Object.keys(filters).forEach(key => {
1696
+ const existingValues = mergedFilters[key] || [];
1697
+ const newValues = filters[key];
1698
+ // Use Set to prevent duplicates
1699
+ const uniqueValues = Array.from(new Set([...existingValues, ...newValues]));
1700
+ mergedFilters[key] = uniqueValues;
1701
+ });
1702
+ return {
1703
+ ...nest,
1704
+ filters: mergedFilters
1705
+ };
1706
+ });
1100
1707
  }
1101
- }
1102
-
1103
- var SortEnum;
1104
- (function (SortEnum) {
1105
- SortEnum["ASC"] = "asc";
1106
- SortEnum["DESC"] = "desc";
1107
- })(SortEnum || (SortEnum = {}));
1108
-
1109
- /**
1110
- * Thrown when a limit value does not satisfy the active driver's constraints
1111
- *
1112
- * Validation is driver-scoped: most drivers require an integer `>= 1`, while
1113
- * the NestJS driver additionally accepts `-1` as a "fetch all items" sentinel
1114
- * (as documented by nestjs-paginate). The message is tailored accordingly so
1115
- * the caller understands which values are permitted.
1116
- */
1117
- class InvalidLimitError extends Error {
1118
1708
  /**
1119
- * @param limit - The rejected limit value
1120
- * @param allowFetchAll - Whether the active driver accepts `-1` (fetch all)
1709
+ * Add resources to include with the request
1710
+ * Automatically prevents duplicate includes
1711
+ *
1712
+ * @param {string[]} includes - Array of resource names to include in the response
1713
+ * @return {void}
1714
+ * @example
1715
+ * service.addIncludes(['profile', 'posts']);
1716
+ * service.addIncludes(['comments']);
1121
1717
  */
1122
- constructor(limit, allowFetchAll = false) {
1123
- const allowed = allowFetchAll
1124
- ? 'a positive integer greater than 0, or -1 to fetch all items'
1125
- : 'a positive integer greater than 0';
1126
- super(`Invalid limit value: Limit must be ${allowed}. Received: ${limit}`);
1127
- this.name = 'InvalidLimitError';
1718
+ addIncludes(includes) {
1719
+ this._nest.update(nest => {
1720
+ // Use Set to prevent duplicates
1721
+ const uniqueIncludes = Array.from(new Set([...nest.includes, ...includes]));
1722
+ return {
1723
+ ...nest,
1724
+ includes: uniqueIncludes
1725
+ };
1726
+ });
1128
1727
  }
1129
- }
1130
-
1131
- class UnselectableModelError extends Error {
1132
- constructor(model) {
1133
- super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
1728
+ /**
1729
+ * Add filters with explicit operators (NestJS only)
1730
+ * Automatically prevents duplicate operator filters for the same field + operator combination
1731
+ *
1732
+ * @param {IOperatorFilter[]} filters - Array of operator filter configurations
1733
+ * @return {void}
1734
+ * @example
1735
+ * import { FilterOperatorEnum } from 'ng-qubee';
1736
+ * service.addOperatorFilters([{ field: 'age', operator: FilterOperatorEnum.GTE, values: [18] }]);
1737
+ */
1738
+ addOperatorFilters(filters) {
1739
+ this._nest.update(nest => {
1740
+ const merged = [...nest.operatorFilters];
1741
+ filters.forEach(newFilter => {
1742
+ const existingIdx = merged.findIndex(f => f.field === newFilter.field && f.operator === newFilter.operator);
1743
+ if (existingIdx > -1) {
1744
+ const existingValues = merged[existingIdx].values;
1745
+ merged[existingIdx] = {
1746
+ ...merged[existingIdx],
1747
+ values: Array.from(new Set([...existingValues, ...newFilter.values]))
1748
+ };
1749
+ }
1750
+ else {
1751
+ merged.push({ ...newFilter });
1752
+ }
1753
+ });
1754
+ return {
1755
+ ...nest,
1756
+ operatorFilters: merged
1757
+ };
1758
+ });
1134
1759
  }
1135
- }
1136
-
1137
- /**
1138
- * Request strategy for the JSON:API driver
1139
- *
1140
- * Generates URIs in the JSON:API format:
1141
- * - Fields: `fields[articles]=title,body&fields[people]=name`
1142
- * - Filters: `filter[status]=active`
1143
- * - Includes: `include=author,comments.author`
1144
- * - Pagination: `page[number]=1&page[size]=15`
1145
- * - Sort: `sort=-created_at,name` (- prefix = DESC)
1146
- *
1147
- * @see https://jsonapi.org/format/
1148
- */
1149
- class JsonApiRequestStrategy {
1150
1760
  /**
1151
- * Accumulator for composing the URI string
1761
+ * Add flat field selection columns (NestJS only)
1762
+ * Automatically prevents duplicate select fields
1763
+ *
1764
+ * @param {string[]} fields - Array of column names to select
1765
+ * @return {void}
1766
+ * @example
1767
+ * service.addSelect(['id', 'name', 'email']);
1152
1768
  */
1153
- _uri = '';
1769
+ addSelect(fields) {
1770
+ this._nest.update(nest => {
1771
+ const uniqueSelect = Array.from(new Set([...nest.select, ...fields]));
1772
+ return {
1773
+ ...nest,
1774
+ select: uniqueSelect
1775
+ };
1776
+ });
1777
+ }
1154
1778
  /**
1155
- * Build a URI string from the given state using the JSON:API format
1779
+ * Add a field that should be used for sorting data
1156
1780
  *
1157
- * @param state - The current query builder state
1158
- * @param options - The query parameter key name configuration
1159
- * @returns The composed URI string
1160
- * @throws Error if resource is not set
1781
+ * @param {ISort} sort - Sort configuration with field name and order (ASC/DESC)
1782
+ * @return {void}
1783
+ * @example
1784
+ * import { SortEnum } from 'ng-qubee';
1785
+ * service.addSort({ field: 'created_at', order: SortEnum.DESC });
1786
+ * service.addSort({ field: 'name', order: SortEnum.ASC });
1161
1787
  */
1162
- buildUri(state, options) {
1163
- if (!state.resource) {
1164
- throw new Error('Set the resource property BEFORE adding filters or calling the url() / get() methods');
1165
- }
1166
- this._uri = '';
1167
- this._parseIncludes(state, options);
1168
- this._parseFields(state, options);
1169
- this._parseFilters(state, options);
1170
- this._parsePagination(state, options);
1171
- this._parseSort(state, options);
1172
- return this._uri;
1788
+ addSort(sort) {
1789
+ this._nest.update(nest => ({
1790
+ ...nest,
1791
+ sorts: [...nest.sorts, sort]
1792
+ }));
1173
1793
  }
1174
1794
  /**
1175
- * Validate that the given limit is accepted by the JSON:API driver
1795
+ * Remove fields for the given model
1796
+ * Uses deep cloning to prevent mutations to the original state
1176
1797
  *
1177
- * The JSON:API specification leaves pagination semantics to the server and
1178
- * does not define a "fetch all" sentinel, so only positive integers are
1179
- * accepted.
1798
+ * @param {IFields} fields - Object mapping model names to arrays of field names to remove
1799
+ * @return {void}
1800
+ * @example
1801
+ * service.deleteFields({ users: ['email'] });
1802
+ * service.deleteFields({ posts: ['content', 'body'] });
1803
+ */
1804
+ deleteFields(fields) {
1805
+ // Deep clone the fields object to prevent mutations
1806
+ const f = this._clone(this._nest().fields);
1807
+ Object.keys(fields).forEach(k => {
1808
+ if (!(k in f)) {
1809
+ return;
1810
+ }
1811
+ f[k] = f[k].filter(v => !fields[k].includes(v));
1812
+ });
1813
+ this._nest.update(nest => ({
1814
+ ...nest,
1815
+ fields: f
1816
+ }));
1817
+ }
1818
+ /**
1819
+ * Remove filters from the request
1820
+ * Uses deep cloning to prevent mutations to the original state
1180
1821
  *
1181
- * @param limit - The limit value to validate
1182
- * @throws {InvalidLimitError} If the value is not a positive integer
1822
+ * @param {...string[]} filters - Filter keys to remove
1823
+ * @return {void}
1824
+ * @example
1825
+ * service.deleteFilters('id');
1826
+ * service.deleteFilters('status', 'type');
1183
1827
  */
1184
- validateLimit(limit) {
1185
- if (Number.isInteger(limit) && limit >= 1) {
1186
- return;
1187
- }
1188
- throw new InvalidLimitError(limit);
1828
+ deleteFilters(...filters) {
1829
+ // Deep clone the filters object to prevent mutations
1830
+ const f = this._clone(this._nest().filters);
1831
+ filters.forEach(k => delete f[k]);
1832
+ this._nest.update(nest => ({
1833
+ ...nest,
1834
+ filters: f
1835
+ }));
1189
1836
  }
1190
1837
  /**
1191
- * Parse and append field selection parameters
1838
+ * Remove includes from the request
1192
1839
  *
1193
- * Validates that each field model exists either as the main resource
1194
- * or in the includes list. Fields are grouped by type in bracket notation.
1840
+ * @param {...string[]} includes - Include names to remove
1841
+ * @return {void}
1842
+ * @example
1843
+ * service.deleteIncludes('profile');
1844
+ * service.deleteIncludes('posts', 'comments');
1845
+ */
1846
+ deleteIncludes(...includes) {
1847
+ this._nest.update(nest => ({
1848
+ ...nest,
1849
+ includes: nest.includes.filter(v => !includes.includes(v))
1850
+ }));
1851
+ }
1852
+ /**
1853
+ * Remove operator filters by field name (NestJS only)
1195
1854
  *
1196
- * @param state - The current query builder state
1197
- * @param options - The query parameter key name configuration
1198
- * @returns The generated field selection parameter string
1199
- * @throws Error if resource is required but not set
1200
- * @throws UnselectableModelError if a field model is not in resource or includes
1855
+ * @param {...string[]} fields - Field names of operator filters to remove
1856
+ * @return {void}
1857
+ * @example
1858
+ * service.deleteOperatorFilters('age');
1859
+ * service.deleteOperatorFilters('price', 'quantity');
1201
1860
  */
1202
- _parseFields(state, options) {
1203
- if (!Object.keys(state.fields).length) {
1204
- return this._uri;
1205
- }
1206
- if (!state.resource) {
1207
- throw new Error('While selecting fields, the -> resource <- is required');
1208
- }
1209
- if (!(state.resource in state.fields)) {
1210
- throw new Error(`Key ${state.resource} is missing in the fields object`);
1211
- }
1212
- const f = {};
1213
- for (const k in state.fields) {
1214
- if (state.fields.hasOwnProperty(k)) {
1215
- if (k !== state.resource && !state.includes.includes(k)) {
1216
- throw new UnselectableModelError(k);
1217
- }
1218
- Object.assign(f, { [`${options.fields}[${k}]`]: state.fields[k].join(',') });
1219
- }
1220
- }
1221
- const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1222
- this._uri += param;
1223
- return param;
1861
+ deleteOperatorFilters(...fields) {
1862
+ this._nest.update(nest => ({
1863
+ ...nest,
1864
+ operatorFilters: nest.operatorFilters.filter(f => !fields.includes(f.field))
1865
+ }));
1224
1866
  }
1225
1867
  /**
1226
- * Parse and append filter parameters
1868
+ * Remove the search term from the state (NestJS only)
1227
1869
  *
1228
- * Generates filter parameters in bracket notation: `filter[key]=value1,value2`
1870
+ * @return {void}
1871
+ * @example
1872
+ * service.deleteSearch();
1873
+ */
1874
+ deleteSearch() {
1875
+ this._nest.update(nest => ({
1876
+ ...nest,
1877
+ search: ''
1878
+ }));
1879
+ }
1880
+ /**
1881
+ * Remove flat field selections from the state (NestJS only)
1229
1882
  *
1230
- * @param state - The current query builder state
1231
- * @param options - The query parameter key name configuration
1232
- * @returns The generated filter parameter string
1883
+ * @param {...string[]} fields - Field names to remove from selection
1884
+ * @return {void}
1885
+ * @example
1886
+ * service.deleteSelect('email');
1887
+ * service.deleteSelect('name', 'email');
1233
1888
  */
1234
- _parseFilters(state, options) {
1235
- const keys = Object.keys(state.filters);
1236
- if (!keys.length) {
1237
- return this._uri;
1238
- }
1239
- const f = {
1240
- [`${options.filters}`]: keys.reduce((acc, key) => {
1241
- return Object.assign(acc, { [key]: state.filters[key].join(',') });
1242
- }, {})
1243
- };
1244
- const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1245
- this._uri += param;
1246
- return param;
1889
+ deleteSelect(...fields) {
1890
+ this._nest.update(nest => ({
1891
+ ...nest,
1892
+ select: nest.select.filter(f => !fields.includes(f))
1893
+ }));
1247
1894
  }
1248
1895
  /**
1249
- * Parse and append include parameters
1896
+ * Remove sorts from the request by field name
1250
1897
  *
1251
- * Generates: `include=author,comments.author`
1898
+ * @param {...string[]} sorts - Field names of sorts to remove
1899
+ * @return {void}
1900
+ * @example
1901
+ * service.deleteSorts('created_at');
1902
+ * service.deleteSorts('name', 'created_at');
1903
+ */
1904
+ deleteSorts(...sorts) {
1905
+ const s = [...this._nest().sorts];
1906
+ sorts.forEach(field => {
1907
+ const p = s.findIndex(sort => sort.field === field);
1908
+ if (p > -1) {
1909
+ s.splice(p, 1);
1910
+ }
1911
+ });
1912
+ this._nest.update(nest => ({
1913
+ ...nest,
1914
+ sorts: s
1915
+ }));
1916
+ }
1917
+ /**
1918
+ * Set the full-text search term (NestJS only)
1252
1919
  *
1253
- * @param state - The current query builder state
1254
- * @param options - The query parameter key name configuration
1255
- * @returns The generated include parameter string
1920
+ * @param {string} search - The search term
1921
+ * @return {void}
1922
+ * @example
1923
+ * service.setSearch('john doe');
1256
1924
  */
1257
- _parseIncludes(state, options) {
1258
- if (!state.includes.length) {
1259
- return this._uri;
1260
- }
1261
- const param = `${this._prepend(state)}${options.includes}=${state.includes}`;
1262
- this._uri += param;
1263
- return param;
1925
+ setSearch(search) {
1926
+ this._nest.update(nest => ({
1927
+ ...nest,
1928
+ search
1929
+ }));
1930
+ }
1931
+ /**
1932
+ * Atomically record the `lastPage` value from a paginated response and
1933
+ * flip `isLastPageKnown` to `true`
1934
+ *
1935
+ * Called exclusively by `PaginationService.paginate()` as part of the
1936
+ * auto-sync contract; not intended to be invoked by consumers directly.
1937
+ * Keeping the two fields under a single write guarantees they cannot
1938
+ * drift out of sync.
1939
+ *
1940
+ * @param {number} lastPage - The last page number parsed from the most recent paginated response
1941
+ * @return {void}
1942
+ */
1943
+ syncLastPage(lastPage) {
1944
+ this._nest.update(nest => ({
1945
+ ...nest,
1946
+ isLastPageKnown: true,
1947
+ lastPage
1948
+ }));
1949
+ }
1950
+ /**
1951
+ * Reset the query builder state to initial values
1952
+ * Clears all fields, filters, includes, sorts, and resets pagination
1953
+ *
1954
+ * @return {void}
1955
+ * @example
1956
+ * service.reset();
1957
+ */
1958
+ reset() {
1959
+ this._nest.update(_ => this._clone(INITIAL_STATE));
1960
+ }
1961
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1962
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService });
1963
+ }
1964
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, decorators: [{
1965
+ type: Injectable
1966
+ }], ctorParameters: () => [] });
1967
+
1968
+ /**
1969
+ * Thrown when a pagination helper that needs `state.lastPage` is called
1970
+ * before `PaginationService.paginate()` has ever synced a value.
1971
+ *
1972
+ * Examples: `NgQubeeService.lastPage()`, `NgQubeeService.totalPages()`.
1973
+ *
1974
+ * Safe-for-templates predicates (`isLastPage`, `hasNextPage`, etc.) do not
1975
+ * throw and return conservative defaults instead.
1976
+ */
1977
+ class PaginationNotSyncedError extends Error {
1978
+ /**
1979
+ * @param action - Short imperative describing what the caller was trying
1980
+ * to do (e.g. "navigate to last page", "read totalPages"). Surfaced in
1981
+ * the error message so the cause is obvious at the call site.
1982
+ */
1983
+ constructor(action) {
1984
+ super(`Cannot ${action}: no paginated response has been synced yet. Call PaginationService.paginate() at least once first.`);
1985
+ this.name = 'PaginationNotSyncedError';
1986
+ }
1987
+ }
1988
+
1989
+ /**
1990
+ * Error thrown when per-model field selection is attempted with a driver that does not support it
1991
+ *
1992
+ * Per-model field selection is only supported by the Spatie driver.
1993
+ * Use `addSelect()` for NestJS flat field selection.
1994
+ */
1995
+ class UnsupportedFieldSelectionError extends Error {
1996
+ constructor() {
1997
+ super('Per-model field selection is only supported by the Spatie driver. Use addSelect() for NestJS.');
1998
+ this.name = 'UnsupportedFieldSelectionError';
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Error thrown when filters are attempted with a driver that does not support them
2004
+ *
2005
+ * Filters are only supported by the Spatie and NestJS drivers.
2006
+ */
2007
+ class UnsupportedFilterError extends Error {
2008
+ constructor() {
2009
+ super('Filters are only supported by the Spatie and NestJS drivers.');
2010
+ this.name = 'UnsupportedFilterError';
2011
+ }
2012
+ }
2013
+
2014
+ /**
2015
+ * Error thrown when filter operators are attempted with a driver that does not support them
2016
+ *
2017
+ * Filter operators are only supported by the NestJS driver.
2018
+ * Use `addFilter()` for Spatie implicit equality filters.
2019
+ */
2020
+ class UnsupportedFilterOperatorError extends Error {
2021
+ constructor() {
2022
+ super('Filter operators are only supported by the NestJS driver. Use addFilter() for Spatie.');
2023
+ this.name = 'UnsupportedFilterOperatorError';
2024
+ }
2025
+ }
2026
+
2027
+ /**
2028
+ * Error thrown when includes are attempted with a driver that does not support them
2029
+ *
2030
+ * Includes are only supported by the Spatie driver.
2031
+ */
2032
+ class UnsupportedIncludesError extends Error {
2033
+ constructor() {
2034
+ super('Includes are only supported by the Spatie driver.');
2035
+ this.name = 'UnsupportedIncludesError';
1264
2036
  }
1265
- /**
1266
- * Parse and append pagination parameters in JSON:API bracket notation
1267
- *
1268
- * Generates: `page[number]=1&page[size]=15`
1269
- *
1270
- * @param state - The current query builder state
1271
- * @param options - The query parameter key name configuration
1272
- * @returns The generated pagination parameter string
1273
- */
1274
- _parsePagination(state, options) {
1275
- const pagination = qs.stringify({ [options.page]: { number: state.page, size: state.limit } }, { encode: false });
1276
- const param = `${this._prepend(state)}${pagination}`;
1277
- this._uri += param;
1278
- return param;
2037
+ }
2038
+
2039
+ /**
2040
+ * Error thrown when search is attempted with a driver that does not support it
2041
+ *
2042
+ * Search is only supported by the NestJS driver.
2043
+ */
2044
+ class UnsupportedSearchError extends Error {
2045
+ constructor() {
2046
+ super('Search is only supported by the NestJS driver.');
2047
+ this.name = 'UnsupportedSearchError';
1279
2048
  }
1280
- /**
1281
- * Parse and append sort parameters
1282
- *
1283
- * Generates: `sort=-field1,field2` where `-` prefix indicates DESC order
1284
- *
1285
- * @param state - The current query builder state
1286
- * @param options - The query parameter key name configuration
1287
- * @returns The generated sort parameter string
1288
- */
1289
- _parseSort(state, options) {
1290
- let param = '';
1291
- if (!state.sorts.length) {
1292
- return param;
1293
- }
1294
- param = `${this._prepend(state)}${options.sort}=`;
1295
- state.sorts.forEach((sort, idx) => {
1296
- param += `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`;
1297
- if (idx < state.sorts.length - 1) {
1298
- param += ',';
1299
- }
1300
- });
1301
- this._uri += param;
1302
- return param;
2049
+ }
2050
+
2051
+ /**
2052
+ * Error thrown when flat field selection is attempted with a driver that does not support it
2053
+ *
2054
+ * Flat field selection is only supported by the NestJS driver.
2055
+ * Use `addFields()` for Spatie per-model field selection.
2056
+ */
2057
+ class UnsupportedSelectError extends Error {
2058
+ constructor() {
2059
+ super('Flat field selection is only supported by the NestJS driver. Use addFields() for Spatie.');
2060
+ this.name = 'UnsupportedSelectError';
1303
2061
  }
1304
- /**
1305
- * Determine the appropriate URI prefix based on the current accumulator state
1306
- *
1307
- * Returns the full base path with `?` for the first parameter,
1308
- * or `&` for subsequent parameters.
1309
- *
1310
- * @param state - The current query builder state
1311
- * @returns The prefix string to prepend to the next parameter
1312
- */
1313
- _prepend(state) {
1314
- if (this._uri) {
1315
- return '&';
1316
- }
1317
- return state.baseUrl ? `${state.baseUrl}/${state.resource}?` : `/${state.resource}?`;
2062
+ }
2063
+
2064
+ /**
2065
+ * Error thrown when sorts are attempted with a driver that does not support them
2066
+ *
2067
+ * Sorts are only supported by the Spatie and NestJS drivers.
2068
+ */
2069
+ class UnsupportedSortError extends Error {
2070
+ constructor() {
2071
+ super('Sorts are only supported by the Spatie and NestJS drivers.');
2072
+ this.name = 'UnsupportedSortError';
1318
2073
  }
1319
2074
  }
1320
2075
 
1321
2076
  /**
1322
- * Response strategy for the JSON:API driver
2077
+ * Injection token for the active pagination driver
1323
2078
  *
1324
- * Parses JSON:API pagination responses:
1325
- * ```json
1326
- * {
1327
- * "data": [...],
1328
- * "meta": {
1329
- * "current-page": 1,
1330
- * "per-page": 10,
1331
- * "total": 100,
1332
- * "page-count": 10,
1333
- * "from": 1,
1334
- * "to": 10
1335
- * },
1336
- * "links": {
1337
- * "first": "url",
1338
- * "prev": "url",
1339
- * "next": "url",
1340
- * "last": "url"
1341
- * }
1342
- * }
1343
- * ```
2079
+ * Provided by `provideNgQubee()` / `NgQubeeModule.forRoot()` from the
2080
+ * user-supplied `IConfig.driver`. Services read it to gate driver-specific
2081
+ * behavior (e.g. `NgQubeeService._assertDriver`).
2082
+ */
2083
+ const NG_QUBEE_DRIVER = new InjectionToken('NG_QUBEE_DRIVER');
2084
+ /**
2085
+ * Injection token for the resolved request URI strategy
1344
2086
  *
1345
- * @see https://jsonapi.org/format/
2087
+ * Provided by `provideNgQubee()` / `NgQubeeModule.forRoot()` based on the
2088
+ * active driver. Used by `NgQubeeService` to build request URIs.
2089
+ */
2090
+ const NG_QUBEE_REQUEST_STRATEGY = new InjectionToken('NG_QUBEE_REQUEST_STRATEGY');
2091
+ /**
2092
+ * Injection token for the resolved request query-parameter key options
2093
+ *
2094
+ * Provided as a fully-built `QueryBuilderOptions` instance. `provideNgQubee()`
2095
+ * constructs it from `IConfig.request`; consumers don't interact with this
2096
+ * token directly.
2097
+ */
2098
+ const NG_QUBEE_REQUEST_OPTIONS = new InjectionToken('NG_QUBEE_REQUEST_OPTIONS');
2099
+ /**
2100
+ * Injection token for the resolved response parsing strategy
2101
+ *
2102
+ * Provided by `provideNgQubee()` / `NgQubeeModule.forRoot()` based on the
2103
+ * active driver. Used by `PaginationService` to parse paginated responses.
2104
+ */
2105
+ const NG_QUBEE_RESPONSE_STRATEGY = new InjectionToken('NG_QUBEE_RESPONSE_STRATEGY');
2106
+ /**
2107
+ * Injection token for the resolved response field-key options
2108
+ *
2109
+ * Provided as a fully-built `ResponseOptions` instance (or a driver-specific
2110
+ * subclass like `JsonApiResponseOptions` / `NestjsResponseOptions`).
2111
+ * `provideNgQubee()` constructs the correct variant from `IConfig.response`.
1346
2112
  */
1347
- class JsonApiResponseStrategy {
2113
+ const NG_QUBEE_RESPONSE_OPTIONS = new InjectionToken('NG_QUBEE_RESPONSE_OPTIONS');
2114
+
2115
+ class NgQubeeService {
2116
+ _nestService;
1348
2117
  /**
1349
- * Parse a JSON:API pagination response into a PaginatedCollection
1350
- *
1351
- * Supports dot-notation key paths for accessing nested values.
1352
- * Computes `from` and `to` from `currentPage` and `perPage` when
1353
- * they are not directly available in the response.
1354
- *
1355
- * @param response - The raw API response object
1356
- * @param options - The response key name configuration
1357
- * @returns A typed PaginatedCollection instance
2118
+ * The active pagination driver
1358
2119
  */
1359
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1360
- paginate(response, options) {
1361
- const data = this._resolve(response, options.data);
1362
- const currentPage = this._resolve(response, options.currentPage);
1363
- const total = this._resolve(response, options.total);
1364
- const perPage = this._resolve(response, options.perPage);
1365
- const lastPage = this._resolve(response, options.lastPage);
1366
- // Compute from/to if not directly available
1367
- const from = this._resolveFrom(response, options, currentPage, perPage);
1368
- const to = this._resolveTo(response, options, currentPage, perPage, total);
1369
- const prevPageUrl = this._resolve(response, options.prevPageUrl);
1370
- const nextPageUrl = this._resolve(response, options.nextPageUrl);
1371
- const firstPageUrl = this._resolve(response, options.firstPageUrl);
1372
- const lastPageUrl = this._resolve(response, options.lastPageUrl);
1373
- return new PaginatedCollection(data, currentPage, from, to, total, perPage, prevPageUrl, nextPageUrl, lastPage, firstPageUrl, lastPageUrl);
1374
- }
2120
+ _driver;
1375
2121
  /**
1376
- * Resolve a value from a response object using a dot-notation path
1377
- *
1378
- * Supports both flat keys ('data') and nested paths ('meta.current-page').
1379
- *
1380
- * @param response - The raw response object
1381
- * @param path - The dot-notation path to resolve
1382
- * @returns The resolved value, or undefined if not found
2122
+ * Resolved query parameter key name options
1383
2123
  */
1384
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1385
- _resolve(response, path) {
1386
- return path.split('.').reduce((obj, key) => obj?.[key], response);
1387
- }
2124
+ _options;
1388
2125
  /**
1389
- * Resolve the "from" index value
1390
- *
1391
- * If the path resolves to a value in the response, use it.
1392
- * Otherwise, compute it from currentPage and perPage:
1393
- * `(currentPage - 1) * perPage + 1`
1394
- *
1395
- * @param response - The raw response object
1396
- * @param options - The response key name configuration
1397
- * @param currentPage - The current page number
1398
- * @param perPage - The number of items per page
1399
- * @returns The computed "from" index
2126
+ * The request strategy that builds URIs for the active driver
1400
2127
  */
1401
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1402
- _resolveFrom(response, options, currentPage, perPage) {
1403
- const direct = this._resolve(response, options.from);
1404
- if (direct !== undefined) {
1405
- return direct;
1406
- }
1407
- if (currentPage && perPage) {
1408
- return (currentPage - 1) * perPage + 1;
1409
- }
1410
- return undefined;
2128
+ _requestStrategy;
2129
+ /**
2130
+ * Internal BehaviorSubject that holds the latest generated URI
2131
+ */
2132
+ _uri$ = new BehaviorSubject('');
2133
+ /**
2134
+ * Observable that emits non-empty generated URIs
2135
+ */
2136
+ uri$ = this._uri$.asObservable().pipe(filter(uri => !!uri));
2137
+ constructor(_nestService, requestStrategy, driver, options = new QueryBuilderOptions({})) {
2138
+ this._nestService = _nestService;
2139
+ this._driver = driver;
2140
+ this._options = options;
2141
+ this._requestStrategy = requestStrategy;
1411
2142
  }
1412
2143
  /**
1413
- * Resolve the "to" index value
2144
+ * Assert that the active strategy declares support for a capability
1414
2145
  *
1415
- * If the path resolves to a value in the response, use it.
1416
- * Otherwise, compute it from currentPage, perPage, and total:
1417
- * `Math.min(currentPage * perPage, total)`
2146
+ * Reads from `IRequestStrategy.capabilities` rather than the driver
2147
+ * enum so adding a new driver only requires declaring its capability
2148
+ * map this method does not change.
1418
2149
  *
1419
- * @param response - The raw response object
1420
- * @param options - The response key name configuration
1421
- * @param currentPage - The current page number
1422
- * @param perPage - The number of items per page
1423
- * @param total - The total number of items
1424
- * @returns The computed "to" index
2150
+ * @param flag - The capability key to check
2151
+ * @param error - The error to throw if the capability is unsupported
2152
+ * @throws The provided error if the active strategy lacks the capability
1425
2153
  */
1426
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1427
- _resolveTo(response, options, currentPage, perPage, total) {
1428
- const direct = this._resolve(response, options.to);
1429
- if (direct !== undefined) {
1430
- return direct;
1431
- }
1432
- if (currentPage && perPage && total) {
1433
- return Math.min(currentPage * perPage, total);
2154
+ _assertCapability(flag, error) {
2155
+ if (!this._requestStrategy.capabilities[flag]) {
2156
+ throw error;
1434
2157
  }
1435
- return undefined;
1436
2158
  }
1437
- }
1438
-
1439
- /**
1440
- * Request strategy for the Laravel (pagination-only) driver
1441
- *
1442
- * Generates simple pagination URIs:
1443
- * - `/{resource}?limit=N&page=N`
1444
- *
1445
- * Filters, sorts, fields, includes, search, and select in state are ignored.
1446
- */
1447
- class LaravelRequestStrategy {
1448
2159
  /**
1449
- * Build a pagination-only URI from the given state
2160
+ * Add fields to the select statement for the given model (JSON:API and Spatie only)
1450
2161
  *
1451
- * @param state - The current query builder state
1452
- * @param options - The query parameter key name configuration
1453
- * @returns The composed URI string
1454
- * @throws Error if resource is not set
2162
+ * @param model - Model that holds the fields
2163
+ * @param fields - Fields to select
2164
+ * @returns {this}
2165
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
1455
2166
  */
1456
- buildUri(state, options) {
1457
- if (!state.resource) {
1458
- throw new Error('Set the resource property BEFORE calling the url() / get() methods');
2167
+ addFields(model, fields) {
2168
+ this._assertCapability('fields', new UnsupportedFieldSelectionError());
2169
+ if (!fields.length) {
2170
+ return this;
1459
2171
  }
1460
- const base = state.baseUrl ? `${state.baseUrl}/${state.resource}` : `/${state.resource}`;
1461
- return `${base}?${options.limit}=${state.limit}&${options.page}=${state.page}`;
2172
+ this._nestService.addFields({ [model]: fields });
2173
+ return this;
1462
2174
  }
1463
2175
  /**
1464
- * Validate that the given limit is accepted by the Laravel driver
2176
+ * Add a filter with the given value(s) (JSON:API, NestJS, PostgREST, and Spatie)
1465
2177
  *
1466
- * Laravel pagination does not recognize `-1` as a "fetch all" sentinel,
1467
- * so only positive integers are accepted.
2178
+ * Produces: `filter[field]=value` (JSON:API / Spatie) or `filter.field=value` (NestJS)
1468
2179
  *
1469
- * @param limit - The limit value to validate
1470
- * @throws {InvalidLimitError} If the value is not a positive integer
2180
+ * @param {string} field - Name of the field to filter
2181
+ * @param {(string | number | boolean)[]} values - The needle(s)
2182
+ * @returns {this}
2183
+ * @throws {UnsupportedFilterError} If the active driver does not support filters
1471
2184
  */
1472
- validateLimit(limit) {
1473
- if (Number.isInteger(limit) && limit >= 1) {
1474
- return;
2185
+ addFilter(field, ...values) {
2186
+ this._assertCapability('filters', new UnsupportedFilterError());
2187
+ if (!values.length) {
2188
+ return this;
1475
2189
  }
1476
- throw new InvalidLimitError(limit);
2190
+ this._nestService.addFilters({
2191
+ [field]: values
2192
+ });
2193
+ this._nestService.page = 1;
2194
+ return this;
1477
2195
  }
1478
- }
1479
-
1480
- /**
1481
- * Response strategy for the Laravel (pagination-only) driver
1482
- *
1483
- * Parses flat Laravel pagination responses:
1484
- * ```json
1485
- * {
1486
- * "data": [...],
1487
- * "current_page": 1,
1488
- * "total": 100,
1489
- * "per_page": 15,
1490
- * "from": 1,
1491
- * "to": 15,
1492
- * ...
1493
- * }
1494
- * ```
1495
- */
1496
- class LaravelResponseStrategy {
1497
2196
  /**
1498
- * Parse a flat Laravel pagination response into a PaginatedCollection
2197
+ * Add a filter with an explicit operator (NestJS and PostgREST)
1499
2198
  *
1500
- * @param response - The raw API response object
1501
- * @param options - The response key name configuration
1502
- * @returns A typed PaginatedCollection instance
1503
- */
1504
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1505
- paginate(response, options) {
1506
- 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]);
1507
- }
1508
- }
1509
-
1510
- /**
1511
- * Request strategy for the NestJS (nestjs-paginate) driver
1512
- *
1513
- * Generates URIs in the NestJS paginate format:
1514
- * - Simple filters: `filter.field=value`
1515
- * - Operator filters: `filter.field=$operator:value`
1516
- * - Sorts: `sortBy=field1:DESC,field2:ASC`
1517
- * - Select: `select=col1,col2`
1518
- * - Search: `search=term`
1519
- * - Pagination: `limit=N&page=N`
1520
- *
1521
- * @see https://github.com/ppetzold/nestjs-paginate
1522
- */
1523
- class NestjsRequestStrategy {
1524
- /**
1525
- * Accumulator for composing the URI string
1526
- */
1527
- _uri = '';
1528
- /**
1529
- * Build a URI string from the given state using the NestJS paginate format
2199
+ * Produces: `filter.field=$operator:value`
1530
2200
  *
1531
- * @param state - The current query builder state
1532
- * @param options - The query parameter key name configuration
1533
- * @returns The composed URI string
1534
- * @throws Error if model is not set
2201
+ * @param {string} field - Name of the field to filter
2202
+ * @param {FilterOperatorEnum} operator - The filter operator to apply
2203
+ * @param {(string | number | boolean)[]} values - The value(s) for the filter
2204
+ * @returns {this}
2205
+ * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
1535
2206
  */
1536
- buildUri(state, options) {
1537
- if (!state.resource) {
1538
- throw new Error('Set the resource property BEFORE adding filters or calling the url() / get() methods');
2207
+ addFilterOperator(field, operator, ...values) {
2208
+ this._assertCapability('operatorFilters', new UnsupportedFilterOperatorError());
2209
+ if (!values.length) {
2210
+ return this;
1539
2211
  }
1540
- this._uri = '';
1541
- this._parseFilters(state, options);
1542
- this._parseOperatorFilters(state, options);
1543
- this._parseSort(state, options);
1544
- this._parseSelect(state, options);
1545
- this._parseSearch(state, options);
1546
- this._parseLimit(state, options);
1547
- this._parsePage(state, options);
1548
- return this._uri;
2212
+ this._nestService.addOperatorFilters([{ field, operator, values }]);
2213
+ this._nestService.page = 1;
2214
+ return this;
1549
2215
  }
1550
2216
  /**
1551
- * Validate that the given limit is accepted by nestjs-paginate
1552
- *
1553
- * Accepts any integer `>= 1` as a page size, plus `-1` which nestjs-paginate
1554
- * interprets as "fetch all items" (server must opt-in via `maxLimit: -1`).
2217
+ * Add related entities to include in the request (JSON:API and Spatie only)
1555
2218
  *
1556
- * @param limit - The limit value to validate
1557
- * @throws {InvalidLimitError} If the value is not an integer, or is 0, or is a negative number other than -1
2219
+ * @param {string[]} models - Models to include
2220
+ * @returns {this}
2221
+ * @throws {UnsupportedIncludesError} If the active driver does not support includes
1558
2222
  */
1559
- validateLimit(limit) {
1560
- if (Number.isInteger(limit) && (limit === -1 || limit >= 1)) {
1561
- return;
2223
+ addIncludes(...models) {
2224
+ this._assertCapability('includes', new UnsupportedIncludesError());
2225
+ if (!models.length) {
2226
+ return this;
1562
2227
  }
1563
- throw new InvalidLimitError(limit, true);
2228
+ this._nestService.addIncludes(models);
2229
+ return this;
1564
2230
  }
1565
2231
  /**
1566
- * Parse and append simple filter parameters
2232
+ * Add flat field selection (NestJS and PostgREST)
1567
2233
  *
1568
- * Generates: `filter.field=value1,value2` for each filter
2234
+ * Produces: `select=col1,col2`
1569
2235
  *
1570
- * @param state - The current query builder state
1571
- * @param options - The query parameter key name configuration
2236
+ * @param {string[]} fields - Fields to select
2237
+ * @returns {this}
2238
+ * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
1572
2239
  */
1573
- _parseFilters(state, options) {
1574
- const keys = Object.keys(state.filters);
1575
- if (!keys.length) {
1576
- return;
2240
+ addSelect(...fields) {
2241
+ this._assertCapability('select', new UnsupportedSelectError());
2242
+ if (!fields.length) {
2243
+ return this;
1577
2244
  }
1578
- keys.forEach(key => {
1579
- const values = state.filters[key].join(',');
1580
- const param = `${this._prepend(state)}${options.filters}.${key}=${values}`;
1581
- this._uri += param;
1582
- });
2245
+ this._nestService.addSelect(fields);
2246
+ return this;
1583
2247
  }
1584
2248
  /**
1585
- * Parse and append the limit parameter
2249
+ * Add a field with a sort criteria (JSON:API, NestJS, PostgREST, and Spatie)
1586
2250
  *
1587
- * @param state - The current query builder state
1588
- * @param options - The query parameter key name configuration
2251
+ * @param field - Field to use for sorting
2252
+ * @param {SortEnum} order - A value from the SortEnum enumeration
2253
+ * @returns {this}
2254
+ * @throws {UnsupportedSortError} If the active driver does not support sorts
1589
2255
  */
1590
- _parseLimit(state, options) {
1591
- const param = `${this._prepend(state)}${options.limit}=${state.limit}`;
1592
- this._uri += param;
2256
+ addSort(field, order) {
2257
+ this._assertCapability('sort', new UnsupportedSortError());
2258
+ this._nestService.addSort({
2259
+ field,
2260
+ order
2261
+ });
2262
+ this._nestService.page = 1;
2263
+ return this;
1593
2264
  }
1594
2265
  /**
1595
- * Parse and append operator filter parameters
2266
+ * Get the current page number
1596
2267
  *
1597
- * Groups operator filters by field and generates:
1598
- * - Single value: `filter.field=$operator:value`
1599
- * - Multiple values ($in, $btw): `filter.field=$operator:val1,val2`
1600
- *
1601
- * @param state - The current query builder state
1602
- * @param options - The query parameter key name configuration
2268
+ * @remarks Always safe to call. Thin accessor over the internal state's `page` field.
2269
+ * @returns The current page number
1603
2270
  */
1604
- _parseOperatorFilters(state, options) {
1605
- if (!state.operatorFilters.length) {
1606
- return;
1607
- }
1608
- state.operatorFilters.forEach((opFilter) => {
1609
- const values = opFilter.values.join(',');
1610
- const param = `${this._prepend(state)}${options.filters}.${opFilter.field}=${opFilter.operator}:${values}`;
1611
- this._uri += param;
1612
- });
2271
+ currentPage() {
2272
+ return this._nestService.nest().page;
1613
2273
  }
1614
2274
  /**
1615
- * Parse and append the page parameter
2275
+ * Delete selected fields for the given models in the current query builder state (JSON:API and Spatie only)
1616
2276
  *
1617
- * @param state - The current query builder state
1618
- * @param options - The query parameter key name configuration
2277
+ * ```
2278
+ * ngQubeeService.deleteFields({
2279
+ * users: ['email', 'password'],
2280
+ * address: ['zipcode']
2281
+ * });
2282
+ * ```
2283
+ *
2284
+ * @param {IFields} fields - Object mapping model names to field arrays to remove
2285
+ * @returns {this}
2286
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
1619
2287
  */
1620
- _parsePage(state, options) {
1621
- const param = `${this._prepend(state)}${options.page}=${state.page}`;
1622
- this._uri += param;
2288
+ deleteFields(fields) {
2289
+ this._assertCapability('fields', new UnsupportedFieldSelectionError());
2290
+ this._nestService.deleteFields(fields);
2291
+ return this;
1623
2292
  }
1624
2293
  /**
1625
- * Parse and append the search parameter
2294
+ * Delete selected fields for the given model in the current query builder state (JSON:API and Spatie only)
1626
2295
  *
1627
- * Generates: `search=term`
2296
+ * ```
2297
+ * ngQubeeService.deleteFieldsByModel('users', 'email', 'password');
2298
+ * ```
1628
2299
  *
1629
- * @param state - The current query builder state
1630
- * @param options - The query parameter key name configuration
2300
+ * @param model - Model that holds the fields
2301
+ * @param {string[]} fields - Fields to delete from the state
2302
+ * @returns {this}
2303
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
1631
2304
  */
1632
- _parseSearch(state, options) {
1633
- if (!state.search) {
1634
- return;
2305
+ deleteFieldsByModel(model, ...fields) {
2306
+ this._assertCapability('fields', new UnsupportedFieldSelectionError());
2307
+ if (!fields.length) {
2308
+ return this;
1635
2309
  }
1636
- const param = `${this._prepend(state)}${options.search}=${state.search}`;
1637
- this._uri += param;
2310
+ this._nestService.deleteFields({
2311
+ [model]: fields
2312
+ });
2313
+ return this;
1638
2314
  }
1639
2315
  /**
1640
- * Parse and append the select parameter
2316
+ * Remove given filters from the query builder state (JSON:API, NestJS, PostgREST, and Spatie)
1641
2317
  *
1642
- * Generates: `select=col1,col2`
1643
- *
1644
- * @param state - The current query builder state
1645
- * @param options - The query parameter key name configuration
2318
+ * @param {string[]} filters - Filters to remove
2319
+ * @returns {this}
2320
+ * @throws {UnsupportedFilterError} If the active driver does not support filters
1646
2321
  */
1647
- _parseSelect(state, options) {
1648
- if (!state.select.length) {
1649
- return;
2322
+ deleteFilters(...filters) {
2323
+ this._assertCapability('filters', new UnsupportedFilterError());
2324
+ if (!filters.length) {
2325
+ return this;
1650
2326
  }
1651
- const param = `${this._prepend(state)}${options.select}=${state.select.join(',')}`;
1652
- this._uri += param;
2327
+ this._nestService.deleteFilters(...filters);
2328
+ this._nestService.page = 1;
2329
+ return this;
1653
2330
  }
1654
2331
  /**
1655
- * Parse and append sort parameters
1656
- *
1657
- * Generates: `sortBy=field1:DESC,field2:ASC`
2332
+ * Remove selected related models from the query builder state (JSON:API and Spatie only)
1658
2333
  *
1659
- * @param state - The current query builder state
1660
- * @param options - The query parameter key name configuration
2334
+ * @param {string[]} includes - Models to remove
2335
+ * @returns {this}
2336
+ * @throws {UnsupportedIncludesError} If the active driver does not support includes
1661
2337
  */
1662
- _parseSort(state, options) {
1663
- if (!state.sorts.length) {
1664
- return;
2338
+ deleteIncludes(...includes) {
2339
+ this._assertCapability('includes', new UnsupportedIncludesError());
2340
+ if (!includes.length) {
2341
+ return this;
1665
2342
  }
1666
- const sortPairs = state.sorts.map(sort => `${sort.field}:${sort.order === SortEnum.DESC ? 'DESC' : 'ASC'}`);
1667
- const param = `${this._prepend(state)}${options.sortBy}=${sortPairs.join(',')}`;
1668
- this._uri += param;
2343
+ this._nestService.deleteIncludes(...includes);
2344
+ return this;
1669
2345
  }
1670
2346
  /**
1671
- * Determine the appropriate URI prefix based on the current accumulator state
1672
- *
1673
- * Returns the full base path with `?` for the first parameter,
1674
- * or `&` for subsequent parameters.
2347
+ * Remove operator filters by field name (NestJS and PostgREST)
1675
2348
  *
1676
- * @param state - The current query builder state
1677
- * @returns The prefix string to prepend to the next parameter
2349
+ * @param {string[]} fields - Field names of operator filters to remove
2350
+ * @returns {this}
2351
+ * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
1678
2352
  */
1679
- _prepend(state) {
1680
- if (this._uri) {
1681
- return '&';
2353
+ deleteOperatorFilters(...fields) {
2354
+ this._assertCapability('operatorFilters', new UnsupportedFilterOperatorError());
2355
+ if (!fields.length) {
2356
+ return this;
1682
2357
  }
1683
- return state.baseUrl ? `${state.baseUrl}/${state.resource}?` : `/${state.resource}?`;
2358
+ this._nestService.deleteOperatorFilters(...fields);
2359
+ this._nestService.page = 1;
2360
+ return this;
1684
2361
  }
1685
- }
1686
-
1687
- /**
1688
- * Response strategy for the NestJS (nestjs-paginate) driver
1689
- *
1690
- * Parses nested NestJS pagination responses:
1691
- * ```json
1692
- * {
1693
- * "data": [...],
1694
- * "meta": {
1695
- * "currentPage": 1,
1696
- * "totalItems": 100,
1697
- * "itemsPerPage": 10,
1698
- * "totalPages": 10
1699
- * },
1700
- * "links": {
1701
- * "first": "url",
1702
- * "previous": "url",
1703
- * "next": "url",
1704
- * "last": "url",
1705
- * "current": "url"
1706
- * }
1707
- * }
1708
- * ```
1709
- *
1710
- * @see https://github.com/ppetzold/nestjs-paginate
1711
- */
1712
- class NestjsResponseStrategy {
1713
2362
  /**
1714
- * Parse a nested NestJS pagination response into a PaginatedCollection
1715
- *
1716
- * Supports dot-notation key paths for accessing nested values.
1717
- * Computes `from` and `to` from `currentPage` and `itemsPerPage` when
1718
- * they are not directly available in the response.
2363
+ * Remove search term from the query builder state (NestJS only)
1719
2364
  *
1720
- * @param response - The raw API response object
1721
- * @param options - The response key name configuration
1722
- * @returns A typed PaginatedCollection instance
2365
+ * @returns {this}
2366
+ * @throws {UnsupportedSearchError} If the active driver does not support search
1723
2367
  */
1724
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1725
- paginate(response, options) {
1726
- const data = this._resolve(response, options.data);
1727
- const currentPage = this._resolve(response, options.currentPage);
1728
- const total = this._resolve(response, options.total);
1729
- const perPage = this._resolve(response, options.perPage);
1730
- const lastPage = this._resolve(response, options.lastPage);
1731
- // Compute from/to if not directly available
1732
- const from = this._resolveFrom(response, options, currentPage, perPage);
1733
- const to = this._resolveTo(response, options, currentPage, perPage, total);
1734
- const prevPageUrl = this._resolve(response, options.prevPageUrl);
1735
- const nextPageUrl = this._resolve(response, options.nextPageUrl);
1736
- const firstPageUrl = this._resolve(response, options.firstPageUrl);
1737
- const lastPageUrl = this._resolve(response, options.lastPageUrl);
1738
- return new PaginatedCollection(data, currentPage, from, to, total, perPage, prevPageUrl, nextPageUrl, lastPage, firstPageUrl, lastPageUrl);
2368
+ deleteSearch() {
2369
+ this._assertCapability('search', new UnsupportedSearchError());
2370
+ this._nestService.deleteSearch();
2371
+ this._nestService.page = 1;
2372
+ return this;
1739
2373
  }
1740
2374
  /**
1741
- * Resolve a value from a response object using a dot-notation path
1742
- *
1743
- * Supports both flat keys ('data') and nested paths ('meta.currentPage').
2375
+ * Remove flat field selections from the query builder state (NestJS and PostgREST)
1744
2376
  *
1745
- * @param response - The raw response object
1746
- * @param path - The dot-notation path to resolve
1747
- * @returns The resolved value, or undefined if not found
2377
+ * @param {string[]} fields - Fields to remove from selection
2378
+ * @returns {this}
2379
+ * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
1748
2380
  */
1749
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1750
- _resolve(response, path) {
1751
- return path.split('.').reduce((obj, key) => obj?.[key], response);
2381
+ deleteSelect(...fields) {
2382
+ this._assertCapability('select', new UnsupportedSelectError());
2383
+ if (!fields.length) {
2384
+ return this;
2385
+ }
2386
+ this._nestService.deleteSelect(...fields);
2387
+ return this;
1752
2388
  }
1753
2389
  /**
1754
- * Resolve the "from" index value
2390
+ * Remove sort rules from the query builder state (JSON:API, NestJS, PostgREST, and Spatie)
1755
2391
  *
1756
- * If the path resolves to a value in the response, use it.
1757
- * Otherwise, compute it from currentPage and perPage:
1758
- * `(currentPage - 1) * perPage + 1`
1759
- *
1760
- * @param response - The raw response object
1761
- * @param options - The response key name configuration
1762
- * @param currentPage - The current page number
1763
- * @param perPage - The number of items per page
1764
- * @returns The computed "from" index
2392
+ * @param sorts - Fields used for sorting to remove
2393
+ * @returns {this}
2394
+ * @throws {UnsupportedSortError} If the active driver does not support sorts
1765
2395
  */
1766
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1767
- _resolveFrom(response, options, currentPage, perPage) {
1768
- const direct = this._resolve(response, options.from);
1769
- if (direct !== undefined) {
1770
- return direct;
1771
- }
1772
- if (currentPage && perPage) {
1773
- return (currentPage - 1) * perPage + 1;
1774
- }
1775
- return undefined;
2396
+ deleteSorts(...sorts) {
2397
+ this._assertCapability('sort', new UnsupportedSortError());
2398
+ this._nestService.deleteSorts(...sorts);
2399
+ this._nestService.page = 1;
2400
+ return this;
1776
2401
  }
1777
2402
  /**
1778
- * Resolve the "to" index value
2403
+ * Navigate to the first page (page 1)
1779
2404
  *
1780
- * If the path resolves to a value in the response, use it.
1781
- * Otherwise, compute it from currentPage, perPage, and total:
1782
- * `Math.min(currentPage * perPage, total)`
2405
+ * @remarks Never throws. Idempotent when already on page 1.
2406
+ * @returns {this}
2407
+ */
2408
+ firstPage() {
2409
+ this._nestService.page = 1;
2410
+ return this;
2411
+ }
2412
+ /**
2413
+ * Generate a URI accordingly to the given data and active driver
1783
2414
  *
1784
- * @param response - The raw response object
1785
- * @param options - The response key name configuration
1786
- * @param currentPage - The current page number
1787
- * @param perPage - The number of items per page
1788
- * @param total - The total number of items
1789
- * @returns The computed "to" index
2415
+ * @returns {Observable<string>} An observable that emits the generated URI
1790
2416
  */
1791
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1792
- _resolveTo(response, options, currentPage, perPage, total) {
1793
- const direct = this._resolve(response, options.to);
1794
- if (direct !== undefined) {
1795
- return direct;
2417
+ generateUri() {
2418
+ try {
2419
+ this._uri$.next(this._requestStrategy.buildUri(this._nestService.nest(), this._options));
2420
+ return this.uri$;
1796
2421
  }
1797
- if (currentPage && perPage && total) {
1798
- return Math.min(currentPage * perPage, total);
2422
+ catch (error) {
2423
+ return throwError(() => error);
1799
2424
  }
1800
- return undefined;
1801
2425
  }
1802
- }
1803
-
1804
- /**
1805
- * Request strategy for the Spatie Query Builder driver
1806
- *
1807
- * Generates URIs in the Spatie format:
1808
- * - Fields: `fields[model]=col1,col2`
1809
- * - Filters: `filter[field]=value`
1810
- * - Includes: `include=model1,model2`
1811
- * - Sorts: `sort=-field1,field2` (- prefix = DESC)
1812
- * - Pagination: `limit=N&page=N`
1813
- *
1814
- * @see https://spatie.be/docs/laravel-query-builder
1815
- */
1816
- class SpatieRequestStrategy {
1817
2426
  /**
1818
- * Accumulator for composing the URI string
2427
+ * Navigate directly to the specified page
2428
+ *
2429
+ * Validates integer/positive via the existing `setPage` path, and
2430
+ * additionally rejects values that exceed `state.lastPage` when
2431
+ * pagination bounds are known.
2432
+ *
2433
+ * @param n - Target page number
2434
+ * @returns {this}
2435
+ * @throws {InvalidPageNumberError} If `n` is not a positive integer, or if `n > state.lastPage` when `state.isLastPageKnown` is true
1819
2436
  */
1820
- _uri = '';
2437
+ goToPage(n) {
2438
+ const state = this._nestService.nest();
2439
+ if (state.isLastPageKnown && n > state.lastPage) {
2440
+ throw new InvalidPageNumberError(n);
2441
+ }
2442
+ this._nestService.page = n;
2443
+ return this;
2444
+ }
1821
2445
  /**
1822
- * Build a URI string from the given state using the Spatie format
2446
+ * Check whether a next page exists
1823
2447
  *
1824
- * @param state - The current query builder state
1825
- * @param options - The query parameter key name configuration
1826
- * @returns The composed URI string
1827
- * @throws Error if resource is not set
2448
+ * @remarks Template-safe. Returns `true` when pagination bounds are unknown (conservative default — keeps a "Next" button enabled before the first `paginate()` call).
2449
+ * @returns `true` if `state.page < state.lastPage` when bounds are known, or `true` when bounds are unknown
1828
2450
  */
1829
- buildUri(state, options) {
1830
- if (!state.resource) {
1831
- throw new Error('Set the resource property BEFORE adding filters or calling the url() / get() methods');
1832
- }
1833
- this._uri = '';
1834
- this._parseIncludes(state, options);
1835
- this._parseFields(state, options);
1836
- this._parseFilters(state, options);
1837
- this._parseLimit(state, options);
1838
- this._parsePage(state, options);
1839
- this._parseSort(state, options);
1840
- return this._uri;
2451
+ hasNextPage() {
2452
+ const state = this._nestService.nest();
2453
+ return !state.isLastPageKnown || state.page < state.lastPage;
1841
2454
  }
1842
2455
  /**
1843
- * Validate that the given limit is accepted by the Spatie driver
2456
+ * Check whether a previous page exists
1844
2457
  *
1845
- * Spatie query-builder does not recognize `-1` as a "fetch all" sentinel,
1846
- * so only positive integers are accepted.
2458
+ * @remarks Always safe. Does not require a synced paginated response.
2459
+ * @returns `true` if `state.page > 1`
2460
+ */
2461
+ hasPreviousPage() {
2462
+ return this._nestService.nest().page > 1;
2463
+ }
2464
+ /**
2465
+ * Check whether the current page is the first page
1847
2466
  *
1848
- * @param limit - The limit value to validate
1849
- * @throws {InvalidLimitError} If the value is not a positive integer
2467
+ * @remarks Always safe. Does not require a synced paginated response.
2468
+ * @returns `true` if `state.page === 1`
1850
2469
  */
1851
- validateLimit(limit) {
1852
- if (Number.isInteger(limit) && limit >= 1) {
1853
- return;
1854
- }
1855
- throw new InvalidLimitError(limit);
2470
+ isFirstPage() {
2471
+ return this._nestService.nest().page === 1;
1856
2472
  }
1857
2473
  /**
1858
- * Parse and append field selection parameters
2474
+ * Check whether the current page is the last page
1859
2475
  *
1860
- * Validates that each field model exists either as the main resource
1861
- * or in the includes list. Fields are grouped by model in bracket notation.
2476
+ * @remarks Template-safe. Returns `false` when pagination bounds are unknown (no paginated response has been synced yet) — keeps "Next" navigation unblocked until the first `paginate()` call syncs.
2477
+ * @returns `true` only when `state.isLastPageKnown` and `state.page === state.lastPage`
2478
+ */
2479
+ isLastPage() {
2480
+ const state = this._nestService.nest();
2481
+ return state.isLastPageKnown && state.page === state.lastPage;
2482
+ }
2483
+ /**
2484
+ * Navigate to the last page known from the most recent paginated response
1862
2485
  *
1863
- * @param state - The current query builder state
1864
- * @param options - The query parameter key name configuration
1865
- * @returns The generated field selection parameter string
1866
- * @throws Error if resource is required but not set
1867
- * @throws UnselectableModelError if a field model is not in resource or includes
2486
+ * @remarks Requires at least one `PaginationService.paginate()` call to have synced `state.lastPage`. Before that, the bound is unknown and this method throws.
2487
+ * @returns {this}
2488
+ * @throws {PaginationNotSyncedError} If `state.isLastPageKnown` is false (no paginated response has been synced yet)
1868
2489
  */
1869
- _parseFields(state, options) {
1870
- if (!Object.keys(state.fields).length) {
1871
- return this._uri;
1872
- }
1873
- if (!state.resource) {
1874
- throw new Error('While selecting fields, the -> resource <- is required');
2490
+ lastPage() {
2491
+ const state = this._nestService.nest();
2492
+ if (!state.isLastPageKnown) {
2493
+ throw new PaginationNotSyncedError('navigate to last page');
1875
2494
  }
1876
- if (!(state.resource in state.fields)) {
1877
- throw new Error(`Key ${state.resource} is missing in the fields object`);
1878
- }
1879
- const f = {};
1880
- for (const k in state.fields) {
1881
- if (state.fields.hasOwnProperty(k)) {
1882
- if (k !== state.resource && !state.includes.includes(k)) {
1883
- throw new UnselectableModelError(k);
1884
- }
1885
- Object.assign(f, { [`${options.fields}[${k}]`]: state.fields[k].join(',') });
1886
- }
2495
+ this._nestService.page = state.lastPage;
2496
+ return this;
2497
+ }
2498
+ /**
2499
+ * Navigate to the next page
2500
+ *
2501
+ * @remarks Never throws. Idempotent at the known last page (no-op). Pair with `hasNextPage()` for a disable-state binding.
2502
+ * @returns {this}
2503
+ */
2504
+ nextPage() {
2505
+ const state = this._nestService.nest();
2506
+ if (state.isLastPageKnown && state.page >= state.lastPage) {
2507
+ return this;
1887
2508
  }
1888
- const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1889
- this._uri += param;
1890
- return param;
2509
+ this._nestService.page = state.page + 1;
2510
+ return this;
1891
2511
  }
1892
2512
  /**
1893
- * Parse and append filter parameters
2513
+ * HTTP request headers the active driver wants the consumer to apply
1894
2514
  *
1895
- * Generates filter parameters in bracket notation: `filter[key]=value1,value2`
2515
+ * Returns `null` for drivers that pass all pagination metadata on the
2516
+ * URL (Laravel, Spatie, JSON:API, NestJS, and PostgREST in its default
2517
+ * QUERY mode). Returns a map of header name → value when the active
2518
+ * driver uses HTTP headers instead — today, only the PostgREST driver
2519
+ * configured with `PaginationModeEnum.RANGE`, which yields
2520
+ * `{ 'Range-Unit': 'items', 'Range': 'from-to' }`.
1896
2521
  *
1897
- * @param state - The current query builder state
1898
- * @param options - The query parameter key name configuration
1899
- * @returns The generated filter parameter string
2522
+ * @returns Map of headers to apply to the HTTP request, or `null` when not needed
1900
2523
  */
1901
- _parseFilters(state, options) {
1902
- const keys = Object.keys(state.filters);
1903
- if (!keys.length) {
1904
- return this._uri;
2524
+ paginationHeaders() {
2525
+ if (typeof this._requestStrategy.buildPaginationHeaders !== 'function') {
2526
+ return null;
1905
2527
  }
1906
- const f = {
1907
- [`${options.filters}`]: keys.reduce((acc, key) => {
1908
- return Object.assign(acc, { [key]: state.filters[key].join(',') });
1909
- }, {})
1910
- };
1911
- const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1912
- this._uri += param;
1913
- return param;
2528
+ return this._requestStrategy.buildPaginationHeaders(this._nestService.nest());
1914
2529
  }
1915
2530
  /**
1916
- * Parse and append include parameters
2531
+ * Navigate to the previous page
1917
2532
  *
1918
- * Generates: `include=model1,model2`
1919
- *
1920
- * @param state - The current query builder state
1921
- * @param options - The query parameter key name configuration
1922
- * @returns The generated include parameter string
2533
+ * @remarks Never throws. Idempotent at page 1 (floored). Pair with `hasPreviousPage()` for a disable-state binding.
2534
+ * @returns {this}
1923
2535
  */
1924
- _parseIncludes(state, options) {
1925
- if (!state.includes.length) {
1926
- return this._uri;
2536
+ previousPage() {
2537
+ const state = this._nestService.nest();
2538
+ if (state.page <= 1) {
2539
+ return this;
1927
2540
  }
1928
- const param = `${this._prepend(state)}${options.includes}=${state.includes}`;
1929
- this._uri += param;
1930
- return param;
2541
+ this._nestService.page = state.page - 1;
2542
+ return this;
1931
2543
  }
1932
2544
  /**
1933
- * Parse and append the limit parameter
2545
+ * Clear the current state and reset the Query Builder to a fresh, clean condition
1934
2546
  *
1935
- * @param state - The current query builder state
1936
- * @param options - The query parameter key name configuration
1937
- * @returns The generated limit parameter string
2547
+ * @returns {this}
1938
2548
  */
1939
- _parseLimit(state, options) {
1940
- const param = `${this._prepend(state)}${options.limit}=${state.limit}`;
1941
- this._uri += param;
1942
- return param;
2549
+ reset() {
2550
+ this._nestService.reset();
2551
+ return this;
1943
2552
  }
1944
2553
  /**
1945
- * Parse and append the page parameter
2554
+ * Set the base URL to use for composing the address
1946
2555
  *
1947
- * @param state - The current query builder state
1948
- * @param options - The query parameter key name configuration
1949
- * @returns The generated page parameter string
2556
+ * @param {string} baseUrl - The base URL
2557
+ * @returns {this}
1950
2558
  */
1951
- _parsePage(state, options) {
1952
- const param = `${this._prepend(state)}${options.page}=${state.page}`;
1953
- this._uri += param;
1954
- return param;
2559
+ setBaseUrl(baseUrl) {
2560
+ this._nestService.baseUrl = baseUrl;
2561
+ return this;
1955
2562
  }
1956
2563
  /**
1957
- * Parse and append sort parameters
2564
+ * Set the items per page number
1958
2565
  *
1959
- * Generates: `sort=-field1,field2` where `-` prefix indicates DESC order
2566
+ * Validation is delegated to the active request strategy because the
2567
+ * accepted range is driver-specific: nestjs-paginate additionally accepts
2568
+ * `-1` as a "fetch all" sentinel, while Laravel, Spatie, and JSON:API
2569
+ * require a positive integer.
1960
2570
  *
1961
- * @param state - The current query builder state
1962
- * @param options - The query parameter key name configuration
1963
- * @returns The generated sort parameter string
2571
+ * @param limit - Number of items per page (or `-1` to fetch all, NestJS only)
2572
+ * @returns {this}
2573
+ * @throws {import('../errors/invalid-limit.error').InvalidLimitError} If the value is not accepted by the active driver
1964
2574
  */
1965
- _parseSort(state, options) {
1966
- let param = '';
1967
- if (!state.sorts.length) {
1968
- return param;
1969
- }
1970
- param = `${this._prepend(state)}${options.sort}=`;
1971
- state.sorts.forEach((sort, idx) => {
1972
- param += `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`;
1973
- if (idx < state.sorts.length - 1) {
1974
- param += ',';
1975
- }
1976
- });
1977
- this._uri += param;
1978
- return param;
2575
+ setLimit(limit) {
2576
+ this._requestStrategy.validateLimit(limit);
2577
+ this._nestService.limit = limit;
2578
+ this._nestService.page = 1;
2579
+ return this;
1979
2580
  }
1980
2581
  /**
1981
- * Determine the appropriate URI prefix based on the current accumulator state
2582
+ * Set the page that the backend will use to paginate the result set
1982
2583
  *
1983
- * Returns the full base path with `?` for the first parameter,
1984
- * or `&` for subsequent parameters.
2584
+ * @param page - Page number
2585
+ * @returns {this}
2586
+ */
2587
+ setPage(page) {
2588
+ this._nestService.page = page;
2589
+ return this;
2590
+ }
2591
+ /**
2592
+ * Set the API resource to run the query against
1985
2593
  *
1986
- * @param state - The current query builder state
1987
- * @returns The prefix string to prepend to the next parameter
2594
+ * @param {string} resource - Resource name (e.g. 'users' produces /users)
2595
+ * @returns {this}
1988
2596
  */
1989
- _prepend(state) {
1990
- if (this._uri) {
1991
- return '&';
1992
- }
1993
- return state.baseUrl ? `${state.baseUrl}/${state.resource}?` : `/${state.resource}?`;
2597
+ setResource(resource) {
2598
+ this._nestService.resource = resource;
2599
+ this._nestService.page = 1;
2600
+ return this;
1994
2601
  }
1995
- }
1996
-
1997
- /**
1998
- * Response strategy for the Spatie Query Builder driver
1999
- *
2000
- * Parses flat Laravel pagination responses:
2001
- * ```json
2002
- * {
2003
- * "data": [...],
2004
- * "current_page": 1,
2005
- * "total": 100,
2006
- * "per_page": 15,
2007
- * "from": 1,
2008
- * "to": 15,
2009
- * ...
2010
- * }
2011
- * ```
2012
- *
2013
- * @see https://spatie.be/docs/laravel-query-builder
2014
- */
2015
- class SpatieResponseStrategy {
2016
2602
  /**
2017
- * Parse a flat Laravel pagination response into a PaginatedCollection
2603
+ * Set the search term for full-text search (NestJS only)
2018
2604
  *
2019
- * @param response - The raw API response object
2020
- * @param options - The response key name configuration
2021
- * @returns A typed PaginatedCollection instance
2605
+ * Produces: `search=term`
2606
+ *
2607
+ * @param {string} search - The search term
2608
+ * @returns {this}
2609
+ * @throws {UnsupportedSearchError} If the active driver does not support search
2022
2610
  */
2023
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2024
- paginate(response, options) {
2025
- 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]);
2611
+ setSearch(search) {
2612
+ this._assertCapability('search', new UnsupportedSearchError());
2613
+ this._nestService.setSearch(search);
2614
+ this._nestService.page = 1;
2615
+ return this;
2026
2616
  }
2027
- }
2028
-
2029
- /**
2030
- * Resolve the request strategy instance for the given driver
2031
- *
2032
- * @param driver - The pagination driver
2033
- * @returns The corresponding request strategy
2034
- */
2035
- function resolveRequestStrategy$1(driver) {
2036
- switch (driver) {
2037
- case DriverEnum.JSON_API:
2038
- return new JsonApiRequestStrategy();
2039
- case DriverEnum.NESTJS:
2040
- return new NestjsRequestStrategy();
2041
- case DriverEnum.SPATIE:
2042
- return new SpatieRequestStrategy();
2043
- case DriverEnum.LARAVEL:
2044
- return new LaravelRequestStrategy();
2617
+ /**
2618
+ * Get the total number of pages reported by the most recent paginated response
2619
+ *
2620
+ * @remarks Throws when called before any `paginate()` has synced a value. For a non-throwing read in a template, read `nest().isLastPageKnown` first as a guard.
2621
+ * @returns The last page number
2622
+ * @throws {PaginationNotSyncedError} If `state.isLastPageKnown` is false (no paginated response has been synced yet)
2623
+ */
2624
+ totalPages() {
2625
+ const state = this._nestService.nest();
2626
+ if (!state.isLastPageKnown) {
2627
+ throw new PaginationNotSyncedError('read totalPages');
2628
+ }
2629
+ return state.lastPage;
2045
2630
  }
2631
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService, deps: [{ token: NestService }, { token: NG_QUBEE_REQUEST_STRATEGY }, { token: NG_QUBEE_DRIVER }, { token: NG_QUBEE_REQUEST_OPTIONS }], target: i0.ɵɵFactoryTarget.Injectable });
2632
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService });
2046
2633
  }
2047
- /**
2048
- * Resolve the response strategy instance for the given driver
2049
- *
2050
- * @param driver - The pagination driver
2051
- * @returns The corresponding response strategy
2052
- */
2053
- function resolveResponseStrategy$1(driver) {
2054
- switch (driver) {
2055
- case DriverEnum.JSON_API:
2056
- return new JsonApiResponseStrategy();
2057
- case DriverEnum.NESTJS:
2058
- return new NestjsResponseStrategy();
2059
- case DriverEnum.SPATIE:
2060
- return new SpatieResponseStrategy();
2061
- case DriverEnum.LARAVEL:
2062
- return new LaravelResponseStrategy();
2634
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService, decorators: [{
2635
+ type: Injectable
2636
+ }], ctorParameters: () => [{ type: NestService }, { type: undefined, decorators: [{
2637
+ type: Inject,
2638
+ args: [NG_QUBEE_REQUEST_STRATEGY]
2639
+ }] }, { type: DriverEnum, decorators: [{
2640
+ type: Inject,
2641
+ args: [NG_QUBEE_DRIVER]
2642
+ }] }, { type: QueryBuilderOptions, decorators: [{
2643
+ type: Inject,
2644
+ args: [NG_QUBEE_REQUEST_OPTIONS]
2645
+ }] }] });
2646
+
2647
+ class PaginationService {
2648
+ /**
2649
+ * The NestService instance that owns the query-builder state for this
2650
+ * PaginationService's scope (environment-level by default, or
2651
+ * component-level when used via `provideNgQubeeInstance()`)
2652
+ */
2653
+ _nestService;
2654
+ /**
2655
+ * Resolved response key name options
2656
+ */
2657
+ _options;
2658
+ /**
2659
+ * The response strategy that parses responses for the active driver
2660
+ */
2661
+ _responseStrategy;
2662
+ constructor(nestService, responseStrategy, options = new ResponseOptions({})) {
2663
+ this._nestService = nestService;
2664
+ this._options = options;
2665
+ this._responseStrategy = responseStrategy;
2063
2666
  }
2064
- }
2065
- // @dynamic
2066
- class NgQubeeModule {
2067
2667
  /**
2068
- * Configure NgQubee for the root module
2668
+ * Transform a raw API response into a typed PaginatedCollection
2069
2669
  *
2070
- * @param config - Configuration object with driver, and optional request and response settings
2071
- * @returns Module with providers configured for the specified driver
2670
+ * Delegates to the active driver's response strategy for parsing, then
2671
+ * auto-syncs the parsed `page` and `lastPage` back into `NestService`
2672
+ * so pagination navigation helpers on `NgQubeeService` can operate
2673
+ * against the live server-reported bounds without consumer bookkeeping.
2674
+ *
2675
+ * @remarks
2676
+ * `lastPage` is only synced when the response yields a positive integer.
2677
+ * Server-emitted `0` (empty collection edge case) and absent fields are
2678
+ * treated as "no useful info" and leave `isLastPageKnown: false`.
2679
+ *
2680
+ * @param response - The raw API response body. For drivers that emit a
2681
+ * bare array (PostgREST), pass the array.
2682
+ * @param headers - Optional HTTP response headers. Required by the
2683
+ * PostgREST driver (reads `Content-Range` for pagination metadata);
2684
+ * body-only drivers ignore it. Accepts Angular's `HttpHeaders`, the
2685
+ * native `Headers` class, or a plain `Record<string, string>`.
2686
+ * @returns A typed PaginatedCollection instance
2072
2687
  */
2073
- static forRoot(config) {
2074
- const driver = config.driver;
2075
- const requestStrategy = resolveRequestStrategy$1(driver);
2076
- const responseStrategy = resolveResponseStrategy$1(driver);
2077
- return {
2078
- ngModule: NgQubeeModule,
2079
- providers: [
2080
- NestService,
2081
- {
2082
- deps: [NestService],
2083
- provide: NgQubeeService,
2084
- useFactory: (nestService) => new NgQubeeService(nestService, requestStrategy, driver, Object.assign({}, config.request))
2085
- },
2086
- {
2087
- provide: PaginationService,
2088
- useFactory: () => {
2089
- const responseConfig = Object.assign({}, config.response);
2090
- if (driver === DriverEnum.JSON_API) {
2091
- return new PaginationService(responseStrategy, new JsonApiResponseOptions(responseConfig));
2092
- }
2093
- return driver === DriverEnum.NESTJS
2094
- ? new PaginationService(responseStrategy, new NestjsResponseOptions(responseConfig))
2095
- : new PaginationService(responseStrategy, responseConfig);
2096
- }
2097
- }
2098
- ]
2099
- };
2688
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2689
+ paginate(response, headers) {
2690
+ const collection = this._responseStrategy.paginate(response, this._options, headers);
2691
+ this._nestService.page = collection.page;
2692
+ if (typeof collection.lastPage === 'number' && Number.isInteger(collection.lastPage) && collection.lastPage > 0) {
2693
+ this._nestService.syncLastPage(collection.lastPage);
2694
+ }
2695
+ return collection;
2100
2696
  }
2101
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2102
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule });
2103
- static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule });
2697
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService, deps: [{ token: NestService }, { token: NG_QUBEE_RESPONSE_STRATEGY }, { token: NG_QUBEE_RESPONSE_OPTIONS }], target: i0.ɵɵFactoryTarget.Injectable });
2698
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService });
2104
2699
  }
2105
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule, decorators: [{
2106
- type: NgModule,
2107
- args: [{}]
2108
- }] });
2700
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService, decorators: [{
2701
+ type: Injectable
2702
+ }], ctorParameters: () => [{ type: NestService }, { type: undefined, decorators: [{
2703
+ type: Inject,
2704
+ args: [NG_QUBEE_RESPONSE_STRATEGY]
2705
+ }] }, { type: ResponseOptions, decorators: [{
2706
+ type: Inject,
2707
+ args: [NG_QUBEE_RESPONSE_OPTIONS]
2708
+ }] }] });
2109
2709
 
2110
2710
  /**
2111
- * Resolve the request strategy instance for the given driver
2711
+ * Build the core provider list shared by `provideNgQubee()` and
2712
+ * `NgQubeeModule.forRoot()`
2112
2713
  *
2113
- * @param driver - The pagination driver
2114
- * @returns The corresponding request strategy
2115
- */
2116
- function resolveRequestStrategy(driver) {
2117
- switch (driver) {
2118
- case DriverEnum.JSON_API:
2119
- return new JsonApiRequestStrategy();
2120
- case DriverEnum.NESTJS:
2121
- return new NestjsRequestStrategy();
2122
- case DriverEnum.SPATIE:
2123
- return new SpatieRequestStrategy();
2124
- case DriverEnum.LARAVEL:
2125
- return new LaravelRequestStrategy();
2126
- }
2127
- }
2128
- /**
2129
- * Resolve the response strategy instance for the given driver
2714
+ * Looks up the driver definition from the registry and calls its three
2715
+ * factories request strategy, response strategy, response options.
2716
+ * Adding a driver means adding one entry to `DRIVERS`; this function
2717
+ * does not change.
2130
2718
  *
2131
- * @param driver - The pagination driver
2132
- * @returns The corresponding response strategy
2719
+ * Exposes the driver, strategies, and options via injection tokens so that
2720
+ * consumers can request a component-scoped instance of the services through
2721
+ * `provideNgQubeeInstance()`.
2722
+ *
2723
+ * @param config - Configuration object compliant to the IConfig interface
2724
+ * @returns An array of Providers for the environment injector
2133
2725
  */
2134
- function resolveResponseStrategy(driver) {
2135
- switch (driver) {
2136
- case DriverEnum.JSON_API:
2137
- return new JsonApiResponseStrategy();
2138
- case DriverEnum.NESTJS:
2139
- return new NestjsResponseStrategy();
2140
- case DriverEnum.SPATIE:
2141
- return new SpatieResponseStrategy();
2142
- case DriverEnum.LARAVEL:
2143
- return new LaravelResponseStrategy();
2144
- }
2726
+ function buildNgQubeeProviders(config) {
2727
+ const driver = config.driver;
2728
+ const paginationMode = config.pagination ?? PaginationModeEnum.QUERY;
2729
+ const definition = DRIVERS[driver];
2730
+ const requestOptions = new QueryBuilderOptions(Object.assign({}, config.request));
2731
+ const responseOptions = definition.createResponseOptions(Object.assign({}, config.response));
2732
+ return [
2733
+ { provide: NG_QUBEE_DRIVER, useValue: driver },
2734
+ { provide: NG_QUBEE_REQUEST_STRATEGY, useValue: definition.createRequestStrategy(paginationMode) },
2735
+ { provide: NG_QUBEE_REQUEST_OPTIONS, useValue: requestOptions },
2736
+ { provide: NG_QUBEE_RESPONSE_STRATEGY, useValue: definition.createResponseStrategy() },
2737
+ { provide: NG_QUBEE_RESPONSE_OPTIONS, useValue: responseOptions },
2738
+ NestService,
2739
+ NgQubeeService,
2740
+ PaginationService
2741
+ ];
2145
2742
  }
2146
2743
  /**
2147
2744
  * Sets up providers necessary to enable `NgQubee` functionality for the application.
@@ -2187,57 +2784,61 @@ function resolveResponseStrategy(driver) {
2187
2784
  * @returns A set of providers to setup NgQubee
2188
2785
  */
2189
2786
  function provideNgQubee(config) {
2190
- const driver = config.driver;
2191
- const requestStrategy = resolveRequestStrategy(driver);
2192
- const responseStrategy = resolveResponseStrategy(driver);
2193
- return makeEnvironmentProviders([
2194
- {
2195
- provide: NestService,
2196
- useClass: NestService
2197
- },
2198
- {
2199
- deps: [NestService],
2200
- provide: NgQubeeService,
2201
- useFactory: (nestService) => new NgQubeeService(nestService, requestStrategy, driver, Object.assign({}, config.request))
2202
- },
2203
- {
2204
- provide: PaginationService,
2205
- useFactory: () => {
2206
- const responseConfig = Object.assign({}, config.response);
2207
- if (driver === DriverEnum.JSON_API) {
2208
- return new PaginationService(responseStrategy, new JsonApiResponseOptions(responseConfig));
2209
- }
2210
- return driver === DriverEnum.NESTJS
2211
- ? new PaginationService(responseStrategy, new NestjsResponseOptions(responseConfig))
2212
- : new PaginationService(responseStrategy, responseConfig);
2213
- }
2214
- }
2215
- ]);
2787
+ return makeEnvironmentProviders(buildNgQubeeProviders(config));
2216
2788
  }
2217
-
2218
2789
  /**
2219
- * Enum representing the available filter operators for the NestJS driver
2790
+ * Providers for a component-scoped NgQubee instance
2220
2791
  *
2221
- * These operators map to the nestjs-paginate filter syntax:
2222
- * `filter.field=$operator:value`
2792
+ * Use this inside a standalone component's `providers: [...]` to get a
2793
+ * dedicated `NgQubeeService` (and its `NestService` / `PaginationService`
2794
+ * collaborators) whose query-builder and pagination state does not bleed
2795
+ * with the app-wide shared instance provided by `provideNgQubee()`.
2223
2796
  *
2224
- * @see https://github.com/ppetzold/nestjs-paginate
2797
+ * @usageNotes
2798
+ *
2799
+ * ```
2800
+ * @Component({
2801
+ * standalone: true,
2802
+ * providers: [...provideNgQubeeInstance()]
2803
+ * })
2804
+ * export class MyFeatureComponent {
2805
+ * constructor(private _qb: NgQubeeService) {}
2806
+ * }
2807
+ * ```
2808
+ *
2809
+ * The driver, strategies, and options are inherited from the environment
2810
+ * injector (`provideNgQubee()` at root), so only the service instances are
2811
+ * re-created at the component level.
2812
+ *
2813
+ * @publicApi
2814
+ * @returns A provider array to spread into a component's `providers`
2225
2815
  */
2226
- var FilterOperatorEnum;
2227
- (function (FilterOperatorEnum) {
2228
- FilterOperatorEnum["BTW"] = "$btw";
2229
- FilterOperatorEnum["CONTAINS"] = "$contains";
2230
- FilterOperatorEnum["EQ"] = "$eq";
2231
- FilterOperatorEnum["GT"] = "$gt";
2232
- FilterOperatorEnum["GTE"] = "$gte";
2233
- FilterOperatorEnum["ILIKE"] = "$ilike";
2234
- FilterOperatorEnum["IN"] = "$in";
2235
- FilterOperatorEnum["LT"] = "$lt";
2236
- FilterOperatorEnum["LTE"] = "$lte";
2237
- FilterOperatorEnum["NOT"] = "$not";
2238
- FilterOperatorEnum["NULL"] = "$null";
2239
- FilterOperatorEnum["SW"] = "$sw";
2240
- })(FilterOperatorEnum || (FilterOperatorEnum = {}));
2816
+ function provideNgQubeeInstance() {
2817
+ return [NestService, NgQubeeService, PaginationService];
2818
+ }
2819
+
2820
+ // @dynamic
2821
+ class NgQubeeModule {
2822
+ /**
2823
+ * Configure NgQubee for the root module
2824
+ *
2825
+ * @param config - Configuration object with driver, and optional request and response settings
2826
+ * @returns Module with providers configured for the specified driver
2827
+ */
2828
+ static forRoot(config) {
2829
+ return {
2830
+ ngModule: NgQubeeModule,
2831
+ providers: buildNgQubeeProviders(config)
2832
+ };
2833
+ }
2834
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2835
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule });
2836
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule });
2837
+ }
2838
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeModule, decorators: [{
2839
+ type: NgModule,
2840
+ args: [{}]
2841
+ }] });
2241
2842
 
2242
2843
  /*
2243
2844
  * Public API Surface of angular-query-builder
@@ -2247,5 +2848,5 @@ var FilterOperatorEnum;
2247
2848
  * Generated bundle index. Do not edit.
2248
2849
  */
2249
2850
 
2250
- export { DriverEnum, FilterOperatorEnum, InvalidLimitError, InvalidPageNumberError, InvalidResourceNameError, JsonApiRequestStrategy, JsonApiResponseStrategy, KeyNotFoundError, LaravelRequestStrategy, LaravelResponseStrategy, NestjsRequestStrategy, NestjsResponseStrategy, NgQubeeModule, NgQubeeService, PaginatedCollection, PaginationService, SortEnum, SpatieRequestStrategy, SpatieResponseStrategy, UnselectableModelError, UnsupportedFieldSelectionError, UnsupportedFilterError, UnsupportedFilterOperatorError, UnsupportedIncludesError, UnsupportedSearchError, UnsupportedSelectError, UnsupportedSortError, provideNgQubee };
2851
+ export { DriverEnum, FilterOperatorEnum, InvalidFilterOperatorValueError, InvalidLimitError, InvalidPageNumberError, InvalidResourceNameError, JsonApiRequestStrategy, JsonApiResponseStrategy, KeyNotFoundError, LaravelRequestStrategy, LaravelResponseStrategy, NG_QUBEE_DRIVER, NG_QUBEE_REQUEST_OPTIONS, NG_QUBEE_REQUEST_STRATEGY, NG_QUBEE_RESPONSE_OPTIONS, NG_QUBEE_RESPONSE_STRATEGY, NestjsRequestStrategy, NestjsResponseStrategy, NgQubeeModule, NgQubeeService, PaginatedCollection, PaginationModeEnum, PaginationNotSyncedError, PaginationService, PostgrestRequestStrategy, PostgrestResponseStrategy, SortEnum, SpatieRequestStrategy, SpatieResponseStrategy, UnselectableModelError, UnsupportedFieldSelectionError, UnsupportedFilterError, UnsupportedFilterOperatorError, UnsupportedIncludesError, UnsupportedSearchError, UnsupportedSelectError, UnsupportedSortError, buildNgQubeeProviders, provideNgQubee, provideNgQubeeInstance, readHeader };
2251
2852
  //# sourceMappingURL=ng-qubee.mjs.map