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