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.
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/PKG-INFO +144 -4
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/README.md +142 -3
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/pyproject.toml +3 -1
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/_base.py +4 -11
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/django.py +24 -7
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/protocol.py +1 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/sqlalchemy.py +37 -11
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/tortoise.py +184 -35
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/core.py +35 -4
- strawberry_orm-0.3.0/src/strawberry_orm/mutations.py +1330 -0
- strawberry_orm-0.2.0/src/strawberry_orm/mutations.py +0 -59
- strawberry_orm-0.2.0/src/strawberry_orm/relay/resolvers.py +0 -119
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/__init__.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/_async.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/backends/__init__.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/fields.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/filters.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/extension.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/optimizer/store.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/py.typed +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/relay/__init__.py +0 -0
- {strawberry_orm-0.2.0 → strawberry_orm-0.3.0}/src/strawberry_orm/relay/connection.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
305
|
-
from django.db.models import QuerySet
|
|
324
|
+
from django.db.models import QuerySet
|
|
306
325
|
|
|
307
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
244
|
-
from sqlalchemy.sql import Select
|
|
250
|
+
from sqlalchemy.sql import Select
|
|
245
251
|
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
# ---------------------------------------------------------------------------
|