GraphSQL
GraphSQL maps GraphQL type fields to ActiveRecord columns and associations, then applies a GraphQL lookahead to an ActiveRecord relation. It keeps query types declarative while avoiding unnecessary SQL columns and common N+1 reads.
Installation
Published on rubygems.org as graphsql
(the Ruby module is GraphSQL, capitalized — only the package name is
lowercase, matching the graphql/GraphQL and activerecord/ActiveRecord
convention). Add it to your Gemfile:
gem "graphsql"
bundle install
Or without Bundler:
gem install graphsql
Usage
class Types::PersonaType < Types::BaseObject
include GraphSQL::Mapping
graphsql_model Persona
graphsql_column :handle
field :handle, String, null: false
end
class Types::GameType < Types::BaseObject
include GraphSQL::Mapping
graphsql_model Game
graphsql_column :slug
graphsql_column :name
graphsql_association :owner, to: Types::PersonaType
field :slug, String, null: false
field :name, String, null: false
field :owner, Types::PersonaType, null: false
end
class Types::QueryType < Types::BaseObject
field :games, [Types::GameType], null: false, extras: [:lookahead]
def games(lookahead:)
GraphSQL.resolve(Game.all, lookahead: lookahead, type: Types::GameType)
end
end
GraphSQL.resolve always selects the model primary key, adds requested mapped
columns, retains foreign keys required by requested belongs_to associations,
and preloads requested mapped associations. For STI models it also retains the
inheritance-discriminator column (type by default), so rows still
instantiate as the correct subclass. Unmapped GraphQL fields remain the
responsibility of their normal resolvers.
Mappings may expose a database attribute or association under another GraphQL field name:
graphsql_column :display_name, as: :name
graphsql_association :organization_memberships, as: :memberships
graphsql_column/required_columns: must name a real column on the mapped
model — not a Ruby method, not a virtual attribute with no DB backing.
GraphSQL.resolve raises GraphSQL::UnknownColumnError naming the type,
field, and column the moment it's requested, rather than letting a bad name
reach Arel and fail later as an opaque ActiveRecord::StatementInvalid.
Nested column-limiting (to:)
When an association's to: target type is itself GraphSQL-mapped, column
selection recurses: only the fields actually requested on the nested object
get selected on its preload query too, at any depth (owner { organization { name } } limits columns on both owner and its own organization).
to: accepts a -> { ... } proc instead of a bare class for mutually
associated types that would otherwise have a load-order problem (a Game
type pointing at an Organization type that itself points back at Game):
graphsql_association :games, to: -> { Types::GameType }
Without a resolvable to: (omitted, a class that turns out not to match the
association's actual target, a polymorphic belongs_to, or a :through
association), that one association just falls back to a plain full-row
preload — the optimization is best-effort per-association; a query mixing a
nestable and a non-nestable association preloads both correctly, just with
only the nestable one getting the narrower SELECT.
Mapping the same underlying association under two different, both-nestable
GraphQL fields on one type (e.g. owner/employer both -> belongs_to :organization) and requesting both in one query raises
GraphSQL::AliasedAssociationError rather than silently applying only one
alias's column-limited scope to both — the two aliases share a single
association proxy on the record, so there's no way to preload them
independently. Only one of the aliased fields, not both, can be requested in
the same query.
Pagination / .count
GraphSQL.resolve always applies a .select() covering every mapped column.
Rails' default .count column inference joins every select_value into
COUNT(col1, col2, ...), invalid SQL outside Postgres — so a bare
.count on a relation with more than one selected column would normally
raise ActiveRecord::StatementInvalid. A relation returned by
GraphSQL.resolve avoids this: a bare .count (no column, no block) counts
by the primary key instead, since it's always among the selected columns and
always safe to count. .count(:some_column) and .count { |r| ... } still
pass straight through unchanged.
relation.count # counts by primary key, safe by default
relation.count(:id) # explicit column still works as normal
This only applies to the ActiveRecord::Relation GraphSQL returns directly.
GraphSQL.resolve returns a plain Array instead whenever the query also
selects a nested GraphSQL-mapped association (see "Nested column-limiting"
above) — Array#count has no SQL to generate and is unaffected either way.
Pagination (.limit/.offset, geared_pagination, Kaminari, etc.) still
belongs on the pre-resolve relation, not chained onto GraphSQL.resolve's
return value — GraphSQL.resolve is meant to be the terminal step right
before returning the field's value.
Generator
In a Rails app, rails generate graphsql:type ModelName introspects an
already-migrated model's real columns and associations and scaffolds a
matching type — this reads the live schema via ActiveRecord, it doesn't
guess from a migration file, so create the model (and run its migration)
first:
rails generate graphsql:type Persona
This writes app/graphql/types/persona_type.rb with a graphsql_column +
field pair for every real column (skipping the primary key, the STI
discriminator column if any, and belongs_to foreign key columns — those
are exposed through the association's own field instead), and a
graphsql_association + field pair for every association, guessing
to: Types::<TargetClass>Type for every non-polymorphic one. Collection
(has_many) associations also get a lookahead:-driven resolver method;
belongs_to/has_one don't need one — they're preloaded by whatever calls
GraphSQL.resolve on the containing type, and graphql-ruby just reads
object.<association> by convention.
It also wires a matching collection field + resolver into your query type
(app/graphql/types/query_type.rb by default), via Thor's
inject_into_class — it lands right after the class's opening line, not
necessarily grouped with existing field calls, since that's as precise as
text injection gets. Review the diff.
Options:
rails generate graphsql:type Persona --type-namespace=GraphQL::Types # default: Types
rails generate graphsql:type Persona --base-class=Types::BaseNode # default: "<type_namespace>::BaseObject"
rails generate graphsql:type Persona --query-type-path=app/graphql/types/root_query.rb
rails generate graphsql:type Persona --query-type-class-name=RootQuery # default: QueryType
rails generate graphsql:type Persona --skip-query # only generate the type, don't touch the query type
A polymorphic association gets a graphsql_association line with no to:
(matching the gem's own documented fallback) but no field/resolver —
there's no single GraphQL type to point at without a Union/Interface, add
that by hand. Everything else the generator can't confidently guess is
meant to be edited afterward, not configured away — it's a starting point,
not a promise of a finished type.
Development
bundle install
bundle exec rake test