Skip to content

Instantly share code, notes, and snippets.

@rmosolgo
Last active March 25, 2025 09:23
Show Gist options
  • Save rmosolgo/68f1d04e2da1dcdbe2de20991c66efba to your computer and use it in GitHub Desktop.
Save rmosolgo/68f1d04e2da1dcdbe2de20991c66efba to your computer and use it in GitHub Desktop.
Implementing a new, custom connection system in GraphQL-Ruby and releasing it in a new API version
require "bundler/inline"
gemfile do
gem "diffy", "3.4.3"
gem "graphql", "2.4.11"
gem "graphql-enterprise", "1.5.6", source: "https://gems.graphql.pro"
end
# dummy data
THINGS = [{name: "Airplane"}, {name: "Bagel"}, {name: "Calendar"}, {name: "Daffodil"}]
# This schema uses a custom scalar, `Cursor`, for a parallel connection system.
# `NewConnection`, `NewEdge` and `NewPageInfo` are based on the GraphQL-Ruby built-ins,
# but they delete any `String` cursor fields and replace them with field definitions using `Cursor`.
#
# Then, new helper methods are added for producing object-type-specific Connection types
# and the old helper method is moved to `legacy_connection_type`.
#
# Finally, a field definition helper (`connection_field`) is added for defining connections
# with both legacy and new connection types, where the new connection fields are released in a changeset.
#
# Also, to use the new `Cursor` type for arguments, `BaseField` uses a custom connection extension
# which adds arguments of that type when it detects a field using the new connection system.
#
# The example schema is inspected and diffed across versions to show actual changes,
# and an example query is executed to confirm that it works.
class MySchema < GraphQL::Schema
class CustomConnectionExtension < GraphQL::Schema::Field::ConnectionExtension
def apply
if field.type.unwrap < NewConnection
field.argument :after, Cursor, "Returns the elements in the list that come after the specified cursor.", required: false
field.argument :before, Cursor, "Returns the elements in the list that come before the specified cursor.", required: false
field.argument :first, "Int", "Returns the first _n_ elements from the list.", required: false
field.argument :last, "Int", "Returns the last _n_ elements from the list.", required: false
else
super
end
end
end
class BaseField < GraphQL::Schema::Field
include GraphQL::Enterprise::Changeset::FieldIntegration
connection_extension(CustomConnectionExtension)
end
class BaseObject < GraphQL::Schema::Object
field_class(BaseField)
class << self
# `GraphQL::Schema::Member::RelayShortcuts` is the inspiration of this code
def new_connection_type
@new_connection_type ||= begin
conn_type_name = self.graphql_name + "Connection"
edge_type_class = self.new_edge_type
Class.new(NewConnection) do
graphql_name(conn_type_name)
edge_type(edge_type_class)
end
end
end
def new_edge_type
@new_edge_type ||= begin
edge_name = self.graphql_name + "Edge"
node_type_class = self
Class.new(NewEdge) do
graphql_name(edge_name)
node_type(node_type_class)
end
end
end
# Move `.connection_type` to `.legacy_connection_type`
alias_method :legacy_connection_type, :connection_type
# Move `.new_connection_type` to `.connection_type`
alias_method :connection_type, :new_connection_type
# Define two connection fields for this configuration,
# one using the legacy cursor, one using the new cursor.
# Pass `legacy: true` to get both.
def connection_field(name, obj_type, legacy: false, **kwargs)
if legacy
field name, obj_type.legacy_connection_type, **kwargs, removed_in: NewConnections
field name, obj_type.connection_type, **kwargs, added_in: NewConnections
else
field name, obj_type.connection_type, **kwargs
end
end
end
end
class Cursor < GraphQL::Types::String
description "A pagination cursor"
end
class NewPageInfo < BaseObject
include GraphQL::Types::Relay::PageInfoBehaviors
graphql_name("PageInfo")
own_fields.delete("startCursor")
own_fields.delete("endCursor")
field :start_cursor, Cursor, description: "When paginating backwards, the cursor to continue."
field :end_cursor, Cursor, description: "When paginating forwards, the cursor to continue."
end
class NewConnection < BaseObject
include GraphQL::Types::Relay::ConnectionBehaviors
own_fields.delete("pageInfo")
field :page_info, NewPageInfo, null: false, description: "Information to aid in pagination."
end
class NewEdge < BaseObject
include GraphQL::Types::Relay::EdgeBehaviors
own_fields.delete("cursor")
field(:cursor, Cursor, null: false, description: "A cursor for use in pagination.")
end
class NewConnections < GraphQL::Enterprise::Changeset
release "2025-03-01"
end
class Thing < BaseObject
field :name, String
end
class Query < BaseObject
# You could do this:
# field :things, Thing.legacy_connection_type, fallback_value: THINGS, removed_in: NewConnections
# field :things, Thing.connection_type, fallback_value: THINGS, added_in: NewConnections
# Or you could make a helper method:
connection_field(:things, Thing, fallback_value: THINGS, legacy: true)
end
query(Query)
use GraphQL::Schema::Visibility
use GraphQL::Enterprise::Changeset::Release, changesets: [NewConnections]
end
new_schema = MySchema.to_definition(context: { changeset_version: "2025-03-01"})
old_schema = MySchema.to_definition
# The differences between old_schema and new_schema are:
# - new_schema includes `scalar Cursor`
# - new_schema's cursor-related fields use `Cursor` instead of `String`
puts Diffy::Diff.new(old_schema, new_schema)
# """
# +A pagination cursor
# +"""
# +scalar Cursor
# +
# +"""
# Information about pagination in a connection.
# """
# type PageInfo {
# """
# When paginating forwards, the cursor to continue.
# """
# - endCursor: String
# + endCursor: Cursor
# """
# When paginating forwards, are there more items?
# """
# hasNextPage: Boolean!
# """
# When paginating backwards, are there more items?
# """
# hasPreviousPage: Boolean!
# """
# When paginating backwards, the cursor to continue.
# """
# - startCursor: String
# + startCursor: Cursor
# }
# type Query {
# things(
# """
# Returns the elements in the list that come after the specified cursor.
# """
# - after: String
# + after: Cursor
# """
# Returns the elements in the list that come before the specified cursor.
# """
# - before: String
# + before: Cursor
# """
# Returns the first _n_ elements from the list.
# """
# first: Int
# """
# Returns the last _n_ elements from the list.
# """
# last: Int
# ): ThingConnection
# }
# type Thing {
# name: String
# }
# """
# The connection type for Thing.
# """
# type ThingConnection {
# """
# A list of edges.
# """
# edges: [ThingEdge]
# """
# A list of nodes.
# """
# nodes: [Thing]
# """
# Information to aid in pagination.
# """
# pageInfo: PageInfo!
# }
# """
# An edge in a connection.
# """
# type ThingEdge {
# """
# A cursor for use in pagination.
# """
# - cursor: String!
# + cursor: Cursor!
# """
# The item at the end of the edge.
# """
# node: Thing
# }
query_str = "{ things { edges { cursor node { name } } pageInfo { startCursor endCursor } } }"
old_response = MySchema.execute(query_str).to_h
new_response = MySchema.execute(query_str, context: { changeset_version: "2025-03-01"}).to_h
pp old_response
# {"data" =>
# {"things" =>
# {"edges" =>
# [{"cursor" => "MQ", "node" => {"name" => "Airplane"}},
# {"cursor" => "Mg", "node" => {"name" => "Bagel"}},
# {"cursor" => "Mw", "node" => {"name" => "Calendar"}},
# {"cursor" => "NA", "node" => {"name" => "Daffodil"}}],
# "pageInfo" => {"startCursor" => "MQ", "endCursor" => "NA"}}}}
pp old_response == new_response
# true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment