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