Repository: gazorby/strawchemy
Pull request #43: feat(cud): add filter update mutation
Author: login: gazorby
Created: 2025-04-09T18:28:41Z | Merged: 2025-04-09T18:45:34Z

<!--- Provide a general summary of your changes in the title above. -->

<!--- This template is entirely optional and can be removed, but is here to help both you and us. -->
<!--- Anything on lines wrapped in comments like these will not show up in the final text. -->

## Description

<!--- Describe your changes in detail here. -->

## Types of Changes

<!--- What types of changes does your pull request introduce? Put an `x` in all the boxes that apply. -->

- [ ] Core
- [ ] Bugfix
- [x] New feature
- [ ] Enhancement/optimization
- [ ] Documentation

## Issues Fixed or Closed by This PR

- Closes #39 

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] I have read the CONTRIBUTING document.
- [x] I have added tests to cover my changes.
- [x] I have tested the changes and verified that they work and don't break anything (as well as I can manage).

## Summary by Sourcery

Add support for filter-based update mutations in Strawchemy, enabling updates to multiple records based on a filter condition

New Features:
- Introduce a new update mutation method that allows updating records based on a filter condition
- Add support for filter-based updates in both async and sync repositories

Enhancements:
- Refactor mutation field classes to support filter-based updates
- Improve input type generation for different update scenarios
- Extend repository interfaces to handle filter-based updates

Tests:
- Add integration tests for filter-based update mutations
- Verify update behavior with different filter conditions
- Ensure only matching records are updated

Linked issue #39: feat(cud): add filtering on update/delete mutations
<!--- Provide a general summary of the changes you want in the title above. -->

<!--- This template is entirely optional and can be removed, but is here to help both you and us. -->
<!--- Anything on lines wrapped in comments like these will not show up in the final text. -->

## Feature Request Type

- [ ] Core functionality
- [x] Alteration (enhancement/optimization) of existing feature(s)
- [ ] New behavior

## Description

<!-- A few sentences describing what it is. -->


Diff:
diff --git a/examples/testapp/testapp/schema.py b/examples/testapp/testapp/schema.py
index 114e7309..0e4a2666 100644
--- a/examples/testapp/testapp/schema.py
+++ b/examples/testapp/testapp/schema.py
@@ -9,6 +9,7 @@
     ProjectType,
     TicketCreate,
     TicketFilter,
+    TicketPartial,
     TicketType,
     TicketUpdate,
     strawchemy,
@@ -36,7 +37,8 @@ class Mutation:
 
     create_milestone: MilestoneType = strawchemy.create(MilestoneCreate)
 
-    update_ticket: TicketType = strawchemy.update_by_ids(TicketUpdate)
+    update_tickets_by_ids: TicketType = strawchemy.update_by_ids(TicketUpdate)
+    update_tickets: list[TicketType] = strawchemy.update(TicketPartial, TicketFilter)
 
     delete_ticket: list[TicketType] = strawchemy.delete(TicketFilter)
 
diff --git a/examples/testapp/testapp/types.py b/examples/testapp/testapp/types.py
index 6cf2a114..bd027da5 100644
--- a/examples/testapp/testapp/types.py
+++ b/examples/testapp/testapp/types.py
@@ -50,10 +50,14 @@ class MilestoneType: ...
 class TicketCreate: ...
 
 
-@strawchemy.update_input(Ticket, include="all")
+@strawchemy.pk_update_input(Ticket, include="all")
 class TicketUpdate: ...
 
 
+@strawchemy.filter_update_input(Ticket, include="all")
+class TicketPartial: ...
+
+
 @strawchemy.create_input(Project, include="all", override=True)
 class ProjectCreate: ...
 
diff --git a/src/strawchemy/dto/base.py b/src/strawchemy/dto/base.py
index aead93f5..fbf64530 100644
--- a/src/strawchemy/dto/base.py
+++ b/src/strawchemy/dto/base.py
@@ -491,7 +491,7 @@ def _node_or_root(
         return Node(Relation(model=model, name=name)) if node is None else node
 
     def _cache_key(
-        self, model: type[Any], dto_config: DTOConfig, node: Node[Relation[Any, DTOBaseT], None]
+        self, model: type[Any], dto_config: DTOConfig, node: Node[Relation[Any, DTOBaseT], None], **factory_kwargs: Any
     ) -> Hashable:
         base_key = frozenset(
             [
@@ -626,7 +626,7 @@ def factory(
             name = base.__name__ if base else self.dto_name(model.__name__, dto_config, current_node)
         node = self._node_or_root(model, name, current_node)
 
-        cache_key = self._cache_key(model, dto_config, node)
+        cache_key = self._cache_key(model, dto_config, node, **kwargs)
 
         if dto := self._dto_cache.get(cache_key):
             return self.backend.copy(dto, name) if node.is_root else dto
diff --git a/src/strawchemy/dto/inspectors/sqlalchemy.py b/src/strawchemy/dto/inspectors/sqlalchemy.py
index 54ec3094..9deabfa9 100644
--- a/src/strawchemy/dto/inspectors/sqlalchemy.py
+++ b/src/strawchemy/dto/inspectors/sqlalchemy.py
@@ -252,7 +252,10 @@ def field_definition(
         prop = mapper.attrs[model_field.key]
         elem = prop if isinstance(prop, MappedSQLExpression) else mapper.attrs[model_field.key]
         config = self._field_config(elem)
-        default, default_factory = self._defaults(elem)
+        if dto_config.exclude_defaults:
+            default, default_factory = DTO_MISSING, DTO_MISSING
+        else:
+            default, default_factory = self._defaults(elem)
         uselist = self._uselist(elem)
         is_relation = self._is_relationship(elem)
 
diff --git a/src/strawchemy/dto/types.py b/src/strawchemy/dto/types.py
index 3d097154..6fd88e28 100644
--- a/src/strawchemy/dto/types.py
+++ b/src/strawchemy/dto/types.py
@@ -154,7 +154,7 @@ class DTOConfig:
     type_overrides: Mapping[Any, Any] = field(default_factory=dict)
     annotation_overrides: dict[str, Any] = field(default_factory=dict)
     aliases: Mapping[str, str] = field(default_factory=dict)
-
+    exclude_defaults: bool = False
     alias_generator: Callable[[str], str] | None = None
 
     def __post_init__(self) -> None:
diff --git a/src/strawchemy/mapper.py b/src/strawchemy/mapper.py
index 20d1202d..82804861 100644
--- a/src/strawchemy/mapper.py
+++ b/src/strawchemy/mapper.py
@@ -11,7 +11,12 @@
 from .config import StrawchemyConfig
 from .graphql.dto import BooleanFilterDTO, EnumDTO, MappedDataclassGraphQLDTO, OrderByDTO, OrderByEnum
 from .graphql.factories.types import DistinctOnFieldsDTOFactory
-from .strawberry import StrawchemyCreateUpdateMutationField, StrawchemyDeleteMutationField, StrawchemyField
+from .strawberry import (
+    StrawchemyCreateMutationField,
+    StrawchemyDeleteMutationField,
+    StrawchemyField,
+    StrawchemyUpdateMutationField,
+)
 from .strawberry.factory import (
     StrawberryAggregateFilterInputFactory,
     StrawberryFilterInputFactory,
@@ -63,7 +68,8 @@ def __init__(self, settings: StrawchemyConfig | None = None) -> None:
         self.distinct_on = self._distinct_on_enum_factory.decorator
         self.input = self._input_factory.input
         self.create_input = partial(self._input_factory.input, mode="create")
-        self.update_input = partial(self._input_factory.input, mode="update")
+        self.pk_update_input = partial(self._input_factory.input, mode="update_by_pk")
+        self.filter_update_input = partial(self._input_factory.input, mode="update_by_filter")
         self.order = self._order_by_factory.input
         self.type = self._type_factory.type
         self.aggregate = self._aggregation_factory.type
@@ -226,9 +232,8 @@ def create(
         type_annotation = StrawberryAnnotation.from_annotation(graphql_type, namespace) if graphql_type else None
         repository_type_ = repository_type if repository_type is not None else self.settings.repository_type
 
-        field = StrawchemyCreateUpdateMutationField(
+        field = StrawchemyCreateMutationField(
             input_type,
-            "create",
             repository_type=repository_type_,
             session_getter=self.settings.session_getter,
             inspector=self.inspector,
@@ -249,12 +254,12 @@ def create(
         )
         return field(resolver) if resolver else field
 
-    def update_by_ids(
+    def update(
         self,
         input_type: type[AnyMappedDTO],
+        filter_input: type[StrawchemyTypeFromPydantic[BooleanFilterDTO[T, ModelFieldT]]],
         resolver: _RESOLVER_TYPE[Any] | None = None,
         *,
-        filter_input: type[StrawchemyTypeFromPydantic[BooleanFilterDTO[T, ModelFieldT]]] | None = None,
         repository_type: AnyRepository | None = None,
         name: str | None = None,
         description: str | None = None,
@@ -271,9 +276,8 @@ def update_by_ids(
         type_annotation = StrawberryAnnotation.from_annotation(graphql_type, namespace) if graphql_type else None
         repository_type_ = repository_type if repository_type is not None else self.settings.repository_type
 
-        field = StrawchemyCreateUpdateMutationField(
-            input_type,
-            "update",
+        field = StrawchemyUpdateMutationField(
+            input_type=input_type,
             filter_type=filter_input,
             repository_type=repository_type_,
             session_getter=self.settings.session_getter,
@@ -295,6 +299,49 @@ def update_by_ids(
         )
         return field(resolver) if resolver else field
 
+    def update_by_ids(
+        self,
+        input_type: type[AnyMappedDTO],
+        resolver: _RESOLVER_TYPE[Any] | None = None,
+        *,
+        repository_type: AnyRepository | None = None,
+        name: str | None = None,
+        description: str | None = None,
+        permission_classes: list[type[BasePermission]] | None = None,
+        deprecation_reason: str | None = None,
+        default: Any = dataclasses.MISSING,
+        default_factory: Callable[..., object] | object = dataclasses.MISSING,
+        metadata: Mapping[Any, Any] | None = None,
+        directives: Sequence[object] = (),
+        graphql_type: Any | None = None,
+        extensions: list[FieldExtension] | None = None,
+    ) -> Any:
+        namespace = self.registry.namespace("object")
+        type_annotation = StrawberryAnnotation.from_annotation(graphql_type, namespace) if graphql_type else None
+        repository_type_ = repository_type if repository_type is not None else self.settings.repository_type
+
+        field = StrawchemyUpdateMutationField(
+            input_type=input_type,
+            repository_type=repository_type_,
+            session_getter=self.settings.session_getter,
+            inspector=self.inspector,
+            auto_snake_case=self.settings.auto_snake_case,
+            python_name=None,
+            graphql_name=name,
+            type_annotation=type_annotation,
+            is_subscription=False,
+            permission_classes=permission_classes or [],
+            deprecation_reason=deprecation_reason,
+            default=default,
+            default_factory=default_factory,
+            metadata=metadata,
+            directives=directives,
+            extensions=extensions or [],
+            registry_namespace=namespace,
+            description=description,
+        )
+        return field(resolver) if resolver else field
+
     def delete(
         self,
         filter_input: type[StrawchemyTypeFromPydantic[BooleanFilterDTO[T, ModelFieldT]]] | None = None,
diff --git a/src/strawchemy/sqlalchemy/repository/_async.py b/src/strawchemy/sqlalchemy/repository/_async.py
index 09cb790b..3d61d1c3 100644
--- a/src/strawchemy/sqlalchemy/repository/_async.py
+++ b/src/strawchemy/sqlalchemy/repository/_async.py
@@ -25,7 +25,7 @@
 T = TypeVar("T", bound=Any)
 
 _RowLike: TypeAlias = "Row[Any] | NamedTuple"
-_InsertOrUpdate: TypeAlias = Literal["insert", "update"]
+_InsertOrUpdate: TypeAlias = Literal["insert", "update_by_pks", "update_where"]
 
 
 class SQLAlchemyGraphQLAsyncRepository(SQLAlchemyGraphQLRepository[DeclarativeT, AnyAsyncSession]):
@@ -170,7 +170,7 @@ async def _set_to_many_relations(
                 assert prop.local_remote_pairs
                 assert relation.field.related_model
                 parent = created_ids[relation.input_index] if relation.level == 1 else relation.parent
-                if relation.level == 1 and mode == "update":
+                if relation.level == 1 and mode in {"update_by_pks", "update_where"}:
                     for local, remote in prop.local_remote_pairs:
                         remove_old_ids[relation.field.related_model][remote.key].append(getattr(parent, local.key))
                 for relation_model in relation.set or []:
@@ -235,24 +235,44 @@ async def _create_to_many_relations(
             for model_type, values in insert_params.items():
                 await self._insert_nested(model_type, values, level)
 
-    async def _mutate(
-        self, mode: _InsertOrUpdate, data: InputData[DeclarativeBase, QueryableAttribute[Any]]
+    async def _execute_insert_or_update(
+        self,
+        mode: _InsertOrUpdate,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None,
     ) -> Sequence[_RowLike]:
         model_pks = self.model.__mapper__.primary_key
+        values = [self._to_dict(instance) for instance in data.input_instances]
+        if mode == "insert":
+            statement = insert(self.model).returning(*model_pks, sort_by_parameter_order=True)
+            result = await self.session.execute(statement, values)
+            return result.all()
+
+        pks = [column.key for column in self.model.__mapper__.primary_key]
+        pk_tuple = namedtuple("AsRow", pks)  # pyright: ignore[reportUntypedNamedTuple]  # noqa: PYI024
+
+        if mode == "update_by_pks":
+            await self.session.execute(update(self.model), values)
+            return [pk_tuple(*[instance[name] for name in pks]) for instance in values]
+
+        transpiler = QueryTranspiler(self.model, self._dialect, statement=self.statement)
+        alias = inspect(transpiler.scope.root_alias)
+        statement = update(alias).values(**values[0]).returning(*model_pks)
+        if dto_filter:
+            statement = statement.where(*transpiler.filter_expressions(dto_filter))
+        result = await self.session.execute(statement)
+        return result.all()
+
+    async def _mutate(
+        self,
+        mode: _InsertOrUpdate,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None = None,
+    ) -> Sequence[_RowLike]:
         async with self.session.begin_nested() as transaction:
             self._connect_to_one_relations(data)
             await self._create_nested_to_one_relations(data)
-            values = [self._to_dict(instance) for instance in data.input_instances]
-            if mode == "insert":
-                result = await self.session.execute(
-                    insert(self.model).returning(*model_pks, sort_by_parameter_order=True), values
-                )
-                instance_ids = result.all()
-            else:
-                pks = [column.key for column in model_pks]
-                pk_tuple = namedtuple("AsRow", pks)  # pyright: ignore[reportUntypedNamedTuple]  # noqa: PYI024
-                await self.session.execute(update(self.model), values)
-                instance_ids = [pk_tuple(*[instance[name] for name in pks]) for instance in values]
+            instance_ids = await self._execute_insert_or_update(mode, data, dto_filter)
             await self._create_to_many_relations(data, instance_ids)
             await self._update_to_many_relations(data, instance_ids)
             await self._set_to_many_relations(mode, data, instance_ids)
@@ -447,7 +467,7 @@ async def create(
         created_ids = await self._mutate("insert", data)
         return await self._list_by_ids(created_ids, selection)
 
-    async def update(
+    async def update_by_ids(
         self, data: InputData[DeclarativeBase, QueryableAttribute[Any]], selection: SQLAlchemyQueryNode | None = None
     ) -> QueryResult[DeclarativeT]:
         """Updates one or more records with nested relationships and returns them.
@@ -465,7 +485,16 @@ async def update(
             A QueryResult containing the updated records, structured
             according to the selection.
         """
-        updated_ids = await self._mutate("update", data)
+        updated_ids = await self._mutate("update_by_pks", data)
+        return await self._list_by_ids(updated_ids, selection)
+
+    async def update_by_filter(
+        self,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]],
+        selection: SQLAlchemyQueryNode | None = None,
+    ) -> QueryResult[DeclarativeT]:
+        updated_ids = await self._mutate("update_where", data, dto_filter)
         return await self._list_by_ids(updated_ids, selection)
 
     async def delete(
diff --git a/src/strawchemy/sqlalchemy/repository/_sync.py b/src/strawchemy/sqlalchemy/repository/_sync.py
index c6c94afc..b4bb12f6 100644
--- a/src/strawchemy/sqlalchemy/repository/_sync.py
+++ b/src/strawchemy/sqlalchemy/repository/_sync.py
@@ -27,7 +27,7 @@
 T = TypeVar("T", bound=Any)
 
 _RowLike: TypeAlias = "Row[Any] | NamedTuple"
-_InsertOrUpdate: TypeAlias = Literal["insert", "update"]
+_InsertOrUpdate: TypeAlias = Literal["insert", "update_by_pks", "update_where"]
 
 
 class SQLAlchemyGraphQLSyncRepository(SQLAlchemyGraphQLRepository[DeclarativeT, AnySyncSession]):
@@ -172,7 +172,7 @@ def _set_to_many_relations(
                 assert prop.local_remote_pairs
                 assert relation.field.related_model
                 parent = created_ids[relation.input_index] if relation.level == 1 else relation.parent
-                if relation.level == 1 and mode == "update":
+                if relation.level == 1 and mode in {"update_by_pks", "update_where"}:
                     for local, remote in prop.local_remote_pairs:
                         remove_old_ids[relation.field.related_model][remote.key].append(getattr(parent, local.key))
                 for relation_model in relation.set or []:
@@ -237,24 +237,44 @@ def _create_to_many_relations(
             for model_type, values in insert_params.items():
                 self._insert_nested(model_type, values, level)
 
-    def _mutate(
-        self, mode: _InsertOrUpdate, data: InputData[DeclarativeBase, QueryableAttribute[Any]]
+    def _execute_insert_or_update(
+        self,
+        mode: _InsertOrUpdate,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None,
     ) -> Sequence[_RowLike]:
         model_pks = self.model.__mapper__.primary_key
+        values = [self._to_dict(instance) for instance in data.input_instances]
+        if mode == "insert":
+            statement = insert(self.model).returning(*model_pks, sort_by_parameter_order=True)
+            result = self.session.execute(statement, values)
+            return result.all()
+
+        pks = [column.key for column in self.model.__mapper__.primary_key]
+        pk_tuple = namedtuple("AsRow", pks)  # pyright: ignore[reportUntypedNamedTuple]  # noqa: PYI024
+
+        if mode == "update_by_pks":
+            self.session.execute(update(self.model), values)
+            return [pk_tuple(*[instance[name] for name in pks]) for instance in values]
+
+        transpiler = QueryTranspiler(self.model, self._dialect, statement=self.statement)
+        alias = inspect(transpiler.scope.root_alias)
+        statement = update(alias).values(**values[0]).returning(*model_pks)
+        if dto_filter:
+            statement = statement.where(*transpiler.filter_expressions(dto_filter))
+        result = self.session.execute(statement)
+        return result.all()
+
+    def _mutate(
+        self,
+        mode: _InsertOrUpdate,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None = None,
+    ) -> Sequence[_RowLike]:
         with self.session.begin_nested() as transaction:
             self._connect_to_one_relations(data)
             self._create_nested_to_one_relations(data)
-            values = [self._to_dict(instance) for instance in data.input_instances]
-            if mode == "insert":
-                result = self.session.execute(
-                    insert(self.model).returning(*model_pks, sort_by_parameter_order=True), values
-                )
-                instance_ids = result.all()
-            else:
-                pks = [column.key for column in model_pks]
-                pk_tuple = namedtuple("AsRow", pks)  # pyright: ignore[reportUntypedNamedTuple]  # noqa: PYI024
-                self.session.execute(update(self.model), values)
-                instance_ids = [pk_tuple(*[instance[name] for name in pks]) for instance in values]
+            instance_ids = self._execute_insert_or_update(mode, data, dto_filter)
             self._create_to_many_relations(data, instance_ids)
             self._update_to_many_relations(data, instance_ids)
             self._set_to_many_relations(mode, data, instance_ids)
@@ -449,7 +469,7 @@ def create(
         created_ids = self._mutate("insert", data)
         return self._list_by_ids(created_ids, selection)
 
-    def update(
+    def update_by_ids(
         self, data: InputData[DeclarativeBase, QueryableAttribute[Any]], selection: SQLAlchemyQueryNode | None = None
     ) -> QueryResult[DeclarativeT]:
         """Updates one or more records with nested relationships and returns them.
@@ -467,7 +487,16 @@ def update(
             A QueryResult containing the updated records, structured
             according to the selection.
         """
-        updated_ids = self._mutate("update", data)
+        updated_ids = self._mutate("update_by_pks", data)
+        return self._list_by_ids(updated_ids, selection)
+
+    def update_by_filter(
+        self,
+        data: InputData[DeclarativeBase, QueryableAttribute[Any]],
+        dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]],
+        selection: SQLAlchemyQueryNode | None = None,
+    ) -> QueryResult[DeclarativeT]:
+        updated_ids = self._mutate("update_where", data, dto_filter)
         return self._list_by_ids(updated_ids, selection)
 
     def delete(
diff --git a/src/strawchemy/strawberry/__init__.py b/src/strawchemy/strawberry/__init__.py
index 4fac8700..092736ea 100644
--- a/src/strawchemy/strawberry/__init__.py
+++ b/src/strawchemy/strawberry/__init__.py
@@ -2,15 +2,21 @@
 
 from strawchemy.types import DefaultOffsetPagination
 
-from ._field import StrawchemyCreateUpdateMutationField, StrawchemyDeleteMutationField, StrawchemyField
+from ._field import (
+    StrawchemyCreateMutationField,
+    StrawchemyDeleteMutationField,
+    StrawchemyField,
+    StrawchemyUpdateMutationField,
+)
 from ._instance import ModelInstance
 from ._utils import default_session_getter
 
 __all__ = (
     "DefaultOffsetPagination",
     "ModelInstance",
-    "StrawchemyCreateUpdateMutationField",
+    "StrawchemyCreateMutationField",
     "StrawchemyDeleteMutationField",
     "StrawchemyField",
+    "StrawchemyUpdateMutationField",
     "default_session_getter",
 )
diff --git a/src/strawchemy/strawberry/_field.py b/src/strawchemy/strawberry/_field.py
index a0ea7f68..30f444f1 100644
--- a/src/strawchemy/strawberry/_field.py
+++ b/src/strawchemy/strawberry/_field.py
@@ -72,13 +72,12 @@
     from .typing import (
         AnySessionGetter,
         FilterStatementCallable,
-        InputType,
         StrawchemyTypeFromPydantic,
         StrawchemyTypeWithStrawberryObjectDefinition,
     )
 
 
-__all__ = ("StrawchemyCreateUpdateMutationField", "StrawchemyDeleteMutationField", "StrawchemyField")
+__all__ = ("StrawchemyCreateMutationField", "StrawchemyDeleteMutationField", "StrawchemyField")
 
 T = TypeVar("T")
 
@@ -472,12 +471,11 @@ def get_result(
         return super().get_result(source, info, args, kwargs)
 
 
-class StrawchemyCreateUpdateMutationField(StrawchemyField[ModelT, ModelFieldT]):
-    def __init__(self, input_type: type[AnyMappedDTO], mutation_type: InputType, *args: Any, **kwargs: Any) -> None:
+class StrawchemyCreateMutationField(StrawchemyField[ModelT, ModelFieldT]):
+    def __init__(self, input_type: type[AnyMappedDTO], *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.is_root_field = True
         self._input_type = input_type
-        self._mutation_type: InputType = mutation_type
 
     def _create_resolver(
         self, info: Info, data: AnyMappedDTO | Sequence[AnyMappedDTO]
@@ -485,14 +483,55 @@ def _create_resolver(
         repository = self._get_repository(info)
         return repository.create_many(data) if isinstance(data, Sequence) else repository.create(data)
 
-    def _update_resolver(
-        self, info: Info, data: AnyMappedDTO
+    @override
+    def auto_arguments(self) -> list[StrawberryArgument]:
+        if self.is_list:
+            return [StrawberryArgument(DATA_KEY, None, type_annotation=StrawberryAnnotation(list[self._input_type]))]
+        return [StrawberryArgument(DATA_KEY, None, type_annotation=StrawberryAnnotation(self._input_type))]
+
+    @override
+    def resolver(
+        self, info: Info[Any, Any], *args: Any, **kwargs: Any
+    ) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
+        return self._create_resolver(info, *args, **kwargs)
+
+
+class StrawchemyUpdateMutationField(StrawchemyField[ModelT, ModelFieldT]):
+    def __init__(self, input_type: type[AnyMappedDTO], *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        self.is_root_field = True
+        self._input_type = input_type
+
+    @override
+    def _validate_type(self, type_: StrawberryType | type[WithStrawberryObjectDefinition] | Any) -> None:
+        if self._filter is not None and not _is_list(type_):
+            msg = f"Type of update mutation by filter must be a list: {self.name}"
+            raise StrawchemyFieldError(msg)
+
+    def _update_by_ids_resolver(
+        self, info: Info, data: AnyMappedDTO | Sequence[AnyMappedDTO]
     ) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
         repository = self._get_repository(info)
-        return repository.update_many(data) if isinstance(data, Sequence) else repository.update(data)
+        return repository.update_many_by_id(data) if isinstance(data, Sequence) else repository.update_by_id(data)
+
+    def _update_by_filter_resolver(
+        self, info: Info, data: AnyMappedDTO, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[T, ModelFieldT]]
+    ) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
+        repository = self._get_repository(info)
+        return repository.update_by_filter(data, filter_input)
 
     @override
     def auto_arguments(self) -> list[StrawberryArgument]:
+        if self.filter:
+            return [
+                StrawberryArgument(DATA_KEY, None, type_annotation=StrawberryAnnotation(self._input_type)),
+                StrawberryArgument(
+                    python_name="filter_input",
+                    graphql_name=FILTER_KEY,
+                    type_annotation=StrawberryAnnotation(self.filter | None),
+                    default=None,
+                ),
+            ]
         if self.is_list:
             return [StrawberryArgument(DATA_KEY, None, type_annotation=StrawberryAnnotation(list[self._input_type]))]
         return [StrawberryArgument(DATA_KEY, None, type_annotation=StrawberryAnnotation(self._input_type))]
@@ -501,9 +540,9 @@ def auto_arguments(self) -> list[StrawberryArgument]:
     def resolver(
         self, info: Info[Any, Any], *args: Any, **kwargs: Any
     ) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
-        if self._mutation_type == "create":
-            return self._create_resolver(info, *args, **kwargs)
-        return self._update_resolver(info, *args, **kwargs)
+        if self.filter:
+            return self._update_by_filter_resolver(info, *args, **kwargs)
+        return self._update_by_ids_resolver(info, *args, **kwargs)
 
 
 class StrawchemyDeleteMutationField(StrawchemyField[ModelT, ModelFieldT]):
diff --git a/src/strawchemy/strawberry/factory.py b/src/strawchemy/strawberry/factory.py
index 7ae8b86c..ab17fd2e 100644
--- a/src/strawchemy/strawberry/factory.py
+++ b/src/strawchemy/strawberry/factory.py
@@ -2,7 +2,7 @@
 
 import dataclasses
 from collections.abc import Sequence
-from typing import TYPE_CHECKING, Any, Self, TypeVar, get_type_hints, override
+from typing import TYPE_CHECKING, Any, Literal, Self, TypeAlias, TypeVar, get_type_hints, override
 
 from typing_extensions import dataclass_transform
 
@@ -88,6 +88,8 @@
 MappedDataclassGraphQLDTOT = TypeVar("MappedDataclassGraphQLDTOT", bound=MappedDataclassGraphQLDTO[Any])
 StrawchemyDTOT = TypeVar("StrawchemyDTOT", bound=StrawchemyDTOAttributes)
 
+UpdateType: TypeAlias = Literal["pk", "filter"]
+
 
 @dataclasses.dataclass(eq=True, frozen=True)
 class _ChildOptions:
@@ -330,29 +332,32 @@ def __init__(
     def _root_input_config(self, model: type[Any], dto_config: DTOConfig, mode: InputType) -> DTOConfig:
         annotations_overrides: dict[str, Any] = {}
         partial = dto_config.partial
+        exclude_defaults = dto_config.exclude_defaults
         id_fields = self.inspector.id_field_definitions(model, dto_config)
         # Add PKs for update/delete inputs
-        if mode in ("update", "delete"):
+        if mode == "update_by_pk":
             if set(dto_config.exclude) & {name for name, _ in id_fields}:
                 msg = (
                     "You cannot exclude primary key columns from an input type intended for create or update mutations"
                 )
                 raise StrawchemyError(msg)
             annotations_overrides |= {name: field.type_hint for name, field in id_fields}
-        if mode == "update":
+        if mode == "update_by_filter":
+            exclude_defaults = True
+        if mode in {"update_by_pk", "update_by_filter"}:
             partial = True
         # Exclude default generated PKs for create inputs, if not explicitly included
         elif dto_config.include == "all":
             for name, field in id_fields:
                 if self.inspector.has_default(field.model_field):
                     annotations_overrides[name] = field.type_hint | None
-
         return dataclasses.replace(
             dto_config,
             annotation_overrides=annotations_overrides,
             partial=partial,
             partial_default=UNSET,
             unset_sentinel=UNSET,
+            exclude_defaults=exclude_defaults,
         )
 
     @classmethod
@@ -812,8 +817,9 @@ def _cache_key(
         node: Node[Relation[Any, MappedDataclassGraphQLDTO[Any]], None],
         *,
         child_options: _ChildOptions | None = None,
+        **factory_kwargs: Any,
     ) -> Hashable:
-        return (super()._cache_key(model, dto_config, node), child_options)
+        return (super()._cache_key(model, dto_config, node, **factory_kwargs), child_options)
 
     @override
     def factory(
@@ -911,8 +917,14 @@ def _cache_key(
         node: Node[Relation[Any, MappedDataclassGraphQLDTO[Any]], None],
         *,
         child_options: _ChildOptions | None = None,
+        mode: InputType,
+        **factory_kwargs: Any,
     ) -> Hashable:
-        return (super()._cache_key(model, dto_config, node, child_options=child_options), node.root.value.model)
+        return (
+            super()._cache_key(model, dto_config, node, child_options=child_options, **factory_kwargs),
+            node.root.value.model,
+            mode,
+        )
 
     @override
     def type_description(self) -> str:
@@ -957,7 +969,7 @@ def _resolve_type(
         if field.uselist:
             if mode == "create":
                 input_type = ToManyCreateInput[identifier_input, field.related_dto]  # pyright: ignore[reportInvalidTypeArguments]
-            elif mode == "update":
+            else:
                 type_ = (
                     RequiredToManyUpdateInput
                     if self.inspector.reverse_relation_required(field.model_field)
@@ -985,7 +997,7 @@ def iter_field_definitions(
         for field in super().iter_field_definitions(
             name, model, dto_config, base, node, raise_if_no_fields, mode=mode, **factory_kwargs
         ):
-            if mode == "update" and self.inspector.is_primary_key(field.model_field):
+            if mode == "update_by_pk" and self.inspector.is_primary_key(field.model_field):
                 field.type_ = non_optional_type_hint(field.type_)
             yield field
 
diff --git a/src/strawchemy/strawberry/repository/_async.py b/src/strawchemy/strawberry/repository/_async.py
index 92ee05f5..90e90de9 100644
--- a/src/strawchemy/strawberry/repository/_async.py
+++ b/src/strawchemy/strawberry/repository/_async.py
@@ -261,14 +261,22 @@ async def create(self, data: AnyMappedDTO) -> T:
         query_results = await self.graphql_repository().create(InputData([data]), self._tree)
         return self._tree.to_strawberry_type(query_results.one())
 
-    async def update_many(self, data: Sequence[AnyMappedDTO]) -> Sequence[T]:
-        query_results = await self.graphql_repository().update(InputData(data), self._tree)
+    async def update_many_by_id(self, data: Sequence[AnyMappedDTO]) -> Sequence[T]:
+        query_results = await self.graphql_repository().update_by_ids(InputData(data), self._tree)
         return self._tree.query_result_to_strawberry_type(query_results)
 
-    async def update(self, data: AnyMappedDTO) -> T:
-        query_results = await self.graphql_repository().update(InputData([data]), self._tree)
+    async def update_by_id(self, data: AnyMappedDTO) -> T:
+        query_results = await self.graphql_repository().update_by_ids(InputData([data]), self._tree)
         return self._tree.to_strawberry_type(query_results.one())
 
+    async def update_by_filter(
+        self, data: AnyMappedDTO, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]]
+    ) -> Sequence[T]:
+        query_results = await self.graphql_repository().update_by_filter(
+            InputData([data]), filter_input.to_pydantic(), self._tree
+        )
+        return self._tree.query_result_to_strawberry_type(query_results)
+
     async def delete(
         self, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]] | None = None
     ) -> Sequence[T]:
diff --git a/src/strawchemy/strawberry/repository/_sync.py b/src/strawchemy/strawberry/repository/_sync.py
index 688458df..834164ea 100644
--- a/src/strawchemy/strawberry/repository/_sync.py
+++ b/src/strawchemy/strawberry/repository/_sync.py
@@ -263,14 +263,22 @@ def create(self, data: AnyMappedDTO) -> T:
         query_results = self.graphql_repository().create(InputData([data]), self._tree)
         return self._tree.to_strawberry_type(query_results.one())
 
-    def update_many(self, data: Sequence[AnyMappedDTO]) -> Sequence[T]:
-        query_results = self.graphql_repository().update(InputData(data), self._tree)
+    def update_many_by_id(self, data: Sequence[AnyMappedDTO]) -> Sequence[T]:
+        query_results = self.graphql_repository().update_by_ids(InputData(data), self._tree)
         return self._tree.query_result_to_strawberry_type(query_results)
 
-    def update(self, data: AnyMappedDTO) -> T:
-        query_results = self.graphql_repository().update(InputData([data]), self._tree)
+    def update_by_id(self, data: AnyMappedDTO) -> T:
+        query_results = self.graphql_repository().update_by_ids(InputData([data]), self._tree)
         return self._tree.to_strawberry_type(query_results.one())
 
+    def update_by_filter(
+        self, data: AnyMappedDTO, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]]
+    ) -> Sequence[T]:
+        query_results = self.graphql_repository().update_by_filter(
+            InputData([data]), filter_input.to_pydantic(), self._tree
+        )
+        return self._tree.query_result_to_strawberry_type(query_results)
+
     def delete(self, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]] | None = None) -> Sequence[T]:
         query_results = self.graphql_repository().delete(
             self._tree, filter_input.to_pydantic() if filter_input else None
diff --git a/src/strawchemy/strawberry/typing.py b/src/strawchemy/strawberry/typing.py
index c1f1db87..144c8b41 100644
--- a/src/strawchemy/strawberry/typing.py
+++ b/src/strawchemy/strawberry/typing.py
@@ -28,7 +28,7 @@
 SyncSessionGetter: TypeAlias = "Callable[[Info[Any, Any]], AnySyncSession]"
 AnySessionGetter: TypeAlias = "AsyncSessionGetter | SyncSessionGetter"
 FilterStatementCallable: TypeAlias = "Callable[[Info[Any, Any]], Select[tuple[Any]]]"
-InputType: TypeAlias = Literal["create", "update"]
+InputType: TypeAlias = Literal["create", "update_by_pk", "update_by_filter"]
 
 
 class StrawchemyTypeWithStrawberryObjectDefinition(StrawchemyDTOAttributes, WithStrawberryObjectDefinition): ...
diff --git a/tests/integration/__snapshots__/test_mutations.ambr b/tests/integration/__snapshots__/test_mutations.ambr
index 59700fea..1a643d65 100644
--- a/tests/integration/__snapshots__/test_mutations.ambr
+++ b/tests/integration/__snapshots__/test_mutations.ambr
@@ -1052,6 +1052,96 @@
    WHERE color.id IN (__[POSTCOMPILE_id_1])
   '''
 # ---
+# name: test_update_by_filter[session-tracked-async-asyncpg_engine]
+  '''
+  UPDATE color AS color
+     SET name = $1::VARCHAR
+   WHERE color.name = $2::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter[session-tracked-async-asyncpg_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
+# name: test_update_by_filter[session-tracked-async-psycopg_async_engine]
+  '''
+  UPDATE color AS color
+     SET name = %(name)s::VARCHAR
+   WHERE color.name = %(name_1)s::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter[session-tracked-async-psycopg_async_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
+# name: test_update_by_filter[session-tracked-sync-psycopg_engine]
+  '''
+  UPDATE color AS color
+     SET name = %(name)s::VARCHAR
+   WHERE color.name = %(name_1)s::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter[session-tracked-sync-psycopg_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-async-asyncpg_engine]
+  '''
+  UPDATE color AS color
+     SET name = $1::VARCHAR
+   WHERE color.name = $2::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-async-asyncpg_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-async-psycopg_async_engine]
+  '''
+  UPDATE color AS color
+     SET name = %(name)s::VARCHAR
+   WHERE color.name = %(name_1)s::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-async-psycopg_async_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-sync-psycopg_engine]
+  '''
+  UPDATE color AS color
+     SET name = %(name)s::VARCHAR
+   WHERE color.name = %(name_1)s::VARCHAR RETURNING color.id
+  '''
+# ---
+# name: test_update_by_filter_only_return_affected_objects[session-tracked-sync-psycopg_engine].1
+  '''
+  SELECT color.name,
+         color.id
+    FROM color AS color
+   WHERE color.id IN (__[POSTCOMPILE_id_1])
+  '''
+# ---
 # name: test_update_many[session-tracked-async-asyncpg_engine]
   '''
   UPDATE color
diff --git a/tests/integration/test_mutations.py b/tests/integration/test_mutations.py
index 58bab764..4707f25b 100644
--- a/tests/integration/test_mutations.py
+++ b/tests/integration/test_mutations.py
@@ -12,6 +12,8 @@
 from .fixtures import QueryTracker
 from .types import (
     ColorCreateInput,
+    ColorFilter,
+    ColorPartial,
     ColorType,
     ColorUpdateInput,
     FruitCreateInput,
@@ -37,6 +39,9 @@ class AsyncMutation:
     update_colors: list[ColorType] = strawchemy.update_by_ids(
         ColorUpdateInput, repository_type=StrawchemyAsyncRepository
     )
+    update_colors_filter: list[ColorType] = strawchemy.update(
+        ColorPartial, ColorFilter, repository_type=StrawchemyAsyncRepository
+    )
 
     create_fruit: FruitType = strawchemy.create(FruitCreateInput, repository_type=StrawchemyAsyncRepository)
     create_fruits: list[FruitType] = strawchemy.create(FruitCreateInput, repository_type=StrawchemyAsyncRepository)
@@ -64,6 +69,9 @@ class SyncMutation:
     update_colors: list[ColorType] = strawchemy.update_by_ids(
         ColorUpdateInput, repository_type=StrawchemySyncRepository
     )
+    update_colors_filter: list[ColorType] = strawchemy.update(
+        ColorPartial, ColorFilter, repository_type=StrawchemySyncRepository
+    )
 
     create_fruits: list[FruitType] = strawchemy.create(FruitCreateInput, repository_type=StrawchemySyncRepository)
 
@@ -546,6 +554,69 @@ async def test_update(
     query_tracker.assert_statements(1, "select", sql_snapshot)  # Fetch id + name
 
 
+@pytest.mark.snapshot
+async def test_update_by_filter(
+    raw_colors: RawRecordData, any_query: AnyQueryExecutor, query_tracker: QueryTracker, sql_snapshot: SnapshotAssertion
+) -> None:
+    """Tests a simple update mutation."""
+    query = """
+        mutation {
+            updateColorsFilter(
+                data: {
+                    name: "updated color"
+                },
+                filter: {
+                    name: { eq: "Red" }
+                }
+            ) {
+                id
+                name
+            }
+        }
+    """
+    result = await maybe_async(any_query(query))
+    assert not result.errors
+    assert result.data
+    assert result.data["updateColorsFilter"] == [
+        {
+            "id": to_graphql_representation(raw_colors[0]["id"], "output"),
+            "name": "updated color",
+        }
+    ]
+
+    query_tracker.assert_statements(1, "update", sql_snapshot)  # Update color name
+    query_tracker.assert_statements(1, "select", sql_snapshot)  # Fetch id + name
+
+
+@pytest.mark.snapshot
+async def test_update_by_filter_only_return_affected_objects(
+    any_query: AnyQueryExecutor, query_tracker: QueryTracker, sql_snapshot: SnapshotAssertion
+) -> None:
+    """Tests a simple update mutation."""
+    query = """
+        mutation {
+            updateColorsFilter(
+                data: {
+                    name: "updated color"
+                },
+                filter: {
+                    name: { eq: "unknown" }
+                }
+            ) {
+                id
+                name
+            }
+        }
+    """
+    result = await maybe_async(any_query(query))
+    assert not result.errors
+    assert result.data
+    assert result.data["updateColorsFilter"] == []
+
+    query_tracker.assert_statements(1, "update", sql_snapshot)  # Update color name
+    query_tracker.assert_statements(1, "select", sql_snapshot)  # Fetch id + name
+
+
 @pytest.mark.snapshot
 async def test_update_with_to_one_set(
     raw_fruits: RawRecordData,
diff --git a/tests/integration/types.py b/tests/integration/types.py
index 83c6201d..16a3532b 100644
--- a/tests/integration/types.py
+++ b/tests/integration/types.py
@@ -47,7 +47,7 @@ class UserFilter: ...
 class UserCreate: ...
 
 
-@strawchemy.update_input(User, include="all")
+@strawchemy.pk_update_input(User, include="all")
 class UserUpdateInput: ...
 
 
@@ -109,7 +109,7 @@ class FruitOrderBy: ...
 class FruitCreateInput: ...
 
 
-@strawchemy.update_input(Fruit, include="all")
+@strawchemy.pk_update_input(Fruit, include="all")
 class FruitUpdateInput: ...
 
 
@@ -150,10 +150,18 @@ class ColorTypeHooks:
 class ColorCreateInput: ...
 
 
-@strawchemy.update_input(Color, include="all")
+@strawchemy.pk_update_input(Color, include="all")
 class ColorUpdateInput: ...
 
 
+@strawchemy.filter_update_input(Color, include="all")
+class ColorPartial: ...
+
+
+@strawchemy.filter(Color, include="all")
+class ColorFilter: ...
+
+
 # SQL Data types
 
 
diff --git a/tests/unit/mapping/__snapshots__/test_types/test_mutation_schemas[update_mutation].gql b/tests/unit/mapping/__snapshots__/test_types/test_mutation_schemas[update_mutation].gql
index 9f052b2a..b4020d3d 100644
--- a/tests/unit/mapping/__snapshots__/test_types/test_mutation_schemas[update_mutation].gql
+++ b/tests/unit/mapping/__snapshots__/test_types/test_mutation_schemas[update_mutation].gql
@@ -10,6 +10,21 @@
   """Decimal (fixed-point)"""
   scalar Decimal
   
+  """
+  Boolean expression to compare fields of type Float. All fields are combined with logical 'AND'
+  """
+  input FloatComparison {
+    eq: Float
+    neq: Float
+    isNull: Boolean
+    in: [Float!]
+    nin: [Float!]
+    gt: Float
+    gte: Float
+    lt: Float
+    lte: Float
+  }
+  
   """Aggregation fields"""
   type GroupAggregate {
     count: Int
@@ -18,14 +33,90 @@
     max: GroupMinMaxFields!
   }
   
+  """
+  Boolean expression to compare aggregated fields. All fields are combined with logical 'AND'.
+  """
+  input GroupAggregateBoolExp {
+    count: GroupAggregateBoolExpCount
+    sum: GroupAggregateBoolExpSum
+    minString: GroupAggregateBoolExpMinstring
+    maxString: GroupAggregateBoolExpMaxstring
+  }
+  
+  """Boolean expression to compare count aggregation."""
+  input GroupAggregateBoolExpCount {
+    arguments: [GroupCountFields!]
+    predicate: IntComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare max aggregation."""
+  input GroupAggregateBoolExpMaxstring {
+    arguments: [GroupMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare min aggregation."""
+  input GroupAggregateBoolExpMinstring {
+    arguments: [GroupMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare sum aggregation."""
+  input GroupAggregateBoolExpSum {
+    arguments: [GroupSumFieldsEnum!]!
+    predicate: FloatComparison!
+    distinct: Boolean
+  }
+  
+  enum GroupCountFields {
+    id
+    name
+    tagId
+  }
+  
+  """
+  Boolean expression to compare fields. All fields are combined with logical 'AND'.
+  """
+  input GroupFilter {
+    _and: [GroupFilter!]
+    _or: [GroupFilter!]
+    _not: GroupFilter
+    id: UUIDComparison
+    name: StringComparison
+    tagId: UUIDComparison
+    tagAggregate: TagAggregateBoolExp
+    tag: TagBoolExp
+    usersAggregate: UserAggregateBoolExp
+    users: UserBoolExp
+  }
+  
   type GroupMinMaxFields {
     name: String
   }
   
+  enum GroupMinMaxStringFieldsEnum {
+    name
+  }
+  
+  """GraphQL update_by_filter input type"""
+  input GroupPartial {
+    id: UUID
+    name: String
+    tag: GroupTagIdFieldsInputGroupTagInputRequiredToOneInput!
+    users: GroupUsersIdFieldsInputGroupUserInputToManyUpdateInput
+  }
+  
   type GroupSumFields {
     name: String
   }
   
+  enum GroupSumFieldsEnum {
+    name
+  }
+  
   """Identifier input"""
   input GroupTagIdFieldsInput {
     id: UUID!
@@ -46,7 +137,7 @@
     create: TagUpdate
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input GroupTagInput {
     id: UUID!
     name: String
@@ -64,7 +155,7 @@
     users: [UserType!]!
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input GroupUpdate {
     id: UUID!
     name: String
@@ -72,7 +163,7 @@
     users: GroupUsersIdFieldsInputGroupUserInputToManyUpdateInput
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input GroupUserInput {
     id: UUID!
     name: String
@@ -98,6 +189,21 @@
     create: [TagUserInput!]
   }
   
+  """
+  Boolean expression to compare fields of type Int. All fields are combined with logical 'AND'
+  """
+  input IntComparison {
+    eq: Int
+    neq: Int
+    isNull: Boolean
+    in: [Int!]
+    nin: [Int!]
+    gt: Int
+    gte: Int
+    lt: Int
+    lte: Int
+  }
+  
   """
   The `Interval` scalar type represents a duration of time as specified by [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations).
   """
@@ -115,11 +221,14 @@
     """Fetch objects from the SQLDataTypesType collection"""
     updateDataTypes(data: [SQLDataTypesUpdate!]!): [SQLDataTypesType!]!
   
+    """Fetch objects from the GroupType collection"""
+    updateGroups(data: GroupPartial!, filter: GroupFilter = null): [GroupType!]!
+  
     """Fetch object from the GroupType collection by id"""
-    updateGroup(data: GroupUpdate!): GroupType!
+    updateGroupById(data: GroupUpdate!): GroupType!
   
     """Fetch objects from the GroupType collection"""
-    updateGroups(data: [GroupUpdate!]!): [GroupType!]!
+    updateGroupsByIds(data: [GroupUpdate!]!): [GroupType!]!
   
     """Fetch object from the TagType collection by id"""
     updateTag(data: TagUpdate!): TagType!
@@ -146,7 +255,7 @@
     arrayStrCol: [String!]!
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input SQLDataTypesUpdate {
     id: UUID!
     dateCol: Date
@@ -163,7 +272,92 @@
     arrayStrCol: [String!]
   }
   
-  """GraphQL update input type"""
+  """
+  Boolean expression to compare fields of type String. All fields are combined with logical 'AND'
+  """
+  input StringComparison {
+    eq: String
+    neq: String
+    isNull: Boolean
+    in: [String!]
+    nin: [String!]
+    gt: String
+    gte: String
+    lt: String
+    lte: String
+    like: String
+    nlike: String
+    ilike: String
+    nilike: String
+    regexp: String
+    iregexp: String
+    nregexp: String
+    inregexp: String
+    startswith: String
+    endswith: String
+    contains: String
+    istartswith: String
+    iendswith: String
+    icontains: String
+  }
+  
+  """
+  Boolean expression to compare aggregated fields. All fields are combined with logical 'AND'.
+  """
+  input TagAggregateBoolExp {
+    count: TagAggregateBoolExpCount
+    sum: TagAggregateBoolExpSum
+    minString: TagAggregateBoolExpMinstring
+    maxString: TagAggregateBoolExpMaxstring
+  }
+  
+  """Boolean expression to compare count aggregation."""
+  input TagAggregateBoolExpCount {
+    arguments: [TagCountFields!]
+    predicate: IntComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare max aggregation."""
+  input TagAggregateBoolExpMaxstring {
+    arguments: [TagMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare min aggregation."""
+  input TagAggregateBoolExpMinstring {
+    arguments: [TagMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare sum aggregation."""
+  input TagAggregateBoolExpSum {
+    arguments: [TagSumFieldsEnum!]!
+    predicate: FloatComparison!
+    distinct: Boolean
+  }
+  
+  """
+  Boolean expression to compare fields. All fields are combined with logical 'AND'.
+  """
+  input TagBoolExp {
+    _and: [TagBoolExp!]
+    _or: [TagBoolExp!]
+    _not: TagBoolExp
+    id: UUIDComparison
+    groupsAggregate: GroupAggregateBoolExp
+    groups: GroupFilter
+    name: StringComparison
+  }
+  
+  enum TagCountFields {
+    id
+    name
+  }
+  
+  """GraphQL update_by_pk input type"""
   input TagGroupInput {
     id: UUID!
     name: String
@@ -181,6 +375,14 @@
     create: [TagGroupInput!]
   }
   
+  enum TagMinMaxStringFieldsEnum {
+    name
+  }
+  
+  enum TagSumFieldsEnum {
+    name
+  }
+  
   """GraphQL type"""
   type TagType {
     id: UUID!
@@ -191,14 +393,14 @@
     name: String!
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input TagUpdate {
     id: UUID!
     groups: TagGroupsIdFieldsInputTagGroupInputRequiredToManyUpdateInput
     name: String
   }
   
-  """GraphQL update input type"""
+  """GraphQL update_by_pk input type"""
   input TagUserInput {
     id: UUID!
     name: String
@@ -210,6 +412,17 @@
   
   scalar UUID
   
+  """
+  Boolean expression to compare fields of type UUID. All fields are combined with logical 'AND'
+  """
+  input UUIDComparison {
+    eq: UUID
+    neq: UUID
+    isNull: Boolean
+    in: [UUID!]
+    nin: [UUID!]
+  }
+  
   """Aggregation fields"""
   type UserAggregate {
     count: Int
@@ -218,14 +431,84 @@
     max: UserMinMaxFields!
   }
   
+  """
+  Boolean expression to compare aggregated fields. All fields are combined with logical 'AND'.
+  """
+  input UserAggregateBoolExp {
+    count: UserAggregateBoolExpCount
+    sum: UserAggregateBoolExpSum
+    minString: UserAggregateBoolExpMinstring
+    maxString: UserAggregateBoolExpMaxstring
+  }
+  
+  """Boolean expression to compare count aggregation."""
+  input UserAggregateBoolExpCount {
+    arguments: [UserCountFields!]
+    predicate: IntComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare max aggregation."""
+  input UserAggregateBoolExpMaxstring {
+    arguments: [UserMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare min aggregation."""
+  input UserAggregateBoolExpMinstring {
+    arguments: [UserMinMaxStringFieldsEnum!]!
+    predicate: StringComparison!
+    distinct: Boolean
+  }
+  
+  """Boolean expression to compare sum aggregation."""
+  input UserAggregateBoolExpSum {
+    arguments: [UserSumFieldsEnum!]!
+    predicate: FloatComparison!
+    distinct: Boolean
+  }
+  
+  """
+  Boolean expression to compare fields. All fields are combined with logical 'AND'.
+  """
+  input UserBoolExp {
+    _and: [UserBoolExp!]
+    _or: [UserBoolExp!]
+    _not: UserBoolExp
+    id: UUIDComparison
+    name: StringComparison
+    groupId: UUIDComparison
+    groupAggregate: GroupAggregateBoolExp
+    group: GroupFilter
+    tagId: UUIDComparison
+    tagAggregate: TagAggregateBoolExp
+    tag: TagBoolExp
+  }
+  
+  enum UserCountFields {
+    id
+    name
+    groupId
+    tagId
+  }
+  
   type UserMinMaxFields {
     name: String
   }
   
+  enum UserMinMaxStringFieldsEnum {
+    name
+  }
+  
   type UserSumFields {
     name: String
   }
   
+  enum UserSumFieldsEnum {
+    name
+  }
+  
   """GraphQL type"""
   type UserType {
     id: UUID!
diff --git a/tests/unit/mapping/test_model_config.py b/tests/unit/mapping/test_model_config.py
index f262dd7b..fc8b8467 100644
--- a/tests/unit/mapping/test_model_config.py
+++ b/tests/unit/mapping/test_model_config.py
@@ -25,7 +25,7 @@ class UserType: ...
     assert type_def.get_field("private") is None
 
 
-@pytest.mark.parametrize("decorator", ["create_input", "update_input"])
+@pytest.mark.parametrize("decorator", ["create_input", "pk_update_input", "filter_update_input"])
 def test_type_no_purpose_excluded_input(
     decorator: str, strawchemy: Strawchemy[DeclarativeBase, QueryableAttribute[Any]]
 ) -> None:
diff --git a/tests/unit/mapping/test_types.py b/tests/unit/mapping/test_types.py
index 4441e47f..eaa97c54 100644
--- a/tests/unit/mapping/test_types.py
+++ b/tests/unit/mapping/test_types.py
@@ -148,7 +148,7 @@ def test_excluding_pk_from_update_input_fail() -> None:
         StrawchemyError,
         match=("""You cannot exclude primary key columns from an input type intended for create or update mutations"""),
     ):
-        import_module("tests.unit.schemas.mutations.invalid_update_input")
+        import_module("tests.unit.schemas.mutations.invalid_pk_update_input")
 
 
 def test_read_only_pk_on_update_input_fail() -> None:
@@ -169,6 +169,14 @@ def test_delete_mutation_type_not_list_raise_error() -> None:
         import_module("tests.unit.schemas.mutations.delete_mutation_type_not_list")
 
 
+def test_update_mutation_by_filter_type_not_list_raise_error() -> None:
+    with pytest.raises(
+        StrawchemyFieldError,
+        match=("Type of update mutation by filter must be a list: update_groups"),
+    ):
+        import_module("tests.unit.schemas.mutations.invalid_filter_update_field")
+
+
 @pytest.mark.parametrize(
     "path",
     [
diff --git a/tests/unit/schemas/mutations/invalid_filter_update_field.py b/tests/unit/schemas/mutations/invalid_filter_update_field.py
new file mode 100644
index 00000000..c955f04f
--- /dev/null
+++ b/tests/unit/schemas/mutations/invalid_filter_update_field.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from strawchemy import Strawchemy
+
+import strawberry
+from tests.unit.models import Group
+
+strawchemy = Strawchemy()
+
+
+@strawchemy.type(Group, include="all")
+class GroupType: ...
+
+
+@strawchemy.filter_update_input(Group, include="all")
+class GroupPartial: ...
+
+
+@strawchemy.filter(Group, include="all")
+class GroupFilter: ...
+
+
+@strawberry.type
+class Mutation:
+    update_groups: GroupType = strawchemy.update(GroupPartial, GroupFilter)
diff --git a/tests/unit/schemas/mutations/invalid_update_input.py b/tests/unit/schemas/mutations/invalid_pk_update_input.py
similarity index 75%
rename from tests/unit/schemas/mutations/invalid_update_input.py
rename to tests/unit/schemas/mutations/invalid_pk_update_input.py
index 5030ab19..5fb0c550 100644
--- a/tests/unit/schemas/mutations/invalid_update_input.py
+++ b/tests/unit/schemas/mutations/invalid_pk_update_input.py
@@ -7,5 +7,5 @@
 strawchemy = Strawchemy()
 
 
-@strawchemy.update_input(Group, exclude={"id"})
+@strawchemy.pk_update_input(Group, exclude={"id"})
 class GroupInput: ...
diff --git a/tests/unit/schemas/mutations/read_only_pk_with_update_input.py b/tests/unit/schemas/mutations/read_only_pk_with_update_input.py
index 13e6e3b2..fe97b357 100644
--- a/tests/unit/schemas/mutations/read_only_pk_with_update_input.py
+++ b/tests/unit/schemas/mutations/read_only_pk_with_update_input.py
@@ -25,5 +25,5 @@ class NewGroup(UUIDBase):
     users: Mapped[list[NewUser]] = relationship(NewUser)
 
 
-@strawchemy.update_input(NewGroup, include="all")
+@strawchemy.pk_update_input(NewGroup, include="all")
 class GroupInput: ...
diff --git a/tests/unit/schemas/mutations/update.py b/tests/unit/schemas/mutations/update.py
index 6bddc0e3..b4117266 100644
--- a/tests/unit/schemas/mutations/update.py
+++ b/tests/unit/schemas/mutations/update.py
@@ -20,15 +20,23 @@ class GroupType: ...
 class TagType: ...
 
 
-@strawchemy.update_input(SQLDataTypes, include="all")
+@strawchemy.pk_update_input(SQLDataTypes, include="all")
 class SQLDataTypesUpdate: ...
 
 
-@strawchemy.update_input(Group, include="all")
+@strawchemy.pk_update_input(Group, include="all")
 class GroupUpdate: ...
 
 
-@strawchemy.update_input(Tag, include="all")
+@strawchemy.filter_update_input(Group, include="all")
+class GroupPartial: ...
+
+
+@strawchemy.filter(Group, include="all")
+class GroupFilter: ...
+
+
+@strawchemy.pk_update_input(Tag, include="all")
 class TagUpdate: ...
 
 
@@ -37,7 +45,8 @@ class Mutation:
     update_data_type: SQLDataTypesType = strawchemy.update_by_ids(SQLDataTypesUpdate)
     update_data_types: list[SQLDataTypesType] = strawchemy.update_by_ids(SQLDataTypesUpdate)
 
-    update_group: GroupType = strawchemy.update_by_ids(GroupUpdate)
-    update_groups: list[GroupType] = strawchemy.update_by_ids(GroupUpdate)
+    update_groups: list[GroupType] = strawchemy.update(GroupPartial, GroupFilter)
+    update_group_by_id: GroupType = strawchemy.update_by_ids(GroupUpdate)
+    update_groups_by_ids: list[GroupType] = strawchemy.update_by_ids(GroupUpdate)
 
     update_tag: TagType = strawchemy.update_by_ids(TagUpdate)


Reviews:
user: login: sourcery-ai[bot]
body: Hey @gazorby - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider renaming `update_by_ids` to just `update` now that you have introduced filter-based updates.
- The logic for insert and update is very similar, consider refactoring to reduce code duplication.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 1 issue found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 2 issues found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-09T18:30:42Z

Review comments:
diff_hunk: @@ -62,8 +67,10 @@ def __init__(self, settings: StrawchemyConfig | None = None) -> None:
         self.aggregate_filter = self._aggregate_filter_factory.input
         self.distinct_on = self._distinct_on_enum_factory.decorator
         self.input = self._input_factory.input
+        self.filter_update_input = partial(self._input_factory.input, mode="update_by_filter", partial=True)
         self.create_input = partial(self._input_factory.input, mode="create")
-        self.update_input = partial(self._input_factory.input, mode="update")
+        self.pk_update_input = partial(self._input_factory.input, mode="update_by_pk")
+        self.filter_update_input = partial(self._input_factory.input, mode="update_by_filter")
path: src/strawchemy/mapper.py
user: login: sourcery-ai[bot]
body: **suggestion (bug_risk):** Duplicate assignment for filter_update_input in the mapper may be unintended.

There's an earlier assignment to self.filter_update_input with a 'partial=True' flag that gets overwritten. It might be worth verifying if both assignments are needed or if one should be removed.

```suggestion
```
created_at: 2025-04-09T18:30:42Z
author_association: NONE
diff_hunk: @@ -472,27 +471,67 @@ def get_result(
         return super().get_result(source, info, args, kwargs)
 
 
-class StrawchemyCreateUpdateMutationField(StrawchemyField[ModelT, ModelFieldT]):
-    def __init__(self, input_type: type[AnyMappedDTO], mutation_type: InputType, *args: Any, **kwargs: Any) -> None:
+class StrawchemyCreateMutationField(StrawchemyField[ModelT, ModelFieldT]):
path: src/strawchemy/strawberry/_field.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider creating a helper class or mixin to encapsulate the resolver and argument logic, reducing code duplication between the create and update classes and removing extra branching complexity within those classes..

Consider extracting the repeated resolver and argument‐logic into helper methods (or a small mixin/base class) to remove duplication between the create and update classes. For example:

```python
# New helper base class or mixin:
class MutationFieldHelpers(StrawchemyField[ModelT, ModelFieldT]):
    def _auto_arguments_helper(self, input_type: type, is_list: bool, filter_type: type | None = None) -> list[StrawberryArgument]:
        args = [
            StrawberryArgument(
                DATA_KEY,
                None,
                type_annotation=StrawberryAnnotation(list[input_type] if is_list else input_type),
            )
        ]
        if filter_type:
            args.append(
                StrawberryArgument(
                    python_name="filter_input",
                    graphql_name=FILTER_KEY,
                    type_annotation=StrawberryAnnotation(filter_type | None),
                    default=None,
                )
            )
        return args

    def _create_or_update_resolver(self, info: Info, action: str, data: AnyMappedDTO | Sequence[AnyMappedDTO], **kwargs) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
        repository = self._get_repository(info)
        if action == "create":
            return repository.create_many(data) if isinstance(data, Sequence) else repository.create(data)
        else:
            if self.filter:
                # Pass the filter argument (e.g., from kwargs) as needed
                return repository.update_by_filter(data, kwargs.get("filter_input"))
            return repository.update_many(data) if isinstance(data, Sequence) else repository.update(data)
```

Then, in your create and update classes, delegate to these helpers:

```python
class StrawchemyCreateMutationField(MutationFieldHelpers):
    def __init__(self, input_type: type[AnyMappedDTO], *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.is_root_field = True
        self._input_type = input_type

    @override
    def auto_arguments(self) -> list[StrawberryArgument]:
        return self._auto_arguments_helper(self._input_type, self.is_list)

    @override
    def resolver(self, info: Info[Any, Any], *args: Any, **kwargs: Any) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
        return self._create_or_update_resolver(info, "create", *args, **kwargs)


class StrawchemyUpdateMutationField(MutationFieldHelpers):
    def __init__(self, input_type: type[AnyMappedDTO], *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.is_root_field = True
        self._input_type = input_type

    @override
    def auto_arguments(self) -> list[StrawberryArgument]:
        # Include filter if set
        return self._auto_arguments_helper(self._input_type, self.is_list, self.filter if self.filter else None)

    @override
    def resolver(self, info: Info[Any, Any], *args: Any, **kwargs: Any) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
        return self._create_or_update_resolver(info, "update", *args, **kwargs)
```

These changes preserve functionality while reducing duplicated logic and extra branching.
created_at: 2025-04-09T18:30:42Z
author_association: NONE
diff_hunk: @@ -249,12 +255,12 @@ def create(
         )
         return field(resolver) if resolver else field
 
-    def update_by_ids(
+    def update(
path: src/strawchemy/mapper.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider refactoring the duplicate update logic into a helper method that selects the appropriate field class based on an update mode to reduce branching and duplicate code across the update endpoints, such as `update`, `update_by_ids`, and `update_by_filter`

Refactoring the duplicate update logic can simplify how you build these endpoints. For example, you can extract the common parameter handling into a helper that selects the appropriate field class based on an update mode. This way, you reduce branching and duplicate code.

**Actionable Steps:**

1. Create a shared helper method (e.g. `_build_update_field`) that accepts all common parameters and a mode.
2. Use a lookup mapping to select the mutation field class based on the mode.
3. Replace the payload of `update`, `update_by_ids`, and `update_by_filter` with calls to this helper.

Below is a simplified example:

```python
def _build_update_field(
    self,
    mode: str,
    input_type: type[AnyMappedDTO],
    *,
    filter_input: type | None = None,
    resolver: _RESOLVER_TYPE[Any] | None = None,
    repository_type: AnyRepository | None = None,
    name: str | None = None,
    graphql_type: Any | None = None,
    **kwargs
) -> Any:
    namespace = self.registry.namespace("object")
    type_annotation = (
        StrawberryAnnotation.from_annotation(graphql_type, namespace)
        if graphql_type
        else None
    )
    repository_type_ = repository_type or self.settings.repository_type

    # Map update modes to their corresponding field classes.
    field_class_map = {
        "update": StrawchemyUpdateMutationField,
        "update_by_ids": StrawchemyUpdateMutationField,
        "update_by_filter": StrawchemyCreateUpdateMutationField,  # Adjust if needed.
    }
    field_cls = field_class_map[mode]

    return field_cls(
        input_type=input_type,
        filter_type=filter_input,
        repository_type=repository_type_,
        session_getter=self.settings.session_getter,
        inspector=self.inspector,
        auto_snake_case=self.settings.auto_snake_case,
        python_name=None,
        graphql_name=name,
        type_annotation=type_annotation,
        is_subscription=False,
        **kwargs
    )(resolver) if resolver else field_cls(
        input_type=input_type,
        filter_type=filter_input,
        repository_type=repository_type_,
        session_getter=self.settings.session_getter,
        inspector=self.inspector,
        auto_snake_case=self.settings.auto_snake_case,
        python_name=None,
        graphql_name=name,
        type_annotation=type_annotation,
        is_subscription=False,
        **kwargs
    )

# Then, use this helper in your endpoints:
def update(
    self,
    input_type: type[AnyMappedDTO],
    filter_input: type,
    resolver: _RESOLVER_TYPE[Any] | None = None,
    *,
    repository_type: AnyRepository | None = None,
    name: str | None = None,
    graphql_type: Any | None = None,
    permission_classes: list[type[BasePermission]] | None = None,
    deprecation_reason: str | None = None,
    default: Any = dataclasses.MISSING,
    default_factory: Callable[..., object] | object = dataclasses.MISSING,
    metadata: Mapping[Any, Any] | None = None,
    directives: Sequence[object] = (),
    extensions: list[FieldExtension] | None = None,
) -> Any:
    return self._build_update_field(
        mode="update",
        input_type=input_type,
        filter_input=filter_input,
        resolver=resolver,
        repository_type=repository_type,
        name=name,
        graphql_type=graphql_type,
        permission_classes=permission_classes or [],
        deprecation_reason=deprecation_reason,
        default=default,
        default_factory=default_factory,
        metadata=metadata,
        directives=directives,
        extensions=extensions or [],
    )

# Similarly for update_by_ids and update_by_filter.
```

This approach centralizes changeable logic, reduces duplicate code, and clarifies how each update mode is handled. Adjust the mapping and parameters as needed to maintain all functionality.
created_at: 2025-04-09T18:30:42Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-09T18:28:46Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces the ability to update database records using a filter in addition to updating by IDs. It adds new functions to the repository, updates the mutation fields, and adds new input types for the filter functionality.

#### Sequence diagram for update by filter

```mermaid
sequenceDiagram
  participant User
  participant Strawberry
  participant StrawchemyRepository
  participant SQLAlchemyGraphQLRepository
  participant SQLAlchemy

  User->>Strawberry: update_tickets(data, filter)
  Strawberry->>StrawchemyRepository: update_by_filter(data, filter)
  StrawchemyRepository->>SQLAlchemyGraphQLRepository: update_by_filter(InputData([data]), filter, tree)
  SQLAlchemyGraphQLRepository->>SQLAlchemy: _mutate(update_where, data, filter)
  SQLAlchemy->>SQLAlchemy: update(model).values(data).where(filter)
  SQLAlchemy-->>SQLAlchemyGraphQLRepository: result
  SQLAlchemyGraphQLRepository-->>StrawchemyRepository: query_results
  StrawchemyRepository-->>Strawberry: query_results
  Strawberry-->>User: TicketType
```

#### Updated class diagram for StrawchemyRepository

```mermaid
classDiagram
    class StrawchemyRepository {
        +create(data: AnyMappedDTO) : T
        +update_by_id(data: AnyMappedDTO) : T
        +update_many_by_id(data: Sequence[AnyMappedDTO]) : Sequence[T]
        +update_by_filter(data: AnyMappedDTO, filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]]) : Sequence[T]
        +delete(filter_input: StrawchemyTypeFromPydantic[BooleanFilterDTO[Any, Any]] | None) : Sequence[T]
    }
```

#### Updated class diagram for SQLAlchemyGraphQLRepository

```mermaid
classDiagram
    class SQLAlchemyGraphQLRepository {
        +_mutate(mode: _InsertOrUpdate, data: InputData[DeclarativeBase, QueryableAttribute[Any]], dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None) : Sequence[_RowLike]
        +create(data: InputData[DeclarativeBase, QueryableAttribute[Any]], selection: SQLAlchemyQueryNode | None) : QueryResult[DeclarativeT]
        +update_by_ids(data: InputData[DeclarativeBase, QueryableAttribute[Any]], selection: SQLAlchemyQueryNode | None) : QueryResult[DeclarativeT]
        +update_by_filter(data: InputData[DeclarativeBase, QueryableAttribute[Any]], dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]], selection: SQLAlchemyQueryNode | None) : QueryResult[DeclarativeT]
        +delete(selection: SQLAlchemyQueryNode | None, dto_filter: BooleanFilterDTO[DeclarativeBase, QueryableAttribute[Any]] | None) : QueryResult[DeclarativeT]
    }
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Added the ability to update records based on a filter, rather than just by ID. | <ul><li>Introduced `update_by_filter` methods in both the synchronous and asynchronous repository classes.</li><li>Added `filter_update_input` to generate input types for partial updates based on filters.</li><li>Implemented the `StrawchemyUpdateMutationField` to handle update mutations with filters.</li><li>Modified the `_mutate` method to support `update_where` mode for filter-based updates.</li><li>Added tests to verify the functionality of the new `updateColorsFilter` mutation.</li></ul> | `tests/integration/test_mutations.py`<br/>`src/strawchemy/sqlalchemy/repository/_async.py`<br/>`src/strawchemy/sqlalchemy/repository/_sync.py`<br/>`src/strawchemy/strawberry/_field.py`<br/>`src/strawchemy/mapper.py`<br/>`src/strawchemy/strawberry/factory.py`<br/>`tests/unit/schemas/mutations/update.py`<br/>`src/strawchemy/strawberry/repository/_async.py`<br/>`src/strawchemy/strawberry/repository/_sync.py`<br/>`tests/integration/types.py`<br/>`src/strawchemy/strawberry/__init__.py`<br/>`tests/unit/mapping/test_types.py`<br/>`tests/unit/schemas/mutations/invalid_update_input.py`<br/>`tests/unit/schemas/mutations/read_only_pk_with_update_input.py`<br/>`examples/testapp/testapp/types.py`<br/>`src/strawchemy/dto/inspectors/sqlalchemy.py`<br/>`examples/testapp/testapp/schema.py`<br/>`src/strawchemy/dto/base.py`<br/>`src/strawchemy/dto/types.py`<br/>`src/strawchemy/strawberry/typing.py`<br/>`tests/unit/mapping/test_model_config.py`<br/>`tests/unit/schemas/mutations/invalid_pk_update_input.py`<br/>`tests/unit/schemas/mutations/read_only_pk_with_update_input.py`<br/>`tests/unit/schemas/mutations/invalid_filter_update_field.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-09T18:32:24Z
body: ## [Codecov](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby) Report
Attention: Patch coverage is `96.09375%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 92.77%. Comparing base [(`a486384`)](https://app.codecov.io/gh/gazorby/strawchemy/commit/a48638470223a426cf02fd9021098b2dd1e4876f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby) to head [(`f22e1de`)](https://app.codecov.io/gh/gazorby/strawchemy/commit/f22e1deab11a24320f7a17139b83942c98da342b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby).
> Report is 4 commits behind head on main.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby) | Patch % | Lines |
|---|---|---|
| [src/strawchemy/sqlalchemy/repository/\_async.py](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?src=pr&el=tree&filepath=src%2Fstrawchemy%2Fsqlalchemy%2Frepository%2F_async.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby#diff-c3JjL3N0cmF3Y2hlbXkvc3FsYWxjaGVteS9yZXBvc2l0b3J5L19hc3luYy5weQ==) | 85.18% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby) |
| [src/strawchemy/sqlalchemy/repository/\_sync.py](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?src=pr&el=tree&filepath=src%2Fstrawchemy%2Fsqlalchemy%2Frepository%2F_sync.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby#diff-c3JjL3N0cmF3Y2hlbXkvc3FsYWxjaGVteS9yZXBvc2l0b3J5L19zeW5jLnB5) | 96.29% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #43      +/-   ##
==========================================
+ Coverage   92.72%   92.77%   +0.05%     
==========================================
  Files          70       70              
  Lines        5138     5207      +69     
  Branches      641      649       +8     
==========================================
+ Hits         4764     4831      +67     
- Misses        236      238       +2     
  Partials      138      138              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gazorby/strawchemy/pull/43?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gazorby).

author_association: NONE