ng-qubee 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ npm i ng-qubee
41
41
 
42
42
  ## Drivers
43
43
 
44
- NgQubee supports four drivers out of the box. A driver **must** be specified in the configuration:
44
+ NgQubee supports five drivers out of the box. A driver **must** be specified in the configuration:
45
45
 
46
46
  | Driver | Backend | Request Format | Response Format |
47
47
  |---|---|---|---|
@@ -49,6 +49,7 @@ NgQubee supports four drivers out of the box. A driver **must** be specified in
49
49
  | **Laravel** | Plain Laravel pagination | `limit=N&page=N` (pagination only) | Flat: `{ data, current_page, total, ... }` |
50
50
  | **Spatie** | Spatie Query Builder | `filter[field]=value`, `sort=-field` | Flat: `{ data, current_page, total, ... }` |
51
51
  | **NestJS** | nestjs-paginate | `filter.field=$operator:value`, `sortBy=field:DESC` | Nested: `{ data, meta: {...}, links: {...} }` |
52
+ | **PostgREST** | PostgREST / Supabase | `col=eq.value`, `order=col.asc`, `limit=N&offset=M` | Bare array body + `Content-Range` header for total |
52
53
 
53
54
  ## Usage
54
55
 
@@ -176,6 +177,141 @@ The NestJS driver generates URIs compatible with [nestjs-paginate](https://githu
176
177
  - **Limit** is composed as `limit=15`
177
178
  - **Page** is composed as `page=1`
178
179
 
180
+ ### PostgREST / Supabase Driver
181
+
182
+ The PostgREST driver generates URIs compatible with [PostgREST](https://postgrest.org/) and anything built on top of it (notably [Supabase](https://supabase.com/)):
183
+
184
+ ```typescript
185
+ import { DriverEnum } from 'ng-qubee';
186
+
187
+ // Standalone approach
188
+ bootstrapApplication(AppComponent, {
189
+ providers: [provideNgQubee({ driver: DriverEnum.POSTGREST })]
190
+ });
191
+
192
+ // Module approach
193
+ @NgModule({
194
+ imports: [
195
+ NgQubeeModule.forRoot({ driver: DriverEnum.POSTGREST })
196
+ ]
197
+ })
198
+ export class AppModule {}
199
+ ```
200
+
201
+ The PostgREST driver supports:
202
+
203
+ - **Filters** (single value) are composed as `col=eq.value`
204
+ - **Filters** (multi-value) are composed as `col=in.(v1,v2,v3)` — PostgREST's native IN-list syntax
205
+ - **Operator filters** cover the full `FilterOperatorEnum` (eq, gt, gte, lt, lte, ilike, in, not, null, btw, sw, contains) plus PostgREST-native full-text search (`fts`, `plfts`, `phfts`, `wfts`) — see the Operator filters section below
206
+ - **Sorts** are composed as `order=col1.asc,col2.desc`
207
+ - **Select** is composed as `select=col1,col2`
208
+ - **Pagination** defaults to `limit=N&offset=M` on the URL and can optionally move to `Range-Unit` + `Range` HTTP headers — see the RANGE-header pagination section below
209
+
210
+ #### Reading totals from the `Content-Range` header
211
+
212
+ PostgREST returns a bare array body and reports the total row count in the `Content-Range` HTTP response header (e.g. `0-9/50`). Opt in by sending `Prefer: count=exact` on the request, then pass the headers to `paginate()`:
213
+
214
+ ```typescript
215
+ this._http.get<User[]>(uri, {
216
+ observe: 'response',
217
+ headers: { 'Prefer': 'count=exact' }
218
+ }).subscribe(response => {
219
+ const collection = this._paginationService.paginate(response.body, response.headers);
220
+ // collection.total, collection.page, collection.lastPage are populated
221
+ });
222
+ ```
223
+
224
+ The second argument to `paginate()` accepts Angular's `HttpHeaders`, the native `Headers` class, or a plain `Record<string, string>` — whatever shape your HTTP client emits. If you omit the header (or the server returns `Content-Range: 0-9/*`), the collection still populates `data` and `page` but leaves `total` / `lastPage` undefined — the pagination helpers' conservative defaults then kick in (`hasNextPage()` returns `true`, `isLastPage()` returns `false`).
225
+
226
+ #### Operator filters
227
+
228
+ Every `FilterOperatorEnum` value maps to a PostgREST prefix operator. Call `addFilterOperator(column, operator, ...values)` and the strategy emits the correct wire format:
229
+
230
+ ```typescript
231
+ qb.addFilterOperator('age', FilterOperatorEnum.GTE, 18); // age=gte.18
232
+ qb.addFilterOperator('id', FilterOperatorEnum.IN, 1, 2, 3); // id=in.(1,2,3)
233
+ qb.addFilterOperator('status', FilterOperatorEnum.NOT, 'deleted'); // status=not.eq.deleted
234
+ qb.addFilterOperator('id', FilterOperatorEnum.NOT, 1, 2); // id=not.in.(1,2)
235
+ qb.addFilterOperator('deletedAt', FilterOperatorEnum.NULL, true); // deletedAt=is.null
236
+ qb.addFilterOperator('deletedAt', FilterOperatorEnum.NULL, false); // deletedAt=is.not.null
237
+ qb.addFilterOperator('price', FilterOperatorEnum.BTW, 10, 100); // price=gte.10&price=lte.100
238
+ qb.addFilterOperator('email', FilterOperatorEnum.SW, 'admin'); // email=like.admin*
239
+ qb.addFilterOperator('name', FilterOperatorEnum.CONTAINS, 'john'); // name=ilike.%john%
240
+ qb.addFilterOperator('description', FilterOperatorEnum.FTS, 'rat'); // description=fts.rat
241
+ qb.addFilterOperator('description', FilterOperatorEnum.PLFTS, 'a b'); // description=plfts.a b
242
+ qb.addFilterOperator('description', FilterOperatorEnum.PHFTS, 'a b'); // description=phfts.a b
243
+ qb.addFilterOperator('description', FilterOperatorEnum.WFTS, 'a -b'); // description=wfts.a -b
244
+ ```
245
+
246
+ Value shape rules enforced at call time (throw `InvalidFilterOperatorValueError`):
247
+ - `BTW` — exactly 2 values (`[min, max]`).
248
+ - `NULL` — exactly 1 boolean value (`true` → `IS NULL`, `false` → `IS NOT NULL`).
249
+
250
+ All other operators let PostgREST validate server-side.
251
+
252
+ The four `*FTS` operators are PostgREST's full-text search variants (`to_tsquery`, `plainto_tsquery`, `phraseto_tsquery`, `websearch_to_tsquery`). They're column-scoped — pick the column to search, pass the term. Language modifiers (`fts(english).term`) are not supported in this release.
253
+
254
+ #### RANGE-header pagination (alternative to limit/offset)
255
+
256
+ PostgREST also accepts pagination via the `Range` HTTP request header instead of URL query params. Opt in via `IConfig.pagination`:
257
+
258
+ ```typescript
259
+ import { DriverEnum, PaginationModeEnum } from 'ng-qubee';
260
+
261
+ provideNgQubee({
262
+ driver: DriverEnum.POSTGREST,
263
+ pagination: PaginationModeEnum.RANGE
264
+ });
265
+ ```
266
+
267
+ When `RANGE` is active, `generateUri()` omits `limit` and `offset` from the URL and `NgQubeeService.paginationHeaders()` returns the headers the consumer applies to the HTTP request:
268
+
269
+ ```typescript
270
+ const uri = await firstValueFrom(qb.generateUri());
271
+ const extraHeaders = qb.paginationHeaders(); // { 'Range-Unit': 'items', 'Range': '0-9' }
272
+
273
+ this._http.get<User[]>(uri, {
274
+ observe: 'response',
275
+ headers: { 'Prefer': 'count=exact', ...extraHeaders }
276
+ }).subscribe(resp => this._pagination.paginate(resp.body, resp.headers));
277
+ ```
278
+
279
+ `paginationHeaders()` returns `null` for any driver that doesn't use header-based pagination — safe to spread into a headers map unconditionally with the nullish-coalescing pattern above.
280
+
281
+ #### Feature matrix
282
+
283
+ | Method | Supported? | Notes |
284
+ |---|---|---|
285
+ | `addFilter` / `deleteFilters` | ✓ | Implicit `eq`; multi-value becomes `in.(...)` |
286
+ | `addFilterOperator` / `deleteOperatorFilters` | ✓ | All 16 operators including `FTS`/`PLFTS`/`PHFTS`/`WFTS` |
287
+ | `addSort` / `deleteSorts` | ✓ | Emits `order=col.asc,col.desc` |
288
+ | `addSelect` / `deleteSelect` | ✓ | Flat column selection |
289
+ | `setLimit` / `setPage` | ✓ | `offset` derived from `page` (QUERY mode) or emitted via `Range` header (RANGE mode) |
290
+ | `addFields` / `deleteFields` / `deleteFieldsByModel` | ✗ | Throws `UnsupportedFieldSelectionError`. Per-type field selection is a JSON:API/Spatie concept; use `addSelect` for PostgREST's column pruning. |
291
+ | `addIncludes` / `deleteIncludes` | ✗ | Throws `UnsupportedIncludesError`. PostgREST uses embedded resources via `select=col,rel(*)` — tracked as #66. |
292
+ | `setSearch` / `deleteSearch` | ✗ | Throws `UnsupportedSearchError`. PostgREST's FTS operators are per-column — use `addFilterOperator(col, FilterOperatorEnum.FTS, term)` instead. |
293
+
294
+ ### Per-component instances
295
+
296
+ By default, `provideNgQubee()` / `NgQubeeModule.forRoot()` register `NgQubeeService` at the environment injector, so every component that injects it shares the same query-builder state. If you need a dedicated instance — e.g. a feature component whose filters and pagination must not bleed into the app-wide one — spread `provideNgQubeeInstance()` into the component's `providers`:
297
+
298
+ ```typescript
299
+ import { Component } from '@angular/core';
300
+ import { NgQubeeService, provideNgQubeeInstance } from 'ng-qubee';
301
+
302
+ @Component({
303
+ selector: 'app-product-list',
304
+ standalone: true,
305
+ providers: [...provideNgQubeeInstance()],
306
+ template: '...'
307
+ })
308
+ export class ProductListComponent {
309
+ constructor(private _qb: NgQubeeService) {}
310
+ }
311
+ ```
312
+
313
+ The component gets its own `NgQubeeService`, `NestService`, and `PaginationService`. The driver, strategies, and options are inherited from the environment injector configured by `provideNgQubee()` — you still configure the library once at bootstrap.
314
+
179
315
  ## Query Builder API
180
316
 
181
317
  For composing queries, the first step is to inject the proper NgQubeeService:
@@ -337,6 +473,94 @@ Limit validation is driver-scoped — each request strategy enforces its own acc
337
473
 
338
474
  Non-integer values, zero, negative numbers (other than `-1` for NestJS), `NaN`, and `Infinity` are all rejected.
339
475
 
476
+ #### Auto-reset of page on result-set-changing mutations
477
+
478
+ Any mutation that changes *which records* the server would return also resets `state.page` to `1` automatically. Staying on page 5 of an old result set after changing filters is almost always a bug, so the library makes the reset explicit:
479
+
480
+ | Resets page to 1 | Does NOT reset page |
481
+ |---|---|
482
+ | `setLimit()` | `setBaseUrl()` |
483
+ | `setResource()` | `setPage()` |
484
+ | `setSearch()` / `deleteSearch()` | `addFields()` / `deleteFields()` / `deleteFieldsByModel()` |
485
+ | `addFilter()` / `deleteFilters()` | `addIncludes()` / `deleteIncludes()` |
486
+ | `addFilterOperator()` / `deleteOperatorFilters()` | `addSelect()` / `deleteSelect()` |
487
+ | `addSort()` / `deleteSorts()` | |
488
+
489
+ Rule of thumb: if a mutation changes the record *set* (filters, sort, search, limit, resource), page resets. If it only changes the record *shape* (fields, includes, select), page stays. If you intentionally want to keep the previous page number, call `setPage(n)` again after the mutation.
490
+
491
+ ### Pagination navigation
492
+
493
+ The service exposes a fluent navigation surface so you can wire a standard paginator UI (Prev / N of M / Next) with no manual bookkeeping. All navigation methods return `this` and can be chained.
494
+
495
+ ```typescript
496
+ this._ngQubeeService.nextPage().generateUri().subscribe(uri => /* fire the request */);
497
+ this._ngQubeeService.previousPage();
498
+ this._ngQubeeService.firstPage();
499
+ this._ngQubeeService.lastPage();
500
+ this._ngQubeeService.goToPage(3);
501
+ ```
502
+
503
+ #### Auto-sync from `PaginationService.paginate()`
504
+
505
+ When you hand a paginated response to `PaginationService.paginate()`, the library automatically copies the response's `page` and `lastPage` back into the query-builder state. That means `lastPage()`, `goToPage(n)` bounds checks, and the predicates below become accurate immediately — you don't thread the collection's `lastPage` back in yourself.
506
+
507
+ ```typescript
508
+ this._paginationService.paginate(response); // auto-writes page + lastPage
509
+ this._ngQubeeService.lastPage(); // now safe; jumps to the last page
510
+ ```
511
+
512
+ The auto-sync only flips `isLastPageKnown` to `true` when the response carries a **positive integer** `lastPage`. Server-emitted `0` (empty collection) and absent fields leave the flag `false` — the helpers fall back to their conservative defaults.
513
+
514
+ #### Predicates and accessors
515
+
516
+ Template-safe methods for driving button disable-states and labels:
517
+
518
+ ```typescript
519
+ qb.isFirstPage(); // true on page 1
520
+ qb.isLastPage(); // true only when bounds known and page === lastPage
521
+ qb.hasNextPage(); // true when bounds unknown, or page < lastPage
522
+ qb.hasPreviousPage(); // true when page > 1
523
+ qb.currentPage(); // state.page (always safe)
524
+ qb.totalPages(); // state.lastPage (throws if never synced)
525
+ ```
526
+
527
+ Angular template wiring:
528
+
529
+ ```html
530
+ <button [disabled]="qb.isFirstPage()" (click)="qb.previousPage()">Prev</button>
531
+ <span>Page {{ qb.currentPage() }} of {{ qb.totalPages() }}</span>
532
+ <button [disabled]="qb.isLastPage()" (click)="qb.nextPage()">Next</button>
533
+ ```
534
+
535
+ For the `qb.totalPages()` template usage above, either call `paginate()` at least once before the template renders, or guard the display with `*ngIf="qb.hasNextPage() || !qb.isFirstPage()"` / by reading `qb.nest().isLastPageKnown` directly.
536
+
537
+ #### Error behavior
538
+
539
+ | Helper | Throws | When |
540
+ |---|---|---|
541
+ | `nextPage()` | — | Never throws. No-op when already at `lastPage` (bounds known). |
542
+ | `previousPage()` | — | Never throws. No-op at page 1. |
543
+ | `firstPage()` | — | Never throws. |
544
+ | `lastPage()` | `PaginationNotSyncedError` | `paginate()` has never run (`state.isLastPageKnown` is `false`). |
545
+ | `goToPage(n)` | `InvalidPageNumberError` | `n` is not a positive integer, or exceeds `lastPage` when bounds are known. |
546
+ | `isFirstPage()` / `isLastPage()` / `hasNextPage()` / `hasPreviousPage()` | — | Template-safe. Conservative defaults when bounds unknown. |
547
+ | `currentPage()` | — | Always safe. |
548
+ | `totalPages()` | `PaginationNotSyncedError` | `paginate()` has never run. Guard with `nest().isLastPageKnown` for a non-throwing check. |
549
+
550
+ #### Guarding the imperative "jump to last" button
551
+
552
+ `lastPage()` and `totalPages()` need a synced response. A safe pattern:
553
+
554
+ ```html
555
+ <button
556
+ [disabled]="!qb.nest().isLastPageKnown || qb.isLastPage()"
557
+ (click)="qb.lastPage()">
558
+ Last
559
+ </button>
560
+ ```
561
+
562
+ The `isLastPageKnown` read short-circuits before `qb.lastPage()` could throw.
563
+
340
564
  ### Retrieving data
341
565
  URI is generated invoking the _generateUri_ method of the NgQubeeService. An observable is returned and the URI will be emitted:
342
566