Last active
March 25, 2025 09:23
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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