Skip to content
Merged
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,70 @@ See the [custom resolvers](#custom-resolvers) for more details

</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.

<details>
<summary>Field group examples</summary>

```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`.

</details>

### Type Override

When generating types for relationships, Strawchemy creates default names (e.g., `<ModelName>Type`). If you have already
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/strawchemy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +21,11 @@
from strawchemy.validation import InputValidationError

__all__ = (
"ALL",
"RELATIONSHIPS",
"SCALARS",
"ErrorType",
"FieldGroup",
"Input",
"InputValidationError",
"ModelInstance",
Expand Down
18 changes: 12 additions & 6 deletions src/strawchemy/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`."""
Expand All @@ -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())
Expand Down
34 changes: 12 additions & 22 deletions src/strawchemy/dto/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
DTOMissing,
DTOSkip,
DTOUnset,
FieldIterable,
IncludeFields,
FieldGroup,
FieldSpec,
Purpose,
PurposeConfig,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()),
]
Expand Down Expand Up @@ -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,
Expand Down
Loading