diff --git a/README.md b/README.md index 084f04f0..ef7d21a1 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,70 @@ See the [custom resolvers](#custom-resolvers) for more details +### Field Groups + +Instead of listing field names one by one, `include` and `exclude` accept the `SCALARS` (column fields), `RELATIONSHIPS` (relation fields) and `ALL` group selectors, importable from `strawchemy`. They can be assigned directly (`include=SCALARS`), used inside an iterable, or mixed with field names — plain strings are always treated as field names. + +
+Field group examples + +```python +from strawchemy import ALL, RELATIONSHIPS, SCALARS + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + password: Mapped[str] + posts: Mapped[list["Post"]] = relationship("Post", back_populates="author") + + +# Only column fields: id, name, password +@strawchemy.type(User, include=[SCALARS]) +class UserType: + pass + + +# Equivalent: a bare constant assigned directly +@strawchemy.type(User, include=SCALARS) +class UserType: + pass + + +# Groups mix with field names: all columns plus the `posts` relationship +@strawchemy.type(User, include=[SCALARS, "posts"]) +class UserType: + pass + + +# Both groups together are equivalent to include=ALL +@strawchemy.type(User, include=[SCALARS, RELATIONSHIPS]) +class UserType: + pass + + +# Groups work in exclude too: a bare exclude implies everything else +# is included, so this keeps only the column fields +@strawchemy.type(User, exclude=[RELATIONSHIPS]) +class UserType: + pass +``` + +A group-bearing `include` can be combined with `exclude` to subtract fields from the group: + +```python +# All columns except `password` +@strawchemy.type(User, include=[SCALARS], exclude=["password"]) +class UserType: + pass +``` + +`include` and `exclude` can always be combined: a field is kept when it is selected by `include` and not selected by `exclude`. + +
+ ### Type Override When generating types for relationships, Strawchemy creates default names (e.g., `Type`). If you have already diff --git a/pyproject.toml b/pyproject.toml index 4dbe9ceb..32655857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,7 +142,7 @@ ignore-words-list = "nin" branch = true source = ["src"] plugins = ["covdefaults"] -omit = ["*/tests/*"] +omit = ["*/tests/*", "src/strawchemy/schema/factories/_kwargs.py"] parallel = true relative_files = true diff --git a/src/strawchemy/__init__.py b/src/strawchemy/__init__.py index c93724b4..f9e25394 100644 --- a/src/strawchemy/__init__.py +++ b/src/strawchemy/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from strawchemy.config.base import StrawchemyConfig +from strawchemy.dto.types import ALL, RELATIONSHIPS, SCALARS, FieldGroup from strawchemy.instance import ModelInstance from strawchemy.mapper import Strawchemy from strawchemy.repository.strawberry import StrawchemyAsyncRepository, StrawchemySyncRepository @@ -20,7 +21,11 @@ from strawchemy.validation import InputValidationError __all__ = ( + "ALL", + "RELATIONSHIPS", + "SCALARS", "ErrorType", + "FieldGroup", "Input", "InputValidationError", "ModelInstance", diff --git a/src/strawchemy/config/base.py b/src/strawchemy/config/base.py index 2fb6a2ce..52b7945d 100644 --- a/src/strawchemy/config/base.py +++ b/src/strawchemy/config/base.py @@ -2,12 +2,13 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from strawchemy.dto import Purpose from strawchemy.dto.inspectors import SQLAlchemyGraphQLInspector -from strawchemy.dto.types import DTOConfig, FieldIterable, IncludeFields +from strawchemy.dto.types import DTOConfig, FieldGroup, FieldSet, FieldSpec from strawchemy.repository.strawberry import StrawchemySyncRepository from strawchemy.utils.strawberry import default_session_getter @@ -59,15 +60,15 @@ class StrawchemyConfig: output object types so they work as GraphQL Union / interface members without boilerplate. A user-defined is_type_of on the decorated class is always respected. Set to False to disable globally.""" - include: IncludeFields = "all" + include: FieldSpec = "all" """Globally included fields.""" - exclude: FieldIterable | None = None + exclude: FieldSpec | None = None """Globally included fields.""" - pagination: IncludeFields | None = None + pagination: FieldSpec | None = None """Enable/disable pagination on list resolvers.""" - order_by: IncludeFields | None = None + order_by: FieldSpec | None = None """Enable/disable order by on list resolvers.""" - distinct_on: IncludeFields | None = None + distinct_on: FieldSpec | None = None """Enable/disable order by on list resolvers.""" pagination_default_limit: int = 100 """Default pagination limit when `pagination=True`.""" @@ -80,6 +81,11 @@ def __post_init__(self) -> None: """Initializes the SQLAlchemyGraphQLInspector after the dataclass is created.""" self.inspector = SQLAlchemyGraphQLInspector(self.dialect, filter_overrides=self.filter_overrides) + if overlap := FieldSet(self.include).overlap(self.exclude): + names = sorted(selector.value if isinstance(selector, FieldGroup) else selector for selector in overlap) + msg = f"Fields are both explicitly included and excluded; exclude takes precedence: {names}" + warnings.warn(msg, stacklevel=2) + @property def field_config(self) -> DTOConfig: return DTOConfig(purpose=Purpose.READ, global_include=self.include, global_exclude=self.exclude or set()) diff --git a/src/strawchemy/dto/base.py b/src/strawchemy/dto/base.py index f219a1b2..467d80c6 100644 --- a/src/strawchemy/dto/base.py +++ b/src/strawchemy/dto/base.py @@ -33,8 +33,8 @@ DTOMissing, DTOSkip, DTOUnset, - FieldIterable, - IncludeFields, + FieldGroup, + FieldSpec, Purpose, PurposeConfig, ) @@ -390,28 +390,16 @@ def should_exclude_field( has_override: bool, ) -> bool: """Whether the model field should be excluded from the dto or not.""" - explictly_excluded = node.is_root and field.model_field_name in dto_config.exclude - explicitly_included = node.is_root and field.model_field_name in dto_config.include - - globally_excluded = field.model_field_name in dto_config.global_exclude - globally_included = field.model_field_name in dto_config.global_include - - if dto_config.include == "all" and not explictly_excluded: - explicitly_included = globally_included = True - - if dto_config.global_include == "all" and not globally_excluded: - globally_included = True + included_by_config = node.is_root and dto_config.is_field_included(field, scope="local") + included_globally = dto_config.is_field_included(field, scope="global") excluded = dto_config.purpose not in field.allowed_purposes # Exclude fields not present in init if purpose is write - if dto_config.purpose is Purpose.WRITE and not (explicitly_included or globally_included): + if dto_config.purpose is Purpose.WRITE and not (included_by_config or included_globally): excluded = excluded or not field.init - if node.is_root: - excluded = excluded or (explictly_excluded or not explicitly_included) - else: - excluded = excluded or (globally_excluded or not globally_included) + excluded = excluded or not included_by_config if node.is_root else excluded or not included_globally return not has_override and excluded @@ -500,8 +488,10 @@ def _base_cache_key(self, dto_config: DTOConfig) -> Hashable: def _root_cache_key(self, dto_config: DTOConfig) -> Hashable: root_key = [ - frozenset(dto_config.include if dto_config.include != "all" else ()), - frozenset(dto_config.exclude), + # ALL is dropped so a root "include all" config shares its cache entry with + # nested defaults, letting relations reuse user-defined types. + frozenset(dto_config.included_fields) - {FieldGroup.ALL}, + frozenset(dto_config.excluded_fields), frozenset(dto_config.aliases.items()), frozenset(dto_config.annotation_overrides.items()), ] @@ -700,8 +690,8 @@ def decorator( model: type[ModelT], purpose: Purpose, *, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, diff --git a/src/strawchemy/dto/types.py b/src/strawchemy/dto/types.py index 771b7224..75cddb11 100644 --- a/src/strawchemy/dto/types.py +++ b/src/strawchemy/dto/types.py @@ -3,19 +3,26 @@ from __future__ import annotations import dataclasses -from dataclasses import dataclass, field +import functools +import warnings +from dataclasses import InitVar, dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, final, get_type_hints, overload +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, final, get_type_hints from typing_extensions import Self, TypeIs, override from strawchemy.utils.annotation import get_annotations if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable, Iterator, Mapping + + from strawchemy.dto.base import DTOFieldDefinition __all__ = ( + "ALL", + "RELATIONSHIPS", + "SCALARS", "DTOAuto", "DTOConfig", "DTOFieldConfig", @@ -23,17 +30,119 @@ "DTOScope", "DTOSkip", "DTOUnset", - "FieldIterable", - "IncludeFields", + "FieldGroup", + "FieldSelector", + "FieldSpec", "Purpose", "PurposeConfig", - "cast_include_fields", "is_fields_iterable", ) DTOScope: TypeAlias = Literal["global", "dto"] -FieldIterable: TypeAlias = "list[str] | set[str] | frozenset[str] | tuple[str, ...]" -IncludeFields: TypeAlias = "FieldIterable | Literal['all']" +FieldSelector: TypeAlias = "str | FieldGroup" +FieldIterable: TypeAlias = ( + "list[FieldSelector] | set[FieldSelector] | frozenset[FieldSelector] | tuple[FieldSelector, ...]" +) +FieldGroupStr: TypeAlias = Literal["all", "scalars", "relationships"] +FieldSpec: TypeAlias = "FieldIterable | FieldGroupStr | FieldGroup" +ConfigScope: TypeAlias = Literal["local", "global"] + + +class FieldGroup(Enum): + """Field-group selectors for ``include``/``exclude`` sequences.""" + + ALL = "all" + """Include all fields from model.""" + SCALARS = "scalars" + """Include everything but relationships.""" + RELATIONSHIPS = "relationships" + """Include only relationships.""" + + @staticmethod + def list_str() -> str: + return ", ".join(member.value for member in FieldGroup) + + @classmethod + @functools.cache + def values(cls) -> frozenset[str]: + return frozenset(member.value for member in FieldGroup) + + @classmethod + def is_group(cls, value: str) -> TypeIs[FieldGroupStr]: + return value in cls.values() + + +@dataclass(slots=True) +class FieldSet: + """Normalized, immutable view over a field selection. + + Wraps a `FieldSpec`, into a uniform `frozenset[FieldSelector]` so selections can be + compared, hashed, and combined regardless of how they were originally + expressed. + """ + + value: InitVar[FieldSpec | None] + + field_set: frozenset[FieldSelector] = field(init=False, default_factory=frozenset) + + def __post_init__(self, value: FieldSpec | None) -> None: + self.field_set = self.normalize(value) + + def __iter__(self) -> Iterator[FieldSelector]: + return iter(self.field_set) + + def __contains__(self, item: FieldSelector | DTOFieldDefinition[Any, Any]) -> bool: + # A FieldGroup is only selected by itself or by ALL, never by group matching. + if isinstance(item, FieldGroup): + return item in self.field_set or FieldGroup.ALL in self.field_set + name, is_relation = (item, False) if isinstance(item, str) else (item.model_field_name, item.is_relation) + item_group = FieldGroup.RELATIONSHIPS if is_relation else FieldGroup.SCALARS + return name in self.field_set or item_group in self.field_set or FieldGroup.ALL in self.field_set + + def __and__(self, other: FieldSpec) -> FieldIterable: + other_set = FieldSet(other) + # ALL subsumes any selection: intersecting with it yields the other side. + if FieldGroup.ALL in self.field_set: + return other_set.field_set + if FieldGroup.ALL in other_set.field_set: + return self.field_set + return frozenset(field for field in self.field_set if field in other_set) | frozenset( + field for field in other_set.field_set if field in self + ) + + def __or__(self, other: FieldSpec | None) -> FieldSpec | None: + other_set = FieldSet(other) + if FieldGroup.ALL in self.field_set or FieldGroup.ALL in other_set.field_set: + return "all" + return (self.field_set | other_set.field_set) or None + + def __bool__(self) -> bool: + return bool(self.field_set) + + def __hash__(self) -> int: + return hash(self.field_set) + + @classmethod + def normalize(cls, value: FieldSpec | None) -> frozenset[FieldSelector]: + """Normalize a field selection into a frozenset of selectors. + + Args: + value: A group string ("all", "scalars", "relationships"), an + iterable of field names and/or `FieldGroup` members, or `None`. + + Returns: + Normalized field selector set + """ + if isinstance(value, FieldGroup): + return frozenset((value,)) + if isinstance(value, str) and FieldGroup.is_group(value): + return frozenset((FieldGroup(value),)) + if value is None: + return frozenset() + return frozenset(value) + + def overlap(self, other: FieldSpec | None) -> frozenset[FieldSelector]: + return self.field_set & FieldSet(other).field_set @final @@ -89,7 +198,7 @@ class Purpose(str, Enum): clients. Fields marked as TO_COMPLETE must not be null.""" -@dataclass +@dataclass(slots=True) class PurposeConfig: """Mark the field as read-only, or private.""" @@ -113,7 +222,7 @@ def purpose_config(self, dto_config: DTOConfig) -> PurposeConfig: return self.configs.get(dto_config.purpose, self.default_config) -@dataclass +@dataclass(slots=True) class DTOConfig: """Control the generated DTO. @@ -127,11 +236,16 @@ class DTOConfig: Determines which fields from the source model are included based on their `DTOFieldConfig`. include: Explicitly include fields from the source model in the generated - DTO. Can be a list or set of field names, or the literal "all" to - include all fields not explicitly excluded. Defaults to an empty set. - exclude: Explicitly exclude fields from the source model. Can be a list - or set of field names. Defaults to an empty set. Setting this - implicitly sets `include` to "all". + DTO. Can be a list or set of field names, and/or the `ALL` / `SCALARS` / + `RELATIONSHIPS` group selectors, either assigned directly + (`include=SCALARS`) or mixed with names inside an iterable (e.g. + `[SCALARS, "owner"]`). `[SCALARS, RELATIONSHIPS]` is equivalent to + `ALL`. Defaults to an empty set. + exclude: Explicitly exclude fields from the source model. Can be a list or + set of field names and/or the `ALL` / `SCALARS` / `RELATIONSHIPS` group + selectors (e.g. `[RELATIONSHIPS]` keeps all scalar fields and walks no + relationships). A bare `exclude` (no `include`) implies everything else + is included. Defaults to an empty set. partial: If True, makes all fields in the generated DTO optional. Defaults to None. partial_default: The default value assigned to fields when `partial` is @@ -153,21 +267,19 @@ class DTOConfig: with `aliases`. Raises: - ValueError: If both `aliases` and `alias_generator` are provided, or - if `exclude` is set while `include` is also set to a specific list/set - (i.e., not "all" or empty). + ValueError: If both `aliases` and `alias_generator` are provided. """ purpose: Purpose """Configure the DTO for "read" or "write" operations.""" - include: IncludeFields = field(default_factory=set) + include: FieldSpec | None = None """Explicitly include fields from the generated DTO.""" - global_include: IncludeFields = field(default_factory=set) + exclude: FieldSpec | None = None + """Explicitly exclude fields from the generated DTO. Implies everything else is included.""" + global_include: FieldSpec | None = None """Explicitly include fields from the generated DTO and all its children.""" - exclude: FieldIterable = field(default_factory=set) - """Explicitly exclude fields from the generated DTO. Implies `include="all"`.""" - global_exclude: FieldIterable = field(default_factory=set) - """Explicitly exclude fields from the generated DTO and all its children. Implies `global_include="all"`.""" + global_exclude: FieldSpec | None = None + """Explicitly exclude fields from the generated DTO and all its children. Implies everything else is included.""" partial: bool | None = None """Make all field optional.""" partial_default: Any = None @@ -181,40 +293,36 @@ class DTOConfig: exclude_from_scope: bool = False tags: set[str] = field(default_factory=set) + included_fields: FieldSet = field(init=False) + excluded_fields: FieldSet = field(init=False) + def __post_init__(self) -> None: if self.aliases and self.alias_generator is not None: msg = "You must set `aliases` or `alias_generator`, not both" raise ValueError(msg) - if self.include and self.include != "all" and self.exclude: - msg = "When using `exclude` you must set `include='all' or leave it unset`" - raise ValueError(msg) - if self.global_include and self.global_include != "all" and self.global_exclude: - msg = "When using `global_exclude` you must set `global_include='all' or leave it unset`" - raise ValueError(msg) - if self.global_exclude: + # A bare exclude (no include) means "everything except"; promote to "all". + # If include carries a FieldGroup it is truthy, so the clobber is skipped. + if self.global_exclude and self.global_include is None: self.global_include = "all" - if self.exclude: + if self.exclude and self.include is None: self.include = "all" - @overload - @classmethod - def _merge_field_iterables(cls, *iterables: FieldIterable) -> FieldIterable: ... + self.included_fields = FieldSet(self.global_include) if self.include is None else FieldSet(self.include) + self.excluded_fields = FieldSet(self.global_exclude) if self.exclude is None else FieldSet(self.exclude) - @overload - @classmethod - def _merge_field_iterables(cls, *iterables: IncludeFields) -> IncludeFields: ... + if overlap := FieldSet(self.include).overlap(self.exclude): + names = sorted(selector.value if isinstance(selector, FieldGroup) else selector for selector in overlap) + msg = f"Fields are both explicitly included and excluded; exclude takes precedence: {names}" + warnings.warn(msg, stacklevel=2) - @classmethod - def _merge_field_iterables(cls, *iterables: IncludeFields | FieldIterable) -> IncludeFields | FieldIterable: - if any(iterable == "all" for iterable in iterables): - return "all" - return set().union(*iterables) + def __or__(self, other: DTOConfig) -> DTOConfig: + return self.union(other) def union(self, other: DTOConfig) -> DTOConfig: - include = self._merge_field_iterables(self.include, other.include) - exclude = self._merge_field_iterables(self.exclude, other.exclude) - global_include = self._merge_field_iterables(self.global_include, other.global_include) - global_exclude = self._merge_field_iterables(self.global_exclude, other.global_exclude) + include = FieldSet(self.include) | other.include + exclude = FieldSet(self.exclude) | other.exclude + global_include = FieldSet(self.global_include) | other.global_include + global_exclude = FieldSet(self.global_exclude) | other.global_exclude type_overrides = dict(self.type_overrides) | dict(other.type_overrides) annotation_overrides = self.annotation_overrides | other.annotation_overrides tags = self.tags | other.tags @@ -230,9 +338,7 @@ def union(self, other: DTOConfig) -> DTOConfig: ) @classmethod - def from_include( - cls, include: IncludeFields | Literal[False] | None = None, purpose: Purpose = Purpose.READ - ) -> Self: + def from_include(cls, include: FieldSpec | Literal[False] | None = None, purpose: Purpose = Purpose.READ) -> Self: """Create a DTOConfig from an include specification. Factory method for creating a DTOConfig with a simplified interface, converting @@ -257,10 +363,10 @@ def from_include( def copy_with( self, purpose: Purpose | type[DTOUnset] = DTOUnset, - include: IncludeFields | None = None, - global_include: IncludeFields | None = None, - exclude: FieldIterable | None = None, - global_exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + global_include: FieldSpec | None = None, + exclude: FieldSpec | None = None, + global_exclude: FieldSpec | None = None, partial: bool | None | type[DTOUnset] = DTOUnset, unset_sentinel: Any | type[DTOUnset] = DTOUnset, type_overrides: Mapping[Any, Any] | type[DTOUnset] = DTOUnset, @@ -320,8 +426,10 @@ def with_base_annotations(self, base: type[Any]) -> DTOConfig: 1. When include is "all" or exclude is specified: All fields from the base class are included 2. When specific fields are included: Only those fields are added to the include set """ - include: set[str] = set(self.include) if self.include != "all" else set() - include_all = self.include == "all" or self.exclude + # Root-level include/exclude only: a global "all" must not pull base fields in. + include_set = FieldSet(self.include) + include = set(include_set.field_set) + include_all = FieldGroup.ALL in include_set.field_set or bool(FieldSet(self.exclude)) annotation_overrides: dict[str, Any] = self.annotation_overrides try: base_annotations = get_type_hints(base, include_extras=True) @@ -344,54 +452,30 @@ def alias(self, name: str) -> str | None: return self.alias_generator(name) return None - def is_field_included(self, name: str) -> bool: - """Check if a field should be included based on this configuration. - - This method is used during DTO factory operations to determine which fields - from the source model should be included in the generated DTO. + def is_field_included( + self, field: FieldSelector | DTOFieldDefinition[Any, Any], scope: ConfigScope | None = None + ) -> bool: + """Whether a field is included per the include/exclude rules. - Args: - name: The field name to check for inclusion. - - Returns: - True if the field should be included based on the include/exclude rules, - False otherwise. + `field` is a field name or a `DTOFieldDefinition`. A bare `str` is treated as a non-relation field name. """ - if self.include == "all": - return name not in self.exclude - if self.global_include == "all": - return name not in self.global_exclude + if scope == "local": + return field in FieldSet(self.include) and field not in FieldSet(self.exclude) + if scope == "global": + included = field in FieldSet(self.global_include) or FieldGroup.ALL in self.included_fields.field_set + return included and field not in FieldSet(self.global_exclude) + return field in self.included_fields and field not in self.excluded_fields - included = set(self.include) | set(self.global_include) - excluded = set(self.exclude) | set(self.global_exclude) - return name in included and name not in excluded - def __or__(self, other: DTOConfig) -> DTOConfig: - return self.union(other) - - -@overload -def cast_include_fields(value: Literal["all"]) -> Literal["all"]: ... - - -@overload -def cast_include_fields(value: frozenset[str] | set[str] | list[str] | tuple[str, ...] | None) -> frozenset[str]: ... - - -def cast_include_fields(value: IncludeFields | None) -> frozenset[str] | Literal["all"]: - match value: - case None: - return frozenset() - case "all": - return "all" - case _: - return frozenset(value) - - -def is_fields_iterable(value: Any) -> TypeIs[IncludeFields | FieldIterable | None]: +def is_fields_iterable(value: Any) -> TypeIs[FieldSpec]: """Test the given value is suitable to be used as either `include` or `exclude` in a DTOConfig.""" - if value == "all" or value is None: + if value == "all" or isinstance(value, FieldGroup): return True if isinstance(value, str): return False return isinstance(value, (frozenset, set, list, tuple)) + + +ALL = FieldGroup.ALL +SCALARS = FieldGroup.SCALARS +RELATIONSHIPS = FieldGroup.RELATIONSHIPS diff --git a/src/strawchemy/dto/utils.py b/src/strawchemy/dto/utils.py index af815036..e14278ab 100644 --- a/src/strawchemy/dto/utils.py +++ b/src/strawchemy/dto/utils.py @@ -15,15 +15,7 @@ from typing import TYPE_CHECKING, Any from strawchemy.dto.constants import DTO_INFO_KEY -from strawchemy.dto.types import ( - DTOConfig, - DTOFieldConfig, - DTOScope, - FieldIterable, - IncludeFields, - Purpose, - PurposeConfig, -) +from strawchemy.dto.types import DTOConfig, DTOFieldConfig, DTOScope, FieldSpec, Purpose, PurposeConfig if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -44,10 +36,10 @@ def config( purpose: Purpose, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, - global_include: IncludeFields | None = None, - global_exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, + global_include: FieldSpec | None = None, + global_exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, @@ -55,24 +47,19 @@ def config( scope: DTOScope | None = None, tags: set[str] | None = None, ) -> DTOConfig: - config = DTOConfig(purpose, alias_generator=alias_generator, scope=scope) - if exclude: - config.exclude = exclude - if include: - config.include = include - if global_include: - config.global_include = global_include - if global_exclude: - config.global_exclude = global_exclude - if type_map: - config.type_overrides = type_map - if aliases: - config.aliases = aliases - if partial is not None: - config.partial = partial - if tags: - config.tags = tags - return config + return DTOConfig( + purpose, + include=include or None, + exclude=exclude or None, + global_include=global_include or None, + global_exclude=global_exclude or None, + partial=partial, + type_overrides=type_map or {}, + aliases=aliases or {}, + alias_generator=alias_generator, + scope=scope, + tags=tags or set(), + ) def field( diff --git a/src/strawchemy/mapper.py b/src/strawchemy/mapper.py index 819aa9e2..92eab240 100644 --- a/src/strawchemy/mapper.py +++ b/src/strawchemy/mapper.py @@ -44,7 +44,7 @@ from strawberry.extensions.field_extension import FieldExtension from strawberry.types.arguments import StrawberryArgument - from strawchemy.dto.types import IncludeFields + from strawchemy.dto.types import FieldSpec from strawchemy.repository.typing import QueryHookCallable from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.transpiler.hook import QueryHook @@ -180,9 +180,9 @@ def field( resolver: Any, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: IncludeFields | type[OrderByDTO] | None = None, + order_by: FieldSpec | type[OrderByDTO] | None = None, pagination: bool | DefaultOffsetPagination | None = None, - distinct_on: IncludeFields | type[EnumDTO] | None = None, + distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, id_field_name: str | None = None, root_aggregations: bool = False, @@ -208,9 +208,9 @@ def field( self, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: IncludeFields | type[OrderByDTO] | None = None, + order_by: FieldSpec | type[OrderByDTO] | None = None, pagination: bool | DefaultOffsetPagination | None = None, - distinct_on: IncludeFields | type[EnumDTO] | None = None, + distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, id_field_name: str | None = None, root_aggregations: bool = False, @@ -236,9 +236,9 @@ def field( resolver: Any | None = None, *, filter_input: type[BooleanFilterDTO] | bool | None = None, - order_by: IncludeFields | type[OrderByDTO] | None = None, + order_by: FieldSpec | type[OrderByDTO] | None = None, pagination: bool | DefaultOffsetPagination | None = None, - distinct_on: IncludeFields | type[EnumDTO] | None = None, + distinct_on: FieldSpec | type[EnumDTO] | None = None, arguments: list[StrawberryArgument] | None = None, id_field_name: str | None = None, root_aggregations: bool = False, diff --git a/src/strawchemy/schema/factories/_kwargs.py b/src/strawchemy/schema/factories/_kwargs.py index 2ad37a2d..f34fcb34 100644 --- a/src/strawchemy/schema/factories/_kwargs.py +++ b/src/strawchemy/schema/factories/_kwargs.py @@ -20,7 +20,7 @@ from strawchemy.dto.base import DTOFieldDefinition from strawchemy.dto.strawberry import BooleanFilterDTO, DTOKey, GraphQLFieldDefinition, OrderByDTO - from strawchemy.dto.types import FieldIterable, IncludeFields + from strawchemy.dto.types import FieldSpec from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.typing import GraphQLPurpose @@ -41,8 +41,8 @@ class DTOConfigKwargs(TypedDict, total=False): """Args forwarded to ``config(...)`` to build a ``DTOConfig``.""" - include: IncludeFields | None - exclude: FieldIterable | None + include: FieldSpec | None + exclude: FieldSpec | None partial: bool | None type_map: Mapping[Any, Any] | None aliases: Mapping[str, str] | None @@ -70,11 +70,11 @@ class RegistrationKwargs(TypedDict, total=False): class TypeWrapperKwargs(TypedDict, total=False): """Args specific to ``.type()`` / ``_type_wrapper``.""" - paginate: IncludeFields | None + paginate: FieldSpec | None default_pagination: DefaultOffsetPagination | None filter_input: type[BooleanFilterDTO] | None - distinct_on: IncludeFields | None - order: IncludeFields | type[OrderByDTO] | None + distinct_on: FieldSpec | None + order: FieldSpec | type[OrderByDTO] | None query_hook: Any diff --git a/src/strawchemy/schema/factories/base.py b/src/strawchemy/schema/factories/base.py index 95c49e53..7496f303 100644 --- a/src/strawchemy/schema/factories/base.py +++ b/src/strawchemy/schema/factories/base.py @@ -55,7 +55,7 @@ from strawchemy import Strawchemy from strawchemy.dto.inspectors import SQLAlchemyGraphQLInspector - from strawchemy.dto.types import FieldIterable, IncludeFields + from strawchemy.dto.types import FieldSpec from strawchemy.schema.factories._kwargs import ( InputDecoratorKwargs, MakeInputKwargs, @@ -174,8 +174,8 @@ def _resolve_config(self, dto_config: DTOConfig, base: type[Any]) -> DTOConfig: def _config( self, purpose: Purpose, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, @@ -199,10 +199,10 @@ def _config( ) def _type_order_by( - self, model: type[DeclarativeBase], include: IncludeFields | type[OrderByDTO] | None = None + self, model: type[DeclarativeBase], include: FieldSpec | type[OrderByDTO] | None = None ) -> type[OrderByDTO] | None: order_include = self._mapper.config.order_by if include is None else include - if is_fields_iterable(order_include) and order_include is not None: + if is_fields_iterable(order_include): try: order_by_input = self._mapper.order_by_factory.make_input( model=model, @@ -223,10 +223,10 @@ def _type_order_by( return order_by_input def _type_distinct_on( - self, model: type[DeclarativeBase], include: IncludeFields | type[EnumDTO] | None = None + self, model: type[DeclarativeBase], include: FieldSpec | type[EnumDTO] | None = None ) -> type[EnumDTO] | None: distinct_on_include = self._mapper.config.distinct_on if include is None else include - if is_fields_iterable(distinct_on_include) and distinct_on_include is not None: + if is_fields_iterable(distinct_on_include): try: distinct_on_input = self._mapper.distinct_on_enum_factory.factory( model=model, @@ -250,17 +250,17 @@ def _type_wrapper( model: type[T], *, mode: GraphQLPurpose, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, alias_generator: Callable[[str], str] | None = None, - paginate: IncludeFields | None = None, - distinct_on: IncludeFields | None = None, + paginate: FieldSpec | None = None, + distinct_on: FieldSpec | None = None, default_pagination: None | DefaultOffsetPagination = None, filter_input: type[BooleanFilterDTO] | None = None, - order: IncludeFields | type[OrderByDTO] | None = None, + order: FieldSpec | type[OrderByDTO] | None = None, name: str | None = None, description: str | None = None, directives: Sequence[object] | None = (), @@ -323,8 +323,8 @@ def _input_wrapper( model: type[T], *, mode: GraphQLPurpose, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, @@ -485,7 +485,7 @@ def _root_input_config(self, model: builtins.type[Any], dto_config: DTOConfig, m id_fields = self.inspector.id_field_definitions(model, dto_config) # Add PKs for update/delete inputs if mode == "update_by_pk_input": - if set(dto_config.exclude) & {name for name, _ in id_fields}: + if set(dto_config.excluded_fields) & {name for name, _ in id_fields}: msg = ( "You cannot exclude primary key columns from an input type intended for create or update mutations" ) @@ -496,9 +496,10 @@ def _root_input_config(self, model: builtins.type[Any], dto_config: DTOConfig, m if mode in {"update_by_pk_input", "update_by_filter_input"}: partial = True # Exclude default generated PKs for create inputs, if not explicitly included - elif dto_config.include == "all": + else: for name, field in id_fields: - if self.inspector.has_default(field.model_field): + # Exclude rules are deliberately ignored: default-generated PKs stay as optional inputs. + if field in dto_config.included_fields and self.inspector.has_default(field.model_field): annotations_overrides[name] = Optional[field.type_hint] return dto_config.copy_with( annotation_overrides=annotations_overrides, diff --git a/src/strawchemy/schema/factories/types.py b/src/strawchemy/schema/factories/types.py index 1f8838c7..4ad9e3b0 100644 --- a/src/strawchemy/schema/factories/types.py +++ b/src/strawchemy/schema/factories/types.py @@ -23,7 +23,7 @@ MappedStrawberryGraphQLDTO, OrderByDTO, ) -from strawchemy.dto.types import DTOConfig, DTOMissing, IncludeFields, Purpose, is_fields_iterable +from strawchemy.dto.types import DTOConfig, DTOMissing, FieldSpec, Purpose, is_fields_iterable from strawchemy.dto.utils import read_partial, write_all_config from strawchemy.exceptions import EmptyDTOError from strawchemy.schema.factories import ( @@ -157,16 +157,13 @@ def _relation_field( related = Self if field.related_dto is dto else field.related_dto type_annotation = list[related] if related is not None else field.type_ # ty: ignore[invalid-type-form] assert field.related_model - field_name = field.model_field_name order_by_input, distinct_on_input, pagination = None, None, False - if order_config.is_field_included(field_name) or self._mapper.config.order_config.is_field_included(field_name): + if order_config.is_field_included(field) or self._mapper.config.order_config.is_field_included(field): order_by_input = self._order_by_input_for_field(field) - if pagination_config.is_field_included(field_name) or self._mapper.config.pagination_config.is_field_included( - field_name - ): + if pagination_config.is_field_included(field) or self._mapper.config.pagination_config.is_field_included(field): pagination = default_pagination or True - if distinct_on_config.is_field_included(field_name) or self._mapper.config.distinct_on_config.is_field_included( - field_name + if distinct_on_config.is_field_included(field) or self._mapper.config.distinct_on_config.is_field_included( + field ): distinct_on_input = self._distinct_on_input_for_field(field) strawberry_field = self._mapper.field( @@ -178,9 +175,9 @@ def _add_fields_arguments( self, dto: type[GraphQLDTOT], base: type[Any] | None, - order: IncludeFields | None = None, - paginate: IncludeFields | None = None, - distinct_on: IncludeFields | None = None, + order: FieldSpec | None = None, + paginate: FieldSpec | None = None, + distinct_on: FieldSpec | None = None, default_pagination: None | DefaultOffsetPagination = None, ) -> type[GraphQLDTOT]: """Add pagination and ordering arguments to a GraphQL DTO type. diff --git a/src/strawchemy/schema/field.py b/src/strawchemy/schema/field.py index fde91138..6e939bd7 100644 --- a/src/strawchemy/schema/field.py +++ b/src/strawchemy/schema/field.py @@ -21,7 +21,7 @@ OrderByDTO, StrawchemyObject, ) -from strawchemy.dto.types import DTOConfig, IncludeFields, Purpose +from strawchemy.dto.types import DTOConfig, FieldSpec, Purpose from strawchemy.exceptions import EmptyDTOError, StrawchemyFieldError from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.utils.annotation import is_type_hint_optional @@ -86,8 +86,8 @@ def __init__( filter_factory: BooleanFilterFactory, distinct_on_factory: DistinctOnEnumFactory, filter_type: builtins.type[BooleanFilterDTO] | bool | None = None, - order_by: IncludeFields | builtins.type[OrderByDTO] | Literal[False] | None = None, - distinct_on: IncludeFields | builtins.type[EnumDTO] | Literal[False] | None = None, + order_by: FieldSpec | builtins.type[OrderByDTO] | Literal[False] | None = None, + distinct_on: FieldSpec | builtins.type[EnumDTO] | Literal[False] | None = None, pagination: DefaultOffsetPagination | bool | None = False, repository_type: AnyRepositoryType | None = None, root_aggregations: bool = False, @@ -263,7 +263,7 @@ def distinct_on(self) -> builtins.type[EnumDTO] | None: return self._distinct_on_factory.factory( inner_type.__dto_model__, dto_config=inner_type.__dto_config__.copy_with( - include=inner_type.__dto_config__.include if distinct_on == "all" else distinct_on + include=inner_type.__dto_config__.included_fields & distinct_on ), no_cache=True, if_no_fields="raise", @@ -294,7 +294,7 @@ def order_by(self) -> builtins.type[OrderByDTO] | None: inner_type.__dto_model__, mode="order_by", dto_config=inner_type.__dto_config__.copy_with( - include=inner_type.__dto_config__.include if order_by == "all" else order_by + include=inner_type.__dto_config__.included_fields & order_by ), no_cache=True, if_no_fields="raise", diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index 8b254c56..479c2e78 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -4,7 +4,7 @@ from collections import defaultdict from copy import copy from enum import Enum -from typing import TYPE_CHECKING, Any, ForwardRef, Literal, NewType, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, ForwardRef, NewType, TypeVar, cast, overload import strawberry from strawberry import LazyType @@ -15,7 +15,7 @@ from strawberry.types.union import StrawberryUnion from strawchemy.dto.strawberry import MappedStrawberryGraphQLDTO -from strawchemy.dto.types import cast_include_fields, is_fields_iterable +from strawchemy.dto.types import FieldSet from strawchemy.exceptions import StrawchemyError from strawchemy.utils.annotation import inner_types from strawchemy.utils.strawberry import strawberry_contained_types @@ -39,7 +39,7 @@ from strawchemy.dto import DTOConfig from strawchemy.dto.base import Node, Relation from strawchemy.dto.strawberry import EnumDTO, OrderByDTO, StrawchemyObject - from strawchemy.dto.types import DTOScope, IncludeFields + from strawchemy.dto.types import DTOScope, FieldSpec from strawchemy.schema.pagination import DefaultOffsetPagination from strawchemy.typing import GraphQLType, StrawchemyObjectWithStrawberryObjectDefinition @@ -149,9 +149,9 @@ class RegistryTypeInfo: user_defined: bool = False override: bool = False pagination: DefaultOffsetPagination | None = None - order: frozenset[str] | Literal["all"] | type[OrderByDTO] = dataclasses.field(default_factory=frozenset) - distinct_on: frozenset[str] | Literal["all"] | type[EnumDTO] = dataclasses.field(default_factory=frozenset) - paginate: frozenset[str] | Literal["all"] = dataclasses.field(default_factory=frozenset) + order: FieldSet | type[OrderByDTO] = dataclasses.field(default_factory=frozenset) + distinct_on: FieldSet | type[EnumDTO] = dataclasses.field(default_factory=frozenset) + paginate: FieldSet = dataclasses.field(default_factory=frozenset) scope: DTOScope | None = None model: type[DeclarativeBase] | None = None tags: frozenset[str] = dataclasses.field(default_factory=frozenset) @@ -349,9 +349,9 @@ def _type_info( current_node: Node[Relation[Any, Any], None] | None, override: bool = False, user_defined: bool = False, - paginate: IncludeFields | None = None, - order: IncludeFields | type[OrderByDTO] | None = None, - distinct_on: IncludeFields | type[EnumDTO] | None = None, + paginate: FieldSpec | None = None, + order: FieldSpec | type[OrderByDTO] | None = None, + distinct_on: FieldSpec | type[EnumDTO] | None = None, default_pagination: DefaultOffsetPagination | None = None, default_name: str | None = None, ) -> RegistryTypeInfo: @@ -365,9 +365,9 @@ def _type_info( override=override, user_defined=user_defined, pagination=default_pagination, - order=cast_include_fields(order) if is_fields_iterable(order) else order, - distinct_on=cast_include_fields(distinct_on) if is_fields_iterable(distinct_on) else distinct_on, - paginate=cast_include_fields(paginate), + order=order if isinstance(order, type) else FieldSet(order), + distinct_on=distinct_on if isinstance(distinct_on, type) else FieldSet(distinct_on), + paginate=FieldSet(paginate), scope=dto_config.scope, model=model, exclude_from_scope=dto_config.exclude_from_scope, @@ -420,9 +420,9 @@ def register_type( current_node: Node[Relation[Any, Any], None] | None = None, override: bool = False, user_defined: bool = False, - paginate: IncludeFields | None = None, - order: IncludeFields | type[OrderByDTO] | None = None, - distinct_on: IncludeFields | type[EnumDTO] | None = None, + paginate: FieldSpec | None = None, + order: FieldSpec | type[OrderByDTO] | None = None, + distinct_on: FieldSpec | type[EnumDTO] | None = None, default_pagination: DefaultOffsetPagination | None = None, default_name: str | None = None, description: str | None = None, diff --git a/src/strawchemy/validation/pydantic.py b/src/strawchemy/validation/pydantic.py index f72a842c..59f69a0f 100644 --- a/src/strawchemy/validation/pydantic.py +++ b/src/strawchemy/validation/pydantic.py @@ -24,7 +24,7 @@ from strawchemy import Strawchemy from strawchemy.dto.base import DTOFieldDefinition, MappedDTO, Relation - from strawchemy.dto.types import DTOConfig, FieldIterable, IncludeFields, Purpose + from strawchemy.dto.types import DTOConfig, FieldSpec, Purpose from strawchemy.repository.typing import DeclarativeT from strawchemy.schema.factories._kwargs import FactoryMethodKwargs from strawchemy.typing import GraphQLPurpose @@ -84,8 +84,8 @@ def input( model: type[DeclarativeT], *, mode: GraphQLPurpose, - include: IncludeFields | None = None, - exclude: FieldIterable | None = None, + include: FieldSpec | None = None, + exclude: FieldSpec | None = None, partial: bool | None = None, type_map: Mapping[Any, Any] | None = None, aliases: Mapping[str, str] | None = None, diff --git a/tests/unit/dto/test_dto.py b/tests/unit/dto/test_dto.py index 4bd2c26d..1bacfc47 100644 --- a/tests/unit/dto/test_dto.py +++ b/tests/unit/dto/test_dto.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Optional +from typing import Any, Optional, get_args from uuid import UUID, uuid4 import pytest @@ -9,10 +9,13 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from typing_extensions import Self +from strawchemy import ALL, RELATIONSHIPS, SCALARS, StrawchemyConfig from strawchemy.dto import DTOConfig, Purpose, PurposeConfig, config, field from strawchemy.dto.constants import DTO_INFO_KEY from strawchemy.dto.strawberry import DTOKey, GraphQLFieldDefinition, StrawchemyDefinition +from strawchemy.dto.types import FieldSpec from strawchemy.dto.utils import DTOFieldConfig, read_all_config, write_all_config +from strawchemy.exceptions import EmptyDTOError from tests.typing import AnyFactory, MappedPydanticFactory from tests.unit.dc_models import ( AdminDataclass, @@ -35,8 +38,14 @@ class _PopulateFieldsModel(_PopulateFieldsBase): id: Mapped[int] = mapped_column(Integer, primary_key=True) -def test_config_function_produces_same_default() -> None: - assert config(Purpose.READ) == DTOConfig(Purpose.READ) +@pytest.mark.parametrize("model", [Tomato, TomatoDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_config_function_produces_same_default(factory: AnyFactory, model: type[Tomato | TomatoDataclass]) -> None: + """Test that config() and DTOConfig produce DTOs with identical fields.""" + from_function = factory.factory(model, config(Purpose.READ, include="all"), name="FromFunction") + from_class = factory.factory(model, DTOConfig(Purpose.READ, include="all"), name="FromClass") + + assert DTOInspect(from_function).annotations() == DTOInspect(from_class).annotations() def test_default_field_config() -> None: @@ -280,73 +289,48 @@ def test_forward_refs_resolved(name: str, sqlalchemy_pydantic_factory: MappedPyd ) -# Tests for DTOConfig.from_include() and is_field_included() - - -def test_from_include_with_none() -> None: - """Test that from_include(None) creates a config with empty include set.""" - config = DTOConfig.from_include(None) - assert config.include == set() - assert config.purpose == Purpose.READ - - -def test_from_include_with_all() -> None: - """Test that from_include('all') creates a config with include='all'.""" - config = DTOConfig.from_include("all") - assert config.include == "all" - assert config.purpose == Purpose.READ - - -def test_from_include_with_list() -> None: - """Test that from_include() accepts a list and converts it to the include parameter.""" - config = DTOConfig.from_include(["field1", "field2"]) - assert config.include == ["field1", "field2"] - assert config.purpose == Purpose.READ - - -def test_from_include_with_set() -> None: - """Test that from_include() accepts a set for the include parameter.""" - config = DTOConfig.from_include({"field1", "field2"}) - assert config.include == {"field1", "field2"} - assert config.purpose == Purpose.READ - - -def test_from_include_with_custom_purpose() -> None: - """Test that from_include() accepts a custom purpose.""" - config = DTOConfig.from_include(["field1"], purpose=Purpose.WRITE) - assert config.include == ["field1"] - assert config.purpose == Purpose.WRITE - - -def test_is_field_included_with_all() -> None: - """Test that is_field_included() returns True for any field when include='all'.""" - config = DTOConfig.from_include("all") - assert config.is_field_included("any_field") is True - assert config.is_field_included("another_field") is True +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_from_include_empty_raises(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that from_include(None) produces a DTO with no fields.""" + with pytest.raises(EmptyDTOError): + factory.factory(model, DTOConfig.from_include(None), if_no_fields="raise") -def test_is_field_included_with_specific_list() -> None: - """Test that is_field_included() returns True only for listed fields.""" - config = DTOConfig.from_include(["field1", "field2"]) - assert config.is_field_included("field1") is True - assert config.is_field_included("field2") is True - assert config.is_field_included("field3") is False +@pytest.mark.parametrize( + ("include_spec", "expected_fields"), + [ + pytest.param("all", {"name", "color_id", "sweetness", "id", "color"}, id="all"), + pytest.param(["name", "sweetness"], {"name", "sweetness"}, id="list"), + pytest.param({"name", "sweetness"}, {"name", "sweetness"}, id="set"), + ], +) +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_from_include_spec( + factory: AnyFactory, + model: type[Fruit | FruitDataclass], + include_spec: Any, + expected_fields: set[str], +) -> None: + """Test that from_include() accepts 'all' and field-name collections.""" + dto = factory.factory(model, DTOConfig.from_include(include_spec)) + assert set(DTOInspect(dto).annotations()) == expected_fields -def test_is_field_included_with_empty_include() -> None: - """Test that is_field_included() returns False for all fields when include is empty.""" - config = DTOConfig.from_include(None) - assert config.is_field_included("field1") is False - assert config.is_field_included("any_field") is False +@pytest.mark.parametrize("factory", factory_iterator()) +def test_from_include_with_custom_purpose(factory: AnyFactory) -> None: + """Test that from_include() honors the purpose: read-only fields are dropped from write DTOs.""" + dto = factory.factory(Book, DTOConfig.from_include(["title", "isbn"], purpose=Purpose.WRITE)) + assert set(DTOInspect(dto).annotations()) == {"title"} -def test_is_field_included_with_exclude() -> None: - """Test that excluded fields are properly excluded even when include='all'.""" - config = DTOConfig(Purpose.READ, include="all", exclude={"field2", "field3"}) - assert config.is_field_included("field1") is True - assert config.is_field_included("field2") is False - assert config.is_field_included("field3") is False - assert config.is_field_included("field4") is True +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_named_exclude_with_include_all(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that excluded fields are dropped from the DTO even when include='all'.""" + dto = factory.factory(model, DTOConfig(Purpose.READ, include="all", exclude={"color", "color_id"})) + assert set(DTOInspect(dto).annotations()) == {"name", "sweetness", "id"} @pytest.mark.parametrize( @@ -368,3 +352,147 @@ def test_strawchemy_definition_populate_fields(key_source: type[DeclarativeBase] assert result is definition assert definition.field_map == {DTOKey([_PopulateFieldsModel]) + "id": field_def} + + +@pytest.mark.parametrize( + "include_spec", + [pytest.param("scalars", id="direct-string"), pytest.param([SCALARS], id="constant-list")], +) +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_scalars_include_allows_exclude( + factory: AnyFactory, model: type[Fruit | FruitDataclass], include_spec: FieldSpec +) -> None: + """Test that a group-bearing include coexists with exclude and is not clobbered to 'all'.""" + dto = factory.factory(model, DTOConfig(Purpose.READ, include=include_spec, exclude=["name"])) + assert set(DTOInspect(dto).annotations()) == {"id", "color_id", "sweetness"} + + +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_plain_include_with_exclude(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that a plain field-name include and exclude combine (exclude wins) and warn on overlap.""" + msg = "both explicitly included and excluded" + with pytest.warns(UserWarning, match=msg): + dto = factory.factory(model, DTOConfig(Purpose.READ, include=["name", "sweetness"], exclude=["sweetness"])) + assert set(DTOInspect(dto).annotations()) == {"name"} + with pytest.warns(UserWarning, match=msg): + StrawchemyConfig(dialect="postgresql", include=["name", "sweetness"], exclude=["sweetness"]) + + +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_bare_exclude_still_implies_all(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that a bare exclude (no include) still implies include='all'.""" + dto = factory.factory(model, DTOConfig(Purpose.READ, exclude=["name"])) + assert set(DTOInspect(dto).annotations()) == {"id", "color_id", "sweetness", "color"} + + +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_mixed_group_and_name_include_allows_exclude(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that a group selector mixed with a field name coexists with exclude.""" + dto = factory.factory(model, DTOConfig(Purpose.READ, include=[SCALARS, "color"], exclude=["name"])) + assert set(DTOInspect(dto).annotations()) == {"id", "color_id", "sweetness", "color"} + + +@pytest.mark.parametrize("model", [Fruit, FruitDataclass]) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_global_group_include_allows_global_exclude(factory: AnyFactory, model: type[Fruit | FruitDataclass]) -> None: + """Test that group-bearing global_include/global_exclude shape nested DTOs.""" + dto = factory.factory( + model, DTOConfig(Purpose.READ, include=[SCALARS, "color"], global_include=[SCALARS], global_exclude=["name"]) + ) + annotations = DTOInspect(dto).annotations() + # Root fields follow `include`; global rules don't apply at the root. + assert set(annotations) == {"id", "name", "color_id", "sweetness", "color"} + # Nested DTO follows global rules: scalars only, minus the global exclude. + color_dto = next((arg for arg in get_args(annotations["color"]) if arg is not type(None)), annotations["color"]) + assert set(DTOInspect(color_dto).annotations()) == {"id"} + + +@pytest.mark.parametrize( + "include_spec", + [ + pytest.param("all", id="all-string"), + pytest.param(ALL, id="bare-constant"), + pytest.param([ALL], id="constant-list"), + pytest.param([SCALARS, RELATIONSHIPS], id="both-groups"), + ], +) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_include_all_equivalents(factory: AnyFactory, include_spec: Any) -> None: + """Test that [ALL] and [SCALARS, RELATIONSHIPS] are equivalent to include='all'.""" + dto = factory.factory(Fruit, DTOConfig(Purpose.READ, include=include_spec)) + assert set(DTOInspect(dto).annotations()) == {"id", "name", "color_id", "sweetness", "color"} + + +@pytest.mark.parametrize( + "include_spec", + [ + pytest.param(("scalars", ()), id="include-scalars-string"), + pytest.param((SCALARS, ()), id="include-scalars-bare-constant"), + pytest.param(([SCALARS], ()), id="include-scalars-list"), + pytest.param((None, RELATIONSHIPS), id="bare-exclude-relationships-bare-constant"), + pytest.param(([ALL], [RELATIONSHIPS]), id="exclude-relationships-constant-list"), + pytest.param(("all", "relationships"), id="exclude-relationships-string"), + pytest.param((None, "relationships"), id="bare-exclude-relationships-string"), + pytest.param((None, [RELATIONSHIPS]), id="bare-exclude-relationships-constant-list"), + ], +) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_include_scalars(factory: AnyFactory, include_spec: tuple[FieldSpec | None, FieldSpec]) -> None: + """Test that scalars group selectors keep scalar fields and drop relationships.""" + include, exclude = include_spec + dto = factory.factory(Fruit, DTOConfig(Purpose.READ, include=include, exclude=exclude)) + assert set(DTOInspect(dto).annotations()) == {"id", "name", "color_id", "sweetness"} + + +@pytest.mark.parametrize( + "include_spec", + [ + pytest.param(("relationships", ()), id="include-relationships-string"), + pytest.param((RELATIONSHIPS, ()), id="include-relationships-bare-constant"), + pytest.param(([RELATIONSHIPS], ()), id="include-relationships-list"), + pytest.param((None, SCALARS), id="bare-exclude-scalars-bare-constant"), + pytest.param(([ALL], [SCALARS]), id="exclude-scalars-constant-list"), + pytest.param(("all", "scalars"), id="exclude-scalars-string"), + pytest.param((None, "scalars"), id="bare-exclude-scalars-string"), + pytest.param((None, [SCALARS]), id="bare-exclude-scalars-constant-list"), + ], +) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_include_relationships(factory: AnyFactory, include_spec: tuple[FieldSpec | None, FieldSpec]) -> None: + """Test that relationships group selectors keep only relationships and drop scalars.""" + include, exclude = include_spec + dto = factory.factory(Fruit, DTOConfig(Purpose.READ, include=include, exclude=exclude)) + assert set(DTOInspect(dto).annotations()) == {"color"} + + +@pytest.mark.parametrize( + "include_spec", + [ + pytest.param(["scalars"], id="scalars-list"), + pytest.param(["relationships"], id="relationships-list"), + pytest.param({"all"}, id="all-set"), + ], +) +@pytest.mark.parametrize("factory", factory_iterator()) +def test_group_string_in_iterable_is_field_name(factory: AnyFactory, include_spec: Any) -> None: + """Test that group string literals inside iterables are treated as field names, not group selectors.""" + with pytest.raises(EmptyDTOError): + factory.factory(Fruit, DTOConfig(Purpose.READ, include=include_spec), if_no_fields="raise") + + +@pytest.mark.parametrize("factory", factory_iterator()) +def test_exclude_relationships_plus_named_scalar(factory: AnyFactory) -> None: + """Test that exclude=[RELATIONSHIPS, 'sweetness'] drops relationships and the named scalar.""" + dto = factory.factory(Fruit, DTOConfig(Purpose.READ, exclude=[RELATIONSHIPS, "sweetness"])) + assert set(DTOInspect(dto).annotations()) == {"id", "name", "color_id"} + + +@pytest.mark.parametrize("factory", factory_iterator()) +def test_include_plain_names_with_relationship(factory: AnyFactory) -> None: + """Test that plain field-name includes select scalars and relations by name, without groups.""" + dto = factory.factory(Fruit, DTOConfig(Purpose.READ, include=frozenset(["name", "color"]))) + assert set(DTOInspect(dto).annotations()) == {"name", "color"} diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index ae873030..04754336 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -16,12 +16,14 @@ from strawberry.types import get_object_definition from strawberry.types.object_type import StrawberryObjectDefinition +from strawchemy import RELATIONSHIPS, SCALARS from strawchemy.exceptions import EmptyDTOError, QueryHookError, StrawchemyError, StrawchemyFieldError from strawchemy.schema.scalars import Interval from strawchemy.testing.pytest_plugin import MockContext from tests.fixtures import DefaultQuery from tests.unit.models import Book as BookModel -from tests.unit.models import Fruit, User +from tests.unit.models import Color, Fruit, User +from tests.utils import DTOInspect if TYPE_CHECKING: from syrupy.assertion import SnapshotAssertion @@ -639,3 +641,21 @@ def test_json_column_class_body_resolver_executes() -> None: result = schema.execute_sync("{ overriddenJson { dictCol } }") assert not result.errors assert result.data == {"overriddenJson": {"dictCol": "OVERRIDE"}} + + +def test_exclude_relationships_avoids_stub_collision(strawchemy: Strawchemy) -> None: + """Test that exclude=[RELATIONSHIPS] walks no relationships, so a later explicit type for a related model does not collide with a pre-registered walker stub (#162).""" + + # First slice scopes Fruit with a relationship-free walk... + @strawchemy.type(Fruit, exclude=[RELATIONSHIPS]) + class FruitNode: + pass + + # ...so a later explicit Color type must NOT collide with a walker stub. + @strawchemy.type(Color, include=[SCALARS]) + class ColorNode: + pass + + fruit_fields = set(DTOInspect(FruitNode).annotations()) + assert "color" not in fruit_fields + assert {"id", "name", "sweetness", "color_id"} <= fruit_fields diff --git a/tests/utils.py b/tests/utils.py index ccb69798..09d6e6cc 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -144,9 +144,6 @@ class FactoryType(Enum): class DTOInspectProtocol(Protocol): dto: type[Any] - def __init__(self, dto: type[Any]) -> None: - self.dto = dto - @classmethod def is_class(cls, dto: type[Any]) -> bool: ... @@ -161,7 +158,7 @@ class DataclassInspect(DTOInspectProtocol): dto: type[DataclassProtocol] def __init__(self, dto: type[DataclassProtocol]) -> None: - super().__init__(dto) + self.dto = dto self._dataclass_fields = {field.name: field for field in dataclasses.fields(self.dto)} @classmethod