diff --git a/Pipfile b/Pipfile index 8a077ada0..e5e7289d3 100755 --- a/Pipfile +++ b/Pipfile @@ -39,7 +39,7 @@ sphinx = ">=1.8,<2" [packages] # Make sure to keep in sync with setup.py requirements. arrow = ">=0.15.0,<1" funcy = ">=1.7.3,<2" -graphql-core = ">=3.1,<3.2" # minor versions sometimes contain breaking changes +graphql-core = ">=3.1.2,<3.2" # minor versions sometimes contain breaking changes pytz = ">=2017.2" six = ">=1.10.0" sqlalchemy = ">=1.3.0,<2" diff --git a/Pipfile.lock b/Pipfile.lock index ad74acaf7..44e1998ee 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f94f71022717ed1f235c4bf301d947442cae49f33eca52dd1e3de3c23f9fb412" + "sha256": "812ec0cb824e6eba8716586a28fe56599f33ad9dcc5ae531683e588d1d622e08" }, "pipfile-spec": 6, "requires": { @@ -56,7 +56,6 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "pytz": { @@ -130,7 +129,6 @@ "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" ], - "markers": "python_version >= '3.5'", "version": "==2.3.3" }, "attrs": { @@ -138,7 +136,6 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -146,7 +143,6 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "bandit": { @@ -184,14 +180,12 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "codecov": { "hashes": [ "sha256:491938ad774ea94a963d5d16354c7299e90422a33a353ba0d38d0943ed1d5091", - "sha256:b67bb8029e8340a7bf22c71cbece5bd18c96261fdebc2f105ee4d5a005bc8728", - "sha256:d8b8109f44edad03b24f5f189dac8de9b1e3dc3c791fa37eeaf8c7381503ec34" + "sha256:b67bb8029e8340a7bf22c71cbece5bd18c96261fdebc2f105ee4d5a005bc8728" ], "index": "pypi", "version": "==2.1.7" @@ -233,7 +227,6 @@ "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.2" }, "docutils": { @@ -241,7 +234,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "fastdiff": { @@ -278,7 +270,6 @@ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" ], - "markers": "python_version >= '3.4'", "version": "==4.0.5" }, "gitpython": { @@ -286,7 +277,6 @@ "sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a", "sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac" ], - "markers": "python_version >= '3.4'", "version": "==3.1.3" }, "idna": { @@ -294,7 +284,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -302,7 +291,6 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "isort": { @@ -318,7 +306,6 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "lazy-object-proxy": { @@ -345,7 +332,6 @@ "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.4.3" }, "markupsafe": { @@ -384,7 +370,6 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "mccabe": { @@ -399,7 +384,6 @@ "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], - "markers": "python_version >= '3.5'", "version": "==8.4.0" }, "mypy": { @@ -463,7 +447,6 @@ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "parameterized": { @@ -493,7 +476,6 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "psycopg2-binary": { @@ -545,7 +527,6 @@ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, "pycodestyle": { @@ -553,7 +534,6 @@ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -569,7 +549,6 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pygments": { @@ -577,7 +556,6 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pylint": { @@ -620,7 +598,6 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -709,7 +686,6 @@ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.24.0" }, "six": { @@ -725,7 +701,6 @@ "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.4" }, "snapshottest": { @@ -763,7 +738,6 @@ "sha256:a2100b79096bbaea5a41e03261cee279d19c803218b9401a1d84ef6aeae17338", "sha256:ee1d43e6e0332558a66fcb4005b9ba7313ad9764d0df0e6703ae869a028e451f" ], - "markers": "python_version >= '3.5'", "version": "==1.2.3" }, "stevedore": { @@ -771,7 +745,6 @@ "sha256:609912b87df5ad338ff8e44d13eaad4f4170a65b79ae9cb0aa5632598994a1b7", "sha256:c4724f8d7b8f6be42130663855d01a9c2414d6046055b5a65ab58a0e38637688" ], - "markers": "python_version >= '3.6'", "version": "==2.0.1" }, "termcolor": { @@ -826,7 +799,6 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "wasmer": { diff --git a/graphql_compiler/schema_transformation/rename_schema.py b/graphql_compiler/schema_transformation/rename_schema.py index fa7bcc558..db6eb1287 100644 --- a/graphql_compiler/schema_transformation/rename_schema.py +++ b/graphql_compiler/schema_transformation/rename_schema.py @@ -1,6 +1,78 @@ # Copyright 2019-present Kensho Technologies, LLC. +"""Implement renaming and suppressing parts of a GraphQL schema. + +There are two ways to rename a part of a schema: 1-1 renaming and 1-many renaming. + +1-1 renaming replaces the name of a type, field, or enum value in the schema. For instance, given +the following part of a schema: + type Dog { + name: String + } + type Human { + pet: Dog + } +1-1 renaming "Dog" to "NewDog" on a schema containing this object type (but not containing a +type named "NewDog") would produce a schema almost identical to the original schema except +with the NewDog object type replacing Dog everywhere it appears. + type NewDog { + name: String + } + type Human { + pet: NewDog + } +If "Dog" also appeared as a field in the schema's root type, it would be renamed to "NewDog" there +as well. + +1-many renaming is an operation intended specifically for any of the following: +- fields in object types +- fields in interface types +- enum values +In 1-many renaming, the same field or enum value is mapped to multiple names. For instance, given +the following type in a schema: + type Dog { + name: String + } +1-many renaming the "Dog" type's "name" field to "name" and "secondname" would produce a schema +almost identical to the original schema except with both fields representing the same underlying +data. + type Dog { + name: String + secondname: String + } + +Suppressing part of the schema removes it altogether. For instance, given the following part of a +schema: + type Dog { + name: String + } +suppressing "Dog" would produce an otherwise-identical schema but with that type (and therefore all +its fields) removed. If "Dog" also appeared as a field in the schema's root type, it would be +removed there as well. + +Operations that are already supported: +- 1-1 renaming of object types, unions, enums, and interfaces. +- Suppressing types that don't implement an interface. +- Suppressing unions. + +Operations that are not yet supported but will be implemented: +- Suppressions for fields, enums, enum values, interfaces, and types that implement interfaces. +- Renamings and suppressions for scalar types +- 1-1 and 1-many renamings for fields and enum values. + +Renaming constraints: +- If you suppress all member types in a union, you must also suppress the union. +- If you suppress a type X, no other type Y may keep fields of type X (those fields must be + suppressed, which requires field suppression which hasn't been implemented yet). However, if type + X has a field of that type X, it is legal to suppress type X without explicitly suppressing that + particular field. +- You may not suppress all types in the schema's root type. +- All names must be valid GraphQL names. +- Names may not conflict with each other. For instance, you may not rename both "Foo" and "Bar" to + "Baz". You also may not rename anything to "Baz" if a type "Baz" already exists and is not also + being renamed or suppressed. +""" from collections import namedtuple -from typing import AbstractSet, Any, Dict, List, Mapping, Tuple, TypeVar, Union, cast +from typing import AbstractSet, Any, Dict, List, Mapping, Optional, Set, Tuple, TypeVar, Union, cast from graphql import ( DocumentNode, @@ -13,11 +85,14 @@ UnionTypeDefinitionNode, build_ast_schema, ) -from graphql.language.visitor import IDLE, Visitor, visit +from graphql.language.visitor import IDLE, REMOVE, Visitor, VisitorAction, visit import six +from ..ast_manipulation import get_ast_with_non_null_and_list_stripped from .utils import ( + CascadingSuppressionError, SchemaNameConflictError, + SchemaTransformError, check_ast_schema_is_valid, check_type_name_is_valid, get_copy_of_node_with_new_name, @@ -37,14 +112,15 @@ ) -# Union of classes of nodes to be renamed by an instance of RenameSchemaTypesVisitor. Note that -# RenameSchemaTypesVisitor also has a class attribute rename_types which parallels the classes here. -# This duplication is necessary due to language and linter constraints-- see the comment in the -# RenameSchemaTypesVisitor class for more information. +# Union of classes of nodes to be renamed or suppressed by an instance of RenameSchemaTypesVisitor. +# Note that RenameSchemaTypesVisitor also has a class attribute rename_types which parallels the +# classes here. This duplication is necessary due to language and linter constraints-- see the +# comment in the RenameSchemaTypesVisitor class for more information. # Unfortunately, RenameTypes itself has to be a module attribute instead of a class attribute # because a bug in flake8 produces a linting error if RenameTypes is a class attribute and we type -# hint the return value of the RenameSchemaTypesVisitor's _rename_name_and_add_to_record() method as -# RenameTypes. More on this here: https://github.com/PyCQA/pyflakes/issues/441 +# hint the return value of the RenameSchemaTypesVisitor's +# _rename_or_suppress_or_ignore_name_and_add_to_record() method as RenameTypes. More on this here: +# https://github.com/PyCQA/pyflakes/issues/441 RenameTypes = Union[ EnumTypeDefinitionNode, InterfaceTypeDefinitionNode, @@ -54,42 +130,47 @@ ] RenameTypesT = TypeVar("RenameTypesT", bound=RenameTypes) # AST visitor functions can return a number of different things, such as returning a Node (to update -# that node) or returning a special value defined in graphql.visitor such as REMOVE (to remove the -# node) and IDLE (to do nothing with the node). In the current GraphQL-core version (>=3,<3.1), -# REMOVE is set to the singleton object Ellipsis and IDLE is set to None. However, because these -# special values' underlying definitions can change, we can't type-hint functions returning a -# special value with anything more specific than Any. For more information, see: -# https://github.com/kensho-technologies/graphql-compiler/pull/834#discussion_r434622400 -# We can update this type hint when a future GraphQL-core release organizes these special return -# values into an enum. -# https://github.com/graphql-python/graphql-core/issues/96 -VisitorReturnType = Union[Node, Any] +# that node) or returning a special value specified in graphql.visitor's VisitorAction. +VisitorReturnType = Union[Node, VisitorAction] def rename_schema( - schema_ast: DocumentNode, renamings: Mapping[str, str] + schema_ast: DocumentNode, renamings: Mapping[str, Optional[str]] ) -> RenamedSchemaDescriptor: - """Create a RenamedSchemaDescriptor; types and query type fields are renamed using renamings. + """Create a RenamedSchemaDescriptor; rename/suppress types and root type fields using renamings. Any type, interface, enum, or fields of the root type/query type whose name - appears in renamings will be renamed to the corresponding value. Any such names that do not - appear in renamings will be unchanged. Scalars, directives, enum values, and fields not - belonging to the root/query type will never be renamed. + appears in renamings will be renamed to the corresponding value if the value is not None. If the + value is None, it will be suppressed in the renamed schema and queries will not be able to + access it. + + Any such names that do not appear in renamings will be unchanged. Directives will never be + renamed. + + In addition, some operations have not been implemented yet (see module-level docstring for more + details). Args: schema_ast: represents a valid schema that does not contain extensions, input object definitions, mutations, or subscriptions, whose fields of the query type share the same name as the types they query. Not modified by this function - renamings: maps original type/field names to renamed type/field names. Type or query type - field names that do not appear in the dict will be unchanged. Any dict-like - object that implements get(key, [default]) may also be used + renamings: maps original type name to renamed name or None (for type suppression). Any + name not in the dict will be unchanged Returns: - RenamedSchemaDescriptor, a namedtuple that contains the AST of the renamed schema, and the - map of renamed type/field names to original names. Only renamed names will be included - in the map. + RenamedSchemaDescriptor containing the AST of the renamed schema, and the map of renamed + type/field names to original names. Only renamed names will be included in the map. Raises: + - CascadingSuppressionError if a type suppression would require further suppressions + - SchemaTransformError if renamings suppressed every type. Note that this is a superclass of + CascadingSuppressionError, InvalidTypeNameError, SchemaStructureError, and + SchemaNameConflictError, so handling exceptions of type SchemaTransformError will also + catch all of its subclasses. This will change after the error classes are modified so that + errors can be fixed programmatically, at which point it will make sense for the user to + attempt to treat different errors differently + - NotImplementedError if renamings attempts to suppress an enum, an interface, or a type + implementing an interface - InvalidTypeNameError if the schema contains an invalid type name, or if the user attempts to rename a type to an invalid name. A name is considered invalid if it does not consist of alphanumeric characters and underscores, if it starts with a numeric character, or @@ -107,16 +188,19 @@ def rename_schema( query_type = get_query_type_name(schema) scalars = get_scalar_names(schema) - # Rename types, interfaces, enums - schema_ast, reverse_name_map = _rename_types(schema_ast, renamings, query_type, scalars) + _validate_renamings(schema_ast, renamings, query_type) + + # Rename types, interfaces, enums, unions and suppress types, unions + schema_ast, reverse_name_map = _rename_and_suppress_types( + schema_ast, renamings, query_type, scalars + ) reverse_name_map_changed_names_only = { renamed_name: original_name for renamed_name, original_name in six.iteritems(reverse_name_map) if renamed_name != original_name } - # Rename query type fields - schema_ast = _rename_query_type_fields(schema_ast, renamings, query_type) + schema_ast = _rename_and_suppress_query_type_fields(schema_ast, renamings, query_type) return RenamedSchemaDescriptor( schema_ast=schema_ast, schema=build_ast_schema(schema_ast), @@ -124,29 +208,144 @@ def rename_schema( ) -def _rename_types( +def _validate_renamings( + schema_ast: DocumentNode, renamings: Mapping[str, Optional[str]], query_type: str +) -> None: + """Validate the renamings argument before attempting to rename the schema. + + Check for fields with suppressed types or unions whose members were all suppressed. Also, + confirm renamings contains no enums, interfaces, or interface implementation suppressions + because that hasn't been implemented yet. + + The input AST will not be modified. + + Args: + schema_ast: represents a valid schema that does not contain extensions, input object + definitions, mutations, or subscriptions, whose fields of the query type share + the same name as the types they query. Not modified by this function + renamings: maps original type name to renamed name or None (for type suppression). Any name + not in the dict will be unchanged + query_type: name of the query type, e.g. 'RootSchemaQuery' + + Raises: + - CascadingSuppressionError if a type suppression would require further suppressions + - NotImplementedError if renamings attempts to suppress an enum, an interface, or a type + implementing an interface + """ + _check_for_cascading_type_suppression(schema_ast, renamings, query_type) + _ensure_no_unsupported_suppression(schema_ast, renamings) + + +def _check_for_cascading_type_suppression( + schema_ast: DocumentNode, renamings: Mapping[str, Optional[str]], query_type: str +) -> None: + """Check for fields with suppressed types or unions whose members were all suppressed.""" + visitor = CascadingSuppressionCheckVisitor(renamings, query_type) + visit(schema_ast, visitor) + if visitor.fields_to_suppress or visitor.union_types_to_suppress: + error_message_components = [ + f"Type renamings {renamings} would require further suppressions to produce a valid " + f"renamed schema." + ] + if visitor.fields_to_suppress: + for object_type in visitor.fields_to_suppress: + error_message_components.append(f"Object type {object_type} contains: ") + error_message_components.extend( + ( + f"field {field} of suppressed type " + f"{visitor.fields_to_suppress[object_type][field]}, " + for field in visitor.fields_to_suppress[object_type] + ) + ) + error_message_components.append( + "A schema containing a field that is of a nonexistent type is invalid. When field " + "suppression is supported, you can fix this problem by suppressing the fields " + "shown above." + ) + if visitor.union_types_to_suppress: + for union_type in visitor.union_types_to_suppress: + error_message_components.append( + f"Union type {union_type} has no non-suppressed members: " + ) + error_message_components.extend( + (union_member.name.value for union_member in union_type.types) + ) + error_message_components.append( + "To fix this, you can suppress the union as well by adding union_type: None to the " + "renamings argument when renaming types, for each value of union_type described " + "here. Note that adding suppressions may lead to other types, fields, etc. " + "requiring suppression so you may need to iterate on this before getting a legal " + "schema." + ) + raise CascadingSuppressionError("\n".join(error_message_components)) + + +def _ensure_no_unsupported_suppression( + schema_ast: DocumentNode, renamings: Mapping[str, Optional[str]] +) -> None: + """Confirm renamings contains no enums, interfaces, or interface implementation suppressions.""" + visitor = SuppressionNotImplementedVisitor(renamings) + visit(schema_ast, visitor) + if ( + not visitor.unsupported_enum_suppressions + and not visitor.unsupported_interface_suppressions + and not visitor.unsupported_interface_implementation_suppressions + ): + return + # Otherwise, attempted to suppress something we shouldn't suppress. + error_message_components = [ + f"Type renamings {renamings} attempted to suppress parts of the schema for which " + f"suppression is not implemented yet." + ] + if visitor.unsupported_enum_suppressions: + error_message_components.append( + f"Type renamings mapped these schema enums to None: " + f"{visitor.unsupported_enum_suppressions}, attempting to suppress them. However, " + f"schema renaming has not implemented enum suppression yet." + ) + if visitor.unsupported_interface_suppressions: + error_message_components.append( + f"Type renamings mapped these schema interfaces to None: " + f"{visitor.unsupported_interface_suppressions}, attempting to suppress them. However, " + f"schema renaming has not implemented interface suppression yet." + ) + if visitor.unsupported_interface_implementation_suppressions: + error_message_components.append( + f"Type renamings mapped these object types to None: " + f"{visitor.unsupported_interface_implementation_suppressions}, attempting to suppress " + f"them. Normally, this would be fine. However, these types each implement at least one " + f"interface and schema renaming has not implemented this particular suppression yet." + ) + error_message_components.append( + "To avoid these suppressions, remove the mappings from the renamings argument." + ) + raise NotImplementedError("\n".join(error_message_components)) + + +def _rename_and_suppress_types( schema_ast: DocumentNode, - renamings: Mapping[str, str], + renamings: Mapping[str, Optional[str]], query_type: str, scalars: AbstractSet[str], ) -> Tuple[DocumentNode, Dict[str, str]]: - """Rename types, enums, interfaces using renamings. + """Rename and suppress types, enums, interfaces using renamings. - The query type will not be renamed. Scalar types, field names, enum values will not be renamed. + The query type will not be renamed. The input schema AST will not be modified. Args: schema_ast: schema that we're returning a modified version of - renamings: maps original type/interface/enum name to renamed name. Any name not in the dict - will be unchanged + renamings: maps original type name to renamed name or None (for type suppression). Any + name not in the dict will be unchanged query_type: name of the query type, e.g. 'RootSchemaQuery' scalars: set of all scalars used in the schema, including user defined scalars and used builtin scalars, excluding unused builtins Returns: Tuple containing the modified version of the schema AST, and the renamed type name to - original type name map. Map contains all types, including those that were not renamed. + original type name map. Map contains all non-suppressed types, including those that were not + renamed. Raises: - InvalidTypeNameError if the schema contains an invalid type name, or if the user attempts @@ -158,21 +357,24 @@ def _rename_types( return renamed_schema_ast, visitor.reverse_name_map -def _rename_query_type_fields( - schema_ast: DocumentNode, renamings: Mapping[str, str], query_type: str +def _rename_and_suppress_query_type_fields( + schema_ast: DocumentNode, renamings: Mapping[str, Optional[str]], query_type: str ) -> DocumentNode: - """Rename all fields of the query type. + """Rename or suppress all fields of the query type. The input schema AST will not be modified. Args: schema_ast: schema that we're returning a modified version of - renamings: maps original query type field name to renamed name. Any name not in the dict - will be unchanged + renamings: maps original type name to renamed name or None (for type suppression). Any name + not in the dict will be unchanged query_type: name of the query type, e.g. 'RootSchemaQuery' Returns: modified version of the input schema AST + + Raises: + - SchemaTransformError if renamings suppressed every type """ visitor = RenameQueryTypeFieldsVisitor(renamings, query_type) renamed_schema_ast = visit(schema_ast, visitor) @@ -244,28 +446,33 @@ class RenameSchemaTypesVisitor(Visitor): ) def __init__( - self, renamings: Mapping[str, str], query_type: str, scalar_types: AbstractSet[str] + self, + renamings: Mapping[str, Optional[str]], + query_type: str, + scalar_types: AbstractSet[str], ) -> None: """Create a visitor for renaming types in a schema AST. Args: - renamings: maps original type name to renamed name. Any name not in the dict will be - unchanged + renamings: maps original type name to renamed name or None (for type suppression). Any + name not in the dict will be unchanged query_type: name of the query type (e.g. RootSchemaQuery), which will not be renamed scalar_types: set of all scalars used in the schema, including all user defined scalars and any builtin scalars that were used """ self.renamings = renamings - self.reverse_name_map: Dict[str, str] = {} # from renamed type name to original type name - # reverse_name_map contains all types, including those that were unchanged + self.reverse_name_map: Dict[str, str] = {} # From renamed type name to original type name + # reverse_name_map contains all non-suppressed types, including those that were unchanged self.query_type = query_type self.scalar_types = frozenset(scalar_types) self.builtin_types = frozenset({"String", "Int", "Float", "Boolean", "ID"}) - def _rename_name_and_add_to_record(self, node: RenameTypesT) -> RenameTypesT: - """Change the name of the input node if necessary, add the name pair to reverse_name_map. + def _rename_or_suppress_or_ignore_name_and_add_to_record( + self, node: RenameTypesT + ) -> Union[RenameTypesT, VisitorAction]: + """Specify input node change based on renamings. If node renamed, update reverse_name_map. - Don't rename if the type is the query type, a scalar type, or a builtin type. + Don't rename if the type is the query type or a builtin type. The input node will not be modified. reverse_name_map may be modified. @@ -274,8 +481,11 @@ def _rename_name_and_add_to_record(self, node: RenameTypesT) -> RenameTypesT: corresponding to an AST node of type NameNode. Returns: - Node object, identical to the input node, except with possibly a new name. If the - name was not changed, the returned object is the exact same object as the input + Node object, REMOVE, or IDLE. The GraphQL library defines special return values REMOVE + and IDLE to delete or do nothing with the node a visitor is currently at, respectively. + If the current node is to be renamed, this function returns a Node object identical to + the input node except with a new name. If it is to be suppressed, this function returns + REMOVE. If neither of these are the case, this function returns IDLE. Raises: - InvalidTypeNameError if either the node's current name or renamed name is invalid @@ -285,9 +495,12 @@ def _rename_name_and_add_to_record(self, node: RenameTypesT) -> RenameTypesT: name_string = node.name.value if name_string == self.query_type or name_string in self.scalar_types: - return node + return IDLE new_name_string = self.renamings.get(name_string, name_string) # Default use original + if new_name_string is None: + # Suppress the type + return REMOVE check_type_name_is_valid(new_name_string) if ( @@ -308,7 +521,7 @@ def _rename_name_and_add_to_record(self, node: RenameTypesT) -> RenameTypesT: self.reverse_name_map[new_name_string] = name_string if new_name_string == name_string: - return node + return IDLE else: # Make copy of node with the changed name, return the copy node_with_new_name = get_copy_of_node_with_new_name(node, new_name_string) return node_with_new_name @@ -322,30 +535,30 @@ def enter( # Do nothing, continue traversal return IDLE elif node_type in self.rename_types: - # Rename node, put name pair into record - renamed_node = self._rename_name_and_add_to_record(cast(RenameTypes, node)) - if renamed_node is node: # Name unchanged, continue traversal - return IDLE - else: # Name changed, return new node, `visit` will make shallow copies along path - return renamed_node + # Process the node by either renaming, suppressing, or not doing anything with it + # (depending on what renamings specifies) + return self._rename_or_suppress_or_ignore_name_and_add_to_record( + cast(RenameTypes, node) + ) else: # All Node types should've been taken care of, this line should never be reached raise AssertionError('Unreachable code reached. Missed type: "{}"'.format(node_type)) class RenameQueryTypeFieldsVisitor(Visitor): - def __init__(self, renamings: Mapping[str, str], query_type: str) -> None: - """Create a visitor for renaming fields of the query type in a schema AST. + def __init__(self, renamings: Mapping[str, Optional[str]], query_type: str) -> None: + """Create a visitor for renaming or suppressing fields of the query type in a schema AST. Args: - renamings: maps original field name to renamed field name. Any name not in the dict will - be unchanged + renamings: maps original type name to renamed name or None (for type suppression). Any + name not in the dict will be unchanged query_type: name of the query type (e.g. RootSchemaQuery) + + Raises: + - SchemaTransformError if every field in the query type was suppressed """ # Note that as field names and type names have been confirmed to match up, any renamed - # field already has a corresponding renamed type. If no errors, due to either invalid - # names or name conflicts, were raised when renaming type, no errors will occur when - # renaming query type fields. + # query type field already has a corresponding renamed type. self.in_query_type = False self.renamings = renamings self.query_type = query_type @@ -371,6 +584,12 @@ def leave_object_type_definition( ancestors: List[Any], ) -> None: """If the node's name matches the query type, record that we left the query type.""" + if not node.fields: + raise SchemaTransformError( + f"Type renamings {self.renamings} suppressed every type in the schema so it will " + f"be impossible to query for anything. To fix this, check why the `renamings` " + f"argument of `rename_schema` mapped every type to None." + ) if node.name.value == self.query_type: self.in_query_type = False @@ -382,14 +601,196 @@ def enter_field_definition( path: List[Any], ancestors: List[Any], ) -> VisitorReturnType: - """If inside the query type, rename field and add the name pair to reverse_field_map.""" + """If inside query type, rename or remove field as specified by renamings.""" if self.in_query_type: field_name = node.name.value new_field_name = self.renamings.get(field_name, field_name) # Default use original if new_field_name == field_name: return IDLE + if new_field_name is None: + # Suppress the type + return REMOVE else: # Make copy of node with the changed name, return the copy field_node_with_new_name = get_copy_of_node_with_new_name(node, new_field_name) return field_node_with_new_name return IDLE + + +class CascadingSuppressionCheckVisitor(Visitor): + """Traverse the schema to check for cascading suppression issues. + + The fields_to_suppress attribute records non-suppressed fields that depend on suppressed types. + The union_types_to_suppress attribute records unions that had all its members suppressed. + + After calling visit() on the schema using this visitor, if any of these attributes are non-empty + then there are further suppressions required to produce a legal schema so the code should then + raise a CascadingSuppressionError. + + """ + + # For a type named T, and its field named F whose type has name V, this dict would be + # {"T": {"F": "V"}} + fields_to_suppress: Dict[str, Dict[str, str]] + union_types_to_suppress: List[UnionTypeDefinitionNode] + + def __init__(self, renamings: Mapping[str, Optional[str]], query_type: str) -> None: + """Create a visitor to check that suppression does not cause an illegal state. + + Args: + renamings: maps original type name to renamed name or None (for type suppression). Any + name not in the dict will be unchanged + query_type: name of the query type (e.g. RootSchemaQuery) + """ + self.renamings = renamings + self.query_type = query_type + self.current_type: Optional[str] = None + self.fields_to_suppress = {} + self.union_types_to_suppress = [] + + def enter_object_type_definition( + self, + node: ObjectTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """Record the current type that the visitor is traversing.""" + self.current_type = node.name.value + + def leave_object_type_definition( + self, + node: ObjectTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """Finish traversing the current type node.""" + self.current_type = None + + def enter_field_definition( + self, + node: FieldDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """Check that no type Y contains a field of type X, where X is suppressed.""" + if self.current_type == self.query_type: + return IDLE + # At a field of a type that is not the query type + field_name = node.name.value + field_type = get_ast_with_non_null_and_list_stripped(node.type).name.value + if self.renamings.get(field_type, field_type): + return IDLE + # Reaching this point means this field is of a type to be suppressed. + if self.current_type is None: + raise AssertionError( + "Entered a field not in any ObjectTypeDefinition scope because " + "self.current_type is None" + ) + if self.current_type == field_type: + # Then node corresponds to a field belonging to type T that is also of type T. + # Therefore, we don't need to explicitly suppress the field as well and this should not + # raise errors. + return IDLE + if self.current_type not in self.fields_to_suppress: + self.fields_to_suppress[self.current_type] = {} + self.fields_to_suppress[self.current_type][field_name] = field_type + return IDLE + + def enter_union_type_definition( + self, + node: UnionTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """Check that each union still has at least one non-suppressed member.""" + union_name = node.name.value + # Check if all the union members are suppressed. + for union_member in node.types: + union_member_type = get_ast_with_non_null_and_list_stripped(union_member).name.value + if self.renamings.get(union_member_type, union_member_type): + # Then at least one member of the union is not suppressed, so there is no cascading + # suppression error concern. + return IDLE + if self.renamings.get(union_name, union_name) is None: + # If the union is also suppressed, then nothing needs to happen here + return IDLE + self.union_types_to_suppress.append(node) + + +class SuppressionNotImplementedVisitor(Visitor): + """Traverse the schema to check for suppressions that are not yet implemented. + + Each attribute that mentions an unsupported suppression records the types that renamings + attempts to suppress. + + After calling visit() on the schema using this visitor, if any of these attributes are non-empty + then some suppressions specified by renamings are unsupported, so the code should then raise a + NotImplementedError. + + """ + + unsupported_enum_suppressions: Set[str] + unsupported_interface_suppressions: Set[str] + unsupported_interface_implementation_suppressions: Set[str] + + def __init__(self, renamings: Mapping[str, Optional[str]]) -> None: + """Confirm renamings does not attempt to suppress enum/interface/interface implementation. + + Args: + renamings: from original field name to renamed field name or None (for type + suppression). Any name not in the dict will be unchanged + """ + self.renamings = renamings + self.unsupported_enum_suppressions = set() + self.unsupported_interface_suppressions = set() + self.unsupported_interface_implementation_suppressions = set() + + def enter_enum_type_definition( + self, + node: EnumTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """If renamings has enum suppression, record it for error message.""" + enum_name = node.name.value + if self.renamings.get(enum_name, enum_name) is None: + self.unsupported_enum_suppressions.add(enum_name) + + def enter_interface_type_definition( + self, + node: InterfaceTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """If renamings has interface suppression, record it for error message.""" + interface_name = node.name.value + if self.renamings.get(interface_name, interface_name) is None: + self.unsupported_interface_suppressions.add(interface_name) + + def enter_object_type_definition( + self, + node: ObjectTypeDefinitionNode, + key: Any, + parent: Any, + path: List[Any], + ancestors: List[Any], + ) -> None: + """If renamings has interface implementation suppression, record it for error message.""" + if not node.interfaces: + return + object_name = node.name.value + if self.renamings.get(object_name, object_name) is None: + # Suppressing interface implementations isn't supported yet. + self.unsupported_interface_implementation_suppressions.add(object_name) diff --git a/graphql_compiler/schema_transformation/utils.py b/graphql_compiler/schema_transformation/utils.py index 463a2267c..e66732cba 100644 --- a/graphql_compiler/schema_transformation/utils.py +++ b/graphql_compiler/schema_transformation/utils.py @@ -57,6 +57,21 @@ class InvalidCrossSchemaEdgeError(SchemaTransformError): """ +class CascadingSuppressionError(SchemaTransformError): + """Raised if existing suppressions would require further suppressions. + + This may be raised during schema renaming if it: + * suppresses all the fields of a type but not the type itself + * suppresses all the members of a union but not the union itself + * suppresses a type X but there still exists a different type Y that has fields of type X. + The error message will suggest fixing this illegal state by describing further suppressions, but + adding these suppressions may lead to other types, unions, fields, etc. needing suppressions of + their own. Most real-world schemas wouldn't have these cascading situations, and if they do, + they are unlikely to have many of them, so the error messages are not meant to describe the full + sequence of steps required to fix all suppression errors in one pass. + """ + + _alphanumeric_and_underscore = frozenset(six.text_type(string.ascii_letters + string.digits + "_"))