From 87884e492514e225c89714dbbabeacf3b151bd72 Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Thu, 30 Oct 2025 14:01:49 -0700 Subject: [PATCH 1/8] Option to always use_list and not make relay Connections --- src/strawberry_sqlalchemy_mapper/mapper.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/strawberry_sqlalchemy_mapper/mapper.py b/src/strawberry_sqlalchemy_mapper/mapper.py index 94b0305..f13118c 100644 --- a/src/strawberry_sqlalchemy_mapper/mapper.py +++ b/src/strawberry_sqlalchemy_mapper/mapper.py @@ -204,6 +204,10 @@ class StrawberrySQLAlchemyMapper(Generic[BaseModelType]): #: for a given (polymorphic base) model model_to_interface_name: Callable[[Type[BaseModelType]], str] + #: If set to true, don't create connections for list type + #: relationships + always_use_list: bool + #: Default mapping from sqlalchemy types to strawberry types _default_sqlalchemy_type_to_strawberry_type_map: Dict[ Type[TypeEngine], Union[Type[Any], SkipTypeSentinelT] @@ -248,6 +252,7 @@ def __init__( self, model_to_type_name: Optional[Callable[[Type[BaseModelType]], str]] = None, model_to_interface_name: Optional[Callable[[Type[BaseModelType]], str]] = None, + always_use_list: Optional[bool] = None, extra_sqlalchemy_type_to_strawberry_type_map: Optional[ Mapping[Type[TypeEngine], Type[Any]] ] = None, @@ -257,6 +262,7 @@ def __init__( self.model_to_type_name = model_to_type_name if model_to_interface_name is None: model_to_interface_name = self._default_model_to_interface_name + self.always_use_list = always_use_list or False self.model_to_interface_name = model_to_interface_name self.sqlalchemy_type_to_strawberry_type_map = ( self._default_sqlalchemy_type_to_strawberry_type_map.copy() @@ -401,7 +407,7 @@ def _convert_relationship_to_strawberry_type( self._related_type_models.add(relationship_model) if relationship.uselist: # Use list if excluding relay pagination - if use_list: + if use_list or self.always_use_list: return List[ForwardRef(type_name)] # type: ignore return self._connection_type_for(type_name) @@ -669,7 +675,7 @@ def connection_resolver_for( passed from the GraphQL query to the database query. """ relationship_resolver = self.relationship_resolver_for(relationship) - if relationship.uselist and not use_list: + if relationship.uselist and not (use_list or self.always_use_list): return self.make_connection_wrapper_resolver( relationship_resolver, relationship, From 7127a2d1142d9efe44789cfe751ca2b8d50f1406 Mon Sep 17 00:00:00 2001 From: Gabor Torok <38512412+gabor-lbl@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:17:58 -0700 Subject: [PATCH 2/8] Update src/strawberry_sqlalchemy_mapper/mapper.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/strawberry_sqlalchemy_mapper/mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strawberry_sqlalchemy_mapper/mapper.py b/src/strawberry_sqlalchemy_mapper/mapper.py index f13118c..82e5557 100644 --- a/src/strawberry_sqlalchemy_mapper/mapper.py +++ b/src/strawberry_sqlalchemy_mapper/mapper.py @@ -675,7 +675,7 @@ def connection_resolver_for( passed from the GraphQL query to the database query. """ relationship_resolver = self.relationship_resolver_for(relationship) - if relationship.uselist and not (use_list or self.always_use_list): + if relationship.uselist and not use_list and not self.always_use_list: return self.make_connection_wrapper_resolver( relationship_resolver, relationship, From c83f3fed5b8499e7553ae337ec101a54ec2a0382 Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Thu, 30 Oct 2025 14:21:28 -0700 Subject: [PATCH 3/8] RELEASE.md --- RELEASE.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..20a0a7c --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,6 @@ +Release type: minor + +Added a new optional constructor parameter to always use lists instead of relay Connectors for relationships. Defaults to False, maintaining current functionality. If set to True, all relationships will be handled as lists. + +Example: +mapper = StrawberrySQLAlchemyMapper(always_use_list=True) From d97e6c5e4e162239c5e0d8a96287969b8edc0c0c Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Mon, 3 Nov 2025 09:31:26 -0800 Subject: [PATCH 4/8] moved always_use_list optinal param to last param. Defaults to False, instead of None. Updated README. --- README.md | 19 +++++++++++++++++++ RELEASE.md | 2 +- src/strawberry_sqlalchemy_mapper/mapper.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 538bdca..01bc519 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,25 @@ class ApiB(ApiA): # "extra_field" will be overridden and will be a float now instead of the String type declared in ModelB: extra_field: float = strawberry.field(name="extraField") ``` + +### Relay connections + +By default, StrawberrySQLAlchemyMapper() will create [Relay connections](https://relay.dev/graphql/connections.htm) for relationships to lists. If instead you want these relationships to present as plain lists, you have two options: + +1. Declare `__use_list__` in your models, for example: + +```python +@strawberry_sqlalchemy_mapper.type(models.Department) +class Department: + __use_list__ = ["employees"] +``` + +2. Alternatively, you can disable relay style connections for all models via the `always_use_list` constructor parameter: + +```python +strawberry_sqlalchemy_mapper = StrawberrySQLAlchemyMapper(always_use_list=True) +``` + ## Limitations ### Supported Types diff --git a/RELEASE.md b/RELEASE.md index 20a0a7c..c9a24a0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,6 @@ Release type: minor -Added a new optional constructor parameter to always use lists instead of relay Connectors for relationships. Defaults to False, maintaining current functionality. If set to True, all relationships will be handled as lists. +Added a new optional constructor parameter to always use lists instead of relay Connections for relationships. Defaults to False, maintaining current functionality. If set to True, all relationships will be handled as lists. Example: mapper = StrawberrySQLAlchemyMapper(always_use_list=True) diff --git a/src/strawberry_sqlalchemy_mapper/mapper.py b/src/strawberry_sqlalchemy_mapper/mapper.py index 82e5557..d522928 100644 --- a/src/strawberry_sqlalchemy_mapper/mapper.py +++ b/src/strawberry_sqlalchemy_mapper/mapper.py @@ -252,10 +252,10 @@ def __init__( self, model_to_type_name: Optional[Callable[[Type[BaseModelType]], str]] = None, model_to_interface_name: Optional[Callable[[Type[BaseModelType]], str]] = None, - always_use_list: Optional[bool] = None, extra_sqlalchemy_type_to_strawberry_type_map: Optional[ Mapping[Type[TypeEngine], Type[Any]] ] = None, + always_use_list: Optional[bool] = False, ) -> None: if model_to_type_name is None: model_to_type_name = self._default_model_to_type_name From ce24580ede5f1339f54f8222c071fa1180977259 Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Tue, 4 Nov 2025 09:38:13 -0800 Subject: [PATCH 5/8] new unit test for always_use_list --- tests/conftest.py | 5 +++++ tests/test_mapper.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 09c9c6e..76d87f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,3 +118,8 @@ def base(): @pytest.fixture def mapper(): return StrawberrySQLAlchemyMapper() + + +@pytest.fixture +def mapper_always_use_list(): + return StrawberrySQLAlchemyMapper(always_use_list=True) diff --git a/tests/test_mapper.py b/tests/test_mapper.py index faa18a2..cde780f 100644 --- a/tests/test_mapper.py +++ b/tests/test_mapper.py @@ -276,6 +276,33 @@ class Department: assert isinstance(name.type, StrawberryList) is True +def test_always_use_list(employee_and_department_tables, mapper_always_use_list): + Employee, Department = employee_and_department_tables + + @mapper_always_use_list.type(Employee) + class Employee: + pass + + @mapper_always_use_list.type(Department) + class Department: + pass + + mapper_always_use_list.finalize() + additional_types = list(mapper_always_use_list.mapped_types.values()) + assert len(additional_types) == 2 + mapped_employee_type = additional_types[0] + assert mapped_employee_type.__name__ == "Employee" + mapped_department_type = additional_types[1] + assert mapped_department_type.__name__ == "Department" + assert len(mapped_department_type.__strawberry_definition__.fields) == 3 + department_type_fields = mapped_department_type.__strawberry_definition__.fields + + name = next((field for field in department_type_fields if field.name == "employees"), None) + assert name is not None + assert isinstance(name.type, StrawberryOptional) is False + assert isinstance(name.type, StrawberryList) is True + + def test_type_relationships(employee_and_department_tables, mapper): Employee, _ = employee_and_department_tables From 646c83e620b30394675109b22e62b18fc967fd9f Mon Sep 17 00:00:00 2001 From: Ckk3 Date: Mon, 1 Dec 2025 15:43:25 -0300 Subject: [PATCH 6/8] Refactor always_use_list parameter to be non-optional and update related tests for mixed relationships --- src/strawberry_sqlalchemy_mapper/mapper.py | 4 +-- tests/test_mapper.py | 37 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/strawberry_sqlalchemy_mapper/mapper.py b/src/strawberry_sqlalchemy_mapper/mapper.py index d522928..d2275f3 100644 --- a/src/strawberry_sqlalchemy_mapper/mapper.py +++ b/src/strawberry_sqlalchemy_mapper/mapper.py @@ -255,14 +255,14 @@ def __init__( extra_sqlalchemy_type_to_strawberry_type_map: Optional[ Mapping[Type[TypeEngine], Type[Any]] ] = None, - always_use_list: Optional[bool] = False, + always_use_list: bool = False, ) -> None: if model_to_type_name is None: model_to_type_name = self._default_model_to_type_name self.model_to_type_name = model_to_type_name if model_to_interface_name is None: model_to_interface_name = self._default_model_to_interface_name - self.always_use_list = always_use_list or False + self.always_use_list = always_use_list self.model_to_interface_name = model_to_interface_name self.sqlalchemy_type_to_strawberry_type_map = ( self._default_sqlalchemy_type_to_strawberry_type_map.copy() diff --git a/tests/test_mapper.py b/tests/test_mapper.py index cde780f..1e3ab07 100644 --- a/tests/test_mapper.py +++ b/tests/test_mapper.py @@ -303,6 +303,43 @@ class Department: assert isinstance(name.type, StrawberryList) is True +def test_always_use_list_with_mixed_relationships( + employee_and_department_tables, mapper_always_use_list +): + Employee, Department = employee_and_department_tables + + @mapper_always_use_list.type(Employee) + class EmployeeType: + pass + + @mapper_always_use_list.type(Department) + class DepartmentType: + pass + + mapper_always_use_list.finalize() + additional_types = list(mapper_always_use_list.mapped_types.values()) + assert len(additional_types) == 2 + mapped_employee_type = additional_types[0] + assert mapped_employee_type.__name__ == "EmployeeType" + mapped_department_type = additional_types[1] + assert mapped_department_type.__name__ == "DepartmentType" + + department_type_fields = mapped_department_type.__strawberry_definition__.fields + employees_field = next((f for f in department_type_fields if f.name == "employees"), None) + assert employees_field is not None + # List relationship should be StrawberryList with always_use_list=True + assert isinstance( + employees_field.type, StrawberryList + ) + + employee_type_fields = mapped_employee_type.__strawberry_definition__.fields + department_field = next((f for f in employee_type_fields if f.name == "department"), None) + assert department_field is not None + # Single relationship should remain as Optional, not converted to a list + assert not isinstance(department_field.type, StrawberryList) + assert isinstance(department_field.type, StrawberryOptional) + + def test_type_relationships(employee_and_department_tables, mapper): Employee, _ = employee_and_department_tables From 20b06bc921a3f18c3e49e1362de48c3d5759b440 Mon Sep 17 00:00:00 2001 From: Ckk3 Date: Mon, 1 Dec 2025 15:52:26 -0300 Subject: [PATCH 7/8] Refactor test_always_use_list_with_mixed_relationships to use clearer class names for Employee and Department --- tests/test_mapper.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_mapper.py b/tests/test_mapper.py index 1e3ab07..2f3e72a 100644 --- a/tests/test_mapper.py +++ b/tests/test_mapper.py @@ -309,11 +309,11 @@ def test_always_use_list_with_mixed_relationships( Employee, Department = employee_and_department_tables @mapper_always_use_list.type(Employee) - class EmployeeType: + class Employee: pass @mapper_always_use_list.type(Department) - class DepartmentType: + class Department: pass mapper_always_use_list.finalize() @@ -328,9 +328,7 @@ class DepartmentType: employees_field = next((f for f in department_type_fields if f.name == "employees"), None) assert employees_field is not None # List relationship should be StrawberryList with always_use_list=True - assert isinstance( - employees_field.type, StrawberryList - ) + assert isinstance(employees_field.type, StrawberryList) employee_type_fields = mapped_employee_type.__strawberry_definition__.fields department_field = next((f for f in employee_type_fields if f.name == "department"), None) From 6f4d2442b0909f39cf93e69a45e1561754240df2 Mon Sep 17 00:00:00 2001 From: Ckk3 Date: Mon, 1 Dec 2025 16:12:48 -0300 Subject: [PATCH 8/8] fix tests --- tests/test_mapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_mapper.py b/tests/test_mapper.py index 2f3e72a..72384e2 100644 --- a/tests/test_mapper.py +++ b/tests/test_mapper.py @@ -320,9 +320,9 @@ class Department: additional_types = list(mapper_always_use_list.mapped_types.values()) assert len(additional_types) == 2 mapped_employee_type = additional_types[0] - assert mapped_employee_type.__name__ == "EmployeeType" + assert mapped_employee_type.__name__ == "Employee" mapped_department_type = additional_types[1] - assert mapped_department_type.__name__ == "DepartmentType" + assert mapped_department_type.__name__ == "Department" department_type_fields = mapped_department_type.__strawberry_definition__.fields employees_field = next((f for f in department_type_fields if f.name == "employees"), None)