Skip to Content
FeaturesLookups

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

LookupList filter (pages(filters: …))
ReturnsA single record or nullA list
UniquenessEnforced by the platformNot enforced
IndexAlwaysOnly 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 --rebuild

Without 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 (textString, numberFloat, booleanBoolean). 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 --rebuild

See also

  • Models — capability flags, field types, schema basics.
  • Variants — how variant overrides shape lookup resolution.
  • Localization — how the locale arg interacts with field translations.
  • Records — the underlying record model.
Last updated on