Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow empty maps instead of null #81

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Qenerate Changelog

## 0.7.0

New Features:

* Add `empty_map_to_none` to properly handle `{}` responses ([#81](https://github.com/app-sre/qenerate/pull/81))

## 0.6.1

BUGFIX:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"
Expand Down
15 changes: 15 additions & 0 deletions qenerate/core/feature_flag_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -57,6 +58,17 @@ def custom_type_mapping(definition: str) -> dict[str, str]:
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:
return FeatureFlags(
Expand All @@ -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
),
)
30 changes: 28 additions & 2 deletions qenerate/plugins/pydantic_v1/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
f"{INDENT}Extra,\n"
f"{INDENT}Field,\n"
f"{INDENT}Json,\n"
f"{INDENT}root_validator,\n"
")"
)

Expand All @@ -76,6 +77,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}:
Expand Down Expand Up @@ -374,7 +394,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(
Expand Down Expand Up @@ -469,7 +492,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,
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions tests/core/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
plugin="PluginV1",
collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT,
gql_scalar_mappings={},
empty_map_to_none=False,
),
],
[
Expand All @@ -31,6 +32,7 @@
plugin="PluginV1",
collision_strategy=NamingCollisionStrategy.ENUMERATE,
gql_scalar_mappings={},
empty_map_to_none=False,
),
],
[
Expand All @@ -43,6 +45,7 @@
plugin="PluginV1",
collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT,
gql_scalar_mappings={},
empty_map_to_none=False,
),
],
[
Expand All @@ -55,6 +58,7 @@
plugin="PluginV1",
collision_strategy=NamingCollisionStrategy.PARENT_CONTEXT,
gql_scalar_mappings={"JSON": "str"},
empty_map_to_none=False,
),
],
[
Expand All @@ -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,
),
],
],
Expand Down
6 changes: 6 additions & 0 deletions tests/generator/definitions/empty_map_to_none/fragment.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
fragment VaultSecret on VaultSecret_v1 {
path
field
version
format
}
9 changes: 9 additions & 0 deletions tests/generator/definitions/empty_map_to_none/query.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
query SaasFilesWithEnum {
apps_v1 {
saasFiles {
pipelinesProvider {
labels
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)

from tests.generator.definitions.complex_queries_with_fragments.vault_secret_fragment import VaultSecret
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ from pydantic import ( # noqa: F401 # pylint: disable=W0611
Extra,
Field,
Json,
root_validator,
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
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,
root_validator,
)


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")
Loading