Generated modules
GraphWeaver::Codegen turns one GraphQL operation into one # typed: strict
Ruby module. Everything srb tc knows about your query results comes from this
file — there is no runtime schema, no lazy wrapper, no reflection.
This is the production path — checked in, reviewed, statically checked
(assembled step by step in the getting started, including
what Sorbet does and doesn't require).
For consoles and dev there's dynamic mode; for one-off
scripts, client.execute! skips modules entirely.
Generating
The workflow that keeps generated code honest: 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_path / generated_path / schema_path):
app/graphql/
schema.json # introspection dump (or schema.graphql SDL)
queries/ # *.graphql — hand-written, reviewed
generated/
inputs.rb # manifest: requires + forward declarations
inputs/ # one file per shared type (input structs, enums)
*_query.rb # one module per query — generated, checked in, never edited
The schema dump is step 0 — codegen reads it, never a live endpoint.
cache: true on a url client writes it on first introspection
(GraphWeaver.new(url, cache: true).schema in a console bootstraps it);
generating without one fails pointing at exactly that.
Rake tasks (self-registering in Rails; elsewhere add
require "graph_weaver/tasks" to your Rakefile):
rake graph_weaver:generate # queries_path -> generated_path
rake graph_weaver:verify # fail if anything is stale — run in CI
Scalar/enum/type registrations are baked into generated source, so they
must run before the tasks do. In Rails they will — the tasks depend on
:environment, which runs your initializers. Outside Rails, require the
file that does your registrations from the Rakefile yourself.
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 spec
In Rails, loading is automatic — the Railtie requires every file under
generated_path at boot, after your initializers (so registrations run
first). Elsewhere it's explicit, factory_bot-style:
GraphWeaver.load_generated! # require every file under generated_path
The conventional paths are lists (entries may be globs — the default
includes app/graphql/*/generated, so per-schema layouts load too).
Append extra locations (a test-only schema, an engine's queries) and
every loader walks them all:
# e.g. in spec/support/graph_weaver.rb
GraphWeaver.generated_paths << "spec/support/graphql/generated"
GraphWeaver.queries_paths << "spec/support/graphql/queries"
The singular accessors (generated_path etc.) read and replace the
first entry — the default target for generate! and the rake tasks.
(Plain requires, not Zeitwerk: Zeitwerk would expect
Generated::PersonQuery from generated/person_query.rb, and generated
code only changes on regeneration — restart, like a schema migration.)
Regenerate when: a query changes, the schema changes (a
schema_stale? error in production is the late signal — refresh
the schema dump and regenerate), a scalar registration changes, or GraphWeaver
itself upgrades (emission may differ across versions; verify_generated!
catches it).
Introspected dumps record their source url, so drift is checkable ahead
of the late signal: rake graph_weaver:schema:verify re-introspects the
recorded url and fails when the server has moved;
rake graph_weaver:schema:refresh rewrites the dump
(GRAPHWEAVER_AUTH supplies a token for private APIs). Don't confuse
the two verifies: graph_weaver:verify asks "is the generated code
fresh?" — local, every CI run; graph_weaver:schema:verify asks "has the
server drifted from the dump?" — network, needs the recorded url, run
on a schedule.
In development, skip the build entirely — client.load_queries! parses
every query file into modules with the same names generation would use
(see dynamic mode).
Anatomy
module PersonQuery
QUERY = "..." # the operation, verbatim
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
def self.client ... # default client (see below)
def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
def self.execute!(client = nil, id:) # -> Result, or raises QueryError
end
executereturns 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 raisedGraphWeaver::QueryError.
Variables become typed kwargs
mutation($name: String!, $species: Species!, $note: String) { ... }
AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
- required vs optional falls out of nullability and defaults: nullable or defaulted variables become optional kwargs (nil is omitted from the wire, so server-side defaults apply)
- enum variables generate module-level
T::Enums and accept the enum or its wire value (species: Species::Dogorspecies: "DOG") - custom scalars serialize through the scalar registry
Input objects: when an operation's only variable is a required input
object (the Relay convention), the input's fields flatten straight into
execute's kwargs — no wrapper at the call site:
mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
AdoptQuery.execute!(name: "Rex", species: "DOG", nickname: "Rexy")
The wrapping level is rebuilt on the wire, and each field type-checks
exactly like a variable would. Operations with more than one variable (or
a nullable input) keep the variable-per-kwarg surface — there the input
kwarg accepts the generated T::Struct or a plain hash (.coerce
normalizes underscored Symbol/String keys; enums accept wire values;
nested inputs accept hashes; unknown keys raise with a spellchecked
hint rather than silently dropping):
AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
In the generate! workflow, input types (and the enums they use) are
emitted once per schema — one file per type under
generated/inputs/, with inputs.rb as the manifest. The module is
named from the output path: the conventional layout gets
GraphQLInputs, while a multi-schema layout names each schema's module
after its directory (app/graphql/github/generated → GithubInputs).
Override globally with GraphWeaver.inputs_module= or per run with
generate!(inputs_module:); opt out with
generate!(shared_inputs: false). Per-type files keep schema drift
reviewable: a migration diffs exactly the types it touched, and types
the schema drops are pruned on regeneration (verify flags strays).
Query modules alias what they touch,
so AdoptQuery::AdoptionInput still works and shared types keep one
identity across modules — three filtered Hasura queries cost one ~11k-line
inputs file plus ~90 lines each, instead of ~35k lines of duplicates.
Deeply nested types live unaliased in the shared module
(GraphQLInputs::PetFilter). Dynamic parse stays self-contained.
The structs themselves are module-level (AdoptQuery::AdoptionInput):
typed consts plus a compact per-field FIELDS table that the
GraphWeaver::InputStruct runtime drives — serialize (aliased to_h)
produces the wire hash with nil optionals omitted, coerce builds from
plain hashes. The conversions ship in the generated file as data
(lambdas in the table), so a Hasura bool_exp pulling hundreds of input
types stays ~2 lines per field instead of unrolled methods. Nested inputs work (dependencies emit
first), including recursive ones — Hasura's self-referential bool_exp
filters generate cleanly (_and:/_not: fields typed as the struct
itself), so variable-driven filtering works:
where = mod::PokemonBoolExp.coerce(
_and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
)
mod.execute!(where:)
Selections
- Fragments — inline fragments and named spreads flatten into the selection; type conditions match exact names or interfaces/unions the type belongs to.
- Unions and interfaces — when the selection varies by concrete
type, each abstract site emits a module: one member struct per
possible type,
Type = T.type_alias { T.any(...) }, and afrom_hdispatching on__typename— which generation therefore requires in the selection (the wire response carries no type tag unless you ask). Two narrower shapes skip the dispatch (and the__typename) entirely: interface-level fields only → one shared struct; a single... on Xcondition and nothing else →X's struct, always nilable — a non-matching runtime type comes back asnil, so narrowing doubles as filtering. @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.
Naming
Module names derive from the operation name (query GetPerson → GetPerson);
GraphWeaver.parse on a .graphql file derives from the file name
(person.graphql → PersonQuery). Pass module_name:/name: to override.
Codegen.generate requires a deliberate name for anonymous operations; dynamic
parse defaults to Query (its constants are container-scoped, so collisions
are impossible).
Nested struct names come from GraphQL type names, disambiguated one level by field name on collision.
Clients
A client is anything with execute(query, variables:) whose result to_hs
into {"data" => ..., "errors" => ...}. Resolution: per call → per module →
baked constant → GraphWeaver.client — the
canonical list lives in transports.
Generate without a baked constant when you want modules to follow the
app default (GraphWeaver.client = in an initializer) — that's also what
lets testing's auto_fake swap in a fake per example.
Dynamic mode
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.execute(schema:, query:, variables: {}) is
the one-shot form.
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.