Lookups
A lookup is a named, indexed access path into your records by fields other than id or naturalKey. When you declare a lookup on a model, Foir generates a typed GraphQL query that returns the matching record in one round trip — backed by an index, with no full-table scan.
Overview
Common patterns lookups solve:
pageByHandle(handle: "about-us")— find a page by its handle without knowing its id.redirectByFromHostAndFromPath(fromHost: "old.example.com", fromPath: "/contact")— composite key for a redirect’s source.productBySku(sku: "WIDGET-RED")— look up a product by its inventory SKU.
A lookup is uniquely identified within its scope: only one record can hold a given value combination per channel, variant, locale, and customer slice. The platform enforces this with a unique constraint — writes that would create a duplicate are rejected with a structured error.
When to use a lookup vs. a list filter
| Lookup | List filter (pages(filters: …)) | |
|---|---|---|
| Returns | A single record or null | A list |
| Uniqueness | Enforced by the platform | Not enforced |
| Index | Always | Only when the field is indexed |
| Use case | ”find the page with handle X" | "find pages matching some criteria” |
Lookups are for identity — “this value points to exactly one record”. List filters are for search — “give me all records matching this”.
Declaring lookups
Add a lookups array to your model in foir.config.ts:
import { defineConfig } from '@eide/foir-cli/configs';
export default defineConfig({
key: 'my-project',
name: 'My Project',
models: [
{
key: 'page',
name: 'Page',
fields: [
{ key: 'handle', type: 'text', required: true },
{ key: 'legacyId', type: 'text' },
{ key: 'title', type: 'text' },
],
lookups: [
{ keyBy: ['handle'] },
{ keyBy: ['legacyId'] },
],
},
{
key: 'redirect',
name: 'Redirect',
fields: [
{ key: 'fromHost', type: 'text' },
{ key: 'fromPath', type: 'text', required: true },
{ key: 'toUrl', type: 'text', required: true },
],
lookups: [
// Composite key — both fields together form the lookup
{ keyBy: ['fromHost', 'fromPath'] },
],
},
],
});foir push validates the declaration server-side. Errors are inline, with the offending lookup index and field name in the message.
Constraints on keyBy fields
- Scalar fields only:
text,number,boolean,enum. Object, array, reference, rich content, and media fields are rejected. - Top-level fields only: nested-field paths (
address.country) are not supported. - 1–4 fields per composite key, 1–4 lookups per model. These are bounded to keep the underlying index efficient; raise the limits via a separate proposal if you have a real case.
Renaming a lookup
The generated GraphQL query name is derived from your keyBy. To override it, set name:
lookups: [
{ keyBy: ['handle'], name: 'pageByPublicHandle' },
]The override must match GraphQL’s field-name rules ([A-Za-z_][A-Za-z0-9_]*).
If you rename a lookup that already has records (changing its name while keeping the same keyBy), Foir treats it as a rebuild: the projection rows under the old name are dropped, then re-emitted under the new name. To accept this on push, pass --rebuild:
foir push --rebuildWithout the flag, the push is rejected with a pointer to the flag — guards against accidental rebuilds on production data.
The generated GraphQL queries
For each declared lookup, Foir adds one query field per model:
extend type Query {
pageByHandle(handle: String!, locale: String): Page
pageByLegacyId(legacyId: String, locale: String): Page
redirectByFromHostAndFromPath(
fromHost: String,
fromPath: String!,
locale: String,
): Redirect
}The arg names match your keyBy field keys. The arg types come from the field’s scalar type (text → String, number → Float, boolean → Boolean). A required field becomes a non-null GraphQL arg (String!), a nullable field becomes a nullable arg (String).
Every lookup query also carries a locale: String arg. Omit it (or pass null) to read the locale-neutral slice; pass a value to read that locale’s translation if the field has a _translations envelope.
Calling the query
query GetPage {
pageByHandle(handle: "about-us") {
id
handle
title
}
}Returns either the matching record or null. There is no “list of matches” — if you’re not sure your value is unique, use a list query with a filter instead.
Working with variants and locales
When a record has variant overrides on a keyBy field, the lookup query returns the record whose active variant for the caller’s context matches. The variant the customer sees is selected by Foir’s variant-targeting rules (the same logic that drives the variant a regular page(id: …) query returns), so the lookup answer matches the variant the rest of the site is showing.
Example: a page record has handle "blog" by default and "journal" for the EU variant. A customer in the EU asking pageByHandle(handle: "journal") gets the record. A customer in the US asking pageByHandle(handle: "blog") gets the same record.
Locales work similarly: pageByHandle(handle: "noticias", locale: "es-ES") resolves against the Spanish translation slice.
Save-time uniqueness
When you save a record whose keyBy field collides with another record’s value in the same scope (channel + variant + locale + customer), the save fails with a structured error:
lookup conflict on page lookup "pageByHandle" (channel=draft, variant=default, locale=any)In the admin, this surfaces as an inline error on the offending field plus a save-banner pointing you at the conflict-resolution view (Lookups → Conflicts) where you can see every record competing for the value and resolve them.
Inline validation runs as you type — when you enter a value already used by another record, the field turns red before you click Save.
Adding a lookup to a model that already has records
When you add a lookups entry to an existing model, foir push triggers a backfill: every existing record’s projection row is materialised so the new lookup queries return them.
If your existing data has duplicates on the new lookup’s keyBy, the backfill halts with a clear error and the lookup definition does not land. Resolve the duplicates (rename, merge, or delete the colliding records) and re-push.
CLI reference
# Push lookup definitions to the platform.
foir push
# Push, accepting any lookup rename as a rebuild.
foir push --rebuildSee also
- Models — capability flags, field types, schema basics.
- Variants — how variant overrides shape lookup resolution.
- Localization — how the
localearg interacts with field translations. - Records — the underlying record model.