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