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