├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── graphql-schema ├── schema_comparator └── setup ├── gemfiles ├── graphql-head.gemfile ├── graphql1.10.gemfile └── graphql1.13.gemfile ├── graphql-schema_comparator.gemspec ├── lib └── graphql │ ├── schema_comparator.rb │ └── schema_comparator │ ├── changes.rb │ ├── changes │ ├── criticality.rb │ └── safe_type_change.rb │ ├── diff │ ├── argument.rb │ ├── directive.rb │ ├── directive_argument.rb │ ├── enum.rb │ ├── field.rb │ ├── input_field.rb │ ├── input_object.rb │ ├── interface.rb │ ├── object_type.rb │ ├── schema.rb │ └── union.rb │ ├── enum_usage.rb │ ├── result.rb │ └── version.rb ├── schema.graphql └── test ├── lib └── graphql │ ├── schema_comparator │ ├── changes │ │ ├── criticality_test.rb │ │ ├── directives_unchanged_test.rb │ │ ├── enum_value_added_test.rb │ │ ├── enum_value_removed_test.rb │ │ ├── field_argument_added_test.rb │ │ ├── field_argument_default_changed_test.rb │ │ ├── field_argument_removed_test.rb │ │ └── input_field_added_test.rb │ ├── diff │ │ ├── argument_test.rb │ │ ├── directive_test.rb │ │ ├── enum_test.rb │ │ ├── field_test.rb │ │ ├── input_field_test.rb │ │ └── schema_test.rb │ ├── result_test.rb │ └── version_test.rb │ └── schema_comparator_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: [2.4, 2.7, '3.0'] 14 | gemfile: [graphql1.10, graphql1.13, graphql-head] 15 | env: 16 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | bundler-cache: true 22 | ruby-version: ${{ matrix.ruby }} 23 | - run: bundle exec rake 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | .ruby-version 3 | /.yardoc 4 | /Gemfile.lock 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.1 (October 27 2023) 4 | 5 | ### Bug Fix 6 | 7 | - Add support for path on `DirectiveArgumentAdded` (#57) 8 | 9 | ## 1.2.0 (October 18 2023) 10 | 11 | ### Bug Fix 12 | 13 | - More selective detection of breaking/dangerous enum value changes (#54) 14 | - Improve schema root operation type changes (#55) 15 | 16 | ## 1.1.2 (September 15 2022) 17 | 18 | ### Bug Fix 19 | 20 | - Fix field argument removed message (#51) 21 | - Fix safe type change comparison (#53) 22 | 23 | ## 1.1.1 (January 7 2022) 24 | 25 | ### Bug Fix 26 | 27 | - Fix directive type change false positive bug (#47) 28 | 29 | ## 1.1.0 (December 20 2021) 30 | 31 | ### Bug Fix 32 | 33 | - Adding non-null arguments with a default value should be non-breaking (#38) 34 | 35 | ### New Features 36 | 37 | - Remove legacy `graphql` gem API usage and support versions >= 1.13 (#46) 38 | 39 | ## 1.0.1 (May 26 2021) 40 | 41 | ### Bug Fix 42 | 43 | - Fix comparing directives (#36) 44 | 45 | ## 1.0.0 (January 23 2020) 46 | 47 | ### Breaking Changes 48 | 49 | - Add support for graphql-ruby 1.10.0+ (#28) 50 | - Starting from 1.0.0 a minimum of graphql-ruby 1.10 is required 51 | 52 | ## 0.6.1 (May 21rst 2019) 53 | 54 | - Added a bunch of reasons to breaking changes (#17) 55 | - Relaxed Thor Dependency 56 | - Add `verify` task for CI usage which returns exit codes depending on breaking changes (#24) 57 | 58 | ## 0.6.0 (Feb 28th 2018) 59 | 60 | ### New Features 61 | 62 | - Add `#path` which returns a dot-delimited path to the affected schema member. (#15) 63 | 64 | ## 0.5.1 (Feb 15th 2018) 65 | 66 | ### Bug Fix 67 | 68 | - Return a better message when adding a default value, if this one was nil before. 69 | 70 | ## 0.5.0 (Dec 2 2017) 71 | 72 | ### New Features 73 | 74 | - `AbstractChange#criticality` now returns a criticality object which 75 | has a level (non_breaking, dangerous, breaking) and a reason 76 | 77 | - Schema::ComparatorResult maintains a list of `#dangerous_changes` 78 | 79 | - New Methods: Change.non_breaking? Change.dangerous? 80 | 81 | - New CLI `schema_comparator` which includes `dangerous_changes` 82 | 83 | ### Breaking Changes 84 | 85 | - Some changes have been recategorized as dangerous 86 | - Some type changes now return breaking or non-breaking depending on the type kind 87 | 88 | ## 0.4.0 (Nov 27 2017) 89 | 90 | ### Breaking Changes 91 | 92 | - Argument and InputValue type changes are considered non 93 | breaking if type goes from Null => Non-Null 94 | 95 | ## 0.3.2 (Nov 14 2017) 96 | 97 | ### New Features 98 | 99 | Added changes: 100 | 101 | - `EnumValueDeprecated` 102 | - `EnumValueDescriptionChanged` 103 | 104 | ### Bug fixes 105 | 106 | - Fix issue in Enum differ (https://github.com/xuorig/graphql-schema_comparator/issues/9) 107 | 108 | ## 0.3.1 (Nov 13 2017) 109 | 110 | ### Bug Fixes 111 | 112 | - Fix no method breaking issue https://github.com/xuorig/graphql-schema_comparator/issues/8 113 | 114 | ## 0.3.0 (Oct 14 2017) 115 | 116 | ### New features 117 | 118 | - Top level Directive definitions are now diffed, but not directives used on definitions (Coming soon) 119 | - Base class for changes added. 120 | 121 | ### breaking changes 122 | 123 | - `breaking` method on change objects has been renamed `breaking?` for style 124 | 125 | ## 0.2.0 (Aug 18 2017) 126 | 127 | ### New features 128 | 129 | - Add `#non_breaking_changes` to get a list of non breaking changes from a comparison result. (#4) 130 | - CLI now Prints results sorted and grouped by breaking / non-breaking (#3) 131 | 132 | ### Bug fixes 133 | 134 | - Fix message for `EnumValueRemoved` (#5) 135 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mgiroux0@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in graphql-schema_comparator.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Marc-Andre Giroux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL::SchemaComparator 2 | 3 | ![Build status](https://github.com/xuorig/graphql-schema_comparator/actions/workflows/ci.yml/badge.svg) 4 | 5 | `GraphQL::SchemaComparator` is a GraphQL Schema comparator. What does that mean? `GraphQL::SchemaComparator` takes 6 | two GraphQL schemas and outputs a list of changes between versions. This is useful for many things: 7 | 8 | - Breaking Change detection 9 | - Applying custom rules to schema changes 10 | - Building automated tools like linters 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'graphql-schema_comparator' 18 | ``` 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install graphql-schema_comparator 27 | 28 | ## CLI 29 | 30 | `GraphQL::SchemaComparator` comes with a handy CLI to help compare two schemas using 31 | the command line. 32 | 33 | After a `gem install graphql-schema_comparator`, use the CLI this way: 34 | 35 | ``` 36 | Commands: 37 | schema_comparator compare OLD_SCHEMA NEW_SCHEMA # Compares OLD_SCHEMA with NEW_SCHEMA and returns a list of changes 38 | schema_comparator help [COMMAND] # Describe available commands or one specific command 39 | ``` 40 | 41 | Where OLD_SCHEMA and NEW_SCHEMA can be a string containing a schema IDL or a filename where that IDL is located. 42 | 43 | ### Example 44 | 45 | ``` 46 | $ ./bin/schema_comparator compare "type Query { a: A } type A { a: String } enum B { A_VALUE }" "type Query { a: A } type A { b: String } enum B { A_VALUE ANOTHER_VALUE }" 47 | ⏳ Checking for changes... 48 | 🎉 Done! Result: 49 | 50 | Detected the following changes between schemas: 51 | 52 | 🛑 Field `a` was removed from object type `A` 53 | ⚠️ Enum value `ANOTHER_VALUE` was added to enum `B` 54 | ✅ Field `b` was added to object type `A` 55 | ``` 56 | 57 | ## Usage 58 | 59 | `GraphQL::SchemaComparator`, provides a simple api for Ruby applications to use. 60 | 61 | ## Docs 62 | 63 | http://www.rubydoc.info/github/xuorig/graphql-schema_comparator/master/GraphQL/SchemaComparator 64 | 65 | ### GraphQL::SchemaComparator.compare 66 | 67 | The compare method takes two arguments, `old_schema` and `new_schema`, the two schemas to compare. 68 | 69 | You may provide schema IDL as strings, or provide instances of `GraphQL::Schema`. 70 | 71 | The result of `compare` returns a `SchemaComparator::Result` object, from which you can 72 | access information on the changes between the two schemas. 73 | 74 | - `result.breaking?` returns true if any breaking changes were found between the two schemas 75 | - `result.identical?` returns true if the two schemas were identical 76 | - `result.breaking_changes` returns the list of breaking changes found between schemas. 77 | - `result.non_breaking_changes` returns the list of non-breaking changes found between schemas. 78 | - `result.dangerous_changes` returns the list of dangerous changes found between schemas. 79 | - `result.changes` returns the full list of change objects. 80 | 81 | ### Change Objects 82 | 83 | `GraphQL::SchemaComparator` returns a list of change objects. These change objects 84 | all inherit from `Changes::AbstractChange` 85 | 86 | Possible changes are all found in [changes.rb](lib/graphql/schema_comparator/changes.rb). 87 | 88 | ### Change Criticality 89 | 90 | Each change object has a `#criticality` method which returns a `Changes::Criticality` object. 91 | This objects defines how dangerous a change is to a schema. 92 | 93 | The different levels of criticality (non_breaking, dangerous, breaking) are explained here: 94 | https://github.com/xuorig/graphql-schema_comparator/blob/master/lib/graphql/schema_comparator/changes/criticality.rb#L6-L19 95 | 96 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 97 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'lib' 8 | t.libs << 'test' 9 | t.test_files = FileList[ 10 | 'test/lib/graphql/schema_comparator/*/*_test.rb', 11 | 'test/lib/graphql/schema_comparator/*_test.rb', 12 | 'test/lib/graphql/*_test.rb' 13 | ] 14 | t.verbose = true 15 | end 16 | 17 | task :default => :test 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql/schema_comparator" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/graphql-schema: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "thor" 5 | require "graphql/schema_comparator" 6 | 7 | class GraphQLSchema < Thor 8 | desc "compare OLD_SCHEMA NEW_SCHEMA", "Compares OLD_SCHEMA with NEW_SCHEMA and returns a list of changes" 9 | 10 | def compare(old_schema, new_schema) 11 | say "[Warning] graphql-schema is deprecated. Please use `schema_comparator` instead", :yellow 12 | 13 | parsed_old = parse_schema(old_schema) 14 | parsed_new = parse_schema(new_schema) 15 | 16 | say "🤖 Checking for changes..." 17 | result = GraphQL::SchemaComparator.compare(parsed_old, parsed_new) 18 | 19 | say "🎉 Done! Result:" 20 | say "\n" 21 | 22 | if result.identical? 23 | say "✅ Schemas are identical" 24 | else 25 | print_changes(result) 26 | end 27 | end 28 | 29 | private 30 | 31 | def print_changes(result) 32 | say "Detected the following changes between schemas:" 33 | say "\n" 34 | 35 | result.changes.each do |change| 36 | if change.breaking? 37 | say "⚠️ #{change.message}", :yellow 38 | else 39 | say "✅ #{change.message}", :green 40 | end 41 | end 42 | end 43 | 44 | def parse_schema(schema) 45 | if File.file?(schema) 46 | File.read(schema) 47 | elsif schema.is_a?(String) 48 | schema 49 | else 50 | raise ArgumentError, "Invalid argument #{schema}. Must be an IDL string or file containing the schema IDL." 51 | end 52 | end 53 | end 54 | 55 | GraphQLSchema.start(ARGV) 56 | -------------------------------------------------------------------------------- /bin/schema_comparator: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "thor" 5 | require "graphql/schema_comparator" 6 | 7 | class GraphQLSchema < Thor 8 | desc "verify OLD_SCHEMA NEW_SCHEMA", "Behaves exactly like `compare` but returns an exit code for CI purposes" 9 | 10 | def verify(old_schema, new_schema) 11 | result = run_compare(old_schema, new_schema) 12 | exit_code = result.breaking? ? 1 : 0 13 | exit(exit_code) 14 | end 15 | 16 | desc "compare OLD_SCHEMA NEW_SCHEMA", "Compares OLD_SCHEMA with NEW_SCHEMA and returns a list of changes" 17 | 18 | def compare(old_schema, new_schema) 19 | run_compare(old_schema, new_schema) 20 | end 21 | 22 | private 23 | 24 | def run_compare(old_schema, new_schema) 25 | parsed_old = parse_schema(old_schema) 26 | parsed_new = parse_schema(new_schema) 27 | 28 | say "⏳ Checking for changes..." 29 | result = GraphQL::SchemaComparator.compare(parsed_old, parsed_new) 30 | 31 | say "🎉 Done! Result:" 32 | say "\n" 33 | 34 | if result.identical? 35 | say "✅ Schemas are identical" 36 | else 37 | print_changes(result) 38 | end 39 | result 40 | end 41 | 42 | def print_changes(result) 43 | say "Detected the following changes between schemas:" 44 | say "\n" 45 | 46 | result.changes.each do |change| 47 | if change.breaking? 48 | say "🛑 #{change.message}", :red 49 | elsif change.dangerous? 50 | say "⚠️ #{change.message}", :yellow 51 | else 52 | say "✅ #{change.message}", :green 53 | end 54 | end 55 | end 56 | 57 | def parse_schema(schema) 58 | if File.file?(schema) 59 | File.read(schema) 60 | elsif schema.is_a?(String) 61 | schema 62 | else 63 | raise ArgumentError, "Invalid argument #{schema}. Must be an IDL string or file containing the schema IDL." 64 | end 65 | end 66 | end 67 | 68 | GraphQLSchema.start(ARGV) 69 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /gemfiles/graphql-head.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "graphql", "~> 1.13", "< 3.0" 4 | 5 | gemspec path: "../" 6 | -------------------------------------------------------------------------------- /gemfiles/graphql1.10.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "graphql", "~> 1.10.0" 4 | 5 | gemspec path: "../" 6 | -------------------------------------------------------------------------------- /gemfiles/graphql1.13.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "graphql", "~> 1.13.0" 4 | 5 | gemspec path: "../" 6 | -------------------------------------------------------------------------------- /graphql-schema_comparator.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'graphql/schema_comparator/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "graphql-schema_comparator" 8 | spec.version = GraphQL::SchemaComparator::VERSION 9 | spec.authors = ["Marc-Andre Giroux"] 10 | spec.email = ["mgiroux0@gmail.com"] 11 | spec.summary = %q{Compare GraphQL schemas and get the changes that happened.} 12 | spec.description = %q{GraphQL::SchemaComparator compares two GraphQL schemas given their SDL and returns a list of changes.} 13 | spec.homepage = "https://github.com/xuorig/graphql-schema_comparator" 14 | spec.license = "MIT" 15 | spec.metadata = { 16 | "homepage_uri" => "https://github.com/xuorig/graphql-schema_comparator", 17 | "changelog_uri" => "https://github.com/xuorig/graphql-schema_comparator/blob/master/CHANGELOG.md", 18 | "source_code_uri" => "https://github.com/xuorig/graphql-schema_comparator", 19 | "bug_tracker_uri" => "https://github.com/xuorig/graphql-schema_comparator/issues", 20 | } 21 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 22 | f.match(%r{^(test|spec|features)/}) 23 | end 24 | 25 | spec.bindir = "bin" 26 | spec.executables = ["graphql-schema", "schema_comparator"] 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_dependency "graphql", ">= 1.10", "< 3.0" 30 | spec.add_dependency "thor", ">= 0.19", "< 2.0" 31 | spec.add_dependency "bundler", ">= 1.14" 32 | 33 | spec.add_development_dependency "rake", "~> 10.0" 34 | spec.add_development_dependency "minitest", "~> 5.10" 35 | spec.add_development_dependency "pry-byebug", "~> 3.4" 36 | end 37 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator.rb: -------------------------------------------------------------------------------- 1 | require "graphql" 2 | 3 | require "graphql/schema_comparator/version" 4 | require "graphql/schema_comparator/result" 5 | 6 | require 'graphql/schema_comparator/changes' 7 | require 'graphql/schema_comparator/enum_usage' 8 | 9 | require "graphql/schema_comparator/diff/schema" 10 | require "graphql/schema_comparator/diff/argument" 11 | require "graphql/schema_comparator/diff/directive" 12 | require "graphql/schema_comparator/diff/directive_argument" 13 | require "graphql/schema_comparator/diff/enum" 14 | require "graphql/schema_comparator/diff/field" 15 | require "graphql/schema_comparator/diff/input_object" 16 | require "graphql/schema_comparator/diff/input_field" 17 | require "graphql/schema_comparator/diff/object_type" 18 | require "graphql/schema_comparator/diff/interface" 19 | require "graphql/schema_comparator/diff/union" 20 | 21 | module GraphQL 22 | module SchemaComparator 23 | # Compares and returns changes for two versions of a schema 24 | # 25 | # @param old_schema [GraphQL::Schema, String] 26 | # @param new_schema [GraphQL::Schema, String] 27 | # @return [GraphQL::SchemaComparator::Result] the result of the comparison 28 | def self.compare(old_schema, new_schema) 29 | parsed_old = parse_schema(old_schema) 30 | parsed_new = parse_schema(new_schema) 31 | 32 | changes = Diff::Schema.new(parsed_old, parsed_new).diff 33 | Result.new(changes) 34 | end 35 | 36 | private 37 | 38 | def self.parse_schema(schema) 39 | if schema.respond_to?(:ancestors) && schema.ancestors.include?(GraphQL::Schema) 40 | schema 41 | elsif schema.is_a?(String) 42 | GraphQL::Schema.from_definition(schema) 43 | else 44 | raise ArgumentError, "Invalid Schema #{schema}. Expected a valid IDL or GraphQL::Schema object." 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/changes.rb: -------------------------------------------------------------------------------- 1 | require 'graphql/schema_comparator/changes/criticality' 2 | require 'graphql/schema_comparator/changes/safe_type_change' 3 | 4 | module GraphQL 5 | module SchemaComparator 6 | module Changes 7 | # Base class for change objects 8 | class AbstractChange 9 | # A message describing the change that happened between the two version 10 | # @return [String] The change message 11 | def message 12 | raise NotImplementedError 13 | end 14 | 15 | # @return [Boolean] If the change is breaking or not 16 | def breaking? 17 | criticality.breaking? 18 | end 19 | 20 | # @return [Boolean] If the change is dangerous or not 21 | def dangerous? 22 | criticality.dangerous? 23 | end 24 | 25 | # @return [Boolean] If the change is non breaking 26 | def non_breaking? 27 | criticality.non_breaking? 28 | end 29 | 30 | # @return [GraphQL::SchemaComparator::Changes::Criticality] The criticality of this change 31 | def criticality 32 | raise NotImplementedError 33 | end 34 | 35 | # @return [String] Dot-delimited path to the affected schema member. 36 | def path 37 | raise NotImplementedError 38 | end 39 | end 40 | 41 | # Mostly breaking changes 42 | 43 | class TypeRemoved < AbstractChange 44 | attr_reader :removed_type, :criticality 45 | 46 | def initialize(removed_type) 47 | @removed_type = removed_type 48 | @criticality = Changes::Criticality.breaking( 49 | reason: "Removing a type is a breaking change. It is preferable to deprecate and remove all references to this type first." 50 | ) 51 | end 52 | 53 | def message 54 | "Type `#{removed_type.graphql_name}` was removed" 55 | end 56 | 57 | def path 58 | removed_type.path 59 | end 60 | end 61 | 62 | class DirectiveRemoved < AbstractChange 63 | attr_reader :directive, :criticality 64 | 65 | def initialize(directive) 66 | @directive = directive 67 | @criticality = Changes::Criticality.breaking 68 | end 69 | 70 | def message 71 | "Directive `#{directive.graphql_name}` was removed" 72 | end 73 | 74 | def path 75 | "@#{directive.graphql_name}" 76 | end 77 | end 78 | 79 | class TypeKindChanged < AbstractChange 80 | attr_reader :old_type, :new_type, :criticality 81 | 82 | def initialize(old_type, new_type) 83 | @old_type = old_type 84 | @new_type = new_type 85 | @criticality = Changes::Criticality.breaking( 86 | reason: "Changing the kind of a type is a breaking change because it can cause existing queries to error. For example, turning an object type to a scalar type would break queries that define a selection set for this type." 87 | ) 88 | end 89 | 90 | def message 91 | "`#{old_type.graphql_name}` kind changed from `#{old_type.kind}` to `#{new_type.kind}`" 92 | end 93 | 94 | def path 95 | old_type.path 96 | end 97 | end 98 | 99 | class EnumValueRemoved < AbstractChange 100 | attr_reader :enum_value, :enum_type, :criticality 101 | 102 | def initialize(enum_type, enum_value, usage) 103 | @enum_value = enum_value 104 | @enum_type = enum_type 105 | @criticality = if usage.input? 106 | Changes::Criticality.breaking( 107 | reason: "Removing an enum value will cause existing queries that use this enum value to error." 108 | ) 109 | else 110 | Changes::Criticality.non_breaking( 111 | reason: "Removing an enum value for enums used only in outputs is non-breaking." 112 | ) 113 | end 114 | end 115 | 116 | def message 117 | "Enum value `#{enum_value.graphql_name}` was removed from enum `#{enum_type.graphql_name}`" 118 | end 119 | 120 | def path 121 | enum_value.path 122 | end 123 | end 124 | 125 | class UnionMemberRemoved < AbstractChange 126 | attr_reader :union_type, :union_member, :criticality 127 | 128 | def initialize(union_type, union_member) 129 | @union_member = union_member 130 | @union_type = union_type 131 | @criticality = Changes::Criticality.breaking( 132 | reason: "Removing a union member from a union can cause existing queries that use this union member in a fragment spread to error." 133 | ) 134 | end 135 | 136 | def message 137 | "Union member `#{union_member.graphql_name}` was removed from Union type `#{union_type.graphql_name}`" 138 | end 139 | 140 | def path 141 | union_type.path 142 | end 143 | end 144 | 145 | class InputFieldRemoved < AbstractChange 146 | attr_reader :input_object_type, :field, :criticality 147 | 148 | def initialize(input_object_type, field) 149 | @input_object_type = input_object_type 150 | @field = field 151 | @criticality = Changes::Criticality.breaking( 152 | reason: "Removing an input field will cause existing queries that use this input field to error." 153 | ) 154 | end 155 | 156 | def message 157 | "Input field `#{field.graphql_name}` was removed from input object type `#{input_object_type.graphql_name}`" 158 | end 159 | 160 | def path 161 | field.path 162 | end 163 | end 164 | 165 | class FieldArgumentRemoved < AbstractChange 166 | attr_reader :object_type, :field, :argument, :criticality 167 | 168 | def initialize(object_type, field, argument) 169 | @object_type = object_type 170 | @field = field 171 | @argument = argument 172 | @criticality = Changes::Criticality.breaking( 173 | reason: "Removing a field argument is a breaking change because it will cause existing queries that use this argument to error." 174 | ) 175 | end 176 | 177 | def message 178 | "Argument `#{argument.graphql_name}: #{argument.type.to_type_signature}` was removed from field `#{object_type.graphql_name}.#{field.graphql_name}`" 179 | end 180 | 181 | def path 182 | argument.path 183 | end 184 | end 185 | 186 | class DirectiveArgumentRemoved < AbstractChange 187 | attr_reader :directive, :argument, :criticality 188 | 189 | def initialize(directive, argument) 190 | @directive = directive 191 | @argument = argument 192 | @criticality = Changes::Criticality.breaking 193 | end 194 | 195 | def message 196 | "Argument `#{argument.graphql_name}` was removed from directive `#{directive.graphql_name}`" 197 | end 198 | 199 | def path 200 | ["@#{directive.graphql_name}", argument.graphql_name].join('.') 201 | end 202 | end 203 | 204 | class RootOperationTypeAdded < AbstractChange 205 | attr_reader :new_schema, :operation_type, :criticality 206 | 207 | def initialize(new_schema:, operation_type:) 208 | @new_schema = new_schema 209 | @operation_type = operation_type 210 | @criticality = Changes::Criticality.non_breaking( 211 | reason: "Adding a schema #{operation_type} root is considered non-breaking." 212 | ) 213 | end 214 | 215 | def message 216 | "Schema #{operation_type} root `#{operation_type_name}` was added" 217 | end 218 | 219 | def path 220 | operation_type_name 221 | end 222 | 223 | def operation_type_name 224 | case operation_type 225 | when :query 226 | new_schema.query.graphql_name 227 | when :mutation 228 | new_schema.mutation.graphql_name 229 | when :subscription 230 | new_schema.subscription.graphql_name 231 | end 232 | end 233 | end 234 | 235 | class RootOperationTypeChanged < AbstractChange 236 | attr_reader :old_schema, :new_schema, :operation_type, :criticality 237 | 238 | def initialize(old_schema:, new_schema:, operation_type:) 239 | @old_schema = old_schema 240 | @new_schema = new_schema 241 | @operation_type = operation_type 242 | @criticality = Changes::Criticality.breaking 243 | end 244 | 245 | def message 246 | "Schema #{operation_type} root has changed from `#{operation_type_name(old_schema)}` to `#{operation_type_name(new_schema)}`" 247 | end 248 | 249 | def path 250 | operation_type_name(old_schema) 251 | end 252 | 253 | def operation_type_name(schema) 254 | case operation_type 255 | when :query 256 | schema.query.graphql_name 257 | when :mutation 258 | schema.mutation.graphql_name 259 | when :subscription 260 | schema.subscription.graphql_name 261 | end 262 | end 263 | end 264 | 265 | class RootOperationTypeRemoved < AbstractChange 266 | attr_reader :old_schema, :operation_type, :criticality 267 | 268 | def initialize(old_schema:, operation_type:) 269 | @old_schema = old_schema 270 | @operation_type = operation_type 271 | @criticality = Changes::Criticality.breaking 272 | end 273 | 274 | def message 275 | "Schema #{operation_type} root `#{operation_type_name}` was removed" 276 | end 277 | 278 | def path 279 | operation_type_name 280 | end 281 | 282 | def operation_type_name 283 | case operation_type 284 | when :query 285 | old_schema.query.graphql_name 286 | when :mutation 287 | old_schema.mutation.graphql_name 288 | when :subscription 289 | old_schema.subscription.graphql_name 290 | end 291 | end 292 | end 293 | 294 | class FieldRemoved < AbstractChange 295 | attr_reader :object_type, :field, :criticality 296 | 297 | def initialize(object_type, field) 298 | @object_type = object_type 299 | @field = field 300 | 301 | if field.deprecation_reason 302 | @criticality = Changes::Criticality.breaking( 303 | reason: "Removing a deprecated field is a breaking change. Before removing it, you may want" \ 304 | "to look at the field's usage to see the impact of removing the field." 305 | ) 306 | else 307 | @criticality = Changes::Criticality.breaking( 308 | reason: "Removing a field is a breaking change. It is preferable to deprecate the field before removing it." 309 | ) 310 | end 311 | end 312 | 313 | def message 314 | "Field `#{field.graphql_name}` was removed from object type `#{object_type.graphql_name}`" 315 | end 316 | 317 | def path 318 | [object_type.graphql_name, field.graphql_name].join(".") 319 | end 320 | end 321 | 322 | class DirectiveLocationRemoved < AbstractChange 323 | attr_reader :directive, :location, :criticality 324 | 325 | def initialize(directive, location) 326 | @directive = directive 327 | @location = location 328 | @criticality = Changes::Criticality.breaking 329 | end 330 | 331 | def message 332 | "Location `#{location}` was removed from directive `#{directive.graphql_name}`" 333 | end 334 | 335 | def path 336 | "@#{directive.graphql_name}" 337 | end 338 | end 339 | 340 | class ObjectTypeInterfaceRemoved < AbstractChange 341 | attr_reader :interface, :object_type, :criticality 342 | 343 | def initialize(interface, object_type) 344 | @interface = interface 345 | @object_type = object_type 346 | @criticality = Changes::Criticality.breaking( 347 | reason: "Removing an interface from an object type can cause existing queries that use this in a fragment spread to error." 348 | ) 349 | end 350 | 351 | def message 352 | "`#{object_type.graphql_name}` object type no longer implements `#{interface.graphql_name}` interface" 353 | end 354 | 355 | def path 356 | object_type.path 357 | end 358 | end 359 | 360 | class FieldTypeChanged < AbstractChange 361 | include SafeTypeChange 362 | 363 | attr_reader :type, :old_field, :new_field 364 | 365 | def initialize(type, old_field, new_field) 366 | @type = type 367 | @old_field = old_field 368 | @new_field = new_field 369 | end 370 | 371 | def message 372 | "Field `#{old_field.path}` changed type from `#{old_field.type.to_type_signature}` to `#{new_field.type.to_type_signature}`" 373 | end 374 | 375 | def criticality 376 | if safe_change_for_field?(old_field.type, new_field.type) 377 | Changes::Criticality.non_breaking 378 | else 379 | Changes::Criticality.breaking # TODO - Add reason 380 | end 381 | end 382 | 383 | def path 384 | old_field.path 385 | end 386 | end 387 | 388 | class InputFieldTypeChanged < AbstractChange 389 | include SafeTypeChange 390 | 391 | attr_reader :input_type, :old_input_field, :new_input_field, :criticality 392 | 393 | def initialize(input_type, old_input_field, new_input_field) 394 | if safe_change_for_input_value?(old_input_field.type, new_input_field.type) 395 | @criticality = Changes::Criticality.non_breaking( 396 | reason: "Changing an input field from non-null to null is considered non-breaking" 397 | ) 398 | else 399 | @criticality = Changes::Criticality.breaking( 400 | reason: "Changing the type of an input field can cause existing queries that use this field to error." 401 | ) 402 | end 403 | 404 | @input_type = input_type 405 | @old_input_field = old_input_field 406 | @new_input_field = new_input_field 407 | end 408 | 409 | def message 410 | "Input field `#{path}` changed type from `#{old_input_field.type.to_type_signature}` to `#{new_input_field.type.to_type_signature}`" 411 | end 412 | 413 | def path 414 | old_input_field.path 415 | end 416 | end 417 | 418 | class FieldArgumentTypeChanged < AbstractChange 419 | include SafeTypeChange 420 | 421 | attr_reader :type, :field, :old_argument, :new_argument, :criticality 422 | 423 | def initialize(type, field, old_argument, new_argument) 424 | if safe_change_for_input_value?(old_argument.type, new_argument.type) 425 | @criticality = Changes::Criticality.non_breaking( 426 | reason: "Changing an input field from non-null to null is considered non-breaking" 427 | ) 428 | else 429 | @criticality = Changes::Criticality.breaking( 430 | reason: "Changing the type of a field's argument can cause existing queries that use this argument to error." 431 | ) 432 | end 433 | 434 | @type = type 435 | @field = field 436 | @old_argument = old_argument 437 | @new_argument = new_argument 438 | end 439 | 440 | def message 441 | "Type for argument `#{new_argument.graphql_name}` on field `#{field.path}` changed"\ 442 | " from `#{old_argument.type.to_type_signature}` to `#{new_argument.type.to_type_signature}`" 443 | end 444 | 445 | def path 446 | old_argument.path 447 | end 448 | end 449 | 450 | class DirectiveArgumentTypeChanged < AbstractChange 451 | include SafeTypeChange 452 | 453 | attr_reader :directive, :old_argument, :new_argument, :criticality 454 | 455 | def initialize(directive, old_argument, new_argument) 456 | if safe_change_for_input_value?(old_argument.type, new_argument.type) 457 | @criticality = Changes::Criticality.non_breaking( 458 | reason: "Changing an input field from non-null to null is considered non-breaking" 459 | ) 460 | else 461 | @criticality = Changes::Criticality.breaking 462 | end 463 | 464 | @directive = directive 465 | @old_argument = old_argument 466 | @new_argument = new_argument 467 | end 468 | 469 | def message 470 | "Type for argument `#{new_argument.graphql_name}` on directive `#{directive.graphql_name}` changed"\ 471 | " from `#{old_argument.type.to_type_signature}` to `#{new_argument.type.to_type_signature}`" 472 | end 473 | 474 | def path 475 | ["@#{directive.graphql_name}", old_argument.graphql_name].join('.') 476 | end 477 | end 478 | 479 | # Dangerous Changes 480 | 481 | class FieldArgumentDefaultChanged < AbstractChange 482 | attr_reader :type, :field, :old_argument, :new_argument, :criticality 483 | 484 | def initialize(type, field, old_argument, new_argument) 485 | @type = type 486 | @field = field 487 | @old_argument = old_argument 488 | @new_argument = new_argument 489 | @criticality = Changes::Criticality.dangerous( 490 | reason: "Changing the default value for an argument may change the runtime " \ 491 | "behaviour of a field if it was never provided." 492 | ) 493 | end 494 | 495 | def message 496 | if old_argument.default_value? 497 | "Default value for argument `#{new_argument.graphql_name}` on field `#{field.path}` changed"\ 498 | " from `#{old_argument.default_value}` to `#{new_argument.default_value}`" 499 | else 500 | "Default value `#{new_argument.default_value}` was added to argument `#{new_argument.graphql_name}` on field `#{field.path}`" 501 | end 502 | end 503 | 504 | def path 505 | old_argument.path 506 | end 507 | end 508 | 509 | class InputFieldDefaultChanged < AbstractChange 510 | attr_reader :input_type, :old_field, :new_field, :criticality 511 | 512 | def initialize(input_type, old_field, new_field) 513 | @criticality = Changes::Criticality.dangerous( 514 | reason: "Changing the default value for an argument may change the runtime " \ 515 | "behaviour of a field if it was never provided." 516 | ) 517 | @input_type = input_type 518 | @old_field = old_field 519 | @new_field = new_field 520 | end 521 | 522 | def message 523 | "Input field `#{path}` default changed"\ 524 | " from `#{old_field.default_value}` to `#{new_field.default_value}`" 525 | end 526 | 527 | def path 528 | old_field.path 529 | end 530 | end 531 | 532 | class DirectiveArgumentDefaultChanged < AbstractChange 533 | attr_reader :directive, :old_argument, :new_argument, :criticality 534 | 535 | def initialize(directive, old_argument, new_argument) 536 | @criticality = Changes::Criticality.dangerous( 537 | reason: "Changing the default value for an argument may change the runtime " \ 538 | "behaviour of a field if it was never provided." 539 | ) 540 | @directive = directive 541 | @old_argument = old_argument 542 | @new_argument = new_argument 543 | end 544 | 545 | def message 546 | if old_argument.default_value? 547 | "Default value for argument `#{new_argument.graphql_name}` on directive `#{directive.graphql_name}` changed"\ 548 | " from `#{old_argument.default_value}` to `#{new_argument.default_value}`" 549 | else 550 | "Default value `#{new_argument.default_value}` was added to argument `#{new_argument.graphql_name}` on directive `#{directive.graphql_name}`" 551 | end 552 | end 553 | 554 | def path 555 | ["@#{directive.graphql_name}", new_argument.graphql_name].join(".") 556 | end 557 | end 558 | 559 | class EnumValueAdded < AbstractChange 560 | attr_reader :enum_type, :enum_value, :criticality 561 | 562 | def initialize(enum_type, enum_value, usage) 563 | @enum_type = enum_type 564 | @enum_value = enum_value 565 | @criticality = if usage.output? 566 | Changes::Criticality.dangerous( 567 | reason: "Adding an enum value may break existing clients that were not " \ 568 | "programmed defensively against an added case when querying an enum." 569 | ) 570 | else 571 | Changes::Criticality.non_breaking( 572 | reason: "Adding an enum value for enums used only in inputs is non-breaking." 573 | ) 574 | end 575 | end 576 | 577 | def message 578 | "Enum value `#{enum_value.graphql_name}` was added to enum `#{enum_type.graphql_name}`" 579 | end 580 | 581 | def path 582 | enum_value.path 583 | end 584 | end 585 | 586 | class UnionMemberAdded < AbstractChange 587 | attr_reader :union_type, :union_member, :criticality 588 | 589 | def initialize(union_type, union_member) 590 | @union_member = union_member 591 | @union_type = union_type 592 | @criticality = Changes::Criticality.dangerous( 593 | reason: "Adding a possible type to Unions may break existing clients " \ 594 | "that were not programming defensively against a new possible type." 595 | ) 596 | end 597 | 598 | def message 599 | "Union member `#{union_member.graphql_name}` was added to Union type `#{union_type.graphql_name}`" 600 | end 601 | 602 | def path 603 | union_type.path 604 | end 605 | end 606 | 607 | class ObjectTypeInterfaceAdded < AbstractChange 608 | attr_reader :interface, :object_type, :criticality 609 | 610 | def initialize(interface, object_type) 611 | @criticality = Changes::Criticality.dangerous( 612 | reason: "Adding an interface to an object type may break existing clients " \ 613 | "that were not programming defensively against a new possible type." 614 | ) 615 | @interface = interface 616 | @object_type = object_type 617 | end 618 | 619 | def message 620 | "`#{object_type.graphql_name}` object implements `#{interface.graphql_name}` interface" 621 | end 622 | 623 | def path 624 | object_type.path 625 | end 626 | end 627 | 628 | # Mostly Non-Breaking Changes 629 | 630 | class InputFieldAdded < AbstractChange 631 | attr_reader :input_object_type, :field, :criticality 632 | 633 | def initialize(input_object_type, field) 634 | @criticality = if field.type.non_null? && !field.default_value? 635 | Changes::Criticality.breaking(reason: "Adding a non-null input field without a default value to an existing input type will cause existing queries that use this input type to error because they will not provide a value for this new field.") 636 | else 637 | Changes::Criticality.non_breaking 638 | end 639 | 640 | @input_object_type = input_object_type 641 | @field = field 642 | end 643 | 644 | def message 645 | "Input field `#{field.graphql_name}` was added to input object type `#{input_object_type.graphql_name}`" 646 | end 647 | 648 | def path 649 | field.path 650 | end 651 | end 652 | 653 | class FieldArgumentAdded < AbstractChange 654 | attr_reader :type, :field, :argument, :criticality 655 | 656 | def initialize(type, field, argument) 657 | @criticality = if argument.type.non_null? && !argument.default_value? 658 | Changes::Criticality.breaking(reason: "Adding a required argument without a default value to an existing field is a breaking change because it will cause existing uses of this field to error.") 659 | else 660 | Changes::Criticality.non_breaking 661 | end 662 | 663 | @type = type 664 | @field = field 665 | @argument = argument 666 | end 667 | 668 | def message 669 | "Argument `#{argument.graphql_name}: #{argument.type.graphql_name}` added to field `#{field.path}`" 670 | end 671 | 672 | def path 673 | argument.path 674 | end 675 | end 676 | 677 | class TypeAdded < AbstractChange 678 | attr_reader :type, :criticality 679 | 680 | def initialize(type) 681 | @type = type 682 | @criticality = Changes::Criticality.non_breaking 683 | end 684 | 685 | def message 686 | "Type `#{type.graphql_name}` was added" 687 | end 688 | 689 | def path 690 | type.path 691 | end 692 | end 693 | 694 | class DirectiveAdded < AbstractChange 695 | attr_reader :directive, :criticality 696 | 697 | def initialize(directive) 698 | @directive = directive 699 | @criticality = Changes::Criticality.non_breaking 700 | end 701 | 702 | def message 703 | "Directive `#{directive.graphql_name}` was added" 704 | end 705 | 706 | def path 707 | "@#{directive.graphql_name}" 708 | end 709 | end 710 | 711 | class TypeDescriptionChanged < AbstractChange 712 | attr_reader :old_type, :new_type, :criticality 713 | 714 | def initialize(old_type, new_type) 715 | @old_type = old_type 716 | @new_type = new_type 717 | @criticality = Changes::Criticality.non_breaking 718 | end 719 | 720 | def message 721 | "Description `#{old_type.description}` on type `#{old_type.graphql_name}` has changed to `#{new_type.description}`" 722 | end 723 | 724 | def path 725 | old_type.path 726 | end 727 | end 728 | 729 | class EnumValueDescriptionChanged < AbstractChange 730 | attr_reader :enum, :old_enum_value, :new_enum_value, :criticality 731 | 732 | def initialize(enum, old_enum_value, new_enum_value) 733 | @enum = enum 734 | @old_enum_value = old_enum_value 735 | @new_enum_value = new_enum_value 736 | @criticality = Changes::Criticality.non_breaking 737 | end 738 | 739 | def message 740 | "Description for enum value `#{new_enum_value.path}` changed from " \ 741 | "`#{old_enum_value.description}` to `#{new_enum_value.description}`" 742 | end 743 | 744 | def path 745 | old_enum_value.path 746 | end 747 | end 748 | 749 | class EnumValueDeprecated < AbstractChange 750 | attr_reader :enum, :old_enum_value, :new_enum_value, :criticality 751 | 752 | def initialize(enum, old_enum_value, new_enum_value) 753 | @criticality = Changes::Criticality.non_breaking 754 | @enum = enum 755 | @old_enum_value = old_enum_value 756 | @new_enum_value = new_enum_value 757 | end 758 | 759 | def message 760 | if old_enum_value.deprecation_reason 761 | "Enum value `#{new_enum_value.path}` deprecation reason changed " \ 762 | "from `#{old_enum_value.deprecation_reason}` to `#{new_enum_value.deprecation_reason}`" 763 | else 764 | "Enum value `#{new_enum_value.path}` was deprecated with reason `#{new_enum_value.deprecation_reason}`" 765 | end 766 | end 767 | 768 | def path 769 | old_enum_value.path 770 | end 771 | end 772 | 773 | class InputFieldDescriptionChanged < AbstractChange 774 | attr_reader :input_type, :old_field, :new_field, :criticality 775 | 776 | def initialize(input_type, old_field, new_field) 777 | @criticality = Changes::Criticality.non_breaking 778 | @input_type = input_type 779 | @old_field = old_field 780 | @new_field = new_field 781 | end 782 | 783 | def message 784 | "Input field `#{old_field.path}` description changed"\ 785 | " from `#{old_field.description}` to `#{new_field.description}`" 786 | end 787 | 788 | def path 789 | old_field.path 790 | end 791 | end 792 | 793 | class DirectiveDescriptionChanged < AbstractChange 794 | attr_reader :old_directive, :new_directive, :criticality 795 | 796 | def initialize(old_directive, new_directive) 797 | @criticality = Changes::Criticality.non_breaking 798 | @old_directive = old_directive 799 | @new_directive = new_directive 800 | end 801 | 802 | def message 803 | "Directive `#{new_directive.graphql_name}` description changed"\ 804 | " from `#{old_directive.description}` to `#{new_directive.description}`" 805 | end 806 | 807 | def path 808 | "@#{old_directive.graphql_name}" 809 | end 810 | end 811 | 812 | class FieldDescriptionChanged < AbstractChange 813 | attr_reader :type, :old_field, :new_field, :criticality 814 | 815 | def initialize(type, old_field, new_field) 816 | @criticality = Changes::Criticality.non_breaking 817 | @type = type 818 | @old_field = old_field 819 | @new_field = new_field 820 | end 821 | 822 | def message 823 | "Field `#{old_field.path}` description changed"\ 824 | " from `#{old_field.description}` to `#{new_field.description}`" 825 | end 826 | 827 | def path 828 | old_field.path 829 | end 830 | end 831 | 832 | class FieldArgumentDescriptionChanged < AbstractChange 833 | attr_reader :type, :field, :old_argument, :new_argument, :criticality 834 | 835 | def initialize(type, field, old_argument, new_argument) 836 | @criticality = Changes::Criticality.non_breaking 837 | @type = type 838 | @field = field 839 | @old_argument = old_argument 840 | @new_argument = new_argument 841 | end 842 | 843 | def message 844 | "Description for argument `#{new_argument.graphql_name}` on field `#{field.path}` changed"\ 845 | " from `#{old_argument.description}` to `#{new_argument.description}`" 846 | end 847 | 848 | def path 849 | old_argument.path 850 | end 851 | end 852 | 853 | class DirectiveArgumentDescriptionChanged < AbstractChange 854 | attr_reader :directive, :old_argument, :new_argument, :criticality 855 | 856 | def initialize(directive, old_argument, new_argument) 857 | @criticality = Changes::Criticality.non_breaking 858 | @directive = directive 859 | @old_argument = old_argument 860 | @new_argument = new_argument 861 | end 862 | 863 | def message 864 | "Description for argument `#{new_argument.graphql_name}` on directive `#{directive.graphql_name}` changed"\ 865 | " from `#{old_argument.description}` to `#{new_argument.description}`" 866 | end 867 | 868 | def path 869 | ["@#{directive.graphql_name}", old_argument.graphql_name].join(".") 870 | end 871 | end 872 | 873 | class FieldDeprecationChanged < AbstractChange 874 | attr_reader :type, :old_field, :new_field, :criticality 875 | 876 | def initialize(type, old_field, new_field) 877 | @criticality = Changes::Criticality.non_breaking 878 | @type = type 879 | @old_field = old_field 880 | @new_field = new_field 881 | end 882 | 883 | def message 884 | "Deprecation reason on field `#{new_field.path}` has changed "\ 885 | "from `#{old_field.deprecation_reason}` to `#{new_field.deprecation_reason}`" 886 | end 887 | 888 | def path 889 | old_field.path 890 | end 891 | end 892 | 893 | class FieldAdded < AbstractChange 894 | attr_reader :object_type, :field, :criticality 895 | 896 | def initialize(object_type, field) 897 | @criticality = Changes::Criticality.non_breaking 898 | @object_type = object_type 899 | @field = field 900 | end 901 | 902 | def message 903 | "Field `#{field.graphql_name}` was added to object type `#{object_type.graphql_name}`" 904 | end 905 | 906 | def path 907 | [object_type.graphql_name, field.graphql_name].join(".") 908 | end 909 | end 910 | 911 | class DirectiveLocationAdded < AbstractChange 912 | attr_reader :directive, :location, :criticality 913 | 914 | def initialize(directive, location) 915 | @criticality = Changes::Criticality.non_breaking 916 | @directive = directive 917 | @location = location 918 | end 919 | 920 | def message 921 | "Location `#{location}` was added to directive `#{directive.graphql_name}`" 922 | end 923 | 924 | def path 925 | "@#{directive.graphql_name}" 926 | end 927 | end 928 | 929 | # TODO 930 | class FieldAstDirectiveAdded < AbstractChange 931 | def initialize(*) 932 | end 933 | end 934 | 935 | # TODO 936 | class FieldAstDirectiveRemoved < AbstractChange 937 | def initialize(*) 938 | end 939 | end 940 | 941 | # TODO 942 | class EnumValueAstDirectiveAdded < AbstractChange 943 | def initialize(*) 944 | end 945 | end 946 | 947 | # TODO 948 | class EnumValueAstDirectiveRemoved < AbstractChange 949 | def initialize(*) 950 | end 951 | end 952 | 953 | # TODO 954 | class InputFieldAstDirectiveAdded < AbstractChange 955 | def initialize(*) 956 | end 957 | end 958 | 959 | # TODO 960 | class InputFieldAstDirectiveRemoved < AbstractChange 961 | def initialize(*) 962 | end 963 | end 964 | 965 | # TODO 966 | class DirectiveArgumentAstDirectiveAdded < AbstractChange 967 | def initialize(*) 968 | end 969 | end 970 | 971 | # TODO 972 | class DirectiveArgumentAstDirectiveRemoved < AbstractChange 973 | def initialize(*) 974 | end 975 | end 976 | 977 | # TODO 978 | class FieldArgumentAstDirectiveAdded < AbstractChange 979 | def initialize(*) 980 | end 981 | end 982 | 983 | # TODO 984 | class FieldArgumentAstDirectiveRemoved < AbstractChange 985 | def initialize(*) 986 | end 987 | end 988 | 989 | # TODO 990 | class ObjectTypeAstDirectiveAdded < AbstractChange 991 | def initialize(*) 992 | end 993 | end 994 | 995 | # TODO 996 | class ObjectTypeAstDirectiveRemoved < AbstractChange 997 | def initialize(*) 998 | end 999 | end 1000 | 1001 | # TODO 1002 | class InterfaceTypeAstDirectiveAdded < AbstractChange 1003 | def initialize(*) 1004 | end 1005 | end 1006 | 1007 | # TODO 1008 | class InterfaceTypeAstDirectiveRemoved < AbstractChange 1009 | def initialize(*) 1010 | end 1011 | end 1012 | 1013 | # TODO 1014 | class UnionTypeAstDirectiveAdded < AbstractChange 1015 | def initialize(*) 1016 | end 1017 | end 1018 | 1019 | # TODO 1020 | class UnionTypeAstDirectiveRemoved < AbstractChange 1021 | def initialize(*) 1022 | end 1023 | end 1024 | 1025 | # TODO 1026 | class EnumTypeAstDirectiveAdded < AbstractChange 1027 | def initialize(*) 1028 | end 1029 | end 1030 | 1031 | # TODO 1032 | class EnumTypeAstDirectiveRemoved < AbstractChange 1033 | def initialize(*) 1034 | end 1035 | end 1036 | 1037 | # TODO 1038 | class ScalarTypeAstDirectiveAdded < AbstractChange 1039 | def initialize(*) 1040 | end 1041 | end 1042 | 1043 | # TODO 1044 | class ScalarTypeAstDirectiveRemoved < AbstractChange 1045 | def initialize(*) 1046 | end 1047 | end 1048 | 1049 | # TODO 1050 | class InputObjectTypeAstDirectiveAdded < AbstractChange 1051 | def initialize(*) 1052 | end 1053 | end 1054 | 1055 | # TODO 1056 | class InputObjectTypeAstDirectiveRemoved < AbstractChange 1057 | def initialize(*) 1058 | end 1059 | end 1060 | 1061 | # TODO 1062 | class SchemaAstDirectiveAdded < AbstractChange 1063 | def initialize(*) 1064 | end 1065 | end 1066 | 1067 | # TODO 1068 | class SchemaAstDirectiveRemoved < AbstractChange 1069 | def initialize(*) 1070 | end 1071 | end 1072 | 1073 | class DirectiveArgumentAdded < AbstractChange 1074 | attr_reader :directive, :argument, :criticality 1075 | 1076 | def initialize(directive, argument) 1077 | @criticality = if argument.type.non_null? 1078 | Changes::Criticality.breaking 1079 | else 1080 | Changes::Criticality.non_breaking 1081 | end 1082 | @directive = directive 1083 | @argument = argument 1084 | end 1085 | 1086 | def message 1087 | "Argument `#{argument.graphql_name}` was added to directive `#{directive.graphql_name}`" 1088 | end 1089 | 1090 | def path 1091 | ["@#{directive.graphql_name}", argument.graphql_name].join('.') 1092 | end 1093 | end 1094 | end 1095 | end 1096 | end 1097 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/changes/criticality.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Changes 4 | # Defines the criticality of a {Change} object. 5 | class Criticality 6 | # Non-breaking criticality usually defines changes that are always 7 | # safe to make to a GraphQL Schema. They do not 8 | # require any changes on the client side 9 | NON_BREAKING = 1 10 | 11 | # Dangerous criticality defines changes that are not breaking 12 | # the schema, but may break runtime logic on clients 13 | # if they did not code defensively enough to prevent 14 | # these changes. 15 | DANGEROUS = 2 16 | 17 | # Breaking criticality are changes that immediately impact 18 | # clients usually causing queries not to be valid anymore. 19 | BREAKING = 3 20 | 21 | attr_reader :level, :reason 22 | 23 | class << self 24 | # Returns a new Criticality object with a BREAKING level 25 | # @param reason [String] optional reason for this criticality 26 | # @return [GraphQL::SchemaComparator::Changes::Criticality] 27 | def breaking(reason: "This change is a breaking change") 28 | new( 29 | level: BREAKING, 30 | reason: reason 31 | ) 32 | end 33 | 34 | # Returns a new Criticality object with a NON_BREAKING level 35 | # @param reason [String] optional reason for this criticality 36 | # @return [GraphQL::SchemaComparator::Changes::Criticality] 37 | def non_breaking(reason: "This change is safe") 38 | new( 39 | level: NON_BREAKING, 40 | reason: reason 41 | ) 42 | end 43 | 44 | # Returns a new Criticality object with a DANGEROUS level 45 | # @param reason [String] optional reason for this criticality 46 | # @return [GraphQL::SchemaComparator::Changes::Criticality] 47 | def dangerous(reason: "This change is dangerous") 48 | new( 49 | level: DANGEROUS, 50 | reason: reason 51 | ) 52 | end 53 | end 54 | 55 | # Creates a new Criticality object 56 | # 57 | # @param level [Symbol] The criticality level 58 | # @param reason [String] The reason why this criticality is set on the change 59 | def initialize(level: NON_BREAKING, reason: nil) 60 | @level = level 61 | @reason = reason 62 | end 63 | 64 | def <=>(other) 65 | if level == other.level 66 | 0 67 | elsif level < other.level 68 | -1 69 | else 70 | 1 71 | end 72 | end 73 | 74 | def breaking? 75 | @level == BREAKING 76 | end 77 | 78 | def non_breaking? 79 | @level == NON_BREAKING 80 | end 81 | 82 | def dangerous? 83 | @level == DANGEROUS 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/changes/safe_type_change.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Changes 4 | module SafeTypeChange 5 | def safe_change_for_field?(old_type, new_type) 6 | if !old_type.kind.wraps? && !new_type.kind.wraps? 7 | old_type.graphql_name == new_type.graphql_name 8 | elsif new_type.kind.non_null? 9 | of_type = old_type.kind.non_null? ? old_type.of_type : old_type 10 | safe_change_for_field?(of_type, new_type.of_type) 11 | elsif old_type.kind.list? 12 | new_type.kind.list? && safe_change_for_field?(old_type.of_type, new_type.of_type) || 13 | new_type.kind.non_null? && safe_change_for_field?(old_type, new_type.of_type) 14 | else 15 | false 16 | end 17 | end 18 | 19 | def safe_change_for_input_value?(old_type, new_type) 20 | if !old_type.kind.wraps? && !new_type.kind.wraps? 21 | old_type.graphql_name == new_type.graphql_name 22 | elsif old_type.kind.list? && new_type.kind.list? 23 | safe_change_for_input_value?(old_type.of_type, new_type.of_type) 24 | elsif old_type.kind.non_null? 25 | of_type = new_type.kind.non_null? ? new_type.of_type : new_type 26 | safe_change_for_input_value?(old_type.of_type, of_type) 27 | else 28 | false 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/argument.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Argument 5 | def initialize(type, field, old_arg, new_arg) 6 | @type = type 7 | @field = field 8 | 9 | @old_arg = old_arg 10 | @new_arg = new_arg 11 | end 12 | 13 | def diff 14 | changes = [] 15 | 16 | if old_arg.description != new_arg.description 17 | changes << Changes::FieldArgumentDescriptionChanged.new(type, field, old_arg, new_arg) 18 | end 19 | 20 | if old_arg.default_value != new_arg.default_value 21 | changes << Changes::FieldArgumentDefaultChanged.new(type, field, old_arg, new_arg) 22 | end 23 | 24 | if old_arg.type.to_type_signature != new_arg.type.to_type_signature 25 | changes << Changes::FieldArgumentTypeChanged.new(type, field, old_arg, new_arg) 26 | end 27 | 28 | # TODO directives 29 | 30 | changes 31 | end 32 | 33 | private 34 | 35 | attr_reader( 36 | :type, 37 | :field, 38 | :new_arg, 39 | :old_arg 40 | ) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/directive.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Directive 5 | def initialize(old_directive, new_directive) 6 | @old_directive = old_directive 7 | @new_directive = new_directive 8 | @old_arguments = old_directive.arguments 9 | @new_arguments = new_directive.arguments 10 | end 11 | 12 | def diff 13 | changes = [] 14 | 15 | if old_directive.description != new_directive.description 16 | changes << Changes::DirectiveDescriptionChanged.new(old_directive, new_directive) 17 | end 18 | 19 | changes += removed_locations.map { |location| Changes::DirectiveLocationRemoved.new(new_directive, location) } 20 | changes += added_locations.map { |location| Changes::DirectiveLocationAdded.new(new_directive, location) } 21 | changes += added_arguments.map { |argument| Changes::DirectiveArgumentAdded.new(new_directive, argument) } 22 | changes += removed_arguments.map { |argument| Changes::DirectiveArgumentRemoved.new(new_directive, argument) } 23 | 24 | each_common_argument do |old_argument, new_argument| 25 | changes += Diff::DirectiveArgument.new(new_directive, old_argument, new_argument).diff 26 | end 27 | 28 | changes 29 | end 30 | 31 | private 32 | 33 | def removed_locations 34 | (old_directive.locations - new_directive.locations) 35 | end 36 | 37 | def added_locations 38 | (new_directive.locations - old_directive.locations) 39 | end 40 | 41 | def removed_arguments 42 | old_arguments.values.select { |arg| !new_arguments[arg.graphql_name] } 43 | end 44 | 45 | def added_arguments 46 | new_arguments.values.select { |arg| !old_arguments[arg.graphql_name] } 47 | end 48 | 49 | def each_common_argument(&block) 50 | intersection = old_arguments.keys & new_arguments.keys 51 | intersection.each do |common_arg| 52 | old_arg = old_directive.arguments[common_arg] 53 | new_arg = new_directive.arguments[common_arg] 54 | 55 | block.call(old_arg, new_arg) 56 | end 57 | end 58 | 59 | attr_reader(:old_directive, :new_directive, :old_arguments, :new_arguments) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/directive_argument.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class DirectiveArgument 5 | def initialize(directive, old_arg, new_arg) 6 | @directive = directive 7 | @old_arg = old_arg 8 | @new_arg = new_arg 9 | end 10 | 11 | def diff 12 | changes = [] 13 | 14 | if old_arg.description != new_arg.description 15 | changes << Changes::DirectiveArgumentDescriptionChanged.new(directive, old_arg, new_arg) 16 | end 17 | 18 | if old_arg.default_value != new_arg.default_value 19 | changes << Changes::DirectiveArgumentDefaultChanged.new(directive, old_arg, new_arg) 20 | end 21 | 22 | if old_arg.type.to_type_signature != new_arg.type.to_type_signature 23 | changes << Changes::DirectiveArgumentTypeChanged.new(directive, old_arg, new_arg) 24 | end 25 | 26 | # TODO directives on directive arguments 27 | 28 | changes 29 | end 30 | 31 | private 32 | 33 | attr_reader(:directive, :new_arg, :old_arg) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/enum.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Enum 5 | def initialize(old_enum, new_enum, usage) 6 | @old_enum = old_enum 7 | @new_enum = new_enum 8 | 9 | @old_values = old_enum.values 10 | @new_values = new_enum.values 11 | 12 | @usage = usage 13 | end 14 | 15 | def diff 16 | changes = [] 17 | 18 | changes += removed_values.map { |value| Changes::EnumValueRemoved.new(old_enum, value, usage) } 19 | changes += added_values.map { |value| Changes::EnumValueAdded.new(new_enum, value, usage) } 20 | 21 | each_common_value do |old_value, new_value| 22 | # TODO: Add Directive Stuff 23 | 24 | if old_value.description != new_value.description 25 | changes << Changes::EnumValueDescriptionChanged.new(new_enum, old_value, new_value) 26 | end 27 | 28 | if old_value.deprecation_reason != new_value.deprecation_reason 29 | changes << Changes::EnumValueDeprecated.new(new_enum, old_value, new_value) 30 | end 31 | end 32 | 33 | changes 34 | end 35 | 36 | private 37 | 38 | attr_reader :old_enum, :new_enum, :old_values, :new_values, :usage 39 | 40 | def each_common_value(&block) 41 | intersection = old_values.keys & new_values.keys 42 | intersection.each do |common_value| 43 | old_value = old_enum.values[common_value] 44 | new_value = new_enum.values[common_value] 45 | 46 | block.call(old_value, new_value) 47 | end 48 | end 49 | 50 | def removed_values 51 | (old_values.keys - new_values.keys).map { |removed| old_enum.values[removed] } 52 | end 53 | 54 | def added_values 55 | (new_values.keys - old_values.keys).map { |added| new_enum.values[added] } 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/field.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Field 5 | def initialize(old_type, new_type, old_field, new_field) 6 | @old_type = old_type 7 | @new_type = new_type 8 | 9 | @old_field = old_field 10 | @new_field = new_field 11 | 12 | @old_arguments = old_field.arguments 13 | @new_arguments = new_field.arguments 14 | end 15 | 16 | def diff 17 | changes = [] 18 | 19 | if old_field.description != new_field.description 20 | changes << Changes::FieldDescriptionChanged.new(new_type, old_field, new_field) 21 | end 22 | 23 | if old_field.deprecation_reason != new_field.deprecation_reason 24 | changes << Changes::FieldDeprecationChanged.new(new_type, old_field, new_field) 25 | end 26 | 27 | if old_field.type.to_type_signature != new_field.type.to_type_signature 28 | changes << Changes::FieldTypeChanged.new(new_type, old_field, new_field) 29 | end 30 | 31 | changes += arg_removals 32 | 33 | changes += arg_additions 34 | 35 | each_common_argument do |old_arg, new_arg| 36 | changes += Diff::Argument.new(new_type, new_field, old_arg, new_arg).diff 37 | end 38 | 39 | changes 40 | end 41 | 42 | private 43 | 44 | attr_reader( 45 | :old_type, 46 | :new_type, 47 | :new_field, 48 | :old_field, 49 | :old_arguments, 50 | :new_arguments 51 | ) 52 | 53 | def arg_removals 54 | removed = old_arguments.values.select { |arg| !new_arguments[arg.graphql_name] } 55 | removed.map { |arg| Changes::FieldArgumentRemoved.new(new_type, old_field, arg) } 56 | end 57 | 58 | def arg_additions 59 | removed = new_arguments.values.select { |arg| !old_arguments[arg.graphql_name] } 60 | removed.map { |arg| Changes::FieldArgumentAdded.new(new_type, new_field, arg) } 61 | end 62 | 63 | def each_common_argument(&block) 64 | intersection = old_arguments.keys & new_arguments.keys 65 | intersection.each do |common_arg| 66 | old_arg = old_field.arguments[common_arg] 67 | new_arg = new_field.arguments[common_arg] 68 | 69 | block.call(old_arg, new_arg) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/input_field.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class InputField 5 | def initialize(old_type, new_type, old_field, new_field) 6 | @old_type = old_type 7 | @new_type = new_type 8 | 9 | @old_field = old_field 10 | @new_field = new_field 11 | end 12 | 13 | def diff 14 | changes = [] 15 | 16 | if old_field.description != new_field.description 17 | changes << Changes::InputFieldDescriptionChanged.new(old_type, old_field, new_field) 18 | end 19 | 20 | if old_field.default_value != new_field.default_value 21 | changes << Changes::InputFieldDefaultChanged.new(old_type, old_field, new_field) 22 | end 23 | 24 | if old_field.type.to_type_signature != new_field.type.to_type_signature 25 | changes << Changes::InputFieldTypeChanged.new(old_type, old_field, new_field) 26 | end 27 | 28 | # TODO: directives 29 | 30 | changes 31 | end 32 | 33 | private 34 | 35 | attr_reader :old_type, :new_type, :new_field, :old_field 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/input_object.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class InputObject 5 | def initialize(old_type, new_type) 6 | @old_type = old_type 7 | @new_type = new_type 8 | 9 | @old_fields = old_type.arguments 10 | @new_fields = new_type.arguments 11 | end 12 | 13 | def diff 14 | changes = [] 15 | 16 | changes += removed_fields.map { |field| Changes::InputFieldRemoved.new(old_type, field) } 17 | changes += added_fields.map { |field| Changes::InputFieldAdded.new(new_type, field) } 18 | 19 | each_common_field do |old_field, new_field| 20 | # TODO: Add Directive Stuff 21 | changes += InputField.new(old_type, new_type, old_field, new_field).diff 22 | end 23 | 24 | changes 25 | end 26 | 27 | private 28 | 29 | attr_reader :old_type, :new_type, :old_fields, :new_fields 30 | 31 | def each_common_field(&block) 32 | intersection = old_fields.keys & new_fields.keys 33 | intersection.each do |common_field| 34 | old_field = old_type.arguments[common_field] 35 | new_field = new_type.arguments[common_field] 36 | 37 | block.call(old_field, new_field) 38 | end 39 | end 40 | 41 | def removed_fields 42 | old_fields.values.select { |field| !new_fields[field.graphql_name] } 43 | end 44 | 45 | def added_fields 46 | new_fields.values.select { |field| !old_fields[field.graphql_name] } 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/interface.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Interface 5 | def initialize(old_interface, new_interface) 6 | @old_interface = old_interface 7 | @new_interface = new_interface 8 | 9 | @old_fields = old_interface.fields 10 | @new_fields = new_interface.fields 11 | end 12 | 13 | def diff 14 | changes = [] 15 | changes += field_removals 16 | changes += field_additions 17 | 18 | each_common_field do |old_field, new_field| 19 | changes += Diff::Field.new(old_interface, new_interface, old_field, new_field).diff 20 | end 21 | 22 | changes 23 | end 24 | 25 | private 26 | 27 | attr_reader :old_interface, :new_interface, :old_fields, :new_fields 28 | 29 | def field_removals 30 | removed = old_fields.values.select { |field| !new_fields[field.graphql_name] } 31 | removed.map { |field| Changes::FieldRemoved.new(old_interface, field) } 32 | end 33 | 34 | def field_additions 35 | added = new_fields.values.select { |field| !old_fields[field.graphql_name] } 36 | added.map { |field| Changes::FieldAdded.new(new_interface, field) } 37 | end 38 | 39 | def each_common_field(&block) 40 | intersection = old_fields.keys & new_fields.keys 41 | intersection.each do |common_field| 42 | old_field = old_interface.fields[common_field] 43 | new_field = new_interface.fields[common_field] 44 | 45 | block.call(old_field, new_field) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/object_type.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class ObjectType 5 | def initialize(old_type, new_type) 6 | @old_type = old_type 7 | @new_type = new_type 8 | 9 | @old_fields = old_type.fields 10 | @new_fields = new_type.fields 11 | 12 | @old_interfaces = old_type.interfaces 13 | @new_interfaces = new_type.interfaces 14 | end 15 | 16 | def diff 17 | changes = [] 18 | 19 | changes += interface_additions 20 | changes += interface_removals 21 | changes += field_removals 22 | changes += field_additions 23 | 24 | each_common_field do |old_field, new_field| 25 | changes += Diff::Field.new(old_type, new_type, old_field, new_field).diff 26 | end 27 | 28 | changes 29 | end 30 | 31 | private 32 | 33 | attr_reader( 34 | :old_type, 35 | :new_type, 36 | :old_fields, 37 | :new_fields, 38 | :old_interfaces, 39 | :new_interfaces 40 | ) 41 | 42 | def interface_removals 43 | removed = filter_interfaces(old_interfaces, new_interfaces) 44 | removed.map { |interface| Changes::ObjectTypeInterfaceRemoved.new(interface, old_type) } 45 | end 46 | 47 | def interface_additions 48 | added = filter_interfaces(new_interfaces, old_interfaces) 49 | added.map { |interface| Changes::ObjectTypeInterfaceAdded.new(interface, new_type) } 50 | end 51 | 52 | def filter_interfaces(interfaces, excluded_interfaces) 53 | interfaces.select { |interface| !excluded_interfaces.map(&:graphql_name).include?(interface.graphql_name) } 54 | end 55 | 56 | def field_removals 57 | removed = old_fields.values.select { |field| !new_fields[field.graphql_name] } 58 | removed.map { |field| Changes::FieldRemoved.new(old_type, field) } 59 | end 60 | 61 | def field_additions 62 | added = new_fields.values.select { |field| !old_fields[field.graphql_name] } 63 | added.map { |field| Changes::FieldAdded.new(new_type, field) } 64 | end 65 | 66 | def each_common_field(&block) 67 | intersection = old_fields.keys & new_fields.keys 68 | intersection.each do |common_field| 69 | old_field = old_type.fields[common_field] 70 | new_field = new_type.fields[common_field] 71 | 72 | block.call(old_field, new_field) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/schema.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Schema 5 | def initialize(old_schema, new_schema) 6 | @old_schema = old_schema 7 | @new_schema = new_schema 8 | 9 | @old_types = old_schema.types 10 | @new_types = new_schema.types 11 | 12 | @old_directives = old_schema.directives 13 | @new_directives = new_schema.directives 14 | end 15 | 16 | def diff 17 | changes = [] 18 | 19 | # Removed and Added Types 20 | changes += removed_types.map { |type| Changes::TypeRemoved.new(type) } 21 | changes += added_types.map { |type| Changes::TypeAdded.new(type) } 22 | 23 | # Type Diff for common types 24 | each_common_type do |old_type, new_type| 25 | changes += changes_in_type(old_type, new_type) 26 | end 27 | 28 | # Diff Schemas 29 | changes += changes_in_schema 30 | 31 | # Diff Directives 32 | changes += changes_in_directives 33 | 34 | changes 35 | end 36 | 37 | def changes_in_type(old_type, new_type) 38 | changes = [] 39 | 40 | if old_type.kind != new_type.kind 41 | changes << Changes::TypeKindChanged.new(old_type, new_type) 42 | else 43 | case old_type.kind.name 44 | when "ENUM" 45 | changes += Diff::Enum.new(old_type, new_type, enum_usage(new_type)).diff 46 | when "UNION" 47 | changes += Diff::Union.new(old_type, new_type).diff 48 | when "INPUT_OBJECT" 49 | changes += Diff::InputObject.new(old_type, new_type).diff 50 | when "OBJECT" 51 | changes += Diff::ObjectType.new(old_type, new_type).diff 52 | when "INTERFACE" 53 | changes += Diff::Interface.new(old_type, new_type).diff 54 | end 55 | end 56 | 57 | if old_type.description != new_type.description 58 | changes << Changes::TypeDescriptionChanged.new(old_type, new_type) 59 | end 60 | 61 | changes 62 | end 63 | 64 | def changes_in_schema 65 | changes = [] 66 | 67 | if old_schema.query&.graphql_name != new_schema.query&.graphql_name 68 | if old_schema.query.nil? 69 | changes << Changes::RootOperationTypeAdded.new(new_schema: new_schema, operation_type: :query) 70 | elsif new_schema.query.nil? 71 | changes << Changes::RootOperationTypeRemoved.new(old_schema: old_schema, operation_type: :query) 72 | else 73 | changes << Changes::RootOperationTypeChanged.new(old_schema: old_schema, new_schema: new_schema, operation_type: :query) 74 | end 75 | end 76 | 77 | if old_schema.mutation&.graphql_name != new_schema.mutation&.graphql_name 78 | if old_schema.mutation.nil? 79 | changes << Changes::RootOperationTypeAdded.new(new_schema: new_schema, operation_type: :mutation) 80 | elsif new_schema.mutation.nil? 81 | changes << Changes::RootOperationTypeRemoved.new(old_schema: old_schema, operation_type: :mutation) 82 | else 83 | changes << Changes::RootOperationTypeChanged.new(old_schema: old_schema, new_schema: new_schema, operation_type: :mutation) 84 | end 85 | end 86 | 87 | if old_schema.subscription&.graphql_name != new_schema.subscription&.graphql_name 88 | if old_schema.subscription.nil? 89 | changes << Changes::RootOperationTypeAdded.new(new_schema: new_schema, operation_type: :subscription) 90 | elsif new_schema.subscription.nil? 91 | changes << Changes::RootOperationTypeRemoved.new(old_schema: old_schema, operation_type: :subscription) 92 | else 93 | changes << Changes::RootOperationTypeChanged.new(old_schema: old_schema, new_schema: new_schema, operation_type: :subscription) 94 | end 95 | end 96 | 97 | changes 98 | end 99 | 100 | def changes_in_directives 101 | changes = [] 102 | 103 | changes += removed_directives.map { |directive| Changes::DirectiveRemoved.new(directive) } 104 | changes += added_directives.map { |directive| Changes::DirectiveAdded.new(directive) } 105 | 106 | each_common_directive do |old_directive, new_directive| 107 | changes += Diff::Directive.new(old_directive, new_directive).diff 108 | end 109 | 110 | changes 111 | end 112 | 113 | private 114 | 115 | def enum_usage(new_enum) 116 | input_usage = new_schema.references_to(new_enum).any? { |member| member.is_a?(GraphQL::Schema::Argument) } 117 | output_usage = new_schema.references_to(new_enum).any? { |member| member.is_a?(GraphQL::Schema::Field) } 118 | EnumUsage.new(input: input_usage, output: output_usage) 119 | end 120 | 121 | def each_common_type(&block) 122 | intersection = old_types.keys & new_types.keys 123 | intersection.each do |common_type_name| 124 | old_type = old_schema.types[common_type_name] 125 | new_type = new_schema.types[common_type_name] 126 | 127 | block.call(old_type, new_type) 128 | end 129 | end 130 | 131 | def removed_types 132 | (old_types.keys - new_types.keys).map { |type_name| old_schema.types[type_name] } 133 | end 134 | 135 | def added_types 136 | (new_types.keys - old_types.keys).map { |type_name| new_schema.types[type_name] } 137 | end 138 | 139 | def removed_directives 140 | (old_directives.keys - new_directives.keys).map { |directive_name| old_schema.directives[directive_name] } 141 | end 142 | 143 | def added_directives 144 | (new_directives.keys - old_directives.keys).map { |directive_name| new_schema.directives[directive_name] } 145 | end 146 | 147 | def each_common_directive(&block) 148 | intersection = old_directives.keys & new_directives.keys 149 | intersection.each do |common_directive_name| 150 | old_directive = old_schema.directives[common_directive_name] 151 | new_directive = new_schema.directives[common_directive_name] 152 | 153 | block.call(old_directive, new_directive) 154 | end 155 | end 156 | 157 | attr_reader :old_schema, :new_schema, :old_types, :new_types, :old_directives, :new_directives 158 | end 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/diff/union.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | module Diff 4 | class Union 5 | def initialize(old_type, new_type) 6 | @old_type = old_type 7 | @new_type = new_type 8 | 9 | @old_possible_types = old_type.possible_types 10 | @new_possible_types = new_type.possible_types 11 | end 12 | 13 | def diff 14 | changes = [] 15 | changes += removed_possible_types.map do |removed| 16 | Changes::UnionMemberRemoved.new(new_type, removed) 17 | end 18 | changes += added_possible_types.map do |added| 19 | Changes::UnionMemberAdded.new(new_type, added) 20 | end 21 | changes 22 | end 23 | 24 | private 25 | 26 | attr_reader :old_type, :new_type, :old_possible_types, :new_possible_types 27 | 28 | def removed_possible_types 29 | filter_types(old_possible_types, new_possible_types) 30 | end 31 | 32 | def added_possible_types 33 | filter_types(new_possible_types, old_possible_types) 34 | end 35 | 36 | def filter_types(types, exclude_types) 37 | types.select { |type| !exclude_types.map(&:graphql_name).include?(type.graphql_name) } 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/enum_usage.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | class EnumUsage 4 | def initialize(input:, output:) 5 | @input = input 6 | @output = output 7 | end 8 | 9 | def input? 10 | @input 11 | end 12 | 13 | def output? 14 | @output 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/result.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | # The result of a comparison between two schema versions 4 | class Result 5 | attr_reader :changes, :breaking_changes, :non_breaking_changes, :dangerous_changes 6 | 7 | def initialize(changes) 8 | @changes = changes.sort_by(&:criticality).reverse 9 | @breaking_changes = @changes.select(&:breaking?) 10 | @non_breaking_changes = @changes.select(&:non_breaking?) 11 | @dangerous_changes = @changes.select(&:dangerous?) 12 | end 13 | 14 | # If the two schemas were identical 15 | # @return [Boolean] 16 | def identical? 17 | @changes.empty? 18 | end 19 | 20 | # If there was a breaking change between the two schema versions 21 | # @return [Boolean] 22 | def breaking? 23 | breaking_changes.any? 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/graphql/schema_comparator/version.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module SchemaComparator 3 | VERSION = "1.2.1" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | schema { query: Query } type Query { a: String } 2 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/criticality_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::CriticalityTest < Minitest::Test 4 | def test_breaking_change 5 | breaking_change = GraphQL::SchemaComparator::Changes::Criticality.breaking 6 | 7 | assert breaking_change.breaking? 8 | assert_equal "This change is a breaking change", breaking_change.reason 9 | end 10 | 11 | def test_dangerous_change 12 | dangerous_change = GraphQL::SchemaComparator::Changes::Criticality.dangerous 13 | 14 | assert dangerous_change.dangerous? 15 | assert_equal "This change is dangerous", dangerous_change.reason 16 | end 17 | 18 | def test_non_breaking_change 19 | non_breaking_change = GraphQL::SchemaComparator::Changes::Criticality.non_breaking 20 | 21 | assert non_breaking_change.non_breaking? 22 | assert_equal "This change is safe", non_breaking_change.reason 23 | end 24 | 25 | def test_breaking_change_with_custom_reason 26 | breaking_change = GraphQL::SchemaComparator::Changes::Criticality.breaking( 27 | reason: "Breaking change because it would break everything" 28 | ) 29 | 30 | assert breaking_change.breaking? 31 | assert_equal "Breaking change because it would break everything", breaking_change.reason 32 | end 33 | 34 | def test_dangerous_change_with_custom_reason 35 | dangerous_change = GraphQL::SchemaComparator::Changes::Criticality.dangerous( 36 | reason: "Dangerous because clients would be mad" 37 | ) 38 | 39 | assert dangerous_change.dangerous? 40 | assert_equal "Dangerous because clients would be mad", dangerous_change.reason 41 | end 42 | 43 | def test_non_breaking_change_with_custom_reason 44 | non_breaking_change = GraphQL::SchemaComparator::Changes::Criticality.non_breaking( 45 | reason: "Perfectly fine" 46 | ) 47 | 48 | assert non_breaking_change.non_breaking? 49 | assert_equal "Perfectly fine", non_breaking_change.reason 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/directives_unchanged_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::DirectivesUnchangedTest < Minitest::Test 4 | def test_identical_schemas 5 | schema_idl = <<~SCHEMA 6 | schema { 7 | query: QueryRoot 8 | } 9 | 10 | type QueryRoot { 11 | name(locale: String = "en"): String! 12 | } 13 | 14 | directive @colorFormat( 15 | colorFormat: ColorFormatEnum! 16 | ) on QUERY 17 | 18 | enum ColorFormatEnum { 19 | HSL 20 | LCH 21 | } 22 | SCHEMA 23 | 24 | result = GraphQL::SchemaComparator.compare(schema_idl, schema_idl) 25 | 26 | assert_equal [], result.breaking_changes.map(&:message) 27 | assert_equal [], result.non_breaking_changes.map(&:message) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/enum_value_added_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::EnumValueAddedTest < Minitest::Test 4 | class ProgrammingLanguageEnum < GraphQL::Schema::Enum 5 | value 'PYTHON' 6 | value 'RUBY' 7 | value 'JAVASCRIPT' 8 | end 9 | 10 | def test_with_input_usage 11 | change = GraphQL::SchemaComparator::Changes::EnumValueAdded.new( 12 | ProgrammingLanguageEnum, 13 | ProgrammingLanguageEnum.values['RUBY'], 14 | GraphQL::SchemaComparator::EnumUsage.new(input: true, output: false) 15 | ) 16 | 17 | assert change.non_breaking? 18 | end 19 | 20 | def test_with_output_usage 21 | change = GraphQL::SchemaComparator::Changes::EnumValueAdded.new( 22 | ProgrammingLanguageEnum, 23 | ProgrammingLanguageEnum.values['RUBY'], 24 | GraphQL::SchemaComparator::EnumUsage.new(input: false, output: true) 25 | ) 26 | 27 | assert change.dangerous? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/enum_value_removed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::EnumValueRemovedTest < Minitest::Test 4 | class ProgrammingLanguageEnum < GraphQL::Schema::Enum 5 | value 'PYTHON' 6 | value 'RUBY' 7 | value 'JAVASCRIPT' 8 | end 9 | 10 | def test_with_input_usage 11 | change = GraphQL::SchemaComparator::Changes::EnumValueRemoved.new( 12 | ProgrammingLanguageEnum, 13 | ProgrammingLanguageEnum.values['JAVASCRIPT'], 14 | GraphQL::SchemaComparator::EnumUsage.new(input: true, output: false) 15 | ) 16 | 17 | assert change.breaking? 18 | end 19 | 20 | def test_with_output_usage 21 | change = GraphQL::SchemaComparator::Changes::EnumValueRemoved.new( 22 | ProgrammingLanguageEnum, 23 | ProgrammingLanguageEnum.values['JAVASCRIPT'], 24 | GraphQL::SchemaComparator::EnumUsage.new(input: false, output: true) 25 | ) 26 | 27 | assert change.non_breaking? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/field_argument_added_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::FieldArgumentAddedTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | graphql_name "Type" 6 | 7 | field :field, String, null: true do 8 | argument :nullable, String, required: false 9 | argument :non_null, String, required: true 10 | argument :non_null_with_default, String, required: true, default_value: "bar" 11 | end 12 | end 13 | 14 | def test_nullable_added 15 | change = GraphQL::SchemaComparator::Changes::FieldArgumentAdded.new( 16 | Type, 17 | Type.fields["field"], 18 | Type.fields["field"].arguments["nullable"], 19 | ) 20 | 21 | assert change.non_breaking? 22 | end 23 | 24 | def test_non_null_added 25 | change = GraphQL::SchemaComparator::Changes::FieldArgumentAdded.new( 26 | Type, 27 | Type.fields["field"], 28 | Type.fields["field"].arguments["nonNull"], 29 | ) 30 | 31 | assert change.breaking? 32 | end 33 | 34 | def test_non_null_with_default_added 35 | change = GraphQL::SchemaComparator::Changes::FieldArgumentAdded.new( 36 | Type, 37 | Type.fields["field"], 38 | Type.fields["field"].arguments["nonNullWithDefault"], 39 | ) 40 | 41 | assert change.non_breaking? 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/field_argument_default_changed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::FieldArgumentDefaultChangedTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | graphql_name "Type" 6 | 7 | field :a, String, null: true do 8 | argument :a, String, required: false 9 | end 10 | 11 | field :b, String, null: true do 12 | argument :a, String, required: false, default_value: "a" 13 | end 14 | 15 | field :c, String, null: true do 16 | argument :a, String, required: false, default_value: "b" 17 | end 18 | end 19 | 20 | def test_default_value_added 21 | change = GraphQL::SchemaComparator::Changes::FieldArgumentDefaultChanged.new( 22 | Type, 23 | Type.fields["a"], 24 | Type.fields["a"].arguments["a"], 25 | Type.fields["b"].arguments["a"], 26 | ) 27 | 28 | expected = "Default value `a` was added to argument `a` on field `Type.a`" 29 | assert_equal expected, change.message 30 | end 31 | 32 | def test_default_value_changed 33 | change = GraphQL::SchemaComparator::Changes::FieldArgumentDefaultChanged.new( 34 | Type, 35 | Type.fields["a"], 36 | Type.fields["b"].arguments["a"], 37 | Type.fields["c"].arguments["a"], 38 | ) 39 | 40 | expected = "Default value for argument `a` on field `Type.a` changed from `a` to `b`" 41 | assert_equal expected, change.message 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/field_argument_removed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::FieldArgumentRemovedTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | graphql_name "Type" 6 | 7 | field :field, String, null: true do 8 | argument :nullable, String, required: false 9 | argument :non_null, String, required: true 10 | argument :non_null_with_default, String, required: true, default_value: "bar" 11 | end 12 | end 13 | 14 | def test_nullable_removed 15 | change = GraphQL::SchemaComparator::Changes::FieldArgumentRemoved.new( 16 | Type, 17 | Type.fields["field"], 18 | Type.fields["field"].arguments["nullable"], 19 | ) 20 | 21 | assert change.breaking? 22 | assert_equal change.message, "Argument `nullable: String` was removed from field `Type.field`" 23 | end 24 | 25 | def test_non_null_removed 26 | change = GraphQL::SchemaComparator::Changes::FieldArgumentRemoved.new( 27 | Type, 28 | Type.fields["field"], 29 | Type.fields["field"].arguments["nonNull"], 30 | ) 31 | 32 | assert change.breaking? 33 | assert_equal change.message, "Argument `nonNull: String!` was removed from field `Type.field`" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/changes/input_field_added_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Changes::InputFieldAddedTest < Minitest::Test 4 | class Type < GraphQL::Schema::InputObject 5 | graphql_name "Input" 6 | 7 | argument :nullable, String, required: false 8 | argument :non_null, String, required: true 9 | argument :non_null_with_default, String, required: true, default_value: "bar" 10 | end 11 | 12 | def test_nullable_added 13 | change = GraphQL::SchemaComparator::Changes::InputFieldAdded.new( 14 | Type, 15 | Type.arguments["nullable"], 16 | ) 17 | 18 | assert change.non_breaking? 19 | end 20 | 21 | def test_non_null_added 22 | change = GraphQL::SchemaComparator::Changes::InputFieldAdded.new( 23 | Type, 24 | Type.arguments["nonNull"], 25 | ) 26 | 27 | assert change.breaking? 28 | end 29 | 30 | def test_non_null_with_default_added 31 | change = GraphQL::SchemaComparator::Changes::InputFieldAdded.new( 32 | Type, 33 | Type.arguments["nonNullWithDefault"], 34 | ) 35 | 36 | assert change.non_breaking? 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/argument_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::ArgumentTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | graphql_name "Query" 6 | 7 | field :a, String, null: true 8 | end 9 | 10 | Field = Type.fields["a"] 11 | 12 | def test_diff_input_field_type_change 13 | old_input_field = Field.argument(:foo, "String", required: false) 14 | new_input_field = Field.argument(:foo, "Boolean", required: false) 15 | 16 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 17 | changes = differ.diff 18 | change = differ.diff.first 19 | 20 | assert_equal 1, changes.size 21 | assert change.breaking? 22 | 23 | assert_equal "Type for argument `foo` on field `Query.a` changed from `String` to `Boolean`", change.message 24 | end 25 | 26 | def test_diff_input_field_type_change_from_scalar_to_list_of_the_same_type 27 | old_input_field = Field.argument(:arg, "[String]", required: false) 28 | new_input_field = Field.argument(:arg, "String", required: false) 29 | 30 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 31 | changes = differ.diff 32 | change = differ.diff.first 33 | 34 | assert_equal 1, changes.size 35 | assert change.breaking? 36 | 37 | assert_equal "Type for argument `arg` on field `Query.a` changed from `[String!]` to `String`", change.message 38 | end 39 | 40 | def test_diff_input_field_type_change_from_non_null_to_null_same_type 41 | old_input_field = Field.argument(:arg, "String", required: true) 42 | new_input_field = Field.argument(:arg, "String", required: false) 43 | 44 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 45 | changes = differ.diff 46 | change = differ.diff.first 47 | 48 | assert_equal 1, changes.size 49 | refute change.breaking? 50 | 51 | assert_equal "Type for argument `arg` on field `Query.a` changed from `String!` to `String`", change.message 52 | end 53 | 54 | def test_diff_input_field_type_change_from_null_to_non_null_of_same_type 55 | old_input_field = Field.argument(:arg, "String", required: false) 56 | new_input_field = Field.argument(:arg, "String", required: true) 57 | 58 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 59 | changes = differ.diff 60 | change = differ.diff.first 61 | 62 | assert_equal 1, changes.size 63 | assert change.breaking? 64 | 65 | assert_equal "Type for argument `arg` on field `Query.a` changed from `String` to `String!`", change.message 66 | end 67 | 68 | def test_diff_input_field_type_nullability_change_on_lists_of_the_same_underlying_types 69 | old_input_field = Field.argument(:arg, "[String]", required: true) 70 | new_input_field = Field.argument(:arg, "[String]", required: false) 71 | 72 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 73 | changes = differ.diff 74 | change = differ.diff.first 75 | 76 | assert_equal 1, changes.size 77 | refute change.breaking? 78 | 79 | assert_equal "Type for argument `arg` on field `Query.a` changed from `[String!]!` to `[String!]`", change.message 80 | end 81 | 82 | def test_diff_input_field_type_change_within_lists_of_the_same_underlying_types 83 | old_input_field = Field.argument(:arg, ["String"], required: true) 84 | new_input_field = Field.argument(:arg, ["String", null: true], required: true) 85 | 86 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 87 | changes = differ.diff 88 | change = differ.diff.first 89 | 90 | assert_equal 1, changes.size 91 | refute change.breaking? 92 | 93 | assert_equal "Type for argument `arg` on field `Query.a` changed from `[String!]!` to `[String]!`", change.message 94 | end 95 | 96 | def test_input_field_type_changes_on_and_within_lists_of_the_same_underlying_types 97 | old_input_field = Field.argument(:arg, ["String"], required: true) 98 | new_input_field = Field.argument(:arg, ["String", null: true], required: false) 99 | 100 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 101 | changes = differ.diff 102 | change = differ.diff.first 103 | 104 | assert_equal 1, changes.size 105 | refute change.breaking? 106 | 107 | assert_equal "Type for argument `arg` on field `Query.a` changed from `[String!]!` to `[String]`", change.message 108 | end 109 | 110 | def test_input_field_type_changes_on_and_within_lists_of_different_underlying_types 111 | old_input_field = Field.argument(:arg, ["String"], required: true) 112 | new_input_field = Field.argument(:arg, ["Boolean", null: true], required: false) 113 | 114 | differ = GraphQL::SchemaComparator::Diff::Argument.new(Type, Field, old_input_field, new_input_field) 115 | changes = differ.diff 116 | change = differ.diff.first 117 | 118 | assert_equal 1, changes.size 119 | assert change.breaking? 120 | 121 | assert_equal "Type for argument `arg` on field `Query.a` changed from `[String!]!` to `[Boolean]`", change.message 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/directive_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::DirectiveTest < Minitest::Test 4 | def test_diff 5 | old_directive = Class.new(GraphQL::Schema::Directive) do 6 | graphql_name "Default" 7 | argument :a, Integer, required: true, default_value: "No", description: "A Description" 8 | argument :b, String, required: true 9 | locations :QUERY, :MUTATION 10 | end 11 | 12 | new_directive = Class.new(GraphQL::Schema::Directive) do 13 | graphql_name "Default" 14 | argument :a, String, required: true, default_value: "Yes", description: "Another Description" 15 | argument :c, String, required: true 16 | locations :MUTATION, :SUBSCRIPTION 17 | end 18 | 19 | differ = GraphQL::SchemaComparator::Diff::Directive.new(old_directive, new_directive) 20 | 21 | assert_equal [ 22 | "Location `QUERY` was removed from directive `Default`", 23 | "Location `SUBSCRIPTION` was added to directive `Default`", 24 | "Argument `c` was added to directive `Default`", 25 | "Argument `b` was removed from directive `Default`", 26 | "Description for argument `a` on directive `Default` changed from `A Description` to `Another Description`", 27 | "Default value for argument `a` on directive `Default` changed from `No` to `Yes`", 28 | "Type for argument `a` on directive `Default` changed from `Int!` to `String!`" 29 | ], differ.diff.map(&:message) 30 | 31 | assert_equal [ 32 | "@Default", 33 | "@Default", 34 | "@Default.c", 35 | "@Default.b", 36 | "@Default.a", 37 | "@Default.a", 38 | "@Default.a" 39 | ], differ.diff.map(&:path) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/enum_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::EnumTest < Minitest::Test 4 | def test_diff 5 | old_enum = Class.new(GraphQL::Schema::Enum) do 6 | graphql_name "Languages" 7 | description "Programming languages for Web projects" 8 | value("PYTHON", "A dynamic, function-oriented language", deprecation_reason: "a") 9 | value("RUBY", "A very dynamic language aimed at programmer happiness") 10 | value("JAVASCRIPT", "Accidental lingua franca of the web") 11 | end 12 | 13 | new_enum = Class.new(GraphQL::Schema::Enum) do 14 | graphql_name "Languages" 15 | description "Programming languages for all projects" 16 | value("PYTHON", "A dynamic, function-oriented language", deprecation_reason: "b") 17 | value("JAVASCRIPT", "Accidental lingua franca of the web lol", deprecation_reason: "Because!") 18 | value("CPLUSPLUS", "iamverysmart") 19 | end 20 | 21 | usage = GraphQL::SchemaComparator::EnumUsage.new(input: true, output: true) 22 | differ = GraphQL::SchemaComparator::Diff::Enum.new(old_enum, new_enum, usage) 23 | 24 | assert_equal [ 25 | "Enum value `RUBY` was removed from enum `Languages`", 26 | "Enum value `CPLUSPLUS` was added to enum `Languages`", 27 | "Enum value `Languages.PYTHON` deprecation reason changed from `a` to `b`", 28 | "Description for enum value `Languages.JAVASCRIPT` changed from `Accidental lingua franca of the web` to `Accidental lingua franca of the web lol`", 29 | "Enum value `Languages.JAVASCRIPT` was deprecated with reason `Because!`", 30 | ], differ.diff.map(&:message) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/field_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::FieldTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | graphql_name "Foo" 6 | end 7 | 8 | def test_field_type_change 9 | old_field = Type.field(:bar, "String", null: true) 10 | new_field = Type.field(:bar, "Boolean", null: true) 11 | 12 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 13 | changes = differ.diff 14 | change = differ.diff.first 15 | 16 | assert_equal 1, changes.size 17 | assert change.breaking? 18 | 19 | assert_equal "Field `Foo.bar` changed type from `String` to `Boolean`", change.message 20 | end 21 | 22 | def test_field_type_change_from_scalar_to_list_of_the_same_type 23 | old_field = Type.field(:bar, ["String", null: true], null: true) 24 | new_field = Type.field(:bar, "String", null: true) 25 | 26 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 27 | changes = differ.diff 28 | change = differ.diff.first 29 | 30 | assert_equal 1, changes.size 31 | assert change.breaking? 32 | 33 | assert_equal "Field `Foo.bar` changed type from `[String]` to `String`", change.message 34 | end 35 | 36 | def test_field_type_change_from_non_null_to_null_of_the_same_type 37 | old_field = Type.field(:bar, "String", null: false) 38 | new_field = Type.field(:bar, "String", null: true) 39 | 40 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 41 | changes = differ.diff 42 | change = differ.diff.first 43 | 44 | assert_equal 1, changes.size 45 | assert change.breaking? 46 | 47 | assert_equal "Field `Foo.bar` changed type from `String!` to `String`", change.message 48 | end 49 | 50 | def test_field_type_change_from_null_to_non_null_of_the_same_type 51 | old_field = Type.field(:bar, "String", null: true) 52 | new_field = Type.field(:bar, "String", null: false) 53 | 54 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 55 | changes = differ.diff 56 | change = differ.diff.first 57 | 58 | assert_equal 1, changes.size 59 | refute change.breaking? 60 | 61 | assert_equal "Field `Foo.bar` changed type from `String` to `String!`", change.message 62 | end 63 | 64 | def test_field_type_change_nullability_change_on_lists_of_same_type 65 | old_field = Type.field(:bar, ["String", null: true], null: false) 66 | new_field = Type.field(:bar, ["String", null: true], null: true) 67 | 68 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 69 | changes = differ.diff 70 | change = differ.diff.first 71 | 72 | assert_equal 1, changes.size 73 | assert change.breaking? 74 | 75 | assert_equal "Field `Foo.bar` changed type from `[String]!` to `[String]`", change.message 76 | end 77 | 78 | def test_field_type_change_within_lists_of_the_same_underlying_types 79 | old_field = Type.field(:bar, ["String"], null: false) 80 | new_field = Type.field(:bar, ["String", null: true], null: false) 81 | 82 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 83 | changes = differ.diff 84 | change = differ.diff.first 85 | 86 | assert_equal 1, changes.size 87 | assert change.breaking? 88 | 89 | assert_equal "Field `Foo.bar` changed type from `[String!]!` to `[String]!`", change.message 90 | end 91 | 92 | def test_field_type_change_within_and_on_list_of_same_type 93 | old_field = Type.field(:bar, ["String"], null: false) 94 | new_field = Type.field(:bar, ["String", null: true], null: true) 95 | 96 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 97 | changes = differ.diff 98 | change = differ.diff.first 99 | 100 | assert_equal 1, changes.size 101 | assert change.breaking? 102 | 103 | assert_equal "Field `Foo.bar` changed type from `[String!]!` to `[String]`", change.message 104 | end 105 | 106 | def test_field_type_change_within_and_on_list_of_same_type_of_different_types 107 | old_field = Type.field(:bar, ["String"], null: false) 108 | new_field = Type.field(:bar, ["Boolean", null: true], null: true) 109 | 110 | differ = GraphQL::SchemaComparator::Diff::Field.new(Type, Type, old_field, new_field) 111 | changes = differ.diff 112 | change = differ.diff.first 113 | 114 | assert_equal 1, changes.size 115 | assert change.breaking? 116 | 117 | assert_equal "Field `Foo.bar` changed type from `[String!]!` to `[Boolean]`", change.message 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/input_field_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::InputFieldTest < Minitest::Test 4 | class Input < GraphQL::Schema::InputObject 5 | graphql_name "Input" 6 | end 7 | 8 | def test_diff_input_field_type_change 9 | old_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: false, owner: Input) 10 | new_input_field = GraphQL::Schema::Argument.new(:arg, "Boolean", required: false, owner: Input) 11 | 12 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 13 | changes = differ.diff 14 | change = differ.diff.first 15 | 16 | assert_equal 1, changes.size 17 | assert change.breaking? 18 | 19 | assert_equal "Input field `Input.arg` changed type from `String` to `Boolean`", change.message 20 | end 21 | 22 | def test_diff_input_field_type_change_from_scalar_to_list_of_the_same_type 23 | old_input_field = GraphQL::Schema::Argument.new(:arg, ["String", null: true], required: false, owner: Input) 24 | new_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: false, owner: Input) 25 | 26 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 27 | changes = differ.diff 28 | change = differ.diff.first 29 | 30 | assert_equal 1, changes.size 31 | assert change.breaking? 32 | 33 | assert_equal "Input field `Input.arg` changed type from `[String]` to `String`", change.message 34 | end 35 | 36 | def test_diff_input_field_type_change_from_non_null_to_null_same_type 37 | old_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: true, owner: Input) 38 | new_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: false, owner: Input) 39 | 40 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 41 | changes = differ.diff 42 | change = differ.diff.first 43 | 44 | assert_equal 1, changes.size 45 | refute change.breaking? 46 | 47 | assert_equal "Input field `Input.arg` changed type from `String!` to `String`", change.message 48 | end 49 | 50 | def test_diff_input_field_type_change_from_null_to_non_null_of_same_type 51 | old_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: false, owner: Input) 52 | new_input_field = GraphQL::Schema::Argument.new(:arg, "String", required: true, owner: Input) 53 | 54 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 55 | changes = differ.diff 56 | change = differ.diff.first 57 | 58 | assert_equal 1, changes.size 59 | assert change.breaking? 60 | 61 | assert_equal "Input field `Input.arg` changed type from `String` to `String!`", change.message 62 | end 63 | 64 | def test_diff_input_field_type_nullability_change_on_lists_of_the_same_underlying_types 65 | old_input_field = GraphQL::Schema::Argument.new(:arg, ["String", null: true], required: true, owner: Input) 66 | new_input_field = GraphQL::Schema::Argument.new(:arg, ["String", null: true], required: false, owner: Input) 67 | 68 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 69 | changes = differ.diff 70 | change = differ.diff.first 71 | 72 | assert_equal 1, changes.size 73 | refute change.breaking? 74 | 75 | assert_equal "Input field `Input.arg` changed type from `[String]!` to `[String]`", change.message 76 | end 77 | 78 | def test_diff_input_field_type_change_within_lists_of_the_same_underlying_types 79 | old_input_field = GraphQL::Schema::Argument.new(:arg, ["String"], required: true, owner: Input) 80 | new_input_field = GraphQL::Schema::Argument.new(:arg, ["String", null: true], required: true, owner: Input) 81 | 82 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 83 | changes = differ.diff 84 | change = differ.diff.first 85 | 86 | assert_equal 1, changes.size 87 | refute change.breaking? 88 | 89 | assert_equal "Input field `Input.arg` changed type from `[String!]!` to `[String]!`", change.message 90 | end 91 | 92 | def test_input_field_type_changes_on_and_within_lists_of_the_same_underlying_types 93 | old_input_field = GraphQL::Schema::Argument.new(:arg, ["String"], required: true, owner: Input) 94 | new_input_field = GraphQL::Schema::Argument.new(:arg, ["String", null: true], required: false, owner: Input) 95 | 96 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 97 | changes = differ.diff 98 | change = differ.diff.first 99 | 100 | assert_equal 1, changes.size 101 | refute change.breaking? 102 | 103 | assert_equal "Input field `Input.arg` changed type from `[String!]!` to `[String]`", change.message 104 | end 105 | 106 | def test_input_field_type_changes_on_and_within_lists_of_different_underlying_types 107 | old_input_field = GraphQL::Schema::Argument.new(:arg, ["String"], required: true, owner: Input) 108 | new_input_field = GraphQL::Schema::Argument.new(:arg, ["Boolean", null: true], required: false, owner: Input) 109 | 110 | differ = GraphQL::SchemaComparator::Diff::InputField.new(Input, Input, old_input_field, new_input_field) 111 | changes = differ.diff 112 | change = differ.diff.first 113 | 114 | assert_equal 1, changes.size 115 | assert change.breaking? 116 | 117 | assert_equal "Input field `Input.arg` changed type from `[String!]!` to `[Boolean]`", change.message 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/diff/schema_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::Diff::SchemaTest < Minitest::Test 4 | def setup 5 | @old_schema = <<~SCHEMA 6 | schema { 7 | query: Query 8 | mutation: OldMutation 9 | } 10 | input AInput { 11 | # a 12 | a: String = "1" 13 | b: String! 14 | options: [Options] 15 | } 16 | # The Query Root of this schema 17 | type Query { 18 | # Just a simple string 19 | a(anArg: String): String! 20 | b: BType 21 | c(arg: Options): Options 22 | } 23 | type BType { 24 | a: String 25 | } 26 | type CType { 27 | a: String @deprecated(reason: "whynot") 28 | c: Int! 29 | d(arg: Int): String 30 | } 31 | union MyUnion = CType | BType 32 | interface AnInterface { 33 | interfaceField: Int! 34 | } 35 | interface AnotherInterface { 36 | anotherInterfaceField: String 37 | } 38 | type WithInterfaces implements AnInterface, AnotherInterface { 39 | a: String! 40 | } 41 | type WithArguments { 42 | a( 43 | # Meh 44 | a: Int 45 | b: String 46 | option: Options 47 | ): String 48 | b(arg: Int = 1): String 49 | } 50 | enum Options { 51 | A 52 | B 53 | C 54 | E 55 | F @deprecated(reason: "Old") 56 | } 57 | 58 | # Old 59 | directive @yolo( 60 | # Included when true. 61 | someArg: Boolean! 62 | 63 | anotherArg: String! 64 | 65 | willBeRemoved: Boolean! 66 | ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT 67 | 68 | type WillBeRemoved { 69 | a: String 70 | } 71 | type OldMutation { 72 | a: String! 73 | } 74 | 75 | directive @willBeRemoved on FIELD 76 | SCHEMA 77 | 78 | @new_schema =<<~SCHEMA 79 | schema { 80 | query: Query 81 | mutation: Mutation 82 | } 83 | input AInput { 84 | # changed 85 | a: Int = 1 86 | c: String! 87 | options: [Options] 88 | } 89 | # Query Root description changed 90 | type Query { 91 | # This description has been changed 92 | a: String! 93 | b: Int! 94 | c(arg: Options): Options 95 | } 96 | input BType { 97 | a: String! 98 | } 99 | type CType implements AnInterface { 100 | a(arg: Int): String @deprecated(reason: "cuz") 101 | b: Int! 102 | d(arg: Int = 10): String 103 | } 104 | type DType { 105 | b: Int! 106 | } 107 | union MyUnion = CType | DType 108 | interface AnInterface { 109 | interfaceField: Int! 110 | } 111 | interface AnotherInterface { 112 | b: Int 113 | } 114 | type WithInterfaces implements AnInterface { 115 | a: String! 116 | } 117 | type WithArguments { 118 | a( 119 | # Description for a 120 | a: Int 121 | b: String! 122 | option: Options 123 | ): String 124 | b(arg: Int = 2): String 125 | } 126 | enum Options { 127 | # Stuff 128 | A 129 | B 130 | D 131 | E @deprecated 132 | F @deprecated(reason: "New") 133 | } 134 | 135 | # New 136 | directive @yolo( 137 | # someArg does stuff 138 | someArg: String! 139 | 140 | anotherArg: String! = "Test" 141 | ) on FIELD | FIELD_DEFINITION 142 | 143 | directive @yolo2( 144 | # Included when true. 145 | someArg: String! 146 | ) on FIELD 147 | 148 | type Mutation { 149 | a: String! 150 | } 151 | SCHEMA 152 | 153 | @differ = GraphQL::SchemaComparator::Diff::Schema.new( 154 | GraphQL::Schema.from_definition(@old_schema), 155 | GraphQL::Schema.from_definition(@new_schema), 156 | ) 157 | end 158 | 159 | def test_changes_kitchensink 160 | assert_equal [ 161 | "Type `WillBeRemoved` was removed", 162 | "Type `DType` was added", 163 | "Field `Query.a` description changed from `Just a simple string` to `This description has been changed`", 164 | "Argument `anArg: String` was removed from field `Query.a`", 165 | "Field `Query.b` changed type from `BType` to `Int!`", 166 | "Description `The Query Root of this schema` on type `Query` has changed to `Query Root description changed`", 167 | "`BType` kind changed from `OBJECT` to `INPUT_OBJECT`", 168 | "Input field `b` was removed from input object type `AInput`", 169 | "Input field `c` was added to input object type `AInput`", 170 | "Input field `AInput.a` description changed from `a` to `changed`", 171 | "Input field `AInput.a` default changed from `1` to `1`", 172 | "Input field `AInput.a` changed type from `String` to `Int`", 173 | "`CType` object implements `AnInterface` interface", 174 | "Field `c` was removed from object type `CType`", 175 | "Field `interfaceField` was added to object type `CType`", 176 | "Field `b` was added to object type `CType`", 177 | "Deprecation reason on field `CType.a` has changed from `whynot` to `cuz`", 178 | "Argument `arg: Int` added to field `CType.a`", 179 | "Default value `10` was added to argument `arg` on field `CType.d`", 180 | "Default value `Test` was added to argument `anotherArg` on directive `yolo`", 181 | "Union member `BType` was removed from Union type `MyUnion`", 182 | "Union member `DType` was added to Union type `MyUnion`", 183 | "Field `anotherInterfaceField` was removed from object type `AnotherInterface`", 184 | "Field `b` was added to object type `AnotherInterface`", 185 | "`WithInterfaces` object type no longer implements `AnotherInterface` interface", 186 | "Field `anotherInterfaceField` was removed from object type `WithInterfaces`", 187 | "Description for argument `a` on field `WithArguments.a` changed from `Meh` to `Description for a`", 188 | "Type for argument `b` on field `WithArguments.a` changed from `String` to `String!`", 189 | "Default value for argument `arg` on field `WithArguments.b` changed from `1` to `2`", 190 | "Enum value `C` was removed from enum `Options`", 191 | "Enum value `D` was added to enum `Options`", 192 | "Description for enum value `Options.A` changed from `` to `Stuff`", 193 | "Enum value `Options.E` was deprecated with reason `No longer supported`", 194 | "Enum value `Options.F` deprecation reason changed from `Old` to `New`", 195 | "Directive `willBeRemoved` was removed", 196 | "Directive `yolo2` was added", 197 | "Directive `yolo` description changed from `Old` to `New`", 198 | "Location `FRAGMENT_SPREAD` was removed from directive `yolo`", 199 | "Location `INLINE_FRAGMENT` was removed from directive `yolo`", 200 | "Location `FIELD_DEFINITION` was added to directive `yolo`", 201 | "Argument `willBeRemoved` was removed from directive `yolo`", 202 | "Description for argument `someArg` on directive `yolo` changed from `Included when true.` to `someArg does stuff`", 203 | "Type for argument `someArg` on directive `yolo` changed from `Boolean!` to `String!`", 204 | "Type `Mutation` was added", 205 | "Type `OldMutation` was removed", 206 | "Schema mutation root has changed from `OldMutation` to `Mutation`", 207 | ].sort, @differ.diff.map(&:message).sort 208 | 209 | assert_equal [ 210 | "WillBeRemoved", 211 | "DType", 212 | "Query.a", 213 | "Query.a.anArg", 214 | "Query.b", 215 | "Query", 216 | "BType", 217 | "AInput.b", 218 | "AInput.c", 219 | "AInput.a", 220 | "AInput.a", 221 | "AInput.a", 222 | "CType", 223 | "CType.c", 224 | "CType.b", 225 | "CType.a", 226 | "CType.a.arg", 227 | "CType.d.arg", 228 | "CType.interfaceField", 229 | "Mutation", 230 | "MyUnion", 231 | "MyUnion", 232 | "OldMutation", 233 | "OldMutation", 234 | "AnotherInterface.anotherInterfaceField", 235 | "AnotherInterface.b", 236 | "WithInterfaces", 237 | "WithInterfaces.anotherInterfaceField", 238 | "WithArguments.a.a", 239 | "WithArguments.a.b", 240 | "WithArguments.b.arg", 241 | "Options.C", 242 | "Options.D", 243 | "Options.A", 244 | "Options.E", 245 | "Options.F", 246 | "@willBeRemoved", 247 | "@yolo2", 248 | "@yolo", 249 | "@yolo", 250 | "@yolo", 251 | "@yolo", 252 | "@yolo.willBeRemoved", 253 | "@yolo.someArg", 254 | "@yolo.someArg", 255 | "@yolo.anotherArg", 256 | ].sort, @differ.diff.map(&:path).sort 257 | end 258 | 259 | def test_schema_root_changes 260 | old_schema = <<~SCHEMA 261 | schema { 262 | query: OldQuery 263 | mutation: Mutation 264 | } 265 | 266 | type OldQuery { 267 | a: String! 268 | } 269 | 270 | type Mutation { 271 | a: String! 272 | } 273 | SCHEMA 274 | 275 | new_schema = <<~SCHEMA 276 | schema { 277 | query: Query 278 | subscription: Subscription 279 | } 280 | 281 | type Query { 282 | a: String! 283 | } 284 | 285 | type Subscription { 286 | a: String! 287 | } 288 | SCHEMA 289 | 290 | expected_changes = [ 291 | { path: "Mutation", message: "Schema mutation root `Mutation` was removed", level: 3 }, 292 | { path: "Mutation", message: "Type `Mutation` was removed", level: 3 }, 293 | { path: "OldQuery", message: "Schema query root has changed from `OldQuery` to `Query`", level: 3 }, 294 | { path: "OldQuery", message: "Type `OldQuery` was removed", level: 3 }, 295 | { path: "Query", message: "Type `Query` was added", level: 1 }, 296 | { path: "Subscription", message: "Schema subscription root `Subscription` was added", level: 1 }, 297 | { path: "Subscription", message: "Type `Subscription` was added", level: 1 }, 298 | ] 299 | 300 | actual_changes = schema_diff(old_schema, new_schema) 301 | assert_equal(normalize_schema_diff(expected_changes), normalize_schema_diff(actual_changes)) 302 | end 303 | 304 | def test_enum_value_changes 305 | old_schema = <<~SCHEMA 306 | schema { 307 | query: Query 308 | } 309 | 310 | type Query { 311 | a(arg: AInput): OutputEnum 312 | b(arg: InputEnum): String 313 | } 314 | 315 | input AInput { 316 | options: [Input2Enum] 317 | } 318 | 319 | enum InputEnum { 320 | INPUT_A 321 | INPUT_B 322 | } 323 | 324 | enum Input2Enum { 325 | INPUT2_A 326 | INPUT2_B 327 | } 328 | 329 | enum OutputEnum { 330 | OUTPUT_A 331 | OUTPUT_B 332 | } 333 | SCHEMA 334 | 335 | new_schema = <<~SCHEMA 336 | schema { 337 | query: Query 338 | } 339 | 340 | type Query { 341 | a(arg: AInput): OutputEnum 342 | b(arg: InputEnum): String 343 | } 344 | 345 | input AInput { 346 | options: [Input2Enum] 347 | } 348 | 349 | enum InputEnum { 350 | INPUT_B 351 | INPUT_C 352 | } 353 | 354 | enum Input2Enum { 355 | INPUT2_B 356 | INPUT2_C 357 | } 358 | 359 | enum OutputEnum { 360 | OUTPUT_B 361 | OUTPUT_C 362 | } 363 | SCHEMA 364 | 365 | expected_changes = [ 366 | { 367 | path: "InputEnum.INPUT_A", 368 | message: "Enum value `INPUT_A` was removed from enum `InputEnum`", 369 | level: GraphQL::SchemaComparator::Changes::Criticality::BREAKING 370 | }, 371 | { 372 | path: "InputEnum.INPUT_C", 373 | message: "Enum value `INPUT_C` was added to enum `InputEnum`", 374 | level: GraphQL::SchemaComparator::Changes::Criticality::NON_BREAKING 375 | }, 376 | { 377 | path: "Input2Enum.INPUT2_A", 378 | message: "Enum value `INPUT2_A` was removed from enum `Input2Enum`", 379 | level: GraphQL::SchemaComparator::Changes::Criticality::BREAKING 380 | }, 381 | { 382 | path: "Input2Enum.INPUT2_C", 383 | message: "Enum value `INPUT2_C` was added to enum `Input2Enum`", 384 | level: GraphQL::SchemaComparator::Changes::Criticality::NON_BREAKING 385 | }, 386 | { 387 | path: "OutputEnum.OUTPUT_A", 388 | message: "Enum value `OUTPUT_A` was removed from enum `OutputEnum`", 389 | level: GraphQL::SchemaComparator::Changes::Criticality::NON_BREAKING 390 | }, 391 | { 392 | path: "OutputEnum.OUTPUT_C", 393 | message: "Enum value `OUTPUT_C` was added to enum `OutputEnum`", 394 | level: GraphQL::SchemaComparator::Changes::Criticality::DANGEROUS 395 | } 396 | ] 397 | 398 | actual_changes = schema_diff(old_schema, new_schema) 399 | assert_equal(normalize_schema_diff(expected_changes), normalize_schema_diff(actual_changes)) 400 | end 401 | 402 | def schema_diff(old_schema, new_schema) 403 | differ = GraphQL::SchemaComparator::Diff::Schema.new( 404 | GraphQL::Schema.from_definition(old_schema), 405 | GraphQL::Schema.from_definition(new_schema) 406 | ) 407 | differ.diff.map do |change| 408 | { 409 | path: change.path, 410 | message: change.message, 411 | level: change.criticality.level 412 | } 413 | end 414 | end 415 | 416 | def normalize_schema_diff(changes) 417 | changes.sort_by { |changes| [changes[:path], changes[:message], changes[:level]] } 418 | end 419 | end 420 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/result_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::ResultTest < Minitest::Test 4 | class Type < GraphQL::Schema::Object 5 | field :foo, String, null: true 6 | end 7 | 8 | class EnumType < GraphQL::Schema::Enum 9 | value :foo 10 | end 11 | 12 | def test_changes 13 | 14 | removed_z = GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 15 | removed_a = GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 16 | added_a = GraphQL::SchemaComparator::Changes::FieldAdded.new(Type, Type.fields["foo"]) 17 | 18 | result = GraphQL::SchemaComparator::Result.new([removed_z, added_a, removed_a]) 19 | 20 | assert_equal [removed_a, removed_z, added_a], result.changes 21 | end 22 | 23 | def test_identical_returns_false_when_schemas_have_changes 24 | result = GraphQL::SchemaComparator::Result.new([ 25 | GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 26 | ]) 27 | 28 | assert_equal false, result.identical? 29 | end 30 | 31 | def test_identical_returns_true_when_schemas_are_the_same 32 | result = GraphQL::SchemaComparator::Result.new([]) 33 | assert_equal true, result.identical? 34 | end 35 | 36 | def test_breaking_returns_true_when_at_least_one_breaking_change 37 | result = GraphQL::SchemaComparator::Result.new([ 38 | GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 39 | ]) 40 | 41 | assert_equal true, result.breaking? 42 | end 43 | 44 | def test_breaking_returns_false_when_no_breaking_changes 45 | result = GraphQL::SchemaComparator::Result.new([ 46 | GraphQL::SchemaComparator::Changes::FieldAdded.new(Type, Type.fields["foo"]) 47 | ]) 48 | 49 | assert_equal false, result.breaking? 50 | end 51 | 52 | def test_breaking_changes 53 | enum_value_added = GraphQL::SchemaComparator::Changes::EnumValueAdded.new( 54 | EnumType, 55 | EnumType.values["foo"], 56 | GraphQL::SchemaComparator::EnumUsage.new(input: true, output: true) 57 | ) 58 | field_added = GraphQL::SchemaComparator::Changes::FieldAdded.new(Type, Type.fields["foo"]) 59 | field_removed = GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 60 | type_description_changed = GraphQL::SchemaComparator::Changes::TypeDescriptionChanged.new(Type, Type) 61 | 62 | result = GraphQL::SchemaComparator::Result.new([ 63 | enum_value_added, 64 | field_added, 65 | field_removed, 66 | type_description_changed 67 | ]) 68 | 69 | assert_equal [field_removed], result.breaking_changes 70 | end 71 | 72 | def test_dangerous_changes 73 | enum_value_added = GraphQL::SchemaComparator::Changes::EnumValueAdded.new( 74 | EnumType, 75 | EnumType.values["foo"], 76 | GraphQL::SchemaComparator::EnumUsage.new(input: false, output: true) 77 | ) 78 | field_added = GraphQL::SchemaComparator::Changes::FieldAdded.new(Type, Type.fields["foo"]) 79 | field_removed = GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 80 | type_description_changed = GraphQL::SchemaComparator::Changes::TypeDescriptionChanged.new(Type, Type) 81 | 82 | result = GraphQL::SchemaComparator::Result.new([ 83 | enum_value_added, 84 | field_added, 85 | field_removed, 86 | type_description_changed 87 | ]) 88 | 89 | assert_equal [enum_value_added], result.dangerous_changes 90 | end 91 | 92 | def test_non_breaking_changes 93 | enum_value_added = GraphQL::SchemaComparator::Changes::EnumValueAdded.new( 94 | EnumType, 95 | EnumType.values["foo"], 96 | GraphQL::SchemaComparator::EnumUsage.new(input: false, output: true) 97 | ) 98 | field_added = GraphQL::SchemaComparator::Changes::FieldAdded.new(Type, Type.fields["foo"]) 99 | field_removed = GraphQL::SchemaComparator::Changes::FieldRemoved.new(Type, Type.fields["foo"]) 100 | type_description_changed = GraphQL::SchemaComparator::Changes::TypeDescriptionChanged.new(Type, Type) 101 | 102 | result = GraphQL::SchemaComparator::Result.new([ 103 | enum_value_added, 104 | field_added, 105 | field_removed, 106 | type_description_changed 107 | ]) 108 | 109 | assert_equal [type_description_changed, field_added], result.non_breaking_changes 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator/version_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparator::VersionTest < Minitest::Test 4 | def test_has_version 5 | assert GraphQL::SchemaComparator::VERSION != nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/lib/graphql/schema_comparator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphQL::SchemaComparatorTest < Minitest::Test 4 | def setup 5 | @old_schema_idl = <<~SCHEMA 6 | schema { 7 | query: Query 8 | } 9 | type Query { 10 | a: String! 11 | } 12 | SCHEMA 13 | 14 | @new_schema_idl = <<~SCHEMA 15 | schema { 16 | query: Query 17 | } 18 | type Query { 19 | a: Int 20 | b: Int! 21 | } 22 | SCHEMA 23 | end 24 | 25 | def test_compare_handles_idls 26 | result = GraphQL::SchemaComparator.compare(@old_schema_idl, @new_schema_idl) 27 | 28 | assert_equal [ 29 | "Field `Query.a` changed type from `String!` to `Int`", 30 | "Field `b` was added to object type `Query`", 31 | "Type `Int` was added", 32 | ], result.changes.map(&:message) 33 | 34 | assert_equal true, result.breaking? 35 | end 36 | 37 | def test_compare_handles_schema_objects 38 | old_schema = GraphQL::Schema.from_definition(@old_schema_idl) 39 | new_schema = GraphQL::Schema.from_definition(@new_schema_idl) 40 | result = GraphQL::SchemaComparator.compare(old_schema, new_schema) 41 | 42 | assert_equal [ 43 | "Field `Query.a` changed type from `String!` to `Int`", 44 | "Field `b` was added to object type `Query`", 45 | "Type `Int` was added", 46 | ], result.changes.map(&:message) 47 | 48 | assert_equal true, result.breaking? 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'minitest/pride' 3 | require 'pry' 4 | require 'pry-byebug' 5 | 6 | require File.expand_path('../../lib/graphql/schema_comparator.rb', __FILE__) 7 | --------------------------------------------------------------------------------