From cbc366511e8cd0cc25f470b34874a2b9a59d89df Mon Sep 17 00:00:00 2001 From: Adi Berkowitz Date: Wed, 3 Jun 2026 10:52:58 -0400 Subject: [PATCH 1/5] fix: resolve relationship refs to explicit override dtos --- src/strawchemy/utils/registry.py | 17 +- ...ery_schemas[override_with_custom_name].gql | 13 +- ...ry_schemas[scope_schema_in_the_middle].gql | 186 ------------------ tests/unit/mapping/test_schemas.py | 32 +++ 4 files changed, 50 insertions(+), 198 deletions(-) delete mode 100644 tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index ab5e590f..359b2fc5 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -122,6 +122,18 @@ class RegistryTypeInfo: def scoped_id(self) -> Hashable: return self.model, self.graphql_type, self.tags + @property + def resolves_scoped_references(self) -> bool: + """Whether this registration should satisfy refs to generated DTOs for the same model.""" + return bool( + self.model + and not self.exclude_from_scope + and ( + self.scope == "global" + or (self.scope is None and self.override and self.user_defined and self.default_name is not None) + ) + ) + class StrawberryRegistry: def __init__(self, strawberry_config: StrawberryConfig) -> None: @@ -242,9 +254,12 @@ def _register(self, type_info: RegistryTypeInfo, strawberry_type: type[Any]) -> reference.update_type(strawberry_type) if type_info.graphql_type != "enum": self._track_references(strawberry_type, type_info.graphql_type, force=type_info.override) - if type_info.scope == "global" and type_info.model: + if type_info.resolves_scoped_references: if type_info.default_name: self._namespaces[type_info.graphql_type][type_info.default_name] = strawberry_type + if type_info.default_name != type_info.name: + for reference in self._forward_type_refs[type_info.graphql_type][type_info.default_name]: + reference.update_type(strawberry_type) for reference in self._type_refs[type_info.scoped_id]: reference.update_type(strawberry_type) self._scoped_types[type_info.scoped_id] = strawberry_type diff --git a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[override_with_custom_name].gql b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[override_with_custom_name].gql index b9dc2f34..f6c2afc7 100644 --- a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[override_with_custom_name].gql +++ b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[override_with_custom_name].gql @@ -4,8 +4,8 @@ type ColorType { name: Int! fruitsAggregate: FruitAggregate! - """Fetch objects from the FruitType collection""" - fruits: [FruitType!]! + """Fetch objects from the FruitTypeCustomName collection""" + fruits: [FruitTypeCustomName!]! id: UUID! } @@ -39,15 +39,6 @@ type FruitSumFields { sweetness: Int } -"""GraphQL type""" -type FruitType { - color: ColorType! - name: Int! - colorId: UUID - sweetness: Int! - id: UUID! -} - """GraphQL type""" type FruitTypeCustomName { name: Int! diff --git a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql deleted file mode 100644 index 3694f14a..00000000 --- a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql +++ /dev/null @@ -1,186 +0,0 @@ -''' -"""GraphQL type""" -type ColorType { - fruitsAggregate: FruitAggregate! - - """Fetch objects from the FruitType collection""" - fruits: [FruitType!]! - name: String! - id: UUID! -} - -"""Aggregation fields""" -type DepartmentAggregate { - count: Int - max: DepartmentMinMaxFields! - min: DepartmentMinMaxFields! - sum: DepartmentSumFields! -} - -"""GraphQL type""" -type DepartmentMinMaxFields { - name: String -} - -"""GraphQL type""" -type DepartmentSumFields { - name: String -} - -"""GraphQL type""" -type DepartmentType { - usersAggregate: UserAggregate! - - """Fetch objects from the GraphQLUser collection""" - users: [GraphQLUser!]! - name: String - id: UUID! -} - -"""Aggregation fields""" -type FruitAggregate { - avg: FruitNumericFields! - count: Int - max: FruitMinMaxFields! - min: FruitMinMaxFields! - stddevPop: FruitNumericFields! - stddevSamp: FruitNumericFields! - sum: FruitSumFields! - varPop: FruitNumericFields! - varSamp: FruitNumericFields! -} - -"""GraphQL type""" -type FruitMinMaxFields { - name: String - sweetness: Int -} - -"""GraphQL type""" -type FruitNumericFields { - sweetness: Float -} - -"""GraphQL type""" -type FruitSumFields { - name: String - sweetness: Int -} - -"""GraphQL type""" -type FruitType { - color: ColorType! - name: String! - colorId: UUID - sweetness: Int! - id: UUID! -} - -"""GraphQL type""" -type GraphQLGroup { - tag: TagType! - usersAggregate: UserAggregate! - - """Fetch objects from the GraphQLUser collection""" - users: [GraphQLUser!]! - color: ColorType! - name: String! - tagId: UUID! - colorId: UUID! - id: UUID! -} - -"""GraphQL type""" -type GraphQLTag { - groupsAggregate: GroupAggregate! - - """Fetch objects from the GraphQLGroup collection""" - groups: [GraphQLGroup!]! - name: String! - id: UUID! -} - -"""GraphQL type""" -type GraphQLUser { - group: GraphQLGroup! - tag: TagType! - departmentsAggregate: DepartmentAggregate! - - """Fetch objects from the DepartmentType collection""" - departments: [DepartmentType!]! - name: String! - groupId: UUID - tagId: UUID - id: UUID! -} - -"""Aggregation fields""" -type GroupAggregate { - count: Int - max: GroupMinMaxFields! - min: GroupMinMaxFields! - sum: GroupSumFields! -} - -"""GraphQL type""" -type GroupMinMaxFields { - name: String -} - -"""GraphQL type""" -type GroupSumFields { - name: String -} - -"""GraphQL type""" -type GroupType { - tag: TagType! - usersAggregate: UserAggregate! - - """Fetch objects from the GraphQLUser collection""" - users: [GraphQLUser!]! - color: ColorType! - name: String! - tagId: UUID! - colorId: UUID! - id: UUID! -} - -type Query { - """Fetch object from the GraphQLUser collection by id""" - user(id: UUID!): GraphQLUser! - - """Fetch object from the GraphQLTag collection by id""" - tag(id: UUID!): GraphQLTag! -} - -"""GraphQL type""" -type TagType { - groupsAggregate: GroupAggregate! - - """Fetch objects from the GroupType collection""" - groups: [GroupType!]! - name: String! - id: UUID! -} - -scalar UUID - -"""Aggregation fields""" -type UserAggregate { - count: Int - max: UserMinMaxFields! - min: UserMinMaxFields! - sum: UserSumFields! -} - -"""GraphQL type""" -type UserMinMaxFields { - name: String -} - -"""GraphQL type""" -type UserSumFields { - name: String -} -''' \ No newline at end of file diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index 6fb4d2b3..5e3c2ba5 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -69,6 +69,38 @@ class ColorSlim: assert slim_fields == {"id"} +def test_relationship_uses_late_explicit_related_type_registration(strawchemy: Strawchemy) -> None: + """Relationship type selection must not depend on decoration/import order.""" + from tests.unit.models import Color, Fruit + + @strawchemy.type(Fruit, name="FruitNode", include=["id", "color"], override=True) + class FruitNode: + pass + + @strawchemy.type(Color, name="ColorNode", include=["id", "name"], override=True) + class ColorNode: + @strawberry.field + def label(self) -> str: + return "label" + + @strawberry.type + class Query: + @strawberry.field(graphql_type=FruitNode | None) + def fruit(self) -> object | None: + return None + + @strawberry.field(graphql_type=ColorNode | None) + def color(self) -> object | None: + return None + + schema = strawberry.Schema(query=Query) + schema_sdl = str(schema) + + assert "color: ColorNode!" in schema_sdl + assert "label: String!" in schema_sdl + assert "color: ColorType!" not in schema_sdl + + def test_type_instance_auto_as_str(strawchemy: Strawchemy) -> None: @strawchemy.type(User) class UserType: From a5ecdeeab544fe65acbfae0c5d8c83c25e5388f9 Mon Sep 17 00:00:00 2001 From: Adi Berkowitz Date: Wed, 3 Jun 2026 11:10:18 -0400 Subject: [PATCH 2/5] Keep relationship refs stable after override resolution --- src/strawchemy/utils/registry.py | 16 +- ...ry_schemas[scope_schema_in_the_middle].gql | 186 ++++++++++++++++++ tests/unit/mapping/test_schemas.py | 7 + 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index 359b2fc5..79f42051 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -101,6 +101,9 @@ def update_type(self, strawberry_type: type[WithStrawberryObjectDefinition]) -> else: self._set_type(strawberry_type) + def contains_type(self, strawberry_type: type[WithStrawberryObjectDefinition]) -> bool: + return any(inner_type is strawberry_type for inner_type in strawberry_contained_types(self.ref_holder.type)) + @dataclasses.dataclass(frozen=True, eq=True) class RegistryTypeInfo: @@ -255,13 +258,22 @@ def _register(self, type_info: RegistryTypeInfo, strawberry_type: type[Any]) -> if type_info.graphql_type != "enum": self._track_references(strawberry_type, type_info.graphql_type, force=type_info.override) if type_info.resolves_scoped_references: + previous_default_type = None if type_info.default_name: + previous_type_info = self._names_map[type_info.graphql_type].get(type_info.default_name) + previous_default_type = self._type_map.get(previous_type_info) if previous_type_info else None self._namespaces[type_info.graphql_type][type_info.default_name] = strawberry_type if type_info.default_name != type_info.name: for reference in self._forward_type_refs[type_info.graphql_type][type_info.default_name]: - reference.update_type(strawberry_type) + if previous_default_type is None or reference.contains_type(previous_default_type): + reference.update_type(strawberry_type) for reference in self._type_refs[type_info.scoped_id]: - reference.update_type(strawberry_type) + if ( + type_info.scope == "global" + or previous_default_type is None + or reference.contains_type(previous_default_type) + ): + reference.update_type(strawberry_type) self._scoped_types[type_info.scoped_id] = strawberry_type self._names_map[type_info.graphql_type][type_info.name] = type_info self._type_map[type_info] = strawberry_type diff --git a/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql new file mode 100644 index 00000000..3694f14a --- /dev/null +++ b/tests/unit/mapping/__snapshots__/test_schemas/test_query_schemas[scope_schema_in_the_middle].gql @@ -0,0 +1,186 @@ +''' +"""GraphQL type""" +type ColorType { + fruitsAggregate: FruitAggregate! + + """Fetch objects from the FruitType collection""" + fruits: [FruitType!]! + name: String! + id: UUID! +} + +"""Aggregation fields""" +type DepartmentAggregate { + count: Int + max: DepartmentMinMaxFields! + min: DepartmentMinMaxFields! + sum: DepartmentSumFields! +} + +"""GraphQL type""" +type DepartmentMinMaxFields { + name: String +} + +"""GraphQL type""" +type DepartmentSumFields { + name: String +} + +"""GraphQL type""" +type DepartmentType { + usersAggregate: UserAggregate! + + """Fetch objects from the GraphQLUser collection""" + users: [GraphQLUser!]! + name: String + id: UUID! +} + +"""Aggregation fields""" +type FruitAggregate { + avg: FruitNumericFields! + count: Int + max: FruitMinMaxFields! + min: FruitMinMaxFields! + stddevPop: FruitNumericFields! + stddevSamp: FruitNumericFields! + sum: FruitSumFields! + varPop: FruitNumericFields! + varSamp: FruitNumericFields! +} + +"""GraphQL type""" +type FruitMinMaxFields { + name: String + sweetness: Int +} + +"""GraphQL type""" +type FruitNumericFields { + sweetness: Float +} + +"""GraphQL type""" +type FruitSumFields { + name: String + sweetness: Int +} + +"""GraphQL type""" +type FruitType { + color: ColorType! + name: String! + colorId: UUID + sweetness: Int! + id: UUID! +} + +"""GraphQL type""" +type GraphQLGroup { + tag: TagType! + usersAggregate: UserAggregate! + + """Fetch objects from the GraphQLUser collection""" + users: [GraphQLUser!]! + color: ColorType! + name: String! + tagId: UUID! + colorId: UUID! + id: UUID! +} + +"""GraphQL type""" +type GraphQLTag { + groupsAggregate: GroupAggregate! + + """Fetch objects from the GraphQLGroup collection""" + groups: [GraphQLGroup!]! + name: String! + id: UUID! +} + +"""GraphQL type""" +type GraphQLUser { + group: GraphQLGroup! + tag: TagType! + departmentsAggregate: DepartmentAggregate! + + """Fetch objects from the DepartmentType collection""" + departments: [DepartmentType!]! + name: String! + groupId: UUID + tagId: UUID + id: UUID! +} + +"""Aggregation fields""" +type GroupAggregate { + count: Int + max: GroupMinMaxFields! + min: GroupMinMaxFields! + sum: GroupSumFields! +} + +"""GraphQL type""" +type GroupMinMaxFields { + name: String +} + +"""GraphQL type""" +type GroupSumFields { + name: String +} + +"""GraphQL type""" +type GroupType { + tag: TagType! + usersAggregate: UserAggregate! + + """Fetch objects from the GraphQLUser collection""" + users: [GraphQLUser!]! + color: ColorType! + name: String! + tagId: UUID! + colorId: UUID! + id: UUID! +} + +type Query { + """Fetch object from the GraphQLUser collection by id""" + user(id: UUID!): GraphQLUser! + + """Fetch object from the GraphQLTag collection by id""" + tag(id: UUID!): GraphQLTag! +} + +"""GraphQL type""" +type TagType { + groupsAggregate: GroupAggregate! + + """Fetch objects from the GroupType collection""" + groups: [GroupType!]! + name: String! + id: UUID! +} + +scalar UUID + +"""Aggregation fields""" +type UserAggregate { + count: Int + max: UserMinMaxFields! + min: UserMinMaxFields! + sum: UserSumFields! +} + +"""GraphQL type""" +type UserMinMaxFields { + name: String +} + +"""GraphQL type""" +type UserSumFields { + name: String +} +''' \ No newline at end of file diff --git a/tests/unit/mapping/test_schemas.py b/tests/unit/mapping/test_schemas.py index 5e3c2ba5..3ec7d304 100644 --- a/tests/unit/mapping/test_schemas.py +++ b/tests/unit/mapping/test_schemas.py @@ -83,6 +83,12 @@ class ColorNode: def label(self) -> str: return "label" + @strawchemy.type(Color, name="AlternateColorNode", include=["id", "name"], override=True) + class AlternateColorNode: + @strawberry.field + def alternate_label(self) -> str: + return "alternate" + @strawberry.type class Query: @strawberry.field(graphql_type=FruitNode | None) @@ -99,6 +105,7 @@ def color(self) -> object | None: assert "color: ColorNode!" in schema_sdl assert "label: String!" in schema_sdl assert "color: ColorType!" not in schema_sdl + assert "color: AlternateColorNode!" not in schema_sdl def test_type_instance_auto_as_str(strawchemy: Strawchemy) -> None: From 9e9a5f6b095532af0840ccd675292ce9e6c5fc55 Mon Sep 17 00:00:00 2001 From: Adi Berkowitz Date: Wed, 3 Jun 2026 11:15:45 -0400 Subject: [PATCH 3/5] Document scoped relationship ref updates --- src/strawchemy/utils/registry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index 79f42051..d3ca029b 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -258,6 +258,13 @@ def _register(self, type_info: RegistryTypeInfo, strawberry_type: type[Any]) -> if type_info.graphql_type != "enum": self._track_references(strawberry_type, type_info.graphql_type, force=type_info.override) if type_info.resolves_scoped_references: + # A user-defined override can replace a generated/default DTO for + # the same model after relationship fields have already recorded + # references to it. Update only refs that still point at that + # previous default DTO; refs already resolved to another explicit + # override for the same model must keep their chosen type. + # ``scope="global"`` keeps the historical behavior and refreshes + # every scoped reference. previous_default_type = None if type_info.default_name: previous_type_info = self._names_map[type_info.graphql_type].get(type_info.default_name) From 8e808916e7cbda8ebe205144ed19781e206395a3 Mon Sep 17 00:00:00 2001 From: Adi Berkowitz Date: Wed, 3 Jun 2026 11:26:30 -0400 Subject: [PATCH 4/5] Type previous relationship default --- src/strawchemy/utils/registry.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index d3ca029b..b0362f05 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -265,10 +265,13 @@ def _register(self, type_info: RegistryTypeInfo, strawberry_type: type[Any]) -> # override for the same model must keep their chosen type. # ``scope="global"`` keeps the historical behavior and refreshes # every scoped reference. - previous_default_type = None + previous_default_type: type[WithStrawberryObjectDefinition] | None = None if type_info.default_name: previous_type_info = self._names_map[type_info.graphql_type].get(type_info.default_name) - previous_default_type = self._type_map.get(previous_type_info) if previous_type_info else None + previous_default_type = cast( + "type[WithStrawberryObjectDefinition] | None", + self._type_map.get(previous_type_info) if previous_type_info else None, + ) self._namespaces[type_info.graphql_type][type_info.default_name] = strawberry_type if type_info.default_name != type_info.name: for reference in self._forward_type_refs[type_info.graphql_type][type_info.default_name]: From 6ff2820a0c5e232fe969b1a955d0448b2d225a03 Mon Sep 17 00:00:00 2001 From: Adi Berkowitz Date: Wed, 3 Jun 2026 14:41:10 -0400 Subject: [PATCH 5/5] Clarify scoped reference resolution predicate --- src/strawchemy/utils/registry.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/strawchemy/utils/registry.py b/src/strawchemy/utils/registry.py index b0362f05..16c8abc1 100644 --- a/src/strawchemy/utils/registry.py +++ b/src/strawchemy/utils/registry.py @@ -125,16 +125,18 @@ class RegistryTypeInfo: def scoped_id(self) -> Hashable: return self.model, self.graphql_type, self.tags + @property + def _is_user_defined_default_name_override(self) -> bool: + """Whether this override should take over generated refs for the model's default DTO name.""" + return self.scope is None and self.override and self.user_defined and self.default_name is not None + @property def resolves_scoped_references(self) -> bool: """Whether this registration should satisfy refs to generated DTOs for the same model.""" return bool( self.model and not self.exclude_from_scope - and ( - self.scope == "global" - or (self.scope is None and self.override and self.user_defined and self.default_name is not None) - ) + and (self.scope == "global" or self._is_user_defined_default_name_override) )