GraphWeaver::Codegen turns one GraphQL operation into one # typed: strict
Ruby module. Everything srb tc knows about your query results comes from that
file — there is no runtime schema, no lazy wrapper, no reflection.
Read this when you want to predict the output, or when a generated name isn't the one you expected:
- Anatomy — what a module holds, and what a
Resultcan do - Naming — how the module and every nested struct get their names
- Variables — kwargs, input objects, coercion
- Enums and selections — fragments, aliases, unions and interfaces
- Type helpers — your own methods on a generated struct
- Clients,
from_response, and the build itself
The setup around it is assembled step by step in
getting started, including
what Sorbet does and doesn't require.
For consoles and dev there's dynamic mode; for one-off scripts,
client.run! skips modules entirely.
module PersonQuery
QUERY = "..." # the operation, verbatim
OPERATION_NAME = "PersonQuery" # its name — the module's, when the file's is anonymous
class Result < T::Struct # the response shape, exactly as selected
class Person < T::Struct
const :name, String # non-null in the schema
const :birthday, T.nilable(Date)
def self.from_h(data) ... # generated casting — no reflection
end
const :person, T.nilable(Person)
end
extend GraphWeaver::QueryModule # client (see below)
def self.execute(id:, client: nil) # -> GraphWeaver::Response[Result]
def self.execute!(id:, client: nil) # -> Result, or raises QueryError
def self.from_response(response) # deserialize a raw hash -> Response[Result]
def self.from_response!(response) # -> Result, or raises QueryError
endexecute returns the envelope — GraphWeaver::Response[Result] with
#data, #data!, #errors, #extensions — so partial data and cost/throttle
metadata survive. execute! is the shortcut: the typed result, or a raised
GraphWeaver::QueryError. See errors.
from_response is the
network-free half of the pair.
OPERATION_NAME rides along on every request as the spec's operationName, so
Apollo Studio, Hasura and your APM key traces, rate limits and slow-query reports
on the operation instead of lumping every request together. You don't have to
name your operations: an anonymous document is named after the module in the
emitted QUERY and in OPERATION_NAME — both, since a server rejects an
operationName its document doesn't declare.
Code that takes any generated module — a persisted-query manifest, a
transport of your own — types it as GraphWeaver::QueryModule and reads the
same two through query_string and operation_name, which carry sigs. That is
the spelling rubocop-sorbet allows: mod.const_get(:QUERY) on a Module is
Sorbet/ConstantsFromStrings, and the T.unsafe that gets around it is
Sorbet/ForbidTUnsafe.
sig { params(mod: GraphWeaver::QueryModule).returns(String) }
def manifest_line(mod) = "#{mod.operation_name}: #{mod.query_string.bytesize} bytes"A Result is an ordinary Ruby object: value == (with eql? and hash,
so a result works as a hash key), deconstruct_keys for pattern matching,
#to_h, and #to_json/#as_json. All of them go the whole way down a nested
result. It is immutable as far as its props go, like Struct or Data — and no
further: the String or Hash a leaf holds is the one the response carried, so
result.name << "!" changes the result, and its hash with it.
case PersonQuery.execute!(id: "1")
in { person: { name:, pets: [{ name: first_pet }, *] } } then "#{name} and #{first_pet}"
in { person: { name: } } then "#{name}, petless"
in { person: nil } then "nobody"
end#to_h is the Ruby shape; #to_json is the wire shape. to_h gives
snake_case prop names as Symbols, nils kept, enums as their T::Enum members,
and a registered scalar as whatever object its codec built — a view, for Ruby to
read. #to_json — and #as_json, which render json: goes through — writes
the response keys instead, every leaf back through its scalar registration's
serialize:, so a result's JSON is the inverse of from_h
(Result.from_h(JSON.parse(result.to_json)) == result), which is what a cache
entry, a log line or a JSON API response wants. The split is deliberate: a
Symbol-keyed Ruby hash can't be mistaken for a server's response, and a JSON
string can, so the JSON is the one that has to be true. (An input struct's
to_h is already the wire hash it sends, so there its JSON and its to_h
agree.) The trip is exactly as faithful as each scalar's own cast:/serialize:
pair: a Time goes back out with
the microseconds its registration writes,
and a register_scalar with a cast: and no serialize: has no wire spelling
at all, so its value reaches the encoder as it is.
Cache a result with Marshal or JSON, not YAML. A T::Enum member is a
singleton that sorbet compares by identity, and Psych allocates an object before
filling it in, so YAML has no way to hand back the canonical one: after a round
trip pet.species == Species::Dog is false and the result no longer equals
itself.
A module is named after its file, suffixed with the operation the file
defines — person.graphql → PersonQuery in person_query.rb,
save_list_entry.graphql → SaveListEntryMutation in
save_list_entry_mutation.rb. The operation name written inside the file never
names the module (it goes on the wire as operationName); leave it off and the
module's name is written into the document instead. The same rule runs at all
three doors: generate!, GraphWeaver.parse(path), and client.load_queries!.
Every run of non-alphanumerics in the file name is a word boundary, after a
trailing .query/.mutation/.subscription extension naming the document's own
operation is dropped — so get-hello.graphql is GetHelloQuery in
get_hello_query.rb, and hello.query.graphql is HelloQuery, not
HelloQueryQuery. Only that extension is dropped: user.profile.graphql is
UserProfileQuery, keeping the profile. A file whose extension names a kind it
doesn't hold (hello.query.graphql defining a mutation) is refused, naming both
halves. What is left still has to spell a constant — 01_home.graphql is
refused, since 01HomeQuery isn't one.
Subdirectories are yours to organize with — queries/admin/pets.graphql is
found, but the module name still comes from the file name alone, so it is
PetsQuery. Two files that name the same module are refused at generation,
naming both, rather than one silently overwriting the other; so is a file holding
two operations, since one file can't name two modules. Change a file's query to
mutation and its constant changes with it; the next generate! prunes the old
file, and verify fails until you regenerate.
A graph's namespace: nests what it generates, and is the answer when two
schemas in one app each have a person.graphql: namespace: "Billing" makes
that one Billing::PersonQuery in the same person_query.rb, and its shared
types module Billing::Types (plain Types inside a namespace, where nothing
collides with graphql-ruby's own Types::; GraphQLTypes without one). Nothing
else about the rule changes. See
getting started.
Parsing a raw query string has no file to name it after, so it uses the
operation name (query GetPerson → GetPerson); dynamic parse falls back to
Query for an anonymous one (its constants are container-scoped, so collisions
are impossible) while Codegen.generate insists on a deliberate name. Override
with name: on either. Assign a parsed module to a constant and every nested
struct upgrades to that real path, so a cast failure names it rather than a hex
object address.
Every nested type is named for the response key that selects it, camelized
(stargazers → Stargazers, nameWithOwner → NameWithOwner, _entities →
Entities). Structs nest the way the selection does, so the constant path reads
like the query:
query { repository { stargazers { edges { node { login } } } } }StargazersQuery::Result::Repository::Stargazers::Edges::NodeThe name is a function of that field's own position and nothing else, which is
the property that matters when generated code is checked in and referenced from
app code: adding, removing, or reordering an unrelated selection can never
rename a struct you already use.
spec/naming_spec.rb asserts each of those three
edits leaves the name alone.
The key is used verbatim — no pluralization heuristics, so a list field pets
generates Pets, not Pet. To choose the name yourself, alias the field in the
query: pet: pets { name } generates Pet (and a .pet accessor).
Union and interface members are the one name that doesn't come from a key:
they take the type condition that produces them (... on Book → Book) inside
the container named for the field, plus the catch-all Other; a type
hoisted out of a shared fragment is named for
the fragment; and several fields sharing one collapsed union type take the first
of their keys alphabetically. Still position-determined, all of it — a fragment
name is a source fact like a result key.
Two collisions are handled rather than left to surprise you. A name that would
shadow the struct it nests in (pet { pet { ... } }) takes a numeric suffix
(Pet2), since a bare Pet inside class Pet would resolve to the child. And a
name that would shadow a constant the file uses is refused — a key date
beside a Date scalar prop turns Date.iso8601 into a NoMethodError in a file
that typechecks. The message names both; alias either one in the query.
mutation($name: String!, $species: Species!, $note: String) { ... }AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)- required vs optional falls out of nullability and defaults: nullable or defaulted variables become optional kwargs
- absent and
nullare different things, and the kwarg says which. Leaving a keyword out omits the variable, so the server's default applies; passingnilsendsnull, which is how a mutation clears a field.bio: params[:bio]therefore sendsnullwhen the param is missing — pass the keyword only when you mean to. A non-null variable can't carrynull, sonilthere still means omit. - enum variables accept the enum or its wire value (
species: Species::Dogorspecies: "DOG") - custom scalars serialize through the scalar registry
- one kwarg per declared variable, always — so adding a variable to a query adds
a kwarg and leaves every existing call site alone. Two names are refused at
generation,
$clientand$variables: the generatedexecutebody already owns them. Rename the variable in the query.
The kwarg is typed exactly as the schema types it, and the value is coerced anyway. Those aren't in tension, because they answer different questions:
StargazersQuery.execute(first: 10) # typechecks
StargazersQuery.execute(first: "10") # srb tc error — you know it's a literal
StargazersQuery.execute(first: params[:first]) # typechecks, and "10" becomes 10first: is Integer, never T.any(Integer, String), so srb tc still catches
a call site that has the wrong thing. But a Rails param is T.untyped — sorbet
lets it through, and execute converts it in its body from what the scalar
already knows (the table is in scalars.md).
A value that won't convert raises GraphWeaver::InputError naming the variable,
the operation and the value.
That is why the emitted sig carries .checked(:never): sorbet-runtime would
otherwise reject the String before the body could read it. Coercion stands in its
place for the arguments — stricter, and with a better message — and the Result
it returns is a T::Struct, so its props are still checked one by one.
(T::Configuration.default_checked_level = :never buys nothing back: that knob
governs sig dispatch, which the emitted sigs already opt out of, and from_h
allocates and costs the same either way.)
Input objects take the generated T::Struct or a plain hash — .coerce
normalizes underscored Symbol/String keys, enums accept wire values, nested
inputs accept hashes, and an unknown key raises with a spellchecked hint rather
than silently dropping:
AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
# the struct form is the one srb tc checks field by field
AdoptMutation.execute!(input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog))A struct is typed consts plus a compact per-field FIELDS table the
GraphWeaver::InputStruct runtime drives — serialize (aliased to_h) builds
the wire hash, coerce builds from a plain hash. coerce remembers which keys
the hash had, so {nickname: nil} sends null and {} omits the field. A
struct built with .new can't tell the two apart — every unset prop is nil
either way — so nil there means omit; reach for coerce to send an explicit
null. Nested inputs work, including recursive ones: a self-referential filter
generates cleanly, with _and:/_not: typed as the struct itself.
In the generate! workflow input types are emitted once per schema, one file
per type under generated/types/ with types.rb as the manifest. Query modules
alias what they touch, so AdoptMutation::AdoptionInput still works and a shared
type keeps one identity across modules; a query module aliases only its variable
root types, so a deeply nested one is reached as GraphQLTypes::<Type>. Per-type
files keep drift reviewable: a schema migration diffs exactly the types it
touched, and types the schema drops are pruned on regeneration (verify flags
strays). Dynamic parse stays self-contained.
A result type is generated per selection set, because a selection set is the
question. An input object has no selection set, so the only static answer to
"what can $where hold" is every input type it can transitively reach — and
codegen emits a file for each. On a hand-written schema that closure is usually
the one type and nothing else. On a generated one (Hasura, Gatsby), where every
_bool_exp references every other, one $where reaches a thousand of them.
The escape is to stop making the filter a variable. Write it as a literal in the query with a variable per leaf, and codegen has ordinary scalars to generate instead of the closure — on the query that emitted ~1,200 files, exactly one:
query($name: String!, $minHeight: Int!) {
pokemon(where: { name: { _ilike: $name }, height: { _gte: $minHeight } }) {
name
}
}srb tc gets more out of that, not less. name: String, min_height: Integer
are types it checks at every call site, where the variable form is
T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped]) — and a hash built from
params, which is how a filter is really assembled, takes the untyped branch.
Refusals land on the leaf too, so path is the form field rather than the
comparison operator under it. Two shapes can't be inlined, and codegen says which
when a prop collision forces the question: a key chosen at runtime (the sort
column in order_by: { <column>: asc }), since GraphQL has no dynamic object
keys, and a list whose length only the runtime knows.
Every schema enum a query touches — as a variable, in a result, or both —
becomes exactly one Ruby type in the shared module, named for the enum
(GraphQLTypes::Species), and every query module aliases it:
species = SearchQuery.execute!(term: "Shelby").search.first.species
AddPetMutation.execute!(name: "Rex", species:) # same class, no conversionSo a value read out of one query hands straight back into another's variable,
case/T.absurd is exhaustive across your app, and the class a field gets
doesn't depend on what else the query happened to reference. An enum value that
camelizes to nothing — _ and __ are both legal GraphQL — is refused at
generation: there is no constant to name it. Map the enum onto one of yours
instead.
Two values that camelize to the same constant are refused for the same reason.
A schema mid-rename declares exactly that — LEGACY_MODE alongside
legacy_mode, so old clients keep working — and if they are one value, say
which spelling goes on the wire:
GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })Both spellings then cast to Status::LegacyMode, only the target gets a
constant, and a variable sends the target — see
scalars.md.
A value the server adds after you generate is drift, and casting it raises by
default. register_enum("Species", fallback: true) gives the generated enum one
extra member, Other, that every undeclared wire value casts to — the same
catch-all an abstract type's dispatch always carries. A variable can't send it;
see scalars.md.
register_enum replaces the generated T::Enum with your own app enum — see
scalars.md. Dynamic parse emits the
enums into the query module itself; there's no cross-query set to share against,
but one enum is still one class within that module.
The one misuse nothing catches is comparing against the wire spelling:
pet.species == "CAT" # => false, always, and silently
pet.species == GraphQLTypes::Species::CatA generated enum is a plain T::Enum, so == against a String is false —
srb tc allows it (== takes BasicObject) and nothing raises. sorbet-runtime
owns this question and ships the switch: turn on
T::Configuration.enable_legacy_t_enum_migration_mode in dev and test, and route
soft_assert_handler wherever your other soft assertions go. It covers your own
T::Enums too, which is why it belongs there rather than in the generated
classes. Careful reading it: in that mode the comparison answers true (it
serializes first), so the handler, not the return value, is the signal.
- Fragments — inline fragments and named spreads flatten into the selection; type conditions match exact names or interfaces/unions the type belongs to.
@skip/@include— a directive-conditional field may be absent from the response regardless of schema nullability, so its generated type is always nilable.- Aliases — result keys follow aliases; props are the underscored alias.
Props are always snake_case (nameWithOwner → name_with_owner). Reaching for
the wire name is a classic stumble, so it fails helpfully at both layers: srb tc flags it statically, and at runtime (consoles, dynamic mode) the struct
raises a NoMethodError naming the prop that does exist — use 'name_with_owner'
for the exact wire name, did you mean ...? for a near-miss typo in either
casing.
A name that would shadow a method every struct answers — class, hash,
display, to_json, and supplied on an input — takes a trailing underscore
instead: class → class_, in results and input types alike, and the generated
source says so on the line above the prop. Only the Ruby name moves. It is the
one Ruby name for the field, so .new, .coerce, a result's #to_h and pattern
matching, and an InputError's #path all use class_ (an input error's
#coordinate still names the schema's Tricky.class) — while the wire keeps the
schema's spelling in both directions, so the query, the request, the response,
and #as_json/#to_json are untouched and render json: result never leaks a
trailing underscore.
A whole field selected as exactly one named shared fragment is one type in
GraphQLTypes, named for the fragment, which each query aliases. Object or
abstract, same sentence:
# app/graphql/fragments/pet_fields.graphql
fragment PetFields on Pet { name species birthday }
# two queries, one Ruby type
query Roster { pets { ...PetFields } }
query Shelter { shelter { pets { ...PetFields } } }GraphQLTypes::PetFields # the struct, generated once
RosterQuery::PetFields # the alias each query opens with
sig { params(pet: GraphQLTypes::PetFields).returns(String) } # nameable by your appThat is what makes the fragment the unit of reuse on both sides of the wire: a
presenter, a serializer or a policy can name its argument's type, and a union
spread this way gives one exhaustive case … T.absurd everywhere instead of a
fresh dispatch module per query.
It is opt-in by how you write the query, and only the exact shape hoists —
{ ...PetFields id }, { ...PetFields ...Vaccinations }, a spread carrying
@skip/@include, a fragment the query file defines itself, and a fragment
written on some other type than the field's all stay a position-named struct
where they are. A spread inside a hoisted fragment is inlined into it rather
than hoisted again. Like shared inputs, it's a generate!-directory concern;
dynamic parse inlines everything.
Registrations follow the type, not the position, so extend_type("Pet", …)
mixins and alias: accessors land on the hoisted struct exactly as they would
on a local one. An alias: path on the parent may end on the hoisted struct
(alias: { first_pet: "pets.first" }) but not read through it — the struct
belongs to another module — and says so at generation.
An abstract field emits one struct per type condition the selection names,
plus a catch-all Other, wrapped in a module with
Type = T.type_alias { T.any(...) } and a from_h that dispatches on
__typename. Generation therefore requires __typename in such a selection,
unaliased and unconditional — the wire response carries no type tag unless you
ask, and from_h reads it on every response. One __typename inside each
... on Type does not substitute, however many of them there are: the
dispatch runs before any member's selection applies, and a member the query never
named would carry none at all.
Size follows the query, not the schema: two ... on conditions against GitHub's
Node — an interface with a few hundred implementations — emit three structs,
not a few hundred. Anything the query didn't name — a member you have no fragment
on, or one the schema grew after you generated — deserializes into Other,
carrying what the abstract type itself guarantees (an interface's selected
interface-level fields; for a union, __typename). Adding a union member
upstream is a non-breaking change, and it stays one here.
Two selections have nothing to dispatch between, so they skip the module and
become the struct directly: no conditions at all (interface-level fields
only) → one shared struct; exactly one condition and nothing else → that
type's struct, always nilable, since a non-matching runtime type comes back as
nil — so narrowing doubles as filtering. "Nothing else" is what keeps the miss
legible: a field every member answers — spelled bare, or inside a fragment on the
abstract type itself, which is the same selection — puts the field back on the
dispatch path, so the other members keep what they sent. Narrowing reads the match
off __typename when the selection carries one unaliased and unguarded, and off
"the object came back empty" when it doesn't — so an all-@skip/@include
narrowed fragment, or one whose __typename is itself guarded, is refused: a
match would be indistinguishable from a miss.
A whole abstract field selected as one named shared fragment
({ ...FeedItemFields }) is hoisted into
GraphQLTypes like any other, in whichever of the three shapes above the
fragment has — a dispatch module, one struct, or a nilable narrowed struct. So
which shape it is never decides whether consuming queries share a type, and
adding a second ... on to a shared fragment doesn't move their constant.
from_h already reads __typename off the wire and builds the right member
struct, so what you hold is a real Book or Disc, not a tag. Branch on the
class and let Sorbet do the rest:
items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc, Result::Item::Other)
case item
when Result::Item::Book then item.title # narrowed to Book — .title is available
when Result::Item::Disc then item.runtime # narrowed to Disc — .runtime is available
when Result::Item::Other then item.__typename # something this query names no fields on
else T.absurd(item)
end
endTwo things a case on the __typename string can't give you. when Book
narrows: inside the branch item is statically a Book, so its fields
typecheck and a Disc field is a compile error. And after every branch the
T.any is exhausted, so T.absurd asserts the else is unreachable — write a
fragment for another member, regenerate, and the T.absurd stops compiling until
you handle it. It is exhaustive over the members this query asked about, plus
Other — deliberately not "every type in the schema", which is what keeps a
case you wrote today compiling when upstream adds a member.
__typename is still there as a plain String, with one use the class can't
cover: two differently-selected occurrences of the same union are distinct type
families (Result::Item::Book is not Result::FeaturedItem::Book), so a case
written for one won't span the other. Select the union through a shared fragment
to hold it as one type across queries
(above); if all you have is the bare tag,
__typename is the common denominator, unchecked.
Derived values (display names, emoji, predicates) belong next to the data but not in it — rewriting wire values on the way in destroys the raw truth. Register a plain module and every struct generated from that GraphQL type includes it, whatever query it appears in:
module PetHelpers
def adult? = birthday && birthday < Date.today << 24
def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
end
GraphWeaver.extend_type("Pet", PetHelpers)
pet.display_name # => "Shelby 🦴"
pet.name # => "Shelby" — the wire value stays honestThe methods live on the struct, so they see its wire fields at runtime and
fakes/cassettes get the behavior automatically; registrations are additive
(repeated ones stack). The mixin is one of your own constants, so in Rails the
registration goes in a to_prepare block like register_enum does, and for the
same reason — getting started has the
rule and the boot order behind it. Editing the mixin in development needs a
restart, unlike a .graphql edit: a reload hands the constant a new module
object, and the include that took the old one doesn't run again.
For quick decoration, build the mixin inline — the block is module_eval'd into
a fresh module auto-named under GraphWeaver::TypeHelpers:
GraphWeaver.extend_type("Pet") do
def display_name = "#{name} 🐶"
endThe name is where the block is written and what it extends:
GraphWeaver::TypeHelpers::Pet at the top level,
GraphWeaver::TypeHelpers::Billing::Pet inside GraphWeaver.graph :billing.
Generated code spells it, so it depends on your source and nothing else — two
graphs can extend the same type name, and the name a generate bakes in is the
one a boot creates. The module is minted at registration, so no file declares
it; generation writes a type_helpers.rbi beside the modules that does, which
is what lets your srb tc resolve the include. Ruby never loads an .rbi, so
dropping the registration still fails loudly at require rather than quietly
handing the struct an empty module.
Neither form has its method bodies statically checked, for the same reason:
srb tc checks a mixin's method bodies in the module's own scope, not the
including struct's, so a helper reading a wire field (name, birthday) fails
with "method does not exist on the module" — and the block form has no source on
disk for srb tc to read at all. A named module can carry real sigs, though, by
declaring the fields it leans on: abstract! plus a
sig { abstract.returns(String) }; def name; end is how a mixin says "whatever
includes me has these", and the struct's consts satisfy them — generation
declares the override Sorbet demands there (const :name, String, override: true, and sig { override.returns(...) } on an alias: accessor), which is
the one thing you couldn't add by hand: a const has no sig to put it in.
T.unsafe(self).name also silences it, at the cost of checking nothing. Either
beats # typed: false for a helper you want checked.
An abstract! mixin has a precondition: every query must select what it
declares. The mixin goes into every struct generated from the type, so a
query selecting a subset has nothing to satisfy the rest, and generation refuses
— naming the struct, the mixin, the members, and the two ways out: select them
in that query, or select the type through one
shared fragment, which hoists a single struct
for every query to share. The second is usually the answer: a shape several
queries lean on is a fragment.
One sig a mixin cannot write is one over a generated type — sig { abstract.returns(GraphQLTypes::Species) } raises NameError at generation,
because registrations load before the enum that generation is about to write.
The field's sig has to be T.untyped there. The struct's own const still
carries the real type, so a call site reading pet.species is checked; only the
mixin's view of it isn't.
The one derivation the generator can type for you is a plain projection — a
selected field, possibly nested, exposed under a flat accessor. alias: emits a
sig'd delegator into the struct body, where the field is in scope, so it's
fully checked (the thing a mixin can't be):
GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
# generated on the Widget struct:
# sig { returns(T.nilable(String)) }
# def tag = meta&.tagForms:
alias: { tag: "meta.tag" } # explicit accessor name
alias: "meta.tag" # accessor named after the last segment (`tag`)
alias: ["meta.tag", "meta.color"] # several at once
alias: { label: "name", tag: "meta.tag" }The path is the Ruby accessor chain, so its segments are snake_case props
(name_with_owner.tag), not wire names. It may end on a
hoisted struct but not read through one. It's typed from the selection: any
nullable hop makes the accessor nilable and inserts &.; the leaf can be a
scalar, enum, or nested struct. It's validated against each query at generation —
an unselected or misspelled segment (did you mean 'tag'?), a selector on a
non-list, or a name that collides with a real field all fail with a pointed
error. Registrations stack, like the mixin forms.
A segment can also be first or last to pick one element out of a list hop —
always nilable, since the list may be empty. This is what turns an
_entities-style "array that logically holds one thing" into a clean accessor:
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
# sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
# def entity = _entities&.first # (a multi-fragment selection types it as the union)optional: true makes the aliases lenient: a query whose selection doesn't fit
the path just omits the accessor instead of failing generation. Reach for it when
the alias lives on a universal type like Query — where a strict alias would
force every query to select the path — or when it only fits some selections. It
excuses a field the query didn't select, not a segment the schema doesn't have: a
typo or a wire-cased name (findPets for find_pets) still raises, since no
selection could ever satisfy it.
One call does both halves. alias: and a block are independent parts of the
same registration — the accessor is emitted into the struct body, the block
becomes a mixin the struct includes — so a block method can call an accessor the
same call declared. Inside the block the alias is spelled alias_field, which
is the same keyword said next to the methods that use it: same paths, same
errors, same accessor. Two positions, one thing.
GraphWeaver.extend_type("Widget") do
alias_field :tag, "meta.tag" # or alias_field "meta.tag" — accessor named `tag`
def shout = tag&.upcase
endThe verb is alias_field because the block can't spell alias itself: it is a
Ruby keyword, and the block is module_eval'd Ruby, so writing it there would
define a method alias rather than a projection. A block says one alias per line
and nothing else — the compact { }/[ ] forms and optional: true stay on
the keyword, since leniency describes the registration rather than one accessor.
For anything beyond a passthrough projection — real logic, still typed — reopen
the generated struct in your own file and add sig'd methods; Sorbet merges the
bodies. Every form above, and every error it raises, is a named example in
spec/aliases_spec.rb.
A client is anything satisfying the execute contract — a
GraphWeaver::Client, a transport, a Retry, a live schema class, a fake.
A module knows which graph it belongs to, and the graph knows how to reach it:
resolution is per call (client:) → a test mode's stand-in → the client its
graph names → GraphWeaver.client;
the canonical list is in transports. There is
no setter — MyQuery.client reads back what the module would execute through,
and a parsed module, which has no graph, runs
against whatever parsed it. A graph naming its own client is no reason a module
escapes testing's graphql: tag, which is exactly the instruction
to replace it; what the example says still wins.
client lives in the gem (GraphWeaver::QueryModule, extended by every
generated module). A generated file says nothing about transport — only a private
GRAPH naming its graph, which is also how graphql: :fake fabricates each
module's own schema with two graphs in play, and the
:graph on every instrumentation event the module's
execute produces.
execute is two steps: make the request, then cast the JSON into the typed
structs. Only the second step is GraphWeaver-specific, and it's exposed on its
own — so you can fetch with any GraphQL client (Apollo, a raw Net::HTTP post, a
batching layer, a recorded fixture) and hand the result over:
raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
# => {"data" => {"person" => {...}}, "errors" => [...], "extensions" => {...}}
response = PersonQuery.from_response(raw) # GraphWeaver::Response[Result]
person = response.data!.person # typed, no network
person = PersonQuery.from_response!(raw).person # or skip the envelopeexecute is from_response(client.execute(...)), so the envelope is identical.
The one requirement: pass the response verbatim — a hash (or anything with
#to_h) with the standard GraphQL shape and wire-cased string keys
("person", "nameWithOwner"), the top-level "data" / "errors" /
"extensions" keys included. Don't symbolize or snake_case it first. Which is
checked, since symbolizing is the likeliest thing to go wrong at this seam: a
hash carrying neither "data" nor "errors" raises a GraphWeaver::CastError
naming the keys it did find, rather than handing back an envelope that reports
success with no data. nil and a bare String are refused the same way.
Queries live as .graphql files (the source of truth), generation writes the
Ruby, and verification fails when the two drift. The conventional layout
(configurable via GraphWeaver.queries_paths / generated_paths /
schema_path):
app/graphql/
schema.json # introspection dump (or schema.graphql SDL)
queries/ # *.graphql / *.gql, nested — hand-written, reviewed
fragments/ # shared fragments, spread by name from any query
generated/
types.rb # manifest: requires + forward declarations, in load order
types/ # one file per shared type
type_helpers.rbi # only with a block-form extend_type — declares its module
*_query.rb # one module per query — generated, checked in, never edited
*_mutation.rb # ...and per mutation
rake graph_weaver:generate # queries_paths -> generated_paths.first
rake graph_weaver:verify # fail if anything is stale — run in CIThe tasks self-register in Rails; elsewhere add require "graph_weaver/tasks" to
your Rakefile. Scalar/enum/type registrations are baked into generated source, so
they must run first — in Rails they do, since the tasks depend on :environment.
Or call the same APIs directly:
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
GraphWeaver.generate!(schema:) # write the modules
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a specverify_generated! costs what generate! costs, minus the writes — it
recomputes the whole plan whether nothing is stale or everything is. So it
belongs in one example per suite run, not in a before or an assertion per
example, where it reads like a cheap check and isn't.
generate! returns every file the plan produces, but rewrites only the ones whose
bytes changed; GraphWeaver.changed_files is that subset. So
rake graph_weaver:generate prints wrote for what moved and N already up to date for the rest, and a watching dev server has one module to reload instead of
all of them. The unregistered-scalar report is the rake task's puts, so off rake
read GraphWeaver.untyped_scalars for the unioned list — or set
GraphWeaver.logger, which generate! names them on at info as
it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
generating without one fails pointing at exactly that.
A type shared across query modules lives in GraphQLTypes and is aliased in.
Input types, schema enums, and the types hoisted from shared fragments are all
one kind of thing — a type that would otherwise be copied into every query that
touches it — so they live in one module, one file each, and a query module that
uses any of them opens with require_relative "types". Rename the constant
(GraphWeaver.types_module=, or generate!(types_module:)) when one app
generates against two schemas. One module is one namespace, so a shared fragment
whose name is already a schema type in that module is refused at generation,
naming both.
Generation prunes. Rename or delete a .graphql and the module it used to
produce is deleted on the next generate! — which says so, since a deletion you
didn't expect is the one worth reading; verify flags it as stale until you
regenerate. Only files carrying GraphWeaver's header
(# Generated by GraphWeaver <version> — do not edit.) are ever deleted, so
hand-written files in the output directory are safe. A run that finds no
queries says where it looked rather than exiting 0 in silence, and
verify_generated! fails outright.
A refusal writes nothing at all — not even the files that planned cleanly — so a failed run leaves the tree exactly as it was, and it reports every query it refused rather than the first.
Generation is deterministic. The same schema and queries produce
byte-identical files, on any machine, in any order — everything with a
non-obvious order (schema members, enum values, requires, hoisted names) is
sorted, and a spec asserts it both across calls and against the checked-in
fixtures. So regenerating a file you didn't change produces no diff,
verify_generated! never fails spuriously, and a generated file is worth
reviewing line by line.
Regenerate when: a query changes, the schema changes, a registration changes, or
GraphWeaver itself upgrades — any release can change what codegen emits, patch
releases included, and verify_generated! is what catches it. The rake tasks that
spot a schema change for you — schema:diff, schema:refresh, queries:check
— are in getting started; a
schema_stale? error in production is the late signal.
Three readers, three tellings — and rails g graph_weaver:install writes the
two that live in files:
- You. Every generated file opens with
# Generated by GraphWeaver <version> — do not edit. - Rubocop. The output directory goes into
AllCops: Exclude:— machine-written code isn't yours to style, and plainrubocopotherwise firesStyle/Documentationon every struct andMetrics/*on everyfrom_h. - GitHub.
linguist-generatedcollapses these files in a pull-request diff (shown as "Load diff") and drops them from the repository's language breakdown. It is display-only: they stay versioned, expandable, andgit diffhere is unchanged.
An app that didn't run the installer adds the two by hand:
# .rubocop.yml — inherit_mode keeps rubocop's own Exclude defaults
AllCops:
inherit_mode:
merge:
- Exclude
Exclude:
- "app/graphql/generated/**/*"# .gitattributes — gitignore-style, so `dir/**` is everything beneath it
app/graphql/generated/** linguist-generated
git check-attr linguist-generated app/graphql/generated/types.rb answers set
when the pattern is right. One line per graph in a multi-schema app, and
linguist's overrides
has the other attributes it reads.
In Rails, loading is automatic — one rule decides whether
Zeitwerk does it or the Railtie does. Elsewhere it's explicit, factory_bot-style:
GraphWeaver.load_generated! requires every file under generated_paths.
Outside Rails, four things have to agree, and nothing wires them together for you — a script that generates its own modules sets all four:
queries_paths— wheregenerate!reads.graphqlfiles.generated_paths— where it writes, and whereload_generated!reads. Point them at the same directory or generation is invisible.- the call above, before the first
execute— nothing else requires the files. GraphWeaver.client =— a module belonging to no declared graph has no other client to reach for.
Miss (3) and the script gets a NameError for its own module; miss (4) and it
gets no client configured — set GraphWeaver.client= or pass a client from a
module that otherwise looks fine. Set the slot to something that can't execute
and the module names the contract instead: PersonQuery: client must respond to #execute(query, variables:, operation_name:), got Hash.
Every directory setting is a list — queries_paths, generated_paths,
fragments_paths — and every entry is read (entries may be globs; the generated
default includes app/graphql/*/generated, so per-schema layouts load too).
Assigning a String wraps it, so pointing at one directory stays a one-liner.
generate! writes into the first generated_paths entry — one run, one output
directory. schema_path is the one singular setting: a run reads one schema, so a
list would name a dump nothing ever opens. A relative path resolves against
GraphWeaver.root — Rails.root in a Rails app, the working directory otherwise
— so where you started the process doesn't change which files it reads.
A generated tree Zeitwerk would name correctly is left to Zeitwerk; any other is hidden from it and required at boot.
The conventional layout is the second one: generated/person_query.rb defines
::PersonQuery where Zeitwerk reads Generated::PersonQuery off the path. So
the Railtie hides that directory from the autoloader and requires every file at
the end of boot — after your initializers and after any to_prepare
registrations of your own, and again after each development reload.
A tree is named correctly when the graph's namespace
is the constant its output path spells:
GraphWeaver.graph :reports do
output "app/graphql/queries/reports/generated"
namespace "Queries::Reports::Generated"
endreport_query.rb then defines Queries::Reports::Generated::ReportQuery, which
is exactly what Zeitwerk expects there — your own inflect/acronym rules
included, since graph_weaver asks the loader rather than guessing. Nothing is
hidden and nothing is required: the modules autoload on first reference and
reload with the rest of the app, and no part of the parent namespace chain is
defined until a query is used. Leave types_module unset for it — the shared
manifest is types.rb, which Zeitwerk reads as <Namespace>::Types, and Types
is what a namespaced graph already calls it.
The Railtie says which side each graph fell on, one line apiece at debug, naming the constant that disagreed:
graph :reports's output app/graphql/queries/reports/generated is hidden from
Zeitwerk and required at boot — types.rb defines …::GraphQLTypes, Zeitwerk
expects …::Types
The settings have two lifetimes. schema, queries, output and
namespace matter only when generate/verify runs. extend_type,
register_scalar and register_enum are needed wherever generated code loads
— which under autoloading is the first request that uses a query, long after
boot. So generation settings can live wherever generation happens, and
registrations belong in config/initializers or a to_prepare block, where
every boot runs them.
In development a query edit regenerates and reloads before the next request;
everywhere else generated code changes only on regeneration — restart, like a
schema migration, or call GraphWeaver.reload_generated! after regenerating in
another terminal.
GraphWeaver.parse generates + evals in one step (no build artifact, evaled into
an anonymous container — no global constants leak). Same runtime semantics;
invisible to srb tc, so prefer the build step where static checking matters.
GraphWeaver.run(source, query, **variables) — or client.run — is the one-shot
form: parse and execute in one call, no module kept. In development
client.load_queries! parses every query file into modules with the same names
generation would use.
A parsed module runs against whatever parsed it — client.parse(query) and
load_queries! bind the client they came from, and GraphWeaver.parse(client:)
says it outright. That is a property of parsing, not a slot you can set later:
it generates no file, so it has no graph to read a client off, and a per-call
client: still wins over it.
In an app with more than one graph, a
parsed module belongs to one of them — that is what a graphql: tag runs it
against and whose client it reaches for, the same thing generation writes into
a file. It is read off the schema
you parsed against when a graph runs that class in-process; say it outright
otherwise:
PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)Generated source is eval'd, so inputs are validated: module names must be constant names, and query heredocs can't be terminated early. Still: queries are code — don't feed untrusted strings to parse.