strawberry-orm 0.3.0__tar.gz → 0.4.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 (22) hide show
  1. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/PKG-INFO +99 -1
  2. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/README.md +98 -0
  3. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/pyproject.toml +1 -1
  4. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/_base.py +122 -10
  5. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/django.py +20 -1
  6. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/sqlalchemy.py +14 -0
  7. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/tortoise.py +20 -1
  8. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/mutations.py +25 -11
  9. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/__init__.py +0 -0
  10. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/_async.py +0 -0
  11. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/__init__.py +0 -0
  12. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/backends/protocol.py +0 -0
  13. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/core.py +0 -0
  14. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/fields.py +0 -0
  15. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/filters.py +0 -0
  16. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
  17. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/optimizer/extension.py +0 -0
  18. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/optimizer/store.py +0 -0
  19. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/py.typed +0 -0
  20. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/relay/__init__.py +0 -0
  21. {strawberry_orm-0.3.0 → strawberry_orm-0.4.0}/src/strawberry_orm/relay/connection.py +0 -0
  22. {strawberry_orm-0.3.0 → strawberry_orm-0.4.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.0
3
+ Version: 0.4.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>
@@ -425,6 +425,104 @@ Typical string lookups: `exact`, `neq`, `contains`, `iContains`, `startsWith`, `
425
425
 
426
426
  Regex lookups (`regex`, `iRegex`) are disabled by default. Enable with `enable_regex_filters=True`.
427
427
 
428
+ #### Object Traversal
429
+
430
+ When filters are registered for related models, the generated filter gains an `object` key that lets you filter parent rows based on conditions on their related objects:
431
+
432
+ ```python
433
+ UserFilter = orm.filter(User)
434
+ PostFilter = orm.filter(Post) # Post has an "author" relation to User
435
+ ```
436
+
437
+ ```graphql
438
+ {
439
+ posts(filter: {
440
+ object: { author: { field: { name: { exact: "Alice" } } } }
441
+ }) {
442
+ title
443
+ }
444
+ }
445
+ ```
446
+
447
+ Object traversal composes with boolean operators:
448
+
449
+ ```graphql
450
+ {
451
+ posts(filter: {
452
+ all: [
453
+ { field: { isPublished: { exact: true } } }
454
+ { object: { author: { field: { name: { exact: "Alice" } } } } }
455
+ ]
456
+ }) { title }
457
+ }
458
+ ```
459
+
460
+ Multi-level traversal works when the intermediate models also have registered filters:
461
+
462
+ ```python
463
+ UserFilter = orm.filter(User)
464
+ PostFilter = orm.filter(Post)
465
+ CommentFilter = orm.filter(Comment) # Comment -> Post -> User
466
+ ```
467
+
468
+ ```graphql
469
+ # Find comments on posts written by Alice
470
+ {
471
+ comments(filter: {
472
+ object: { post: {
473
+ object: { author: { field: { name: { exact: "Alice" } } } }
474
+ } }
475
+ }) { body }
476
+ }
477
+ ```
478
+
479
+ The `object` type is `@oneOf`, so each filter entry names exactly one relation.
480
+
481
+ Relations only appear in `object` if their target model already has a registered filter at the time `orm.filter()` is called. Register child model filters before parent model filters.
482
+
483
+ #### Filter Projection
484
+
485
+ By default every relation with a registered filter appears in `object`. Pass `project={...}` to `orm.filter()` to control exactly which relations are exposed and how deep traversal can go:
486
+
487
+ ```python
488
+ UserFilter = orm.filter(User)
489
+ TagFilter = orm.filter(Tag)
490
+ CommentFilter = orm.filter(Comment)
491
+
492
+ # Only expose "author" in the object type — tags and comments are excluded
493
+ PostFilter = orm.filter(Post, project={"author": {}})
494
+ ```
495
+
496
+ ```graphql
497
+ # This works (author is projected)
498
+ { posts(filter: { object: { author: { field: { name: { exact: "Alice" } } } } }) { title } }
499
+
500
+ # This would be a schema error (tags is not projected)
501
+ { posts(filter: { object: { tags: { field: { name: { exact: "python" } } } } }) { title } }
502
+ ```
503
+
504
+ Sub-project dicts control nested traversal. An empty dict `{}` means "include this relation but don't allow further object traversal from it". A non-empty dict lists which of the related model's relations are reachable:
505
+
506
+ ```python
507
+ # Allow Comment -> post, and from post -> author (but not post -> tags)
508
+ CommentFilter = orm.filter(Comment, project={
509
+ "post": {
510
+ "author": {},
511
+ },
512
+ })
513
+ ```
514
+
515
+ Summary:
516
+
517
+ | `project` value | Behavior |
518
+ | --- | --- |
519
+ | `None` (default) | Auto-include all relations with registered filters |
520
+ | `{}` | No `object` type at all (scalar lookups only) |
521
+ | `{"rel": {}}` | Include `rel` as a leaf (no further traversal from it) |
522
+ | `{"rel": {"nested": {}}}` | Include `rel` and allow traversal to `nested` from it |
523
+
524
+ Projected filters are cached internally and do not overwrite the global filter registry, so you can create multiple projected variants of the same model's filter for different schema entry points.
525
+
428
526
  ### Ordering
429
527
 
430
528
  Generate an order input:
@@ -390,6 +390,104 @@ Typical string lookups: `exact`, `neq`, `contains`, `iContains`, `startsWith`, `
390
390
 
391
391
  Regex lookups (`regex`, `iRegex`) are disabled by default. Enable with `enable_regex_filters=True`.
392
392
 
393
+ #### Object Traversal
394
+
395
+ When filters are registered for related models, the generated filter gains an `object` key that lets you filter parent rows based on conditions on their related objects:
396
+
397
+ ```python
398
+ UserFilter = orm.filter(User)
399
+ PostFilter = orm.filter(Post) # Post has an "author" relation to User
400
+ ```
401
+
402
+ ```graphql
403
+ {
404
+ posts(filter: {
405
+ object: { author: { field: { name: { exact: "Alice" } } } }
406
+ }) {
407
+ title
408
+ }
409
+ }
410
+ ```
411
+
412
+ Object traversal composes with boolean operators:
413
+
414
+ ```graphql
415
+ {
416
+ posts(filter: {
417
+ all: [
418
+ { field: { isPublished: { exact: true } } }
419
+ { object: { author: { field: { name: { exact: "Alice" } } } } }
420
+ ]
421
+ }) { title }
422
+ }
423
+ ```
424
+
425
+ Multi-level traversal works when the intermediate models also have registered filters:
426
+
427
+ ```python
428
+ UserFilter = orm.filter(User)
429
+ PostFilter = orm.filter(Post)
430
+ CommentFilter = orm.filter(Comment) # Comment -> Post -> User
431
+ ```
432
+
433
+ ```graphql
434
+ # Find comments on posts written by Alice
435
+ {
436
+ comments(filter: {
437
+ object: { post: {
438
+ object: { author: { field: { name: { exact: "Alice" } } } }
439
+ } }
440
+ }) { body }
441
+ }
442
+ ```
443
+
444
+ The `object` type is `@oneOf`, so each filter entry names exactly one relation.
445
+
446
+ Relations only appear in `object` if their target model already has a registered filter at the time `orm.filter()` is called. Register child model filters before parent model filters.
447
+
448
+ #### Filter Projection
449
+
450
+ By default every relation with a registered filter appears in `object`. Pass `project={...}` to `orm.filter()` to control exactly which relations are exposed and how deep traversal can go:
451
+
452
+ ```python
453
+ UserFilter = orm.filter(User)
454
+ TagFilter = orm.filter(Tag)
455
+ CommentFilter = orm.filter(Comment)
456
+
457
+ # Only expose "author" in the object type — tags and comments are excluded
458
+ PostFilter = orm.filter(Post, project={"author": {}})
459
+ ```
460
+
461
+ ```graphql
462
+ # This works (author is projected)
463
+ { posts(filter: { object: { author: { field: { name: { exact: "Alice" } } } } }) { title } }
464
+
465
+ # This would be a schema error (tags is not projected)
466
+ { posts(filter: { object: { tags: { field: { name: { exact: "python" } } } } }) { title } }
467
+ ```
468
+
469
+ Sub-project dicts control nested traversal. An empty dict `{}` means "include this relation but don't allow further object traversal from it". A non-empty dict lists which of the related model's relations are reachable:
470
+
471
+ ```python
472
+ # Allow Comment -> post, and from post -> author (but not post -> tags)
473
+ CommentFilter = orm.filter(Comment, project={
474
+ "post": {
475
+ "author": {},
476
+ },
477
+ })
478
+ ```
479
+
480
+ Summary:
481
+
482
+ | `project` value | Behavior |
483
+ | --- | --- |
484
+ | `None` (default) | Auto-include all relations with registered filters |
485
+ | `{}` | No `object` type at all (scalar lookups only) |
486
+ | `{"rel": {}}` | Include `rel` as a leaf (no further traversal from it) |
487
+ | `{"rel": {"nested": {}}}` | Include `rel` and allow traversal to `nested` from it |
488
+
489
+ Projected filters are cached internally and do not overwrite the global filter registry, so you can create multiple projected variants of the same model's filter for different schema entry points.
490
+
393
491
  ### Ordering
394
492
 
395
493
  Generate an order input:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "strawberry-orm"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Unified, backend-agnostic ORM abstraction for Strawberry GraphQL"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import re
6
7
  import typing
7
8
  import warnings
@@ -59,6 +60,8 @@ class BaseBackend:
59
60
  self._filter_overrides: dict[type, type] = kwargs.get("filter_overrides") or {}
60
61
  self._type_registry: dict[str, type] = {}
61
62
  self._graphql_type_registry: dict[type, type] = {}
63
+ self._filter_registry: dict[type, type] = {}
64
+ self._projected_filter_cache: dict[tuple[type, Any], type] = {}
62
65
  self._type_querysets: dict[type, Any] = {}
63
66
  self._warn_sensitive: bool = kwargs.get("warn_sensitive", True)
64
67
  self._exclude_sensitive_fields: bool = kwargs.get(
@@ -137,15 +140,40 @@ class BaseBackend:
137
140
  model = model_or_type
138
141
  include = kwargs.get("include")
139
142
  exclude = kwargs.get("exclude")
143
+ project: dict[str, Any] | None = kwargs.get("project")
140
144
 
141
145
  enable_regex = getattr(self, "_enable_regex_filters", False)
142
146
 
143
147
  fields_meta = self._introspect_model(model)
144
148
 
149
+ if project is not None:
150
+ relation_names = {
151
+ fname for fname, _, is_rel, _ in fields_meta if is_rel
152
+ }
153
+ unknown = set(project.keys()) - relation_names
154
+ if unknown:
155
+ raise ValueError(
156
+ f"Unknown relation(s) in project for {model.__name__}: "
157
+ f"{', '.join(sorted(unknown))}"
158
+ )
159
+
160
+ # Check the projected-filter cache for projected (non-default) calls.
161
+ if project is not None:
162
+ cache_key = (model, self._filter_project_signature(project))
163
+ cached = self._projected_filter_cache.get(cache_key)
164
+ if cached is not None:
165
+ return cached
166
+
167
+ suffix = self._filter_project_suffix(project)
168
+
145
169
  field_annotations: dict[str, Any] = {}
146
170
  field_defaults: dict[str, Any] = {}
147
171
 
148
- for fname, ftype, is_relation, _rel_model in fields_meta:
172
+ object_annotations: dict[str, Any] = {}
173
+ object_defaults: dict[str, Any] = {}
174
+ relation_models: dict[str, type] = {}
175
+
176
+ for fname, ftype, is_relation, rel_model in fields_meta:
149
177
  if include and fname not in include:
150
178
  continue
151
179
  if exclude and fname in exclude:
@@ -153,6 +181,19 @@ class BaseBackend:
153
181
  if self._exclude_generated_sensitive_field(fname, include):
154
182
  continue
155
183
  if is_relation:
184
+ if rel_model is None:
185
+ continue
186
+ if project is not None and fname not in project:
187
+ continue
188
+ sub_project = project[fname] if project is not None else None
189
+ if sub_project is not None:
190
+ rel_filter = self._get_projected_filter(rel_model, sub_project)
191
+ else:
192
+ rel_filter = self._filter_registry.get(rel_model)
193
+ if rel_filter is not None:
194
+ object_annotations[fname] = Optional[rel_filter]
195
+ object_defaults[fname] = strawberry.UNSET
196
+ relation_models[fname] = rel_model
156
197
  continue
157
198
  lookup_type = self._filter_overrides.get(ftype) or TYPE_TO_LOOKUP.get(ftype)
158
199
  if lookup_type is not None:
@@ -161,15 +202,20 @@ class BaseBackend:
161
202
  field_annotations[fname] = Optional[lookup_type]
162
203
  field_defaults[fname] = strawberry.UNSET
163
204
 
164
- field_type_name = f"{model.__name__}Field"
165
- field_ns: dict[str, Any] = {
166
- "__annotations__": field_annotations,
167
- **field_defaults,
168
- }
169
- field_cls = type(field_type_name, (), field_ns)
170
- FieldType = strawberry.input(field_cls, one_of=True)
171
-
172
- filter_type_name = f"{model.__name__}Filter"
205
+ # Reuse the existing FieldType when creating a projected variant.
206
+ base_filter = self._filter_registry.get(model)
207
+ if project is not None and base_filter is not None and hasattr(base_filter, "_field_type"):
208
+ FieldType = base_filter._field_type
209
+ else:
210
+ field_type_name = f"{model.__name__}Field{suffix}"
211
+ field_ns: dict[str, Any] = {
212
+ "__annotations__": field_annotations,
213
+ **field_defaults,
214
+ }
215
+ field_cls = type(field_type_name, (), field_ns)
216
+ FieldType = strawberry.input(field_cls, one_of=True)
217
+
218
+ filter_type_name = f"{model.__name__}Filter{suffix}"
173
219
  filter_ns: dict[str, Any] = {
174
220
  "__annotations__": {"field": Optional[FieldType]},
175
221
  "field": strawberry.UNSET,
@@ -185,11 +231,77 @@ class BaseBackend:
185
231
  FilterCls.not_ = strawberry.field(default=strawberry.UNSET, name="not")
186
232
  FilterCls.one_of = strawberry.UNSET
187
233
 
234
+ if object_annotations:
235
+ obj_type_name = f"{model.__name__}Object{suffix}"
236
+ obj_ns: dict[str, Any] = {
237
+ "__annotations__": object_annotations,
238
+ **object_defaults,
239
+ }
240
+ obj_cls = type(obj_type_name, (), obj_ns)
241
+ ObjectType = strawberry.input(obj_cls, one_of=True)
242
+ FilterCls.__annotations__["object"] = Optional[ObjectType]
243
+ FilterCls.object = strawberry.UNSET
244
+
188
245
  FilterType = strawberry.input(FilterCls, one_of=True)
189
246
  FilterType._field_type = FieldType # type: ignore[attr-defined]
190
247
  FilterType.__orm_model__ = model # type: ignore[attr-defined]
248
+ if object_annotations:
249
+ FilterType._object_type = ObjectType # type: ignore[attr-defined]
250
+ FilterType._relation_models = relation_models # type: ignore[attr-defined]
251
+
252
+ if project is None:
253
+ self._filter_registry[model] = FilterType
254
+ else:
255
+ self._projected_filter_cache[cache_key] = FilterType
191
256
  return FilterType
192
257
 
258
+ # -- Filter project helpers ------------------------------------------------
259
+
260
+ def _get_projected_filter(self, model: type, sub_project: dict[str, Any]) -> Any:
261
+ """Return a filter type for *model* constrained by *sub_project*.
262
+
263
+ If *sub_project* is empty the returned filter has no ``object`` type
264
+ (leaf). Otherwise only the relations listed in *sub_project* are
265
+ exposed, each recursively constrained by their own sub-project.
266
+ """
267
+ sig = self._filter_project_signature(sub_project)
268
+ cache_key = (model, sig)
269
+ cached = self._projected_filter_cache.get(cache_key)
270
+ if cached is not None:
271
+ return cached
272
+ if not sub_project:
273
+ base = self._filter_registry.get(model)
274
+ if base is not None and not hasattr(base, "_object_type"):
275
+ self._projected_filter_cache[cache_key] = base
276
+ return base
277
+ result = self.filter(model, project=sub_project)
278
+ return result
279
+
280
+ @staticmethod
281
+ def _filter_project_signature(project: dict[str, Any] | None) -> Any:
282
+ """Turn a project dict into a hashable, comparable value."""
283
+ if project is None:
284
+ return None
285
+ if not project:
286
+ return ()
287
+ return tuple(
288
+ sorted(
289
+ (k, BaseBackend._filter_project_signature(v))
290
+ for k, v in project.items()
291
+ )
292
+ )
293
+
294
+ @staticmethod
295
+ def _filter_project_suffix(project: dict[str, Any] | None) -> str:
296
+ """Short hash suffix for projected type names (empty for default)."""
297
+ sig = BaseBackend._filter_project_signature(project)
298
+ if sig is None:
299
+ return ""
300
+ if not sig:
301
+ return "_leaf"
302
+ digest = hashlib.sha1(repr(sig).encode("utf-8")).hexdigest()[:8]
303
+ return f"_{digest}"
304
+
193
305
  def order(self, model_or_type: type, **kwargs: Any) -> Any:
194
306
  model = model_or_type
195
307
  include = kwargs.get("include")
@@ -636,6 +636,7 @@ def _build_django_filter(
636
636
  enable_regex: bool = False,
637
637
  max_in_list_size: int = 500,
638
638
  _depth: int = 0,
639
+ _prefix: str = "",
639
640
  ) -> Any:
640
641
  from django.db.models import Q
641
642
 
@@ -652,6 +653,7 @@ def _build_django_filter(
652
653
  enable_regex=enable_regex,
653
654
  max_in_list_size=max_in_list_size,
654
655
  _depth=_depth + 1,
656
+ _prefix=_prefix,
655
657
  )
656
658
 
657
659
  for key in fields:
@@ -662,9 +664,25 @@ def _build_django_filter(
662
664
  if key == "field":
663
665
  return _build_django_field_clause(
664
666
  val,
667
+ prefix=_prefix,
665
668
  enable_regex=enable_regex,
666
669
  max_in_list_size=max_in_list_size,
667
670
  )
671
+ elif key == "object":
672
+ obj_fields = val.__class__.__dataclass_fields__
673
+ for rel_name in obj_fields:
674
+ nested_filter = getattr(val, rel_name)
675
+ if nested_filter is strawberry.UNSET or nested_filter is None:
676
+ continue
677
+ return _build_django_filter(
678
+ nested_filter,
679
+ max_depth=max_depth,
680
+ max_branches=max_branches,
681
+ enable_regex=enable_regex,
682
+ max_in_list_size=max_in_list_size,
683
+ _depth=_depth + 1,
684
+ _prefix=f"{_prefix}{rel_name}__",
685
+ )
668
686
  elif key == "all":
669
687
  if len(val) > max_branches:
670
688
  raise ValueError(
@@ -708,6 +726,7 @@ def _build_django_filter(
708
726
  def _build_django_field_clause(
709
727
  field_input: Any,
710
728
  *,
729
+ prefix: str = "",
711
730
  enable_regex: bool = False,
712
731
  max_in_list_size: int = 500,
713
732
  ) -> Any:
@@ -722,7 +741,7 @@ def _build_django_field_clause(
722
741
  continue
723
742
 
724
743
  col_q = _build_django_lookup(
725
- col_name,
744
+ f"{prefix}{col_name}",
726
745
  lookup,
727
746
  enable_regex=enable_regex,
728
747
  max_in_list_size=max_in_list_size,
@@ -880,6 +880,20 @@ def _build_sa_filter(
880
880
  enable_regex=enable_regex,
881
881
  max_in_list_size=max_in_list_size,
882
882
  )
883
+ elif key == "object":
884
+ obj_fields = val.__class__.__dataclass_fields__
885
+ for rel_name in obj_fields:
886
+ nested_filter = getattr(val, rel_name)
887
+ if nested_filter is strawberry.UNSET or nested_filter is None:
888
+ continue
889
+ relationship_prop = getattr(model, rel_name)
890
+ rel_model = relationship_prop.property.mapper.class_
891
+ inner = _build_sa_filter(nested_filter, rel_model, **recurse_kw)
892
+ if inner is not None:
893
+ if relationship_prop.property.uselist:
894
+ return relationship_prop.any(inner)
895
+ else:
896
+ return relationship_prop.has(inner)
883
897
  elif key == "all":
884
898
  if len(val) > max_branches:
885
899
  raise ValueError(
@@ -803,6 +803,7 @@ def _build_tortoise_filter(
803
803
  enable_regex: bool = False,
804
804
  max_in_list_size: int = 500,
805
805
  _depth: int = 0,
806
+ _prefix: str = "",
806
807
  ) -> Any:
807
808
  """Recursively translate a filter input into a Tortoise Q object."""
808
809
  from tortoise.queryset import Q
@@ -820,6 +821,7 @@ def _build_tortoise_filter(
820
821
  enable_regex=enable_regex,
821
822
  max_in_list_size=max_in_list_size,
822
823
  _depth=_depth + 1,
824
+ _prefix=_prefix,
823
825
  )
824
826
 
825
827
  for key in fields:
@@ -830,9 +832,25 @@ def _build_tortoise_filter(
830
832
  if key == "field":
831
833
  return _build_tortoise_field_clause(
832
834
  val,
835
+ prefix=_prefix,
833
836
  enable_regex=enable_regex,
834
837
  max_in_list_size=max_in_list_size,
835
838
  )
839
+ elif key == "object":
840
+ obj_fields = val.__class__.__dataclass_fields__
841
+ for rel_name in obj_fields:
842
+ nested_filter = getattr(val, rel_name)
843
+ if nested_filter is strawberry.UNSET or nested_filter is None:
844
+ continue
845
+ return _build_tortoise_filter(
846
+ nested_filter,
847
+ max_depth=max_depth,
848
+ max_branches=max_branches,
849
+ enable_regex=enable_regex,
850
+ max_in_list_size=max_in_list_size,
851
+ _depth=_depth + 1,
852
+ _prefix=f"{_prefix}{rel_name}__",
853
+ )
836
854
  elif key == "all":
837
855
  if len(val) > max_branches:
838
856
  raise ValueError(
@@ -882,6 +900,7 @@ def _build_tortoise_filter(
882
900
  def _build_tortoise_field_clause(
883
901
  field_input: Any,
884
902
  *,
903
+ prefix: str = "",
885
904
  enable_regex: bool = False,
886
905
  max_in_list_size: int = 500,
887
906
  ) -> Any:
@@ -896,7 +915,7 @@ def _build_tortoise_field_clause(
896
915
  if lookup is strawberry.UNSET or lookup is None:
897
916
  continue
898
917
  col_q = _build_tortoise_lookup(
899
- col_name,
918
+ f"{prefix}{col_name}",
900
919
  lookup,
901
920
  enable_regex=enable_regex,
902
921
  max_in_list_size=max_in_list_size,
@@ -170,30 +170,39 @@ class MutationNamespace:
170
170
  *,
171
171
  models: Iterable[type] | None = None,
172
172
  project: dict[str, Any] | None = None,
173
+ name: str | None = None,
173
174
  ) -> type:
174
175
  selected_models = self._resolve_root_models(models)
175
176
  root_projects = self._normalize_root_project(selected_models, project)
176
- return self._root_input_type("create", selected_models, root_projects)
177
+ return self._root_input_type(
178
+ "create", selected_models, root_projects, name=name
179
+ )
177
180
 
178
181
  def update_node_input(
179
182
  self,
180
183
  *,
181
184
  models: Iterable[type] | None = None,
182
185
  project: dict[str, Any] | None = None,
186
+ name: str | None = None,
183
187
  ) -> type:
184
188
  selected_models = self._resolve_root_models(models)
185
189
  root_projects = self._normalize_root_project(selected_models, project)
186
- return self._root_input_type("update", selected_models, root_projects)
190
+ return self._root_input_type(
191
+ "update", selected_models, root_projects, name=name
192
+ )
187
193
 
188
194
  def create_node(
189
195
  self,
190
196
  *,
191
197
  models: Iterable[type] | None = None,
192
198
  project: dict[str, Any] | None = None,
199
+ input_name: str | None = None,
193
200
  description: str | None = None,
194
201
  ) -> Any:
195
202
  factory = self
196
- input_type = self.create_node_input(models=models, project=project)
203
+ input_type = self.create_node_input(
204
+ models=models, project=project, name=input_name
205
+ )
197
206
 
198
207
  if self._backend.__class__.__name__ == "TortoiseBackend":
199
208
 
@@ -221,10 +230,13 @@ class MutationNamespace:
221
230
  *,
222
231
  models: Iterable[type] | None = None,
223
232
  project: dict[str, Any] | None = None,
233
+ input_name: str | None = None,
224
234
  description: str | None = None,
225
235
  ) -> Any:
226
236
  factory = self
227
- input_type = self.update_node_input(models=models, project=project)
237
+ input_type = self.update_node_input(
238
+ models=models, project=project, name=input_name
239
+ )
228
240
 
229
241
  if self._backend.__class__.__name__ == "TortoiseBackend":
230
242
 
@@ -409,16 +421,19 @@ class MutationNamespace:
409
421
  operation: str,
410
422
  models: tuple[type, ...],
411
423
  root_projects: dict[str, Any],
424
+ *,
425
+ name: str | None = None,
412
426
  ) -> type:
413
427
  cache = (
414
428
  self._root_create_inputs
415
429
  if operation == "create"
416
430
  else self._root_update_inputs
417
431
  )
418
- cache_key = tuple(
419
- (model, self._project_signature(root_projects[_model_key(model)]))
432
+ root_signature = tuple(
433
+ (model.__name__, self._project_signature(root_projects[_model_key(model)]))
420
434
  for model in models
421
435
  )
436
+ cache_key = (root_signature, name)
422
437
  if cache_key in cache:
423
438
  return cache[cache_key]
424
439
 
@@ -433,13 +448,12 @@ class MutationNamespace:
433
448
  ) | None
434
449
  defaults[key] = strawberry.UNSET
435
450
 
436
- root_signature = tuple(
437
- (model.__name__, self._project_signature(root_projects[_model_key(model)]))
438
- for model in models
439
- )
440
451
  root_suffix = self._signature_suffix(root_signature)
452
+ class_name = name or (
453
+ f"{'Create' if operation == 'create' else 'Update'}NodeInput{root_suffix}"
454
+ )
441
455
  cls = type(
442
- f"{'Create' if operation == 'create' else 'Update'}NodeInput{root_suffix}",
456
+ class_name,
443
457
  (),
444
458
  {"__annotations__": annotations, **defaults},
445
459
  )