diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ec900..a6c7ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Qenerate Changelog +## 0.7.0 + +New Features: + +* Add `empty_map_to_none` to properly handle `{}` responses ([#76](https://github.com/app-sre/qenerate/pull/76)) + ## 0.6.1 BUGFIX: diff --git a/README.md b/README.md index d527c08..19a43d8 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,17 @@ The above will tell qenerate to map the GQL `JSON` type to `str` instead of pyda # qenerate: map_gql_scalar=DateTime -> str ``` +### Allow Empty Maps + +Some GQL servers might return an empty map `{}` instead of `null`. In that case you can use `empty_map_to_none=True`. +This will properly convert `{}` to `None`. By default this behavior is disabled, because it adds some +extra overhead when converting untyped data to typed classes. E.g., in case of `pydantic_v1` we add +a `@root_validator` to iterate over each given value. + +```graphql +# qenerate: empty_map_to_none=True +``` + ### Naming Collision Strategy ```graphql diff --git a/pyproject.toml b/pyproject.toml index 6b16ac8..58fd3a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "qenerate" -version = "0.6.1" +version = "0.7.0" description = "Code Generator for GraphQL Query and Fragment Data Classes" authors = [ "Red Hat - Service Delivery - AppSRE " diff --git a/qenerate/core/feature_flag_parser.py b/qenerate/core/feature_flag_parser.py index 665e24f..279888e 100644 --- a/qenerate/core/feature_flag_parser.py +++ b/qenerate/core/feature_flag_parser.py @@ -14,6 +14,7 @@ class FeatureFlags: plugin: str gql_scalar_mappings: Mapping[str, str] collision_strategy: NamingCollisionStrategy = NamingCollisionStrategy.PARENT_CONTEXT + empty_map_to_none: bool = False class FeatureFlagError(Exception): @@ -56,6 +57,17 @@ def custom_type_mapping(definition: str) -> dict[str, str]: for groups in m: mappings[groups[0]] = groups[1] return mappings + + @staticmethod + def empty_map_to_none(definition: str) -> bool: + m = re.search( + r"#\s*qenerate:\s*empty_map_to_none\s*=\s*(\w+)\s*", + definition, + ) + allow = False + if m: + allow = m.group(1) in ("True", "true", "TRUE", "yes") + return allow @staticmethod def parse(definition: str) -> FeatureFlags: @@ -67,4 +79,7 @@ def parse(definition: str) -> FeatureFlags: gql_scalar_mappings=FeatureFlagParser.custom_type_mapping( definition=definition ), + empty_map_to_none=FeatureFlagParser.empty_map_to_none( + definition=definition + ) ) diff --git a/qenerate/plugins/pydantic_v1/plugin.py b/qenerate/plugins/pydantic_v1/plugin.py index c34cbda..06a8f9c 100644 --- a/qenerate/plugins/pydantic_v1/plugin.py +++ b/qenerate/plugins/pydantic_v1/plugin.py @@ -76,6 +76,25 @@ ) +CONF_WITH_VALIDATOR = ( + f"class {BASE_CLASS_NAME}(BaseModel):\n" + f"{INDENT}@root_validator(pre=True)\n" + f"{INDENT}def remove_empty(cls, values: dict):\n" + f"{INDENT}{INDENT}fields = list(values.keys())\n" + f"{INDENT}{INDENT}for field in fields:\n" + f"{INDENT}{INDENT}{INDENT}value = values[field]\n" + f"{INDENT}{INDENT}{INDENT}if isinstance(value, dict):\n" + f"{INDENT}{INDENT}{INDENT}{INDENT}if not values[field]:\n" + f"{INDENT}{INDENT}{INDENT}{INDENT}{INDENT}values[field] = None\n" + f"{INDENT}{INDENT}return values\n\n" + f"{INDENT}class Config:\n" + # https://pydantic-docs.helpmanual.io/usage/model_config/#smart-union + # https://stackoverflow.com/a/69705356/4478420 + f"{INDENT}{INDENT}smart_union=True\n" + f"{INDENT}{INDENT}extra=Extra.forbid" +) + + def query_convenience_function(cls: str) -> str: return f""" def query(query_func: Callable, **kwargs: Any) -> {cls}: @@ -374,7 +393,10 @@ def generate_fragments( if fragment_imports: result += "\n" result += fragment_imports - result += f"\n\n\n{CONF}" + if definition.feature_flags.empty_map_to_none: + result += f"\n\n\n{CONF_WITH_VALIDATOR}" + else: + result += f"\n\n\n{CONF}" qf = definition.source_file parser = QueryParser() ast = parser.parse( @@ -469,7 +491,10 @@ def generate_operations( ) ) result += 'DEFINITION = """\n' f"{assembled_definition}" '\n"""' - result += f"\n\n\n{CONF}" + if definition.feature_flags.empty_map_to_none: + result += f"\n\n\n{CONF_WITH_VALIDATOR}" + else: + result += f"\n\n\n{CONF}" parser = QueryParser() ast = parser.parse( definition=definition, diff --git a/setup.py b/setup.py index dd4ff07..f828506 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup_kwargs = { "name": "qenerate", - "version": "0.6.1", + "version": "0.7.0", "description": "Code Generator for GraphQL Query and Fragment Data Classes", "long_description": "Qenerate is a Code Generator for GraphQL Query and Fragment Data Classes. Documentation is at https://github.com/app-sre/qenerate .", "author": "Service Delivery - AppSRE", diff --git a/tests/core/test_feature_flags.py b/tests/core/test_feature_flags.py index f6879ba..e0e8645 100644 --- a/tests/core/test_feature_flags.py +++ b/tests/core/test_feature_flags.py @@ -19,6 +19,7 @@ plugin="PluginV1", collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, gql_scalar_mappings={}, + empty_map_to_none=False, ), ], [ @@ -31,6 +32,7 @@ plugin="PluginV1", collision_strategy=NamingCollisionStrategy.ENUMERATE, gql_scalar_mappings={}, + empty_map_to_none=False, ), ], [ @@ -43,6 +45,7 @@ plugin="PluginV1", collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, gql_scalar_mappings={}, + empty_map_to_none=False, ), ], [ @@ -55,6 +58,7 @@ plugin="PluginV1", collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, gql_scalar_mappings={"JSON": "str"}, + empty_map_to_none=False, ), ], [ @@ -68,6 +72,46 @@ plugin="PluginV1", collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, gql_scalar_mappings={"JSON": "str", "A": "B"}, + empty_map_to_none=False, + ), + ], + [ + """ + # qenerate: plugin=PluginV1 + # qenerate: empty_map_to_none=True + query {} + """, + FeatureFlags( + plugin="PluginV1", + collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, + gql_scalar_mappings={}, + empty_map_to_none=True, + ), + ], + [ + """ + # qenerate: plugin=PluginV1 + # qenerate: empty_map_to_none=true + query {} + """, + FeatureFlags( + plugin="PluginV1", + collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, + gql_scalar_mappings={}, + empty_map_to_none=True, + ), + ], + [ + """ + # qenerate: plugin=PluginV1 + # qenerate: empty_map_to_none=no + query {} + """, + FeatureFlags( + plugin="PluginV1", + collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT, + gql_scalar_mappings={}, + empty_map_to_none=False, ), ], ], diff --git a/tests/generator/definitions/empty_map_to_none/fragment.gql b/tests/generator/definitions/empty_map_to_none/fragment.gql new file mode 100644 index 0000000..fe3c1b2 --- /dev/null +++ b/tests/generator/definitions/empty_map_to_none/fragment.gql @@ -0,0 +1,6 @@ +fragment VaultSecret on VaultSecret_v1 { + path + field + version + format +} diff --git a/tests/generator/definitions/empty_map_to_none/query.gql b/tests/generator/definitions/empty_map_to_none/query.gql new file mode 100644 index 0000000..668d327 --- /dev/null +++ b/tests/generator/definitions/empty_map_to_none/query.gql @@ -0,0 +1,9 @@ +query SaasFilesWithEnum { + apps_v1 { + saasFiles { + pipelinesProvider { + labels + } + } + } +} diff --git a/tests/generator/expected/pydantic_v1/empty_map_to_none/fragment.py.txt b/tests/generator/expected/pydantic_v1/empty_map_to_none/fragment.py.txt new file mode 100644 index 0000000..485d241 --- /dev/null +++ b/tests/generator/expected/pydantic_v1/empty_map_to_none/fragment.py.txt @@ -0,0 +1,41 @@ +""" +Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY! +""" +from collections.abc import Callable # noqa: F401 # pylint: disable=W0611 +from datetime import datetime # noqa: F401 # pylint: disable=W0611 +from enum import Enum # noqa: F401 # pylint: disable=W0611 +from typing import ( # noqa: F401 # pylint: disable=W0611 + Any, + Optional, + Union, +) + +from pydantic import ( # noqa: F401 # pylint: disable=W0611 + BaseModel, + Extra, + Field, + Json, +) + + +class ConfiguredBaseModel(BaseModel): + @root_validator(pre=True) + def remove_empty(cls, values: dict): + fields = list(values.keys()) + for field in fields: + value = values[field] + if isinstance(value, dict): + if not values[field]: + values[field] = None + return values + + class Config: + smart_union=True + extra=Extra.forbid + + +class VaultSecret(ConfiguredBaseModel): + path: str = Field(..., alias="path") + field: str = Field(..., alias="field") + version: Optional[int] = Field(..., alias="version") + q_format: Optional[str] = Field(..., alias="format") diff --git a/tests/generator/expected/pydantic_v1/empty_map_to_none/query.py.txt b/tests/generator/expected/pydantic_v1/empty_map_to_none/query.py.txt new file mode 100644 index 0000000..d8b614c --- /dev/null +++ b/tests/generator/expected/pydantic_v1/empty_map_to_none/query.py.txt @@ -0,0 +1,83 @@ +""" +Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY! +""" +from collections.abc import Callable # noqa: F401 # pylint: disable=W0611 +from datetime import datetime # noqa: F401 # pylint: disable=W0611 +from enum import Enum # noqa: F401 # pylint: disable=W0611 +from typing import ( # noqa: F401 # pylint: disable=W0611 + Any, + Optional, + Union, +) + +from pydantic import ( # noqa: F401 # pylint: disable=W0611 + BaseModel, + Extra, + Field, + Json, +) + + +DEFINITION = """ +query SaasFilesWithEnum { + apps_v1 { + saasFiles { + pipelinesProvider { + labels + } + } + } +} + +""" + + +class ConfiguredBaseModel(BaseModel): + @root_validator(pre=True) + def remove_empty(cls, values: dict): + fields = list(values.keys()) + for field in fields: + value = values[field] + if isinstance(value, dict): + if not values[field]: + values[field] = None + return values + + class Config: + smart_union=True + extra=Extra.forbid + + +class PipelinesProviderV1(ConfiguredBaseModel): + labels: Optional[Json] = Field(..., alias="labels") + + +class SaasFileV2(ConfiguredBaseModel): + pipelines_provider: PipelinesProviderV1 = Field(..., alias="pipelinesProvider") + + +class AppV1(ConfiguredBaseModel): + saas_files: Optional[list[Optional[SaasFileV2]]] = Field(..., alias="saasFiles") + + +class SaasFilesWithEnumQueryData(ConfiguredBaseModel): + apps_v1: Optional[list[Optional[AppV1]]] = Field(..., alias="apps_v1") + + +def query(query_func: Callable, **kwargs: Any) -> SaasFilesWithEnumQueryData: + """ + This is a convenience function which queries and parses the data into + concrete types. It should be compatible with most GQL clients. + You do not have to use it to consume the generated data classes. + Alternatively, you can also mime and alternate the behavior + of this function in the caller. + + Parameters: + query_func (Callable): Function which queries your GQL Server + kwargs: optional arguments that will be passed to the query function + + Returns: + SaasFilesWithEnumQueryData: queried data parsed into generated classes + """ + raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs) + return SaasFilesWithEnumQueryData(**raw_data) diff --git a/tests/plugins/test_generate.py b/tests/plugins/test_generate.py index 1193fad..e4c31e9 100644 --- a/tests/plugins/test_generate.py +++ b/tests/plugins/test_generate.py @@ -14,7 +14,7 @@ class Schema(Enum): @pytest.mark.parametrize( - "case, dep_graph, type_map, collision_strategies, use_schema, custom_type_mapping", + "case, dep_graph, type_map, collision_strategies, use_schema, custom_type_mapping, empty_map_to_none", [ [ "simple_queries", @@ -29,6 +29,7 @@ class Schema(Enum): {}, Schema.APP_INTERFACE, {}, + False, ], [ "complex_queries", @@ -43,6 +44,7 @@ class Schema(Enum): }, Schema.APP_INTERFACE, {}, + False, ], [ "fragments", @@ -60,6 +62,7 @@ class Schema(Enum): {}, Schema.APP_INTERFACE, {}, + False, ], [ "fragments_2023_03", @@ -70,6 +73,7 @@ class Schema(Enum): {}, Schema.APP_INTERFACE_2023_03, {}, + False, ], [ "simple_queries_with_fragments", @@ -89,6 +93,7 @@ class Schema(Enum): {}, Schema.APP_INTERFACE, {}, + False, ], [ "complex_queries_with_fragments", @@ -102,6 +107,7 @@ class Schema(Enum): {}, Schema.APP_INTERFACE, {}, + False, ], [ "github", @@ -114,6 +120,7 @@ class Schema(Enum): {}, Schema.GITHUB, {}, + False, ], [ "custom_mappings", @@ -124,6 +131,19 @@ class Schema(Enum): {}, Schema.APP_INTERFACE, {"JSON": "str"}, + False, + ], + [ + "empty_map_to_none", + {}, + { + "query": GQLDefinitionType.QUERY, + "fragment": GQLDefinitionType.FRAGMENT, + }, + {}, + Schema.APP_INTERFACE, + {}, + True, ], ], ) @@ -140,6 +160,7 @@ def test_rendering( collision_strategies: dict[str, NamingCollisionStrategy], plugin_name, custom_type_mapping, + empty_map_to_none, ): """Test code generation for each CASE x PLUGIN combination.""" schema = app_interface_schema @@ -162,6 +183,7 @@ def test_rendering( plugin=plugin_name, gql_scalar_mappings=custom_type_mapping, collision_strategy=collision_strategy, + empty_map_to_none=empty_map_to_none, ), source_file=source_file, definition=content,