developer guide
Connect a Data Grid to OData: Server-Side Filter/Sort
Point the grid at an OData service and it pushes filtering, sorting, paging and the count to the endpoint, so an enterprise feed of any size is answered server-side.
A grid over an OData feed, with nothing to write
OData was built for exactly this: filter, order, top, skip and count all travel in the URL, which is why the grid needs no negotiation and no server of ours to sit in front of one. Point the adapter at an entity-set URL and the grid turns each sort, filter and scroll into an OData query, sends it, and shows the rows that come back. A feed of any size stays fast because the service answers the query rather than the browser thinning a download.
import { createGrid, createPushdownSource, odataAdapter } from '@toclocoinc/lattice-grid';
import * as compute from '@toclocoinc/lattice-grid';
createGrid(el, {
columns,
rowKey: 'OrderID',
source: createPushdownSource({
adapter: odataAdapter({
url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders',
}),
compute, // the grid's own kernels, for anything the service did not do
pageSize: 100,
}),
});
The grid stays a client library. The adapter builds a URL and reads JSON, and imports nothing, so the bundle is the same whether a page uses it or not.
What pushes down to the service
This is the honest version, because the value of a server source is exactly which half of the query reaches the server. The OData adapter pushes:
| Query part | Pushed as | Notes |
|---|---|---|
| Filtering | $filter | The full condition tree, with and and or groups. |
| Sorting | $orderby | Multi-column, in order. |
| Paging | $skip and $top | The grid asks for the window it needs. |
| Count | $count=true | The exact match count, for a true scrollbar. On by default. |
| Quick filter | $search | Only when you pass search: true, because not every service implements it. |
// The URL the adapter builds for a filtered, sorted page:
GET /Orders?$filter=(Country eq 'Germany' and Freight gt 50)
&$orderby=OrderDate desc
&$skip=100&$top=100
&$count=true
The operators pushed into $filter are the ones OData v4 expresses directly: equals and not
equals, the four inequalities, contains, startsWith and endsWith,
and blank and not-blank as eq null and ne null. A filter that uses only these
is answered entirely by the service.
What runs in the browser
Grouping, totals and the statistics layer are computed in the browser, not by the service. By default they run over the rows the grid has loaded, so over a windowed OData source they describe the current view rather than the whole matching set, and a dev-mode warning fires when a figure covers only part of the data. To get a whole-dataset figure, turn on the full-dataset pull: the grid asks the service for everything the filter matches, once, and holds it, so every group, total and statistic then computes over all of it. It is memory-guarded, so a set past the configured ceiling is refused with a visible error rather than truncated. Pushing the aggregation down to the service is a DuckDB-adapter capability and is not offered here, because OData does not express the grid's statistics.
Residual work is a separate case and does pull the dataset in. A filter using an operator OData cannot
express is split: the supported conjuncts go to the service and the remainder is applied locally after the
fetch. When a filter, a sort or a quick search is left for the browser to finish, the grid stops asking
for a window and asks for the whole matching result, because filtering or sorting a single page locally
would return the wrong rows. That is correct but slower, and it is said out loud: the source warns once,
naming the part it could not push. And if the adapter is ever handed a fraction of the result it asked for
while there is residual work to do, it refuses the result and surfaces an error rather than working over a
fraction and reporting it as the whole. A wrong answer is never preferred to a visible failure. Grouping,
totals and statistics are not residual work, so an unpushed operator does not force them whole; the
full-dataset pull above is the separate, deliberate way to compute them over everything. You can see the
split after every query with source.lastPlan().
Server-driven paging is followed
When the whole result is needed and the service applies its own page size, it says so with an
@odata.nextLink rather than by failing. The adapter follows that link until the result is
complete, so "everything" means everything, up to a ceiling of two hundred pages, at which point a
filter that matches far more than expected fails loudly rather than fetching a table forever.
Authentication and options
The adapter carries no auth logic of its own; it only sends what you give it. A credential that does not
change - an API key or a long-lived token - goes in headers, which the adapter merges into
every request. A credential that expires needs a different approach, because a static header goes stale:
pass a custom fetch instead, and the adapter calls it fresh for every request, which is the
one place a new token can be minted per call. Turn the count off if your service does not support
$count, and turn search on only if it implements $search. With the
count off there is no total to report, so the result carries none and the grid scrolls open-ended,
discovering the end as the reader reaches it, rather than being told the set is one page long.
// A fixed credential (an API key, or a long-lived token) goes in headers,
// sent as-is on every request.
odataAdapter({
url: 'https://your-service/odata/Orders',
headers: { Authorization: 'Bearer ' + token },
count: true, // default; sends $count=true so the total is exact
search: true, // only if your service implements $search
})
// A token that expires cannot live in a static header, so refresh it in a
// custom fetch instead. The adapter calls your fetch fresh for every
// request, which is the one place a new token can be minted per call.
odataAdapter({
url: 'https://your-service/odata/Orders',
fetch: async (url, init) => {
const token = await getFreshToken();
return fetch(url, { ...init, headers: { ...init.headers, Authorization: 'Bearer ' + token } });
},
})
Those two options cover every scheme, because headers and a custom fetch are all
an HTTP call ever needs. The examples below are the common cases spelled out.
Basic auth and a custom header name
A username and password becomes an Authorization: Basic header once you encode it, and it
goes in headers like any other fixed credential. A service that wants its key under its own
header name is the same idea: the adapter sends whatever keys you give it, so X-API-Key or
any other name works, not just Bearer.
// Basic auth: encode username and password into the Authorization header.
odataAdapter({
url: 'https://your-service/odata/Orders',
headers: { Authorization: 'Basic ' + btoa(user + ':' + pass) },
})
// A non-standard scheme is just another header. Any name works, not only
// Authorization, because the adapter sends the map through unchanged.
odataAdapter({
url: 'https://your-service/odata/Orders',
headers: { 'X-API-Key': apiKey },
})
Cookie or session auth
If your users already hold a session cookie from signing in, you can let the browser send it with each
request rather than managing a token at all. The adapter sends no cookies on its own, so ask for them in
a custom fetch by setting credentials: 'include'. One thing to get right for a
cross-origin service: it has to allow credentialed requests in its CORS response, or the browser will
quietly leave the cookie off.
// Cookie or session auth: let the browser attach the session cookie by
// setting credentials on the request. The adapter carries no cookies of its
// own, so this lives in a custom fetch. The service must return
// Access-Control-Allow-Credentials for a cross-origin call, or the browser
// drops the cookie.
odataAdapter({
url: 'https://your-service/odata/Orders',
fetch: (url, init) => fetch(url, { ...init, credentials: 'include' }),
})
Refresh on 401 and retry
Minting a fresh token on every single request works, but it is wasteful when the token you hold is still good. The pattern most OAuth setups actually want is to use the cached token, and only refresh when the service rejects it with a 401, then retry the one request. A still-valid token is reused; an expired one is replaced exactly once, at the moment it stops working. It is written for the grid's own read requests; if you adapt it for a call that sends a body, carry that body onto the retry so the second attempt is the same request as the first.
// Refresh on 401 and retry once. This is what most OAuth integrations
// actually need: use the cached token, and only mint a fresh one when the
// service says the old one has expired, so a still-valid token is never
// thrown away.
odataAdapter({
url: 'https://your-service/odata/Orders',
fetch: async (url, init) => {
const withToken = (token) => ({
...init,
headers: { ...init.headers, Authorization: 'Bearer ' + token },
});
let response = await fetch(url, withToken(await getToken()));
if (response.status === 401) {
response = await fetch(url, withToken(await getToken({ force: true })));
}
return response;
},
})
OAuth client-credentials
For a service-to-service integration, fetch a token from your identity provider's token endpoint, hold it
until it is close to expiring, and refresh it from inside the custom fetch. Keeping the token
endpoint call in a small helper means the adapter never has to know how the token is obtained; it only
ever receives the finished header. The cache shown here is shared, so an app that runs several connections
against different services or tenants would scope the cache per connection rather than through one.
// OAuth client-credentials: mint a token from the token endpoint, cache it
// until it is close to expiry, and refresh from inside the custom fetch. The
// getToken helper owns the token endpoint call; the adapter only ever sees
// the header it produces.
let cached = null; // { token, expiresAt }
async function getToken() {
if (cached && cached.expiresAt > Date.now() + 30_000) return cached.token;
const res = await fetch('https://your-idp/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
}),
});
const { access_token, expires_in } = await res.json();
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
return cached.token;
}
odataAdapter({
url: 'https://your-service/odata/Orders',
fetch: async (url, init) => {
const token = await getToken();
return fetch(url, { ...init, headers: { ...init.headers, Authorization: 'Bearer ' + token } });
},
})
See it running: pushdown to a live OData service, which drives a
public service and shows lastPlan() as you filter. The
pushdown guide covers the split rules that apply across every adapter, and
connect your data lists the other options.