GraphSQL

Gem Version

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