# Predicates and Saved Views

When you want only *some* records — only featured articles, only members from one department, only events after a date — you write a **predicate** in a `where:` field: on a query in `queries.yml`, to decide which records the query selects, or on a fetch, to narrow a query for one page or section. Predicates are structured YAML objects: no special grammar to learn beyond the field-name → value pattern you already use in frontmatter.

This guide covers the predicate format, the patterns for reusing predicates as saved views, and how foundations build filter UIs that compose predicates from reader interactions.

---

## The simplest predicate

A predicate is a YAML object whose top-level keys are field names. Bare values are matched as equality. Multiple keys are combined with implicit AND:

```yaml
# pages/blog/page.yml
fetch:
  query: articles
  where:
    featured: true
    category: news
```

This delivers the query's articles where `featured == true` AND `category == 'news'`. On a fetch, a predicate narrows the query: a record must meet the query's own `where:` and this one, so a fetch never shows a record its query leaves out.

The same predicate works on a query and on any fetch — in section frontmatter, `page.yml` or `site.yml` — and against any source: the site's own records, a host's live records, an external query's response.

---

## Operators for non-equality

When you need more than equality, the value becomes a small object naming the operator:

```yaml
where:
  rank: { in: [associate, full] }         # value is one of the list
  start_year: { gte: 2010 }               # ≥ comparison
  status: { not_in: [draft, archived] }   # value is none of the list
  email: { exists: true }                 # field has a value
  title: { starts_with: 'the' }           # text, case-insensitive
```

Every operator means the same thing wherever the records come from — the site's own compiled records, or a host that answers queries.

**Equality, comparison, membership, presence:**

| Operator | Meaning | Example |
| --- | --- | --- |
| `eq` | Equal (also implicit when the value is bare) | `{ status: { eq: 'active' } }` |
| `ne` | Not equal | `{ status: { ne: 'archived' } }` |
| `gt`, `gte` | Greater than / greater than or equal | `{ year: { gte: 2020 } }` |
| `lt`, `lte` | Less than / less than or equal | `{ price: { lt: 100 } }` |
| `in` | Equal to one of the listed values | `{ tag: { in: [news, events] } }` |
| `not_in` | Equal to none of the listed values | `{ status: { not_in: [draft, archived] } }` |
| `exists` | `true`: the field has a value — not missing, empty text or an empty list (`0` and `false` are values). `false`: it has none | `{ author: { exists: true } }` |

**Text** — plain text, no wildcards, compared regardless of case:

| Operator | Meaning | Example |
| --- | --- | --- |
| `starts_with` | A text that starts with the value | `{ title: { starts_with: 'the' } }` |
| `ends_with` | A text that ends with the value | `{ file: { ends_with: '.pdf' } }` |
| `contains` | On a text, holds the value as a piece of it; on a list, holds an item equal to it | `{ title: { contains: 'guide' } }` · `{ tags: { contains: news } }` |

Values keep their type: `'3'` does not equal `3`, and a number or a boolean is not text.

**Lists.** A condition on a field that holds a list holds when **any member** satisfies it: `{ tags: featured }` selects a record whose `tags` are `[featured, sale]`, `{ tags: { ne: featured } }` selects one that does not have `featured`, `{ tags: { not_in: [a, b] } }` one that has neither, and a comparison holds when some member satisfies it.

**Missing fields.** A record with no value for a field satisfies `ne`, `not_in` and `exists: false`, and nothing else — a record with no `status` is "not archived".

**Composition** — `and`, `or`, `not` — is covered below. `or` earns its place across *different* fields (*featured or pinned*); alternatives on one field are an `in` list.

A predicate the language does not contain stops the build with a message naming the problem: an unknown operator, an empty `and:` or `or:`, a text operator with empty text. `like` and `nin` are retired — write `starts_with`, `ends_with` or `contains`, and `not_in`.

Dotted field names (`tenure.start`) descend into a nested record — see [Dotted field names](#dotted-field-names) below.

### A folder branch is `scope:`, not a predicate

Where a record sits inside the site's folder is its **placement** — the top of the folder, or `2024/spring` inside two folders. A record does not carry it; to read one branch of that folder, give the query a `scope:`. It holds the named folder **and everything below it**, and it respects segment boundaries, so a sibling that merely starts with the same letters is not swept in:

```yaml
queries:
  spring:
    schema: '@/post'
    scope: '2024'          # 2024 and 2024/spring — never 2024b
```

`scope:` works the same wherever the records come from: on a static site the compiled records are filtered by it, and a host that answers queries takes it as the query's own scope. It belongs to the query alone — which branch a query reads decides what the query is — so a `fetch:` that names the query cannot carry one; the build stops and says where it goes. To read another branch, declare another query. On a parametric page, `scope: :dir` reads the branch the URL names (see [Parametric Pages](../reference/dynamic-routes.md#multi-segment-routes--path)).

Plain equality on `path` still selects a single level:

```yaml
where: { path: '2024' }            # only records directly at 2024
where: { path: '' }                # only records at the top level
```

An earlier spelling, `where: { path: { under: '2024' } }`, is retired: the build refuses it and names `scope:` in its message.

#### Why not `starts_with` on `path`?

`starts_with` compares text, not folders. Against records at `''`, `2024`, `2024/spring`, `2024/spring/may`, `2024b` and `2023`:

| condition | what it actually selects |
| --- | --- |
| `{ path: { starts_with: '2024/' } }` | `2024/spring`, `2024/spring/may` — misses `2024` itself |
| `{ path: { starts_with: '2024' } }` | those two, `2024` — **and `2024b`**, a different branch |
| `scope: '2024'` | `2024`, `2024/spring`, `2024/spring/may` |

`scope:` stops at segment boundaries, so a neighbour like `2024b` never sneaks in. **Use the text operators for text — names, titles, codes. Use `scope:` for a branch of the folder.**

Strings use single or double quotes; numbers and booleans are bare. `null` matches missing or null fields.

---

## AND, OR, NOT

Top-level keys are combined with implicit AND. For OR or NOT, use explicit composition keys at any nesting level:

```yaml
where:
  # Implicit AND across these keys.
  featured: true
  department: biology

  # Explicit OR for this one.
  or:
    - { rank: full }
    - { tenured: true }

  # Negation.
  not:
    department: emeritus
```

`and:` is also available explicitly when you need it (rare, since the top level is already AND).

Composition keys can nest:

```yaml
where:
  and:
    - { tenured: true }
    - or:
        - { rank: full }
        - { years_in_role: { gte: 10 } }
```

Means: tenured AND (rank is full OR has been in role for 10+ years).

---

## Dotted field names

For nested objects, use dotted paths:

```yaml
where:
  tenure.start: { gte: 2015 }
  address.city: Oxford
```

Each dot descends one level. Missing intermediate objects don't error — they just don't match.

When a step reaches a list — a CV's `education` entries, say — the path descends into each item, and the values it reaches are read as a list: `education.degree: PhD` selects a record any of whose `education` entries has that degree.

---

## Saved views

If you find yourself writing the same predicate in multiple places, save it. A saved view is just a regular set of records, where each record holds a `where:` predicate alongside human-readable metadata:

```
site/
└── records/
    └── view/
        ├── tenured-biology.yml
        ├── recent-hires.yml
        └── professors-only.yml
```

Each file is a record:

```yaml
# records/view/tenured-biology.yml
name: Tenured Biology
description: Tenured members of the Department of Biology.
where:
  department: biology
  tenured: true
```

Declare the query in `site.yml` like any other:

```yaml
# site.yml
queries:
  members:
    schema: '@/member'
  views:
    schema: '@/view'
```

A foundation reads the views query (e.g., on a `Cover` section) to populate a dropdown. When the reader picks a view, the foundation filters with that record's `where:` value — the same language, with the same meaning, as a `where:` you write on a query.

There's no special framework feature for saved views. They're just records, and `where:` is just a field on each record. Authors who want curated named populations write them; foundations decide how to surface them.

---

## Queryable surfaces (filter UIs from a foundation)

For sites where readers compose their own predicates by interacting with controls — a department dropdown, a tenure toggle, a year-range slider — the **query** declares its **queryable surface**: which fields are filterable, what type each is, and any type-specific metadata (enum options, range bounds):

```yaml
# site.yml
queries:
  members:
    schema: '@/member'
    queryable:
      department:
        type: enum
        label: Department
        options: [biology, physics, chemistry, geology]
      rank:
        type: enum
        label: Rank
        options: [assistant, associate, full, professor]
      tenured:
        type: boolean
        label: Tenured
      start_year:
        type: range
        label: Start year
        min: 1800
        max: 2025
```

You declare what's filterable; the foundation reads the metadata and renders the controls. When the reader picks values, the foundation composes a where-object — exactly the same shape you'd write by hand — and filters the records with it. Nothing on your side changes.

Field types in the starter set:

| Type | Metadata | Foundation typically renders |
| --- | --- | --- |
| `enum` | `options: [...]` | Dropdown / radio / checkbox-list |
| `boolean` | (none) | Toggle / checkbox |
| `range` | `min`, `max`, optional `step` | Slider / number-range input |
| `text` | optional `placeholder` | Text input |

Foundations may add richer types as they need them. The framework passes the metadata through unchanged — your `queryable:` declaration is the contract.

---

## Where evaluation happens

You don't pick. The same `where: { department: biology, tenured: true }` is evaluated wherever the records come from:

- **The site's own records, on a static site** — the framework applies the predicate itself, over the file the build generates from the query: at build time for the pages it prerenders, in the browser for anything fetched later.
- **An external query** — the framework applies it over the records the endpoint returned.
- **A host that answers queries** — the predicate travels with the query, and the host returns only the matching records. Nothing changes in what you write.
- **A backend reached through a foundation transport** — the transport decides what it evaluates.

---

## What predicates can't do

The where-object format is deliberately small. It's a way to **select records**, not a general query language.

These are intentionally out of scope:

- **Aggregation.** `COUNT`, `SUM`, `AVG`, `GROUP BY`. Compute these in your component code (or in the backend) over the records the predicate returned.
- **Projection.** "Give me only the title and excerpt fields." A predicate never changes which fields a record carries. A list carries each record's [brief](../reference/queries.md#what-a-list-carries).
- **Joins.** Matching records of one query against another's. The author embeds the relationship in the data (id references, embedded arrays).
- **Subqueries.** Predicates can compose with `and`/`or`/`not` but can't reference other queries.

If you find yourself reaching for these, the answer is usually: precompute it during the build, or compute it in your component. The predicate's job is to narrow which records arrive — that's it.

---

## Examples

### Featured articles, newest first, top three

```yaml
fetch:
  query: articles
  where: { tags: featured }
  sort: date desc
  limit: 3
```

### Active products under $100

```yaml
fetch:
  query: products
  where:
    active: true
    price: { lt: 100 }
  sort: price asc
```

### Recently joined biology faculty

```yaml
fetch:
  query: members
  where:
    department: biology
    start_year: { gte: 2020 }
```

### Anything but drafts

```yaml
fetch:
  query: articles
  where:
    not:
      status: draft
```

### Members hired in two specific departments

```yaml
fetch:
  query: members
  where:
    department: { in: [biology, geology] }
```

### Members who completed a doctorate

```yaml
fetch:
  query: members
  where:
    'education.doctorate.year': { exists: true }
```

---

## What's next

- **[Queries](../reference/queries.md)** — everything a query can say, including the complete operator reference and what a list carries.
- **[Data Fetching](../reference/data-fetching.md)** — the `fetch:` declaration, and how a fetch narrows a query.
- **[Working with Records](./collections.md)** — records and queries in depth.
- **[Data Sources](../development/data-sources.md)** — when your where-objects ship over the wire instead of running locally.