MTG Copilot Docs
Search

Anatomy of a search

From query text to rows: tokenizing, the intermediate representation, and how it compiles to SQL.

A search goes through three stages, and each one can explain a surprising result. The syntax reference covers what to type. This covers what happens next.

1. Text becomes a tree

The query string is tokenized and parsed into an intermediate representation: a tree of conditions and groups.

t:creature (r:rare OR r:mythic) -kw:defender

becomes an AND group containing a condition, an OR group of two conditions, and a negated condition. Parentheses in the text become nesting in the tree. The tree is what gets compiled, so grouping changes results.

Two properties of the parser decide how a malformed query behaves:

  • It is best-effort. A token it cannot parse becomes a bare name search. t:creature garbage!! searches for creatures whose name contains garbage!! and returns rows.
  • The tree round-trips. The same module serializes a tree back to a query string, so the query the system ran can always be shown back to you. This is how the AI's searches are displayed.

A query with bare words and no operators is recognized as a plain name search and routed to a typo-tolerant lookup. That is why lightening bolt finds Lightning Bolt while o:lightening finds nothing.

2. The tree becomes SQL

Each condition maps to a filter on a column of the cards table, or on a denormalized array beside it. The mapping is looser than it looks:

You writeIt filters
t:wolfType line substring or the parsed subtype list
o:flyingA generated column concatenating every face of the card
set:cmmThe set-code array on the card, with no join to printings
tag:removalA denormalized tag array, with no join to associations
price<=1The cheapest-price sidecar, joined as a view

The denormalized arrays exist because joining through association tables on every search measured slower on a table this size, with no benefit. They are maintained by the jobs named in the reference pages, so in principle they can lag the tables they derive from.

OR across different fields cannot always be compiled to a single filter chain. Two things have no expression inside an OR group: exact mana costs (m:) and strict colour subsets (c<, ci<). A query using them there fails with an error naming what to split out. The alternative would be to drop the branch and answer with the unfiltered superset, which is a wrong result that looks like a successful one.

3. Rows come back

Results are read from a view that joins cards to its price sidecar, so price can be filtered and sorted in the same query even though it lives in a separate table on a separate refresh schedule.

Counts come from the database as exact counts. A result count is the size of the full match set, not of the page you were given.

Why a search surprised you

In rough order of likelihood:

  • A bare word was treated as a name. Anything the parser could not read becomes a name term. Check the query that actually ran.
  • t: matched a subtype you did not intend. t:bear matches the creature type.
  • o: matched the back face. Double-faced cards match on text you cannot see on the front.
  • A colour operator meant the other direction. Bare c: is "contains" and bare ci: is "fits inside". Write >= or <= explicitly. The asymmetry is Scryfall's convention and we match it.
  • The query used m: or a strict colour subset inside an OR. That combination raises an error. Run the branches as separate searches.

Where the code lives

The IR and its serializer are in packages/core/src/query/, as pure TypeScript with no database access. The compiler is apps/web/lib/search/executor.ts. The split lets the query language be parsed, validated and round-tripped anywhere, including in the browser and inside the AI pipeline.

On this page