diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index 9e1fe4a0b..a43c01508 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -1,4 +1,5 @@ -from django.db.models import CharField, Lookup +from django.db.models import CharField, JSONField, Lookup +from django.db.models.fields.json import KeyTextTransform from .fields import CachedValueField @@ -18,6 +19,30 @@ class Empty(Lookup): return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params +class JSONEmpty(Lookup): + """ + Support "empty" lookups for JSONField keys. + + A key is considered empty if it is "", null, or does not exist. + """ + lookup_name = "empty" + + def as_sql(self, compiler, connection): + # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform) + # Rebuild the expression using KeyTextTransform to guarantee ->> (text) + text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs) + lhs_sql, lhs_params = compiler.compile(text_expr) + + value = self.rhs + if value not in (True, False): + raise ValueError("The 'empty' lookup only accepts True or False.") + + condition = 'NOT ' if value else '' + sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)" + + return sql, lhs_params + + class NetHost(Lookup): """ Similar to ipam.lookups.NetHost, but casts the field to INET. @@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup): CharField.register_lookup(Empty) +JSONField.register_lookup(JSONEmpty) CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetContainsOrEquals) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index aeeb15728..cabaeada8 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -603,8 +603,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): if lookup_expr is not None: kwargs['lookup_expr'] = lookup_expr + # 'Empty' lookup is always a boolean + if lookup_expr == 'empty': + filter_class = django_filters.BooleanFilter + # Text/URL - if self.type in ( + elif self.type in ( CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT, CustomFieldTypeChoices.TYPE_URL,