Skip to content

Commit

Permalink
allow empty maps instead of null
Browse files Browse the repository at this point in the history
  • Loading branch information
fishi0x01 committed Apr 17, 2023
1 parent 700b1e4 commit e0c0dfe
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 5 deletions.
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 ([#76](https://github.com/app-sre/qenerate/pull/76))

## 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 @@ -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:
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
)
)
29 changes: 27 additions & 2 deletions qenerate/plugins/pydantic_v1/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
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
@@ -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")
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit e0c0dfe

Please sign in to comment.