npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@urartian/loopback4-odata

v1.0.1

Published

LoopBack 4 extension that adds OData protocol support with decorators, generated CRUD routes, and PostgreSQL-focused production guidance.

Downloads

247

Readme

@urartian/loopback4-odata

An extension for LoopBack 4 that adds OData protocol support. This package implements OData v4.0 for LoopBack 4 applications.

  • Auto-discovers OData controllers and generates CRUD routes.
  • Exposes OData-style endpoints (/Products(1)) and OpenAPI-compliant ones (/Products/{id}).
  • Provides $metadata endpoint.
  • Simple developer experience with decorators.
  • Advanced $apply support including chained transformations, navigation-path aggregates, and safe in-memory fallbacks when connector pushdown is unavailable.
  • Streams $batch multipart payloads end-to-end, preserving binary sub-responses (PDFs, CSV exports, etc.) without re-encoding them (JSON batches base64-encode binary bodies and include their Content-Type).

For v1, the officially documented and supported SQL path is PostgreSQL. Other connector-specific paths that may still exist in the codebase are not part of the supported surface yet.

v1.0.0 is the first supported public release of the package. CRUD endpoints, $expand, $count, $batch, Actions/Functions, server-driven paging ($skiptoken), delta links ($deltatoken), and PostgreSQL-first production guidance are part of the documented surface. Ongoing work is tracked in docs/roadmap.md.

Community and support guidance lives in docs/community.md.


Installation

npm install @urartian/loopback4-odata
# if your application does not yet depend on LoopBack core packages:
npm install @loopback/core@^7 @loopback/repository@^8 @loopback/rest@^15 @loopback/boot@^8

Requires Node.js 22.x and the host application must supply compatible versions of @loopback/boot, @loopback/core, @loopback/repository, and @loopback/rest (the extension lists them as peer dependencies to avoid duplicate copies).

Getting Started

  1. Enable the component

In your application class:

import { ApplicationConfig } from '@loopback/core';
import { BootMixin } from '@loopback/boot';
import { RepositoryMixin } from '@loopback/repository';
import { RestApplication } from '@loopback/rest';
import { ODataComponent, ODATA_BINDINGS, ODataConfig } from '@urartian/loopback4-odata';

export class MyAppApplication extends BootMixin(RepositoryMixin(RestApplication)) {
  constructor(options: ApplicationConfig = {}) {
    super(options);

    this.component(ODataComponent); // enable OData support
    const current = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;
    this.bind(ODATA_BINDINGS.CONFIG).to({
      ...current,
      tokenSecret: process.env.ODATA_TOKEN_SECRET ?? current.tokenSecret,
    });
    // Register datasources and repositories once they are defined (see Step 3).
  }
}

Production tip: In production (NODE_ENV=production or ODATA_ENV=production) you must provide ODATA_TOKEN_SECRET; the component fails to boot when it’s missing. In non-production environments the component auto-generates a random per-boot secret when none is configured and logs an INFO message (Generated per-boot OData token secret ...). Set ODATA_TOKEN_SECRET=$(openssl rand -hex 32) locally if you need tokens to survive restarts. Rotating the secret (manually or via auto-generation) invalidates existing $skiptoken / $deltatoken links.

  1. Define a model
import { Entity, property } from '@loopback/repository';
import { odataModel } from '@urartian/loopback4-odata';

@odataModel({
  lbModel: { settings: { strict: true } },
  etag: 'updatedAt',
  delta: {
    enabled: true,
    field: 'updatedAt',
  },
})
export class Product extends Entity {
  @property({ id: true })
  id!: number;

  @property()
  name!: string;

  @property()
  price!: number;

  @property({ type: 'date', defaultFn: 'now' })
  updatedAt!: Date;
}

Note: @odataModel() replaces LoopBack’s @model() for OData entity sets, but you still use standard LoopBack @property/relation decorators to define schema and relations. The lbModel block is passed through 1:1 to LoopBack’s @model(definition) decorator (the same object you’d otherwise pass to @model({ ... })). See LoopBack model definition settings: https://loopback.io/doc/en/lb4/Model.html#supported-entries-of-model-definition

Common lbModel.settings examples:

@odataModel({ lbModel: { settings: { strict: true } } })
@odataModel({ lbModel: { settings: { postgresql: { table: 'products' } } } })
@odataModel({ lbModel: { settings: { indexes: { idx_sku: { keys: { sku: 1 }, options: { unique: true } } } } } })
  1. Create a repository

The component expects a DefaultCrudRepository binding for each model decorated with @odataController. Provide a datasource and expose the repository via RepositoryMixin.

import { inject } from '@loopback/core';
import { DefaultCrudRepository, juggler } from '@loopback/repository';

const ds = new juggler.DataSource({
  name: 'db',
  connector: 'memory',
});

export class ProductRepository extends DefaultCrudRepository<Product, typeof Product.prototype.id> {
  constructor(@inject('datasources.db') dataSource: juggler.DataSource) {
    super(Product, dataSource);
  }
}

Register both the datasource and the repository inside the application constructor from Step 1:

this.dataSource(ds);
this.repository(ProductRepository);
  1. Add a controller
@odataController(Product)
export class ProductODataController {}

That’s it — the extension generates repository-backed CRUD endpoints automatically.

Singletons

OData V4 singletons expose a single entity instance at a stable URL (for example /odata/Me) while reusing the same model/repository and CSDL EntityType as the backing entity set.

Entity sets vs singletons (important)

By default, declaring a singleton does not disable the entity set endpoints for that model. When you decorate a model with both entitySetName and singleton, this extension exposes two top-level resources:

  • EntitySet at /odata/<entitySetName> (collection semantics)
    • GET /odata/<entitySetName> returns { "@odata.context": "...", "value": [ ... ] }
    • POST /odata/<entitySetName> creates a new entity
    • GET/PATCH/PUT/DELETE /odata/<entitySetName>(<key>) address a single entity by key
  • Singleton at /odata/<singleton.name> (single-entity semantics)
    • GET /odata/<singleton.name> returns a single entity (no value array)
    • PATCH/PUT /odata/<singleton.name> update/replace the singleton entity
    • DELETE /odata/<singleton.name> is only allowed when singleton.nullable: true

To avoid route and metadata conflicts, singleton names must not match the entity set name (case-insensitive) unless you enable singletonOnly (see below).

Declare a singleton on the model via @odataModel({ singleton: ... }):

@odataModel({
  entitySetName: 'Users',
  singleton: {
    name: 'Me',
    resolveId: async ({ request }) => (request as any).user.id,
  },
})
export class User extends Entity {}

Static singleton (fixed key):

@odataModel({
  // Pick distinct names: the entity set is a collection, the singleton is a single entity.
  entitySetName: 'SettingEntries',
  singleton: { name: 'Settings', id: '1' },
})
export class AppSettings extends Entity {}

Interop note

Some OData servers choose to treat singletons as “singleton-only” resources that reject POST and do not expose a corresponding entity set collection endpoint at the same URL. By default, this extension exposes singletons in addition to the entity set endpoints, so clients must call the singleton URL (/odata/<singleton.name>) to get singleton semantics. Use singletonOnly: true for singleton-only behavior.

Singleton-only mode (singletonOnly: true)

To prevent accidental use of the entity set endpoints (for example, POST /odata/Settings creating multiple rows), you can enable singleton-only mode:

@odataModel({
  entitySetName: 'Settings',
  singletonOnly: true,
  singleton: { name: 'Settings', id: '1' },
})
export class AppSettings extends Entity {}

When singletonOnly: true:

  • The service document and $metadata omit the EntitySet entry for the model.
  • Collection/entity set CRUD routes are not registered (no GET/POST /odata/<EntitySetName>).
  • The singleton routes remain available under /odata/<singleton.name>.
  • singleton.name may match entitySetName (for example /odata/Settings).
  • Bound actions/functions and entity-set navigation routes are not generated in this mode yet.

Endpoints (examples):

  • GET /odata/Me
  • PATCH /odata/Me and PUT /odata/Me
  • Navigation reads like GET /odata/Me/Orders
  • $ref routes like POST /odata/Me/Orders/$ref, PUT /odata/Me/Manager/$ref, and DELETE /odata/Me/Orders(<key>)/$ref (relation-dependent)

The service document (GET /odata) and $metadata include singleton entries. DELETE /odata/<Singleton> is only allowed when singleton.nullable: true.

Errors and error codes

OData endpoints return errors using an OData error payload (for example {"error":{"code":"BadRequest","message":"..."}}).

  • error.code is stable and intended for client logic (use it instead of parsing messages).
  • error.message is human-readable and may change; do not parse it for behavior.
  • If a downstream connector/database error bubbles up with its own string err.code, it is preserved as diagnostics under error.innererror.dbCode (not as error.code).

The authoritative list of stable codes lives in src/odata-error-codes.ts (exported as ODataErrorCodes from @urartian/loopback4-odata).

Stable codes are grouped as follows:

  • Core HTTP-derived codes: BadRequest, Unauthorized, Forbidden, NotFound, Conflict, MethodNotAllowed, PreconditionFailed, PreconditionRequired, PayloadTooLarge, NotImplemented, InternalServerError, NotAcceptable, UnsupportedMediaType, UnprocessableEntity, Gone, TooManyRequests, ServiceUnavailable. These are the default response codes emitted when no more specific OData code is attached to the error.
  • Batch and transport codes: InvalidUrl, InvalidMethod, ResponseTooLarge, TooManyRedirects, BatchExecutionError, BatchSubRequestTimeout, batch-operation-limit-exceeded, changeset-operation-limit-exceeded, batch-payload-size-limit-exceeded, batch-part-size-limit-exceeded, batch-depth-limit-exceeded. These identify malformed batch requests, batch guardrail violations, redirect problems, timeouts, and oversized responses.
  • Preferences, tenancy, and transaction codes: PreferenceNotSupported, TenantResolutionFailed, TransactionCommitFailed, TransactionsNotSupported, MultiDataSourceChangesetNotSupported, AtomicityGroupNotSupported. These cover unsupported client preferences, tenant resolution failures, and transactional guarantees that cannot be honored.
  • Content-ID resolution code: content-id-reference-invalid. This is returned when a $batch request references an unknown or invalid Content-ID.
  • Query, lambda, pushdown, and guardrail codes: lambda-or-unsupported, nested-lambda-depth-exceeded, lambda-alias-prefix-required, pushdown-join-count-exceeded, through-relation-unsupported, navigation-filter-requires-pushdown, postfilter-requires-pushdown, postfilter-top-required, postfilter-scan-limit-exceeded, lambda-pushdown-not-eligible, lambda-scan-limit-exceeded. These describe unsupported query shapes, lambda validation failures, and cases where bounded in-memory fallback or SQL pushdown constraints were exceeded.
  • Typed literal validation codes: invalid-guid-literal, invalid-date-literal, invalid-datetimeoffset-literal, invalid-int64-literal, invalid-decimal-literal, in-list-too-large, in-operator-requires-list, in-operator-requires-literal-list-items, in-operator-requires-non-empty-list. These are used when OData literals cannot be parsed safely or when in (...) operands violate validation rules.

For client code, prefer importing ODataErrorCodes and comparing against the exported constants instead of hard-coding string literals.

Authentication & Authorization

The component mirrors LoopBack’s authentication and authorization metadata from your controller onto every generated CRUD endpoint. Decorate your OData controller exactly as you would a regular REST controller and the extension takes care of the rest:

import { authenticate } from '@loopback/authentication';
import { authorize } from '@loopback/authorization';

@odataController(Product)
@authenticate('jwt')
@authorize({ scopes: ['product.read'] })
export class ProductODataController {
  // Stubbing a method is enough to apply fine-grained metadata.
  // Decorators (authorization, interceptors, etc.) only run when a stub exists.
  @authorize({ scopes: ['product.summary'] })
  async find() {}
}

Generated routes (list, findById, create, update, delete) will enforce the same strategies and scopes. $batch requests automatically reuse the caller’s headers and resolved user profile, so you don’t have to repeat credentials for each entry.

LoopBack’s built-in methods (find, findById, create, updateById, replaceById, deleteById, count) are remapped to the OData CRUD handlers automatically. $value routes reuse metadata from getMediaValue, replaceMediaValue, and deleteMediaValue, so add empty stubs with those names when you need to secure media streams:

@odataController(MediaAsset)
@authenticate('jwt')
export class MediaAssetController {
  @authorize({ scopes: ['media.read'] })
  async find() {}

  @authorize({ scopes: ['media.read'] })
  async findById() {}

  @authorize({ scopes: ['media.read'] })
  async getMediaValue() {}

  @authorize({ scopes: ['media.update'] })
  async replaceMediaValue() {}

  @authorize({ scopes: ['media.delete'] })
  async deleteMediaValue() {}
}

If you expose differently named controller methods, supply custom aliases when registering the entity set so the security metadata still flows through. Any of the canonical handlers below can be remapped: find, findById, create, updateById, replaceById, deleteById, count, linkNavigationRef, unlinkNavigationRef, getMediaValue, replaceMediaValue, and deleteMediaValue.

import { ODATA_BINDINGS } from '@urartian/loopback4-odata';

const registry = await app.get(ODATA_BINDINGS.ENTITY_SET_REGISTRY);
registry.register({
  name: 'Products',
  modelCtor: Product,
  repositoryBindingKey: 'repositories.ProductRepository',
  securityMethodAliases: {
    find: 'list',
    findById: 'get',
    create: 'create',
    updateById: 'patch',
    replaceById: 'put',
    deleteById: 'remove',
    count: 'count',
    linkNavigationRef: 'attachRelation',
    unlinkNavigationRef: 'detachRelation',
    replaceMediaValue: 'uploadBinary',
    getMediaValue: 'downloadBinary',
    deleteMediaValue: 'removeBinary',
  },
});

Write metadata from the controller (update*, replace*, patch*, delete*) automatically propagates to the navigation $ref handlers so users with read-only scopes cannot relink entities. If you need different policies on $ref, you can still override them via securityMethodAliases or by decorating the stub methods directly:

@odataController(Order)
@authenticate('jwt')
export class OrderODataController {
  // Override default write policy by attaching custom metadata
  @authorize({ allowedRoles: ['order-manager'] })
  async linkNavigationRef() {}

  @authorize({ allowedRoles: ['order-manager'] })
  async unlinkNavigationRef() {}
}

Navigation reference routes run through the generated CRUD controller, so the same LoopBack authentication and authorization interceptors execute before links are created or removed. Stub methods with @authenticate / @authorize metadata (or aliases from the writable methods) are enough to secure the $ref endpoints without any additional plumbing.

Endpoints (Phase 3)

Start your app and test:

npm start

For a quick demo, run npm run dev; this boots the example app in examples/basic-app, with an in-memory datasource pre-seeded with sample products and orders so you can experiment with the query options immediately.

The example binds tokenSecret from process.env.ODATA_TOKEN_SECRET but the component already enforces the same behavior internally. In production you must set that environment variable; in dev/test a random secret is generated per boot (logged at INFO) unless you override it. You can also tweak guardrails at runtime via environment variables such as BATCH_MAX_OPERATIONS, BATCH_MAX_PART_BYTES, ODATA_MAX_TOP, ODATA_MAX_SKIP, ODATA_MAX_PAGE_SIZE, and ODATA_MAX_APPLY_PAGE_SIZE.

Metadata
GET /odata/$metadata

Returns generated EDMX/CSDL describing your registered entity sets.

Collection
GET /odata/Products
Response:
{
  "@odata.context": "/odata/$metadata#Products",
  "value": [
    {
      "id": 1,
      "name": "Laptop",
      "price": 1299
    }
  ]
}
Single entity

Both forms work:

GET /odata/Products/1
GET /odata/Products(1)
Response:
{
  "@odata.context": "/odata/$metadata#Products/$entity",
  "@odata.etag": "W/\"01FZJH6G9E7AM4\"",
  "id": 1,
  "name": "Laptop",
  "price": 1299
}
Primitive values

Use the $value path segment to stream a single primitive property:

GET /odata/Products(1)/name/$value
Response (text/plain):
Laptop

For binary fields the server responds with application/octet-stream and streams the raw payload.

Create
curl -X POST /odata/Products \
  -H 'Content-Type: application/json' \
  -d '{"name":"Laptop","price":1299}'

Returns the persisted entity at the top level with standard OData annotations.

Update & Delete
PATCH  /odata/Products/1
DELETE /odata/Products/1

PATCH accepts partial payloads, and DELETE responds with 204 No Content once the repository removes the entity.

Note

The CRUD controller does not generate a PUT /odata/<EntitySet>/{id} endpoint. All scalar/JSON updates must go through PATCH (or the $batch equivalent). PUT is reserved exclusively for $value media streams, so calling PUT /odata/Products(1)/$value only replaces the binary payload and its metadata — it will never touch regular model properties like title.

When you enable optimistic concurrency by configuring an ETag property (for example @odataModel({etag: 'updatedAt'})), the generated endpoints require clients to supply the latest ETag via the If-Match request header. Missing headers result in 428 Precondition Required, while mismatched values return 412 Precondition Failed. ETags are exposed both in response headers and as the @odata.etag field in response bodies so clients can round-trip them easily.

Query options

Common OData query options are translated into LoopBack filters out of the box:

GET /odata/Products?$filter=price gt 500 and name ne 'Monitor'&$orderby=price desc&$top=5&$skip=10&$select=id,name,price

Becomes:

{
  "where": {
    "and": [{ "price": { "gt": 500 } }, { "name": { "neq": "Monitor" } }]
  },
  "order": ["price DESC"],
  "limit": 5,
  "offset": 10,
  "fields": { "id": true, "name": true, "price": true }
}

You can combine $filter (eq, ne, gt, ge, lt, le with and/or, plus string predicates like contains, startswith, endswith), $orderby, $top, $skip, and $select to shape the data returned by your repository queries.

Examples of string predicates translated to LoopBack filters:

GET /odata/Products?$filter=contains(name,'Lap')
{
  "where": {
    "name": { "like": "%Lap%", "escape": "\\" }
  }
}
GET /odata/Products?$filter=startswith(code,'PR-')
{
  "where": {
    "code": { "like": "PR-%", "escape": "\\" }
  }
}
GET /odata/Products?$filter=endswith(category,'ware')
{
  "where": {
    "category": { "like": "%ware", "escape": "\\" }
  }
}

Use $expand to inline related models that are registered on your LoopBack entity relations:

GET /odata/Orders?$expand=customer,items

The extension validates relation names against the model metadata and produces the corresponding include filter:

{
  "include": [{ "relation": "customer" }, { "relation": "items" }]
}

$expand is also supported on single-entity requests (/odata/Orders(1)?$expand=customer). Unknown relation names result in a 400 Bad Request response so clients get immediate feedback when requesting unsupported navigation properties.

Note: OData identifiers are case-sensitive. Use the exact navigation property names exposed in $metadata (for example, $expand=orders not $expand=Orders).

Use $levels to follow a recursive navigation property for multiple hops while reusing the same scoped options at every depth:

GET /odata/Employees?$expand=manager($levels=2;$select=id,name)

This returns each employee with their direct manager and that manager's manager in a single round trip.

The $compute option projects virtual fields evaluated after the repository fetch. Expressions support arithmetic (add, sub, mul, div, mod), simple string helpers (tolower, toupper, concat), literals, and property paths:

GET /odata/OrderItems?$compute=quantity mul unitPrice as LineTotal&$select=id,LineTotal

The response includes the additional LineTotal column without altering/store schemas. Computed aliases can participate in $select and client-side sorting but are currently incompatible with $apply pushdowns or server-driven ordering.

Responses are emitted as JSON by default. Clients can force a JSON payload regardless of the Accept header via ?$format=json. Other media types (XML, CSV, etc.) are not yet supported.

Advanced filter helpers supported:

  • Logical NOT
GET /odata/Products?$filter=not price gt 100
{ "where": { "price": { "lte": 100 } } }
  • Numeric functions: round, floor, ceiling
GET /odata/Products?$filter=round(price) eq 10
{ "where": { "and": [{ "price": { "gte": 9.5 } }, { "price": { "lt": 10.5 } }] } }
  • Compound keys and alternate key predicates are rewritten transparently:
GET /odata/Orders(OrderID=10248,CustomerID='ALFKI')

Normalizes to the REST-friendly route /odata/Orders/OrderID%3D10248%2CCustomerID%3DALFKI before reaching the controller, while preserving string literals (including embedded parentheses, commas, and escaped quotes).

  • Date extraction: year(<DateTimeOffset>) eq <year>
GET /odata/Orders?$filter=year(updatedAt) eq 2024

Translates to a UTC date range for that year.

  • Basic $search
GET /odata/Products?$search=Laptop

Performs a case‑insensitive substring search across all string properties of the model. Multiple terms are OR’ed. Quoted phrases are treated as a single token. Boolean operators are not yet interpreted.

  • String position: indexof
GET /odata/Products?$filter=indexof(name,'Lap') ge 0

Equivalent to contains(name,'Lap'). To test absence use eq -1, or wrap a supported comparison in not:

GET /odata/Products?$filter=indexof(name,'Lap') eq -1

Strict limitations: only presence/absence forms are supported (ge 0, gt -1, eq -1) plus their negations. Exact position comparisons like indexof(name,'Lap') eq 2 are rejected with 400 in strict mode.

  • Substring at position: substring
GET /odata/Products?$filter=substring(code,2) eq 'ABC'

Checks that code has ABC starting at index 2 (0‑based). With explicit length:

GET /odata/Products?$filter=substring(code,4,3) ne 'XYZ'

Strict limitations: supports only eq / ne with a string literal on the right‑hand side. Other comparators or non‑string RHS are rejected (400).

  • String length predicates: length
GET /odata/Products?$filter=length(description) eq 0
GET /odata/Products?$filter=length(code) gt 3
GET /odata/Products?$filter=not length(code) lt 2

All comparison operators (eq, ne, gt, ge, lt, le) are supported with integer literals.

  • Lambda filters alongside additional predicates
GET /odata/Products?$filter=orderItems/any(i: i/unitPrice gt 800) and price gt 1000

The parser keeps lambdas for post-processing or SQL pushdown while still applying any root predicates (like price gt 1000) to the database query. Lambdas support any/all across multi-segment navigation paths and can participate in full $filter boolean expression trees (for example (<lambda>) or (<root predicate>)). See Configuration for the full lambda grammar, guardrails, and pushdown options.

Searchable Fields

Control which fields participate in $search:

  • Decorate properties with @odataSearchable() in your model.
  • Or configure per–entity set in ODataConfig.searchFields.
  • Default : $search is opt-in and uses only annotated fields. If no searchable fields are configured, strict mode returns 400 Bad Request.
  • Guardrails: cap inputs with maxSearchTerms (requests above the cap return 400 Bad Request) and trim the evaluated field list with maxSearchFields so only the first N configured properties participate.

Example:

@odataModel()
export class Product extends Entity {
  @property({ id: true }) id!: number;
  @odataSearchable() @property() name!: string;
  @odataSearchable() @property() sku!: string;
  @property() price!: number;
}

// Or centrally via config
this.bind(ODATA_BINDINGS.CONFIG).to({
  searchMode: 'config-only',
  searchFields: { Products: ['name', 'sku'] },
} as ODataConfig);

Handling Large Integer Types (BigInt/Int64)

When working with large integer types like bigint in PostgreSQL or int64 in OData, special care must be taken to avoid JavaScript's number precision limitations. JavaScript's Number.MAX_SAFE_INTEGER is 9007199254740991, which is smaller than the maximum value for 64-bit integers (9223372036854775807).

To properly handle large integers without precision loss:

  1. Define the property as a string type with proper format specification:
@property({
  type: 'string',  // Use string type to preserve precision
  jsonSchema: {
    type: 'string',
    format: 'int64',
    dataType: 'int64'
  },
  postgresql: {
    dataType: 'bigint'  // Still maps to bigint in the database
  }
})
sequence?: string;  // Use string type in the application layer
  1. Avoid using type: 'number' for properties that might exceed Number.MAX_SAFE_INTEGER:

Incorrect:

@property({
  type: 'number',  // This can cause precision loss for large values
  postgresql: {
    dataType: 'bigint'
  }
})
sequence?: number;

Correct:

@property({
  type: 'string',  // Preserves precision
  jsonSchema: {
    type: 'string',
    format: 'int64'  // Tells OData to treat as int64
  },
  postgresql: {
    dataType: 'bigint'  // Maps to bigint in database
  }
})
sequence?: string;

This approach ensures that large integer values maintain their precision throughout the application layer while still being stored as the appropriate database type. The OData extension will properly handle int64'9223372036854775807' literals without precision loss when the property is defined as a string with the proper format specification.

Key normalization

Incoming URLs are normalized by a path-rewriter middleware so /odata/Products(42) becomes /odata/Products/42 before routing. Key expressions are parsed strictly, escaped (including quotes and GUID prefixes), and capped at 4 KB; malformed or oversized segments are left untouched, which means the request proceeds with the original path and the framework responds with the usual 404/400.

Enable inline counts by passing $count=true alongside other query options:

GET /odata/Products?$filter=price gt 500&$count=true

Response:

{
  "@odata.context": "/odata/$metadata#Products",
  "@odata.count": 12,
  "value": [{ "id": 1, "name": "Laptop", "price": 1299 }]
}

To fetch the count only, call the dedicated path:

GET /odata/Products/$count

The endpoint responds with a plain number and honours $filter (and other supported query options) to scope the count.

Batch multiple operations with a single round-trip using the $batch endpoint:

POST /odata/$batch
Content-Type: application/json

{
  "requests": [
    {"id": "1", "method": "GET", "url": "/odata/Products?$top=1"},
    {"id": "2", "method": "GET", "url": "/odata/Products/$count"},
    {
      "id": "3",
      "atomicityGroup": "changeset-1",
      "method": "POST",
      "url": "/odata/Products",
      "body": {"name": "Tablet", "price": 499}
    }
  ]
}

Responses preserve request order; operations that share atomicityGroup succeed or fail together:

{
  "responses": [
    { "id": "1", "status": 200, "body": { "value": [{ "id": 1, "name": "Laptop" }] } },
    { "id": "2", "status": 200, "body": { "value": [] } },
    { "atomicityGroup": "changeset-1", "id": "3", "status": 201, "body": { "value": { "id": 4 } } }
  ]
}

Actions & Functions

You can publish custom OData operations on top of the generated CRUD surface by decorating controller methods. The OData booter discovers them at startup, wires REST routes automatically, and emits <Action> / <Function> entries in $metadata so OData clients can discover them.

@odataAction() and @odataFunction() accept the same options:

  • name overrides the exported operation name (defaults to the method name).
  • binding selects the scope: entity (default), collection, or unbound.
  • params describes parameters for $metadata (each entry has name and optional type).
  • returnType sets the CSDL return type hint. It can be an EDM string ('Edm.Guid', 'Collection(Edm.String)', etc.), a LoopBack model constructor/factory (DecisionPermissionConfig or () => DecisionPermissionConfig), or collectionOf(...) for Collection(...) return types. Functions default to Edm.String when omitted.
  • rawResponse skips the default OData annotations (like @odata.context/@odata.etag) so you can return a bespoke payload.

params[].type and returnType accept either a literal EDM string ('Edm.Guid', 'Collection(Edm.String)', etc.) or a LoopBack model constructor (pass the class or a factory such as () => DecisionInput). When you reference a model, the generator emits the referenced complex type in $metadata, so clients can discover the schema of your payload/return type without adding dummy properties to entities. Runtime requests are validated against the model definition, so unknown properties trigger 400 Bad Request before your controller runs.

At runtime the framework resolves method arguments this way:

  • Entity-bound operations receive the entity key as the first argument and then the JSON body (actions) or query object (functions).
  • Collection-bound operations receive only the body/query object.
  • Unbound operations are mounted at /odata/<OperationName> and never receive an entity id.

| Binding | HTTP verb | Route example | Notes | | ------------ | --------- | ------------------------------------- | ------------------------------------------------------------ | | entity | POST/GET | POST /odata/Products(1)/discount | Actions expect a JSON body; functions read the query string. | | collection | POST/GET | GET /odata/Products/premiumProducts | Operates on the entire set. | | unbound | POST/GET | POST /odata/resetInventory | No entity segment; useful for cross-cutting jobs. |

Decorated methods still run through the standard LoopBack interceptors and middleware pipeline. The generated CSDL includes bound parameters and return types so metadata-driven tooling (e.g. Power BI, SAP UI5) can discover the operations automatically.

Example: entity action & collection function

Define custom actions and functions with decorators:

@odataController(Product)
class ProductController {
  constructor(@repository(ProductRepository) private products: ProductRepository) {}

  @odataAction({ binding: 'entity' })
  async discount(id: number, body: { percent: number }) {
    const entity = await this.products.findById(id);
    const percent = Number(body?.percent ?? 0);
    await this.products.updateById(id, {
      price: Number(entity.price ?? 0) * (1 - percent / 100),
    });
    return this.products.findById(id);
  }

  @odataFunction({ binding: 'collection' })
  async premiumProducts(query: { minPrice?: string }) {
    const minPrice = Number(query?.minPrice ?? 1_000);
    return this.products.find({ where: { price: { gte: minPrice } } });
  }
}
  • Actions map to POST /odata/Products({id})/discount (body contains parameters) and registered routes respect the usual LoopBack interceptors/middleware.
  • Functions map to GET /odata/Products/premiumProducts?minPrice=1000 and return a collection via GET. Canonical OData invocation is also accepted: GET /odata/Products/premiumProducts(minPrice=1000) (and the namespace-qualified form GET /odata/Products/Default.premiumProducts(minPrice=1000)).
  • Set rawResponse: true in the decorator if you want to return a custom payload instead of the standard OData-formatted entity.
  • Decorated operations are listed automatically in $metadata (CSDL) as bound/unbound actions and functions.

Complex return types

Use a model constructor/factory to generate ComplexType metadata for operation return values. For collections, wrap the type with collectionOf(...) (or use a literal Collection(...) CSDL string):

import { odataFunction, collectionOf } from '@urartian/loopback4-odata';
import { DecisionPermissionConfig } from '../models/decision-permission-config.model';

@odataFunction({
  name: 'readDecisionPermissionConfig',
  binding: 'unbound',
  returnType: () => DecisionPermissionConfig,
})
async readDecisionPermissionConfig() {
  return { id: '1', name: 'example' };
}

@odataFunction({
  name: 'listDecisionPermissionConfigs',
  binding: 'unbound',
  returnType: collectionOf(() => DecisionPermissionConfig),
})
async listDecisionPermissionConfigs() {
  return [{ id: '1', name: 'example' }];
}

Controller Hooks & Overrides

Declare OData hooks right inside your LB4 controller using @odata.before, @odata.after, and @odata.on. These attach to the generated CRUD routes for your model and let you implement before/after logic or fully override an operation.

Import from the package root:

import { odata, CrudHookContext, CrudOnContext } from '@urartian/loopback4-odata';

Supported operations and scopes:

  • Operations: READ, CREATE, UPDATE, DELETE, LINK_NAVIGATION, UNLINK_NAVIGATION
  • Scopes for READ: collection, entity, count

Decorators accept a single operation, an array of operations, or '*' to run on every CRUD action. Arrays behave exactly like stacking individual decorators, so @odata.before(['CREATE', 'UPDATE']) is equivalent to declaring both @odata.before('CREATE') and @odata.before('UPDATE'). Passing '*' expands to all CRUD verbs (READ, CREATE, UPDATE, DELETE, LINK_NAVIGATION, UNLINK_NAVIGATION). When multiple operations are expanded, the hook scope is preserved only for the entries whose resolved operation is READ.

Example usage:

@odataController(Product)
export class ProductODataController {
  constructor(@repository(ProductRepository) private products: ProductRepository) {}

  // Validate and normalize payload before create
  @odata.before('CREATE')
  ensureName(ctx: CrudHookContext) {
    const body = ctx.payload as any;
    if (!body?.name) throw new HttpErrors.BadRequest('name is required');
    body.name = String(body.name).trim();
  }

  // Enforce default ordering on list
  @odata.before('READ', 'collection')
  defaultOrder(ctx: CrudHookContext) {
    ctx.filter = ctx.filter ?? {};
    if (!ctx.filter.order) ctx.filter.order = ['updatedAt DESC'];
  }

  // Redact a field when returning a single entity
  @odata.after('READ', 'entity')
  redact(ctx: CrudHookContext) {
    const entity = ctx.result as any;
    if (entity) delete entity.secret;
  }

  // Override UPDATE. Call next() to delegate to default CRUD logic,
  // or skip next() to fully replace the implementation.
  @odata.on('UPDATE')
  async customUpdate(ctx: CrudOnContext, next: () => Promise<any>) {
    if ((ctx.payload as any)?.blocked) {
      throw new HttpErrors.Forbidden('Blocked field');
    }
    // Augment default logic
    return next();
  }

  // Fully custom collection read using helpers
  @odata.on('READ', 'collection')
  async customList(ctx: CrudOnContext, next: () => Promise<any>) {
    if (!ctx.request.query['featured']) return next();
    const items = await this.products.find({ where: { featured: true } }, ctx.options);
    return ctx.helpers.collection(items);
  }

  // Reuse one hook across multiple write operations
  @odata.before(['CREATE', 'UPDATE'])
  stampWrites(ctx: CrudHookContext) {
    ctx.state.lastWriteOp = ctx.operation;
  }

  // Run after hook on every operation
  @odata.after('*')
  auditAll(ctx: CrudHookContext) {
    console.log('completed', ctx.operation);
  }
}

Notes:

  • before → on → after is the execution order.
  • @odata.before is the place for validation, authorization checks, or enriching/mutating incoming payload/filter data before the generated CRUD logic runs.
  • @odata.on lets you replace or wrap the default CRUD handler, e.g., to call external REST APIs, implement custom persistence, or add business logic before delegating via next().
  • @odata.after is ideal for post-processing responses, emitting audit logs, or firing side effects/events after a successful CRUD call but before the response is sent.
  • Scopes exist solely to split the single READ operation into its three variants (collection, entity, $count). Non-read operations have no notion of scope, so the argument is ignored once a decorator entry resolves to CREATE, UPDATE, DELETE, LINK_NAVIGATION, or UNLINK_NAVIGATION. For example, @odata.before(['READ', 'UPDATE'], 'collection') runs on collection reads and on every update.
  • @odata.on can replace the generated logic by not calling next(). Use ctx.helpers.entity, ctx.helpers.collection, ctx.helpers.count, or ctx.helpers.noContent to produce OData-correct responses when you override.
  • Hooks receive CrudHookContext with request, response, repository, options (including active transactions for $batch), payload/filter/id, and a mutable state bag for passing data between phases.
  • Only one @odata.on is allowed per operation/scope per controller; duplicates fail at boot.
  • Navigation reference routes trigger the dedicated operations LINK_NAVIGATION (for POST/PUT .../$ref) and UNLINK_NAVIGATION (for DELETE .../$ref). The hook context includes relationName, navigationTargetId, navigationTargetKey, plus navigationRelationRepository / navigationTargetRepository so you can enforce custom linking rules.
@odata.before('LINK_NAVIGATION')
blockDuplicateLinks(ctx: CrudHookContext) {
  if (ctx.relationName !== 'items') return;
  const header = ctx.request.get('x-block-link');
  if (header?.toLowerCase() === 'true') {
    throw new HttpErrors.Conflict('Link prevented by business rules.');
  }
}

@odata.on('UNLINK_NAVIGATION')
async auditUnlink(ctx: CrudOnContext, next: () => Promise<unknown>) {
  await next();
  console.log('Unlinked', ctx.navigationTargetId, 'from order', ctx.id);
}

Example: unbound action with a raw response

@odataAction({
  name: 'resetInventoryRaw',
  binding: 'unbound',
  params: [{ name: 'confirm', type: 'Edm.Boolean' }],
  rawResponse: true,
})
async resetInventoryRaw(body: { confirm?: boolean }) {
  if (!body?.confirm) {
    throw new HttpErrors.BadRequest('Pass {"confirm": true} to reset inventory');
  }
  await this.products.updateAll({ quantityOnHand: 0 });
  return { status: 'ok' };
}

This action is exposed as POST /odata/resetInventoryRaw, surfaces in $metadata as an unbound action, and because rawResponse is set, the controller controls the full payload. The sample application also includes a resetInventory action without rawResponse to illustrate the default OData envelope (payload returned under value with @odata.context).

Example: virtual/computed properties

LoopBack models can advertise computed fields by marking them as non-persistent. The extension will list the field in $metadata, but you are responsible for calculating it at runtime and ignoring any client-supplied values.

// order.model.ts
import { Entity, property } from '@loopback/repository';
import { odataModel } from '@urartian/loopback4-odata';

@odataModel({ entitySetName: 'Orders' })
export class Order extends Entity {
  @property({ id: true })
  id: string;

  @property({ type: 'number', required: true })
  amount: number;

  @property({ type: 'string', required: true })
  currency: string;

  @property({ type: 'number', persist: false, jsonSchema: { readOnly: true } })
  totalWithTax?: number;
}
// order.odata-controller.ts
import { AnyObject, repository } from '@loopback/repository';
import { odata, CrudHookContext } from '@urartian/loopback4-odata';
import { Order } from './order.model';
import { OrderRepository } from './order.repository';

const addTotal = (entity: AnyObject) => {
  const rate = entity.currency === 'EUR' ? 0.19 : 0.07;
  const amount = Number(entity.amount ?? 0);
  entity.totalWithTax = Number.isFinite(amount) ? +(amount * (1 + rate)).toFixed(2) : undefined;
  return entity;
};

@odataController(Order)
export class OrderODataController {
  constructor(@repository(OrderRepository) private readonly orders: OrderRepository) {}

  @odata.before('CREATE')
  @odata.before('UPDATE')
  stripVirtual(ctx: CrudHookContext) {
    if (ctx.payload) delete (ctx.payload as AnyObject).totalWithTax;
  }

  @odata.after('READ', 'entity')
  addVirtualToEntity(ctx: CrudHookContext) {
    const entity = ctx.result as AnyObject | undefined;
    if (entity) addTotal(entity);
  }

  @odata.after('READ', 'collection')
  addVirtualToCollection(ctx: CrudHookContext) {
    const payload = ctx.result as { value?: AnyObject[] };
    if (!payload?.value) return;
    payload.value = payload.value.map((item) => addTotal(item));
  }
}

Key points:

  • Declare the field on the model with persist: false so it is not stored in the datasource but still appears in $metadata.
  • Use @odata.before hooks to strip the field from incoming payloads.
  • Populate the computed value in an @odata.after hook (or @odata.on override) before the response is sent.

Using $batch

Send a JSON payload containing requests. When multiple entries share the same atomicityGroup, LoopBack executes them as a changeset and either commits or rolls everything back.

POST /odata/$batch
Content-Type: application/json

{
  "requests": [
    {
      "id": "create",
      "method": "POST",
      "url": "/odata/Products",
      "body": {"name": "Tablet", "price": 599},
      "atomicityGroup": "g1"
    },
    {
      "id": "update",
      "method": "PATCH",
      "url": "/odata/Products(1)",
      "body": {"price": 1499},
      "atomicityGroup": "g1"
    }
  ]
}

If the datasource behind the repositories cannot create transactions (for example, the in-memory connector), the OData component returns 501 Not Implemented with a BatchExecutionError explaining that the changeset could not be guaranteed. Use a transactional connector or omit atomicityGroup to execute requests independently.

The $batch parser enforces guardrails derived from config.batch: payload size (maxPayloadBytes, default 16 MB), total operations (maxOperations, default 100), operations per changeset (maxChangesetOperations, default 50), nesting depth (maxDepth, default 2 levels), and per-part payload size (maxPartBodyBytes, default 4 MB). Requests that exceed these limits short-circuit with 413 Payload Too Large (for size breaches) or 400 Bad Request (for operation limits), and the controller logs a structured warning so you can correlate rejections with client traffic. Tune the limits to match your back-end capacity—anything outside the allowed envelope is rejected before the atomic handlers run.

Optimistic Concurrency (ETags)

Add an ETag column to your LoopBack model and opt in by passing it to @odataModel. The property is typically a timestamp or version counter that you update whenever the record changes.

@odataModel({ etag: 'updatedAt' })
export class Product extends Entity {
  @property({ id: true })
  id!: number;

  @property()
  name!: string;

  @property()
  price!: number;

  @property({ type: 'date', required: true, defaultFn: 'now' })
  updatedAt!: Date;
}

Keep the field fresh in your repository (for example, by stamping updatedAt in create/update hooks). The generated CRUD controller then:

  • Emits ETag: W/"…" headers and @odata.etag payload metadata on GET /odata/Products(…).
  • Accepts optional If-Match headers on PATCH/DELETE. When present, the update succeeds only if the token still matches the stored value; stale tokens return 412 Precondition Failed.
  • Supports caching via If-None-Match on reads (GET responds 304 Not Modified when the token matches).
  • The ETag covers only the root entity unless you choose to update the parent token whenever child rows change.

You can pass an array of property names (@odataModel({etag: ['id', 'updatedAt']})) to build a composite token; the header value becomes a key/value list such as W/"id=42&updatedAt=2025-04-01T10%3A00%3A00.000Z".

Testing

Run npm test to compile the TypeScript specs and execute the unit suite. Acceptance specs leverage @loopback/testlab and will be skipped automatically in environments that disallow binding HTTP ports (for example, certain sandboxes). When running locally, the acceptance suite exercises the generated REST endpoints against a seeded in-memory datasource.

Features

  • [x] OData-style entity paths (Products(1), Orders(OrderID=10248,CustomerID='ALFKI')) supported via middleware with compound and quoted key support
  • [x] Auto-discovery of OData controllers (Booter)
  • [x] Registry of entity sets
  • [x] CRUD controller factory backed by LoopBack repositories
  • [x] Service document exposing registered entity sets
  • [x] $metadata endpoint with generated CSDL (including navigation properties for relations)
  • [x] Basic query options → LoopBack filters ($filter, $orderby, $top, $skip, $select)
  • [x] Extended filter support: not, numeric functions (round, floor, ceiling), date extraction (year), string helpers (trim, concat), date parts (month, day, hour, minute, second) with strict-mode guards when unsupported, and $search across string fields
  • [x] any/all (lambdas): support <nav>/(any|all)(x: <expr>) (including multi-segment paths), not <lambda> rewrite, nested lambdas (depth capped), and combining lambdas with other predicates using boolean trees (including top-level or)
  • [x] Relational expansion via $expand
  • [x] Inline and standalone $count
  • [x] $batch endpoint (JSON and multipart/mixed)
  • Transactions are attempted for changesets (atomicityGroup). The component now caches whether each entity set's datasource can open LoopBack transactions: transactional connectors such as PostgreSQL succeed, while the in-memory connector is marked as non-transactional the first time a client attempts an atomicity group. Once a datasource is confirmed non-transactional the controller refuses future atomicity groups touching that entity set without instantiating its repository. When a datasource lacks transactions the affected changeset is rejected up front with 501 Not Implemented and a BatchExecutionError; omit atomicityGroup to accept best-effort processing or switch to a transactional connector for true atomicity. $metadata surfaces the service-wide capability via the EntityContainer annotation Org.OData.Capabilities.V1.BatchSupported/ChangeSetsSupported (set to true only when every registered entity set is backed by a transactional datasource) and per-entity-set hints via LoopBack.V1.BatchCapabilities.ChangeSetsSupported, so clients can decide whether to emit change sets globally or only for specific entity sets.
  • [x] Honors Prefer: return=minimal|representation for write operations and emits OData-Version/Preference-Applied headers by default
  • [x] OData-compliant error payloads (odata.error) with 501 PreferenceNotSupported for unsupported preferences like respond-async
  • [x] Actions & Functions decorators with auto CSDL generation
  • [x] Proper pluralization of entity sets (via inflection)
  • [x] Transaction-backed $batch changesets (when datasource supports transactions)
  • [x] Optimistic concurrency with OData ETags (If-Match / If-None-Match support on generated CRUD routes)
  • [x] $batch execution runs through the LoopBack pipeline so interceptors/auth apply; changesets use per-datasource transactions and commit/rollback as a unit
  • [x] Configurable base path (basePath), $top limit (maxTop), $count toggle (enableCount), and guardrails for $skip/$expand via config
  • [x] Opt-in $search with boolean operators (AND/OR/NOT), quoted phrases, per-field configuration, and guardrails
  • [x] Configurable CSDL namespace/container names and JSON CSDL output with enriched primitive facets
  • [x] Complex types, enum types, and referential constraints reflected in generated CSDL (XML & JSON)
  • [x] Capabilities annotations (filter functions/restrictions, count/navigation restrictions, permissions, streams, insert/update/delete/search restrictions) to describe service behaviors to OData clients
  • [x] Derived LoopBack models surface $BaseType so inheritance is reflected in the generated CSDL
  • [x] Deep insert support for hasOne/hasMany relations (opt-in per entity set, multi-level traversal)
  • [x] Navigation $ref endpoints for hasOne/hasMany relations (link/unlink existing entities)
  • [x] OpenAPI visibility controls via per-model documentInOpenApi flags and global policies (documentInOpenApiDefault, removeUndocumentedFromSpec)
  • [x] Media streams via $value routes for HasStream entity sets (property-backed binary fields or custom repository adapters)
  • [x] Structured telemetry with request logging, apply/rewrite diagnostics, hook traces, and correlation IDs (opt-in via config)

Queries can now combine boolean operators and phrases:

GET /odata/Products?$search="coffee beans" AND grinder NOT decaf

The example above matches products that include the phrase "coffee beans", also mention "grinder", and omit anything containing "decaf".

String helpers such as trim/concat and date part functions (month, day, hour, minute, second) are processed automatically when strict=false. In strict mode they are accepted only when the server can guarantee correct execution (for example via Postgres pushdown); otherwise they return 400 Bad Request.

Configuration

Customize the OData component via ODataConfig bound at odata.config (the component registers a default). You can override it in your application before boot:

import { ODATA_BINDINGS } from '@urartian/loopback4-odata';
import { ODataConfig } from '@urartian/loopback4-odata';

// inside your app setup
this.bind(ODATA_BINDINGS.CONFIG).to({
  basePath: '/api/odata', // default: '/odata'
  csdlFormat: 'xml', // 'xml' | 'json' (default 'xml')
  namespace: 'Catalog', // default: 'Default'
  entityContainerName: 'CatalogService', // default: 'DefaultContainer'
  namespaceAlias: 'CatalogNS',
  tokenSecret: process.env.ODATA_TOKEN_SECRET!, // required for signed paging/delta tokens (auto-generated per boot outside production when omitted)
  capabilities: {
    filterFunctions: ['contains', 'startswith', 'endswith'],
    countable: true,
    aggregation: true,
  },
  pagination: {
    maxTop: 100, // server paging cap
    maxSkip: 1000, // max skip allowed
    maxPageSize: 50, // server-driven paging guardrail
    maxApplyPageSize: 100, // guardrail for $apply pipelines
  },
  pageSize: 50, // default server-driven paging size (default: 200)
  maxExpandDepth: 2, // max $expand nesting depth
  enableCount: true, // enable inline and standalone $count
  strict: true, // enable strict validations (default: true)
  enableDelta: true, // emit $deltatoken links for incremental syncs
  batch: {
    maxPayloadBytes: 16 * 1024 * 1024, // total request size limit
    maxOperations: 100, // total requests allowed per batch
    maxChangesetOperations: 50, // per-changeset limit
    maxPartBodyBytes: 4 * 1024 * 1024, // individual part payload limit
    maxResponseBodyBytes: 4 * 1024 * 1024, // per-sub-response buffering limit
    maxResponsePayloadBytes: 32 * 1024 * 1024, // aggregate buffered + serialized response limit
    allowedSubRequestHeaders: ['if-match', 'prefer'], // optional extra headers sub-requests may override
    subRequestTimeoutMs: 30_000, // abort sub-requests that exceed this duration
  },
  writeTransactions: {
    enabled: true, // wrap non-$batch writes in a datasource transaction when supported
    rejectMultiDataSource: true, // default: true (reject writes spanning multiple datasources)
  },
  onLog(entry) {
    myTelemetryClient.trackEvent({
      name: 'odata-log',
      properties: {
        level: entry.level,
        message: entry.message,
        ...entry.context,
      },
    });
  },
} as ODataConfig);

Capabilities presets (FilterFunctions)

The service advertises supported $filter functions in $metadata via Org.OData.Capabilities.V1.FilterFunctions. To keep this aligned with the library’s implementation without maintaining long arrays, you can use a preset (no runtime connector auto-detection):

  • filterFunctionsPreset: 'default': legacy/minimal set (matches previous default behavior)
  • filterFunctionsPreset: 'postgres': Postgres-ready set aligned with implemented pushdowns (includes trim, concat, month, etc.)
import { ODATA_BINDINGS, ODataConfig, FILTER_FUNCTIONS_POSTGRES } from '@urartian/loopback4-odata';

const currentConfig = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;

this.bind(ODATA_BINDINGS.CONFIG).to({
  ...currentConfig,
  capabilities: {
    ...currentConfig.capabilities,
    filterFunctionsPreset: 'postgres',
  },
} satisfies ODataConfig);

// or explicitly:
this.bind(ODATA_BINDINGS.CONFIG).to({
  ...currentConfig,
  capabilities: {
    ...currentConfig.capabilities,
    filterFunctions: FILTER_FUNCTIONS_POSTGRES,
  },
} satisfies ODataConfig);

Notes: FilterFunctions only advertises $filter functions. Operators like in (...) are not represented there. Presets and lists are normalized (trimmed, lowercased, de-duplicated).

$filter not implemented (yet)

This library focuses on a predictable subset of $filter that can be enforced in strict=true and pushed down efficiently on Postgres. If a client sends anything outside this subset, behavior is:

  • strict=true: rejected with 400 Bad Request
  • strict=false: may still be rejected (or bounded post-filtered) depending on whether the expression can be evaluated safely

Supported $filter summary

At a high level, the server supports:

  • Boolean logic: nested and / or / not with parentheses
  • Comparisons: eq/ne/gt/ge/lt/le, including null/true/false
  • Typed literals + model-aware coercion: guid'...', date'...', datetimeoffset'...', int64'...' (and 123L), decimal'...'
  • in (...): parsed as inq with capabilities.filter.maxInListItems guardrail (null-safe)
  • String filtering: contains/startswith/endswith, and direct tolower(...)/toupper(...) comparisons (plus Postgres pushdown for supported patterns)
  • Date parts: month/day/hour/minute/second comparisons (Postgres pushdown)
  • Navigation paths:
    • To-one chains (belongsTo/hasOne) ending in primitive fields are supported (Postgres pushdown when available; bounded fallback otherwise)
    • To-many navigation filters are supported only via lambdas (any/all)
  • Lambdas (any/all): nested (depth capped), top-level or support, Postgres pushdown when enabled, and bounded fallback when pushdown declines

For the exact set of supported $filter functions, rely on $metadata (Org.OData.Capabilities.V1.FilterFunctions) or the filterFunctionsPreset presets (default / postgres).

$filter examples

Basic comparisons:

GET /odata/Products?$filter=price ge 100 and active eq true

Typed literals (model-aware coercion):

GET /odata/Orders?$filter=customerId eq guid'01234567-89ab-cdef-0123-456789abcdef'
GET /odata/Orders?$filter=createdAt ge datetimeoffset'2026-01-03T10:20:30Z'

in (...) (null-safe; subject to filter.maxInListItems):

GET /odata/Products?$filter=status in ('Open','Closed',null)

String filtering:

GET /odata/Products?$filter=contains(name,'lap')
GET /odata/Products?$filter=tolower(name) eq 'laptop'

To-one navigation path filters (Postgres pushdown when available; otherwise bounded fallback):

GET /odata/Orders?$filter=customer/name eq 'Alice'&$top=50

To-many navigation filters via lambdas:

GET /odata/Orders?$filter=items/any(i: i/quantity gt 0)
GET /odata/Orders?$filter=items/all(i: i/cancelled eq false)

Notable $filter features that are currently not implemented:

  • Type functions: cast(...), isof(...)
  • String functions: replace(...) (and most other string functions beyond what $metadata advertises)
  • Arithmetic expressions/operators in $filter (add, sub, mul, div, mod)
  • Spatial/geo functions (all geo.* and geography/geometry operators)
  • Enum flag operator has
  • Deep function composition (e.g. replace(tolower(name), 'a', 'b') eq '...')
  • To-many navigation path filters outside lambdas (e.g. items/quantity gt 0); only lambda forms like items/any(i: i/quantity gt 0) are supported

Capabilities defaults (FilterRestrictions)

The service also emits Org.OData.Capabilities.V1.FilterRestrictions per entity set. By default, it marks any to-many navigation properties (hasMany / hasManyThrough) as non-filterable (since the server rejects to-many navigation filters outside lambdas).

You can add additional hints (or override booleans) via config:

this.bind(ODATA_BINDINGS.CONFIG).to({
  ...currentConfig,
  capabilities: {
    ...currentConfig.capabilities,
    filterRestrictions: {
      requiresFilter: false,
      nonFilterableProperties: ['internalFlag'],
    },
  },
} satisfies ODataConfig);

For mixed datasources (or per-entity differences), prefer per-entity-set overrides:

import { FILTER_FUNCTIONS_POSTGRES, type ODataEntitySetConfig } from '@urartian/loopback4-odata';

export const PurchasesSet: ODataEntitySetConfig = {
  name: 'Purchases',
  modelCtor: Purchase,
  capabilities: { filterFunctions: FILTER_FUNCTIONS_POSTGRES },
};

Important: capabilities is a nested object. Flags such as aggregation, applySupported, or filterFunctions belong under config.capabilities. If you bind a brand-new config object without copying the defaults registered by ODataComponent, those flags disappear and features like $apply aggregations are reported as not implemented. Prefer this.getSync(ODATA_BINDINGS.CONFIG) and spread the existing value before applying overrides.

const currentConfig = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;

this.bind(ODATA_BINDINGS.CONFIG).to({
  ...currentConfig,
  maxTop: 100,
  enableDeepInsert: true,
  capabilities: {
    ...currentConfig.capabilities,
    aggregation: true,
    applySupported: true,
    filterFunctions: ['contains', 'startswith', 'endswith'],
  },
} satisfies ODataConfig);

Spreading the current config ensures sensitive settings such as tokenSecret remain intact unless you explicitly replace them.

Per-entity guardrails can be applied through the entity-set config you register with the registry binding. Entity-level limits override the global pagination block, allowing you to relax or tighten caps on a per-feed basis:

import { type ODataEntitySetConfig } from '@urartian/loopback4-odata';

const ProductsSet: ODataEntitySetConfig<Product> = {
  name: 'Products',
  modelCtor: Product,
  repositoryBindingKey: 'repositories.ProductRepository',
  pagination: {
    maxTop: 500,
    maxPageSize: 250,
    maxApplyPageSize: 100,
  },
};

Validation: Guardrail values must be positive integers. Invalid settings (for example, pagination.maxPageSize: 0 or skipTokenTtl: -5) cause startup to fail fast so configuration issues surface immediately.

  • basePath: Externally visible service root. All OData routes are served under this path (via middleware rewrite) while internal routes remain at /odata. Response metadata (@odata.context) uses this value.
  • trustProxyHeaders: Controls whether Forwarded / X-Forwarded-* headers participate in host/protocol detection. Set to true to always trust them, or false/omitted to ignore them entirely (this now ignores Express' global trust proxy flag so the OData component cannot be opted-in accidentally). When disabled, the controller only considers the immediate request's Host and protocol values, preventing spoofed headers from bypassing origin scoping.
  • trustedProxySubnets: CIDR/IP allow-list (string[]) describing which reverse proxies are allowed to supply Forwarded / X-Forwarded-* headers (for example ['10.0.0.0/8', '2001:db8::/32']). Defaults to [], meaning proxy headers are ignored unless trustProxyHeaders === true. When populated, the controller only honors proxy headers if the remote address matches a configured subnet, so direct-to-app attackers cannot spoof origins even when the app runs behind ingress.
  • pagination.maxTop: Caps $top for collection reads. When strict=true requests above the cap return 400 Bad Request; otherwise the server clamps the value. Legacy config.maxTop is still honored but the nested value takes precedence.
  • pagination.maxSkip: Maximum allowed $skip. Requests above the cap are clamped when strict=false and rejected when strict=true. Legacy config.maxSkip remains available for backward compatibility.
  • pagination.maxPageSize: Upper bound for server-driven paging on collection endpoints. The service never emits more than this many entities in a single page even when clients omit $top.
  • pagination.maxApplyPageSize: Upper bound for server-driven paging when executing $apply pipelines. When unset, it falls back to maxPageSize.
  • pageSize: Default number of records per page for server-driven paging. The service always returns at most this many entities and emits an @odata.nextLink with a signed $skiptoken so clients can resume the feed. Automatically clamped to the configured pagination guardrails.
  • appendKeysForClientPaging: When true (default) the controller appends the entity key columns to client-supplied $orderby clauses whenever requests use manual $skip. This keeps offset-based paging stable for frameworks that ignore @odata.nextLink (for example, SAPUI5 growing tables). Set to false only if you need the backend to preserve the original order verbatim even at the cost of potential duplicates across pages.
  • Manual paging: Supplying both $skip (even 0) and a positive $top switches the request into client-driven paging. In that mode the backend clamps $top to the configured page size, appends key columns for deterministic ordering, and suppresses @odata.nextLink. Clients must increment $skip themselves to fetch more rows. If $skip is sent without $top, the controller sticks with server-driven paging and still emits @odata.nextLink.
  • enableDelta: When true, collection responses include @odata.deltaLink so clients can poll only the rows that changed since the last snapshot.
  • tokenSecret: Required secret used to sign $skiptoken / $deltatoken payloads. When NODE_ENV or ODATA_ENV is production, ODATA_TOKEN_SECRET must be set or the app refuses to boot. In non-production environments the component auto-generates a per-boot random secret if none is provided and logs an INFO message; pagination/delta links expire on every restart until you set ODATA_TOKEN_SECRET yourself (for example export ODATA_TOKEN_SECRET=$(openssl rand -hex 32)).
  • skipTokenTtl: Lifetime (in seconds) for issued $skiptoken links. Defaults to 900 (15 minutes). Expired tokens return 400 Invalid $skiptoken.
  • deltaTokenTtl: Optional lifetime (seconds