Skip to content

Commit 9cdea08

Browse files
committed
Skip virtual fields like GenericForeignKey
Virtual fields (concrete=False) like GenericForeignKey don't have database columns, causing AttributeError when accessing field.attname. Skip them like reverse relations are skipped. Fixes #16
1 parent e564666 commit 9cdea08

File tree

2 files changed

+59
-4
lines changed

2 files changed

+59
-4
lines changed

fieldsignals/signals.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,17 @@ def connect( # type: ignore[override]
6565
def is_reverse_rel(f: Any) -> bool:
6666
return f.many_to_many or f.one_to_many or isinstance(f, ForeignObjectRel)
6767

68+
def is_virtual_field(f: Any) -> bool:
69+
"""Virtual fields like GenericForeignKey don't have database columns."""
70+
return not f.concrete
71+
6872
if fields is None:
6973
resolved_fields = sender._meta.get_fields()
70-
resolved_fields = [f for f in resolved_fields if not is_reverse_rel(f)]
74+
resolved_fields = [
75+
f
76+
for f in resolved_fields
77+
if not is_reverse_rel(f) and not is_virtual_field(f)
78+
]
7179
else:
7280
resolved_fields = [f for f in sender._meta.get_fields() if f.name in set(fields)]
7381
for f in resolved_fields:
@@ -76,6 +84,11 @@ def is_reverse_rel(f: Any) -> bool:
7684
"django-fieldsignals doesn't handle reverse related fields "
7785
f"({f.name} is a {f.__class__.__name__})"
7886
)
87+
if is_virtual_field(f):
88+
raise ValueError(
89+
"django-fieldsignals doesn't handle virtual fields "
90+
f"({f.name} is a {f.__class__.__name__})"
91+
)
7992

8093
if not resolved_fields:
8194
raise ValueError("fields must be non-empty")
@@ -166,6 +179,9 @@ def get_and_update_changed_fields(
166179
deferred_fields = instance.get_deferred_fields()
167180

168181
for field in fields:
182+
if not field.concrete:
183+
# Skip virtual fields (e.g. GenericForeignKey)
184+
continue
169185
if field.attname in deferred_fields:
170186
continue
171187
# using value_from_object instead of getattr() means we don't traverse foreignkeys

fieldsignals/tests/test_signals.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(self, name: str, m2m: bool = False) -> None:
5252
self.attname = name
5353
self.many_to_many = m2m
5454
self.one_to_many = False
55+
self.concrete = True
5556

5657
def value_from_object(self, instance: Any) -> Any:
5758
return getattr(instance, self.name)
@@ -144,6 +145,29 @@ def get_fields() -> list[Field | MockOneToOneRel]:
144145
return [Field("f"), MockOneToOneRel("o2o")]
145146

146147

148+
class VirtualField:
149+
"""Mock GenericForeignKey - virtual field with no database column."""
150+
151+
def __init__(self, name: str) -> None:
152+
self.name = name
153+
self.many_to_many = False
154+
self.one_to_many = False
155+
self.concrete = False
156+
157+
158+
class FakeModelWithVirtualField:
159+
f = "a value"
160+
gfk = None
161+
162+
class _meta:
163+
@staticmethod
164+
def get_fields() -> list[Field | VirtualField]:
165+
return [Field("f"), VirtualField("gfk")]
166+
167+
def get_deferred_fields(self) -> set[str]:
168+
return set()
169+
170+
147171
class TestGeneral:
148172
@pytest.fixture(autouse=True)
149173
def ready(self) -> None:
@@ -157,9 +181,7 @@ def test_m2m_fields_error(self) -> None:
157181
def test_one_to_one_rel_field_error(self) -> None:
158182
with must_be_called(False) as func:
159183
with pytest.raises(ValueError):
160-
post_save_changed.connect(
161-
func, sender=FakeModelWithOneToOne, fields=["o2o", "f"]
162-
)
184+
post_save_changed.connect(func, sender=FakeModelWithOneToOne, fields=["o2o", "f"])
163185

164186
def test_one_to_one_rel_excluded(self) -> None:
165187
with must_be_called(False) as func:
@@ -223,6 +245,23 @@ def test_boolean_field_transition_to_valid(self) -> None:
223245

224246
assert func.kwargs["changed_fields"] == {"is_active": (None, True)}
225247

248+
def test_virtual_field_excluded(self) -> None:
249+
"""Virtual fields like GenericForeignKey are auto-skipped."""
250+
with must_be_called(False) as func:
251+
post_save_changed.connect(func, sender=FakeModelWithVirtualField)
252+
253+
obj = FakeModelWithVirtualField()
254+
post_init.send(instance=obj, sender=FakeModelWithVirtualField)
255+
post_save.send(instance=obj, sender=FakeModelWithVirtualField)
256+
257+
def test_virtual_field_error(self) -> None:
258+
"""Explicitly requesting a virtual field raises clear error."""
259+
with must_be_called(False) as func:
260+
with pytest.raises(
261+
ValueError, match="doesn't handle virtual fields.*gfk.*VirtualField"
262+
):
263+
post_save_changed.connect(func, sender=FakeModelWithVirtualField, fields=["gfk"])
264+
226265

227266
class TestPostSave:
228267
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)