strawberry-orm 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/PKG-INFO +144 -4
  2. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/README.md +142 -3
  3. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/pyproject.toml +3 -1
  4. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/_base.py +4 -11
  5. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/django.py +24 -7
  6. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/protocol.py +1 -0
  7. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/sqlalchemy.py +37 -11
  8. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/tortoise.py +184 -35
  9. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/core.py +35 -4
  10. strawberry_orm-0.3.0/src/strawberry_orm/mutations.py +1330 -0
  11. strawberry_orm-0.2.0/src/strawberry_orm/mutations.py +0 -59
  12. strawberry_orm-0.2.0/src/strawberry_orm/relay/resolvers.py +0 -119
  13. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/__init__.py +0 -0
  14. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/_async.py +0 -0
  15. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/__init__.py +0 -0
  16. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/fields.py +0 -0
  17. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/filters.py +0 -0
  18. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
  19. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/extension.py +0 -0
  20. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/store.py +0 -0
  21. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/py.typed +0 -0
  22. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/relay/__init__.py +0 -0
  23. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/relay/connection.py +0 -0
  24. {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strawberry-orm
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Unified, backend-agnostic ORM abstraction for Strawberry GraphQL
5
5
  Author: James Davidson, Patrick Arminio
6
6
  Author-email: James Davidson <jamie.t.davidson@gmail.com>, Patrick Arminio <patrick.arminio@gmail.com>
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Requires-Dist: strawberry-graphql>=0.311.0
17
+ Requires-Dist: greenlet>=3.3.2 ; extra == 'dev'
17
18
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
18
19
  Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
19
20
  Requires-Dist: pytest-django>=4.8 ; extra == 'dev'
@@ -54,9 +55,9 @@ Backend-agnostic schema generation for [Strawberry GraphQL](https://strawberry.r
54
55
  uv add strawberry-orm
55
56
 
56
57
  # With a backend
57
- uv add strawberry-orm[django]
58
- uv add strawberry-orm[sqlalchemy]
59
- uv add strawberry-orm[tortoise]
58
+ uv add "strawberry-orm[django]"
59
+ uv add "strawberry-orm[sqlalchemy]"
60
+ uv add "strawberry-orm[tortoise]"
60
61
  ```
61
62
 
62
63
  You can do the same with `pip`:
@@ -65,6 +66,8 @@ You can do the same with `pip`:
65
66
  pip install "strawberry-orm[sqlalchemy]"
66
67
  ```
67
68
 
69
+ If you are using `zsh`, keep the quotes around extras such as `"strawberry-orm[sqlalchemy]"`. Unquoted square brackets are treated as shell glob syntax before `uv` or `pip` sees the package name.
70
+
68
71
  Requirements:
69
72
 
70
73
  - Python `>=3.12`
@@ -640,6 +643,143 @@ In practice:
640
643
 
641
644
  ---
642
645
 
646
+ ### Recursive Node Mutations
647
+
648
+ `orm.mutations.create_node(...)` and `orm.mutations.update_node(...)` generate catch-all Relay `Node` mutations with recursive nested inputs.
649
+
650
+ If you want to implement the resolver logic yourself, you can generate the root input types directly with `orm.mutations.create_node_input(...)` and `orm.mutations.update_node_input(...)`:
651
+
652
+ ```python
653
+ import strawberry
654
+ from strawberry import relay
655
+
656
+
657
+ @orm.type(User)
658
+ class UserNode(relay.Node):
659
+ id: relay.NodeID[int]
660
+ name: auto
661
+ email: auto
662
+
663
+
664
+ @orm.type(Post)
665
+ class PostNode(relay.Node):
666
+ id: relay.NodeID[int]
667
+ title: auto
668
+ body: auto
669
+
670
+
671
+ CreateNodeInput = orm.mutations.create_node_input()
672
+ UpdateNodeInput = orm.mutations.update_node_input()
673
+
674
+
675
+ @strawberry.type
676
+ class Mutation:
677
+ create_node = orm.mutations.create_node()
678
+ update_node = orm.mutations.update_node()
679
+
680
+ @strawberry.field
681
+ def custom_create_node(self, input: CreateNodeInput) -> str:
682
+ return "implement your own create logic here"
683
+
684
+ @strawberry.field
685
+ def custom_update_node(self, input: UpdateNodeInput) -> str:
686
+ return "implement your own update logic here"
687
+ ```
688
+
689
+ List relations use `items`, while singular relations use explicit `create` / `update` branches:
690
+
691
+ ```graphql
692
+ mutation {
693
+ createNode(input: {
694
+ post: {
695
+ title: "Hello"
696
+ body: "World"
697
+ author: {
698
+ create: {
699
+ name: "Alice"
700
+ email: "alice@example.com"
701
+ }
702
+ }
703
+ tags: {
704
+ items: [{ create: { name: "python" } }]
705
+ mode: REPLACE
706
+ onRemove: DELETE
707
+ }
708
+ }
709
+ }) {
710
+ __typename
711
+ }
712
+ }
713
+ ```
714
+
715
+ #### Projection And Policy Config
716
+
717
+ Pass `project={...}` to restrict recursion depth and configure relation semantics in one dict:
718
+
719
+ ```python
720
+ project = {
721
+ "post": {
722
+ "author": {
723
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
724
+ },
725
+ "comments": {
726
+ "_meta": {
727
+ "mode": ["PATCH", "REPLACE"],
728
+ "onRemove": ["DISCONNECT", "DELETE"],
729
+ },
730
+ "author": {
731
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
732
+ },
733
+ },
734
+ "tags": {
735
+ "_meta": {
736
+ "mode": "REPLACE",
737
+ "onRemove": "DELETE",
738
+ },
739
+ },
740
+ },
741
+ "comment": {
742
+ "author": {
743
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
744
+ },
745
+ },
746
+ }
747
+
748
+
749
+ @strawberry.type
750
+ class Mutation:
751
+ create_node = orm.mutations.create_node(project=project)
752
+ update_node = orm.mutations.update_node(project=project)
753
+ ```
754
+
755
+ Rules for the config object:
756
+
757
+ - Root keys are model names (`post`, `comment`, `user`, ...).
758
+ - Nested keys are relation names available on that model.
759
+ - `_meta` is optional and configures behavior for that relation subtree.
760
+ - Omitted relations still exist as shallow nested inputs, but recursion stops after one more level.
761
+
762
+ `_meta` supports:
763
+
764
+ - `mode`: list relation merge strategy (`PATCH` or `REPLACE`)
765
+ - `onRemove`: what to do with removed items from a list relation (`DISCONNECT` or `DELETE`)
766
+ - `onReplace`: what to do with the previous object when replacing a singular relation (`DISCONNECT` or `DELETE`)
767
+
768
+ The `_meta` values can be either:
769
+
770
+ - an array of enum strings, which means the GraphQL input exposes that field and the caller may choose from those options
771
+ - a single enum string, which fixes that behavior for the relation and omits the corresponding GraphQL field
772
+
773
+ Default behavior when a field is exposed but omitted in the mutation input:
774
+
775
+ - `mode` defaults to `PATCH` when allowed
776
+ - `onRemove` defaults to `DISCONNECT` when allowed
777
+ - `onReplace` defaults to `DISCONNECT` when allowed
778
+
779
+ If the preferred default is not included in the allowed array, the first configured value is used.
780
+
781
+ ---
782
+
643
783
  ## Query Optimization
644
784
 
645
785
  Add the optimizer extension to your schema:
@@ -20,9 +20,9 @@ Backend-agnostic schema generation for [Strawberry GraphQL](https://strawberry.r
20
20
  uv add strawberry-orm
21
21
 
22
22
  # With a backend
23
- uv add strawberry-orm[django]
24
- uv add strawberry-orm[sqlalchemy]
25
- uv add strawberry-orm[tortoise]
23
+ uv add "strawberry-orm[django]"
24
+ uv add "strawberry-orm[sqlalchemy]"
25
+ uv add "strawberry-orm[tortoise]"
26
26
  ```
27
27
 
28
28
  You can do the same with `pip`:
@@ -31,6 +31,8 @@ You can do the same with `pip`:
31
31
  pip install "strawberry-orm[sqlalchemy]"
32
32
  ```
33
33
 
34
+ If you are using `zsh`, keep the quotes around extras such as `"strawberry-orm[sqlalchemy]"`. Unquoted square brackets are treated as shell glob syntax before `uv` or `pip` sees the package name.
35
+
34
36
  Requirements:
35
37
 
36
38
  - Python `>=3.12`
@@ -606,6 +608,143 @@ In practice:
606
608
 
607
609
  ---
608
610
 
611
+ ### Recursive Node Mutations
612
+
613
+ `orm.mutations.create_node(...)` and `orm.mutations.update_node(...)` generate catch-all Relay `Node` mutations with recursive nested inputs.
614
+
615
+ If you want to implement the resolver logic yourself, you can generate the root input types directly with `orm.mutations.create_node_input(...)` and `orm.mutations.update_node_input(...)`:
616
+
617
+ ```python
618
+ import strawberry
619
+ from strawberry import relay
620
+
621
+
622
+ @orm.type(User)
623
+ class UserNode(relay.Node):
624
+ id: relay.NodeID[int]
625
+ name: auto
626
+ email: auto
627
+
628
+
629
+ @orm.type(Post)
630
+ class PostNode(relay.Node):
631
+ id: relay.NodeID[int]
632
+ title: auto
633
+ body: auto
634
+
635
+
636
+ CreateNodeInput = orm.mutations.create_node_input()
637
+ UpdateNodeInput = orm.mutations.update_node_input()
638
+
639
+
640
+ @strawberry.type
641
+ class Mutation:
642
+ create_node = orm.mutations.create_node()
643
+ update_node = orm.mutations.update_node()
644
+
645
+ @strawberry.field
646
+ def custom_create_node(self, input: CreateNodeInput) -> str:
647
+ return "implement your own create logic here"
648
+
649
+ @strawberry.field
650
+ def custom_update_node(self, input: UpdateNodeInput) -> str:
651
+ return "implement your own update logic here"
652
+ ```
653
+
654
+ List relations use `items`, while singular relations use explicit `create` / `update` branches:
655
+
656
+ ```graphql
657
+ mutation {
658
+ createNode(input: {
659
+ post: {
660
+ title: "Hello"
661
+ body: "World"
662
+ author: {
663
+ create: {
664
+ name: "Alice"
665
+ email: "alice@example.com"
666
+ }
667
+ }
668
+ tags: {
669
+ items: [{ create: { name: "python" } }]
670
+ mode: REPLACE
671
+ onRemove: DELETE
672
+ }
673
+ }
674
+ }) {
675
+ __typename
676
+ }
677
+ }
678
+ ```
679
+
680
+ #### Projection And Policy Config
681
+
682
+ Pass `project={...}` to restrict recursion depth and configure relation semantics in one dict:
683
+
684
+ ```python
685
+ project = {
686
+ "post": {
687
+ "author": {
688
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
689
+ },
690
+ "comments": {
691
+ "_meta": {
692
+ "mode": ["PATCH", "REPLACE"],
693
+ "onRemove": ["DISCONNECT", "DELETE"],
694
+ },
695
+ "author": {
696
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
697
+ },
698
+ },
699
+ "tags": {
700
+ "_meta": {
701
+ "mode": "REPLACE",
702
+ "onRemove": "DELETE",
703
+ },
704
+ },
705
+ },
706
+ "comment": {
707
+ "author": {
708
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
709
+ },
710
+ },
711
+ }
712
+
713
+
714
+ @strawberry.type
715
+ class Mutation:
716
+ create_node = orm.mutations.create_node(project=project)
717
+ update_node = orm.mutations.update_node(project=project)
718
+ ```
719
+
720
+ Rules for the config object:
721
+
722
+ - Root keys are model names (`post`, `comment`, `user`, ...).
723
+ - Nested keys are relation names available on that model.
724
+ - `_meta` is optional and configures behavior for that relation subtree.
725
+ - Omitted relations still exist as shallow nested inputs, but recursion stops after one more level.
726
+
727
+ `_meta` supports:
728
+
729
+ - `mode`: list relation merge strategy (`PATCH` or `REPLACE`)
730
+ - `onRemove`: what to do with removed items from a list relation (`DISCONNECT` or `DELETE`)
731
+ - `onReplace`: what to do with the previous object when replacing a singular relation (`DISCONNECT` or `DELETE`)
732
+
733
+ The `_meta` values can be either:
734
+
735
+ - an array of enum strings, which means the GraphQL input exposes that field and the caller may choose from those options
736
+ - a single enum string, which fixes that behavior for the relation and omits the corresponding GraphQL field
737
+
738
+ Default behavior when a field is exposed but omitted in the mutation input:
739
+
740
+ - `mode` defaults to `PATCH` when allowed
741
+ - `onRemove` defaults to `DISCONNECT` when allowed
742
+ - `onReplace` defaults to `DISCONNECT` when allowed
743
+
744
+ If the preferred default is not included in the allowed array, the first configured value is used.
745
+
746
+ ---
747
+
609
748
  ## Query Optimization
610
749
 
611
750
  Add the optimizer extension to your schema:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "strawberry-orm"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Unified, backend-agnostic ORM abstraction for Strawberry GraphQL"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -33,6 +33,7 @@ django = ["django>=4.2"]
33
33
  sqlalchemy = ["sqlalchemy>=2.0"]
34
34
  tortoise = ["tortoise-orm>=0.25.0"]
35
35
  dev = [
36
+ "greenlet>=3.3.2",
36
37
  "pytest>=8.0",
37
38
  "pytest-asyncio>=0.24",
38
39
  "pytest-django>=4.8",
@@ -46,6 +47,7 @@ build-backend = "uv_build"
46
47
 
47
48
  [dependency-groups]
48
49
  dev = [
50
+ "pytest-cov>=7.0.0",
49
51
  "starlette>=0.52.1",
50
52
  "typer>=0.24.1",
51
53
  "uvicorn>=0.41.0",
@@ -58,6 +58,7 @@ class BaseBackend:
58
58
  self._store = OptimizerStore()
59
59
  self._filter_overrides: dict[type, type] = kwargs.get("filter_overrides") or {}
60
60
  self._type_registry: dict[str, type] = {}
61
+ self._graphql_type_registry: dict[type, type] = {}
61
62
  self._type_querysets: dict[type, Any] = {}
62
63
  self._warn_sensitive: bool = kwargs.get("warn_sensitive", True)
63
64
  self._exclude_sensitive_fields: bool = kwargs.get(
@@ -312,10 +313,7 @@ class BaseBackend:
312
313
  cls.__orm_model__ = model # type: ignore[attr-defined]
313
314
 
314
315
  if self._warn_sensitive:
315
- excluded = set(exclude or [])
316
316
  for field_name in annotations:
317
- if field_name in excluded:
318
- continue
319
317
  if _SENSITIVE_PATTERNS.search(field_name):
320
318
  warnings.warn(
321
319
  f"Field '{field_name}' on {model.__name__} looks sensitive "
@@ -350,15 +348,9 @@ class BaseBackend:
350
348
  ),
351
349
  )
352
350
  else:
353
- try:
354
- delattr(cls, attr_name)
355
- except AttributeError:
356
- pass
357
- elif getattr(val, "_orm_auto_field", False):
358
- try:
359
351
  delattr(cls, attr_name)
360
- except AttributeError:
361
- pass
352
+ elif getattr(val, "_orm_auto_field", False):
353
+ delattr(cls, attr_name)
362
354
 
363
355
  return type_name
364
356
 
@@ -372,4 +364,5 @@ class BaseBackend:
372
364
  """Call ``strawberry.type()`` and register the model in the type registry."""
373
365
  result = strawberry.type(cls, name=name if name else None)
374
366
  self._type_registry[type_name] = model
367
+ self._graphql_type_registry[model] = result
375
368
  return result
@@ -48,6 +48,10 @@ _DJANGO_FIELD_MAP: dict[str, type] = {
48
48
  }
49
49
 
50
50
 
51
+ def _primary_key(value: Any) -> Any:
52
+ return getattr(value, "pk", getattr(value, "id", None))
53
+
54
+
51
55
  class DjangoBackend(BaseBackend):
52
56
  """Backend adapter for Django."""
53
57
 
@@ -216,10 +220,17 @@ class DjangoBackend(BaseBackend):
216
220
  *,
217
221
  authorize: Any | None = None,
218
222
  mode: str = "replace",
223
+ hard_delete_removed: bool | None = None,
219
224
  ) -> Any:
220
225
  def apply() -> None:
221
226
  manager = getattr(instance, field)
222
227
  rel_model = manager.model
228
+ hard_delete = (
229
+ self._hard_delete_refs
230
+ if hard_delete_removed is None
231
+ else hard_delete_removed
232
+ )
233
+ existing_related = list(manager.all())
223
234
 
224
235
  new_related: list[Any] = []
225
236
  to_delete: list[Any] = []
@@ -260,12 +271,21 @@ class DjangoBackend(BaseBackend):
260
271
  manager.add(*new_related)
261
272
  if to_delete:
262
273
  manager.remove(*rel_model.objects.filter(pk__in=to_delete))
263
- if self._hard_delete_refs:
274
+ if hard_delete:
264
275
  rel_model.objects.filter(pk__in=to_delete).delete()
265
276
  else:
266
277
  manager.set(new_related)
267
- if to_delete and self._hard_delete_refs:
278
+ if to_delete and hard_delete:
268
279
  rel_model.objects.filter(pk__in=to_delete).delete()
280
+ if hard_delete:
281
+ new_ids = {_primary_key(obj) for obj in new_related}
282
+ removed_ids = [
283
+ _primary_key(obj)
284
+ for obj in existing_related
285
+ if _primary_key(obj) not in new_ids
286
+ ]
287
+ if removed_ids:
288
+ rel_model.objects.filter(pk__in=removed_ids).delete()
269
289
 
270
290
  return run_sync(apply, thread_sensitive=True)
271
291
 
@@ -301,12 +321,9 @@ class DjangoBackend(BaseBackend):
301
321
  return qs
302
322
 
303
323
  def is_query_object(self, value: Any) -> bool:
304
- try:
305
- from django.db.models import QuerySet
324
+ from django.db.models import QuerySet
306
325
 
307
- return isinstance(value, QuerySet)
308
- except ImportError:
309
- return False
326
+ return isinstance(value, QuerySet)
310
327
 
311
328
  def materialize_query(self, query: Any, info: Any) -> Any:
312
329
  return run_sync(list, query, thread_sensitive=True)
@@ -89,6 +89,7 @@ class Backend(Protocol):
89
89
  *,
90
90
  authorize: Any | None = None,
91
91
  mode: str = "replace",
92
+ hard_delete_removed: bool | None = None,
92
93
  ) -> AwaitableOrValue[None]:
93
94
  """Apply a list of ref operations to *instance*'s *field* relation.
94
95
 
@@ -19,6 +19,10 @@ from strawberry_orm.backends._base import (
19
19
  from strawberry_orm.optimizer import OptimizerExtension
20
20
 
21
21
 
22
+ def _primary_key(value: Any) -> Any:
23
+ return getattr(value, "id", getattr(value, "pk", None))
24
+
25
+
22
26
  class SQLAlchemyBackend(BaseBackend):
23
27
  """Backend adapter for SQLAlchemy."""
24
28
 
@@ -182,6 +186,7 @@ class SQLAlchemyBackend(BaseBackend):
182
186
  *,
183
187
  authorize: Callable[..., bool] | None = None,
184
188
  mode: str = "replace",
189
+ hard_delete_removed: bool | None = None,
185
190
  ) -> Any:
186
191
  session = self._get_session(info)
187
192
  if self._is_async_session(session):
@@ -193,6 +198,7 @@ class SQLAlchemyBackend(BaseBackend):
193
198
  info,
194
199
  authorize=authorize,
195
200
  mode=mode,
201
+ hard_delete_removed=hard_delete_removed,
196
202
  )
197
203
 
198
204
  return self._apply_ref_list_sync(
@@ -203,6 +209,7 @@ class SQLAlchemyBackend(BaseBackend):
203
209
  info,
204
210
  authorize,
205
211
  mode,
212
+ hard_delete_removed,
206
213
  )
207
214
 
208
215
  # -- Query application ----------------------------------------------------
@@ -240,12 +247,9 @@ class SQLAlchemyBackend(BaseBackend):
240
247
  return stmt
241
248
 
242
249
  def is_query_object(self, value: Any) -> bool:
243
- try:
244
- from sqlalchemy.sql import Select
250
+ from sqlalchemy.sql import Select
245
251
 
246
- return isinstance(value, Select)
247
- except ImportError:
248
- return False
252
+ return isinstance(value, Select)
249
253
 
250
254
  def materialize_query(self, query: Any, info: Any) -> Any:
251
255
  return self._execute_stmt(query, info)
@@ -487,10 +491,7 @@ class SQLAlchemyBackend(BaseBackend):
487
491
  )
488
492
 
489
493
  def _is_async_session(self, session: Any) -> bool:
490
- try:
491
- from sqlalchemy.ext.asyncio import AsyncSession
492
- except ImportError:
493
- return False
494
+ from sqlalchemy.ext.asyncio import AsyncSession
494
495
 
495
496
  return isinstance(session, AsyncSession)
496
497
 
@@ -530,9 +531,16 @@ class SQLAlchemyBackend(BaseBackend):
530
531
  info: Any,
531
532
  authorize: Callable[..., bool] | None,
532
533
  mode: str,
534
+ hard_delete_removed: bool | None,
533
535
  ) -> None:
534
536
  relationship = getattr(type(instance), field).property
535
537
  target_model = relationship.mapper.class_
538
+ existing_related = list(getattr(instance, field))
539
+ hard_delete = (
540
+ self._hard_delete_refs
541
+ if hard_delete_removed is None
542
+ else hard_delete_removed
543
+ )
536
544
 
537
545
  new_related: list[Any] = []
538
546
  to_remove: list[Any] = []
@@ -572,7 +580,7 @@ class SQLAlchemyBackend(BaseBackend):
572
580
  obj = session.get(target_model, ref_delete.id)
573
581
  if obj is not None:
574
582
  to_remove.append(obj)
575
- if self._hard_delete_refs:
583
+ if hard_delete:
576
584
  session.delete(obj)
577
585
 
578
586
  if mode == "patch":
@@ -584,6 +592,11 @@ class SQLAlchemyBackend(BaseBackend):
584
592
  setattr(instance, field, merged)
585
593
  else:
586
594
  setattr(instance, field, new_related)
595
+ if hard_delete:
596
+ new_ids = {_primary_key(obj) for obj in new_related}
597
+ for obj in existing_related:
598
+ if _primary_key(obj) not in new_ids:
599
+ session.delete(obj)
587
600
 
588
601
  async def _apply_ref_list_async(
589
602
  self,
@@ -595,9 +608,17 @@ class SQLAlchemyBackend(BaseBackend):
595
608
  *,
596
609
  authorize: Callable[..., bool] | None,
597
610
  mode: str,
611
+ hard_delete_removed: bool | None,
598
612
  ) -> None:
599
613
  relationship = getattr(type(instance), field).property
600
614
  target_model = relationship.mapper.class_
615
+ await session.refresh(instance, [field])
616
+ existing_related = list(getattr(instance, field))
617
+ hard_delete = (
618
+ self._hard_delete_refs
619
+ if hard_delete_removed is None
620
+ else hard_delete_removed
621
+ )
601
622
 
602
623
  new_related: list[Any] = []
603
624
  to_remove: list[Any] = []
@@ -637,7 +658,7 @@ class SQLAlchemyBackend(BaseBackend):
637
658
  obj = await session.get(target_model, ref_delete.id)
638
659
  if obj is not None:
639
660
  to_remove.append(obj)
640
- if self._hard_delete_refs:
661
+ if hard_delete:
641
662
  await session.delete(obj)
642
663
 
643
664
  if mode == "patch":
@@ -650,6 +671,11 @@ class SQLAlchemyBackend(BaseBackend):
650
671
  setattr(instance, field, merged)
651
672
  else:
652
673
  setattr(instance, field, new_related)
674
+ if hard_delete:
675
+ new_ids = {_primary_key(obj) for obj in new_related}
676
+ for obj in existing_related:
677
+ if _primary_key(obj) not in new_ids:
678
+ await session.delete(obj)
653
679
 
654
680
 
655
681
  # ---------------------------------------------------------------------------