ng-qubee 3.0.0 → 3.1.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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, Inject, Optional, NgModule, makeEnvironmentProviders } from '@angular/core';
2
+ import { signal, computed, Injectable, NgModule, makeEnvironmentProviders } from '@angular/core';
3
3
  import { BehaviorSubject, filter, throwError } from 'rxjs';
4
4
  import * as qs from 'qs';
5
5
 
@@ -74,6 +74,7 @@ class PaginatedCollection {
74
74
  */
75
75
  var DriverEnum;
76
76
  (function (DriverEnum) {
77
+ DriverEnum["JSON_API"] = "json-api";
77
78
  DriverEnum["LARAVEL"] = "laravel";
78
79
  DriverEnum["NESTJS"] = "nestjs";
79
80
  DriverEnum["SPATIE"] = "spatie";
@@ -121,6 +122,31 @@ class ResponseOptions {
121
122
  this.total = options.total || 'total';
122
123
  }
123
124
  }
125
+ /**
126
+ * Pre-configured ResponseOptions for the JSON:API driver
127
+ *
128
+ * Uses dot-notation paths to access nested values in the JSON:API response format.
129
+ * JSON:API meta key names vary by implementation; these defaults cover the most
130
+ * common conventions and can be fully customised via `IPaginationConfig`.
131
+ */
132
+ class JsonApiResponseOptions extends ResponseOptions {
133
+ constructor(options) {
134
+ super({
135
+ currentPage: options.currentPage || 'meta.current-page',
136
+ data: options.data || 'data',
137
+ firstPageUrl: options.firstPageUrl || 'links.first',
138
+ from: options.from || 'meta.from',
139
+ lastPage: options.lastPage || 'meta.page-count',
140
+ lastPageUrl: options.lastPageUrl || 'links.last',
141
+ nextPageUrl: options.nextPageUrl || 'links.next',
142
+ path: options.path || 'path',
143
+ perPage: options.perPage || 'meta.per-page',
144
+ prevPageUrl: options.prevPageUrl || 'links.prev',
145
+ to: options.to || 'meta.to',
146
+ total: options.total || 'meta.total'
147
+ });
148
+ }
149
+ }
124
150
  /**
125
151
  * Pre-configured ResponseOptions for the NestJS driver
126
152
  *
@@ -263,203 +289,539 @@ class QueryBuilderOptions {
263
289
  }
264
290
  }
265
291
 
266
- class InvalidLimitError extends Error {
267
- constructor(limit) {
268
- super(`Invalid limit value: Limit must be a positive integer greater than 0. Received: ${limit}`);
269
- this.name = 'InvalidLimitError';
270
- }
271
- }
272
-
273
- /**
274
- * Error thrown when an invalid resource name is provided
275
- *
276
- * Resource name must be a non-empty string.
277
- */
278
- class InvalidResourceNameError extends Error {
279
- constructor(resource) {
280
- super(`Invalid resource name: Resource name must be a non-empty string. Received: ${JSON.stringify(resource)}`);
281
- this.name = 'InvalidResourceNameError';
282
- }
283
- }
284
-
285
- class InvalidPageNumberError extends Error {
286
- constructor(page) {
287
- super(`Invalid page number: Page must be a positive integer greater than 0. Received: ${page}`);
288
- this.name = 'InvalidPageNumberError';
289
- }
290
- }
291
-
292
- const INITIAL_STATE = {
293
- baseUrl: '',
294
- fields: {},
295
- filters: {},
296
- includes: [],
297
- limit: 15,
298
- operatorFilters: [],
299
- page: 1,
300
- resource: '',
301
- search: '',
302
- select: [],
303
- sorts: []
304
- };
305
- class NestService {
292
+ class NgQubeeService {
293
+ _nestService;
306
294
  /**
307
- * Private writable signal that holds the Query Builder state
308
- *
309
- * @type {IQueryBuilderState}
295
+ * The active pagination driver
310
296
  */
311
- _nest = signal(this._clone(INITIAL_STATE), ...(ngDevMode ? [{ debugName: "_nest" }] : []));
297
+ _driver;
312
298
  /**
313
- * A computed signal that makes readonly the writable signal _nest
314
- *
315
- * @type {Signal<IQueryBuilderState>}
299
+ * Resolved query parameter key name options
316
300
  */
317
- nest = computed(() => this._clone(this._nest()), ...(ngDevMode ? [{ debugName: "nest" }] : []));
318
- constructor() {
319
- // Nothing to see here
320
- }
301
+ _options;
321
302
  /**
322
- * Set the base URL for the API
323
- *
324
- * @param {string} baseUrl - The base URL to prepend to generated URIs
325
- * @example
326
- * service.baseUrl = 'https://api.example.com';
303
+ * The request strategy that builds URIs for the active driver
327
304
  */
328
- set baseUrl(baseUrl) {
329
- this._nest.update(nest => ({
330
- ...nest,
331
- baseUrl
332
- }));
305
+ _requestStrategy;
306
+ /**
307
+ * Internal BehaviorSubject that holds the latest generated URI
308
+ */
309
+ _uri$ = new BehaviorSubject('');
310
+ /**
311
+ * Observable that emits non-empty generated URIs
312
+ */
313
+ uri$ = this._uri$.asObservable().pipe(filter(uri => !!uri));
314
+ constructor(_nestService, requestStrategy, driver, options = {}) {
315
+ this._nestService = _nestService;
316
+ this._driver = driver;
317
+ this._options = new QueryBuilderOptions(options);
318
+ this._requestStrategy = requestStrategy;
333
319
  }
334
320
  /**
335
- * Set the limit for paginated results
336
- * Must be a positive integer greater than 0
321
+ * Assert that the active driver is one of the allowed drivers
337
322
  *
338
- * @param {number} limit - The number of items per page
339
- * @throws {InvalidLimitError} If limit is not a positive integer
340
- * @example
341
- * service.limit = 25;
323
+ * @param allowed - The allowed drivers
324
+ * @param error - The error to throw if the driver is not allowed
325
+ * @throws The provided error if the active driver is not in the allowed list
342
326
  */
343
- set limit(limit) {
344
- this._validateLimit(limit);
345
- this._nest.update(nest => ({
346
- ...nest,
347
- limit
348
- }));
327
+ _assertDriver(allowed, error) {
328
+ if (!allowed.includes(this._driver)) {
329
+ throw error;
330
+ }
349
331
  }
350
332
  /**
351
- * Set the page number for pagination
352
- * Must be a positive integer greater than 0
333
+ * Add fields to the select statement for the given model (JSON:API and Spatie only)
353
334
  *
354
- * @param {number} page - The page number to fetch
355
- * @throws {InvalidPageNumberError} If page is not a positive integer
356
- * @example
357
- * service.page = 2;
335
+ * @param model - Model that holds the fields
336
+ * @param fields - Fields to select
337
+ * @returns {this}
338
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
358
339
  */
359
- set page(page) {
360
- this._validatePageNumber(page);
361
- this._nest.update(nest => ({
362
- ...nest,
363
- page
364
- }));
340
+ addFields(model, fields) {
341
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
342
+ if (!fields.length) {
343
+ return this;
344
+ }
345
+ this._nestService.addFields({ [model]: fields });
346
+ return this;
365
347
  }
366
348
  /**
367
- * Set the resource name for the query
368
- * Must be a non-empty string
349
+ * Add a filter with the given value(s) (JSON:API, NestJS, and Spatie)
369
350
  *
370
- * @param {string} resource - The API resource name (e.g., 'users', 'posts')
371
- * @throws {InvalidResourceNameError} If resource is not a non-empty string
372
- * @example
373
- * service.resource = 'users';
351
+ * Produces: `filter[field]=value` (JSON:API / Spatie) or `filter.field=value` (NestJS)
352
+ *
353
+ * @param {string} field - Name of the field to filter
354
+ * @param {(string | number | boolean)[]} values - The needle(s)
355
+ * @returns {this}
356
+ * @throws {UnsupportedFilterError} If the active driver does not support filters
374
357
  */
375
- set resource(resource) {
376
- this._validateResourceName(resource);
377
- this._nest.update(nest => ({
378
- ...nest,
379
- resource
380
- }));
381
- }
382
- _clone(obj) {
383
- return JSON.parse(JSON.stringify(obj));
358
+ addFilter(field, ...values) {
359
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedFilterError());
360
+ if (!values.length) {
361
+ return this;
362
+ }
363
+ this._nestService.addFilters({
364
+ [field]: values
365
+ });
366
+ return this;
384
367
  }
385
368
  /**
386
- * Validates that the limit is a positive integer
369
+ * Add a filter with an explicit operator (NestJS only)
387
370
  *
388
- * @param {number} limit - The limit value to validate
389
- * @throws {InvalidLimitError} If limit is not a positive integer
390
- * @private
371
+ * Produces: `filter.field=$operator:value`
372
+ *
373
+ * @param {string} field - Name of the field to filter
374
+ * @param {FilterOperatorEnum} operator - The filter operator to apply
375
+ * @param {(string | number | boolean)[]} values - The value(s) for the filter
376
+ * @returns {this}
377
+ * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
391
378
  */
392
- _validateLimit(limit) {
393
- if (!Number.isInteger(limit) || limit < 1) {
394
- throw new InvalidLimitError(limit);
379
+ addFilterOperator(field, operator, ...values) {
380
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
381
+ if (!values.length) {
382
+ return this;
395
383
  }
384
+ this._nestService.addOperatorFilters([{ field, operator, values }]);
385
+ return this;
396
386
  }
397
387
  /**
398
- * Validates that the page number is a positive integer
388
+ * Add related entities to include in the request (JSON:API and Spatie only)
399
389
  *
400
- * @param {number} page - The page number to validate
401
- * @throws {InvalidPageNumberError} If page is not a positive integer
402
- * @private
390
+ * @param {string[]} models - Models to include
391
+ * @returns {this}
392
+ * @throws {UnsupportedIncludesError} If the active driver does not support includes
403
393
  */
404
- _validatePageNumber(page) {
405
- if (!Number.isInteger(page) || page < 1) {
406
- throw new InvalidPageNumberError(page);
394
+ addIncludes(...models) {
395
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedIncludesError());
396
+ if (!models.length) {
397
+ return this;
407
398
  }
399
+ this._nestService.addIncludes(models);
400
+ return this;
408
401
  }
409
402
  /**
410
- * Validates that the resource name is a non-empty string
403
+ * Add flat field selection (NestJS only)
411
404
  *
412
- * @param {string} resource - The resource name to validate
413
- * @throws {InvalidResourceNameError} If resource is not a non-empty string
414
- * @private
405
+ * Produces: `select=col1,col2`
406
+ *
407
+ * @param {string[]} fields - Fields to select
408
+ * @returns {this}
409
+ * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
415
410
  */
416
- _validateResourceName(resource) {
417
- if (!resource || typeof resource !== 'string' || resource.trim().length === 0) {
418
- throw new InvalidResourceNameError(resource);
411
+ addSelect(...fields) {
412
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
413
+ if (!fields.length) {
414
+ return this;
419
415
  }
416
+ this._nestService.addSelect(fields);
417
+ return this;
420
418
  }
421
419
  /**
422
- * Add selectable fields for the given model to the request
423
- * Automatically prevents duplicate fields for each model
420
+ * Add a field with a sort criteria (JSON:API, NestJS, and Spatie)
424
421
  *
425
- * @param {IFields} fields - Object mapping model names to arrays of field names
426
- * @return {void}
427
- * @example
428
- * service.addFields({ users: ['id', 'email', 'username'] });
429
- * service.addFields({ posts: ['title', 'content'] });
422
+ * @param field - Field to use for sorting
423
+ * @param {SortEnum} order - A value from the SortEnum enumeration
424
+ * @returns {this}
425
+ * @throws {UnsupportedSortError} If the active driver does not support sorts
430
426
  */
431
- addFields(fields) {
432
- this._nest.update(nest => {
433
- const mergedFields = { ...nest.fields };
434
- Object.keys(fields).forEach(model => {
435
- const existingFields = mergedFields[model] || [];
436
- const newFields = fields[model];
437
- // Use Set to prevent duplicates
438
- const uniqueFields = Array.from(new Set([...existingFields, ...newFields]));
439
- mergedFields[model] = uniqueFields;
440
- });
441
- return {
442
- ...nest,
443
- fields: mergedFields
444
- };
427
+ addSort(field, order) {
428
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedSortError());
429
+ this._nestService.addSort({
430
+ field,
431
+ order
445
432
  });
433
+ return this;
446
434
  }
447
435
  /**
448
- * Add filters to the request
449
- * Automatically prevents duplicate filter values for each filter key
436
+ * Delete selected fields for the given models in the current query builder state (JSON:API and Spatie only)
450
437
  *
451
- * @param {IFilters} filters - Object mapping filter keys to arrays of values
452
- * @return {void}
453
- * @example
454
- * service.addFilters({ id: [1, 2, 3] });
455
- * service.addFilters({ status: ['active', 'pending'] });
456
- */
457
- addFilters(filters) {
458
- this._nest.update(nest => {
459
- const mergedFilters = { ...nest.filters };
460
- Object.keys(filters).forEach(key => {
461
- const existingValues = mergedFilters[key] || [];
462
- const newValues = filters[key];
438
+ * ```
439
+ * ngQubeeService.deleteFields({
440
+ * users: ['email', 'password'],
441
+ * address: ['zipcode']
442
+ * });
443
+ * ```
444
+ *
445
+ * @param {IFields} fields - Object mapping model names to field arrays to remove
446
+ * @returns {this}
447
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
448
+ */
449
+ deleteFields(fields) {
450
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
451
+ this._nestService.deleteFields(fields);
452
+ return this;
453
+ }
454
+ /**
455
+ * Delete selected fields for the given model in the current query builder state (JSON:API and Spatie only)
456
+ *
457
+ * ```
458
+ * ngQubeeService.deleteFieldsByModel('users', 'email', 'password');
459
+ * ```
460
+ *
461
+ * @param model - Model that holds the fields
462
+ * @param {string[]} fields - Fields to delete from the state
463
+ * @returns {this}
464
+ * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
465
+ */
466
+ deleteFieldsByModel(model, ...fields) {
467
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
468
+ if (!fields.length) {
469
+ return this;
470
+ }
471
+ this._nestService.deleteFields({
472
+ [model]: fields
473
+ });
474
+ return this;
475
+ }
476
+ /**
477
+ * Remove given filters from the query builder state (JSON:API, NestJS, and Spatie)
478
+ *
479
+ * @param {string[]} filters - Filters to remove
480
+ * @returns {this}
481
+ * @throws {UnsupportedFilterError} If the active driver does not support filters
482
+ */
483
+ deleteFilters(...filters) {
484
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedFilterError());
485
+ if (!filters.length) {
486
+ return this;
487
+ }
488
+ this._nestService.deleteFilters(...filters);
489
+ return this;
490
+ }
491
+ /**
492
+ * Remove selected related models from the query builder state (JSON:API and Spatie only)
493
+ *
494
+ * @param {string[]} includes - Models to remove
495
+ * @returns {this}
496
+ * @throws {UnsupportedIncludesError} If the active driver does not support includes
497
+ */
498
+ deleteIncludes(...includes) {
499
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.SPATIE], new UnsupportedIncludesError());
500
+ if (!includes.length) {
501
+ return this;
502
+ }
503
+ this._nestService.deleteIncludes(...includes);
504
+ return this;
505
+ }
506
+ /**
507
+ * Remove operator filters by field name (NestJS only)
508
+ *
509
+ * @param {string[]} fields - Field names of operator filters to remove
510
+ * @returns {this}
511
+ * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
512
+ */
513
+ deleteOperatorFilters(...fields) {
514
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
515
+ if (!fields.length) {
516
+ return this;
517
+ }
518
+ this._nestService.deleteOperatorFilters(...fields);
519
+ return this;
520
+ }
521
+ /**
522
+ * Remove search term from the query builder state (NestJS only)
523
+ *
524
+ * @returns {this}
525
+ * @throws {UnsupportedSearchError} If the active driver does not support search
526
+ */
527
+ deleteSearch() {
528
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
529
+ this._nestService.deleteSearch();
530
+ return this;
531
+ }
532
+ /**
533
+ * Remove flat field selections from the query builder state (NestJS only)
534
+ *
535
+ * @param {string[]} fields - Fields to remove from selection
536
+ * @returns {this}
537
+ * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
538
+ */
539
+ deleteSelect(...fields) {
540
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
541
+ if (!fields.length) {
542
+ return this;
543
+ }
544
+ this._nestService.deleteSelect(...fields);
545
+ return this;
546
+ }
547
+ /**
548
+ * Remove sort rules from the query builder state (JSON:API, NestJS, and Spatie)
549
+ *
550
+ * @param sorts - Fields used for sorting to remove
551
+ * @returns {this}
552
+ * @throws {UnsupportedSortError} If the active driver does not support sorts
553
+ */
554
+ deleteSorts(...sorts) {
555
+ this._assertDriver([DriverEnum.JSON_API, DriverEnum.NESTJS, DriverEnum.SPATIE], new UnsupportedSortError());
556
+ this._nestService.deleteSorts(...sorts);
557
+ return this;
558
+ }
559
+ /**
560
+ * Generate a URI accordingly to the given data and active driver
561
+ *
562
+ * @returns {Observable<string>} An observable that emits the generated URI
563
+ */
564
+ generateUri() {
565
+ try {
566
+ this._uri$.next(this._requestStrategy.buildUri(this._nestService.nest(), this._options));
567
+ return this.uri$;
568
+ }
569
+ catch (error) {
570
+ return throwError(() => error);
571
+ }
572
+ }
573
+ /**
574
+ * Clear the current state and reset the Query Builder to a fresh, clean condition
575
+ *
576
+ * @returns {this}
577
+ */
578
+ reset() {
579
+ this._nestService.reset();
580
+ return this;
581
+ }
582
+ /**
583
+ * Set the base URL to use for composing the address
584
+ *
585
+ * @param {string} baseUrl - The base URL
586
+ * @returns {this}
587
+ */
588
+ setBaseUrl(baseUrl) {
589
+ this._nestService.baseUrl = baseUrl;
590
+ return this;
591
+ }
592
+ /**
593
+ * Set the items per page number
594
+ *
595
+ * Validation is delegated to the active request strategy because the
596
+ * accepted range is driver-specific: nestjs-paginate additionally accepts
597
+ * `-1` as a "fetch all" sentinel, while Laravel, Spatie, and JSON:API
598
+ * require a positive integer.
599
+ *
600
+ * @param limit - Number of items per page (or `-1` to fetch all, NestJS only)
601
+ * @returns {this}
602
+ * @throws {import('../errors/invalid-limit.error').InvalidLimitError} If the value is not accepted by the active driver
603
+ */
604
+ setLimit(limit) {
605
+ this._requestStrategy.validateLimit(limit);
606
+ this._nestService.limit = limit;
607
+ return this;
608
+ }
609
+ /**
610
+ * Set the page that the backend will use to paginate the result set
611
+ *
612
+ * @param page - Page number
613
+ * @returns {this}
614
+ */
615
+ setPage(page) {
616
+ this._nestService.page = page;
617
+ return this;
618
+ }
619
+ /**
620
+ * Set the API resource to run the query against
621
+ *
622
+ * @param {string} resource - Resource name (e.g. 'users' produces /users)
623
+ * @returns {this}
624
+ */
625
+ setResource(resource) {
626
+ this._nestService.resource = resource;
627
+ return this;
628
+ }
629
+ /**
630
+ * Set the search term for full-text search (NestJS only)
631
+ *
632
+ * Produces: `search=term`
633
+ *
634
+ * @param {string} search - The search term
635
+ * @returns {this}
636
+ * @throws {UnsupportedSearchError} If the active driver does not support search
637
+ */
638
+ setSearch(search) {
639
+ this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
640
+ this._nestService.setSearch(search);
641
+ return this;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Error thrown when an invalid resource name is provided
647
+ *
648
+ * Resource name must be a non-empty string.
649
+ */
650
+ class InvalidResourceNameError extends Error {
651
+ constructor(resource) {
652
+ super(`Invalid resource name: Resource name must be a non-empty string. Received: ${JSON.stringify(resource)}`);
653
+ this.name = 'InvalidResourceNameError';
654
+ }
655
+ }
656
+
657
+ class InvalidPageNumberError extends Error {
658
+ constructor(page) {
659
+ super(`Invalid page number: Page must be a positive integer greater than 0. Received: ${page}`);
660
+ this.name = 'InvalidPageNumberError';
661
+ }
662
+ }
663
+
664
+ const INITIAL_STATE = {
665
+ baseUrl: '',
666
+ fields: {},
667
+ filters: {},
668
+ includes: [],
669
+ limit: 15,
670
+ operatorFilters: [],
671
+ page: 1,
672
+ resource: '',
673
+ search: '',
674
+ select: [],
675
+ sorts: []
676
+ };
677
+ class NestService {
678
+ /**
679
+ * Private writable signal that holds the Query Builder state
680
+ *
681
+ * @type {IQueryBuilderState}
682
+ */
683
+ _nest = signal(this._clone(INITIAL_STATE), ...(ngDevMode ? [{ debugName: "_nest" }] : []));
684
+ /**
685
+ * A computed signal that makes readonly the writable signal _nest
686
+ *
687
+ * @type {Signal<IQueryBuilderState>}
688
+ */
689
+ nest = computed(() => this._clone(this._nest()), ...(ngDevMode ? [{ debugName: "nest" }] : []));
690
+ constructor() {
691
+ // Nothing to see here
692
+ }
693
+ /**
694
+ * Set the base URL for the API
695
+ *
696
+ * @param {string} baseUrl - The base URL to prepend to generated URIs
697
+ * @example
698
+ * service.baseUrl = 'https://api.example.com';
699
+ */
700
+ set baseUrl(baseUrl) {
701
+ this._nest.update(nest => ({
702
+ ...nest,
703
+ baseUrl
704
+ }));
705
+ }
706
+ /**
707
+ * Set the limit for paginated results
708
+ *
709
+ * This setter performs a raw state write. Validation of the value is the
710
+ * responsibility of the active request strategy and is enforced upstream
711
+ * by `NgQubeeService.setLimit()`, because the accepted range depends on
712
+ * the driver (e.g. nestjs-paginate accepts `-1` for "fetch all").
713
+ *
714
+ * @param {number} limit - The number of items per page
715
+ * @example
716
+ * service.limit = 25;
717
+ */
718
+ set limit(limit) {
719
+ this._nest.update(nest => ({
720
+ ...nest,
721
+ limit
722
+ }));
723
+ }
724
+ /**
725
+ * Set the page number for pagination
726
+ * Must be a positive integer greater than 0
727
+ *
728
+ * @param {number} page - The page number to fetch
729
+ * @throws {InvalidPageNumberError} If page is not a positive integer
730
+ * @example
731
+ * service.page = 2;
732
+ */
733
+ set page(page) {
734
+ this._validatePageNumber(page);
735
+ this._nest.update(nest => ({
736
+ ...nest,
737
+ page
738
+ }));
739
+ }
740
+ /**
741
+ * Set the resource name for the query
742
+ * Must be a non-empty string
743
+ *
744
+ * @param {string} resource - The API resource name (e.g., 'users', 'posts')
745
+ * @throws {InvalidResourceNameError} If resource is not a non-empty string
746
+ * @example
747
+ * service.resource = 'users';
748
+ */
749
+ set resource(resource) {
750
+ this._validateResourceName(resource);
751
+ this._nest.update(nest => ({
752
+ ...nest,
753
+ resource
754
+ }));
755
+ }
756
+ _clone(obj) {
757
+ return JSON.parse(JSON.stringify(obj));
758
+ }
759
+ /**
760
+ * Validates that the page number is a positive integer
761
+ *
762
+ * @param {number} page - The page number to validate
763
+ * @throws {InvalidPageNumberError} If page is not a positive integer
764
+ * @private
765
+ */
766
+ _validatePageNumber(page) {
767
+ if (!Number.isInteger(page) || page < 1) {
768
+ throw new InvalidPageNumberError(page);
769
+ }
770
+ }
771
+ /**
772
+ * Validates that the resource name is a non-empty string
773
+ *
774
+ * @param {string} resource - The resource name to validate
775
+ * @throws {InvalidResourceNameError} If resource is not a non-empty string
776
+ * @private
777
+ */
778
+ _validateResourceName(resource) {
779
+ if (!resource || typeof resource !== 'string' || resource.trim().length === 0) {
780
+ throw new InvalidResourceNameError(resource);
781
+ }
782
+ }
783
+ /**
784
+ * Add selectable fields for the given model to the request
785
+ * Automatically prevents duplicate fields for each model
786
+ *
787
+ * @param {IFields} fields - Object mapping model names to arrays of field names
788
+ * @return {void}
789
+ * @example
790
+ * service.addFields({ users: ['id', 'email', 'username'] });
791
+ * service.addFields({ posts: ['title', 'content'] });
792
+ */
793
+ addFields(fields) {
794
+ this._nest.update(nest => {
795
+ const mergedFields = { ...nest.fields };
796
+ Object.keys(fields).forEach(model => {
797
+ const existingFields = mergedFields[model] || [];
798
+ const newFields = fields[model];
799
+ // Use Set to prevent duplicates
800
+ const uniqueFields = Array.from(new Set([...existingFields, ...newFields]));
801
+ mergedFields[model] = uniqueFields;
802
+ });
803
+ return {
804
+ ...nest,
805
+ fields: mergedFields
806
+ };
807
+ });
808
+ }
809
+ /**
810
+ * Add filters to the request
811
+ * Automatically prevents duplicate filter values for each filter key
812
+ *
813
+ * @param {IFilters} filters - Object mapping filter keys to arrays of values
814
+ * @return {void}
815
+ * @example
816
+ * service.addFilters({ id: [1, 2, 3] });
817
+ * service.addFilters({ status: ['active', 'pending'] });
818
+ */
819
+ addFilters(filters) {
820
+ this._nest.update(nest => {
821
+ const mergedFilters = { ...nest.filters };
822
+ Object.keys(filters).forEach(key => {
823
+ const existingValues = mergedFilters[key] || [];
824
+ const newValues = filters[key];
463
825
  // Use Set to prevent duplicates
464
826
  const uniqueValues = Array.from(new Set([...existingValues, ...newValues]));
465
827
  mergedFilters[key] = uniqueValues;
@@ -635,483 +997,444 @@ class NestService {
635
997
  * @return {void}
636
998
  * @example
637
999
  * service.deleteSearch();
638
- */
639
- deleteSearch() {
640
- this._nest.update(nest => ({
641
- ...nest,
642
- search: ''
643
- }));
644
- }
645
- /**
646
- * Remove flat field selections from the state (NestJS only)
647
- *
648
- * @param {...string[]} fields - Field names to remove from selection
649
- * @return {void}
650
- * @example
651
- * service.deleteSelect('email');
652
- * service.deleteSelect('name', 'email');
653
- */
654
- deleteSelect(...fields) {
655
- this._nest.update(nest => ({
656
- ...nest,
657
- select: nest.select.filter(f => !fields.includes(f))
658
- }));
659
- }
660
- /**
661
- * Remove sorts from the request by field name
662
- *
663
- * @param {...string[]} sorts - Field names of sorts to remove
664
- * @return {void}
665
- * @example
666
- * service.deleteSorts('created_at');
667
- * service.deleteSorts('name', 'created_at');
668
- */
669
- deleteSorts(...sorts) {
670
- const s = [...this._nest().sorts];
671
- sorts.forEach(field => {
672
- const p = s.findIndex(sort => sort.field === field);
673
- if (p > -1) {
674
- s.splice(p, 1);
675
- }
676
- });
677
- this._nest.update(nest => ({
678
- ...nest,
679
- sorts: s
680
- }));
681
- }
682
- /**
683
- * Set the full-text search term (NestJS only)
684
- *
685
- * @param {string} search - The search term
686
- * @return {void}
687
- * @example
688
- * service.setSearch('john doe');
689
- */
690
- setSearch(search) {
691
- this._nest.update(nest => ({
692
- ...nest,
693
- search
694
- }));
695
- }
696
- /**
697
- * Reset the query builder state to initial values
698
- * Clears all fields, filters, includes, sorts, and resets pagination
699
- *
700
- * @return {void}
701
- * @example
702
- * service.reset();
703
- */
704
- reset() {
705
- this._nest.update(_ => this._clone(INITIAL_STATE));
706
- }
707
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
708
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService });
709
- }
710
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, decorators: [{
711
- type: Injectable
712
- }], ctorParameters: () => [] });
713
-
714
- class NgQubeeService {
715
- _nestService;
716
- /**
717
- * The active pagination driver
718
- */
719
- _driver;
720
- /**
721
- * Resolved query parameter key name options
722
- */
723
- _options;
724
- /**
725
- * The request strategy that builds URIs for the active driver
726
- */
727
- _requestStrategy;
728
- /**
729
- * Internal BehaviorSubject that holds the latest generated URI
730
- */
731
- _uri$ = new BehaviorSubject('');
732
- /**
733
- * Observable that emits non-empty generated URIs
734
- */
735
- uri$ = this._uri$.asObservable().pipe(filter(uri => !!uri));
736
- constructor(_nestService, requestStrategy, driver, options = {}) {
737
- this._nestService = _nestService;
738
- this._driver = driver;
739
- this._options = new QueryBuilderOptions(options);
740
- this._requestStrategy = requestStrategy;
741
- }
742
- /**
743
- * Assert that the active driver is one of the allowed drivers
744
- *
745
- * @param allowed - The allowed drivers
746
- * @param error - The error to throw if the driver is not allowed
747
- * @throws The provided error if the active driver is not in the allowed list
748
- */
749
- _assertDriver(allowed, error) {
750
- if (!allowed.includes(this._driver)) {
751
- throw error;
752
- }
753
- }
754
- /**
755
- * Add fields to the select statement for the given model (Spatie only)
756
- *
757
- * @param model - Model that holds the fields
758
- * @param fields - Fields to select
759
- * @returns {this}
760
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
761
- */
762
- addFields(model, fields) {
763
- this._assertDriver([DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
764
- if (!fields.length) {
765
- return this;
766
- }
767
- this._nestService.addFields({ [model]: fields });
768
- return this;
769
- }
770
- /**
771
- * Add a filter with the given value(s) (Spatie and NestJS only)
772
- *
773
- * Produces: `filter[field]=value` (Spatie) or `filter.field=value` (NestJS)
774
- *
775
- * @param {string} field - Name of the field to filter
776
- * @param {(string | number | boolean)[]} values - The needle(s)
777
- * @returns {this}
778
- * @throws {UnsupportedFilterError} If the active driver does not support filters
779
- */
780
- addFilter(field, ...values) {
781
- this._assertDriver([DriverEnum.SPATIE, DriverEnum.NESTJS], new UnsupportedFilterError());
782
- if (!values.length) {
783
- return this;
784
- }
785
- this._nestService.addFilters({
786
- [field]: values
787
- });
788
- return this;
789
- }
790
- /**
791
- * Add a filter with an explicit operator (NestJS only)
792
- *
793
- * Produces: `filter.field=$operator:value`
794
- *
795
- * @param {string} field - Name of the field to filter
796
- * @param {FilterOperatorEnum} operator - The filter operator to apply
797
- * @param {(string | number | boolean)[]} values - The value(s) for the filter
798
- * @returns {this}
799
- * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
800
- */
801
- addFilterOperator(field, operator, ...values) {
802
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
803
- if (!values.length) {
804
- return this;
805
- }
806
- this._nestService.addOperatorFilters([{ field, operator, values }]);
807
- return this;
808
- }
809
- /**
810
- * Add related entities to include in the request (Spatie only)
811
- *
812
- * @param {string[]} models - Models to include
813
- * @returns {this}
814
- * @throws {UnsupportedIncludesError} If the active driver does not support includes
815
- */
816
- addIncludes(...models) {
817
- this._assertDriver([DriverEnum.SPATIE], new UnsupportedIncludesError());
818
- if (!models.length) {
819
- return this;
820
- }
821
- this._nestService.addIncludes(models);
822
- return this;
1000
+ */
1001
+ deleteSearch() {
1002
+ this._nest.update(nest => ({
1003
+ ...nest,
1004
+ search: ''
1005
+ }));
823
1006
  }
824
1007
  /**
825
- * Add flat field selection (NestJS only)
826
- *
827
- * Produces: `select=col1,col2`
1008
+ * Remove flat field selections from the state (NestJS only)
828
1009
  *
829
- * @param {string[]} fields - Fields to select
830
- * @returns {this}
831
- * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
1010
+ * @param {...string[]} fields - Field names to remove from selection
1011
+ * @return {void}
1012
+ * @example
1013
+ * service.deleteSelect('email');
1014
+ * service.deleteSelect('name', 'email');
832
1015
  */
833
- addSelect(...fields) {
834
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
835
- if (!fields.length) {
836
- return this;
837
- }
838
- this._nestService.addSelect(fields);
839
- return this;
1016
+ deleteSelect(...fields) {
1017
+ this._nest.update(nest => ({
1018
+ ...nest,
1019
+ select: nest.select.filter(f => !fields.includes(f))
1020
+ }));
840
1021
  }
841
1022
  /**
842
- * Add a field with a sort criteria (Spatie and NestJS only)
1023
+ * Remove sorts from the request by field name
843
1024
  *
844
- * @param field - Field to use for sorting
845
- * @param {SortEnum} order - A value from the SortEnum enumeration
846
- * @returns {this}
847
- * @throws {UnsupportedSortError} If the active driver does not support sorts
1025
+ * @param {...string[]} sorts - Field names of sorts to remove
1026
+ * @return {void}
1027
+ * @example
1028
+ * service.deleteSorts('created_at');
1029
+ * service.deleteSorts('name', 'created_at');
848
1030
  */
849
- addSort(field, order) {
850
- this._assertDriver([DriverEnum.SPATIE, DriverEnum.NESTJS], new UnsupportedSortError());
851
- this._nestService.addSort({
852
- field,
853
- order
1031
+ deleteSorts(...sorts) {
1032
+ const s = [...this._nest().sorts];
1033
+ sorts.forEach(field => {
1034
+ const p = s.findIndex(sort => sort.field === field);
1035
+ if (p > -1) {
1036
+ s.splice(p, 1);
1037
+ }
854
1038
  });
855
- return this;
1039
+ this._nest.update(nest => ({
1040
+ ...nest,
1041
+ sorts: s
1042
+ }));
856
1043
  }
857
1044
  /**
858
- * Delete selected fields for the given models in the current query builder state (Spatie only)
859
- *
860
- * ```
861
- * ngQubeeService.deleteFields({
862
- * users: ['email', 'password'],
863
- * address: ['zipcode']
864
- * });
865
- * ```
1045
+ * Set the full-text search term (NestJS only)
866
1046
  *
867
- * @param {IFields} fields - Object mapping model names to field arrays to remove
868
- * @returns {this}
869
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
1047
+ * @param {string} search - The search term
1048
+ * @return {void}
1049
+ * @example
1050
+ * service.setSearch('john doe');
870
1051
  */
871
- deleteFields(fields) {
872
- this._assertDriver([DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
873
- this._nestService.deleteFields(fields);
874
- return this;
1052
+ setSearch(search) {
1053
+ this._nest.update(nest => ({
1054
+ ...nest,
1055
+ search
1056
+ }));
875
1057
  }
876
1058
  /**
877
- * Delete selected fields for the given model in the current query builder state (Spatie only)
878
- *
879
- * ```
880
- * ngQubeeService.deleteFieldsByModel('users', 'email', 'password');
881
- * ```
1059
+ * Reset the query builder state to initial values
1060
+ * Clears all fields, filters, includes, sorts, and resets pagination
882
1061
  *
883
- * @param model - Model that holds the fields
884
- * @param {string[]} fields - Fields to delete from the state
885
- * @returns {this}
886
- * @throws {UnsupportedFieldSelectionError} If the active driver does not support per-model field selection
1062
+ * @return {void}
1063
+ * @example
1064
+ * service.reset();
887
1065
  */
888
- deleteFieldsByModel(model, ...fields) {
889
- this._assertDriver([DriverEnum.SPATIE], new UnsupportedFieldSelectionError());
890
- if (!fields.length) {
891
- return this;
892
- }
893
- this._nestService.deleteFields({
894
- [model]: fields
895
- });
896
- return this;
1066
+ reset() {
1067
+ this._nest.update(_ => this._clone(INITIAL_STATE));
897
1068
  }
1069
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1070
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService });
1071
+ }
1072
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NestService, decorators: [{
1073
+ type: Injectable
1074
+ }], ctorParameters: () => [] });
1075
+
1076
+ class PaginationService {
898
1077
  /**
899
- * Remove given filters from the query builder state (Spatie and NestJS only)
900
- *
901
- * @param {string[]} filters - Filters to remove
902
- * @returns {this}
903
- * @throws {UnsupportedFilterError} If the active driver does not support filters
1078
+ * Resolved response key name options
904
1079
  */
905
- deleteFilters(...filters) {
906
- this._assertDriver([DriverEnum.SPATIE, DriverEnum.NESTJS], new UnsupportedFilterError());
907
- if (!filters.length) {
908
- return this;
909
- }
910
- this._nestService.deleteFilters(...filters);
911
- return this;
912
- }
1080
+ _options;
913
1081
  /**
914
- * Remove selected related models from the query builder state (Spatie only)
915
- *
916
- * @param {string[]} includes - Models to remove
917
- * @returns {this}
918
- * @throws {UnsupportedIncludesError} If the active driver does not support includes
1082
+ * The response strategy that parses responses for the active driver
919
1083
  */
920
- deleteIncludes(...includes) {
921
- this._assertDriver([DriverEnum.SPATIE], new UnsupportedIncludesError());
922
- if (!includes.length) {
923
- return this;
924
- }
925
- this._nestService.deleteIncludes(...includes);
926
- return this;
1084
+ _responseStrategy;
1085
+ constructor(responseStrategy, options = {}) {
1086
+ this._options = new ResponseOptions(options);
1087
+ this._responseStrategy = responseStrategy;
927
1088
  }
928
1089
  /**
929
- * Remove operator filters by field name (NestJS only)
1090
+ * Transform a raw API response into a typed PaginatedCollection
930
1091
  *
931
- * @param {string[]} fields - Field names of operator filters to remove
932
- * @returns {this}
933
- * @throws {UnsupportedFilterOperatorError} If the active driver does not support filter operators
1092
+ * Delegates to the active driver's response strategy for parsing.
1093
+ *
1094
+ * @param response - The raw API response object
1095
+ * @returns A typed PaginatedCollection instance
934
1096
  */
935
- deleteOperatorFilters(...fields) {
936
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedFilterOperatorError());
937
- if (!fields.length) {
938
- return this;
939
- }
940
- this._nestService.deleteOperatorFilters(...fields);
941
- return this;
1097
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1098
+ paginate(response) {
1099
+ return this._responseStrategy.paginate(response, this._options);
942
1100
  }
1101
+ }
1102
+
1103
+ var SortEnum;
1104
+ (function (SortEnum) {
1105
+ SortEnum["ASC"] = "asc";
1106
+ SortEnum["DESC"] = "desc";
1107
+ })(SortEnum || (SortEnum = {}));
1108
+
1109
+ /**
1110
+ * Thrown when a limit value does not satisfy the active driver's constraints
1111
+ *
1112
+ * Validation is driver-scoped: most drivers require an integer `>= 1`, while
1113
+ * the NestJS driver additionally accepts `-1` as a "fetch all items" sentinel
1114
+ * (as documented by nestjs-paginate). The message is tailored accordingly so
1115
+ * the caller understands which values are permitted.
1116
+ */
1117
+ class InvalidLimitError extends Error {
943
1118
  /**
944
- * Remove search term from the query builder state (NestJS only)
945
- *
946
- * @returns {this}
947
- * @throws {UnsupportedSearchError} If the active driver does not support search
1119
+ * @param limit - The rejected limit value
1120
+ * @param allowFetchAll - Whether the active driver accepts `-1` (fetch all)
948
1121
  */
949
- deleteSearch() {
950
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
951
- this._nestService.deleteSearch();
952
- return this;
1122
+ constructor(limit, allowFetchAll = false) {
1123
+ const allowed = allowFetchAll
1124
+ ? 'a positive integer greater than 0, or -1 to fetch all items'
1125
+ : 'a positive integer greater than 0';
1126
+ super(`Invalid limit value: Limit must be ${allowed}. Received: ${limit}`);
1127
+ this.name = 'InvalidLimitError';
1128
+ }
1129
+ }
1130
+
1131
+ class UnselectableModelError extends Error {
1132
+ constructor(model) {
1133
+ super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
953
1134
  }
1135
+ }
1136
+
1137
+ /**
1138
+ * Request strategy for the JSON:API driver
1139
+ *
1140
+ * Generates URIs in the JSON:API format:
1141
+ * - Fields: `fields[articles]=title,body&fields[people]=name`
1142
+ * - Filters: `filter[status]=active`
1143
+ * - Includes: `include=author,comments.author`
1144
+ * - Pagination: `page[number]=1&page[size]=15`
1145
+ * - Sort: `sort=-created_at,name` (- prefix = DESC)
1146
+ *
1147
+ * @see https://jsonapi.org/format/
1148
+ */
1149
+ class JsonApiRequestStrategy {
954
1150
  /**
955
- * Remove flat field selections from the query builder state (NestJS only)
1151
+ * Accumulator for composing the URI string
1152
+ */
1153
+ _uri = '';
1154
+ /**
1155
+ * Build a URI string from the given state using the JSON:API format
956
1156
  *
957
- * @param {string[]} fields - Fields to remove from selection
958
- * @returns {this}
959
- * @throws {UnsupportedSelectError} If the active driver does not support flat field selection
1157
+ * @param state - The current query builder state
1158
+ * @param options - The query parameter key name configuration
1159
+ * @returns The composed URI string
1160
+ * @throws Error if resource is not set
960
1161
  */
961
- deleteSelect(...fields) {
962
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSelectError());
963
- if (!fields.length) {
964
- return this;
1162
+ buildUri(state, options) {
1163
+ if (!state.resource) {
1164
+ throw new Error('Set the resource property BEFORE adding filters or calling the url() / get() methods');
965
1165
  }
966
- this._nestService.deleteSelect(...fields);
967
- return this;
1166
+ this._uri = '';
1167
+ this._parseIncludes(state, options);
1168
+ this._parseFields(state, options);
1169
+ this._parseFilters(state, options);
1170
+ this._parsePagination(state, options);
1171
+ this._parseSort(state, options);
1172
+ return this._uri;
968
1173
  }
969
1174
  /**
970
- * Remove sort rules from the query builder state (Spatie and NestJS only)
1175
+ * Validate that the given limit is accepted by the JSON:API driver
971
1176
  *
972
- * @param sorts - Fields used for sorting to remove
973
- * @returns {this}
974
- * @throws {UnsupportedSortError} If the active driver does not support sorts
1177
+ * The JSON:API specification leaves pagination semantics to the server and
1178
+ * does not define a "fetch all" sentinel, so only positive integers are
1179
+ * accepted.
1180
+ *
1181
+ * @param limit - The limit value to validate
1182
+ * @throws {InvalidLimitError} If the value is not a positive integer
975
1183
  */
976
- deleteSorts(...sorts) {
977
- this._assertDriver([DriverEnum.SPATIE, DriverEnum.NESTJS], new UnsupportedSortError());
978
- this._nestService.deleteSorts(...sorts);
979
- return this;
1184
+ validateLimit(limit) {
1185
+ if (Number.isInteger(limit) && limit >= 1) {
1186
+ return;
1187
+ }
1188
+ throw new InvalidLimitError(limit);
980
1189
  }
981
1190
  /**
982
- * Generate a URI accordingly to the given data and active driver
1191
+ * Parse and append field selection parameters
983
1192
  *
984
- * @returns {Observable<string>} An observable that emits the generated URI
1193
+ * Validates that each field model exists either as the main resource
1194
+ * or in the includes list. Fields are grouped by type in bracket notation.
1195
+ *
1196
+ * @param state - The current query builder state
1197
+ * @param options - The query parameter key name configuration
1198
+ * @returns The generated field selection parameter string
1199
+ * @throws Error if resource is required but not set
1200
+ * @throws UnselectableModelError if a field model is not in resource or includes
985
1201
  */
986
- generateUri() {
987
- try {
988
- this._uri$.next(this._requestStrategy.buildUri(this._nestService.nest(), this._options));
989
- return this.uri$;
1202
+ _parseFields(state, options) {
1203
+ if (!Object.keys(state.fields).length) {
1204
+ return this._uri;
990
1205
  }
991
- catch (error) {
992
- return throwError(() => error);
1206
+ if (!state.resource) {
1207
+ throw new Error('While selecting fields, the -> resource <- is required');
1208
+ }
1209
+ if (!(state.resource in state.fields)) {
1210
+ throw new Error(`Key ${state.resource} is missing in the fields object`);
1211
+ }
1212
+ const f = {};
1213
+ for (const k in state.fields) {
1214
+ if (state.fields.hasOwnProperty(k)) {
1215
+ if (k !== state.resource && !state.includes.includes(k)) {
1216
+ throw new UnselectableModelError(k);
1217
+ }
1218
+ Object.assign(f, { [`${options.fields}[${k}]`]: state.fields[k].join(',') });
1219
+ }
993
1220
  }
1221
+ const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1222
+ this._uri += param;
1223
+ return param;
994
1224
  }
995
1225
  /**
996
- * Clear the current state and reset the Query Builder to a fresh, clean condition
1226
+ * Parse and append filter parameters
997
1227
  *
998
- * @returns {this}
1228
+ * Generates filter parameters in bracket notation: `filter[key]=value1,value2`
1229
+ *
1230
+ * @param state - The current query builder state
1231
+ * @param options - The query parameter key name configuration
1232
+ * @returns The generated filter parameter string
999
1233
  */
1000
- reset() {
1001
- this._nestService.reset();
1002
- return this;
1234
+ _parseFilters(state, options) {
1235
+ const keys = Object.keys(state.filters);
1236
+ if (!keys.length) {
1237
+ return this._uri;
1238
+ }
1239
+ const f = {
1240
+ [`${options.filters}`]: keys.reduce((acc, key) => {
1241
+ return Object.assign(acc, { [key]: state.filters[key].join(',') });
1242
+ }, {})
1243
+ };
1244
+ const param = `${this._prepend(state)}${qs.stringify(f, { encode: false })}`;
1245
+ this._uri += param;
1246
+ return param;
1003
1247
  }
1004
1248
  /**
1005
- * Set the base URL to use for composing the address
1249
+ * Parse and append include parameters
1006
1250
  *
1007
- * @param {string} baseUrl - The base URL
1008
- * @returns {this}
1251
+ * Generates: `include=author,comments.author`
1252
+ *
1253
+ * @param state - The current query builder state
1254
+ * @param options - The query parameter key name configuration
1255
+ * @returns The generated include parameter string
1009
1256
  */
1010
- setBaseUrl(baseUrl) {
1011
- this._nestService.baseUrl = baseUrl;
1012
- return this;
1257
+ _parseIncludes(state, options) {
1258
+ if (!state.includes.length) {
1259
+ return this._uri;
1260
+ }
1261
+ const param = `${this._prepend(state)}${options.includes}=${state.includes}`;
1262
+ this._uri += param;
1263
+ return param;
1013
1264
  }
1014
1265
  /**
1015
- * Set the items per page number
1266
+ * Parse and append pagination parameters in JSON:API bracket notation
1016
1267
  *
1017
- * @param limit - Number of items per page
1018
- * @returns {this}
1268
+ * Generates: `page[number]=1&page[size]=15`
1269
+ *
1270
+ * @param state - The current query builder state
1271
+ * @param options - The query parameter key name configuration
1272
+ * @returns The generated pagination parameter string
1019
1273
  */
1020
- setLimit(limit) {
1021
- this._nestService.limit = limit;
1022
- return this;
1274
+ _parsePagination(state, options) {
1275
+ const pagination = qs.stringify({ [options.page]: { number: state.page, size: state.limit } }, { encode: false });
1276
+ const param = `${this._prepend(state)}${pagination}`;
1277
+ this._uri += param;
1278
+ return param;
1023
1279
  }
1024
1280
  /**
1025
- * Set the page that the backend will use to paginate the result set
1281
+ * Parse and append sort parameters
1026
1282
  *
1027
- * @param page - Page number
1028
- * @returns {this}
1283
+ * Generates: `sort=-field1,field2` where `-` prefix indicates DESC order
1284
+ *
1285
+ * @param state - The current query builder state
1286
+ * @param options - The query parameter key name configuration
1287
+ * @returns The generated sort parameter string
1029
1288
  */
1030
- setPage(page) {
1031
- this._nestService.page = page;
1032
- return this;
1289
+ _parseSort(state, options) {
1290
+ let param = '';
1291
+ if (!state.sorts.length) {
1292
+ return param;
1293
+ }
1294
+ param = `${this._prepend(state)}${options.sort}=`;
1295
+ state.sorts.forEach((sort, idx) => {
1296
+ param += `${sort.order === SortEnum.DESC ? '-' : ''}${sort.field}`;
1297
+ if (idx < state.sorts.length - 1) {
1298
+ param += ',';
1299
+ }
1300
+ });
1301
+ this._uri += param;
1302
+ return param;
1033
1303
  }
1034
1304
  /**
1035
- * Set the API resource to run the query against
1305
+ * Determine the appropriate URI prefix based on the current accumulator state
1036
1306
  *
1037
- * @param {string} resource - Resource name (e.g. 'users' produces /users)
1038
- * @returns {this}
1307
+ * Returns the full base path with `?` for the first parameter,
1308
+ * or `&` for subsequent parameters.
1309
+ *
1310
+ * @param state - The current query builder state
1311
+ * @returns The prefix string to prepend to the next parameter
1039
1312
  */
1040
- setResource(resource) {
1041
- this._nestService.resource = resource;
1042
- return this;
1313
+ _prepend(state) {
1314
+ if (this._uri) {
1315
+ return '&';
1316
+ }
1317
+ return state.baseUrl ? `${state.baseUrl}/${state.resource}?` : `/${state.resource}?`;
1043
1318
  }
1319
+ }
1320
+
1321
+ /**
1322
+ * Response strategy for the JSON:API driver
1323
+ *
1324
+ * Parses JSON:API pagination responses:
1325
+ * ```json
1326
+ * {
1327
+ * "data": [...],
1328
+ * "meta": {
1329
+ * "current-page": 1,
1330
+ * "per-page": 10,
1331
+ * "total": 100,
1332
+ * "page-count": 10,
1333
+ * "from": 1,
1334
+ * "to": 10
1335
+ * },
1336
+ * "links": {
1337
+ * "first": "url",
1338
+ * "prev": "url",
1339
+ * "next": "url",
1340
+ * "last": "url"
1341
+ * }
1342
+ * }
1343
+ * ```
1344
+ *
1345
+ * @see https://jsonapi.org/format/
1346
+ */
1347
+ class JsonApiResponseStrategy {
1044
1348
  /**
1045
- * Set the search term for full-text search (NestJS only)
1349
+ * Parse a JSON:API pagination response into a PaginatedCollection
1046
1350
  *
1047
- * Produces: `search=term`
1351
+ * Supports dot-notation key paths for accessing nested values.
1352
+ * Computes `from` and `to` from `currentPage` and `perPage` when
1353
+ * they are not directly available in the response.
1048
1354
  *
1049
- * @param {string} search - The search term
1050
- * @returns {this}
1051
- * @throws {UnsupportedSearchError} If the active driver does not support search
1355
+ * @param response - The raw API response object
1356
+ * @param options - The response key name configuration
1357
+ * @returns A typed PaginatedCollection instance
1052
1358
  */
1053
- setSearch(search) {
1054
- this._assertDriver([DriverEnum.NESTJS], new UnsupportedSearchError());
1055
- this._nestService.setSearch(search);
1056
- return this;
1359
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1360
+ paginate(response, options) {
1361
+ const data = this._resolve(response, options.data);
1362
+ const currentPage = this._resolve(response, options.currentPage);
1363
+ const total = this._resolve(response, options.total);
1364
+ const perPage = this._resolve(response, options.perPage);
1365
+ const lastPage = this._resolve(response, options.lastPage);
1366
+ // Compute from/to if not directly available
1367
+ const from = this._resolveFrom(response, options, currentPage, perPage);
1368
+ const to = this._resolveTo(response, options, currentPage, perPage, total);
1369
+ const prevPageUrl = this._resolve(response, options.prevPageUrl);
1370
+ const nextPageUrl = this._resolve(response, options.nextPageUrl);
1371
+ const firstPageUrl = this._resolve(response, options.firstPageUrl);
1372
+ const lastPageUrl = this._resolve(response, options.lastPageUrl);
1373
+ return new PaginatedCollection(data, currentPage, from, to, total, perPage, prevPageUrl, nextPageUrl, lastPage, firstPageUrl, lastPageUrl);
1057
1374
  }
1058
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService, deps: [{ token: NestService }, { token: 'REQUEST_STRATEGY' }, { token: 'DRIVER' }, { token: 'QUERY_PARAMS_CONFIG', optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
1059
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService });
1060
- }
1061
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NgQubeeService, decorators: [{
1062
- type: Injectable
1063
- }], ctorParameters: () => [{ type: NestService }, { type: undefined, decorators: [{
1064
- type: Inject,
1065
- args: ['REQUEST_STRATEGY']
1066
- }] }, { type: DriverEnum, decorators: [{
1067
- type: Inject,
1068
- args: ['DRIVER']
1069
- }] }, { type: undefined, decorators: [{
1070
- type: Inject,
1071
- args: ['QUERY_PARAMS_CONFIG']
1072
- }, {
1073
- type: Optional
1074
- }] }] });
1075
-
1076
- class PaginationService {
1077
1375
  /**
1078
- * Resolved response key name options
1376
+ * Resolve a value from a response object using a dot-notation path
1377
+ *
1378
+ * Supports both flat keys ('data') and nested paths ('meta.current-page').
1379
+ *
1380
+ * @param response - The raw response object
1381
+ * @param path - The dot-notation path to resolve
1382
+ * @returns The resolved value, or undefined if not found
1079
1383
  */
1080
- _options;
1384
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1385
+ _resolve(response, path) {
1386
+ return path.split('.').reduce((obj, key) => obj?.[key], response);
1387
+ }
1081
1388
  /**
1082
- * The response strategy that parses responses for the active driver
1389
+ * Resolve the "from" index value
1390
+ *
1391
+ * If the path resolves to a value in the response, use it.
1392
+ * Otherwise, compute it from currentPage and perPage:
1393
+ * `(currentPage - 1) * perPage + 1`
1394
+ *
1395
+ * @param response - The raw response object
1396
+ * @param options - The response key name configuration
1397
+ * @param currentPage - The current page number
1398
+ * @param perPage - The number of items per page
1399
+ * @returns The computed "from" index
1083
1400
  */
1084
- _responseStrategy;
1085
- constructor(responseStrategy, options = {}) {
1086
- this._options = new ResponseOptions(options);
1087
- this._responseStrategy = responseStrategy;
1401
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1402
+ _resolveFrom(response, options, currentPage, perPage) {
1403
+ const direct = this._resolve(response, options.from);
1404
+ if (direct !== undefined) {
1405
+ return direct;
1406
+ }
1407
+ if (currentPage && perPage) {
1408
+ return (currentPage - 1) * perPage + 1;
1409
+ }
1410
+ return undefined;
1088
1411
  }
1089
1412
  /**
1090
- * Transform a raw API response into a typed PaginatedCollection
1413
+ * Resolve the "to" index value
1091
1414
  *
1092
- * Delegates to the active driver's response strategy for parsing.
1415
+ * If the path resolves to a value in the response, use it.
1416
+ * Otherwise, compute it from currentPage, perPage, and total:
1417
+ * `Math.min(currentPage * perPage, total)`
1093
1418
  *
1094
- * @param response - The raw API response object
1095
- * @returns A typed PaginatedCollection instance
1419
+ * @param response - The raw response object
1420
+ * @param options - The response key name configuration
1421
+ * @param currentPage - The current page number
1422
+ * @param perPage - The number of items per page
1423
+ * @param total - The total number of items
1424
+ * @returns The computed "to" index
1096
1425
  */
1097
1426
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1098
- paginate(response) {
1099
- return this._responseStrategy.paginate(response, this._options);
1427
+ _resolveTo(response, options, currentPage, perPage, total) {
1428
+ const direct = this._resolve(response, options.to);
1429
+ if (direct !== undefined) {
1430
+ return direct;
1431
+ }
1432
+ if (currentPage && perPage && total) {
1433
+ return Math.min(currentPage * perPage, total);
1434
+ }
1435
+ return undefined;
1100
1436
  }
1101
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService, deps: [{ token: 'RESPONSE_STRATEGY' }, { token: 'RESPONSE_OPTIONS', optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
1102
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService });
1103
1437
  }
1104
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationService, decorators: [{
1105
- type: Injectable
1106
- }], ctorParameters: () => [{ type: undefined, decorators: [{
1107
- type: Inject,
1108
- args: ['RESPONSE_STRATEGY']
1109
- }] }, { type: undefined, decorators: [{
1110
- type: Inject,
1111
- args: ['RESPONSE_OPTIONS']
1112
- }, {
1113
- type: Optional
1114
- }] }] });
1115
1438
 
1116
1439
  /**
1117
1440
  * Request strategy for the Laravel (pagination-only) driver
@@ -1137,6 +1460,21 @@ class LaravelRequestStrategy {
1137
1460
  const base = state.baseUrl ? `${state.baseUrl}/${state.resource}` : `/${state.resource}`;
1138
1461
  return `${base}?${options.limit}=${state.limit}&${options.page}=${state.page}`;
1139
1462
  }
1463
+ /**
1464
+ * Validate that the given limit is accepted by the Laravel driver
1465
+ *
1466
+ * Laravel pagination does not recognize `-1` as a "fetch all" sentinel,
1467
+ * so only positive integers are accepted.
1468
+ *
1469
+ * @param limit - The limit value to validate
1470
+ * @throws {InvalidLimitError} If the value is not a positive integer
1471
+ */
1472
+ validateLimit(limit) {
1473
+ if (Number.isInteger(limit) && limit >= 1) {
1474
+ return;
1475
+ }
1476
+ throw new InvalidLimitError(limit);
1477
+ }
1140
1478
  }
1141
1479
 
1142
1480
  /**
@@ -1169,12 +1507,6 @@ class LaravelResponseStrategy {
1169
1507
  }
1170
1508
  }
1171
1509
 
1172
- var SortEnum;
1173
- (function (SortEnum) {
1174
- SortEnum["ASC"] = "asc";
1175
- SortEnum["DESC"] = "desc";
1176
- })(SortEnum || (SortEnum = {}));
1177
-
1178
1510
  /**
1179
1511
  * Request strategy for the NestJS (nestjs-paginate) driver
1180
1512
  *
@@ -1215,6 +1547,21 @@ class NestjsRequestStrategy {
1215
1547
  this._parsePage(state, options);
1216
1548
  return this._uri;
1217
1549
  }
1550
+ /**
1551
+ * Validate that the given limit is accepted by nestjs-paginate
1552
+ *
1553
+ * Accepts any integer `>= 1` as a page size, plus `-1` which nestjs-paginate
1554
+ * interprets as "fetch all items" (server must opt-in via `maxLimit: -1`).
1555
+ *
1556
+ * @param limit - The limit value to validate
1557
+ * @throws {InvalidLimitError} If the value is not an integer, or is 0, or is a negative number other than -1
1558
+ */
1559
+ validateLimit(limit) {
1560
+ if (Number.isInteger(limit) && (limit === -1 || limit >= 1)) {
1561
+ return;
1562
+ }
1563
+ throw new InvalidLimitError(limit, true);
1564
+ }
1218
1565
  /**
1219
1566
  * Parse and append simple filter parameters
1220
1567
  *
@@ -1454,12 +1801,6 @@ class NestjsResponseStrategy {
1454
1801
  }
1455
1802
  }
1456
1803
 
1457
- class UnselectableModelError extends Error {
1458
- constructor(model) {
1459
- super(`Unselectable Model: the selected model (${model}) is not present neither in the "model" property, nor in the includes object.`);
1460
- }
1461
- }
1462
-
1463
1804
  /**
1464
1805
  * Request strategy for the Spatie Query Builder driver
1465
1806
  *
@@ -1498,6 +1839,21 @@ class SpatieRequestStrategy {
1498
1839
  this._parseSort(state, options);
1499
1840
  return this._uri;
1500
1841
  }
1842
+ /**
1843
+ * Validate that the given limit is accepted by the Spatie driver
1844
+ *
1845
+ * Spatie query-builder does not recognize `-1` as a "fetch all" sentinel,
1846
+ * so only positive integers are accepted.
1847
+ *
1848
+ * @param limit - The limit value to validate
1849
+ * @throws {InvalidLimitError} If the value is not a positive integer
1850
+ */
1851
+ validateLimit(limit) {
1852
+ if (Number.isInteger(limit) && limit >= 1) {
1853
+ return;
1854
+ }
1855
+ throw new InvalidLimitError(limit);
1856
+ }
1501
1857
  /**
1502
1858
  * Parse and append field selection parameters
1503
1859
  *
@@ -1678,6 +2034,8 @@ class SpatieResponseStrategy {
1678
2034
  */
1679
2035
  function resolveRequestStrategy$1(driver) {
1680
2036
  switch (driver) {
2037
+ case DriverEnum.JSON_API:
2038
+ return new JsonApiRequestStrategy();
1681
2039
  case DriverEnum.NESTJS:
1682
2040
  return new NestjsRequestStrategy();
1683
2041
  case DriverEnum.SPATIE:
@@ -1694,6 +2052,8 @@ function resolveRequestStrategy$1(driver) {
1694
2052
  */
1695
2053
  function resolveResponseStrategy$1(driver) {
1696
2054
  switch (driver) {
2055
+ case DriverEnum.JSON_API:
2056
+ return new JsonApiResponseStrategy();
1697
2057
  case DriverEnum.NESTJS:
1698
2058
  return new NestjsResponseStrategy();
1699
2059
  case DriverEnum.SPATIE:
@@ -1727,6 +2087,9 @@ class NgQubeeModule {
1727
2087
  provide: PaginationService,
1728
2088
  useFactory: () => {
1729
2089
  const responseConfig = Object.assign({}, config.response);
2090
+ if (driver === DriverEnum.JSON_API) {
2091
+ return new PaginationService(responseStrategy, new JsonApiResponseOptions(responseConfig));
2092
+ }
1730
2093
  return driver === DriverEnum.NESTJS
1731
2094
  ? new PaginationService(responseStrategy, new NestjsResponseOptions(responseConfig))
1732
2095
  : new PaginationService(responseStrategy, responseConfig);
@@ -1752,6 +2115,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImpor
1752
2115
  */
1753
2116
  function resolveRequestStrategy(driver) {
1754
2117
  switch (driver) {
2118
+ case DriverEnum.JSON_API:
2119
+ return new JsonApiRequestStrategy();
1755
2120
  case DriverEnum.NESTJS:
1756
2121
  return new NestjsRequestStrategy();
1757
2122
  case DriverEnum.SPATIE:
@@ -1768,6 +2133,8 @@ function resolveRequestStrategy(driver) {
1768
2133
  */
1769
2134
  function resolveResponseStrategy(driver) {
1770
2135
  switch (driver) {
2136
+ case DriverEnum.JSON_API:
2137
+ return new JsonApiResponseStrategy();
1771
2138
  case DriverEnum.NESTJS:
1772
2139
  return new NestjsResponseStrategy();
1773
2140
  case DriverEnum.SPATIE:
@@ -1797,6 +2164,15 @@ function resolveResponseStrategy(driver) {
1797
2164
  * });
1798
2165
  * ```
1799
2166
  *
2167
+ * JSON:API driver example:
2168
+ * ```
2169
+ * import { DriverEnum } from 'ng-qubee';
2170
+ *
2171
+ * bootstrapApplication(AppComponent, {
2172
+ * providers: [provideNgQubee({ driver: DriverEnum.JSON_API })]
2173
+ * });
2174
+ * ```
2175
+ *
1800
2176
  * NestJS driver example:
1801
2177
  * ```
1802
2178
  * import { DriverEnum } from 'ng-qubee';
@@ -1828,6 +2204,9 @@ function provideNgQubee(config) {
1828
2204
  provide: PaginationService,
1829
2205
  useFactory: () => {
1830
2206
  const responseConfig = Object.assign({}, config.response);
2207
+ if (driver === DriverEnum.JSON_API) {
2208
+ return new PaginationService(responseStrategy, new JsonApiResponseOptions(responseConfig));
2209
+ }
1831
2210
  return driver === DriverEnum.NESTJS
1832
2211
  ? new PaginationService(responseStrategy, new NestjsResponseOptions(responseConfig))
1833
2212
  : new PaginationService(responseStrategy, responseConfig);
@@ -1868,5 +2247,5 @@ var FilterOperatorEnum;
1868
2247
  * Generated bundle index. Do not edit.
1869
2248
  */
1870
2249
 
1871
- export { DriverEnum, FilterOperatorEnum, InvalidLimitError, InvalidPageNumberError, InvalidResourceNameError, KeyNotFoundError, LaravelRequestStrategy, LaravelResponseStrategy, NestjsRequestStrategy, NestjsResponseStrategy, NgQubeeModule, NgQubeeService, PaginatedCollection, PaginationService, SortEnum, SpatieRequestStrategy, SpatieResponseStrategy, UnselectableModelError, UnsupportedFieldSelectionError, UnsupportedFilterError, UnsupportedFilterOperatorError, UnsupportedIncludesError, UnsupportedSearchError, UnsupportedSelectError, UnsupportedSortError, provideNgQubee };
2250
+ export { DriverEnum, FilterOperatorEnum, InvalidLimitError, InvalidPageNumberError, InvalidResourceNameError, JsonApiRequestStrategy, JsonApiResponseStrategy, KeyNotFoundError, LaravelRequestStrategy, LaravelResponseStrategy, NestjsRequestStrategy, NestjsResponseStrategy, NgQubeeModule, NgQubeeService, PaginatedCollection, PaginationService, SortEnum, SpatieRequestStrategy, SpatieResponseStrategy, UnselectableModelError, UnsupportedFieldSelectionError, UnsupportedFilterError, UnsupportedFilterOperatorError, UnsupportedIncludesError, UnsupportedSearchError, UnsupportedSelectError, UnsupportedSortError, provideNgQubee };
1872
2251
  //# sourceMappingURL=ng-qubee.mjs.map