├── tests
├── __init__.py
├── diff
│ ├── __init__.py
│ ├── test_union.py
│ ├── test_object_type.py
│ ├── test_enum.py
│ ├── test_interface.py
│ ├── test_argument.py
│ ├── test_schema.py
│ ├── test_input_object.py
│ ├── test_field.py
│ └── test_directive.py
├── data
│ ├── invalid_schema.gql
│ ├── simple_schema.gql
│ ├── simple_schema_breaking_changes.gql
│ ├── simple_schema_dangerous_and_breaking.gql
│ ├── simple_schema_dangerous_changes.gql
│ ├── simple_schema_rules_validation.gql
│ ├── simple_schema_rules_validation_new.gql
│ ├── allowlist.json
│ ├── old_schema.gql
│ └── new_schema.gql
├── test_allow_list.py
├── test_formatting.py
├── test_schema_loading.py
├── test_change_severity.py
├── test_schema_integration.py
├── test_changes_as_json.py
├── test_cli.py
└── test_validation_rules.py
├── schemadiff
├── diff
│ ├── __init__.py
│ ├── union_type.py
│ ├── interface.py
│ ├── argument.py
│ ├── enum.py
│ ├── input_object_type.py
│ ├── field.py
│ ├── object_type.py
│ ├── directive.py
│ └── schema.py
├── schema_loader.py
├── changes
│ ├── schema.py
│ ├── union.py
│ ├── object.py
│ ├── type.py
│ ├── argument.py
│ ├── enum.py
│ ├── field.py
│ ├── input.py
│ ├── interface.py
│ ├── __init__.py
│ └── directive.py
├── formatting.py
├── validation.py
├── __init__.py
├── allow_list.py
├── __main__.py
└── validation_rules
│ └── __init__.py
├── _config.yml
├── .flake8
├── requirements.txt
├── images
├── logo.png
├── usage.gif
└── logo.svg
├── requirements_dev.txt
├── Dockerfile
├── .travis.yml
├── Makefile
├── .github
└── workflows
│ └── pythonpublish.yml
├── pyproject.toml
├── .gitignore
├── README.md
└── docs
├── schema_loader.html
└── allow_list.html
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/diff/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/schemadiff/diff/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-minimal
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 120
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | . # Instruct setuptools to run 'python setup.py install'
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ambro17/graphql-schema-diff/HEAD/images/logo.png
--------------------------------------------------------------------------------
/images/usage.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ambro17/graphql-schema-diff/HEAD/images/usage.gif
--------------------------------------------------------------------------------
/tests/data/invalid_schema.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | a: InvalidType!
7 | }
8 |
--------------------------------------------------------------------------------
/requirements_dev.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | flake8
3 | pytest
4 | pytest-cov
5 | codecov
6 | pdoc3==0.9.1
7 | -e . # Install schemadiff in editable form for local development
8 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.7-slim
2 |
3 | ENV PYTHONUNBUFFERED 1
4 |
5 | RUN pip install graphql-schema-diff==1.0.2
6 |
7 | WORKDIR /app
8 |
9 | ENTRYPOINT ["schemadiff"]
10 |
--------------------------------------------------------------------------------
/tests/data/simple_schema.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | a: Int!
7 | b: Field
8 | }
9 |
10 | type Field {
11 | calculus(x: Int=0): Int!
12 | }
13 |
--------------------------------------------------------------------------------
/tests/data/simple_schema_breaking_changes.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | b: Field
7 | }
8 |
9 | type Field {
10 | calculus(x: Int=0): Int!
11 | }
12 |
--------------------------------------------------------------------------------
/tests/data/simple_schema_dangerous_and_breaking.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | b: Field
7 | c: Int
8 | }
9 |
10 | type Field {
11 | calculus(x: Int=1): Int!
12 | }
13 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "3.6"
4 | - "3.7"
5 | - "3.8"
6 | install:
7 | - pip install -r requirements_dev.txt
8 | script:
9 | - make test-coverage
10 | after_success:
11 | - codecov
12 |
--------------------------------------------------------------------------------
/tests/data/simple_schema_dangerous_changes.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | a: Int!
7 | b: Field
8 | c: Float
9 | }
10 |
11 | type Field {
12 | calculus(x: Int=100): Int!
13 | }
--------------------------------------------------------------------------------
/tests/data/simple_schema_rules_validation.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | a: Int!
7 | b: Field
8 | }
9 |
10 | """Some desc"""
11 | type Field {
12 | """Field desc"""
13 | calculus(x: Int=0): Int!
14 | }
15 |
16 | enum Enum {
17 | VALUE_1
18 | """Value 2 desc"""
19 | VALUE_2
20 | }
21 |
--------------------------------------------------------------------------------
/tests/data/simple_schema_rules_validation_new.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 |
5 | type Query {
6 | a: Int!
7 | b: Field
8 | c: NewTypeWithoutDesc
9 | }
10 |
11 | type Field {
12 | calculus(x: Int=0): Int!
13 | }
14 |
15 | type NewTypeWithoutDesc {
16 | data: Int
17 | }
18 |
19 | enum NewEnumWithoutDesc {
20 | VALUE
21 | }
22 |
23 | enum Enum {
24 | VALUE_1
25 | VALUE_2
26 | VALUE_3
27 | }
28 |
--------------------------------------------------------------------------------
/tests/data/allowlist.json:
--------------------------------------------------------------------------------
1 | {
2 | "5fba3d6ffc43c6769c6959ce5cb9b1c8": {
3 | "acceptance_reason": "It is correctly handled on FE already",
4 | "criticality": {
5 | "level": "DANGEROUS",
6 | "reason": "Changing the default value for an argument may change the runtime behaviour of a field if it was never provided."
7 | },
8 | "message": "Default value for argument `x` on field `Field.calculus` changed from `0` to `100`",
9 | "path": "Field.calculus"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/schemadiff/schema_loader.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema, GraphQLSchema
2 |
3 |
4 | class SchemaLoader:
5 | """Represents a GraphQL Schema loaded from a string or file."""
6 |
7 | @classmethod
8 | def from_sdl(cls, schema_string: str) -> GraphQLSchema:
9 | return build_schema(schema_string)
10 |
11 | @classmethod
12 | def from_file(cls, filepath: str) -> GraphQLSchema:
13 | with open(filepath, encoding='utf-8') as f:
14 | schema_string = f.read()
15 |
16 | return cls.from_sdl(schema_string)
17 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: test test-coverage test-coverage-html flake8 clean docs
2 |
3 | WORK_DIR=./schemadiff
4 |
5 | test:
6 | pytest
7 |
8 | test-coverage:
9 | pytest --cov $(WORK_DIR)
10 |
11 | test-coverage-html:
12 | pytest --cov $(WORK_DIR) --cov-report html
13 |
14 | flake8:
15 | flake8 --count --exit-zero $(WORK_DIR) --output-file flake8_issues.txt
16 |
17 | clean:
18 | find . -name "*.py[co]" -o -name __pycache__ -exec rm -rf {} +
19 |
20 | docs:
21 | pdoc3 schemadiff/ --html -o docs --force && \
22 | # Remove nested dir as github pages expects an index.html on docs folder \
23 | cp -r docs/schemadiff/* docs && rm -rf docs/schemadiff && echo "📚 Docs updated successfully ✨"
24 |
25 | publish:
26 | hatch build
27 | hatch publish
28 | rm -rf dist/ build/
29 |
30 |
31 |
--------------------------------------------------------------------------------
/schemadiff/diff/union_type.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.union import UnionMemberAdded, UnionMemberRemoved
2 |
3 |
4 | class UnionType:
5 |
6 | def __init__(self, old_type, new_type):
7 | self.type = new_type
8 | self.old_values = old_type.types
9 | self.new_values = new_type.types
10 |
11 | def diff(self):
12 | changes = []
13 |
14 | old_values = set(x.name for x in self.old_values)
15 | new_values = set(x.name for x in self.new_values)
16 |
17 | added = new_values - old_values
18 | removed = old_values - new_values
19 |
20 | changes.extend(UnionMemberAdded(self.type, value) for value in added)
21 | changes.extend(UnionMemberRemoved(self.type, value) for value in removed)
22 |
23 | return changes
--------------------------------------------------------------------------------
/.github/workflows/pythonpublish.yml:
--------------------------------------------------------------------------------
1 | # This workflows will upload a Python Package using Twine when a release is created
2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
3 |
4 | name: Upload Python Package
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | deploy:
12 |
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | - name: Set up Python
18 | uses: actions/setup-python@v1
19 | with:
20 | python-version: '3.x'
21 | - name: Install dependencies
22 | run: |
23 | python -m pip install --upgrade pip
24 | python -m pip install hatch
25 | - name: Build and publish
26 | env:
27 | HATCH_INDEX_USER: "__token__"
28 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_API_TOKEN }}
29 | run: |
30 | hatch build
31 | hatch publish
--------------------------------------------------------------------------------
/schemadiff/changes/schema.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | class SchemaChange(Change):
5 | criticality = Criticality.breaking('Changing a root type is a breaking change')
6 |
7 | def __init__(self, old_type, new_type):
8 | self.old_type = old_type
9 | self.new_type = new_type
10 |
11 | @property
12 | def path(self):
13 | return self.new_type
14 |
15 |
16 | class SchemaQueryTypeChanged(SchemaChange):
17 |
18 | @property
19 | def message(self):
20 | return f"Schema query root has changed from `{self.old_type}` to `{self.new_type}`"
21 |
22 |
23 | class SchemaMutationTypeChanged(SchemaChange):
24 |
25 | @property
26 | def message(self):
27 | return f"Schema mutation root has changed from `{self.old_type}` to `{self.new_type}`"
28 |
29 |
30 | class SchemaSubscriptionTypeChanged(SchemaChange):
31 |
32 | @property
33 | def message(self):
34 | return f"Schema subscription root has changed from `{self.old_type}` to `{self.new_type}`"
35 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "graphql-schema-diff"
7 | description = "Compare GraphQL Schemas"
8 | readme = "README.md"
9 | version = "1.2.5"
10 | authors = [
11 | { name = "Nahuel Ambrosini", email = "ambro17.1@gmail.com" }
12 | ]
13 | dependencies = [
14 | "graphql-core>=3.0.1",
15 | "attrs>=19.3.0",
16 | ]
17 | requires-python = ">=3.6"
18 | classifiers = [
19 | "Programming Language :: Python :: 3",
20 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
21 | "Operating System :: OS Independent",
22 | ]
23 | license = "GPL-3.0-or-later"
24 |
25 | [project.scripts]
26 | schemadiff = "schemadiff.__main__:cli"
27 |
28 | [project.urls]
29 | Homepage = "https://github.com/Ambro17/graphql-schema-diff"
30 | Documentation = "https://ambro17.github.io/graphql-schema-diff/"
31 |
32 | [project.optional-dependencies]
33 | dev = [
34 | "pytest",
35 | "flake8",
36 | "pytest",
37 | "pytest-cov",
38 | "codecov",
39 | "pdoc3==0.9.1",
40 | "hatch",
41 | ]
42 |
43 | [tool.hatch.build.targets.wheel]
44 | packages = ["schemadiff"]
45 |
--------------------------------------------------------------------------------
/schemadiff/diff/interface.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.interface import InterfaceFieldAdded, InterfaceFieldRemoved
2 | from schemadiff.diff.field import Field
3 |
4 |
5 | class InterfaceType:
6 |
7 | def __init__(self, old_interface, new_interface):
8 | self.old_face = old_interface
9 | self.new_face = new_interface
10 |
11 | self.old_fields = set(old_interface.fields)
12 | self.new_fields = set(new_interface.fields)
13 |
14 | def diff(self):
15 | changes = []
16 |
17 | added = self.new_fields - self.old_fields
18 | removed = self.old_fields - self.new_fields
19 | changes.extend(InterfaceFieldAdded(self.new_face, name, self.new_face.fields[name]) for name in added)
20 | changes.extend(InterfaceFieldRemoved(self.new_face, field_name) for field_name in removed)
21 |
22 | common = self.old_fields & self.new_fields
23 | for field_name in common:
24 | old_field = self.old_face.fields[field_name]
25 | new_field = self.new_face.fields[field_name]
26 |
27 | changes += Field(self.new_face, field_name, old_field, new_field).diff() or []
28 |
29 | return changes
30 |
--------------------------------------------------------------------------------
/tests/test_allow_list.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 |
5 | from schemadiff.allow_list import read_allowed_changes, InvalidAllowlist
6 |
7 |
8 | def test_read_from_invalid_json():
9 | with pytest.raises(InvalidAllowlist, match='Invalid json format provided.'):
10 | read_allowed_changes("")
11 |
12 |
13 | def test_allow_list_is_not_a_mapping():
14 | with pytest.raises(InvalidAllowlist, match='Allowlist must be a mapping.'):
15 | read_allowed_changes("[]")
16 |
17 |
18 | def test_allow_list_keys_are_not_change_checksums():
19 | with pytest.raises(InvalidAllowlist, match='All keys must be a valid md5 checksum'):
20 | read_allowed_changes(json.dumps(
21 | {
22 | '1e3b776bda2dd8b11804e7341bb8b2d1': 'md5 checksum key',
23 | 'bad key': 'some message'
24 | }
25 | ))
26 |
27 |
28 | def test_read_from_valid_json():
29 | original = """{
30 | "1e3b776bda2dd8b11804e7341bb8b2d1": "lorem",
31 | "1234776bda2dd8b11804e7341bb8b2d1": "ipsum"
32 | }"""
33 | assert read_allowed_changes(original) == json.loads(original) == {
34 | "1e3b776bda2dd8b11804e7341bb8b2d1": "lorem",
35 | "1234776bda2dd8b11804e7341bb8b2d1": "ipsum",
36 | }
--------------------------------------------------------------------------------
/schemadiff/changes/union.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | class UnionMemberAdded(Change):
5 |
6 | criticality = Criticality.dangerous(
7 | "Adding a possible type to Unions may break existing clients "
8 | "that were not programming defensively against a new possible type."
9 | )
10 |
11 | def __init__(self, union, value):
12 | self.union = union
13 | self.value = value
14 |
15 | @property
16 | def message(self):
17 | return f"Union member `{self.value}` was added to `{self.union.name}` Union type"
18 |
19 | @property
20 | def path(self):
21 | return f"{self.union.name}"
22 |
23 |
24 | class UnionMemberRemoved(Change):
25 |
26 | criticality = Criticality.breaking(
27 | 'Removing a union member from a union can break '
28 | 'queries that use this union member in a fragment spread'
29 | )
30 |
31 | def __init__(self, union, value):
32 | self.union = union
33 | self.value = value
34 |
35 | @property
36 | def message(self):
37 | return f"Union member `{self.value}` was removed from `{self.union.name}` Union type"
38 |
39 | @property
40 | def path(self):
41 | return f"{self.union.name}"
42 |
--------------------------------------------------------------------------------
/schemadiff/diff/argument.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.argument import (
2 | FieldArgumentDescriptionChanged,
3 | FieldArgumentTypeChanged,
4 | FieldArgumentDefaultValueChanged,
5 | )
6 |
7 |
8 | class Argument:
9 |
10 | def __init__(self, type_, field_name, argument_name, old_arg, new_arg):
11 | self.type_ = type_
12 | self.field_name = field_name
13 | self.argument_name = argument_name
14 | self.old_arg = old_arg
15 | self.new_arg = new_arg
16 |
17 | def diff(self):
18 | changes = []
19 | if self.old_arg.description != self.new_arg.description:
20 | changes.append(FieldArgumentDescriptionChanged(
21 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg
22 | ))
23 | if self.old_arg.default_value != self.new_arg.default_value:
24 | changes.append(FieldArgumentDefaultValueChanged(
25 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg
26 | ))
27 | if str(self.old_arg.type) != str(self.new_arg.type):
28 | changes.append(FieldArgumentTypeChanged(
29 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg
30 | ))
31 |
32 | return changes
33 |
--------------------------------------------------------------------------------
/tests/data/old_schema.gql:
--------------------------------------------------------------------------------
1 | directive @willBeRemoved on FIELD
2 |
3 | schema {
4 | query: Query
5 | }
6 | input AInput {
7 | """a"""
8 | a: String = "1"
9 | b: String!
10 | }
11 | """The Query Root of this schema"""
12 | type Query {
13 | """Just a simple string"""
14 | a(anArg: String): String!
15 | b: BType
16 | }
17 | type BType {
18 | a: String
19 | }
20 | type CType {
21 | a: String @deprecated(reason: "whynot")
22 | c: Int!
23 | d(arg: Int): String
24 | }
25 | union MyUnion = CType | BType
26 | interface AnInterface {
27 | interfaceField: Int!
28 | }
29 | interface AnotherInterface {
30 | anotherInterfaceField: String
31 | }
32 | type WithInterfaces implements AnInterface & AnotherInterface {
33 | a: String!
34 | }
35 | type WithArguments {
36 | a(
37 | """Meh"""
38 | a: Int
39 | b: String
40 | ): String
41 | b(arg: Int = 1): String
42 | }
43 | enum Options {
44 | A
45 | B
46 | C
47 | E
48 | F @deprecated(reason: "Old")
49 | }
50 |
51 | """Old"""
52 | directive @yolo(
53 | """Included when true."""
54 | someArg: Boolean!
55 |
56 | anotherArg: String!
57 |
58 | willBeRemoved: Boolean!
59 | ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
60 |
61 | type WillBeRemoved {
62 | a: String
63 | }
--------------------------------------------------------------------------------
/tests/data/new_schema.gql:
--------------------------------------------------------------------------------
1 | schema {
2 | query: Query
3 | }
4 | input AInput {
5 | # changed
6 | a: Int = 1
7 | c: String!
8 | }
9 | # Query Root description changed
10 | type Query {
11 | # This description has been changed
12 | a: String!
13 | b: Int!
14 | }
15 | input BType {
16 | a: String!
17 | }
18 | type CType implements AnInterface {
19 | a(arg: Int): String @deprecated(reason: "cuz")
20 | b: Int!
21 | d(arg: Int = 10): String
22 | }
23 | type DType {
24 | b: Int!
25 | }
26 | union MyUnion = CType | DType
27 | interface AnInterface {
28 | interfaceField: Int!
29 | }
30 | interface AnotherInterface {
31 | b: Int
32 | }
33 | type WithInterfaces implements AnInterface {
34 | a: String!
35 | }
36 | type WithArguments {
37 | a(
38 | # Description for a
39 | a: Int
40 | b: String!
41 | ): String
42 | b(arg: Int = 2): String
43 | }
44 | enum Options {
45 | # Stuff
46 | A
47 | B
48 | D
49 | E @deprecated
50 | F @deprecated(reason: "New")
51 | }
52 |
53 | # New
54 | directive @yolo(
55 | # someArg does stuff
56 | someArg: String!
57 |
58 | anotherArg: String! = "Test"
59 | ) on FIELD | FIELD_DEFINITION
60 |
61 | directive @yolo2(
62 | # Included when true.
63 | someArg: String!
64 | ) on FIELD
--------------------------------------------------------------------------------
/schemadiff/diff/enum.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.enum import (
2 | EnumValueAdded,
3 | EnumValueRemoved,
4 | EnumValueDescriptionChanged,
5 | EnumValueDeprecationReasonChanged,
6 | )
7 |
8 |
9 | class EnumDiff:
10 |
11 | def __init__(self, old_enum, new_enum):
12 | self.enum = new_enum
13 | self.old_values = old_enum.values
14 | self.new_values = new_enum.values
15 |
16 | def diff(self):
17 | changes = []
18 | old_values = set(self.old_values)
19 | new_values = set(self.new_values)
20 |
21 | added = new_values - old_values
22 | removed = old_values - new_values
23 | changes.extend(EnumValueAdded(self.enum, value) for value in added)
24 | changes.extend(EnumValueRemoved(self.enum, value) for value in removed)
25 |
26 | common = old_values & new_values
27 | for enum_name in common:
28 | old_value = self.old_values[enum_name]
29 | new_value = self.new_values[enum_name]
30 | if old_value.description != new_value.description:
31 | changes.append(EnumValueDescriptionChanged(self.enum, enum_name, old_value, new_value))
32 | if old_value.deprecation_reason != new_value.deprecation_reason:
33 | changes.append(EnumValueDeprecationReasonChanged(self.enum, enum_name, old_value, new_value))
34 |
35 | return changes
36 |
--------------------------------------------------------------------------------
/schemadiff/formatting.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from typing import List
4 |
5 | from schemadiff.changes import CriticalityLevel, Change
6 |
7 |
8 | def format_diff(changes: List[Change]) -> str:
9 | """Format a list of changes into a printable string"""
10 | changes = '\n'.join(
11 | format_change_by_criticality(change)
12 | for change in changes
13 | )
14 | return changes or '🎉 Both schemas are equal!'
15 |
16 |
17 | def format_change_by_criticality(change: Change) -> str:
18 | icon_by_criticality = {
19 | CriticalityLevel.Breaking: os.getenv('SD_BREAKING_CHANGE_ICON', '❌'),
20 | CriticalityLevel.Dangerous: os.getenv('SD_DANGEROUS_CHANGE_ICON', '⚠️'),
21 | CriticalityLevel.NonBreaking: os.getenv('SD_SAFE_CHANGE_ICON', '✔️'),
22 | }
23 | icon = icon_by_criticality[change.criticality.level]
24 | if change.restricted is not None:
25 | return f"⛔ {change.restricted}"
26 | return f"{icon} {change.message}"
27 |
28 |
29 | def print_diff(changes: List[Change]) -> None:
30 | """Pretty print a list of changes"""
31 | print(format_diff(changes))
32 |
33 |
34 | def changes_to_dict(changes: List[Change]) -> List[dict]:
35 | return [
36 | change.to_dict()
37 | for change in changes
38 | ]
39 |
40 |
41 | def json_dump_changes(changes: List[Change]) -> str:
42 | return json.dumps(changes_to_dict(changes), indent=4)
43 |
44 |
45 | def print_json(changes: List[Change]) -> None:
46 | print(json_dump_changes(changes))
47 |
--------------------------------------------------------------------------------
/schemadiff/diff/input_object_type.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.input import (
2 | InputFieldAdded,
3 | InputFieldRemoved,
4 | InputFieldDescriptionChanged,
5 | InputFieldDefaultChanged,
6 | InputFieldTypeChanged,
7 | )
8 |
9 |
10 | class InputObjectType:
11 | def __init__(self, old_type, new_type):
12 | self.type = new_type
13 | self.old_fields = old_type.fields
14 | self.new_fields = new_type.fields
15 |
16 | def diff(self):
17 | changes = []
18 |
19 | old_field_names = set(self.old_fields)
20 | new_field_names = set(self.new_fields)
21 |
22 | added = new_field_names - old_field_names
23 | removed = old_field_names - new_field_names
24 |
25 | changes.extend(InputFieldAdded(self.type, field_name, self.new_fields[field_name]) for field_name in added)
26 | changes.extend(InputFieldRemoved(self.type, field_name) for field_name in removed)
27 |
28 | common_types = old_field_names & new_field_names
29 | for type_name in common_types:
30 | old = self.old_fields[type_name]
31 | new = self.new_fields[type_name]
32 | if str(old.type) != str(new.type):
33 | changes.append(InputFieldTypeChanged(self.type, type_name, new, old))
34 | if old.description != new.description:
35 | changes.append(InputFieldDescriptionChanged(self.type, type_name, new, old))
36 | if old.default_value != new.default_value:
37 | changes.append(InputFieldDefaultChanged(self.type, type_name, new, old))
38 |
39 | return changes
40 |
--------------------------------------------------------------------------------
/schemadiff/validation.py:
--------------------------------------------------------------------------------
1 | from schemadiff import Change
2 | from typing import List, Dict, Any
3 |
4 | from dataclasses import dataclass
5 |
6 | from schemadiff.validation_rules import ValidationRule
7 |
8 |
9 | @dataclass
10 | class ValidationResult:
11 | ok: bool
12 | errors: List['ValidationError']
13 |
14 |
15 | @dataclass
16 | class ValidationError:
17 | rule: str
18 | reason: str
19 | change: Change
20 |
21 |
22 | def validate_changes(diff: List[Change], rules: List[str], allowed_changes: Dict[str, Any] = None) -> ValidationResult:
23 | """Given a list of changes between schemas and a list of rules,
24 | it runs all rules against the changes, to detect invalid changes.
25 | It also admits an allowlist of accepted invalid changes to document exceptions to the rules
26 |
27 | Returns:
28 | bool: True if there is at least one restricted change,
29 | False otherwise.
30 | """
31 | allowed_changes = allowed_changes or {}
32 | is_valid = True
33 | errors = []
34 | rules = ValidationRule.get_subclasses_by_names(rules)
35 | for change in diff:
36 | for rule in rules:
37 | if not rule(change).is_valid():
38 | if change.checksum() in allowed_changes:
39 | continue
40 |
41 | change.restricted = rule(change).message
42 | is_valid = False
43 | errors.append(ValidationError(rule.name, change.restricted, change))
44 |
45 | return ValidationResult(is_valid, errors)
46 |
47 |
48 | def rules_list():
49 | return ValidationRule.get_rules_list()
50 |
--------------------------------------------------------------------------------
/schemadiff/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Union, List
2 |
3 | from graphql import GraphQLSchema as GQLSchema, is_schema
4 |
5 | from schemadiff.changes import Change
6 | from schemadiff.diff.schema import Schema
7 | from schemadiff.schema_loader import SchemaLoader
8 | from schemadiff.formatting import print_diff, format_diff
9 | from schemadiff.validation import validate_changes
10 |
11 |
12 | SDL = str # Alias for string describing schema through schema definition language
13 |
14 |
15 | def diff(old_schema: Union[SDL, GQLSchema], new_schema: Union[SDL, GQLSchema]) -> List[Change]:
16 | """Compare two graphql schemas highlighting dangerous and breaking changes.
17 |
18 | Returns:
19 | changes (List[Change]): List of differences between both schemas with details about each change
20 | """
21 | first = SchemaLoader.from_sdl(old_schema) if not is_schema(old_schema) else old_schema
22 | second = SchemaLoader.from_sdl(new_schema) if not is_schema(new_schema) else new_schema
23 | return Schema(first, second).diff()
24 |
25 |
26 | def diff_from_file(schema_file: str, other_schema_file: str):
27 | """Compare two graphql schema files highlighting dangerous and breaking changes.
28 |
29 | Returns:
30 | changes (List[Change]): List of differences between both schemas with details about each change
31 | """
32 | first = SchemaLoader.from_file(schema_file)
33 | second = SchemaLoader.from_file(other_schema_file)
34 | return Schema(first, second).diff()
35 |
36 |
37 | __all__ = [
38 | 'diff',
39 | 'diff_from_file',
40 | 'format_diff',
41 | 'print_diff',
42 | 'validate_changes',
43 | 'Change',
44 | ]
45 |
--------------------------------------------------------------------------------
/schemadiff/changes/object.py:
--------------------------------------------------------------------------------
1 | from graphql import GraphQLField, GraphQLObjectType
2 |
3 | from schemadiff.changes import Change, Criticality
4 |
5 |
6 | class ObjectTypeFieldAdded(Change):
7 |
8 | criticality = Criticality.safe()
9 |
10 | def __init__(self, parent: GraphQLObjectType, field_name, field: GraphQLField):
11 | self.parent = parent
12 | self.field_name = field_name
13 | self.field = field
14 | self.description = parent.fields[field_name].description
15 |
16 | @property
17 | def message(self):
18 | return f"Field `{self.field_name}` was added to object type `{self.parent.name}`"
19 |
20 | @property
21 | def path(self):
22 | return f"{self.parent.name}.{self.field_name}"
23 |
24 |
25 | class ObjectTypeFieldRemoved(Change):
26 |
27 | def __init__(self, parent, field_name, field):
28 | self.parent = parent
29 | self.field_name = field_name
30 | self.field = field
31 | self.criticality = (
32 | Criticality.dangerous(
33 | "Removing deprecated fields without sufficient time for clients "
34 | "to update their queries may break their code"
35 | ) if field.deprecation_reason else Criticality.breaking(
36 | "Removing a field is a breaking change. It is preferred to deprecate the field before removing it."
37 | )
38 | )
39 |
40 | @property
41 | def message(self):
42 | return f"Field `{self.field_name}` was removed from object type `{self.parent.name}`"
43 |
44 | @property
45 | def path(self):
46 | return f"{self.parent.name}.{self.field_name}"
47 |
--------------------------------------------------------------------------------
/tests/test_formatting.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from schemadiff.diff.schema import Schema
4 | from schemadiff.schema_loader import SchemaLoader
5 | from schemadiff.formatting import print_diff, print_json
6 | from tests.test_schema_loading import TESTS_DATA
7 |
8 |
9 | def test_print_diff_shows_difference(capsys):
10 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql')
11 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql')
12 |
13 | diff = Schema(old_schema, new_schema).diff()
14 | assert len(diff) == 1
15 | ret = print_diff(diff)
16 | assert ret is None
17 | assert capsys.readouterr().out == (
18 | '❌ Field `a` was removed from object type `Query`\n'
19 | )
20 |
21 |
22 | def test_print_json_shows_difference(capsys):
23 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql')
24 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql')
25 |
26 | diff = Schema(old_schema, new_schema).diff()
27 | assert len(diff) == 1
28 | ret = print_json(diff)
29 | assert ret is None
30 | json_output = capsys.readouterr().out
31 | output_as_dict = json.loads(json_output)
32 | change_message = "Field `a` was removed from object type `Query`"
33 | assert output_as_dict == [{
34 | "message": change_message,
35 | "path": "Query.a",
36 | "is_safe_change": False,
37 | "criticality": {
38 | "level": "BREAKING",
39 | "reason": "Removing a field is a breaking change. "
40 | "It is preferred to deprecate the field before removing it."
41 | },
42 | "checksum": '5fba3d6ffc43c6769c6959ce5cb9b1c8'
43 | }]
44 |
--------------------------------------------------------------------------------
/schemadiff/allow_list.py:
--------------------------------------------------------------------------------
1 | import json
2 | import re
3 | from json import JSONDecodeError
4 |
5 |
6 | class InvalidAllowlist(Exception):
7 | """Exception raised when the user provides an invalid json file of allowed changes"""
8 |
9 |
10 | def read_allowed_changes(file_content):
11 | """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
12 |
13 | Compliant formats:
14 | {
15 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it',
16 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant',
17 | }
18 | But the value can be as detailed as the user wants (as long as the key remains the change checksum)
19 | {
20 | '0cc175b9c0f1b6a831c399e269772661': {
21 | 'date': '2030-01-01 15:00:00,
22 | 'reason': 'my reason',
23 | 'message': 'Field `a` was removed from object type `Query`'
24 | },
25 | '92eb5ffee6ae2fec3ad71c777531578f': {
26 | 'date': '2031-01-01 23:59:00,
27 | 'reason': 'my new reason',
28 | 'message': 'Field `b` was removed from object type `MyType`'
29 | },
30 | }
31 | """
32 | try:
33 | allowlist = json.loads(file_content)
34 | except JSONDecodeError as e:
35 | raise InvalidAllowlist("Invalid json format provided.") from e
36 | if not isinstance(allowlist, dict):
37 | raise InvalidAllowlist("Allowlist must be a mapping.")
38 |
39 | CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}')
40 | if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()):
41 | raise InvalidAllowlist("All keys must be a valid md5 checksum")
42 |
43 | return allowlist
44 |
--------------------------------------------------------------------------------
/tests/diff/test_union.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_add_type_to_union():
8 | two_types = schema("""
9 | type Query {
10 | c: Int
11 | }
12 | type Result {
13 | message: String
14 | }
15 | type Error {
16 | message: String
17 | details: String
18 | }
19 | type Unknown {
20 | message: String
21 | details: String
22 | traceback: String
23 | }
24 |
25 | union Outcome = Result | Error
26 | """)
27 |
28 | three_types = schema("""
29 | type Query {
30 | c: Int
31 | }
32 | type Result {
33 | message: String
34 | }
35 | type Error {
36 | message: String
37 | details: String
38 | }
39 | type Unknown {
40 | message: String
41 | details: String
42 | traceback: String
43 | }
44 |
45 | union Outcome = Result | Error | Unknown
46 | """)
47 | diff = Schema(two_types, three_types).diff()
48 | assert diff and len(diff) == 1
49 | assert diff[0].message == "Union member `Unknown` was added to `Outcome` Union type"
50 | assert diff[0].path == 'Outcome'
51 | assert diff[0].criticality == Criticality.dangerous(
52 | 'Adding a possible type to Unions may break existing clients '
53 | 'that were not programming defensively against a new possible type.'
54 | )
55 |
56 | diff = Schema(three_types, two_types).diff()
57 | assert diff and len(diff) == 1
58 | assert diff[0].message == "Union member `Unknown` was removed from `Outcome` Union type"
59 | assert diff[0].path == 'Outcome'
60 | assert diff[0].criticality == Criticality.breaking(
61 | 'Removing a union member from a union can break queries that use this union member in a fragment spread'
62 | )
63 |
--------------------------------------------------------------------------------
/schemadiff/diff/field.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.field import (
2 | FieldDescriptionChanged,
3 | FieldDeprecationReasonChanged,
4 | FieldTypeChanged,
5 | FieldArgumentAdded,
6 | FieldArgumentRemoved
7 | )
8 | from schemadiff.diff.argument import Argument
9 |
10 |
11 | class Field:
12 |
13 | def __init__(self, parent, name, old_field, new_field):
14 | self.parent = parent
15 | self.field_name = name
16 | self.old_field = old_field
17 | self.new_field = new_field
18 | self.old_args = set(old_field.args)
19 | self.new_args = set(new_field.args)
20 |
21 | def diff(self):
22 | changes = []
23 |
24 | if self.old_field.description != self.new_field.description:
25 | changes.append(FieldDescriptionChanged(self.parent, self.field_name, self.old_field, self.new_field))
26 |
27 | if self.old_field.deprecation_reason != self.new_field.deprecation_reason:
28 | changes.append(FieldDeprecationReasonChanged(self.parent, self.field_name, self.old_field, self.new_field))
29 |
30 | if str(self.old_field.type) != str(self.new_field.type):
31 | changes.append(FieldTypeChanged(self.parent, self.field_name, self.old_field, self.new_field))
32 |
33 | added = self.new_args - self.old_args
34 | removed = self.old_args - self.new_args
35 |
36 | changes.extend(
37 | FieldArgumentAdded(self.parent, self.field_name, self.new_field, arg_name, self.new_field.args[arg_name])
38 | for arg_name in added
39 | )
40 | changes.extend(
41 | FieldArgumentRemoved(self.parent, self.field_name, arg_name)
42 | for arg_name in removed
43 | )
44 |
45 | common_arguments = self.common_arguments()
46 | for arg_name in common_arguments:
47 | old_arg = self.old_field.args[arg_name]
48 | new_arg = self.new_field.args[arg_name]
49 | changes += Argument(self.parent, self.field_name, arg_name, old_arg, new_arg).diff() or []
50 |
51 | return changes
52 |
53 | def common_arguments(self):
54 | return self.old_args & self.new_args
55 |
--------------------------------------------------------------------------------
/tests/test_schema_loading.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import pytest
4 | from graphql import is_schema, GraphQLSyntaxError
5 |
6 | from schemadiff import diff, diff_from_file
7 | from schemadiff.schema_loader import SchemaLoader
8 |
9 | TESTS_DATA = Path(__file__).parent / 'data'
10 |
11 |
12 | def test_load_from_file():
13 | schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql')
14 | assert is_schema(schema)
15 | assert len(schema.query_type.fields) == 2
16 |
17 |
18 | def test_load_from_string():
19 | schema_string = """
20 | schema {
21 | query: Query
22 | }
23 |
24 | type Query {
25 | a: ID!
26 | b: MyType
27 | }
28 |
29 | type MyType {
30 | c: String
31 | d: Float
32 | }
33 | """
34 | schema = SchemaLoader.from_sdl(schema_string)
35 | assert is_schema(schema)
36 | assert len(schema.query_type.fields) == 2
37 |
38 |
39 | def test_diff_from_str():
40 | schema_str = """
41 | schema {
42 | query: Query
43 | }
44 | type Query {
45 | a: ID!
46 | b: Int
47 | }
48 | """
49 | changes = diff(schema_str, schema_str)
50 | assert changes == []
51 |
52 |
53 | def test_diff_from_schema():
54 | schema_object = SchemaLoader.from_sdl("""
55 | schema {
56 | query: Query
57 | }
58 | type Query {
59 | a: ID!
60 | b: Int
61 | }
62 | """)
63 | assert is_schema(schema_object)
64 | changes = diff(schema_object, schema_object)
65 | assert changes == []
66 |
67 |
68 | def test_diff_from_file():
69 | changes = diff_from_file(TESTS_DATA / 'simple_schema.gql', TESTS_DATA / 'simple_schema_dangerous_changes.gql')
70 | assert changes
71 | assert len(changes) == 2
72 |
73 |
74 | def test_load_invalid_schema():
75 | with pytest.raises(TypeError, match="Unknown type 'InvalidType'"):
76 | SchemaLoader.from_file(TESTS_DATA / 'invalid_schema.gql')
77 |
78 |
79 | @pytest.mark.parametrize("schema", ["", "{}", "\n{}\n", "[]"])
80 | def test_load_empty_schema(schema):
81 | with pytest.raises(GraphQLSyntaxError):
82 | SchemaLoader.from_sdl(schema)
83 |
--------------------------------------------------------------------------------
/schemadiff/changes/type.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | class RemovedType(Change):
5 |
6 | criticality = Criticality.breaking(
7 | "Removing a type is a breaking change. "
8 | "It is preferred to deprecate and remove all references to this type first."
9 | )
10 |
11 | def __init__(self, type_):
12 | self.type_ = type_
13 |
14 | @property
15 | def message(self):
16 | return f"Type `{self.type_.name}` was removed"
17 |
18 | @property
19 | def path(self):
20 | return f'{self.type_.name}'
21 |
22 |
23 | class AddedType(Change):
24 |
25 | criticality = Criticality.safe()
26 |
27 | def __init__(self, added_type):
28 | self.type = added_type
29 |
30 | @property
31 | def message(self):
32 | return f"Type `{self.type.name}` was added"
33 |
34 | @property
35 | def path(self):
36 | return f'{self.type.name}'
37 |
38 |
39 | class TypeDescriptionChanged(Change):
40 |
41 | criticality = Criticality.safe()
42 |
43 | def __init__(self, type_name, old_desc, new_desc):
44 | self.type = type_name
45 | self.old_desc = old_desc
46 | self.new_desc = new_desc
47 |
48 | @property
49 | def message(self):
50 | return (
51 | f"Description for type `{self.type}` changed from "
52 | f"`{self.old_desc}` to `{self.new_desc}`"
53 | )
54 |
55 | @property
56 | def path(self):
57 | return self.type
58 |
59 |
60 | class TypeKindChanged(Change):
61 | criticality = Criticality.breaking(
62 | "Changing the kind of a type is a breaking change because "
63 | "it can cause existing queries to error. "
64 | "For example, turning an object type to a scalar type "
65 | "would break queries that define a selection set for this type."
66 | )
67 |
68 | def __init__(self, type_, old_kind, new_kind):
69 | self.type_ = type_
70 | self.old_kind = old_kind
71 | self.new_kind = new_kind
72 |
73 | @property
74 | def message(self):
75 | return f"`{self.type_}` kind changed from `{self.old_kind.value.upper()}` to `{self.new_kind.value.upper()}`"
76 |
77 | @property
78 | def path(self):
79 | return f"{self.type_}"
80 |
--------------------------------------------------------------------------------
/schemadiff/changes/argument.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value
2 |
3 |
4 | class FieldAbstractArgumentChange(Change):
5 | def __init__(self, parent_type, field, arg_name, old_arg, new_arg):
6 | self.parent = parent_type
7 | self.field_name = field
8 | self.arg_name = arg_name
9 | self.old_arg = old_arg
10 | self.new_arg = new_arg
11 |
12 | @property
13 | def path(self):
14 | return f"{self.parent.name}.{self.field_name}"
15 |
16 |
17 | class FieldArgumentDescriptionChanged(FieldAbstractArgumentChange):
18 |
19 | criticality = Criticality.safe()
20 |
21 | @property
22 | def message(self):
23 | return (
24 | f"Description for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` "
25 | f"changed from `{self.old_arg.description}` to `{self.new_arg.description}`"
26 | )
27 |
28 |
29 | class FieldArgumentDefaultValueChanged(FieldAbstractArgumentChange):
30 | criticality = Criticality.dangerous(
31 | "Changing the default value for an argument may change the runtime "
32 | "behaviour of a field if it was never provided."
33 | )
34 |
35 | @property
36 | def message(self):
37 | return (
38 | f"Default value for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` "
39 | f"changed from `{self.old_arg.default_value!r}` to `{self.new_arg.default_value!r}`"
40 | )
41 |
42 |
43 | class FieldArgumentTypeChanged(FieldAbstractArgumentChange):
44 |
45 | def __init__(self, parent_type, field, arg_name, old_arg, new_arg):
46 | super().__init__(parent_type, field, arg_name, old_arg, new_arg)
47 | self.criticality = (
48 | Criticality.safe()
49 | if is_safe_change_for_input_value(old_arg.type, new_arg.type)
50 | else Criticality.breaking(
51 | "Changing the type of a field's argument can break existing queries that use this argument."
52 | )
53 | )
54 |
55 | @property
56 | def message(self):
57 | return (
58 | f"Type for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` "
59 | f"changed from `{self.old_arg.type}` to `{self.new_arg.type}`"
60 | )
61 |
--------------------------------------------------------------------------------
/tests/diff/test_object_type.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_object_type_added_field():
8 | a = schema("""
9 | type MyType{
10 | a: Int
11 | }
12 | """)
13 | b = schema("""
14 | type MyType{
15 | a: Int
16 | b: String!
17 | }
18 | """)
19 | diff = Schema(a, b).diff()
20 | assert diff and len(diff) == 1
21 | assert diff[0].message == "Field `b` was added to object type `MyType`"
22 | assert diff[0].path == 'MyType.b'
23 | assert diff[0].criticality == Criticality.safe()
24 |
25 | diff = Schema(b, a).diff()
26 | assert diff and len(diff) == 1
27 | assert diff[0].message == "Field `b` was removed from object type `MyType`"
28 | assert diff[0].path == 'MyType.b'
29 | assert diff[0].criticality == Criticality.breaking(
30 | 'Removing a field is a breaking change. It is preferred to deprecate the field before removing it.'
31 | )
32 |
33 |
34 | def test_object_type_description_changed():
35 | a = schema('''
36 | """docstring"""
37 | type MyType{
38 | a: Int
39 | }
40 | ''')
41 | b = schema('''
42 | """my new docstring"""
43 | type MyType{
44 | a: Int
45 | }
46 | ''')
47 | diff = Schema(a, b).diff()
48 | assert diff and len(diff) == 1
49 | assert diff[0].message == "Description for type `MyType` changed from `docstring` to `my new docstring`"
50 | assert diff[0].path == 'MyType'
51 | assert diff[0].criticality == Criticality.safe()
52 |
53 |
54 | def test_type_kind_change():
55 | atype = schema('''
56 | type MyType{
57 | a: Int
58 | }
59 | ''')
60 | input_type = schema('''
61 | input MyType{
62 | a: Int
63 | }
64 | ''')
65 | diff = Schema(atype, input_type).diff()
66 | assert diff and len(diff) == 1
67 | assert diff[0].message == "`MyType` kind changed from `OBJECT` to `INPUT OBJECT`"
68 | assert diff[0].path == 'MyType'
69 | assert diff[0].criticality == Criticality.breaking(
70 | 'Changing the kind of a type is a breaking change because it can '
71 | 'cause existing queries to error. For example, turning an object '
72 | 'type to a scalar type would break queries that define a selection set for this type.'
73 | )
74 |
--------------------------------------------------------------------------------
/schemadiff/diff/object_type.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.object import ObjectTypeFieldAdded, ObjectTypeFieldRemoved
2 | from schemadiff.changes.interface import NewInterfaceImplemented, DroppedInterfaceImplementation
3 | from schemadiff.diff.field import Field
4 |
5 |
6 | class ObjectType:
7 |
8 | def __init__(self, old, new):
9 | self.old = old
10 | self.new = new
11 |
12 | self.old_field_names = set(old.fields)
13 | self.new_field_names = set(new.fields)
14 |
15 | self.old_interfaces = set(old.interfaces)
16 | self.new_interfaces = set(new.interfaces)
17 |
18 | def diff(self):
19 | changes = []
20 |
21 | # Added and removed fields
22 | added = self.new_field_names - self.old_field_names
23 | removed = self.old_field_names - self.new_field_names
24 | changes.extend(ObjectTypeFieldAdded(self.new, field_name, self.new.fields[field_name]) for field_name in added)
25 | changes.extend(ObjectTypeFieldRemoved(self.new, field_name, self.old.fields[field_name])
26 | for field_name in removed)
27 |
28 | # Added and removed interfaces
29 | added = self.added_interfaces()
30 | removed = self.removed_interfaces()
31 | changes.extend(NewInterfaceImplemented(interface, self.new) for interface in added)
32 | changes.extend(DroppedInterfaceImplementation(interface, self.new) for interface in removed)
33 |
34 | for field_name in self.common_fields():
35 | old_field = self.old.fields[field_name]
36 | new_field = self.new.fields[field_name]
37 | changes += Field(self.new, field_name, old_field, new_field).diff() or []
38 |
39 | return changes
40 |
41 | def common_fields(self):
42 | return self.old_field_names & self.new_field_names
43 |
44 | def added_interfaces(self):
45 | """Compare interfaces equality by name. Internal diffs are solved later"""
46 | old_interface_names = {str(x) for x in self.old_interfaces}
47 | return [interface for interface in self.new_interfaces
48 | if str(interface) not in old_interface_names]
49 |
50 | def removed_interfaces(self):
51 | """Compare interfaces equality by name. Internal diffs are solved later"""
52 | new_interface_names = {str(x) for x in self.new_interfaces}
53 | return [interface for interface in self.old_interfaces
54 | if str(interface) not in new_interface_names]
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | backlog
2 | .idea/
3 | .pytest_cache/
4 | .vscode/
5 |
6 | # Byte-compiled / optimized / DLL files
7 | __pycache__/
8 | *.py[cod]
9 | *$py.class
10 |
11 | # C extensions
12 | *.so
13 |
14 | # Distribution / packaging
15 | .Python
16 | build/
17 | develop-eggs/
18 | dist/
19 | downloads/
20 | eggs/
21 | .eggs/
22 | lib/
23 | lib64/
24 | parts/
25 | sdist/
26 | var/
27 | wheels/
28 | pip-wheel-metadata/
29 | share/python-wheels/
30 | *.egg-info/
31 | .installed.cfg
32 | *.egg
33 | MANIFEST
34 |
35 | # PyInstaller
36 | # Usually these files are written by a python script from a template
37 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
38 | *.manifest
39 | *.spec
40 |
41 | # Installer logs
42 | pip-log.txt
43 | pip-delete-this-directory.txt
44 |
45 | # Unit test / coverage reports
46 | htmlcov/
47 | .tox/
48 | .nox/
49 | .coverage
50 | .coverage.*
51 | .cache
52 | nosetests.xml
53 | coverage.xml
54 | *.cover
55 | *.py,cover
56 | .hypothesis/
57 | .pytest_cache/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | .python-version
91 |
92 | # pipenv
93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
96 | # install all needed dependencies.
97 | #Pipfile.lock
98 |
99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100 | __pypackages__/
101 |
102 | # Celery stuff
103 | celerybeat-schedule
104 | celerybeat.pid
105 |
106 | # SageMath parsed files
107 | *.sage.py
108 |
109 | # Environments
110 | .env
111 | .venv
112 | env/
113 | venv/
114 | ENV/
115 | env.bak/
116 | venv.bak/
117 |
118 | # Spyder project settings
119 | .spyderproject
120 | .spyproject
121 |
122 | # Rope project settings
123 | .ropeproject
124 |
125 | # mkdocs documentation
126 | /site
127 |
128 | # mypy
129 | .mypy_cache/
130 | .dmypy.json
131 | dmypy.json
132 |
133 | # Pyre type checker
134 | .pyre/
135 |
--------------------------------------------------------------------------------
/tests/test_change_severity.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | def test_safe_change():
5 | class NonBreakingChange(Change):
6 | criticality = Criticality.safe('this is a safe change')
7 |
8 | def message(self):
9 | return 'test message'
10 |
11 | def path(self):
12 | return 'test path'
13 |
14 | c = NonBreakingChange()
15 | assert c.breaking is False
16 | assert c.dangerous is False
17 | assert c.safe is True
18 | assert c.criticality.reason == 'this is a safe change'
19 |
20 |
21 | def test_breaking_change():
22 | class BreakingChange(Change):
23 | criticality = Criticality.breaking('this is breaking')
24 |
25 | def message(self):
26 | return 'test message'
27 |
28 | def path(self):
29 | return 'test path'
30 |
31 | c = BreakingChange()
32 | assert c.breaking is True
33 | assert c.dangerous is False
34 | assert c.safe is False
35 | assert c.criticality.reason == 'this is breaking'
36 |
37 |
38 | def test_dangerous_change():
39 | class DangerousChange(Change):
40 | criticality = Criticality.dangerous('this is dangerous')
41 |
42 | def message(self):
43 | return 'test message'
44 |
45 | def path(self):
46 | return 'test path'
47 |
48 | c = DangerousChange()
49 | assert c.breaking is False
50 | assert c.dangerous is True
51 | assert c.safe is False
52 | assert c.criticality.reason == 'this is dangerous'
53 |
54 |
55 | def test_change_str_method_shows_change_message():
56 | class Testchange(Change):
57 | criticality = Criticality.safe('this is a safe change')
58 |
59 | @property
60 | def message(self):
61 | return 'test message'
62 |
63 | def path(self):
64 | return 'Query.path'
65 |
66 | change = Testchange()
67 | assert str(change) == 'test message'
68 |
69 |
70 | def test_change_repr_simulates_class_instantiation():
71 | class Testchange(Change):
72 | criticality = Criticality.safe('this is a safe change')
73 |
74 | @property
75 | def message(self):
76 | return 'test message'
77 |
78 | @property
79 | def path(self):
80 | return 'Query.path'
81 |
82 | change = Testchange()
83 | print(repr(change))
84 | assert repr(change) == (
85 | "Change("
86 | "criticality=Criticality(level=CriticalityLevel.NonBreaking, reason=this is a safe change), "
87 | "message='test message', "
88 | "path='Query.path')"
89 | )
90 |
--------------------------------------------------------------------------------
/schemadiff/diff/directive.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes.directive import (
2 | DirectiveDescriptionChanged,
3 | DirectiveLocationsChanged,
4 | DirectiveArgumentAdded,
5 | DirectiveArgumentRemoved,
6 | DirectiveArgumentTypeChanged,
7 | DirectiveArgumentDefaultChanged,
8 | DirectiveArgumentDescriptionChanged,
9 | )
10 |
11 |
12 | class Directive:
13 | def __init__(self, old_directive, new_directive):
14 | self.old_directive = old_directive
15 | self.new_directive = new_directive
16 | self.old_arguments = set(old_directive.args)
17 | self.new_arguments = set(new_directive.args)
18 |
19 | def diff(self):
20 | changes = []
21 | if self.old_directive.description != self.new_directive.description:
22 | changes.append(DirectiveDescriptionChanged(self.old_directive, self.new_directive))
23 | if self.old_directive.locations != self.new_directive.locations:
24 | changes.append(DirectiveLocationsChanged(
25 | self.new_directive, self.old_directive.locations, self.new_directive.locations
26 | ))
27 |
28 | removed = self.old_arguments - self.new_arguments
29 | added = self.new_arguments - self.old_arguments
30 | changes.extend(DirectiveArgumentAdded(self.new_directive, argument_name, self.new_directive.args[argument_name])
31 | for argument_name in added)
32 | changes.extend(DirectiveArgumentRemoved(self.new_directive, argument_name, self.old_directive.args[argument_name])
33 | for argument_name in removed)
34 |
35 | for arg_name in self.old_arguments & self.new_arguments:
36 | old_arg = self.old_directive.args[arg_name]
37 | new_arg = self.new_directive.args[arg_name]
38 | changes += DirectiveArgument(self.new_directive, arg_name, old_arg, new_arg).diff()
39 |
40 | return changes
41 |
42 |
43 | class DirectiveArgument:
44 | def __init__(self, directive, arg_name, old_arg, new_arg):
45 | self.directive = directive
46 | self.arg_name = arg_name
47 | self.old_arg = old_arg
48 | self.new_arg = new_arg
49 |
50 | def diff(self):
51 | changes = []
52 | if str(self.old_arg.type) != str(self.new_arg.type):
53 | changes.append(DirectiveArgumentTypeChanged(
54 | self.directive, self.arg_name, self.old_arg.type, self.new_arg.type
55 | ))
56 | if self.old_arg.default_value != self.new_arg.default_value:
57 | changes.append(DirectiveArgumentDefaultChanged(
58 | self.directive, self.arg_name, self.old_arg.default_value, self.new_arg.default_value
59 | ))
60 | if self.old_arg.description != self.new_arg.description:
61 | changes.append(DirectiveArgumentDescriptionChanged(
62 | self.directive, self.arg_name, self.old_arg.description, self.new_arg.description
63 | ))
64 |
65 | return changes
66 |
--------------------------------------------------------------------------------
/schemadiff/changes/enum.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | class EnumValueAdded(Change):
5 | def __init__(self, enum, value):
6 | self.criticality = Criticality.dangerous(
7 | "Adding an enum value may break existing clients that were not "
8 | "programming defensively against an added case when querying an enum."
9 | )
10 | self.enum = enum
11 | self.value = value
12 | self.description = enum.values[value].description
13 |
14 | @property
15 | def message(self):
16 | return f"Enum value `{self.value}` was added to `{self.enum.name}` enum"
17 |
18 | @property
19 | def path(self):
20 | return f"{self.enum.name}.{self.value}"
21 |
22 |
23 | class EnumValueRemoved(Change):
24 | def __init__(self, enum, value):
25 | self.criticality = Criticality.breaking(
26 | "Removing an enum value will break existing queries that use this enum value"
27 | )
28 | self.enum = enum
29 | self.value = value
30 |
31 | @property
32 | def message(self):
33 | return f"Enum value `{self.value}` was removed from `{self.enum.name}` enum"
34 |
35 | @property
36 | def path(self):
37 | return f"{self.enum.name}"
38 |
39 |
40 | class EnumValueDescriptionChanged(Change):
41 |
42 | criticality = Criticality.safe()
43 |
44 | def __init__(self, enum, name, old_value, new_value):
45 | self.enum = enum
46 | self.name = name
47 | self.old_value = old_value
48 | self.new_value = new_value
49 |
50 | @property
51 | def message(self):
52 | if not self.old_value.description:
53 | msg = f"Description for enum value `{self.name}` set to `{self.new_value.description}`"
54 | else:
55 | msg = (
56 | f"Description for enum value `{self.name}` changed"
57 | f" from `{self.old_value.description}` to `{self.new_value.description}`"
58 | )
59 | return msg
60 |
61 | @property
62 | def path(self):
63 | return f"{self.enum.name}.{self.name}"
64 |
65 |
66 | class EnumValueDeprecationReasonChanged(Change):
67 |
68 | criticality = Criticality.safe(
69 | "A deprecated field can still be used by clients and will give them time to adapt their queries"
70 | )
71 |
72 | def __init__(self, enum, name, old_value, new_value):
73 | self.enum = enum
74 | self.name = name
75 | self.old_value = old_value
76 | self.new_value = new_value
77 |
78 | @property
79 | def message(self):
80 | if not self.old_value.deprecation_reason:
81 | msg = (
82 | f"Enum value `{self.name}` was deprecated "
83 | f"with reason `{self.new_value.deprecation_reason}`"
84 | )
85 | else:
86 | msg = (
87 | f"Deprecation reason for enum value `{self.name}` changed "
88 | f"from `{self.old_value.deprecation_reason}` to `{self.new_value.deprecation_reason}`"
89 | )
90 | return msg
91 |
92 | @property
93 | def path(self):
94 | return f"{self.enum.name}.{self.name}"
--------------------------------------------------------------------------------
/schemadiff/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import argparse
3 |
4 | from schemadiff.allow_list import read_allowed_changes
5 | from schemadiff.diff.schema import Schema
6 | from schemadiff.schema_loader import SchemaLoader
7 | from schemadiff.formatting import print_diff, print_json
8 | from schemadiff.validation import rules_list, validate_changes
9 |
10 |
11 | def cli():
12 | args = parse_args(sys.argv[1:])
13 | return main(args)
14 |
15 |
16 | def parse_args(arguments):
17 | parser = argparse.ArgumentParser(description='Schema comparator')
18 | parser.add_argument('-o', '--old-schema',
19 | dest='old_schema',
20 | type=argparse.FileType('r', encoding='UTF-8'),
21 | help='Path to old graphql schema file',
22 | required=True)
23 | parser.add_argument('-n', '--new-schema',
24 | dest='new_schema',
25 | type=argparse.FileType('r', encoding='UTF-8'),
26 | help='Path to new graphql schema file',
27 | required=True)
28 | parser.add_argument('-j', '--as-json',
29 | action='store_true',
30 | help='Output a detailed summary of changes in json format',
31 | required=False)
32 | parser.add_argument('-a', '--allow-list',
33 | type=argparse.FileType('r', encoding='UTF-8'),
34 | help='Path to the allowed list of changes')
35 | parser.add_argument('-t', '--tolerant',
36 | action='store_true',
37 | help="Tolerant mode. Error out only if there's a breaking change but allow dangerous changes")
38 | parser.add_argument('-s', '--strict',
39 | action='store_true',
40 | help="Strict mode. Error out on dangerous and breaking changes.")
41 | parser.add_argument('-r', '--validation-rules', choices=rules_list(), nargs='*',
42 | help="Evaluate rules mode. Error out on changes that fail some validation rule.")
43 |
44 | return parser.parse_args(arguments)
45 |
46 |
47 | def main(args) -> int:
48 | # Load schemas from file path args
49 | old_schema = SchemaLoader.from_sdl(args.old_schema.read())
50 | new_schema = SchemaLoader.from_sdl(args.new_schema.read())
51 | args.old_schema.close()
52 | args.new_schema.close()
53 | if args.allow_list:
54 | allowed_changes = read_allowed_changes(args.allow_list.read())
55 | args.allow_list.close()
56 | else:
57 | allowed_changes = {}
58 |
59 | diff = Schema(old_schema, new_schema).diff()
60 | validation_result = validate_changes(diff, args.validation_rules, allowed_changes)
61 | diff = [change for change in diff if change.checksum() not in allowed_changes]
62 | if args.as_json:
63 | print_json(diff)
64 | else:
65 | print_diff(diff)
66 |
67 | return exit_code(diff, args.strict, not validation_result.ok, args.tolerant)
68 |
69 |
70 | def exit_code(changes, strict, some_change_is_restricted, tolerant) -> int:
71 | exit_code = 0
72 | if strict and any(change.breaking or change.dangerous for change in changes):
73 | exit_code = 1
74 | elif tolerant and any(change.breaking for change in changes):
75 | exit_code = 2
76 | elif some_change_is_restricted:
77 | exit_code = 3
78 |
79 | return exit_code
80 |
81 |
82 | if __name__ == '__main__':
83 | sys.exit(cli())
84 |
--------------------------------------------------------------------------------
/tests/test_schema_integration.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from schemadiff.schema_loader import SchemaLoader
4 | from schemadiff.diff.schema import Schema
5 |
6 | TESTS_DATA = Path(__file__).parent / 'data'
7 |
8 |
9 | def test_compare_from_schema_string_sdl():
10 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'old_schema.gql')
11 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'new_schema.gql')
12 |
13 | diff = Schema(old_schema, new_schema).diff()
14 | assert len(diff) == 38
15 |
16 | expected_changes = {
17 | 'Argument `arg: Int` added to `CType.a`',
18 | "Default value for argument `anotherArg` on `@yolo` directive changed from `Undefined` to `'Test'`",
19 | 'Default value for argument `arg` on field `CType.d` changed from `Undefined` to `10`',
20 | 'Default value for argument `arg` on field `WithArguments.b` changed from `1` to `2`',
21 | "Default value for input field `AInput.a` changed from `'1'` to `1`",
22 | 'Deprecation reason for enum value `F` changed from `Old` to `New`',
23 | 'Deprecation reason on field `CType.a` changed from `whynot` to `cuz`',
24 | 'Description for Input field `AInput.a` changed from `a` to `None`',
25 | 'Description for argument `a` on field `WithArguments.a` changed from `Meh` to `None`',
26 | 'Description for argument `someArg` on `@yolo` directive changed from `Included when true.` to `None`',
27 | 'Description for directive `@yolo` changed from `Old` to `None`',
28 | 'Description for type `Query` changed from `The Query Root of this schema` to `None`',
29 | 'Directive `@willBeRemoved` was removed',
30 | 'Directive `@yolo2` was added to use on `FIELD`',
31 | (
32 | 'Directive locations of `@yolo` changed from `FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT` '
33 | 'to `FIELD | FIELD_DEFINITION`'
34 | ),
35 | 'Enum value `C` was removed from `Options` enum',
36 | 'Enum value `D` was added to `Options` enum',
37 | 'Enum value `E` was deprecated with reason `No longer supported`',
38 | 'Field `anotherInterfaceField` was removed from interface `AnotherInterface`',
39 | 'Field `b` of type `Int` was added to interface `AnotherInterface`',
40 | 'Field `b` was added to object type `CType`',
41 | 'Field `c` was removed from object type `CType`',
42 | 'Input Field `b` removed from input type `AInput`',
43 | 'Input Field `c: String!` was added to input type `AInput`',
44 | 'Removed argument `anArg` from `Query.a`',
45 | 'Removed argument `willBeRemoved: Boolean!` from `@yolo` directive',
46 | 'Type `DType` was added',
47 | 'Type `WillBeRemoved` was removed',
48 | 'Type for argument `b` on field `WithArguments.a` changed from `String` to `String!`',
49 | 'Type for argument `someArg` on `@yolo` directive changed from `Boolean!` to `String!`',
50 | 'Union member `BType` was removed from `MyUnion` Union type',
51 | 'Union member `DType` was added to `MyUnion` Union type',
52 | '`AInput.a` type changed from `String` to `Int`',
53 | '`BType` kind changed from `OBJECT` to `INPUT OBJECT`',
54 | '`CType` implements new interface `AnInterface`',
55 | '`Query.a` description changed from `Just a simple string` to `None`',
56 | '`Query.b` type changed from `BType` to `Int!`',
57 | '`WithInterfaces` no longer implements interface `AnotherInterface`'
58 | }
59 | messages = [change.message for change in diff]
60 | for message in messages:
61 | assert message in expected_changes, f"{message} not found on expected messages"
62 |
--------------------------------------------------------------------------------
/schemadiff/changes/field.py:
--------------------------------------------------------------------------------
1 | from graphql import is_non_null_type, GraphQLField
2 |
3 | from schemadiff.changes import Change, Criticality, is_safe_type_change
4 |
5 |
6 | class FieldDescriptionChanged(Change):
7 |
8 | criticality = Criticality.safe()
9 |
10 | def __init__(self, new_type, name, old_field, new_field):
11 | self.type = new_type
12 | self.field_name = name
13 | self.old_field = old_field
14 | self.new_field = new_field
15 |
16 | @property
17 | def message(self):
18 | return (
19 | f"`{self.type.name}.{self.field_name}` description changed"
20 | f" from `{self.old_field.description}` to `{self.new_field.description}`"
21 | )
22 |
23 | @property
24 | def path(self):
25 | return f'{self.type}.{self.field_name}'
26 |
27 |
28 | class FieldDeprecationReasonChanged(Change):
29 |
30 | criticality = Criticality.safe()
31 |
32 | def __init__(self, type_, name, old_field, new_field):
33 | self.type_ = type_
34 | self.field_name = name
35 | self.old_field = old_field
36 | self.new_field = new_field
37 |
38 | @property
39 | def message(self):
40 | return (
41 | f"Deprecation reason on field `{self.type_}.{self.field_name}` changed "
42 | f"from `{self.old_field.deprecation_reason}` to `{self.new_field.deprecation_reason}`"
43 | )
44 |
45 | @property
46 | def path(self):
47 | return f"{self.type_}.{self.field_name}"
48 |
49 |
50 | class FieldTypeChanged(Change):
51 |
52 | def __init__(self, type_, field_name, old_field, new_field):
53 | self.criticality = Criticality.safe()\
54 | if is_safe_type_change(old_field.type, new_field.type)\
55 | else Criticality.breaking('Changing a field type will break queries that assume its type')
56 | self.type_ = type_
57 | self.field_name = field_name
58 | self.old_field = old_field
59 | self.new_field = new_field
60 |
61 | @property
62 | def message(self):
63 | return (
64 | f"`{self.type_}.{self.field_name}` type changed "
65 | f"from `{self.old_field.type}` to `{self.new_field.type}`"
66 | )
67 |
68 | @property
69 | def path(self):
70 | return f"{self.type_}.{self.field_name}"
71 |
72 |
73 | class FieldArgumentAdded(Change):
74 | def __init__(self, parent, field_name: str, field: GraphQLField, argument_name, arg_type):
75 | self.criticality = Criticality.safe('Adding an optional argument is a safe change')\
76 | if not is_non_null_type(arg_type.type)\
77 | else Criticality.breaking("Adding a required argument to an existing field is a breaking "
78 | "change because it will break existing uses of this field")
79 | self.parent = parent
80 | self.field_name = field_name
81 | self.field = field
82 | self.argument_name = argument_name
83 | self.arg_type = arg_type
84 |
85 | @property
86 | def message(self):
87 | return (
88 | f"Argument `{self.argument_name}: {self.arg_type.type}` "
89 | f"added to `{self.parent.name}.{self.field_name}`"
90 | )
91 |
92 | @property
93 | def path(self):
94 | return f"{self.parent}.{self.field_name}"
95 |
96 |
97 | class FieldArgumentRemoved(Change):
98 |
99 | criticality = Criticality.breaking(
100 | "Removing a field argument will break queries that use this argument"
101 | )
102 |
103 | def __init__(self, parent, field_name, argument_name):
104 | self.parent = parent
105 | self.field_name = field_name
106 | self.argument_name = argument_name
107 |
108 | @property
109 | def message(self):
110 | return (
111 | f"Removed argument `{self.argument_name}` from `{self.parent.name}.{self.field_name}`"
112 | )
113 |
114 | @property
115 | def path(self):
116 | return f"{self.parent}.{self.field_name}"
117 |
--------------------------------------------------------------------------------
/schemadiff/changes/input.py:
--------------------------------------------------------------------------------
1 | from graphql import is_non_null_type
2 |
3 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value
4 |
5 |
6 | class InputFieldAdded(Change):
7 |
8 | BREAKING_MSG = (
9 | "Adding a non-null field to an existing input type will cause existing "
10 | "queries that use this input type to break because they will "
11 | "not provide a value for this new field."
12 | )
13 |
14 | def __init__(self, input_object, field_name, field):
15 | self.criticality = (
16 | Criticality.safe()
17 | if is_non_null_type(field.type) is False
18 | else Criticality.breaking(self.BREAKING_MSG)
19 | )
20 |
21 | self.input_object = input_object
22 | self.field_name = field_name
23 | self.field = field
24 |
25 | @property
26 | def message(self):
27 | return f"Input Field `{self.field_name}: {self.field.type}` was added to input type `{self.input_object}`"
28 |
29 | @property
30 | def path(self):
31 | return f"{self.input_object}.{self.field_name}"
32 |
33 |
34 | class InputFieldRemoved(Change):
35 |
36 | criticality = Criticality.breaking(
37 | 'Removing an input field will break queries that use this input field.'
38 | )
39 |
40 | def __init__(self, input_object, value):
41 | self.input_object = input_object
42 | self.value = value
43 |
44 | @property
45 | def message(self):
46 | return f"Input Field `{self.value}` removed from input type `{self.input_object}`"
47 |
48 | @property
49 | def path(self):
50 | return f"{self.input_object}.{self.value}"
51 |
52 |
53 | class InputFieldDescriptionChanged(Change):
54 |
55 | criticality = Criticality.safe()
56 |
57 | def __init__(self, input_, name, new_field, old_field):
58 | self.input_ = input_
59 | self.name = name
60 | self.new_field = new_field
61 | self.old_field = old_field
62 |
63 | @property
64 | def message(self):
65 | return (
66 | f"Description for Input field `{self.input_.name}.{self.name}` "
67 | f"changed from `{self.old_field.description}` to `{self.new_field.description}`"
68 | )
69 |
70 | @property
71 | def path(self):
72 | return f"{self.input_.name}.{self.name}"
73 |
74 |
75 | class InputFieldDefaultChanged(Change):
76 |
77 | criticality = Criticality.dangerous(
78 | "Changing the default value for an argument may change the runtime "
79 | "behaviour of a field if it was never provided."
80 | )
81 |
82 | def __init__(self, input_, name, new_field, old_field):
83 | self.input_ = input_
84 | self.name = name
85 | self.new_field = new_field
86 | self.old_field = old_field
87 |
88 | @property
89 | def message(self):
90 | return (
91 | f"Default value for input field `{self.input_.name}.{self.name}` "
92 | f"changed from `{self.old_field.default_value!r}` to `{self.new_field.default_value!r}`"
93 | )
94 |
95 | @property
96 | def path(self):
97 | return f"{self.input_.name}.{self.name}"
98 |
99 |
100 | class InputFieldTypeChanged(Change):
101 | def __init__(self, input_, name, new_field, old_field):
102 | self.criticality = (
103 | Criticality.safe()
104 | if is_safe_change_for_input_value(old_field.type, new_field.type)
105 | else Criticality.breaking(
106 | "Changing the type of an input field can break existing queries that use this field"
107 | )
108 | )
109 | self.input_ = input_
110 | self.name = name
111 | self.new_field = new_field
112 | self.old_field = old_field
113 |
114 | @property
115 | def message(self):
116 | return (
117 | f"`{self.input_.name}.{self.name}` type changed from "
118 | f"`{self.old_field.type}` to `{self.new_field.type}`"
119 | )
120 |
121 | @property
122 | def path(self):
123 | return f"{self.input_.name}.{self.name}"
--------------------------------------------------------------------------------
/schemadiff/changes/interface.py:
--------------------------------------------------------------------------------
1 | from schemadiff.changes import Change, Criticality
2 |
3 |
4 | class InterfaceFieldAdded(Change):
5 |
6 | criticality = Criticality.dangerous(
7 | "Adding an interface to an object type may break existing clients "
8 | "that were not programming defensively against a new possible type."
9 | )
10 |
11 | def __init__(self, interface, field_name, field):
12 | self.interface = interface
13 | self.field_name = field_name
14 | self.field = field
15 |
16 | @property
17 | def message(self):
18 | return f"Field `{self.field_name}` of type `{self.field.type}` was added to interface `{self.interface}`"
19 |
20 | @property
21 | def path(self):
22 | return f"{self.interface.name}.{self.field_name}"
23 |
24 |
25 | class InterfaceFieldRemoved(Change):
26 |
27 | criticality = Criticality.dangerous(
28 | "Removing an interface field can break existing "
29 | "queries that use this in a fragment spread."
30 | )
31 |
32 | def __init__(self, interface, field_name):
33 | self.interface = interface
34 | self.field_name = field_name
35 |
36 | @property
37 | def message(self):
38 | return f"Field `{self.field_name}` was removed from interface `{self.interface}`"
39 |
40 | @property
41 | def path(self):
42 | return f"{self.interface.name}.{self.field_name}"
43 |
44 |
45 | class AbstractInterfaceChange(Change):
46 | def __init__(self, interface, field_name, old_field, new_field):
47 | self.interface = interface
48 | self.field_name = field_name
49 | self.old_field = old_field
50 | self.new_field = new_field
51 |
52 | @property
53 | def path(self):
54 | return f"{self.interface.name}.{self.field_name}"
55 |
56 |
57 | class InterfaceFieldTypeChanged(AbstractInterfaceChange):
58 |
59 | @property
60 | def message(self):
61 | return (
62 | f"Field `{self.interface.name}.{self.field_name}` type "
63 | f"changed from `{self.old_field.type}` to `{self.new_field.type}`"
64 | )
65 |
66 |
67 | class NewInterfaceImplemented(Change):
68 |
69 | criticality = Criticality.dangerous(
70 | "Adding an interface to an object type may break existing clients "
71 | "that were not programming defensively against a new possible type."
72 | )
73 |
74 | def __init__(self, interface, type_):
75 | self.interface = interface
76 | self.type_ = type_
77 |
78 | @property
79 | def message(self):
80 | return f"`{self.type_.name}` implements new interface `{self.interface.name}`"
81 |
82 | @property
83 | def path(self):
84 | return f"{self.type_}"
85 |
86 |
87 | class DroppedInterfaceImplementation(Change):
88 |
89 | criticality = Criticality.breaking(
90 | "Removing an interface from an object type can break existing queries "
91 | "that use this in a fragment spread."
92 | )
93 |
94 | def __init__(self, interface, type_):
95 | self.interface = interface
96 | self.type_ = type_
97 |
98 | @property
99 | def message(self):
100 | return f"`{self.type_.name}` no longer implements interface `{self.interface.name}`"
101 |
102 | @property
103 | def path(self):
104 | return f"{self.type_}"
105 |
106 |
107 | class InterfaceFieldDescriptionChanged(AbstractInterfaceChange):
108 | criticality = Criticality.safe()
109 |
110 | @property
111 | def message(self):
112 | return (
113 | f"`{self.interface.name}.{self.field_name}` description changed "
114 | f"from `{self.old_field.description}` to `{self.new_field.description}`"
115 | )
116 |
117 |
118 | class InterfaceFieldDeprecationReasonChanged(AbstractInterfaceChange):
119 | criticality = Criticality.breaking('Breaking change') # TODO: Improve this logic to check if it was deprecated before
120 |
121 | @property
122 | def message(self):
123 | return (
124 | f"`{self.interface.name}.{self.field_name}` deprecation reason changed "
125 | f"from `{self.old_field.deprecation_reason}` to `{self.new_field.deprecation_reason}`"
126 | )
127 |
--------------------------------------------------------------------------------
/tests/diff/test_enum.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_enum_value_removed():
8 | a = schema("""
9 | type Query {
10 | a: String
11 | }
12 | enum Letters {
13 | A
14 | B
15 | }
16 | """)
17 | b = schema("""
18 | type Query {
19 | a: String
20 | }
21 | enum Letters {
22 | A
23 | }
24 | """)
25 | diff = Schema(a, b).diff()
26 | assert len(diff) == 1
27 | change = diff[0]
28 | assert change.message == "Enum value `B` was removed from `Letters` enum"
29 | assert change.path == 'Letters'
30 | assert change.criticality == Criticality.breaking(
31 | 'Removing an enum value will break existing queries that use this enum value'
32 | )
33 |
34 |
35 | def test_enums_added():
36 | a = schema("""
37 | type Query {
38 | a: String
39 | }
40 | enum Letters {
41 | A
42 | B
43 | }
44 | """)
45 | b = schema("""
46 | type Query {
47 | a: String
48 | }
49 | enum Letters {
50 | A
51 | B
52 | C
53 | D
54 | }
55 | """)
56 | diff = Schema(a, b).diff()
57 | assert len(diff) == 2
58 | expected_diff = {
59 | "Enum value `C` was added to `Letters` enum",
60 | "Enum value `D` was added to `Letters` enum",
61 | }
62 | expected_paths = {'Letters.C', 'Letters.D'}
63 | for change in diff:
64 | assert change.message in expected_diff
65 | assert change.path in expected_paths
66 | assert change.criticality == Criticality.dangerous(
67 | "Adding an enum value may break existing clients that "
68 | "were not programming defensively against an added case when querying an enum."
69 | )
70 |
71 |
72 | def test_deprecated_enum_value():
73 | a = schema("""
74 | type Query {
75 | a: Int
76 | }
77 | enum Letters {
78 | A
79 | B
80 | }
81 | """)
82 | b = schema("""
83 | type Query {
84 | a: Int
85 | }
86 | enum Letters {
87 | A
88 | B @deprecated(reason: "Changed the alphabet")
89 | }
90 | """)
91 | diff = Schema(a, b).diff()
92 | assert len(diff) == 1
93 | assert diff[0].message == "Enum value `B` was deprecated with reason `Changed the alphabet`"
94 | assert diff[0].path == 'Letters.B'
95 | assert diff[0].criticality == Criticality.safe(
96 | "A deprecated field can still be used by clients and will give them time to adapt their queries"
97 | )
98 |
99 |
100 | def test_deprecated_reason_changed():
101 | a = schema("""
102 | type Query {
103 | a: Int
104 | }
105 | enum Letters {
106 | A
107 | B @deprecated(reason: "a reason")
108 | }
109 | """)
110 | b = schema("""
111 | type Query {
112 | a: Int
113 | }
114 | enum Letters {
115 | A
116 | B @deprecated(reason: "a new reason")
117 | }
118 | """)
119 | diff = Schema(a, b).diff()
120 | assert len(diff) == 1
121 | assert diff[0].message == (
122 | "Deprecation reason for enum value `B` changed from `a reason` to `a new reason`"
123 | )
124 | assert diff[0].path == 'Letters.B'
125 | assert diff[0].criticality == Criticality.safe(
126 | "A deprecated field can still be used by clients and will give them time to adapt their queries"
127 | )
128 |
129 |
130 | def test_description_added():
131 | a = schema('''
132 | type Query {
133 | a: Int
134 | }
135 | enum Letters {
136 | A
137 | }
138 | ''')
139 | b = schema('''
140 | type Query {
141 | a: Int
142 | }
143 | enum Letters {
144 | """My new description"""
145 | A
146 | }
147 | ''')
148 | diff = Schema(a, b).diff()
149 | assert len(diff) == 1
150 | assert diff[0].message == (
151 | "Description for enum value `A` set to `My new description`"
152 | )
153 | assert diff[0].path == 'Letters.A'
154 | assert diff[0].criticality == Criticality.safe()
155 |
156 |
157 | def test_description_changed():
158 | a = schema('''
159 | type Query {
160 | a: Int
161 | }
162 | enum Letters {
163 | """My description"""
164 | A
165 | }
166 | ''')
167 | b = schema('''
168 | type Query {
169 | a: Int
170 | }
171 | enum Letters {
172 | """My new description"""
173 | A
174 | }
175 | ''')
176 | diff = Schema(a, b).diff()
177 | assert len(diff) == 1
178 | assert diff[0].message == (
179 | "Description for enum value `A` changed from `My description` to `My new description`"
180 | )
181 | assert diff[0].path == 'Letters.A'
182 | assert diff[0].criticality == Criticality.safe()
183 |
--------------------------------------------------------------------------------
/tests/diff/test_interface.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_interface_no_changes():
8 | a = schema("""
9 | interface Person {
10 | name: String
11 | age: Int
12 | }
13 | """)
14 | b = schema("""
15 | interface Person {
16 | age: Int
17 | name: String
18 | }
19 | """)
20 | diff = Schema(a, b).diff()
21 | assert not diff
22 |
23 |
24 | def test_interface_field_added_and_removed():
25 | a = schema("""
26 | interface Person {
27 | name: String
28 | age: Int
29 | }
30 | """)
31 | b = schema("""
32 | interface Person {
33 | name: String
34 | age: Int
35 | favorite_number: Float
36 | }
37 | """)
38 |
39 | diff = Schema(a, b).diff()
40 | assert diff and len(diff) == 1
41 | assert diff[0].message == "Field `favorite_number` of type `Float` was added to interface `Person`"
42 | assert diff[0].path == 'Person.favorite_number'
43 | assert diff[0].criticality == Criticality.dangerous(
44 | 'Adding an interface to an object type may break existing clients '
45 | 'that were not programming defensively against a new possible type.'
46 | )
47 |
48 | diff = Schema(b, a).diff()
49 | assert diff[0].message == "Field `favorite_number` was removed from interface `Person`"
50 | assert diff[0].path == 'Person.favorite_number'
51 | assert diff[0].criticality == Criticality.dangerous(
52 | 'Removing an interface field can break existing queries that use this in a fragment spread.'
53 | )
54 |
55 |
56 | def test_interface_field_type_changed():
57 | a = schema("""
58 | interface Person {
59 | age: Int
60 | }
61 | """)
62 | b = schema("""
63 | interface Person {
64 | age: Float!
65 | }
66 | """)
67 | diff = Schema(a, b).diff()
68 | assert diff and len(diff) == 1
69 | assert diff[0].message == "`Person.age` type changed from `Int` to `Float!`"
70 | assert diff[0].path == 'Person.age'
71 | assert diff[0].criticality == Criticality.breaking(
72 | 'Changing a field type will break queries that assume its type'
73 | )
74 |
75 |
76 | def test_interface_field_description_changed():
77 | a = schema('''
78 | interface Person {
79 | """desc"""
80 | age: Int
81 | }
82 | ''')
83 | b = schema('''
84 | interface Person {
85 | """other desc"""
86 | age: Int
87 | }
88 | ''')
89 | diff = Schema(a, b).diff()
90 | assert diff and len(diff) == 1
91 | assert diff[0].message == "`Person.age` description changed from `desc` to `other desc`"
92 | assert diff[0].path == 'Person.age'
93 | assert diff[0].criticality == Criticality.safe()
94 |
95 |
96 | def test_interface_field_deprecation_reason_changed():
97 | a = schema('''
98 | interface Person {
99 | age: Int @deprecated
100 | }
101 | ''') # A default deprecation reason is appended on deprecation
102 | b = schema('''
103 | interface Person {
104 | age: Int @deprecated(reason: "my reason")
105 | }
106 | ''')
107 | diff = Schema(a, b).diff()
108 | assert diff and len(diff) == 1
109 | assert diff[0].message == (
110 | "Deprecation reason on field `Person.age` changed from `No longer supported` to `my reason`"
111 | )
112 | assert diff[0].path == 'Person.age'
113 | assert diff[0].criticality == Criticality.safe()
114 |
115 |
116 | def test_type_implements_new_interface():
117 | a = schema("""
118 | interface InterfaceA {
119 | a: Int
120 | }
121 | type MyType implements InterfaceA {
122 | a: Int
123 | }
124 | """)
125 | b = schema("""
126 | interface InterfaceA {
127 | a: Int
128 | }
129 | interface InterfaceB {
130 | b: String
131 | }
132 | type MyType implements InterfaceA & InterfaceB {
133 | a: Int
134 | b: String
135 | }
136 | """)
137 | diff = Schema(a, b).diff()
138 | assert diff and len(diff) == 3
139 | expected_diff = {
140 | "Field `b` was added to object type `MyType`",
141 | "Type `InterfaceB` was added",
142 | "`MyType` implements new interface `InterfaceB`"
143 | }
144 | expected_paths = {'InterfaceB', 'MyType', 'MyType.b'}
145 | for change in diff:
146 | assert change.message in expected_diff
147 | assert change.path in expected_paths
148 |
149 | diff = Schema(b, a).diff()
150 | assert diff and len(diff) == 3
151 | expected_diff = {
152 | "Field `b` was removed from object type `MyType`",
153 | "Type `InterfaceB` was removed",
154 | "`MyType` no longer implements interface `InterfaceB`"
155 | }
156 | expected_paths = {'InterfaceB', 'MyType', 'MyType.b'}
157 | for change in diff:
158 | assert change.message in expected_diff
159 | assert change.path in expected_paths
160 |
--------------------------------------------------------------------------------
/tests/test_changes_as_json.py:
--------------------------------------------------------------------------------
1 | import json
2 | from operator import itemgetter
3 |
4 | from schemadiff.changes import Criticality, Change
5 | from schemadiff.diff.schema import Schema
6 | from schemadiff.formatting import json_dump_changes, changes_to_dict
7 | from schemadiff.schema_loader import SchemaLoader as SchemaLoad
8 | from tests.test_schema_loading import TESTS_DATA
9 |
10 |
11 | def test_dict_representation():
12 | class MyChange(Change):
13 | criticality = Criticality.breaking('If lorem is ipsum the world might end')
14 |
15 | @property
16 | def message(self):
17 | return 'Lorem should not be ipsum'
18 |
19 | @property
20 | def path(self):
21 | return 'Lorem.Ipsum'
22 |
23 | the_change = MyChange()
24 | expected_repr = {
25 | 'message': 'Lorem should not be ipsum',
26 | 'path': 'Lorem.Ipsum',
27 | 'is_safe_change': False,
28 | 'criticality': {
29 | 'level': 'BREAKING',
30 | 'reason': 'If lorem is ipsum the world might end',
31 | },
32 | 'checksum': 'be519517bbcc5dd41ff526719e0764ec',
33 | }
34 | assert the_change.to_dict() == expected_repr
35 |
36 |
37 | def test_json_representation():
38 | class MyChange(Change):
39 | criticality = Criticality.dangerous('This might be dangerous')
40 |
41 | @property
42 | def message(self):
43 | return 'Unagi'
44 |
45 | @property
46 | def path(self):
47 | return 'Unagi.Friends'
48 |
49 | the_change = MyChange()
50 | expected_dict = {
51 | 'message': 'Unagi',
52 | 'path': 'Unagi.Friends',
53 | 'is_safe_change': False,
54 | 'criticality': {
55 | 'level': 'DANGEROUS',
56 | 'reason': 'This might be dangerous',
57 | },
58 | 'checksum': '6dde00418330279921218878d9ce7d38',
59 | }
60 | assert json.loads(the_change.to_json()) == expected_dict
61 |
62 |
63 | def test_safe_change():
64 | class MyChange(Change):
65 | criticality = Criticality.safe('Hello')
66 |
67 | @property
68 | def message(self):
69 | return ''
70 |
71 | @property
72 | def path(self):
73 | return None
74 |
75 | the_change = MyChange()
76 | expected_dict = {
77 | 'message': '',
78 | 'path': None,
79 | 'is_safe_change': True,
80 | 'criticality': {
81 | 'level': 'NON_BREAKING',
82 | 'reason': 'Hello',
83 | },
84 | 'checksum': 'd41d8cd98f00b204e9800998ecf8427e',
85 | }
86 | assert expected_dict == the_change.to_dict()
87 | assert json.loads(the_change.to_json()) == expected_dict
88 |
89 |
90 | def test_json_dump_changes():
91 | old = SchemaLoad.from_file(TESTS_DATA / 'simple_schema.gql')
92 | new = SchemaLoad.from_file(TESTS_DATA / 'simple_schema_dangerous_and_breaking.gql')
93 |
94 | changes = Schema(old, new).diff()
95 | expected_changes = [
96 | {
97 | 'criticality': {
98 | 'level': 'NON_BREAKING',
99 | 'reason': "This change won't break any preexisting query"
100 | },
101 | 'is_safe_change': True,
102 | 'message': 'Field `c` was added to object type `Query`',
103 | 'path': 'Query.c',
104 | 'checksum': '1e3b776bda2dd8b11804e7341bb8b2d1',
105 | },
106 | {
107 | 'criticality': {
108 | 'level': 'BREAKING',
109 | 'reason': (
110 | 'Removing a field is a breaking change. It is preferred to deprecate the field before removing it.'
111 | )
112 | },
113 | 'is_safe_change': False,
114 | 'message': 'Field `a` was removed from object type `Query`',
115 | 'path': 'Query.a',
116 | 'checksum': '5fba3d6ffc43c6769c6959ce5cb9b1c8',
117 | },
118 | {
119 | 'criticality': {
120 | 'level': 'DANGEROUS',
121 | 'reason': (
122 | 'Changing the default value for an argument may '
123 | 'change the runtime behaviour of a field if it was never provided.'
124 | )
125 | },
126 | 'is_safe_change': False,
127 | 'message': 'Default value for argument `x` on field `Field.calculus` changed from `0` to `1`',
128 | 'path': 'Field.calculus',
129 | 'checksum': '8890b911d44b2ead0dceeae066d98617',
130 | }
131 | ]
132 |
133 | changes_result = changes_to_dict(changes)
134 | by_path = itemgetter('path')
135 | assert sorted(changes_result, key=by_path) == sorted(expected_changes, key=by_path)
136 |
137 | # Assert the json dump has a 32-char checksum and the changes attributes as expected
138 | json_changes = json.loads(json_dump_changes(changes))
139 | assert all(len(change['checksum']) == 32 for change in json_changes)
140 | assert sorted(json_changes, key=by_path) == sorted(expected_changes, key=by_path)
141 |
--------------------------------------------------------------------------------
/schemadiff/validation_rules/__init__.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from abc import ABC, abstractmethod
3 | from typing import List, Set
4 |
5 | from graphql import GraphQLObjectType
6 |
7 | from schemadiff.changes.enum import EnumValueAdded, EnumValueDescriptionChanged
8 | from schemadiff.changes.field import FieldDescriptionChanged, FieldArgumentAdded
9 | from schemadiff.changes.object import ObjectTypeFieldAdded
10 | from schemadiff.changes.type import AddedType, TypeDescriptionChanged
11 |
12 |
13 | class ValidationRule(ABC):
14 | """Abstract class for creating schema Validation Rules."""
15 | name: str = ""
16 |
17 | def __init__(self, change):
18 | self.change = change
19 |
20 | @abstractmethod
21 | def is_valid(self) -> bool:
22 | """Evaluate the change regarding the rule class defined.
23 |
24 | Returns:
25 | bool: True if change is valid, False otherwise
26 | """
27 |
28 | @property
29 | @abstractmethod
30 | def message(self) -> str:
31 | """Formatted change message"""
32 |
33 | @classmethod
34 | def get_subclasses_by_names(cls, names: List[str]) -> Set[typing.Type['ValidationRule']]:
35 | if not names:
36 | return set()
37 | return {subclass for subclass in cls.__subclasses__() if subclass.name in names}
38 |
39 | @classmethod
40 | def get_rules_list(cls) -> Set[str]:
41 | return {subclass.name for subclass in cls.__subclasses__()}
42 |
43 |
44 | class AddTypeWithoutDescription(ValidationRule):
45 | """Restrict adding new GraphQL types without entering
46 | a non-empty description."""
47 | name = "add-type-without-description"
48 |
49 | def is_valid(self) -> bool:
50 | EMPTY = (None, "")
51 | if not isinstance(self.change, AddedType):
52 | return True
53 |
54 | added_type = self.change.type
55 | type_has_description = added_type.description not in EMPTY
56 |
57 | if isinstance(added_type, GraphQLObjectType):
58 | all_its_fields_have_description = all(
59 | field.description not in EMPTY
60 | for field in added_type.fields.values()
61 | )
62 | return type_has_description and all_its_fields_have_description
63 | else:
64 | return type_has_description
65 |
66 | @property
67 | def message(self):
68 | return f"{self.change.message} without a description for {self.change.path} (rule: `{self.name}`)."
69 |
70 |
71 | class RemoveTypeDescription(ValidationRule):
72 | """Restrict removing the description from an existing
73 | GraphQL type."""
74 | name = "remove-type-description"
75 |
76 | def is_valid(self) -> bool:
77 | if isinstance(self.change, TypeDescriptionChanged):
78 | return self.change.new_desc not in (None, "")
79 | return True
80 |
81 | @property
82 | def message(self):
83 | return (
84 | f"Description for type `{self.change.type}` was "
85 | f"removed (rule: `{self.name}`)"
86 | )
87 |
88 |
89 | class AddFieldWithoutDescription(ValidationRule):
90 | """Restrict adding fields without description."""
91 | name = "add-field-without-description"
92 |
93 | def is_valid(self) -> bool:
94 | if isinstance(self.change, ObjectTypeFieldAdded):
95 | return self.change.description not in (None, "")
96 | return True
97 |
98 | @property
99 | def message(self):
100 | return f"{self.change.message} without a description for {self.change.path} (rule: `{self.name}`)."
101 |
102 |
103 | class RemoveFieldDescription(ValidationRule):
104 | """Restrict removing field description."""
105 | name = "remove-field-description"
106 |
107 | def is_valid(self) -> bool:
108 | if isinstance(self.change, FieldDescriptionChanged):
109 | return self.change.new_field.description not in (None, "")
110 | return True
111 |
112 | @property
113 | def message(self):
114 | return (
115 | f"`{self.change.type.name}.{self.change.field_name}` description was "
116 | f"removed (rule: `{self.name}`)"
117 | )
118 |
119 |
120 | class AddEnumValueWithoutDescription(ValidationRule):
121 | """Restrict adding enum value without description."""
122 | name = "add-enum-value-without-description"
123 |
124 | def is_valid(self) -> bool:
125 | if isinstance(self.change, EnumValueAdded):
126 | return self.change.description not in (None, "")
127 | return True
128 |
129 | @property
130 | def message(self):
131 | return f"{self.change.message} without a description (rule: `{self.name}`)"
132 |
133 |
134 | class RemoveEnumValueDescription(ValidationRule):
135 | """Restrict adding enum value without description."""
136 | name = "remove-enum-value-description"
137 |
138 | def is_valid(self) -> bool:
139 | if isinstance(self.change, EnumValueDescriptionChanged):
140 | return self.change.new_value.description not in (None, "")
141 | return True
142 |
143 | @property
144 | def message(self):
145 | return f"Description for enum value `{self.change.name}` was removed (rule: `{self.name}`)"
146 |
147 |
148 |
--------------------------------------------------------------------------------
/tests/diff/test_argument.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import CriticalityLevel, Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 | SAFE_CHANGE_MSG = "This change won't break any preexisting query"
7 |
8 |
9 | def test_argument_type_changed():
10 | a = schema("""
11 | type Math {
12 | sum(arg1: Int, arg2: Int): Int
13 | }
14 | """)
15 | b = schema("""
16 | type Math {
17 | sum(arg1: Float, arg2: Int): Int
18 | }
19 | """)
20 |
21 | diff = Schema(a, b).diff()
22 | assert len(diff) == 1
23 | assert diff[0].message == (
24 | "Type for argument `arg1` on field `Math.sum` changed from `Int` to `Float`"
25 | )
26 | assert diff[0].path == 'Math.sum'
27 | assert diff[0].criticality.level == CriticalityLevel.Breaking
28 | assert diff[0].criticality.reason == (
29 | "Changing the type of a field's argument can break existing queries that use this argument."
30 | )
31 |
32 |
33 | def test_argument_add_non_nullable_field():
34 | a = schema("""
35 | type Field {
36 | exp(a: Int): Int
37 | }
38 | """)
39 | b = schema("""
40 | type Field {
41 | exp(a: Int, power: Int!): Int
42 | }
43 | """)
44 | diff = Schema(a, b).diff()
45 | assert len(diff) == 1
46 | change = diff[0]
47 | assert change.message == (
48 | "Argument `power: Int!` added to `Field.exp`"
49 | )
50 | assert change.path == 'Field.exp'
51 | assert change.criticality.level == CriticalityLevel.Breaking
52 | assert change.criticality.reason == (
53 | "Adding a required argument to an existing field is a "
54 | "breaking change because it will break existing uses of this field"
55 | )
56 |
57 |
58 | def test_argument_added_removed():
59 | a = schema("""
60 | type Field {
61 | exp(a: Int): Int
62 | }
63 | """)
64 | b = schema("""
65 | type Field {
66 | exp(a: Int, power: Int): Int
67 | }
68 | """)
69 |
70 | diff = Schema(a, b).diff()
71 | assert len(diff) == 1
72 | assert diff[0].message == (
73 | "Argument `power: Int` added to `Field.exp`"
74 | )
75 | assert diff[0].path == 'Field.exp'
76 | assert diff[0].criticality.level == CriticalityLevel.NonBreaking
77 | assert diff[0].criticality.reason == "Adding an optional argument is a safe change"
78 |
79 | diff = Schema(b, a).diff()
80 | assert diff[0].message == (
81 | "Removed argument `power` from `Field.exp`"
82 | )
83 | assert diff[0].path == 'Field.exp'
84 | assert diff[0].criticality.level == CriticalityLevel.Breaking
85 | assert diff[0].criticality.reason == "Removing a field argument will break queries that use this argument"
86 |
87 |
88 | def test_argument_description_changed():
89 | a = schema('''
90 | input Precision {
91 | """result precision"""
92 | decimals: Int
93 | }
94 | type Math {
95 | sum(arg1: Precision): Float
96 | }
97 | ''')
98 | b = schema('''
99 | input Precision {
100 | """new desc"""
101 | decimals: Int
102 | }
103 | type Math {
104 | sum(arg1: Precision): Float
105 | }
106 | ''')
107 |
108 | diff = Schema(a, b).diff()
109 | assert len(diff) == 1
110 | assert diff[0].message == (
111 | "Description for Input field `Precision.decimals` "
112 | "changed from `result precision` to `new desc`"
113 | )
114 | assert diff[0].path == 'Precision.decimals'
115 | assert diff[0].criticality.level == CriticalityLevel.NonBreaking
116 | assert diff[0].criticality.reason == SAFE_CHANGE_MSG
117 |
118 |
119 | def test_argument_description_of_inner_type_changed():
120 | a = schema('''
121 | type TypeWithArgs {
122 | field(
123 | """abc"""
124 | a: Int
125 | b: String!
126 | ): String
127 | }
128 | ''')
129 | b = schema('''
130 | type TypeWithArgs {
131 | field(
132 | """zzz wxYZ"""
133 | a: Int
134 | b: String!
135 | ): String
136 | }
137 | ''')
138 |
139 | diff = Schema(a, b).diff()
140 | assert diff and len(diff) == 1
141 | assert diff[0].message == (
142 | "Description for argument `a` on field `TypeWithArgs.field` "
143 | "changed from `abc` to `zzz wxYZ`"
144 | )
145 | assert diff[0].path == 'TypeWithArgs.field'
146 | assert diff[0].criticality == Criticality.safe(SAFE_CHANGE_MSG)
147 |
148 |
149 | def test_argument_default_value_changed():
150 | a = schema("""
151 | type Field {
152 | exp(base: Int=0): Int
153 | }
154 | """)
155 | b = schema("""
156 | type Field {
157 | exp(base: Int=1): Int
158 | }
159 | """)
160 |
161 | diff = Schema(a, b).diff()
162 | assert len(diff) == 1
163 | assert diff[0].message == (
164 | "Default value for argument `base` on field `Field.exp` changed from `0` to `1`"
165 | )
166 | assert diff[0].path == 'Field.exp'
167 | assert diff[0].criticality.level == CriticalityLevel.Dangerous
168 | assert diff[0].criticality.reason == (
169 | "Changing the default value for an argument may change the "
170 | "runtime behaviour of a field if it was never provided."
171 | )
172 |
--------------------------------------------------------------------------------
/schemadiff/changes/__init__.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import json
3 | from abc import abstractmethod, ABC
4 | from enum import Enum
5 | from typing import Optional
6 |
7 | from attr import dataclass
8 | from graphql import is_wrapping_type, is_non_null_type, is_list_type
9 |
10 |
11 | class CriticalityLevel(Enum):
12 | NonBreaking = 'NON_BREAKING'
13 | Dangerous = 'DANGEROUS'
14 | Breaking = 'BREAKING'
15 |
16 |
17 | @dataclass(repr=False)
18 | class Criticality:
19 | level: CriticalityLevel
20 | reason: str
21 |
22 | @classmethod
23 | def breaking(cls, reason):
24 | """Helper constructor of a breaking change"""
25 | return cls(level=CriticalityLevel.Breaking, reason=reason)
26 |
27 | @classmethod
28 | def dangerous(cls, reason):
29 | """Helper constructor of a dangerous change"""
30 | return cls(level=CriticalityLevel.Dangerous, reason=reason)
31 |
32 | @classmethod
33 | def safe(cls, reason="This change won't break any preexisting query"):
34 | """Helper constructor of a safe change"""
35 | return cls(level=CriticalityLevel.NonBreaking, reason=reason)
36 |
37 | def __repr__(self):
38 | # Replace repr because of attrs bug https://github.com/python-attrs/attrs/issues/95
39 | return f"Criticality(level={self.level}, reason={self.reason})"
40 |
41 |
42 | def is_safe_type_change(old_type, new_type) -> bool:
43 | """Depending on the old an new type, a field type change may be breaking, dangerous or safe
44 |
45 | * If both fields are 'leafs' in the sense they don't wrap an inner type, just compare their type.
46 | * If the new type has a non-null constraint, check that it was already non-null and compare against the contained
47 | type. If the contraint is new, just compare with old_type
48 | * If the old type is a list and the new one too, compare their inner type
49 | If the new type has a non-null constraint, compare against the wrapped type
50 | """
51 | if not is_wrapping_type(old_type) and not is_wrapping_type(new_type):
52 | return str(old_type) == str(new_type)
53 | if is_non_null_type(new_type):
54 | # Grab the inner type it it also was non null.
55 | of_type = old_type.of_type if is_non_null_type(old_type) else old_type
56 | return is_safe_type_change(of_type, new_type.of_type)
57 |
58 | if is_list_type(old_type):
59 | # If both types are lists, compare their inner type.
60 | # If the new type has a non-null constraint, compare with its inner type (may be a list or not)
61 | return (
62 | (
63 | is_list_type(new_type) and is_safe_type_change(old_type.of_type, new_type.of_type)
64 | )
65 | or
66 | (
67 | is_non_null_type(new_type) and is_safe_type_change(old_type, new_type.of_type)
68 | )
69 | )
70 |
71 | return False
72 |
73 |
74 | def is_safe_change_for_input_value(old_type, new_type):
75 | if not is_wrapping_type(old_type) and not is_wrapping_type(new_type):
76 | return str(old_type) == str(new_type)
77 | if is_list_type(old_type) and is_list_type(new_type):
78 | return is_safe_change_for_input_value(old_type.of_type, new_type.of_type)
79 |
80 | if is_non_null_type(old_type):
81 | # Grab the inner type it it also was non null.
82 | of_type = new_type.of_type if is_non_null_type(new_type) else new_type
83 | return is_safe_type_change(old_type.of_type, of_type)
84 |
85 | return False
86 |
87 |
88 | class Change(ABC):
89 | """Common interface of all schema changes
90 |
91 | This class offers the common operations and properties of all
92 | schema changes. You may use it as a type hint to get better
93 | suggestions in your editor of choice.
94 | """
95 |
96 | criticality: Criticality = None
97 |
98 | restricted: Optional[str] = None
99 | """Descriptive message only present when a change was restricted"""
100 |
101 | @property
102 | def breaking(self) -> bool:
103 | """Is this change a breaking change?"""
104 | return self.criticality.level == CriticalityLevel.Breaking
105 |
106 | @property
107 | def dangerous(self) -> bool:
108 | """Is this a change which might break some naive api clients?"""
109 | return self.criticality.level == CriticalityLevel.Dangerous
110 |
111 | @property
112 | def safe(self) -> bool:
113 | """Is this change safe for all clients?"""
114 | return self.criticality.level == CriticalityLevel.NonBreaking
115 |
116 | @property
117 | @abstractmethod
118 | def message(self) -> str:
119 | """Formatted change message"""
120 |
121 | @property
122 | @abstractmethod
123 | def path(self) -> str:
124 | """Path to the affected schema member"""
125 |
126 | def __repr__(self) -> str:
127 | return f"Change(criticality={self.criticality!r}, message={self.message!r}, path={self.path!r})"
128 |
129 | def __str__(self) -> str:
130 | return self.message
131 |
132 | def to_dict(self) -> dict:
133 | """Get detailed representation of a change"""
134 | return {
135 | 'message': self.message,
136 | 'path': self.path,
137 | 'is_safe_change': self.safe,
138 | 'criticality': {
139 | 'level': self.criticality.level.value,
140 | 'reason': self.criticality.reason
141 | },
142 | 'checksum': self.checksum(),
143 | }
144 |
145 | def to_json(self) -> str:
146 | """Get detailed representation of a change as a json string"""
147 | return json.dumps(self.to_dict())
148 |
149 | def checksum(self) -> str:
150 | """Get and identifier of a change. Used for allowlisting changes"""
151 | return hashlib.md5(self.message.encode('utf-8')).hexdigest()
152 |
--------------------------------------------------------------------------------
/tests/diff/test_schema.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_schema_no_diff():
8 | a_schema = schema("""
9 | schema {
10 | query: Query
11 | }
12 | type Query {
13 | a: String!
14 | }
15 | """)
16 | diff = Schema(a_schema, a_schema).diff()
17 | assert diff == []
18 |
19 |
20 | def test_schema_added_type():
21 | old_schema = schema("""
22 | schema {
23 | query: Query
24 | }
25 |
26 | type Query {
27 | field: String!
28 | }
29 | """)
30 | new_schema = schema("""
31 | schema {
32 | query: Query
33 | }
34 |
35 | type Query {
36 | field: String!
37 | }
38 |
39 | type AddedType {
40 | added: Int
41 | }
42 | """)
43 | diff = Schema(old_schema, new_schema).diff()
44 | assert diff and len(diff) == 1
45 | # Type Int was also added but its ignored because its a primitive.
46 | assert diff[0].message == "Type `AddedType` was added"
47 | assert diff[0].criticality == Criticality.safe()
48 |
49 |
50 | def test_schema_removed_type():
51 | old_schema = schema("""
52 | schema {
53 | query: Query
54 | }
55 |
56 | type Query {
57 | field: String!
58 | }
59 |
60 | type ToBeRemovedType {
61 | added: Int
62 | }
63 |
64 | """)
65 | new_schema = schema("""
66 | schema {
67 | query: Query
68 | }
69 |
70 | type Query {
71 | field: String!
72 | }
73 | """)
74 | diff = Schema(old_schema, new_schema).diff()
75 | assert diff and len(diff) == 1
76 | # Type Int was also removed but it is ignored because it's a primitive.
77 | assert diff[0].message == "Type `ToBeRemovedType` was removed"
78 | assert diff[0].criticality == Criticality.breaking(
79 | 'Removing a type is a breaking change. It is preferred to '
80 | 'deprecate and remove all references to this type first.'
81 | )
82 |
83 |
84 | def test_schema_query_fields_type_has_changes():
85 | old_schema = schema("""
86 | schema {
87 | query: Query
88 | }
89 |
90 | type Query {
91 | field: String!
92 | }
93 | """)
94 | new_schema = schema("""
95 | schema {
96 | query: Query
97 | }
98 |
99 | type Query {
100 | field: Int!
101 | }
102 | """)
103 | diff = Schema(old_schema, new_schema).diff()
104 | assert diff and len(diff) == 1
105 | assert diff[0].message == "`Query.field` type changed from `String!` to `Int!`"
106 | assert diff[0].criticality == Criticality.breaking(
107 | 'Changing a field type will break queries that assume its type'
108 | )
109 |
110 |
111 | def test_named_typed_changed_type():
112 | a = schema("""
113 | input QueryParams {
114 | a: String!
115 | }
116 | """)
117 | b = schema("""
118 | type QueryParams {
119 | a: String!
120 | }
121 | """)
122 | diff = Schema(a, b).diff()
123 | assert diff and len(diff) == 1
124 | assert diff[0].message == "`QueryParams` kind changed from `INPUT OBJECT` to `OBJECT`"
125 | assert diff[0].criticality == Criticality.breaking(
126 | 'Changing the kind of a type is a breaking change because '
127 | 'it can cause existing queries to error. For example, '
128 | 'turning an object type to a scalar type would break queries '
129 | 'that define a selection set for this type.'
130 | )
131 |
132 |
133 | def test_schema_query_root_changed():
134 | old_schema = schema("""
135 | schema {
136 | query: Query
137 | }
138 |
139 | type Query {
140 | field: String!
141 | }
142 | """)
143 | new_schema = schema("""
144 | schema {
145 | query: ChangedQuery
146 | }
147 |
148 | type ChangedQuery {
149 | field: String!
150 | }
151 | """)
152 | diff = Schema(old_schema, new_schema).diff()
153 | print(diff)
154 | assert diff and len(diff) == 3
155 | assert {x.message for x in diff} == {
156 | 'Type `ChangedQuery` was added',
157 | 'Type `Query` was removed',
158 | 'Schema query root has changed from `Query` to `ChangedQuery`'
159 | }
160 |
161 |
162 | def test_schema_mutation_root_changed():
163 | old_schema = schema("""
164 | schema {
165 | query: Query
166 | }
167 |
168 | type Query {
169 | field: String!
170 | }
171 | """)
172 | new_schema = schema("""
173 | schema {
174 | query: Query
175 | mutation: Mutation
176 | }
177 |
178 | type Query {
179 | field: String!
180 | }
181 |
182 | type Mutation {
183 | my_mutation: Int
184 | }
185 | """)
186 | diff = Schema(old_schema, new_schema).diff()
187 | print(diff)
188 | assert diff and len(diff) == 2
189 | assert {x.message for x in diff} == {
190 | 'Type `Mutation` was added',
191 | 'Schema mutation root has changed from `None` to `Mutation`'
192 | }
193 |
194 |
195 | def test_schema_suscription_root_changed():
196 | old_schema = schema("""
197 | schema {
198 | query: Query
199 | subscription: Subscription
200 | }
201 |
202 | type Query {
203 | field: String!
204 | }
205 |
206 | type Subscription {
207 | a: Int
208 | }
209 | """)
210 | new_schema = schema("""
211 | schema {
212 | query: Query
213 | subscription: ChangedSubscription
214 | }
215 |
216 | type Query {
217 | field: String!
218 | }
219 |
220 | type ChangedSubscription {
221 | a: Int
222 | }
223 |
224 | type Subscription {
225 | a: Int
226 | }
227 | """)
228 | diff = Schema(old_schema, new_schema).diff()
229 | print(diff)
230 | assert diff and len(diff) == 2
231 | assert {x.message for x in diff} == {
232 | 'Type `ChangedSubscription` was added',
233 | 'Schema subscription root has changed from `Subscription` to `ChangedSubscription`'
234 | }
235 |
--------------------------------------------------------------------------------
/schemadiff/changes/directive.py:
--------------------------------------------------------------------------------
1 | from graphql import is_non_null_type
2 |
3 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value
4 |
5 |
6 | class DirectiveChange(Change):
7 |
8 | @property
9 | def path(self):
10 | return f"{self.directive}"
11 |
12 |
13 | class AddedDirective(DirectiveChange):
14 |
15 | criticality = Criticality.safe()
16 |
17 | def __init__(self, directive, directive_locations):
18 | self.directive = directive
19 | self.directive_locations = directive_locations
20 |
21 | @property
22 | def message(self):
23 | locations = ' | '.join(loc.name for loc in self.directive_locations)
24 | return f"Directive `{self.directive}` was added to use on `{locations}`"
25 |
26 |
27 | class RemovedDirective(DirectiveChange):
28 |
29 | criticality = Criticality.breaking("Removing a directive may break clients that depend on them.")
30 |
31 | def __init__(self, directive):
32 | self.directive = directive
33 |
34 | @property
35 | def message(self):
36 | return f"Directive `{self.directive}` was removed"
37 |
38 |
39 | class DirectiveDescriptionChanged(DirectiveChange):
40 | criticality = Criticality.safe()
41 |
42 | def __init__(self, old, new):
43 | self.old = old
44 | self.new = new
45 |
46 | @property
47 | def message(self):
48 | return (
49 | f"Description for directive `{self.new!s}` "
50 | f"changed from `{self.old.description}` to `{self.new.description}`"
51 | )
52 |
53 | @property
54 | def path(self):
55 | return f"{self.new}"
56 |
57 |
58 | class DirectiveLocationsChanged(DirectiveChange):
59 | def __init__(self, directive, old_locations, new_locations):
60 | self.directive = directive
61 | self.old_locations = old_locations
62 | self.new_locations = new_locations
63 | self.criticality = (
64 | Criticality.safe() if self._only_additions() else Criticality.breaking(
65 | "Removing a directive location will break any instance of its usage. "
66 | "Be sure no one uses it before removing it"
67 | )
68 | )
69 |
70 | def _only_additions(self):
71 | new_location_names = {l.name for l in self.new_locations}
72 | return all(l.name in new_location_names for l in self.old_locations)
73 |
74 | @property
75 | def message(self):
76 | return (
77 | f"Directive locations of `{self.directive!s}` changed "
78 | f"from `{' | '.join(l.name for l in self.old_locations)}` "
79 | f"to `{' | '.join(l.name for l in self.new_locations)}`"
80 | )
81 |
82 |
83 | class DirectiveArgumentAdded(DirectiveChange):
84 | def __init__(self, directive, arg_name, arg_type):
85 | self.criticality = Criticality.safe() if not is_non_null_type(arg_type.type) else Criticality.breaking(
86 | "Adding a non nullable directive argument will break existing usages of the directive"
87 | )
88 | self.directive = directive
89 | self.arg_name = arg_name
90 | self.arg_type = arg_type
91 |
92 | @property
93 | def message(self):
94 | return f"Added argument `{self.arg_name}: {self.arg_type.type}` to `{self.directive!s}` directive"
95 |
96 |
97 | class DirectiveArgumentRemoved(DirectiveChange):
98 |
99 | criticality = Criticality.breaking("Removing a directive argument will break existing usages of the argument")
100 |
101 | def __init__(self, directive, arg_name, arg_type):
102 | self.directive = directive
103 | self.arg_name = arg_name
104 | self.arg_type = arg_type
105 |
106 | @property
107 | def message(self):
108 | return f"Removed argument `{self.arg_name}: {self.arg_type.type}` from `{self.directive!s}` directive"
109 |
110 |
111 | class DirectiveArgumentTypeChanged(DirectiveChange):
112 | def __init__(self, directive, arg_name, old_type, new_type):
113 | self.criticality = (
114 | Criticality.breaking("Changing the argument type is a breaking change")
115 | if not is_safe_change_for_input_value(old_type, new_type)
116 | else Criticality.safe("Changing an input field from non-null to null is considered non-breaking")
117 | )
118 | self.directive = directive
119 | self.arg_name = arg_name
120 | self.old_type = old_type
121 | self.new_type = new_type
122 |
123 | @property
124 | def message(self):
125 | return (
126 | f"Type for argument `{self.arg_name}` on `{self.directive!s}` directive changed "
127 | f"from `{self.old_type}` to `{self.new_type}`"
128 | )
129 |
130 |
131 | class DirectiveArgumentDefaultChanged(DirectiveChange):
132 | def __init__(self, directive, arg_name, old_default, new_default):
133 | self.criticality = Criticality.dangerous(
134 | "Changing the default value for an argument may change the runtime "
135 | "behaviour of a field if it was never provided."
136 | )
137 | self.directive = directive
138 | self.arg_name = arg_name
139 | self.old_default = old_default
140 | self.new_default = new_default
141 |
142 | @property
143 | def message(self):
144 | return (
145 | f"Default value for argument `{self.arg_name}` on `{self.directive!s}` directive changed "
146 | f"from `{self.old_default!r}` to `{self.new_default!r}`"
147 | )
148 |
149 |
150 | class DirectiveArgumentDescriptionChanged(DirectiveChange):
151 | criticality = Criticality.safe()
152 |
153 | def __init__(self, directive, arg_name, old_desc, new_desc):
154 | self.directive = directive
155 | self.arg_name = arg_name
156 | self.old_desc = old_desc
157 | self.new_desc = new_desc
158 |
159 | @property
160 | def message(self):
161 | return (
162 | f"Description for argument `{self.arg_name}` on `{self.directive!s}` directive changed "
163 | f"from `{self.old_desc}` to `{self.new_desc}`"
164 | )
165 |
--------------------------------------------------------------------------------
/schemadiff/diff/schema.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 |
3 | from graphql import (
4 | is_enum_type,
5 | is_union_type,
6 | is_input_object_type,
7 | is_object_type,
8 | is_interface_type,
9 | )
10 |
11 | from schemadiff.changes.directive import RemovedDirective, AddedDirective
12 | from schemadiff.changes.schema import (
13 | SchemaQueryTypeChanged,
14 | SchemaMutationTypeChanged,
15 | SchemaSubscriptionTypeChanged,
16 | )
17 | from schemadiff.changes.type import (
18 | AddedType,
19 | RemovedType,
20 | TypeDescriptionChanged,
21 | TypeKindChanged,
22 | )
23 | from schemadiff.diff.directive import Directive
24 | from schemadiff.diff.enum import EnumDiff
25 | from schemadiff.diff.interface import InterfaceType
26 | from schemadiff.diff.object_type import ObjectType
27 | from schemadiff.diff.union_type import UnionType
28 | from schemadiff.diff.input_object_type import InputObjectType
29 |
30 |
31 | def _get_type_resolvers():
32 | """Get TypeResolvers/TypeFields with fallback for different graphql-core versions."""
33 | # Try modern graphql-core (>= 3.2.7) - TypeFields is the official replacement
34 | try:
35 | from graphql.type.introspection import TypeFields
36 | return TypeFields
37 | except ImportError:
38 | pass
39 |
40 | # Try graphql-core 3.2.0 - 3.2.6
41 | try:
42 | from graphql.type.introspection import TypeResolvers
43 | return TypeResolvers
44 | except ImportError:
45 | pass
46 |
47 | # Fallback for older graphql-core (< 3.2.0)
48 | try:
49 | from graphql.type.introspection import TypeFieldResolvers
50 | return TypeFieldResolvers
51 | except ImportError:
52 | raise ImportError(
53 | "Could not import TypeFields, TypeResolvers, or TypeFieldResolvers from graphql-core. "
54 | "Please upgrade to a supported version of graphql-core."
55 | )
56 |
57 |
58 | TypeResolvers = _get_type_resolvers()
59 |
60 | type_kind = partial(TypeResolvers.kind, _info={})
61 |
62 |
63 | class Schema:
64 | primitives = {'String', 'Int', 'Float', 'Boolean', 'ID'}
65 | internal_types = {'__Schema', '__Type', '__TypeKind', '__Field', '__InputValue', '__EnumValue',
66 | '__Directive', '__DirectiveLocation'}
67 |
68 | def __init__(self, old_schema, new_schema):
69 | self.old_schema = old_schema
70 | self.new_schema = new_schema
71 |
72 | self.old_types = old_schema.type_map
73 | self.new_types = new_schema.type_map
74 |
75 | self.old_directives = old_schema.directives
76 | self.new_directives = new_schema.directives
77 |
78 | def diff(self):
79 | changes = []
80 | changes += self.type_changes()
81 | changes += self.directive_changes()
82 | changes += self.schema_changes()
83 | return changes
84 |
85 | def schema_changes(self):
86 | changes = []
87 | old, new = self.old_schema, self.new_schema
88 | if str(old.query_type) != str(new.query_type):
89 | changes.append(SchemaQueryTypeChanged(str(old.query_type), str(new.query_type)))
90 | if str(old.mutation_type) != str(new.mutation_type):
91 | changes.append(SchemaMutationTypeChanged(str(old.mutation_type), str(new.mutation_type)))
92 | if str(old.subscription_type) != str(new.subscription_type):
93 | changes.append(SchemaSubscriptionTypeChanged(str(old.subscription_type), str(new.subscription_type)))
94 |
95 | return changes
96 |
97 | def type_changes(self):
98 | changes = []
99 | changes += self.removed_types()
100 | changes += self.added_types()
101 | changes += self.common_type_changes()
102 | return changes
103 |
104 | def removed_types(self):
105 | return [
106 | RemovedType(self.old_types[field_name])
107 | for field_name in self.old_types
108 | if field_name not in self.new_types and not self.is_primitive(field_name)
109 | ]
110 |
111 | def added_types(self):
112 | return [
113 | AddedType(self.new_types[field_name])
114 | for field_name in self.new_types
115 | if field_name not in self.old_types and not self.is_primitive(field_name)
116 | ]
117 |
118 | def common_type_changes(self):
119 | changes = []
120 |
121 | common_types = (set(self.old_types.keys()) & set(self.new_types.keys())) - self.primitives - self.internal_types
122 | for type_name in common_types:
123 | old_type = self.old_types[type_name]
124 | new_type = self.new_types[type_name]
125 | changes += self.compare_types(old_type, new_type)
126 |
127 | return changes
128 |
129 | @staticmethod
130 | def compare_types(old_type, new_type):
131 | changes = []
132 | if old_type.description != new_type.description:
133 | changes.append(TypeDescriptionChanged(new_type.name, old_type.description, new_type.description))
134 |
135 | if type_kind(old_type) != type_kind(new_type):
136 | changes.append(TypeKindChanged(new_type, type_kind(old_type), type_kind(new_type)))
137 | else:
138 | if is_enum_type(old_type):
139 | changes += EnumDiff(old_type, new_type).diff()
140 | elif is_union_type(old_type):
141 | changes += UnionType(old_type, new_type).diff()
142 | elif is_input_object_type(old_type):
143 | changes += InputObjectType(old_type, new_type).diff()
144 | elif is_object_type(old_type):
145 | changes += ObjectType(old_type, new_type).diff()
146 | elif is_interface_type(old_type):
147 | changes += InterfaceType(old_type, new_type).diff()
148 |
149 | return changes
150 |
151 | def directive_changes(self):
152 | changes = []
153 | changes += self.removed_directives()
154 | changes += self.added_directives()
155 | changes += self.common_directives_changes()
156 | return changes
157 |
158 | def removed_directives(self):
159 | new_directive_names = {x.name for x in self.new_directives}
160 | return [
161 | RemovedDirective(directive)
162 | for directive in self.old_directives
163 | if directive.name not in new_directive_names
164 | ]
165 |
166 | def added_directives(self):
167 | old_directive_names = {x.name for x in self.old_directives}
168 | return [
169 | AddedDirective(directive, directive.locations)
170 | for directive in self.new_directives
171 | if directive.name not in old_directive_names
172 | ]
173 |
174 | def common_directives_changes(self):
175 | old_directive_names = {x.name for x in self.old_directives}
176 | new_directive_names = {x.name for x in self.new_directives}
177 | old_directives = {
178 | x.name: x
179 | for x in self.old_directives
180 | }
181 | new_directives = {
182 | x.name: x
183 | for x in self.new_directives
184 | }
185 |
186 | changes = []
187 | for directive_name in old_directive_names & new_directive_names:
188 | changes += Directive(old_directives[directive_name], new_directives[directive_name]).diff()
189 |
190 | return changes
191 |
192 | def is_primitive(self, atype):
193 | return atype in self.primitives
194 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | # schemadiff
26 | `schemadiff` is a lib that shows you the difference between two GraphQL Schemas.
27 | It takes two schemas from a string or a file and gives you a list of changes between both versions.
28 | This might be useful for:
29 | * Detecting breaking changes before they reach the api clients
30 | * Integrating into CI pipelines to control your api evolution
31 | * Document your api changes and submit them for an approval along with your pull requests.
32 | * Restricting unwanted changes
33 |
34 | ## Installation
35 | The lib requires python3.6 or greater to work. In order to install it run
36 | ```bash
37 | $ python3 -m pip install graphql-schema-diff
38 | ```
39 |
40 | ## Usage
41 | You can use this package as a lib or as a CLI. You can choose what better suits your needs
42 |
43 | ### As a Lib
44 | ```python
45 | from schemadiff import diff, diff_from_file, print_diff
46 |
47 | old_schema = """
48 | schema {
49 | query: Query
50 | }
51 |
52 | type Query {
53 | a: Int!,
54 | sum(start: Float=0): Int
55 | }
56 | """
57 |
58 | new_schema = """
59 | schema {
60 | query: Query
61 | }
62 |
63 | type Query {
64 | b: String,
65 | sum(start: Float=1): Int
66 | }
67 | """
68 |
69 | changes = diff(old_schema, new_schema)
70 | print_diff(changes) # Pretty print difference
71 | any(change.breaking or change.dangerous for change in changes) # Check if there was any breaking or dangerous change
72 |
73 | # You can also compare from schema files
74 | with open('old_schema.gql', 'w') as f:
75 | f.write(old_schema)
76 |
77 | with open('new_schema.gql', 'w') as f:
78 | f.write(new_schema)
79 |
80 | changes = diff_from_file('old_schema.gql', 'new_schema.gql')
81 | print_diff(changes)
82 | ```
83 | ### CLI
84 | Inside your virtualenv you can invoke the entrypoint to see its usage options
85 | ```bash
86 | $ schemadiff -h
87 | Usage: schemadiff [-h] -o OLD_SCHEMA -n NEW_SCHEMA [-j] [-a ALLOW_LIST] [-t] [-r] [-s]
88 |
89 | Schema comparator
90 |
91 | optional arguments:
92 | -h, --help show this help message and exit
93 | -o OLD_SCHEMA, --old-schema OLD_SCHEMA
94 | Path to old graphql schema file
95 | -n NEW_SCHEMA, --new-schema NEW_SCHEMA
96 | Path to new graphql schema file
97 | -j, --as-json Output a detailed summary of changes in json format
98 | -a ALLOW_LIST, --allow-list ALLOW_LIST
99 | Path to the allowed list of changes
100 | -t, --tolerant Tolerant mode. Error out only if there's a breaking
101 | change but allow dangerous changes
102 | -r, --restrictions Restricted mode. Error out on restricted changes.
103 | -s, --strict Strict mode. Error out on dangerous and breaking
104 | changes.
105 | ```
106 | #### Examples
107 | ```bash
108 | # Compare schemas and output diff to stdout
109 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql
110 |
111 | # Pass a evaluation flag (mixing long arg name and short arg name)
112 | schemadiff --old-schema tests/data/simple_schema.gql -n tests/data/new_schema.gql --strict
113 |
114 | # Print output as json with details of each change
115 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql --as-json
116 |
117 | # Save output to a json file
118 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql --as-json > changes.json
119 |
120 | # Compare schemas ignoring allowed changes
121 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql -a allowlist.json
122 |
123 | # Compare schemas restricting adding new types without description
124 | schemadiff -o tests/data/simple_schema.gql -n simple_schema_new_type_without_description.gql -r add-type-without-description
125 | ```
126 |
127 | >If you run the cli and see a replacement character (�) or a square box (□) instead of the emojis run
128 | >```bash
129 | >$ sudo apt install fonts-noto-color-emoji
130 | >$ vim ~/.config/fontconfig/fonts.conf # and paste https://gist.github.com/Ambro17/80bce76d07a6eb74323db2ca9b887263
131 | >$ fc-cache -f -v
132 | >```
133 | >That should install noto emoji fonts and set is as the fallback font to render emojis 😎
134 |
135 | ## Restricting changes
136 | You can use this library to validate whether your schema matches a set of rules.
137 |
138 | ### Built-in restrictions
139 | The library has its own built-in restrictions ready-to-use. Just append them to the `-r` command in `CLI`. You can
140 | add as many as you want.
141 |
142 | - `add-type-without-description`
143 | Restrict adding new GraphQL types without entering a non-empty description.
144 |
145 | - `remove-type-description`
146 | Restrict removing the description from an existing GraphQL type.
147 |
148 | - `add-field-without-description`
149 | Restrict adding fields without description.
150 |
151 | - `remove-field-description`
152 | Restrict removing the description from an existing GraphQL field.
153 |
154 | - `add-enum-value-without-description`
155 | Restrict adding enum value without description.
156 |
157 | - `remove-enum-value-description`
158 | Restrict adding enum value without description.
159 |
160 | Running the following command, you could restrict type additions without entering a nice description.
161 | ```
162 | # Compare schemas restricting adding new types without description
163 | schemadiff -o tests/data/simple_schema.gql -n simple_schema_new_type_without_description.gql -r add-type-without-description
164 | ```
165 |
166 |
167 | ## API Reference
168 | You can also read the [API Reference](https://ambro17.github.io/graphql-schema-diff/) if you want to get a better understanding of the inner workings of the lib
169 |
170 | ## Credits
171 | Implementation was heavily inspired by Marc Giroux [ruby version](https://github.com/xuorig/graphql-schema_comparator)
172 | and Kamil Kisiela [js implementation](https://github.com/kamilkisiela/graphql-inspector).
173 |
174 | Logo arrows were adapted from the work of [Paul Verhulst @ The Noun Project](https://thenounproject.com/paulverhulst/)
175 |
176 | ## Contributors
177 | - @Checho3388: Added the whole `evaluate_diff` functionality. Thank you!
178 |
179 | You can contribute reporting bugs, writing issues or pull requests for any new features!
--------------------------------------------------------------------------------
/tests/diff/test_input_object.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 | ERROR = 'Changing the type of an input field can break existing queries that use this field'
7 |
8 |
9 | def test_input_field_no_diff():
10 | a = schema("""
11 | input Params {
12 | will: String!
13 | love: Int!
14 | }
15 | """)
16 | b = schema("""
17 | input Params {
18 | will: String!
19 | love: Int!
20 | }
21 | """)
22 | diff = Schema(a, b).diff()
23 | assert not diff
24 |
25 |
26 | def test_input_field_type_changed():
27 | a = schema("""
28 | input Params {
29 | will: String!
30 | love: Int
31 | }
32 | """)
33 | b = schema("""
34 | input Params {
35 | will: String!
36 | love: Float!
37 | }
38 | """)
39 | diff = Schema(a, b).diff()
40 | assert diff and len(diff) == 1
41 | assert diff[0].message == "`Params.love` type changed from `Int` to `Float!`"
42 | assert diff[0].path == 'Params.love'
43 | assert diff[0].criticality == Criticality.breaking(
44 | 'Changing the type of an input field can break existing queries that use this field'
45 | )
46 |
47 |
48 | def test_input_field_changed_from_list_to_scalar():
49 | a = schema("""
50 | input Params {
51 | arg: Int
52 | }
53 | """)
54 | b = schema("""
55 | input Params {
56 | arg: [Int]
57 | }
58 | """)
59 | diff = Schema(a, b).diff()
60 | assert diff and len(diff) == 1
61 | assert diff[0].message == "`Params.arg` type changed from `Int` to `[Int]`"
62 | assert diff[0].path == 'Params.arg'
63 | assert diff[0].criticality == Criticality.breaking(
64 | 'Changing the type of an input field can break existing queries that use this field'
65 | )
66 |
67 |
68 | def test_input_field_dropped_non_null_constraint():
69 | a = schema("""
70 | input Params {
71 | arg: String!
72 | }
73 | """)
74 | b = schema("""
75 | input Params {
76 | arg: String
77 | }
78 | """)
79 | diff = Schema(a, b).diff()
80 | assert diff and len(diff) == 1
81 | assert diff[0].message == "`Params.arg` type changed from `String!` to `String`"
82 | assert diff[0].path == 'Params.arg'
83 | assert diff[0].criticality == Criticality.safe()
84 |
85 |
86 | def test_input_field_now_is_not_nullable():
87 | a = schema("""
88 | input Params {
89 | arg: ID
90 | }
91 | """)
92 | b = schema("""
93 | input Params {
94 | arg: ID!
95 | }
96 | """)
97 | diff = Schema(a, b).diff()
98 | assert diff and len(diff) == 1
99 | assert diff[0].message == "`Params.arg` type changed from `ID` to `ID!`"
100 | assert diff[0].path == 'Params.arg'
101 | assert diff[0].criticality == Criticality.breaking(ERROR)
102 |
103 |
104 | def test_input_field_type_nullability_change_on_lists_of_the_same_underlying_types():
105 | a = schema("""
106 | input Params {
107 | arg: [ID!]!
108 | }
109 | """)
110 | b = schema("""
111 | input Params {
112 | arg: [ID!]
113 | }
114 | """)
115 | c = schema("""
116 | input Params {
117 | arg: [ID]
118 | }
119 | """)
120 | d = schema("""
121 | input Params {
122 | arg: ID
123 | }
124 | """)
125 | diff = Schema(a, b).diff()
126 | assert diff and len(diff) == 1
127 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `[ID!]`"
128 | assert diff[0].path == 'Params.arg'
129 | assert diff[0].criticality == Criticality.safe() # Because dropping the non-null constraint will not break anything
130 |
131 | diff = Schema(a, c).diff()
132 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `[ID]`"
133 | assert diff[0].path == 'Params.arg'
134 | assert diff[0].criticality == Criticality.breaking(ERROR)
135 |
136 | diff = Schema(a, d).diff()
137 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `ID`"
138 | assert diff[0].path == 'Params.arg'
139 | assert diff[0].criticality == Criticality.breaking(ERROR)
140 |
141 |
142 | def test_input_field_inner_type_changed():
143 | a = schema("""
144 | input Params {
145 | arg: [Int]
146 | }
147 | """)
148 | b = schema("""
149 | input Params {
150 | arg: [String]
151 | }
152 | """)
153 | diff = Schema(a, b).diff()
154 | assert diff and len(diff) == 1
155 | assert diff[0].message == "`Params.arg` type changed from `[Int]` to `[String]`"
156 | assert diff[0].path == 'Params.arg'
157 | assert diff[0].criticality == Criticality.breaking(ERROR)
158 |
159 |
160 | def test_input_field_default_value_changed():
161 | a = schema("""
162 | input Params {
163 | love: Int = 0
164 | }
165 | """)
166 | b = schema("""
167 | input Params {
168 | love: Int = 100
169 | }
170 | """)
171 | diff = Schema(a, b).diff()
172 | assert diff and len(diff) == 1
173 | assert diff[0].message == "Default value for input field `Params.love` changed from `0` to `100`"
174 | assert diff[0].path == 'Params.love'
175 | assert diff[0].criticality == Criticality.dangerous(
176 | 'Changing the default value for an argument may change '
177 | 'the runtime behaviour of a field if it was never provided.'
178 | )
179 |
180 |
181 | def test_input_field_description_changed():
182 | a = schema('''
183 | input Params {
184 | """abc"""
185 | love: Int
186 | }
187 | ''')
188 | b = schema('''
189 | input Params {
190 | """His description"""
191 | love: Int
192 | }
193 | ''')
194 | diff = Schema(a, b).diff()
195 | assert diff and len(diff) == 1
196 | assert diff[0].message == (
197 | "Description for Input field `Params.love` changed from `abc` to `His description`"
198 | )
199 | assert diff[0].path == 'Params.love'
200 | assert diff[0].criticality == Criticality.safe()
201 |
202 |
203 | def test_input_field_added_field():
204 | a = schema("""
205 | input Recipe {
206 | ingredients: [String]
207 | }
208 | """)
209 | b = schema("""
210 | input Recipe {
211 | ingredients: [String]
212 | love: Float
213 | }
214 | """)
215 | diff = Schema(a, b).diff()
216 | assert diff and len(diff) == 1
217 | assert diff[0].message == (
218 | "Input Field `love: Float` was added to input type `Recipe`"
219 | )
220 | assert diff[0].path == 'Recipe.love'
221 | assert diff[0].criticality == Criticality.safe()
222 |
223 | diff = Schema(b, a).diff()
224 | assert diff and len(diff) == 1
225 | assert diff[0].message == (
226 | "Input Field `love` removed from input type `Recipe`"
227 | )
228 | assert diff[0].path == 'Recipe.love'
229 | assert diff[0].criticality == Criticality.breaking(
230 | 'Removing an input field will break queries that use this input field.'
231 | )
232 |
233 |
234 | def test_add_non_null_input_field():
235 | a = schema("""
236 | input Recipe {
237 | ingredients: [String]
238 | }
239 | """)
240 | b = schema("""
241 | input Recipe {
242 | ingredients: [String]
243 | love: Float!
244 | }
245 | """)
246 | diff = Schema(a, b).diff()
247 | assert diff and len(diff) == 1
248 | assert diff[0].message == (
249 | "Input Field `love: Float!` was added to input type `Recipe`"
250 | )
251 | assert diff[0].path == 'Recipe.love'
252 | assert diff[0].criticality == Criticality.breaking(
253 | 'Adding a non-null field to an existing input type will cause existing '
254 | 'queries that use this input type to break because they will not provide a value for this new field.'
255 | )
256 |
--------------------------------------------------------------------------------
/tests/diff/test_field.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_no_field_diff():
8 | a = schema("""
9 | type Query {
10 | b: Int!
11 | }
12 | """)
13 | b = schema("""
14 | type Query {
15 | b: Int!
16 | }
17 | """)
18 | diff = Schema(a, b).diff()
19 | assert not diff
20 |
21 |
22 | def test_field_type_changed():
23 | a_schema = schema("""
24 | type Query {
25 | a: String!
26 | }
27 | """)
28 | changed_schema = schema("""
29 | type Query {
30 | a: Int
31 | }
32 | """)
33 | diff = Schema(a_schema, changed_schema).diff()
34 | assert len(diff) == 1
35 | assert diff[0].message == "`Query.a` type changed from `String!` to `Int`"
36 | assert diff[0].path == 'Query.a'
37 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
38 |
39 |
40 | def test_field_type_change_from_scalar_to_list_of_the_same_type():
41 | a = schema("""
42 | type Query {
43 | a: String
44 | }
45 | """)
46 | b = schema("""
47 | type Query {
48 | a: [String]
49 | }
50 | """)
51 | diff = Schema(a, b).diff()
52 | assert len(diff) == 1
53 | assert diff[0].message == "`Query.a` type changed from `String` to `[String]`"
54 | assert diff[0].path == 'Query.a'
55 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
56 |
57 |
58 | def test_field_nullability_changed():
59 | a = schema("""
60 | type Query {
61 | a: Int!
62 | }
63 | """)
64 | b = schema("""
65 | type Query {
66 | a: Int
67 | }
68 | """)
69 | diff = Schema(a, b).diff()
70 | assert len(diff) == 1
71 | assert diff[0].message == "`Query.a` type changed from `Int!` to `Int`"
72 | assert diff[0].path == 'Query.a'
73 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
74 |
75 | diff = Schema(b, a).diff()
76 | assert len(diff) == 1
77 | assert diff[0].message == "`Query.a` type changed from `Int` to `Int!`"
78 | assert diff[0].path == 'Query.a'
79 | assert diff[0].criticality == Criticality.safe()
80 |
81 |
82 | def test_field_type_change_nullability_change_on_lists_of_same_type():
83 | a = schema("""
84 | type Query {
85 | a: [Boolean]!
86 | }
87 | """)
88 | b = schema("""
89 | type Query {
90 | a: [Boolean]
91 | }
92 | """)
93 | diff = Schema(a, b).diff()
94 | assert len(diff) == 1
95 | assert diff[0].message == "`Query.a` type changed from `[Boolean]!` to `[Boolean]`"
96 | assert diff[0].path == 'Query.a'
97 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
98 |
99 |
100 | def test_field_listof_nullability_of_inner_type_changed():
101 | a = schema("""
102 | type Query {
103 | a: [Int!]!
104 | }
105 | """)
106 | b = schema("""
107 | type Query {
108 | a: [Int]!
109 | }
110 | """)
111 | diff = Schema(a, b).diff()
112 | assert len(diff) == 1
113 | assert diff[0].message == "`Query.a` type changed from `[Int!]!` to `[Int]!`"
114 | assert diff[0].path == 'Query.a'
115 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
116 |
117 |
118 | def test_field_listof_nullability_of_inner_and_outer_types_changed():
119 | a = schema("""
120 | type Query {
121 | a: [Float!]!
122 | }
123 | """)
124 | b = schema("""
125 | type Query {
126 | a: [Float]
127 | }
128 | """)
129 | diff = Schema(a, b).diff()
130 | assert len(diff) == 1
131 | assert diff[0].message == "`Query.a` type changed from `[Float!]!` to `[Float]`"
132 | assert diff[0].path == 'Query.a'
133 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
134 |
135 |
136 | def test_field_list_of_inner_type_changed():
137 | a = schema("""
138 | type Query {
139 | a: [Float!]!
140 | }
141 | """)
142 | b = schema("""
143 | type Query {
144 | a: [String]
145 | }
146 | """)
147 | diff = Schema(a, b).diff()
148 | assert len(diff) == 1
149 | assert diff[0].message == "`Query.a` type changed from `[Float!]!` to `[String]`"
150 | assert diff[0].path == 'Query.a'
151 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type')
152 |
153 |
154 | def test_description_changed():
155 | a = schema('''
156 | type Query {
157 | """some desc"""
158 | a: String
159 | }
160 | ''')
161 | b = schema('''
162 | type Query {
163 | """once upon a time"""
164 | a: String
165 | }
166 | ''')
167 | diff = Schema(a, b).diff()
168 | assert len(diff) == 1
169 | assert diff[0].message == "`Query.a` description changed from `some desc` to `once upon a time`"
170 | assert diff[0].path == 'Query.a'
171 | assert diff[0].criticality == Criticality.safe()
172 |
173 |
174 | def test_deprecation_reason_changed():
175 | a = schema('''
176 | type Query {
177 | b: Int! @deprecated(reason: "Not used")
178 | }
179 | ''')
180 | b = schema('''
181 | type Query {
182 | b: Int! @deprecated(reason: "Some string")
183 | }
184 | ''')
185 | diff = Schema(a, b).diff()
186 | assert diff and len(diff) == 1
187 | assert diff[0].message == "Deprecation reason on field `Query.b` changed from `Not used` to `Some string`"
188 | assert diff[0].path == 'Query.b'
189 | assert diff[0].criticality == Criticality.safe()
190 |
191 |
192 | def test_added_removed_arguments():
193 | a = schema("""
194 | type Football {
195 | skill: Float!
196 | }
197 | """)
198 | b = schema("""
199 | type Football {
200 | skill(player: ID): Float!
201 | }
202 | """)
203 | diff = Schema(a, b).diff()
204 | assert diff and len(diff) == 1
205 | assert diff[0].message == "Argument `player: ID` added to `Football.skill`"
206 | assert diff[0].path == 'Football.skill'
207 | assert diff[0].criticality == Criticality.safe('Adding an optional argument is a safe change')
208 |
209 | diff = Schema(b, a).diff()
210 | assert diff and len(diff) == 1
211 | assert diff[0].message == "Removed argument `player` from `Football.skill`"
212 | assert diff[0].path == 'Football.skill'
213 | assert diff[0].criticality == Criticality.breaking(
214 | 'Removing a field argument will break queries that use this argument'
215 | )
216 |
217 | c = schema("""
218 | type Football {
219 | skill(player: ID, age: Int): Float!
220 | }
221 | """)
222 | diff = Schema(b, c).diff()
223 | assert diff and len(diff) == 1
224 | assert diff[0].message == "Argument `age: Int` added to `Football.skill`"
225 | assert diff[0].path == 'Football.skill'
226 | assert diff[0].criticality == Criticality.safe('Adding an optional argument is a safe change')
227 |
228 |
229 | def test_field_type_change_is_safe():
230 | non_nullable_schema = schema("""
231 | type Query {
232 | a: Int!
233 | }
234 | """)
235 | nullable_schema = schema("""
236 | type Query {
237 | a: Int
238 | }
239 | """)
240 | # Dropping the non-null constraint is a breaking change because clients may have assumed it could never be null
241 | diff = Schema(non_nullable_schema, nullable_schema).diff()
242 | assert len(diff) == 1
243 | assert diff[0].criticality == Criticality.breaking(
244 | 'Changing a field type will break queries that assume its type'
245 | )
246 |
247 | # But if it was already nullable, they already had to handle that case.
248 | diff = Schema(nullable_schema, non_nullable_schema).diff()
249 | assert len(diff) == 1
250 | assert diff[0].criticality == Criticality.safe()
251 |
--------------------------------------------------------------------------------
/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
21 |
23 |
24 |
26 | image/svg+xml
27 |
29 |
30 |
31 |
32 |
33 |
35 |
63 |
67 |
71 |
72 |
82 |
92 |
99 |
104 |
109 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | import json
2 | import sys
3 | from operator import itemgetter
4 | from unittest.mock import patch
5 |
6 | import pytest
7 |
8 | from schemadiff.__main__ import main, parse_args, cli
9 |
10 |
11 | def test_no_diff(capsys):
12 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
13 | args = parse_args([
14 | '-o', SCHEMA_FILE,
15 | '-n', SCHEMA_FILE,
16 | ])
17 | exit_code = main(args)
18 | stdout = capsys.readouterr()
19 | assert exit_code == 0
20 | assert stdout.out == '🎉 Both schemas are equal!\n'
21 | assert stdout.err == ''
22 |
23 |
24 | def test_schema_path_not_found(capsys):
25 | SCHEMA_FILE = 'tests/data/not_a_path.gql'
26 | with pytest.raises(SystemExit):
27 | parse_args([
28 | '-o', SCHEMA_FILE,
29 | '-n', SCHEMA_FILE,
30 | ])
31 | assert "No such file or directory: 'tests/data/not_a_path.gql'" in capsys.readouterr().err
32 |
33 |
34 | def test_schema_default_mode(capsys):
35 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
36 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql'
37 | args = parse_args([
38 | '--old-schema', SCHEMA_FILE,
39 | '--new-schema', ANOTHER_SCHEMA_FILE,
40 | ])
41 | exit_code = main(args)
42 | assert exit_code == 0
43 |
44 | stdout = capsys.readouterr()
45 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out
46 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out
47 |
48 |
49 | def test_schema_default_mode_json_output(capsys):
50 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
51 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql'
52 | args = parse_args([
53 | '--old-schema', SCHEMA_FILE,
54 | '--new-schema', ANOTHER_SCHEMA_FILE,
55 | '--as-json'
56 | ])
57 | exit_code = main(args)
58 | assert exit_code == 0
59 |
60 | stdout = capsys.readouterr().out
61 | result = json.loads(stdout)
62 | assert sorted(result, key=itemgetter('path')) == sorted([
63 | {
64 | 'criticality': {
65 | 'level': 'NON_BREAKING',
66 | 'reason': "This change won't break any preexisting query"
67 | },
68 | 'is_safe_change': True,
69 | 'message': 'Field `c` was added to object type `Query`',
70 | 'path': 'Query.c',
71 | 'checksum': '1e3b776bda2dd8b11804e7341bb8b2d1',
72 | },
73 | {
74 | 'criticality': {
75 | 'level': 'DANGEROUS',
76 | 'reason': 'Changing the default value for an argument '
77 | 'may change the runtime behaviour of a field if it was never provided.'
78 | },
79 | 'is_safe_change': False,
80 | 'message': 'Default value for argument `x` on field `Field.calculus` changed from `0` to `100`',
81 | 'path': 'Field.calculus',
82 | 'checksum': 'a43d73d21c69cbd72334c06904439f50',
83 | }
84 | ], key=itemgetter('path'))
85 |
86 |
87 | def test_cli_with_allow_list_does_not_show_allowed_changes(capsys):
88 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
89 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_breaking_changes.gql'
90 | ALLOW_LIST = 'tests/data/allowlist.json'
91 | args = parse_args([
92 | '--old-schema', SCHEMA_FILE,
93 | '--new-schema', ANOTHER_SCHEMA_FILE,
94 | '-a', ALLOW_LIST
95 | ])
96 | exit_code = main(args)
97 | assert exit_code == 0
98 |
99 | stdout = capsys.readouterr().out
100 | # The only difference between both schemas was allowed, so there are no differences.
101 | assert stdout == '🎉 Both schemas are equal!\n'
102 |
103 |
104 | def test_schema_strict_mode(capsys):
105 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
106 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql'
107 | args = parse_args([
108 | '-o', SCHEMA_FILE,
109 | '--new-schema', ANOTHER_SCHEMA_FILE,
110 | '--strict'
111 | ])
112 | exit_code = main(args)
113 | # As we run the comparison in strict mode and there is a dangerous change, the exit code is 1
114 | assert exit_code == 1
115 |
116 | stdout = capsys.readouterr()
117 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out
118 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out
119 |
120 |
121 | def test_schema_rules_mode(capsys):
122 | SCHEMA_FILE = 'tests/data/simple_schema_rules_validation.gql'
123 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_rules_validation_new.gql'
124 | RULES = [
125 | 'add-type-without-description',
126 | 'remove-type-description',
127 | 'add-field-without-description',
128 | 'remove-field-description',
129 | 'add-enum-value-without-description',
130 | 'remove-enum-value-description',
131 | ]
132 | args = parse_args([
133 | '-o', SCHEMA_FILE,
134 | '--new-schema', ANOTHER_SCHEMA_FILE,
135 | '--validation-rules', *RULES
136 | ])
137 | exit_code = main(args)
138 | # As we run the comparison in validation mode and there is a restricted change, the exit code is 1
139 | assert exit_code == 3
140 |
141 | stdout = capsys.readouterr()
142 |
143 | assert "⛔ Type `NewTypeWithoutDesc` was added without a description for NewTypeWithoutDesc " \
144 | "(rule: `add-type-without-description`)" in stdout.out
145 | assert "⛔ Type `NewEnumWithoutDesc` was added without a description for NewEnumWithoutDesc " \
146 | "(rule: `add-type-without-description`)" in stdout.out
147 | assert "⛔ Description for type `Field` was removed " \
148 | "(rule: `remove-type-description`)" in stdout.out
149 | assert "⛔ `Field.calculus` description was removed " \
150 | "(rule: `remove-field-description`)" in stdout.out
151 | assert "⛔ Field `c` was added to object type `Query` without a description for " \
152 | "Query.c (rule: `add-field-without-description`)" in stdout.out
153 | assert "⛔ Enum value `VALUE_3` was added to `Enum` enum without a description " \
154 | "(rule: `add-enum-value-without-description`)" in stdout.out
155 | assert "⛔ Description for enum value `VALUE_2` was removed " \
156 | "(rule: `remove-enum-value-description`)" in stdout.out
157 |
158 |
159 | def test_schema_tolerant_mode(capsys):
160 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
161 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql'
162 | args = parse_args([
163 | '-o', SCHEMA_FILE,
164 | '--new-schema', ANOTHER_SCHEMA_FILE,
165 | '--tolerant'
166 | ])
167 | exit_code = main(args)
168 | # In tolerant mode, dangerous changes don't cause an error exit code
169 | assert exit_code == 0
170 |
171 | stdout = capsys.readouterr()
172 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out
173 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out
174 | assert len(stdout.out.split('\n')) == 3
175 |
176 |
177 | def test_schema_strict_mode_with_breaking_changes_gives_error_exit_code(capsys):
178 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
179 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_breaking_changes.gql'
180 | args = parse_args([
181 | '-o', SCHEMA_FILE,
182 | '--new-schema', ANOTHER_SCHEMA_FILE,
183 | '--strict'
184 | ])
185 | exit_code = main(args)
186 | assert exit_code == 1
187 |
188 | stdout = capsys.readouterr()
189 | assert '❌ Field `a` was removed from object type `Query`\n' in stdout.out
190 | assert len(stdout.out.split('\n')) == 2
191 |
192 |
193 | def test_cli_exits_normally_by_default_when_strict_or_tolerant_mode_is_specified(capsys):
194 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
195 | BREAKING_CHANGES_SCHEMA = 'tests/data/simple_schema_breaking_changes.gql'
196 | args = parse_args([
197 | '--old-schema', SCHEMA_FILE,
198 | '-n', BREAKING_CHANGES_SCHEMA,
199 | ])
200 | exit_code = main(args)
201 | assert exit_code == 0
202 |
203 | stdout = capsys.readouterr()
204 | assert '❌ Field `a` was removed from object type `Query`\n' in stdout.out
205 | assert len(stdout.out.split('\n')) == 2
206 |
207 |
208 | def test_tolerant_mode_doesnt_allow_breaking_changes(capsys):
209 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
210 | BREAKING_CHANGES_SCHEMA = 'tests/data/simple_schema_breaking_changes.gql'
211 | tolerant_args = parse_args([
212 | '-o', SCHEMA_FILE,
213 | '-n', BREAKING_CHANGES_SCHEMA,
214 | '--tolerant'
215 | ])
216 | default_args = parse_args([
217 | '-o', SCHEMA_FILE,
218 | '-n', BREAKING_CHANGES_SCHEMA,
219 | ])
220 | exit_code = main(tolerant_args)
221 | assert exit_code == 2
222 |
223 | exit_code = main(default_args)
224 | assert exit_code == 0
225 |
226 |
227 | def test_cli_function(capsys):
228 | SCHEMA_FILE = 'tests/data/simple_schema.gql'
229 | command_args = [
230 | 'schemadiff',
231 | '-o', SCHEMA_FILE,
232 | '-n', SCHEMA_FILE,
233 | ]
234 | with patch.object(sys, 'argv', command_args):
235 | exit_code = cli()
236 | assert exit_code == 0
237 | assert capsys.readouterr().out == '🎉 Both schemas are equal!\n'
238 |
--------------------------------------------------------------------------------
/docs/schema_loader.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | schemadiff.schema_loader API documentation
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Module schemadiff.schema_loader
23 |
24 |
25 |
26 |
27 | Expand source code
28 |
29 | from graphql import build_schema, GraphQLSchema
30 |
31 |
32 | class SchemaLoader:
33 | """Represents a GraphQL Schema loaded from a string or file."""
34 |
35 | @classmethod
36 | def from_sdl(cls, schema_string: str) -> GraphQLSchema:
37 | return build_schema(schema_string)
38 |
39 | @classmethod
40 | def from_file(cls, filepath: str) -> GraphQLSchema:
41 | with open(filepath, encoding='utf-8') as f:
42 | schema_string = f.read()
43 |
44 | return cls.from_sdl(schema_string)
45 |
46 |
47 |
49 |
51 |
53 |
54 |
55 |
56 |
57 | class SchemaLoader
58 |
59 |
60 | Represents a GraphQL Schema loaded from a string or file.
61 |
62 |
63 | Expand source code
64 |
65 | class SchemaLoader:
66 | """Represents a GraphQL Schema loaded from a string or file."""
67 |
68 | @classmethod
69 | def from_sdl(cls, schema_string: str) -> GraphQLSchema:
70 | return build_schema(schema_string)
71 |
72 | @classmethod
73 | def from_file(cls, filepath: str) -> GraphQLSchema:
74 | with open(filepath, encoding='utf-8') as f:
75 | schema_string = f.read()
76 |
77 | return cls.from_sdl(schema_string)
78 |
79 | Static methods
80 |
81 |
82 | def from_file (filepath: str) ‑> graphql.type.schema.GraphQLSchema
83 |
84 |
85 |
86 |
87 |
88 | Expand source code
89 |
90 | @classmethod
91 | def from_file(cls, filepath: str) -> GraphQLSchema:
92 | with open(filepath, encoding='utf-8') as f:
93 | schema_string = f.read()
94 |
95 | return cls.from_sdl(schema_string)
96 |
97 |
98 |
99 | def from_sdl (schema_string: str) ‑> graphql.type.schema.GraphQLSchema
100 |
101 |
102 |
103 |
104 |
105 | Expand source code
106 |
107 | @classmethod
108 | def from_sdl(cls, schema_string: str) -> GraphQLSchema:
109 | return build_schema(schema_string)
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
141 |
142 |
145 |
146 |
--------------------------------------------------------------------------------
/tests/diff/test_directive.py:
--------------------------------------------------------------------------------
1 | from graphql import build_schema as schema
2 |
3 | from schemadiff.changes import Criticality
4 | from schemadiff.diff.schema import Schema
5 |
6 |
7 | def test_added_removed_directive():
8 | no_directive = schema("""
9 | type A {
10 | a: String
11 | }
12 | """)
13 | one_directive = schema("""
14 | directive @somedir on FIELD_DEFINITION
15 | type A {
16 | a: String
17 | }
18 | """)
19 | diff = Schema(no_directive, one_directive).diff()
20 | assert diff and len(diff) == 1
21 | assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION`"
22 | assert diff[0].path == '@somedir'
23 | assert diff[0].criticality == Criticality.safe()
24 |
25 | diff = Schema(one_directive, no_directive).diff()
26 | assert diff and len(diff) == 1
27 | assert diff[0].message == "Directive `@somedir` was removed"
28 | assert diff[0].path == '@somedir'
29 | assert diff[0].criticality == Criticality.breaking('Removing a directive may break clients that depend on them.')
30 |
31 | two_locations = schema("""
32 | directive @somedir on FIELD_DEFINITION | QUERY
33 | type A {
34 | a: String
35 | }
36 | """)
37 | diff = Schema(no_directive, two_locations).diff()
38 | assert diff and len(diff) == 1
39 | assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION | QUERY`"
40 | assert diff[0].path == '@somedir'
41 | assert diff[0].criticality == Criticality.safe()
42 |
43 |
44 | def test_description_changed():
45 | old_desc = schema('''
46 | """directive desc"""
47 | directive @somedir on FIELD_DEFINITION
48 |
49 | directive @nodesc on FIELD_DEFINITION
50 |
51 | """to be removed"""
52 | directive @toberemoveddesc on FIELD_DEFINITION
53 |
54 | type A {
55 | a: String
56 | }
57 | ''')
58 | new_desc = schema('''
59 | """updated desc"""
60 | directive @somedir on FIELD_DEFINITION
61 |
62 | """added desc"""
63 | directive @nodesc on FIELD_DEFINITION
64 |
65 | directive @toberemoveddesc on FIELD_DEFINITION
66 |
67 | type A {
68 | a: String
69 | }
70 | ''')
71 | diff = Schema(old_desc, new_desc).diff()
72 | assert diff and len(diff) == 3
73 | expected_diff = {
74 | "Description for directive `@somedir` changed from `directive desc` to `updated desc`",
75 | "Description for directive `@nodesc` changed from `None` to `added desc`",
76 | "Description for directive `@toberemoveddesc` changed from `to be removed` to `None`",
77 | }
78 | expected_paths = {'@somedir', '@nodesc', '@toberemoveddesc'}
79 | for change in diff:
80 | assert change.message in expected_diff
81 | assert change.path in expected_paths
82 | assert change.criticality == Criticality.safe()
83 |
84 |
85 | def test_directive_location_added_and_removed():
86 | one_location = schema("""
87 | directive @somedir on FIELD_DEFINITION
88 | type A {
89 | a: String
90 | }
91 | """)
92 | two_locations = schema("""
93 | directive @somedir on FIELD_DEFINITION | FIELD
94 | type A {
95 | a: String
96 | }
97 | """)
98 | diff = Schema(one_location, two_locations).diff()
99 | assert diff and len(diff) == 1
100 | expected_message = (
101 | "Directive locations of `@somedir` changed from `FIELD_DEFINITION` to `FIELD_DEFINITION | FIELD`"
102 | )
103 | assert diff[0].message == expected_message
104 | assert diff[0].path == '@somedir'
105 | assert diff[0].criticality == Criticality.safe()
106 |
107 | diff = Schema(two_locations, one_location).diff()
108 | assert diff and len(diff) == 1
109 | expected_message = (
110 | "Directive locations of `@somedir` changed from `FIELD_DEFINITION | FIELD` to `FIELD_DEFINITION`"
111 | )
112 | assert diff[0].message == expected_message
113 | assert diff[0].path == '@somedir'
114 | assert diff[0].criticality == Criticality.breaking(
115 | 'Removing a directive location will break any instance of its usage. Be sure no one uses it before removing it'
116 | )
117 |
118 |
119 | def test_directive_argument_changes():
120 | name_arg = schema("""
121 | directive @somedir(name: String) on FIELD_DEFINITION
122 | type A {
123 | a: String
124 | }
125 | """)
126 | id_arg = schema("""
127 | directive @somedir(id: ID) on FIELD_DEFINITION
128 | type A {
129 | a: String
130 | }
131 | """)
132 | diff = Schema(name_arg, id_arg).diff()
133 | assert diff and len(diff) == 2
134 | expected_message = (
135 | 'Removed argument `name: String` from `@somedir` directive'
136 | "Added argument `id: ID` to `@somedir` directive"
137 | )
138 | for change in diff:
139 | assert change.message in expected_message
140 | assert change.path == '@somedir'
141 | assert change.criticality == Criticality.breaking(
142 | 'Removing a directive argument will break existing usages of the argument'
143 | ) if 'Removed' in change.message else Criticality.safe()
144 |
145 |
146 | def test_directive_description_changed():
147 | no_desc = schema("""
148 | directive @my_directive on FIELD
149 | type A {
150 | a: String
151 | }
152 | """)
153 | with_desc = schema('''
154 | """directive desc"""
155 | directive @my_directive on FIELD
156 | type A {
157 | a: String
158 | }
159 | ''')
160 | new_desc = schema('''
161 | """new description"""
162 | directive @my_directive on FIELD
163 | type A {
164 | a: String
165 | }
166 | ''')
167 | diff = Schema(no_desc, with_desc).diff()
168 | assert diff and len(diff) == 1
169 | expected_message = (
170 | 'Description for directive `@my_directive` changed from `None` to `directive desc`'
171 | )
172 | assert diff[0].message == expected_message
173 | assert diff[0].path == '@my_directive'
174 | assert diff[0].criticality == Criticality.safe()
175 |
176 | diff = Schema(with_desc, new_desc).diff()
177 | assert diff and len(diff) == 1
178 | expected_message = (
179 | 'Description for directive `@my_directive` changed from `directive desc` to `new description`'
180 | )
181 | assert diff[0].message == expected_message
182 | assert diff[0].path == '@my_directive'
183 | assert diff[0].criticality == Criticality.safe()
184 |
185 | diff = Schema(with_desc, no_desc).diff()
186 | assert diff and len(diff) == 1
187 | expected_message = (
188 | 'Description for directive `@my_directive` changed from `directive desc` to `None`'
189 | )
190 | assert diff[0].message == expected_message
191 | assert diff[0].path == '@my_directive'
192 | assert diff[0].criticality == Criticality.safe()
193 |
194 |
195 | def test_directive_default_value_changed():
196 | default_100 = schema("""
197 | directive @limit(number: Int=100) on FIELD_DEFINITION
198 | type A {
199 | a: String
200 | }
201 | """)
202 | default_0 = schema("""
203 | directive @limit(number: Int=0) on FIELD_DEFINITION
204 | type A {
205 | a: String
206 | }
207 | """)
208 |
209 | diff = Schema(default_100, default_0).diff()
210 | assert diff and len(diff) == 1
211 | expected_message = (
212 | 'Default value for argument `number` on `@limit` directive changed from `100` to `0`'
213 | )
214 | assert diff[0].message == expected_message
215 | assert diff[0].path == '@limit'
216 | assert diff[0].criticality == Criticality.dangerous(
217 | 'Changing the default value for an argument may change '
218 | 'the runtime behaviour of a field if it was never provided.'
219 | )
220 |
221 |
222 | def test_directive_argument_type_changed():
223 | int_arg = schema("""
224 | directive @limit(number: Int) on FIELD_DEFINITION
225 | type A {
226 | a: String
227 | }
228 | """)
229 | float_arg = schema("""
230 | directive @limit(number: Float) on FIELD_DEFINITION
231 | type A {
232 | a: String
233 | }
234 | """)
235 |
236 | diff = Schema(int_arg, float_arg).diff()
237 | assert diff and len(diff) == 1
238 | expected_message = (
239 | "Type for argument `number` on `@limit` directive changed from `Int` to `Float`"
240 | )
241 | assert diff[0].message == expected_message
242 | assert diff[0].path == '@limit'
243 | assert diff[0].criticality == Criticality.breaking('Changing the argument type is a breaking change')
244 |
245 |
246 | def test_directive_argument_description_changed():
247 | no_desc = schema("""
248 | directive @limit(
249 | number: Int
250 | ) on FIELD_DEFINITION
251 |
252 | type A {
253 | a: String
254 | }
255 | """)
256 | a_desc = schema("""
257 | directive @limit(
258 | "number limit"
259 | number: Int
260 | ) on FIELD_DEFINITION
261 |
262 | type A {
263 | a: String
264 | }
265 | """)
266 | other_desc = schema("""
267 | directive @limit(
268 | "field limit"
269 | number: Int
270 | ) on FIELD_DEFINITION
271 |
272 | type A {
273 | a: String
274 | }
275 | """)
276 |
277 | diff = Schema(no_desc, a_desc).diff()
278 | assert diff and len(diff) == 1
279 | expected_message = (
280 | "Description for argument `number` on `@limit` directive changed from `None` to `number limit`"
281 | )
282 | assert diff[0].message == expected_message
283 | assert diff[0].path == '@limit'
284 | assert diff[0].criticality == Criticality.safe()
285 |
286 | diff = Schema(a_desc, other_desc).diff()
287 | assert diff and len(diff) == 1
288 | expected_message = (
289 | "Description for argument `number` on `@limit` directive changed from `number limit` to `field limit`"
290 | )
291 | assert diff[0].message == expected_message
292 | assert diff[0].path == '@limit'
293 | assert diff[0].criticality == Criticality.safe()
294 |
295 | diff = Schema(other_desc, no_desc).diff()
296 | assert diff and len(diff) == 1
297 | expected_message = (
298 | "Description for argument `number` on `@limit` directive changed from `field limit` to `None`"
299 | )
300 | assert diff[0].message == expected_message
301 | assert diff[0].path == '@limit'
302 | assert diff[0].criticality == Criticality.safe()
303 |
--------------------------------------------------------------------------------
/tests/test_validation_rules.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from graphql import build_schema as schema
3 |
4 | from schemadiff.changes import Change, Criticality
5 | from schemadiff.changes.field import FieldArgumentAdded
6 | from schemadiff.changes.object import ObjectTypeFieldAdded
7 | from schemadiff.validation import validate_changes
8 | from schemadiff.validation_rules import (
9 | ValidationRule,
10 | AddFieldWithoutDescription,
11 | AddTypeWithoutDescription,
12 | AddEnumValueWithoutDescription,
13 | RemoveEnumValueDescription,
14 | RemoveTypeDescription,
15 | )
16 | from schemadiff.diff.schema import Schema
17 |
18 |
19 | @pytest.mark.parametrize('rule', ValidationRule.__subclasses__())
20 | def test_is_valid_defaults_to_true_for_any_other_change(rule):
21 | class UnexpectedChange(Change):
22 | criticality = Criticality.safe()
23 |
24 | @property
25 | def message(self):
26 | return ""
27 |
28 | @property
29 | def path(self):
30 | return ""
31 |
32 | assert rule(UnexpectedChange()).is_valid() is True
33 |
34 |
35 | def test_type_added_with_desc():
36 | a = schema('''
37 | type MyType{
38 | a: Int
39 | }
40 | ''')
41 | b = schema('''
42 | type MyType{
43 | a: Int
44 | }
45 | """This has desc"""
46 | type NewType{
47 | """And its field also has a desc"""
48 | b: String!
49 | }
50 | ''')
51 | diff = Schema(a, b).diff()
52 | assert AddTypeWithoutDescription(diff[0]).is_valid() is True
53 |
54 |
55 | def test_type_added_with_desc_but_missing_desc_on_its_fields():
56 | a = schema('''
57 | type MyType{
58 | a: Int
59 | }
60 | ''')
61 | b = schema('''
62 | type MyType{
63 | a: Int
64 | }
65 | """This has desc"""
66 | type NewType{
67 | """This one has desc"""
68 | b: String!
69 | c: Int!
70 | }
71 | ''')
72 | diff = Schema(a, b).diff()
73 | assert AddTypeWithoutDescription(diff[0]).is_valid() is False
74 | assert AddTypeWithoutDescription(diff[0]).message == (
75 | 'Type `NewType` was added without a description for NewType (rule: '
76 | '`add-type-without-description`).'
77 | )
78 |
79 |
80 | def test_type_added_without_desc():
81 | a = schema('''
82 | type MyType{
83 | a: Int
84 | }
85 | ''')
86 | b = schema('''
87 | type MyType{
88 | a: Int
89 | }
90 | type NewType{
91 | b: String!
92 | }
93 | ''')
94 | diff = Schema(a, b).diff()
95 | assert AddTypeWithoutDescription(diff[0]).is_valid() is False
96 |
97 |
98 | def test_type_changed_desc_removed():
99 | a = schema('''
100 | """This has desc"""
101 | type MyType{
102 | a: Int
103 | }
104 | ''')
105 | b = schema('''
106 | type MyType{
107 | a: Int
108 | }
109 | ''')
110 | c = schema('''
111 | """"""
112 | type MyType{
113 | a: Int
114 | }
115 | ''')
116 | diff = Schema(a, b).diff()
117 | assert RemoveTypeDescription(diff[0]).is_valid() is False
118 |
119 | diff = Schema(a, c).diff()
120 | assert RemoveTypeDescription(diff[0]).is_valid() is False
121 |
122 |
123 | def test_field_added_without_desc():
124 | a = schema('''
125 | type MyType{
126 | a: Int
127 | }
128 | ''')
129 | b = schema('''
130 | type MyType{
131 | a: Int
132 | b: String!
133 | }
134 | ''')
135 | c = schema('''
136 | type MyType{
137 | a: Int
138 | """WithDesc"""
139 | b: String!
140 | }
141 | ''')
142 | diff = Schema(a, b).diff()
143 | assert AddFieldWithoutDescription(diff[0]).is_valid() is False
144 |
145 | diff = Schema(a, c).diff()
146 | assert AddFieldWithoutDescription(diff[0]).is_valid() is True
147 |
148 |
149 | def test_enum_added_without_desc():
150 | a = schema('''
151 | enum Letters {
152 | A
153 | }
154 | ''')
155 | b = schema('''
156 | enum Letters {
157 | A
158 | B
159 | }
160 | ''')
161 | c = schema('''
162 | enum Letters {
163 | A
164 | """WithDesc"""
165 | B
166 | }
167 | ''')
168 | diff = Schema(a, b).diff()
169 | assert AddEnumValueWithoutDescription(diff[0]).is_valid() is False
170 |
171 | diff = Schema(a, c).diff()
172 | assert AddEnumValueWithoutDescription(diff[0]).is_valid() is True
173 |
174 |
175 | def test_enum_value_removing_desc():
176 | a = schema('''
177 | enum Letters {
178 | """WithDesc"""
179 | A
180 | }
181 | ''')
182 | b = schema('''
183 | enum Letters {
184 | A
185 | }
186 | ''')
187 | diff = Schema(a, b).diff()
188 | assert RemoveEnumValueDescription(diff[0]).is_valid() is False
189 |
190 |
191 | def test_schema_added_field_no_desc():
192 |
193 | schema_restrictions = ['add-field-without-description']
194 |
195 | old_schema = schema("""
196 | schema {
197 | query: Query
198 | }
199 |
200 | type Query {
201 | field: String!
202 | }
203 |
204 | type AddedType {
205 | added: Int
206 | }
207 | """)
208 | new_schema = schema("""
209 | schema {
210 | query: Query
211 | }
212 |
213 | type Query {
214 | field: String!
215 | }
216 |
217 | type AddedType {
218 | added: Int
219 | other: Int
220 | }
221 | """)
222 | diff = Schema(old_schema, new_schema).diff()
223 | # Type Int was also added but its ignored because its a primitive.
224 | assert diff and len(diff) == 1
225 | error_msg = (
226 | 'Field `other` was added to object type `AddedType` without a description for AddedType.other '
227 | '(rule: `add-field-without-description`).'
228 | )
229 | result = validate_changes(diff, schema_restrictions)
230 | assert result.ok is False
231 | assert result.errors and len(result.errors) == 1
232 | assert result.errors[0].reason == error_msg
233 | assert diff[0].path == 'AddedType.other'
234 | assert result.errors[0].change.path == 'AddedType.other'
235 | assert result.errors[0].rule == 'add-field-without-description'
236 |
237 |
238 | # Register the new validation rule for the following two tests
239 | class FieldHasTooManyArguments(ValidationRule):
240 | """Restrict adding fields with too many top level arguments"""
241 |
242 | name = "field-has-too-many-arguments"
243 | limit = 10
244 |
245 | def is_valid(self) -> bool:
246 | if not isinstance(self.change, (ObjectTypeFieldAdded, FieldArgumentAdded)):
247 | return True
248 |
249 | if len(self.args) > self.limit:
250 | return False
251 | else:
252 | return True
253 |
254 | @property
255 | def args(self):
256 | return self.change.field.args or {}
257 |
258 | @property
259 | def message(self):
260 | return f"Field `{self.change.parent.name}.{self.change.field_name}` has too many arguments " \
261 | f"({len(self.args)}>{self.limit}). Rule: {self.name}"
262 |
263 |
264 | def test_cant_create_mutation_with_more_than_10_arguments():
265 | schema_restrictions = [FieldHasTooManyArguments.name]
266 |
267 | old_schema = schema("""
268 | schema {
269 | mutation: Mutation
270 | }
271 |
272 | type Mutation {
273 | field: Int
274 | }
275 | """)
276 |
277 | new_schema = schema("""
278 | schema {
279 | mutation: Mutation
280 | }
281 |
282 | type Mutation {
283 | field: Int
284 | mutation_with_too_many_args(
285 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int
286 | ): Int
287 | }
288 | """)
289 |
290 | diff = Schema(old_schema, new_schema).diff()
291 | # Type Int was also added but its ignored because its a primitive.
292 | assert diff and len(diff) == 1
293 | error_msg = (
294 | 'Field `Mutation.mutation_with_too_many_args` has too many arguments (11>10). '
295 | 'Rule: field-has-too-many-arguments'
296 | )
297 | result = validate_changes(diff, schema_restrictions)
298 | assert result.ok is False
299 | assert result.errors and len(result.errors) == 1
300 | assert result.errors[0].reason == error_msg
301 | assert result.errors[0].change.path == 'Mutation.mutation_with_too_many_args'
302 | assert result.errors[0].rule == FieldHasTooManyArguments.name
303 |
304 |
305 | def test_cant_add_arguments_to_mutation_if_exceeds_10_args():
306 | schema_restrictions = [FieldHasTooManyArguments.name]
307 |
308 | old_schema = schema("""
309 | schema {
310 | mutation: Mutation
311 | }
312 |
313 | type Mutation {
314 | field: Int
315 | mutation_with_too_many_args(
316 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int
317 | ): Int
318 | }
319 | """)
320 |
321 | new_schema = schema("""
322 | schema {
323 | mutation: Mutation
324 | }
325 |
326 | type Mutation {
327 | field: Int
328 | mutation_with_too_many_args(
329 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int
330 | ): Int
331 | }
332 | """)
333 |
334 | diff = Schema(old_schema, new_schema).diff()
335 | # Type Int was also added but its ignored because its a primitive.
336 | assert diff and len(diff) == 1
337 | error_msg = (
338 | 'Field `Mutation.mutation_with_too_many_args` has too many arguments (11>10). '
339 | 'Rule: field-has-too-many-arguments'
340 | )
341 | result = validate_changes(diff, schema_restrictions)
342 | assert result.ok is False
343 | assert result.errors and len(result.errors) == 1
344 | assert result.errors[0].reason == error_msg
345 | assert result.errors[0].change.message == "Argument `a11: Int` added to `Mutation.mutation_with_too_many_args`"
346 | assert result.errors[0].change.checksum() == "221964c2ab5bbc6bd1ed19bcd8d69e70"
347 | assert result.errors[0].rule == FieldHasTooManyArguments.name
348 |
349 | assert diff[0].path == 'Mutation.mutation_with_too_many_args'
350 |
351 |
352 | def test_can_allow_rule_infractions():
353 | CHANGE_ID = '221964c2ab5bbc6bd1ed19bcd8d69e70'
354 | validation_rules = [FieldHasTooManyArguments.name]
355 |
356 | old_schema = schema("""
357 | schema {
358 | mutation: Mutation
359 | }
360 |
361 | type Mutation {
362 | field: Int
363 | mutation_with_too_many_args(
364 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int
365 | ): Int
366 | }
367 | """)
368 |
369 | new_schema = schema("""
370 | schema {
371 | mutation: Mutation
372 | }
373 |
374 | type Mutation {
375 | field: Int
376 | mutation_with_too_many_args(
377 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int
378 | ): Int
379 | }
380 | """)
381 |
382 | changes = Schema(old_schema, new_schema).diff()
383 | result = validate_changes(changes, validation_rules)
384 | assert result.ok is False
385 | assert result.errors[0].change.checksum() == CHANGE_ID
386 | result = validate_changes(changes, validation_rules, allowed_changes={CHANGE_ID})
387 | assert result.ok is True
388 | assert result.errors == []
389 |
--------------------------------------------------------------------------------
/docs/allow_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | schemadiff.allow_list API documentation
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Module schemadiff.allow_list
23 |
24 |
25 |
26 |
27 | Expand source code
28 |
29 | import json
30 | import re
31 | from json import JSONDecodeError
32 |
33 |
34 | class InvalidAllowlist(Exception):
35 | """Exception raised when the user provides an invalid json file of allowed changes"""
36 |
37 |
38 | def read_allowed_changes(file_content):
39 | """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
40 |
41 | Compliant formats:
42 | {
43 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it',
44 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant',
45 | }
46 | But the value can be as detailed as the user wants (as long as the key remains the change checksum)
47 | {
48 | '0cc175b9c0f1b6a831c399e269772661': {
49 | 'date': '2030-01-01 15:00:00,
50 | 'reason': 'my reason',
51 | 'message': 'Field `a` was removed from object type `Query`'
52 | },
53 | '92eb5ffee6ae2fec3ad71c777531578f': {
54 | 'date': '2031-01-01 23:59:00,
55 | 'reason': 'my new reason',
56 | 'message': 'Field `b` was removed from object type `MyType`'
57 | },
58 | }
59 | """
60 | try:
61 | allowlist = json.loads(file_content)
62 | except JSONDecodeError as e:
63 | raise InvalidAllowlist("Invalid json format provided.") from e
64 | if not isinstance(allowlist, dict):
65 | raise InvalidAllowlist("Allowlist must be a mapping.")
66 |
67 | CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}')
68 | if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()):
69 | raise InvalidAllowlist("All keys must be a valid md5 checksum")
70 |
71 | return allowlist
72 |
73 |
74 |
76 |
78 |
79 |
80 |
81 |
82 | def read_allowed_changes (file_content)
83 |
84 |
85 | Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
86 |
Compliant formats:
87 | {
88 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field MyField because clients don't use it',
89 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument Arg because it became redundant',
90 | }
91 | But the value can be as detailed as the user wants (as long as the key remains the change checksum)
92 | {
93 | '0cc175b9c0f1b6a831c399e269772661': {
94 | 'date': '2030-01-01 15:00:00,
95 | 'reason': 'my reason',
96 | 'message': 'Field a was removed from object type Query'
97 | },
98 | '92eb5ffee6ae2fec3ad71c777531578f': {
99 | 'date': '2031-01-01 23:59:00,
100 | 'reason': 'my new reason',
101 | 'message': 'Field b was removed from object type MyType'
102 | },
103 | }
104 |
105 |
106 | Expand source code
107 |
108 | def read_allowed_changes(file_content):
109 | """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
110 |
111 | Compliant formats:
112 | {
113 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it',
114 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant',
115 | }
116 | But the value can be as detailed as the user wants (as long as the key remains the change checksum)
117 | {
118 | '0cc175b9c0f1b6a831c399e269772661': {
119 | 'date': '2030-01-01 15:00:00,
120 | 'reason': 'my reason',
121 | 'message': 'Field `a` was removed from object type `Query`'
122 | },
123 | '92eb5ffee6ae2fec3ad71c777531578f': {
124 | 'date': '2031-01-01 23:59:00,
125 | 'reason': 'my new reason',
126 | 'message': 'Field `b` was removed from object type `MyType`'
127 | },
128 | }
129 | """
130 | try:
131 | allowlist = json.loads(file_content)
132 | except JSONDecodeError as e:
133 | raise InvalidAllowlist("Invalid json format provided.") from e
134 | if not isinstance(allowlist, dict):
135 | raise InvalidAllowlist("Allowlist must be a mapping.")
136 |
137 | CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}')
138 | if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()):
139 | raise InvalidAllowlist("All keys must be a valid md5 checksum")
140 |
141 | return allowlist
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | class InvalidAllowlist
151 | ( *args, **kwargs)
152 |
153 |
154 | Exception raised when the user provides an invalid json file of allowed changes
155 |
156 |
157 | Expand source code
158 |
159 | class InvalidAllowlist(Exception):
160 | """Exception raised when the user provides an invalid json file of allowed changes"""
161 |
162 | Ancestors
163 |
164 | builtins.Exception
165 | builtins.BaseException
166 |
167 |
168 |
169 |
170 |
171 |
196 |
197 |
200 |
201 |
--------------------------------------------------------------------------------