# ng-ql > An Eloquent-inspired HTTP request and query builder for Angular. It is **not an ORM** — it manages no database, no models, no identity map. It builds, serializes, executes, types, caches, and exposes HTTP requests against REST APIs using Angular `HttpClient`, Signals, and RxJS. Runtime dependencies: `@angular/core`, `@angular/common`, `rxjs` only. Angular 21+. ## Install ```bash npm install ng-ql ``` ## Provider setup ```ts import { provideHttpClient } from '@angular/common/http'; import { provideNgQl } from 'ng-ql'; bootstrapApplication(AppComponent, { providers: [ provideHttpClient(), provideNgQl({ baseUrl: 'https://api.example.com/v1', defaultHeaders: { Accept: 'application/json' }, withCredentials: false, defaultCachePolicy: 'no-store', // default for Signal reads that don't specify one defaultCacheTtl: 60_000, // ms // querySerializer: custom NgQlQuerySerializer (optional) // responseAdapter: custom NgQlResponseAdapter (optional) }), ], }); ``` `baseUrl` is joined onto relative endpoints without duplicate/missing slashes. An endpoint starting with `http://`, `https://`, or `//` is treated as absolute and used as-is (never prefixed). ## Defining a resource A model is a plain `interface` — no base class, no decorators. A resource extends `NgQlResource` and is injectable like any other service: ```ts export interface Post { id: number; title: string; status: 'draft' | 'published' | 'archived'; } @Injectable({ providedIn: 'root' }) export class PostResource extends NgQlResource { constructor(client: NgQlClient) { super(client, { endpoint: 'posts', cacheTags: ['posts'] }); } } ``` `NgQlResourceConfig` fields: `endpoint` (required), `primaryKey`, `mapItem`/`mapCollection`/`mapPaginated` (per-resource response overrides), `buildUrl`, `requestOptions`, `headers`, `cacheTags`. IMPORTANT: `NgQlResource`'s constructor takes `(client: NgQlClient, config: NgQlResourceConfig)` via plain constructor injection (not `inject()`), specifically so subclasses can pass `client` through `super(...)`. Do not "fix" this to use `inject()` — it is intentional. ## Query builder — immutable, no request until executed Every chain method on `NgQlQueryBuilder` returns a **new** instance; nothing is ever mutated. **No HTTP request is made** until an execution method (`get`, `first`, `paginate`, `find`, or a `*Signal` twin) is called. ```ts this.posts .query() .where('status', 'published') // shorthand: field, value => equality .where('price', '>=', 100) // explicit operator: field, operator, value .where({ title: 'Hello', name: 'Test' }) // object form: every entry AND-ed together .orWhere('featured', true) // ORs a new group against everything before it .orWhere({ role: 'admin', active: true }) // object form works on orWhere too .whereIn('categoryId', [1, 2]) .whereNotIn('authorId', [9]) .whereNull('deletedAt') .whereNotNull('publishedAt') .whereBetween('price', [10, 20]) .when(someCondition, (q) => q.where('featured', true)) .select(['id', 'title']) .with('author') // or with(['author', 'comments']) .withQuery({ custom: 'param' }) // alias: append(...) .orderBy('createdAt', 'desc') // or latest('createdAt') / oldest('createdAt') .limit(10) .offset(20) .page(2, 20) // page number, per-page size .perPage(20) .setHeaders({ 'X-Foo': 'bar' }) .setOptions({ withCredentials: true }) .get() // Observable .subscribe((posts) => console.log(posts)); ``` Supported operators for the explicit `where(field, operator, value)` form: `'=' | '!=' | '<>' | '>' | '>=' | '<' | '<=' | 'like' | 'not like'`. `where`/`orWhere` grouping: consecutive `where(...)` calls AND together into the current group; `orWhere(...)` starts a new group OR-ed against everything before it. `where(a).where(b).orWhere(c).where(d)` groups as `(a AND b) OR (c AND d)`. With no `orWhere` anywhere in the chain, this is invisible — output is a single flat `filter[...]` group, unchanged. As soon as one `orWhere` appears, every group (including the first) is nested under `filter[or][]` (see the wire-format section below). Invalid inputs (`NaN`, negative `limit`/`offset`, `page` < 1, `perPage` < 1, an unrecognized operator string) throw `NgQlValidationError` synchronously at the chain-call site — before any request, without needing to subscribe. ### Observable execution methods ```ts query.get(options?) // Observable query.first(options?) // Observable (null if empty) query.paginate(page?, perPage?, options?) // Observable> query.find(id, options?) // Observable ``` ### Signal execution methods Must be called from an injection context (a field initializer, a constructor, or inside `runInInjectionContext(...)`) because they call `inject(DestroyRef)` internally for lifecycle-safe cleanup. ```ts query.getSignal(options?) // NgQlRequestState query.firstSignal(options?) // NgQlRequestState query.paginateSignal(page?, perPage?, options?) // NgQlPaginatedRequestState ``` ```ts protected readonly postsState = this.posts .query() .where('status', 'published') .orderBy('createdAt', 'desc') .getSignal({ cache: 'stale-while-revalidate', cacheTtl: 30_000 }); // postsState.data() / .loading() / .error() / .status() / .hasData() // postsState.refresh() -- re-executes, bypassing cache-first shortcut, still writes cache // postsState.invalidate() -- drops the cache entry, resets state to 'idle' protected readonly postsPage = this.posts.query().paginateSignal(1, 10); // postsPage.data() / .meta() / .links() / .setPage(2) / .setPerPage(25) -- these REPLACE data() // Infinite-scroll / "load more": appends instead of replacing. // postsPage.hasMore() / .loadingMore() (distinct from .loading()) // postsPage.loadMore() -- no-op while already loading, or once hasMore() is false ``` `status` is one of `'idle' | 'loading' | 'success' | 'error'`. All public signals are read-only. Every Signal state (both `NgQlRequestState` and `NgQlPaginatedRequestState`) also has `mutateOptimistically(updater, commit)` for optimistic UI: applies `updater` to the current data immediately, subscribes to `commit()`, and — only on error — restores the previous value/status and surfaces the error via `.error()`. Does not touch the cache (a mutation's own cache invalidation already governs what a later `refresh()` sees). ```ts postsState.mutateOptimistically( (posts) => (posts ?? []).filter((p) => p.id !== id), () => this.posts.destroy(id), ); ``` ### Inspection methods (never trigger a request) ```ts query.toUrl(): string query.toQueryParams(): HttpParams query.toRequest(): NgQlRequestDescription // { method, url, params, headers, withCredentials?, body? } ``` ## Resource convenience methods ```ts resource.query(): NgQlQueryBuilder resource.all(options?): Observable resource.allSignal(options?): NgQlRequestState resource.find(id, options?): Observable resource.findSignal(id, options?): NgQlRequestState resource.first(options?): Observable resource.firstSignal(options?): NgQlRequestState resource.create(payload: Partial, options?): Observable resource.update(id, payload: Partial, options?): Observable // PUT resource.patch(id, payload: Partial, options?): Observable // PATCH resource.destroy(id, options?): Observable // DELETE resource.getId(model: TModel): TId // reads model[primaryKey ?? 'id'] ``` Every successful `create`/`update`/`patch`/`destroy` automatically invalidates cache entries owned by that resource's endpoint, plus any of the resource's configured `cacheTags`. Mutations are never cached themselves. ### `@NgQlEndpoint` — per-method URL/HTTP-method/id-placement override By default `update`/`patch` send `PUT`/`PATCH ${endpoint}/:id` and `create` sends `POST ${endpoint}`. To use a different URL, HTTP method, or put the id in the body instead of the URL for one method on one resource, `override` that method (delegating to `super`) and decorate it: ```ts class PostResource extends NgQlResource { constructor(client: NgQlClient) { super(client, { endpoint: 'posts' }); } @NgQlEndpoint({ url: 'posts/:id/save' }) // PUT /posts/:id/save override update(id: number, payload: Partial) { return super.update(id, payload); } @NgQlEndpoint({ method: 'POST', idIn: 'body' }) // POST /posts with { id, ...payload } override patch(id: number, payload: Partial) { return super.patch(id, payload); } } ``` `NgQlEndpointOptions`: `url?: string | ((endpoint, id?) => string)` (`:id` placeholder in a string template; default `${endpoint}/:id`, or `endpoint` when `idIn: 'body'`), `method?: NgQlHttpMethod` (default: the method's usual verb), `idIn?: 'url' | 'body'` (default `'url'`; `'body'` merges the id into the payload under `primaryKey`, unless already present). Undecorated methods keep default behavior. ## Caching policies (`NgQlCachePolicy`) Only relevant to the `*Signal` methods (`NgQlSignalRequestOptions` extends `NgQlRequestOptions` with `cache`, `cacheTtl`, `cacheKey`, `cacheTags`, `retry`, `retryDelay`). Plain Observable methods always hit the network. | Policy | Behavior | | --- | --- | | `'no-store'` | Always executes the request; never reads or writes the cache. | | `'cache-first'` | Returns valid cached data immediately if present; otherwise fetches. | | `'network-first'` | Fetches fresh data first; falls back to a cached value if the request fails. | | `'stale-while-revalidate'` | Returns cached data immediately (if any) while revalidating in the background. | Cache keys are deterministic (method + resolved URL + normalized, order-independent params/headers/body), so equivalent query builders always hit the same cache entry regardless of chain order. Concurrent identical in-flight `GET`s are always deduplicated into one HTTP call, regardless of policy. `NgQlCacheService` (injectable, instance-scoped — never global/static) also exposes `invalidateKey(key)`, `invalidateTag(tag)`, `invalidateEndpoint(endpoint)`, `clear()`. ### Retries `retry` (default `0`, disabled) and `retryDelay` (default `300`ms) on `NgQlSignalRequestOptions` retry a failed network attempt with exponential backoff (`retryDelay * 2^(attempt-1)`) *before* the policy's fallback/error kicks in — pairs naturally with `network-first`. Concurrent identical requests share a single retry sequence. ```ts this.posts.query().getSignal({ cache: 'network-first', retry: 2, retryDelay: 300 }); ``` ## Header precedence (ascending — later wins) 1. `NgQlConfig.defaultHeaders` 2. `NgQlResourceConfig.headers` 3. `NgQlQueryBuilder#setHeaders(...)` 4. Per-call `options.headers` ## Response shapes the default adapter understands - Raw array: `[{...}, {...}]` - `{ data: [{...}, {...}] }` - `{ data: {...} }` or a raw item object - Paginated: `{ data: [...], meta: { current_page | currentPage, last_page | lastPage, per_page | perPage, total }, links?: { first, last, prev, next } }` Override globally via `provideNgQl({ responseAdapter })`, or per-resource via `NgQlResourceConfig.mapItem/mapCollection/mapPaginated`. ## Default query-serializer wire format ```text where('status', 'published') => filter[status]=published where('price', '>=', 100) => filter[price][gte]=100 whereIn('categoryId', [1, 2]) => filter[categoryId][]=1&filter[categoryId][]=2 whereNotIn('categoryId', [1, 2]) => filter[categoryId][not_in][]=1&filter[categoryId][not_in][]=2 whereNull('deletedAt') => filter[deletedAt]=null whereNotNull('deletedAt') => filter[deletedAt][ne]=null whereBetween('price', [10, 20]) => filter[price][between]=10,20 select(['id', 'title']) => fields=id,title with('author') => include=author orderBy('createdAt', 'desc') => sort=-createdAt limit(10) => page[size]=10 offset(20) => page[offset]=20 page(2, 20) => page[number]=2&page[size]=20 where('status', 'published').orWhere('featured', true) => filter[or][0][status]=published&filter[or][1][featured]=true ``` The `filter[...]` wrapper is configurable via `DefaultNgQlQuerySerializerOptions.filterPrefix` (default `'filter'`). Pass `filterPrefix: null` for bare field names — `id=1122` instead of `filter[id]=1122` — for backends that don't use a `filter[...]` envelope; the OR-group root becomes `or[]` instead of `filter[or][]` in that mode: ```ts provideNgQl({ querySerializer: new DefaultNgQlQuerySerializer({ filterPrefix: null }) }); ``` Override globally via `provideNgQl({ querySerializer })` to match a different backend convention (OData, JSON:API, a bespoke dialect, ...). ## Testing (HttpTestingController) ```ts TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), provideNgQl({ baseUrl: '/api' }), PostResource], }); const httpMock = TestBed.inject(HttpTestingController); const posts = TestBed.inject(PostResource); posts.all().subscribe(); httpMock.expectOne('/api/posts').flush([{ id: 1, title: 'Hello' }]); httpMock.verify(); ``` For Signal methods, call them inside `TestBed.runInInjectionContext(() => ...)`. ## SSR No `window`, `document`, `localStorage`, or other browser globals anywhere in the library. Safe to instantiate once per server-rendered request. ## Common mistakes to avoid when generating code against ng-ql - Do not treat `NgQlResource` as an ORM entity/model — it has no identity map, no change tracking, no `.save()`. It is only a typed façade over HTTP. - Do not call `getSignal`/`firstSignal`/`paginateSignal`/`allSignal`/`findSignal` outside an injection context (they'll throw `NG0203`). Call them as field initializers or inside `runInInjectionContext`. - Do not expect a plain `get()`/`first()`/`paginate()` (Observable) call to use the cache — only the `*Signal` methods cache, via `NgQlSignalRequestOptions`. - Do not mutate a query builder in place or expect chain methods to affect the receiver — always use the returned value: `const q2 = q1.where(...)`, not `q1.where(...); use(q1)`. - Do not assume `first()` throws on an empty result — it resolves to `null`. - Do not hand-roll query strings — use the builder's `where*`/`select`/`with`/`orderBy`/`page` methods so the configured `NgQlQuerySerializer` stays in control of the wire format. ## Full reference README (installation, full API table, custom serializers/adapters, header precedence details): https://github.com/Hkarimi561/ng-ql/blob/main/projects/ng-ql/README.md Live interactive showcase (query console + real example components + copy-pasteable code): https://hkarimi561.github.io/ng-ql/ Source: https://github.com/Hkarimi561/ng-ql