def test_directive_location_added_and_removed(): one_location = schema(""" directive @somedir on FIELD_DEFINITION type A { a: String } """) two_locations = schema(""" directive @somedir on FIELD_DEFINITION | FIELD type A { a: String } """) diff = Schema(one_location, two_locations).diff() assert diff and len(diff) == 1 expected_message = ( "Directive locations of `@somedir` changed from `FIELD_DEFINITION` to `FIELD_DEFINITION | FIELD`" ) assert diff[0].message == expected_message assert diff[0].path == '@somedir' assert diff[0].criticality == Criticality.safe() diff = Schema(two_locations, one_location).diff() assert diff and len(diff) == 1 expected_message = ( "Directive locations of `@somedir` changed from `FIELD_DEFINITION | FIELD` to `FIELD_DEFINITION`" ) assert diff[0].message == expected_message assert diff[0].path == '@somedir' assert diff[0].criticality == Criticality.breaking( 'Removing a directive location will break any instance of its usage. Be sure no one uses it before removing it' )
def test_input_field_added_field(): a = schema(""" input Recipe { ingredients: [String] } """) b = schema(""" input Recipe { ingredients: [String] love: Float } """) diff = Schema(a, b).diff() assert diff and len(diff) == 1 assert diff[0].message == ( "Input Field `love: Float` was added to input type `Recipe`") assert diff[0].path == 'Recipe.love' assert diff[0].criticality == Criticality.safe() diff = Schema(b, a).diff() assert diff and len(diff) == 1 assert diff[0].message == ( "Input Field `love` removed from input type `Recipe`") assert diff[0].path == 'Recipe.love' assert diff[0].criticality == Criticality.breaking( 'Removing an input field will break queries that use this input field.' )
def test_interface_field_added_and_removed(): a = schema(""" interface Person { name: String age: Int } """) b = schema(""" interface Person { name: String age: Int favorite_number: Float } """) diff = Schema(a, b).diff() assert diff and len(diff) == 1 assert diff[ 0].message == "Field `favorite_number` of type `Float` was added to interface `Person`" assert diff[0].path == 'Person.favorite_number' assert diff[0].criticality == Criticality.dangerous( 'Adding an interface to an object type may break existing clients ' 'that were not programming defensively against a new possible type.') diff = Schema(b, a).diff() assert diff[ 0].message == "Field `favorite_number` was removed from interface `Person`" assert diff[0].path == 'Person.favorite_number' assert diff[0].criticality == Criticality.dangerous( 'Removing an interface field can break existing queries that use this in a fragment spread.' )
def test_added_removed_directive(): no_directive = schema(""" type A { a: String } """) one_directive = schema(""" directive @somedir on FIELD_DEFINITION type A { a: String } """) diff = Schema(no_directive, one_directive).diff() assert diff and len(diff) == 1 assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION`" assert diff[0].path == '@somedir' assert diff[0].criticality == Criticality.safe() diff = Schema(one_directive, no_directive).diff() assert diff and len(diff) == 1 assert diff[0].message == "Directive `@somedir` was removed" assert diff[0].path == '@somedir' assert diff[0].criticality == Criticality.breaking('Removing a directive may break clients that depend on them.') two_locations = schema(""" directive @somedir on FIELD_DEFINITION | QUERY type A { a: String } """) diff = Schema(no_directive, two_locations).diff() assert diff and len(diff) == 1 assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION | QUERY`" assert diff[0].path == '@somedir' assert diff[0].criticality == Criticality.safe()
def test_object_type_added_field(): a = schema(""" type MyType{ a: Int } """) b = schema(""" type MyType{ a: Int b: String! } """) diff = Schema(a, b).diff() assert diff and len(diff) == 1 assert diff[0].message == "Field `b` was added to object type `MyType`" assert diff[0].path == 'MyType.b' assert diff[0].criticality == Criticality.safe() diff = Schema(b, a).diff() assert diff and len(diff) == 1 assert diff[0].message == "Field `b` was removed from object type `MyType`" assert diff[0].path == 'MyType.b' assert diff[0].criticality == Criticality.breaking( 'Removing a field is a breaking change. It is preferred to deprecate the field before removing it.' )
def test_add_type_to_union(): two_types = schema(""" type Query { c: Int } type Result { message: String } type Error { message: String details: String } type Unknown { message: String details: String traceback: String } union Outcome = Result | Error """) three_types = schema(""" type Query { c: Int } type Result { message: String } type Error { message: String details: String } type Unknown { message: String details: String traceback: String } union Outcome = Result | Error | Unknown """) diff = Schema(two_types, three_types).diff() assert diff and len(diff) == 1 assert diff[ 0].message == "Union member `Unknown` was added to `Outcome` Union type" assert diff[0].path == 'Outcome' assert diff[0].criticality == Criticality.dangerous( 'Adding a possible type to Unions may break existing clients ' 'that were not programming defensively against a new possible type.') diff = Schema(three_types, two_types).diff() assert diff and len(diff) == 1 assert diff[ 0].message == "Union member `Unknown` was removed from `Outcome` Union type" assert diff[0].path == 'Outcome' assert diff[0].criticality == Criticality.breaking( 'Removing a union member from a union can break queries that use this union member in a fragment spread' )
def test_directive_argument_description_changed(): no_desc = schema(""" directive @limit( number: Int ) on FIELD_DEFINITION type A { a: String } """) a_desc = schema(""" directive @limit( "number limit" number: Int ) on FIELD_DEFINITION type A { a: String } """) other_desc = schema(""" directive @limit( "field limit" number: Int ) on FIELD_DEFINITION type A { a: String } """) diff = Schema(no_desc, a_desc).diff() assert diff and len(diff) == 1 expected_message = ( "Description for argument `number` on `@limit` directive changed from `None` to `number limit`" ) assert diff[0].message == expected_message assert diff[0].path == '@limit' assert diff[0].criticality == Criticality.safe() diff = Schema(a_desc, other_desc).diff() assert diff and len(diff) == 1 expected_message = ( "Description for argument `number` on `@limit` directive changed from `number limit` to `field limit`" ) assert diff[0].message == expected_message assert diff[0].path == '@limit' assert diff[0].criticality == Criticality.safe() diff = Schema(other_desc, no_desc).diff() assert diff and len(diff) == 1 expected_message = ( "Description for argument `number` on `@limit` directive changed from `field limit` to `None`" ) assert diff[0].message == expected_message assert diff[0].path == '@limit' assert diff[0].criticality == Criticality.safe()
def test_schema_query_root_changed(): old_schema = schema(""" schema { query: Query } type Query { field: String! } """) new_schema = schema(""" schema { query: ChangedQuery } type ChangedQuery { field: String! } """) diff = Schema(old_schema, new_schema).diff() print(diff) assert diff and len(diff) == 3 assert {x.message for x in diff} == { 'Type `ChangedQuery` was added', 'Type `Query` was removed', 'Schema query root has changed from `Query` to `ChangedQuery`' }
def test_deprecated_reason_changed(): a = schema(""" type Query { a: Int } enum Letters { A B @deprecated(reason: "a reason") } """) b = schema(""" type Query { a: Int } enum Letters { A B @deprecated(reason: "a new reason") } """) diff = Schema(a, b).diff() assert len(diff) == 1 assert diff[0].message == ( "Deprecation reason for enum value `B` changed from `a reason` to `a new reason`" ) assert diff[0].path == 'Letters.B' assert diff[0].criticality == Criticality.safe( "A deprecated field can still be used by clients and will give them time to adapt their queries" )
def test_description_changed(): a = schema(''' type Query { a: Int } enum Letters { """My description""" A } ''') b = schema(''' type Query { a: Int } enum Letters { """My new description""" A } ''') diff = Schema(a, b).diff() assert len(diff) == 1 assert diff[0].message == ( "Description for enum value `A` changed from `My description` to `My new description`" ) assert diff[0].path == 'Letters.A' assert diff[0].criticality == Criticality.safe()
def test_enums_added(): a = schema(""" type Query { a: String } enum Letters { A B } """) b = schema(""" type Query { a: String } enum Letters { A B C D } """) diff = Schema(a, b).diff() assert len(diff) == 2 expected_diff = { "Enum value `C` was added to `Letters` enum", "Enum value `D` was added to `Letters` enum", } expected_paths = {'Letters.C', 'Letters.D'} for change in diff: assert change.message in expected_diff assert change.path in expected_paths assert change.criticality == Criticality.dangerous( "Adding an enum value may break existing clients that " "were not programming defensively against an added case when querying an enum." )
def test_schema_removed_type(): old_schema = schema(""" schema { query: Query } type Query { field: String! } type ToBeRemovedType { added: Int } """) new_schema = schema(""" schema { query: Query } type Query { field: String! } """) diff = Schema(old_schema, new_schema).diff() assert diff and len(diff) == 1 # Type Int was also removed but it is ignored because it's a primitive. assert diff[0].message == "Type `ToBeRemovedType` was removed" assert diff[0].criticality == Criticality.breaking( 'Removing a type is a breaking change. It is preferred to ' 'deprecate and remove all references to this type first.')
def test_schema_added_type(): old_schema = schema(""" schema { query: Query } type Query { field: String! } """) new_schema = schema(""" schema { query: Query } type Query { field: String! } type AddedType { added: Int } """) diff = Schema(old_schema, new_schema).diff() assert diff and len(diff) == 1 # Type Int was also added but its ignored because its a primitive. assert diff[0].message == "Type `AddedType` was added" assert diff[0].criticality == Criticality.safe()
def test_schema_mutation_root_changed(): old_schema = schema(""" schema { query: Query } type Query { field: String! } """) new_schema = schema(""" schema { query: Query mutation: Mutation } type Query { field: String! } type Mutation { my_mutation: Int } """) diff = Schema(old_schema, new_schema).diff() print(diff) assert diff and len(diff) == 2 assert {x.message for x in diff} == { 'Type `Mutation` was added', 'Schema mutation root has changed from `None` to `Mutation`' }
def test_directive_argument_changes(): name_arg = schema(""" directive @somedir(name: String) on FIELD_DEFINITION type A { a: String } """) id_arg = schema(""" directive @somedir(id: ID) on FIELD_DEFINITION type A { a: String } """) diff = Schema(name_arg, id_arg).diff() assert diff and len(diff) == 2 expected_message = ( 'Removed argument `name: String` from `@somedir` directive' "Added argument `id: ID` to `@somedir` directive" ) for change in diff: assert change.message in expected_message assert change.path == '@somedir' assert change.criticality == Criticality.breaking( 'Removing a directive argument will break existing usages of the argument' ) if 'Removed' in change.message else Criticality.safe()
def test_enum_value_removed(): a = schema(""" type Query { a: String } enum Letters { A B } """) b = schema(""" type Query { a: String } enum Letters { A } """) diff = Schema(a, b).diff() assert len(diff) == 1 change = diff[0] assert change.message == "Enum value `B` was removed from `Letters` enum" assert change.path == 'Letters' assert change.criticality == Criticality.breaking( 'Removing an enum value will break existing queries that use this enum value' )
def test_print_json_shows_difference(capsys): old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql') new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql') diff = Schema(old_schema, new_schema).diff() assert len(diff) == 1 ret = print_json(diff) assert ret is None json_output = capsys.readouterr().out output_as_dict = json.loads(json_output) change_message = "Field `a` was removed from object type `Query`" assert output_as_dict == [{ "message": change_message, "path": "Query.a", "is_safe_change": False, "criticality": { "level": "BREAKING", "reason": "Removing a field is a breaking change. " "It is preferred to deprecate the field before removing it." }, "checksum": '5fba3d6ffc43c6769c6959ce5cb9b1c8' }]
def test_deprecated_enum_value(): a = schema(""" type Query { a: Int } enum Letters { A B } """) b = schema(""" type Query { a: Int } enum Letters { A B @deprecated(reason: "Changed the alphabet") } """) diff = Schema(a, b).diff() assert len(diff) == 1 assert diff[ 0].message == "Enum value `B` was deprecated with reason `Changed the alphabet`" assert diff[0].path == 'Letters.B' assert diff[0].criticality == Criticality.safe( "A deprecated field can still be used by clients and will give them time to adapt their queries" )
def test_directive_default_value_changed(): default_100 = schema(""" directive @limit(number: Int=100) on FIELD_DEFINITION type A { a: String } """) default_0 = schema(""" directive @limit(number: Int=0) on FIELD_DEFINITION type A { a: String } """) diff = Schema(default_100, default_0).diff() assert diff and len(diff) == 1 expected_message = ( 'Default value for argument `number` on `@limit` directive changed from `100` to `0`' ) assert diff[0].message == expected_message assert diff[0].path == '@limit' assert diff[0].criticality == Criticality.dangerous( 'Changing the default value for an argument may change ' 'the runtime behaviour of a field if it was never provided.' )
def test_schema_query_fields_type_has_changes(): old_schema = schema(""" schema { query: Query } type Query { field: String! } """) new_schema = schema(""" schema { query: Query } type Query { field: Int! } """) diff = Schema(old_schema, new_schema).diff() assert diff and len(diff) == 1 assert diff[ 0].message == "`Query.field` type changed from `String!` to `Int!`" assert diff[0].criticality == Criticality.breaking( 'Changing a field type will break queries that assume its type')
def test_directive_description_changed(): no_desc = schema(""" directive @my_directive on FIELD type A { a: String } """) with_desc = schema(''' """directive desc""" directive @my_directive on FIELD type A { a: String } ''') new_desc = schema(''' """new description""" directive @my_directive on FIELD type A { a: String } ''') diff = Schema(no_desc, with_desc).diff() assert diff and len(diff) == 1 expected_message = ( 'Description for directive `@my_directive` changed from `None` to `directive desc`' ) assert diff[0].message == expected_message assert diff[0].path == '@my_directive' assert diff[0].criticality == Criticality.safe() diff = Schema(with_desc, new_desc).diff() assert diff and len(diff) == 1 expected_message = ( 'Description for directive `@my_directive` changed from `directive desc` to `new description`' ) assert diff[0].message == expected_message assert diff[0].path == '@my_directive' assert diff[0].criticality == Criticality.safe() diff = Schema(with_desc, no_desc).diff() assert diff and len(diff) == 1 expected_message = ( 'Description for directive `@my_directive` changed from `directive desc` to `None`' ) assert diff[0].message == expected_message assert diff[0].path == '@my_directive' assert diff[0].criticality == Criticality.safe()
def diff_from_file(schema_file: str, other_schema_file: str): """Compare two graphql schema files highlighting dangerous and breaking changes. Returns: changes (List[Change]): List of differences between both schemas with details about each change """ first = SchemaLoader.from_file(schema_file) second = SchemaLoader.from_file(other_schema_file) return Schema(first, second).diff()
def test_type_implements_new_interface(): a = schema(""" interface InterfaceA { a: Int } type MyType implements InterfaceA { a: Int } """) b = schema(""" interface InterfaceA { a: Int } interface InterfaceB { b: String } type MyType implements InterfaceA & InterfaceB { a: Int b: String } """) diff = Schema(a, b).diff() assert diff and len(diff) == 3 expected_diff = { "Field `b` was added to object type `MyType`", "Type `InterfaceB` was added", "`MyType` implements new interface `InterfaceB`" } expected_paths = {'InterfaceB', 'MyType', 'MyType.b'} for change in diff: assert change.message in expected_diff assert change.path in expected_paths diff = Schema(b, a).diff() assert diff and len(diff) == 3 expected_diff = { "Field `b` was removed from object type `MyType`", "Type `InterfaceB` was removed", "`MyType` no longer implements interface `InterfaceB`" } expected_paths = {'InterfaceB', 'MyType', 'MyType.b'} for change in diff: assert change.message in expected_diff assert change.path in expected_paths
def test_schema_no_diff(): a_schema = schema(""" schema { query: Query } type Query { a: String! } """) diff = Schema(a_schema, a_schema).diff() assert diff == []
def test_print_diff_shows_difference(capsys): old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql') new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql') diff = Schema(old_schema, new_schema).diff() assert len(diff) == 1 ret = print_diff(diff) assert ret is None assert capsys.readouterr().out == ( '❌ Field `a` was removed from object type `Query`\n')
def diff(old_schema: Union[SDL, GQLSchema], new_schema: Union[SDL, GQLSchema]) -> List[Change]: """Compare two graphql schemas highlighting dangerous and breaking changes. Returns: changes (List[Change]): List of differences between both schemas with details about each change """ first = SchemaLoader.from_sdl( old_schema) if not is_schema(old_schema) else old_schema second = SchemaLoader.from_sdl( new_schema) if not is_schema(new_schema) else new_schema return Schema(first, second).diff()
def test_field_type_change_is_safe(): non_nullable_schema = schema(""" type Query { a: Int! } """) nullable_schema = schema(""" type Query { a: Int } """) # Dropping the non-null constraint is a breaking change because clients may have assumed it could never be null diff = Schema(non_nullable_schema, nullable_schema).diff() assert len(diff) == 1 assert diff[0].criticality == Criticality.breaking( 'Changing a field type will break queries that assume its type') # But if it was already nullable, they already had to handle that case. diff = Schema(nullable_schema, non_nullable_schema).diff() assert len(diff) == 1 assert diff[0].criticality == Criticality.safe()
def test_compare_from_schema_string_sdl(): old_schema = SchemaLoader.from_file(TESTS_DATA / 'old_schema.gql') new_schema = SchemaLoader.from_file(TESTS_DATA / 'new_schema.gql') diff = Schema(old_schema, new_schema).diff() assert len(diff) == 38 expected_changes = { 'Argument `arg: Int` added to `CType.a`', "Default value for argument `anotherArg` on `@yolo` directive changed from `Undefined` to `'Test'`", 'Default value for argument `arg` on field `CType.d` changed from `Undefined` to `10`', 'Default value for argument `arg` on field `WithArguments.b` changed from `1` to `2`', "Default value for input field `AInput.a` changed from `'1'` to `1`", 'Deprecation reason for enum value `F` changed from `Old` to `New`', 'Deprecation reason on field `CType.a` changed from `whynot` to `cuz`', 'Description for Input field `AInput.a` changed from `a` to `None`', 'Description for argument `a` on field `WithArguments.a` changed from `Meh` to `None`', 'Description for argument `someArg` on `@yolo` directive changed from `Included when true.` to `None`', 'Description for directive `@yolo` changed from `Old` to `None`', 'Description for type `Query` changed from `The Query Root of this schema` to `None`', 'Directive `@willBeRemoved` was removed', 'Directive `@yolo2` was added to use on `FIELD`', ( 'Directive locations of `@yolo` changed from `FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT` ' 'to `FIELD | FIELD_DEFINITION`' ), 'Enum value `C` was removed from `Options` enum', 'Enum value `D` was added to `Options` enum', 'Enum value `E` was deprecated with reason `No longer supported`', 'Field `anotherInterfaceField` was removed from interface `AnotherInterface`', 'Field `b` of type `Int` was added to interface `AnotherInterface`', 'Field `b` was added to object type `CType`', 'Field `c` was removed from object type `CType`', 'Input Field `b` removed from input type `AInput`', 'Input Field `c: String!` was added to input type `AInput`', 'Removed argument `anArg` from `Query.a`', 'Removed argument `willBeRemoved: Boolean!` from `@yolo` directive', 'Type `DType` was added', 'Type `WillBeRemoved` was removed', 'Type for argument `b` on field `WithArguments.a` changed from `String` to `String!`', 'Type for argument `someArg` on `@yolo` directive changed from `Boolean!` to `String!`', 'Union member `BType` was removed from `MyUnion` Union type', 'Union member `DType` was added to `MyUnion` Union type', '`AInput.a` type changed from `String` to `Int`', '`BType` kind changed from `OBJECT` to `INPUT OBJECT`', '`CType` implements new interface `AnInterface`', '`Query.a` description changed from `Just a simple string` to `None`', '`Query.b` type changed from `BType` to `Int!`', '`WithInterfaces` no longer implements interface `AnotherInterface`' } messages = [change.message for change in diff] for message in messages: assert message in expected_changes
def test_input_field_type_nullability_change_on_lists_of_the_same_underlying_types( ): a = schema(""" input Params { arg: [ID!]! } """) b = schema(""" input Params { arg: [ID!] } """) c = schema(""" input Params { arg: [ID] } """) d = schema(""" input Params { arg: ID } """) diff = Schema(a, b).diff() assert diff and len(diff) == 1 assert diff[ 0].message == "`Params.arg` type changed from `[ID!]!` to `[ID!]`" assert diff[0].path == 'Params.arg' assert diff[0].criticality == Criticality.safe( ) # Because dropping the non-null constraint will not break anything diff = Schema(a, c).diff() assert diff[ 0].message == "`Params.arg` type changed from `[ID!]!` to `[ID]`" assert diff[0].path == 'Params.arg' assert diff[0].criticality == Criticality.breaking(ERROR) diff = Schema(a, d).diff() assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `ID`" assert diff[0].path == 'Params.arg' assert diff[0].criticality == Criticality.breaking(ERROR)
def test_no_field_diff(): a = schema(""" type Query { b: Int! } """) b = schema(""" type Query { b: Int! } """) diff = Schema(a, b).diff() assert not diff