1- from typing import Any , TypeVar , cast
1+ from typing import Any , List , Optional , TypeVar , cast
22
33from django .core .exceptions import ImproperlyConfigured
44from django .db .models import Q , QuerySet
77
88from .schema import Schema
99
10- DEFAULT_IGNORE_NONE = True
11- DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR = "AND"
12- DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR = "OR"
13-
1410# XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor
1511ExpressionConnector = Literal ["AND" , "OR" , "XOR" ]
1612
17-
18- # class FilterConfig(BaseConfig):
19- # ignore_none: bool = DEFAULT_IGNORE_NONE
20- # expression_connector: ExpressionConnector = cast(
21- # ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
22- # )
13+ DEFAULT_IGNORE_NONE = True
14+ DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR : ExpressionConnector = "AND"
15+ DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR : ExpressionConnector = "OR"
16+
17+
18+ class FilterLookup :
19+ """
20+ Annotation class for specifying database query lookups in FilterSchema fields.
21+
22+ Example usage:
23+ class MyFilterSchema(FilterSchema):
24+ name: Annotated[str | None, FilterLookup("name__icontains")] = None
25+ search: Annotated[str | None, FilterLookup(["name__icontains", "email__icontains"])] = None
26+ """
27+
28+ def __init__ (
29+ self ,
30+ q : str | List [str ],
31+ * ,
32+ expression_connector : str = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR ,
33+ ignore_none : Optional [bool ] = DEFAULT_IGNORE_NONE ,
34+ ):
35+ """
36+ Args:
37+ q: Database lookup expression(s). Can be:
38+ - A string like "name__icontains"
39+ - A list of strings like ["name__icontains", "email__icontains"]
40+ - Use "__" prefix for implicit field name: "__icontains" becomes "fieldname__icontains"
41+ expression_connector: How to combine multiple field-level expressions ("OR", "AND", "XOR"). Default is "OR".
42+ ignore_none: Whether to ignore None values for this field specifically. Default is True.
43+ """
44+ self .q = q
45+ self .expression_connector = cast (ExpressionConnector , expression_connector )
46+ self .ignore_none = ignore_none
2347
2448
2549T = TypeVar ("T" , bound = QuerySet )
@@ -33,8 +57,8 @@ class FilterSchema(Schema):
3357
3458 class Config (Schema .Config ):
3559 ignore_none : bool = DEFAULT_IGNORE_NONE
36- expression_connector : ExpressionConnector = cast (
37- ExpressionConnector , DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
60+ expression_connector : ExpressionConnector = (
61+ DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
3862 )
3963
4064 def custom_expression (self ) -> Q :
@@ -55,62 +79,137 @@ def get_filter_expression(self) -> Q:
5579 def filter (self , queryset : T ) -> T :
5680 return queryset .filter (self .get_filter_expression ())
5781
82+ def _get_filter_lookup (
83+ self , field_name : str , field_info : FieldInfo
84+ ) -> Optional [FilterLookup ]:
85+ if not hasattr (field_info , "metadata" ) or not field_info .metadata :
86+ return None
87+
88+ filter_lookups = [
89+ metadata_item
90+ for metadata_item in field_info .metadata
91+ if isinstance (metadata_item , FilterLookup )
92+ ]
93+
94+ if len (filter_lookups ) == 0 :
95+ return None
96+ elif len (filter_lookups ) == 1 :
97+ return filter_lookups [0 ]
98+ else :
99+ raise ImproperlyConfigured (
100+ f"Multiple FilterLookup instances found in metadata of { self .__class__ .__name__ } .{ field_name } . "
101+ f"Use at most one FilterLookup instance per field. "
102+ f"If you need multiple lookups, specify them as a list in a single FilterLookup: "
103+ f"{ field_name } : Annotated[{ field_info .annotation } , FilterLookup(['lookup1', 'lookup2', ...])]"
104+ )
105+
106+ def _get_field_q_expression (
107+ self ,
108+ field_name : str ,
109+ field_info : FieldInfo ,
110+ default : str | list [str ] | None = None ,
111+ ) -> str | List [str ] | None :
112+ filter_lookup = self ._get_filter_lookup (field_name , field_info )
113+ if filter_lookup :
114+ return filter_lookup .q if filter_lookup .q is not None else default
115+
116+ # Legacy approach, consider removing in future versions
117+ field_extra = cast (dict , field_info .json_schema_extra ) or {}
118+ return cast (str | list [str ] | None , field_extra .get ("q" , default ))
119+
120+ def _get_field_expression_connector (
121+ self ,
122+ field_name : str ,
123+ field_info : FieldInfo ,
124+ default : ExpressionConnector | None = None ,
125+ ) -> ExpressionConnector | None :
126+ filter_lookup = self ._get_filter_lookup (field_name , field_info )
127+ if filter_lookup :
128+ return filter_lookup .expression_connector or default
129+
130+ # Legacy approach, consider removing in future versions
131+ field_extra = cast (dict , field_info .json_schema_extra ) or {}
132+ return cast (
133+ ExpressionConnector | None , field_extra .get ("expression_connector" , default )
134+ )
135+
136+ def _get_field_ignore_none (
137+ self , field_name : str , field_info : FieldInfo , default : bool | None = None
138+ ) -> bool | None :
139+ filter_lookup = self ._get_filter_lookup (field_name , field_info )
140+ if filter_lookup :
141+ return (
142+ filter_lookup .ignore_none
143+ if filter_lookup .ignore_none is not None
144+ else default
145+ )
146+
147+ # Legacy approach, consider removing in future versions
148+ field_extra = cast (dict , field_info .json_schema_extra ) or {}
149+ return cast (bool | None , field_extra .get ("ignore_none" , default ))
150+
58151 def _resolve_field_expression (
59- self , field_name : str , field_value : Any , field : FieldInfo
152+ self , field_name : str , field_value : Any , field_info : FieldInfo
60153 ) -> Q :
61154 func = getattr (self , f"filter_{ field_name } " , None )
62155 if callable (func ):
63- return func (field_value ) # type: ignore[no-any-return]
156+ return cast ( Q , func (field_value ))
64157
65- field_extra = field .json_schema_extra or {}
158+ q_expression = self ._get_field_q_expression (field_name , field_info )
159+ expression_connector = self ._get_field_expression_connector (
160+ field_name , field_info , default = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
161+ )
66162
67- q_expression = field_extra .get ("q" , None ) # type: ignore
68163 if not q_expression :
69164 return Q (** {field_name : field_value })
70165 elif isinstance (q_expression , str ):
71166 if q_expression .startswith ("__" ):
72167 q_expression = f"{ field_name } { q_expression } "
73168 return Q (** {q_expression : field_value })
74- elif isinstance (q_expression , list ):
75- expression_connector = field_extra .get ( # type: ignore
76- "expression_connector" , DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
77- )
169+ elif isinstance (q_expression , list ) and all (
170+ isinstance (item , str ) for item in q_expression
171+ ):
78172 q = Q ()
79173 for q_expression_part in q_expression :
80- q_expression_part = str (q_expression_part )
81174 if q_expression_part .startswith ("__" ):
82175 q_expression_part = f"{ field_name } { q_expression_part } "
83- q = q ._combine ( # type: ignore
176+ q = q ._combine ( # type: ignore[attr-defined]
84177 Q (** {q_expression_part : field_value }),
85178 expression_connector ,
86179 )
87180 return q
88181 else :
89182 raise ImproperlyConfigured (
90- f"Field { field_name } of { self .__class__ .__name__ } defines an invalid value under 'q' kwarg.\n "
91- f"Define a 'q' kwarg as a string or a list of strings, each string corresponding to a database lookup you wish to filter against:\n "
92- f" { field_name } : { field .annotation } = Field(..., q='<here>')\n "
93- f"or\n "
94- f" { field_name } : { field .annotation } = Field(..., q=['lookup1', 'lookup2', ...])\n "
95- f"You can omit the field name and make it implicit by starting the lookup directly by '__'."
183+ f"Field { field_name } of { self .__class__ .__name__ } defines an invalid value for 'q'.\n "
184+ f"Use FilterLookup annotation: { field_name } : Annotated[{ field_info .annotation } , FilterLookup('lookup')]\n "
96185 f"Alternatively, you can implement { self .__class__ .__name__ } .filter_{ field_name } that must return a Q expression for that field"
97186 )
98187
99188 def _connect_fields (self ) -> Q :
100189 q = Q ()
101- for field_name , field in self .model_fields .items ():
190+ for field_name , field_info in self . __class__ .model_fields .items ():
102191 filter_value = getattr (self , field_name )
103- field_extra = field .json_schema_extra or {}
104- ignore_none = field_extra .get ( # type: ignore
105- "ignore_none" ,
106- self .model_config ["ignore_none" ], # type: ignore
192+ ignore_none = self ._get_field_ignore_none (
193+ field_name ,
194+ field_info ,
195+ cast (
196+ bool | None ,
197+ self .model_config .get ("ignore_none" , DEFAULT_IGNORE_NONE ),
198+ ),
107199 )
108200
109- # Resolve q for a field even if we skip it due to None value
201+ # Resolve Q expression for a field even if we skip it due to None value
110202 # So that improperly configured fields are easier to detect
111- field_q = self ._resolve_field_expression (field_name , filter_value , field )
203+ field_q = self ._resolve_field_expression (
204+ field_name , filter_value , field_info
205+ )
112206 if filter_value is None and ignore_none :
113207 continue
114- q = q ._combine (field_q , self .model_config ["expression_connector" ]) # type: ignore
208+ q = q ._combine ( # type: ignore[attr-defined]
209+ field_q ,
210+ self .model_config .get (
211+ "expression_connector" , DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
212+ ),
213+ )
115214
116215 return q
0 commit comments