Selection set expansion
GraphQL only
Selection set expansion makes sure every field a surrogate key needs is in the query your origin receives, even when the client did not select it. Your clients write the queries they need, and GraphPilot still tags every cached response with the keys that let a write purge it.
query { products { name } }query { products { name __gp_id: id } }{ name, __gp_id }{ name }Why it matters
Section titled “Why it matters”A surrogate key is built from field values in the response,
such as a product’s id. If the client did not select that field, the value is not in the
response, and without expansion the cached entry would carry no key for it. A later write to that
product could not purge it.
Expansion removes that dependency on the client:
- Keys do not depend on what the client selected. The same operation is tagged with the same
keys whether or not the client asked for
id, so a purge reaches it either way. - Clients need no cache-specific fields. Nobody has to remember to select
id“for the cache”, and adding such a field to one client does not split the cache from the clients that do not. - Composite and related keys work. A key built from a relation, such as
category.id, gets the relation’s field added too.
How it works
Section titled “How it works”When you deploy, the deployment build reads your schema and every configured source of surrogate
keys: the @surrogateKey directive, a directive of your
own, and the global rules for scalars, enums and field names. From these it knows which fields
each key needs.
On every query or mutation that reaches your origin, GraphPilot compares the client’s selection
set with those fields and adds the missing ones. Each added field gets an alias with the prefix
set by origin.field_prefix (default __gp_), so it cannot
collide with a field the client selected. The prefix is reserved for this: a query with a
response key that starts with it is refused with 400 and RESERVED_FIELD_PREFIX
before it reaches your origin, since its answer would otherwise be removed along with the added
fields. The response key is a field’s alias when it has one and its name otherwise, so an
unaliased schema field whose name starts with the prefix is refused as well. Keep that in mind
before you set a short prefix such as data_: every query that selects such a field without an
alias is then refused, and the fix the edge’s message asks for (“Choose a different alias”) means
aliasing that field. When the response comes back, GraphPilot reads the key
values from the added fields, derives the surrogate keys, and removes the added fields from the
response, and from any errors entry that mentions them, before the client receives it.
Example
Section titled “Example”Consider this schema, from a product catalog:
type Query { products(category: ID): [Product!]!}
type Product @surrogateKey(of: "category.id") { id: ID! name: String! price: Int! category: Category!}
type Category { id: ID! name: String!}A client sends:
query { products { name price }}GraphPilot adds what the keys on Product need: id for the automatic Product:id key (an ID
field), and category.id for the key the directive declares.
query { products { name price __gp_id: id __gp_category: category { id } }}The alias sits on the field GraphPilot added. Fields inside an added relation, here id inside
__gp_category, keep their names, because the whole relation is already set apart by its alias. No __typename is added here: Product and Category are object types, so the type of every
position is known from the query. GraphPilot adds __typename only where a key sits under an
interface or a union, to tell the concrete types apart.
Your origin responds:
{ "data": { "products": [ { "name": "Studio headphones", "price": 1999, "__gp_id": "p01", "__gp_category": { "id": "audio" } }, { "name": "Standing desk", "price": 39900, "__gp_id": "p06", "__gp_category": { "id": "desks" } } ] }}From it, GraphPilot derives these surrogate keys for the cached entry:
| What a purge by this key reaches | Surrogate keys |
|---|---|
| Products with a specific id | Product:id:p01Product:id:p06 |
| Products of a specific category | Product:category.Product:category. |
There is no Category:id:audio among them, although Category.id is a key field: the category
GraphPilot added is there for the Product key alone. A client that selects category { id name }
itself gets Category:id:audio on its entry as well.
Before the response leaves, GraphPilot removes every added field, so the client receives exactly what it asked for:
{ "data": { "products": [ { "name": "Studio headphones", "price": 1999 }, { "name": "Standing desk", "price": 39900 } ] }}The gp-surrogate-key header shows the same thing on a real response. A client asking only for
product(id: "p02") { name price } gets back an entry tagged with both keys:
gp-surrogate-key: ck:0ed2ffb7…7884 q:product Product Query Product:category.id:audio Product:id:p02Purging a category’s products
Section titled “Purging a category’s products”Product:category.id:audio is on every entry that contains a product of the audio category,
whichever query produced it and whether or not the client selected the category. One purge
reaches all of them:
gpilot purge --service shop-api Product:category.id:audioIt is a different key from Category:id:audio, which is on entries that contain the category
object itself, such as a page that selected category { name }. A renameCategory mutation
returns the category, so it purges Category:id:audio and reaches the pages that show the old
name. A price change in the category is the job of Product:category.id:audio. Pick the key that
matches what a write actually changes.
Fragments
Section titled “Fragments”GraphPilot never adds fields to a fragment definition. A fragment can be spread in several
places, so a field added to its definition would change every place it is used. Instead, the
missing fields are added to the selection set at the place where the fragment is used, beside
the spread. This is the same whether the @surrogateKey sits on the type or on the field that
returns it.
fragment ProductFields on Product { name}
query { products { ...ProductFields }}fragment ProductFields on Product { name}
query { products { __gp_id: id __gp_category: category { id } ...ProductFields }}A fragment spread in two places gets the added fields at both places. A field that is already selected where the fragment is used, directly or through an inline fragment, is reused rather than added a second time.
A fragment cannot spread itself, directly or through another fragment: the GraphQL specification forbids fragment cycles, and your origin rejects such a document.
Limitations
Section titled “Limitations”- A response GraphPilot cannot read keeps the added fields. Removing them needs the response
parsed as JSON. When that fails, the client receives the body exactly as your origin sent it,
including the aliased key fields (
__gp_idand the like), and the response is not stored (CACHE_SKIPPED_UNPARSEABLE_GRAPHQL_RESPONSE). The causes are a body larger than 133 MiB and a JSON integer larger than 18446744073709551615. A body that is not JSON at all, such as an HTML error page, carries no added fields in the first place. The added fields are only ever key fields of the objects the client selected, never data from anywhere else, and because the prefix is reserved a client cannot mistake one for a field of its own. To avoid it, return very large integers as strings (anIDor a custom scalar) and keep responses well below the size limit. See refusals that depend only on what the response carries.