[WIP] collection #181
This commit is contained in:
@@ -25,7 +25,7 @@ class ServiceProvider:
|
||||
|
||||
for descriptor in self._service_descriptors:
|
||||
if typing.get_origin(service_type) is None and (
|
||||
descriptor.service_type == service_type
|
||||
descriptor.service_type.__name__ == service_type.__name__
|
||||
or typing.get_origin(descriptor.base_type) is None
|
||||
and issubclass(descriptor.base_type, service_type)
|
||||
):
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from cpl.api.api_module import ApiModule
|
||||
from cpl.dependency.module.module import Module
|
||||
from cpl.dependency.service_provider import ServiceProvider
|
||||
from cpl.graphql.schema.collection import CollectionGraphType
|
||||
from cpl.graphql.schema.root_query import RootQuery
|
||||
from cpl.graphql.service.schema import Schema
|
||||
from cpl.graphql.service.service import GraphQLService
|
||||
from cpl.graphql.service.type_converter import TypeConverter
|
||||
|
||||
|
||||
class GraphQLModule(Module):
|
||||
dependencies = [ApiModule]
|
||||
singleton = [Schema]
|
||||
scoped = [GraphQLService, RootQuery]
|
||||
singleton = [TypeConverter, Schema]
|
||||
scoped = [GraphQLService, RootQuery, CollectionGraphType]
|
||||
|
||||
@staticmethod
|
||||
def configure(services: ServiceProvider) -> None:
|
||||
|
||||
18
src/cpl-graphql/cpl/graphql/schema/collection.py
Normal file
18
src/cpl-graphql/cpl/graphql/schema/collection.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Generic, Type
|
||||
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.graph_type import GraphType
|
||||
|
||||
|
||||
class Collection(Generic[T]):
|
||||
def __init__(self, nodes: list[T], total_count: int, count: int):
|
||||
self.nodes = nodes
|
||||
self.totalCount = total_count
|
||||
self.count = count
|
||||
|
||||
class CollectionGraphType(GraphType[T]):
|
||||
def __init__(self, t: Type[GraphType[T]]):
|
||||
GraphType.__init__(self)
|
||||
self.string_field("totalCount", resolver=lambda obj, *_: obj.totalCount)
|
||||
self.string_field("count", resolver=lambda obj, *_: obj.count)
|
||||
self.list_field("nodes", t, resolver=lambda obj, *_: obj.nodes)
|
||||
@@ -1,12 +1,12 @@
|
||||
from typing import Self
|
||||
|
||||
from cpl.graphql.schema.argument import Argument
|
||||
from cpl.graphql.typing import TQuery
|
||||
from cpl.graphql.typing import TQuery, Resolver
|
||||
|
||||
|
||||
class Field:
|
||||
|
||||
def __init__(self, name: str, gql_type: type, resolver: callable, subquery: TQuery | None = None):
|
||||
def __init__(self, name: str, gql_type: type, resolver: Resolver = None, subquery: TQuery = None):
|
||||
self._name = name
|
||||
self._gql_type = gql_type
|
||||
self._resolver = resolver
|
||||
@@ -37,7 +37,7 @@ class Field:
|
||||
def with_argument(self, arg_type: type, name: str, description: str = None, default_value=None) -> Self:
|
||||
if name in self._args:
|
||||
raise ValueError(f"Argument with name '{name}' already exists in field '{self._name}'")
|
||||
self._args[name] = Argument(name, arg_type, description, default_value)
|
||||
self._args[name] = Argument(arg_type, name, description, default_value)
|
||||
return self
|
||||
|
||||
def with_arguments(self, args: list[Argument]) -> Self:
|
||||
@@ -45,5 +45,5 @@ class Field:
|
||||
if not isinstance(arg, Argument):
|
||||
raise ValueError(f"Expected Argument instance, got {type(arg)}")
|
||||
|
||||
self.with_argument(arg.name, arg.type, arg.description, arg.default_value)
|
||||
self.with_argument(arg.type, arg.name, arg.description, arg.default_value)
|
||||
return self
|
||||
|
||||
9
src/cpl-graphql/cpl/graphql/schema/filter/filter.py
Normal file
9
src/cpl-graphql/cpl/graphql/schema/filter/filter.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.input import Input
|
||||
|
||||
|
||||
class Filter(Input[T]):
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
Input.__init__(self)
|
||||
10
src/cpl-graphql/cpl/graphql/schema/graph_type.py
Normal file
10
src/cpl-graphql/cpl/graphql/schema/graph_type.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Generic
|
||||
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.query import Query
|
||||
|
||||
|
||||
class GraphType(Generic[T], Query):
|
||||
|
||||
def __init__(self):
|
||||
Query.__init__(self)
|
||||
26
src/cpl-graphql/cpl/graphql/schema/input.py
Normal file
26
src/cpl-graphql/cpl/graphql/schema/input.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Type, Generic
|
||||
|
||||
import graphene
|
||||
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.field import Field
|
||||
|
||||
|
||||
class Input(Generic[T], graphene.InputObjectType):
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
graphene.InputObjectType.__init__(self)
|
||||
self._fields: dict[str, Field] = {}
|
||||
|
||||
def get_fields(self) -> dict[str, Field]:
|
||||
return self._fields
|
||||
|
||||
def field(
|
||||
self,
|
||||
field: str,
|
||||
t: Type["Input"] | Type[int | str | bool | datetime | list | Enum],
|
||||
):
|
||||
self._fields[field] = Field(field, t)
|
||||
9
src/cpl-graphql/cpl/graphql/schema/object_graph_type.py
Normal file
9
src/cpl-graphql/cpl/graphql/schema/object_graph_type.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.graph_type import GraphType
|
||||
from cpl.graphql.schema.query import Query
|
||||
|
||||
|
||||
class ObjectGraphType(GraphType[T], Query):
|
||||
|
||||
def __init__(self):
|
||||
Query.__init__(self)
|
||||
@@ -2,9 +2,12 @@ from typing import Callable, Type
|
||||
|
||||
from graphene import ObjectType
|
||||
|
||||
from cpl.graphql.schema.argument import Argument
|
||||
from cpl.graphql.schema.field import Field
|
||||
from cpl.graphql.schema.filter.filter import Filter
|
||||
from cpl.graphql.schema.sort.sort import Sort
|
||||
from cpl.graphql.schema.sort.sort_order import SortOrder
|
||||
from cpl.graphql.typing import Resolver
|
||||
from cpl.graphql.utils.type_converter import TypeConverter
|
||||
|
||||
|
||||
class Query(ObjectType):
|
||||
@@ -32,7 +35,7 @@ class Query(ObjectType):
|
||||
def with_query(self, name: str, subquery: Type["Query"]):
|
||||
from cpl.graphql.schema.field import Field
|
||||
|
||||
f = Field(name=name, gql_type=object, resolver=lambda root, info, **kwargs: {}, subquery=subquery)
|
||||
f = Field(name=name, gql_type=subquery, resolver=lambda root, info, **kwargs: {}, subquery=subquery)
|
||||
self._fields[name] = f
|
||||
return self._fields[name]
|
||||
|
||||
@@ -47,3 +50,44 @@ class Query(ObjectType):
|
||||
|
||||
def bool_field(self, name: str, resolver: Resolver = None) -> "Field":
|
||||
return self.field(name, bool, resolver)
|
||||
|
||||
def list_field(self, name: str, t: type, resolver: Resolver = None) -> "Field":
|
||||
return self.field(name, list[t], resolver)
|
||||
|
||||
def collection_field(
|
||||
self, t: type, name: str, filter_type: type, sort_type: type, resolver: Resolver = None
|
||||
) -> "Field":
|
||||
from cpl.graphql.schema.collection import Collection, CollectionGraphType
|
||||
|
||||
def _resolve_collection(*_, filter: Filter, sort: Sort, skip: int, take: int):
|
||||
items = resolver()
|
||||
|
||||
for field in filter or []:
|
||||
if filter[field] is None:
|
||||
continue
|
||||
|
||||
items = [item for item in items if getattr(item, field) == filter[field]]
|
||||
|
||||
for field in sort or []:
|
||||
if sort[field] is None:
|
||||
continue
|
||||
|
||||
reverse = sort[field] == SortOrder.DESC
|
||||
items = sorted(items, key=lambda item: getattr(item, field), reverse=reverse)
|
||||
|
||||
total_count = len(items)
|
||||
paged = items[skip : skip + take]
|
||||
return Collection(nodes=paged, total_count=total_count, count=len(paged))
|
||||
|
||||
# base = getattr(t, "__gqlname__", t.__class__.__name__)
|
||||
wrapper = CollectionGraphType(t)
|
||||
# wrapper.set_graphql_name(f"{base}Collection")
|
||||
f = self.field(name, wrapper, resolver=_resolve_collection)
|
||||
return f.with_arguments(
|
||||
[
|
||||
Argument(filter_type, "filter"),
|
||||
Argument(sort_type, "sort"),
|
||||
Argument(int, "skip", default_value=0),
|
||||
Argument(int, "take", default_value=10),
|
||||
]
|
||||
)
|
||||
|
||||
0
src/cpl-graphql/cpl/graphql/schema/sort/__init__.py
Normal file
0
src/cpl-graphql/cpl/graphql/schema/sort/__init__.py
Normal file
9
src/cpl-graphql/cpl/graphql/schema/sort/sort.py
Normal file
9
src/cpl-graphql/cpl/graphql/schema/sort/sort.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.input import Input
|
||||
|
||||
|
||||
class Sort(Input[T]):
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
Input.__init__(self)
|
||||
6
src/cpl-graphql/cpl/graphql/schema/sort/sort_order.py
Normal file
6
src/cpl-graphql/cpl/graphql/schema/sort/sort_order.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class SortOrder(Enum):
|
||||
ASC = auto()
|
||||
DESC = auto()
|
||||
@@ -1,21 +1,22 @@
|
||||
from typing import Type
|
||||
|
||||
import graphene
|
||||
|
||||
from cpl.api.logger import APILogger
|
||||
from cpl.dependency.service_provider import ServiceProvider
|
||||
from cpl.graphql.schema.argument import Argument
|
||||
from cpl.graphql.schema.query import Query
|
||||
from cpl.graphql.schema.collection import CollectionGraphType
|
||||
from cpl.graphql.schema.graph_type import GraphType
|
||||
from cpl.graphql.schema.root_query import RootQuery
|
||||
from cpl.graphql.typing import Resolver
|
||||
from cpl.graphql.utils.type_converter import TypeConverter
|
||||
from cpl.graphql.service.type_converter import TypeConverter
|
||||
|
||||
|
||||
class Schema:
|
||||
|
||||
def __init__(self, logger: APILogger, query: RootQuery, provider: ServiceProvider):
|
||||
def __init__(self, logger: APILogger, converter: TypeConverter, query: RootQuery, provider: ServiceProvider):
|
||||
self._logger = logger
|
||||
self._provider = provider
|
||||
self._converter = converter
|
||||
|
||||
self._types = set(GraphType.__subclasses__())
|
||||
self._types.remove(CollectionGraphType)
|
||||
|
||||
self._query = query
|
||||
self._schema = None
|
||||
@@ -28,37 +29,15 @@ class Schema:
|
||||
def query(self) -> RootQuery:
|
||||
return self._query
|
||||
|
||||
def with_type(self, t: type[GraphType]):
|
||||
self._types.add(t)
|
||||
return self
|
||||
|
||||
def build(self) -> graphene.Schema:
|
||||
self._schema = graphene.Schema(
|
||||
query=self.to_graphene(self._query),
|
||||
query=self._converter.to_graphene(self._query),
|
||||
mutation=None,
|
||||
subscription=None,
|
||||
# types=[self._converter.to_graphene(t) for t in self._types] if len(self._types) > 0 else None,
|
||||
)
|
||||
return self._schema
|
||||
|
||||
@staticmethod
|
||||
def _field_to_graphene(t: Type[graphene.Scalar] | type, args: dict[str, Argument] = None, resolver: Resolver = None) -> graphene.Field:
|
||||
arguments = {}
|
||||
if args is not None:
|
||||
arguments = {
|
||||
arg.name: graphene.Argument(TypeConverter.to_graphene(arg.type), description=arg.description, default_value=arg.default_value)
|
||||
for arg in args.values()
|
||||
}
|
||||
|
||||
return graphene.Field(t, args=arguments, resolver=resolver)
|
||||
|
||||
def to_graphene(self, query: Query, name: str | None = None):
|
||||
assert query is not None, "Query cannot be None"
|
||||
attrs = {}
|
||||
|
||||
for field in query.get_fields().values():
|
||||
if field.type == object and field.subquery is not None:
|
||||
subquery = self._provider.get_service(field.subquery)
|
||||
sub = self.to_graphene(subquery, name=field.name.capitalize())
|
||||
attrs[field.name] = self._field_to_graphene(sub, field.args, field.resolver)
|
||||
continue
|
||||
|
||||
attrs[field.name] = self._field_to_graphene(TypeConverter.to_graphene(field.type), field.args, field.resolver)
|
||||
|
||||
class_name = name or query.__class__.__name__
|
||||
return type(class_name, (graphene.ObjectType,), attrs)
|
||||
|
||||
89
src/cpl-graphql/cpl/graphql/service/type_converter.py
Normal file
89
src/cpl-graphql/cpl/graphql/service/type_converter.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import typing
|
||||
from enum import Enum
|
||||
from inspect import isclass
|
||||
|
||||
import graphene
|
||||
from typing import Any, get_origin, get_args
|
||||
|
||||
from cpl.dependency import ServiceProvider
|
||||
from cpl.graphql.schema.argument import Argument
|
||||
from cpl.graphql.schema.filter.filter import Filter
|
||||
from cpl.graphql.schema.graph_type import GraphType
|
||||
from cpl.graphql.schema.object_graph_type import ObjectGraphType
|
||||
from cpl.graphql.schema.sort.sort import Sort
|
||||
from cpl.graphql.typing import Resolver
|
||||
from cpl.graphql.utils.name_pipe import NamePipe
|
||||
|
||||
|
||||
class TypeConverter:
|
||||
__scalar_map: dict[Any, type[graphene.Scalar]] = {
|
||||
str: graphene.String,
|
||||
int: graphene.Int,
|
||||
float: graphene.Float,
|
||||
bool: graphene.Boolean,
|
||||
}
|
||||
|
||||
def __init__(self, provider: ServiceProvider):
|
||||
self._provider = provider
|
||||
|
||||
def _field_to_graphene(self, t: typing.Type[graphene.Scalar] | type, args: dict[str, Argument] = None, resolver: Resolver = None) -> graphene.Field:
|
||||
arguments = {}
|
||||
if args is not None:
|
||||
arguments = {
|
||||
arg.name: graphene.Argument(self.to_graphene(arg.type), name=arg.name, description=arg.description, default_value=arg.default_value)
|
||||
for arg in args.values()
|
||||
}
|
||||
|
||||
return graphene.Field(t, args=arguments, resolver=resolver)
|
||||
|
||||
def to_graphene(self, t: Any, name: str | None = None) -> Any:
|
||||
try:
|
||||
origin = get_origin(t)
|
||||
args = get_args(t)
|
||||
|
||||
if t in self.__scalar_map:
|
||||
return self.__scalar_map[t]
|
||||
|
||||
if origin in (list, typing.List):
|
||||
if not args:
|
||||
raise ValueError("List must specify element type, e.g. list[str]")
|
||||
inner = self.to_graphene(args[0])
|
||||
return graphene.List(inner)
|
||||
|
||||
if t is list or t is typing.List:
|
||||
raise ValueError("List must be parametrized: list[str], list[int], list[UserQuery]")
|
||||
|
||||
if isclass(t) and issubclass(t, Enum):
|
||||
return graphene.Enum.from_enum(t)
|
||||
|
||||
from cpl.graphql.schema.query import Query
|
||||
if isinstance(t, type) and issubclass(t, (Query)):
|
||||
query = self._provider.get_service(t)
|
||||
if query is None:
|
||||
raise ValueError(f"Could not resolve query of type {t}")
|
||||
|
||||
t = query
|
||||
|
||||
if isinstance(t, type) and issubclass(t, (ObjectGraphType, GraphType, Filter, Sort)):
|
||||
t = t()
|
||||
|
||||
if isinstance(t, (Query, Filter, Sort)):
|
||||
attrs = {}
|
||||
for field in t.get_fields().values():
|
||||
if isclass(field.type) and issubclass(field.type, Query) and field.subquery is not None:
|
||||
subquery = self._provider.get_service(field.subquery)
|
||||
sub = self.to_graphene(subquery, name=field.name.capitalize())
|
||||
attrs[field.name] = self._field_to_graphene(sub, field.args, field.resolver)
|
||||
continue
|
||||
|
||||
attrs[field.name] = self._field_to_graphene(self.to_graphene(field.type), field.args, field.resolver)
|
||||
|
||||
class_name = NamePipe.to_str(name or t.__class__)
|
||||
if isinstance(t, (Filter, Sort)):
|
||||
return type(class_name, (graphene.InputObjectType,), attrs)
|
||||
|
||||
return type(class_name, (graphene.ObjectType,), attrs)
|
||||
|
||||
raise ValueError(f"Unsupported field type: {t}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to convert type {t} to graphene type: {e}") from e
|
||||
28
src/cpl-graphql/cpl/graphql/utils/name_pipe.py
Normal file
28
src/cpl-graphql/cpl/graphql/utils/name_pipe.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from cpl.core.pipes import PipeABC
|
||||
from cpl.core.typing import T
|
||||
from cpl.graphql.schema.collection import CollectionGraphType
|
||||
from cpl.graphql.schema.graph_type import GraphType
|
||||
from cpl.graphql.schema.object_graph_type import ObjectGraphType
|
||||
|
||||
|
||||
class NamePipe(PipeABC):
|
||||
|
||||
@staticmethod
|
||||
def to_str(value: type, *args) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
|
||||
if not isinstance(value, type):
|
||||
raise ValueError(f"Expected a type, got {type(value)}")
|
||||
|
||||
if issubclass(value, CollectionGraphType):
|
||||
return f"{value.__name__.replace(GraphType.__name__, "")}"
|
||||
|
||||
if issubclass(value, (ObjectGraphType, GraphType)):
|
||||
return value.__name__.replace(GraphType.__name__, "")
|
||||
|
||||
return value.__name__
|
||||
|
||||
@staticmethod
|
||||
def from_str(value: str, *args) -> T:
|
||||
pass
|
||||
@@ -1,38 +0,0 @@
|
||||
from typing import Type
|
||||
|
||||
import graphene
|
||||
|
||||
from cpl.graphql.typing import ScalarType
|
||||
|
||||
|
||||
class TypeConverter:
|
||||
|
||||
@staticmethod
|
||||
def from_graphene(t: Type[graphene.Scalar]) -> ScalarType:
|
||||
graphene_type_map: dict[Type[graphene.Scalar], ScalarType] = {
|
||||
graphene.String: str,
|
||||
graphene.Int: int,
|
||||
graphene.Float: float,
|
||||
graphene.Boolean: bool,
|
||||
graphene.ObjectType: object,
|
||||
}
|
||||
|
||||
if t not in graphene_type_map:
|
||||
raise ValueError(f"Unsupported field type: {t}")
|
||||
|
||||
return graphene_type_map[t]
|
||||
|
||||
@staticmethod
|
||||
def to_graphene(t: ScalarType) -> Type[graphene.Scalar]:
|
||||
type_graphene_map: dict[ScalarType, Type[graphene.Scalar]] = {
|
||||
str: graphene.String,
|
||||
int: graphene.Int,
|
||||
float: graphene.Float,
|
||||
bool: graphene.Boolean,
|
||||
object: graphene.ObjectType,
|
||||
}
|
||||
|
||||
if t not in type_graphene_map:
|
||||
raise ValueError(f"Unsupported field type: {t}")
|
||||
|
||||
return type_graphene_map[t]
|
||||
Reference in New Issue
Block a user