Coverage for gws-app/gws/base/search/filter.py: 29%
98 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""OGC Filter Encoding 2.0 parser and matcher."""
3import re
4import operator
6import gws
7import gws.lib.shape
8import gws.lib.bounds
9import gws.lib.gml
10import gws.lib.xmlx as xmlx
13class Error(gws.Error):
14 """Invalid or unsupported filter."""
16 pass
19_SUPPORTED_OPS = {
20 'propertyisequalto': gws.SearchFilterOperator.PropertyIsEqualTo,
21 'propertyisnotequalto': gws.SearchFilterOperator.PropertyIsNotEqualTo,
22 'propertyislessthan': gws.SearchFilterOperator.PropertyIsLessThan,
23 'propertyisgreaterthan': gws.SearchFilterOperator.PropertyIsGreaterThan,
24 'propertyislessthanorequalto': gws.SearchFilterOperator.PropertyIsLessThanOrEqualTo,
25 'propertyisgreaterthanorequalto': gws.SearchFilterOperator.PropertyIsGreaterThanOrEqualTo,
26 'bbox': gws.SearchFilterOperator.BBOX,
27}
30##
32class Matcher:
33 """Evaluates a search filter against Python objects.
35 By default, properties are object attributes and the geometry is the ``shape``
36 attribute. Subclasses can override ``get_property`` and ``get_shape`` to match
37 other kinds of objects.
38 """
40 def get_property(self, obj, prop):
41 """Return a property value of an object.
43 Args:
44 obj: Object to match.
45 prop: Property name.
47 Returns:
48 The attribute value, or ``None`` if the object has no such attribute.
49 """
50 return getattr(obj, prop, None)
52 def get_shape(self, obj):
53 """Return the geometry of an object.
55 Args:
56 obj: Object to match.
58 Returns:
59 The ``shape`` attribute, or ``None`` if the object has none.
60 """
61 return getattr(obj, 'shape', None)
63 def matches(self, flt: gws.SearchFilter, obj):
64 """Check if an object matches a filter.
66 Calls the ``match_<operator>`` method for the filter operator.
68 Args:
69 flt: Search filter.
70 obj: Object to match.
72 Returns:
73 ``True`` if the object matches the filter.
75 Raises:
76 ``AttributeError``: If the filter operator is not supported.
77 """
78 return getattr(self, f'match_{flt.operator}'.lower())(flt, obj)
80 ##
82 def match_and(self, flt, obj):
83 """Check if an object matches all sub-filters.
85 Args:
86 flt: Search filter with the ``And`` operator.
87 obj: Object to match.
89 Returns:
90 ``True`` if all sub-filters match.
91 """
92 return all(self.matches(sf, obj) for sf in flt.subFilters)
94 def match_or(self, flt, obj):
95 """Check if an object matches any sub-filter.
97 Args:
98 flt: Search filter with the ``Or`` operator.
99 obj: Object to match.
101 Returns:
102 ``True`` if at least one sub-filter matches.
103 """
104 return any(self.matches(sf, obj) for sf in flt.subFilters)
106 def match_not(self, flt, obj):
107 """Check if an object does not match the first sub-filter.
109 Args:
110 flt: Search filter with the ``Not`` operator.
111 obj: Object to match.
113 Returns:
114 ``True`` if the first sub-filter does not match.
115 """
116 return not (self.matches(flt.subFilters[0], obj))
118 ##
120 def match_propertyisequalto(self, flt, obj):
121 """Check if a property is equal to the filter value.
123 Args:
124 flt: Search filter with the ``PropertyIsEqualTo`` operator.
125 obj: Object to match.
127 Returns:
128 ``True`` if the property matches.
129 """
130 return self.compare(self.get_property(obj, flt.property), flt.value, operator.eq)
132 def match_propertyisnotequalto(self, flt, obj):
133 """Check if a property is not equal to the filter value.
135 Args:
136 flt: Search filter with the ``PropertyIsNotEqualTo`` operator.
137 obj: Object to match.
139 Returns:
140 ``True`` if the property matches.
141 """
142 return self.compare(self.get_property(obj, flt.property), flt.value, operator.ne)
144 def match_propertyislessthan(self, flt, obj):
145 """Check if a property is less than the filter value.
147 Args:
148 flt: Search filter with the ``PropertyIsLessThan`` operator.
149 obj: Object to match.
151 Returns:
152 ``True`` if the property matches.
153 """
154 return self.compare(self.get_property(obj, flt.property), flt.value, operator.lt)
156 def match_propertyisgreaterthan(self, flt, obj):
157 """Check if a property is greater than the filter value.
159 Args:
160 flt: Search filter with the ``PropertyIsGreaterThan`` operator.
161 obj: Object to match.
163 Returns:
164 ``True`` if the property matches.
165 """
166 return self.compare(self.get_property(obj, flt.property), flt.value, operator.gt)
168 def match_propertyislessthanorequalto(self, flt, obj):
169 """Check if a property is less than or equal to the filter value.
171 Args:
172 flt: Search filter with the ``PropertyIsLessThanOrEqualTo`` operator.
173 obj: Object to match.
175 Returns:
176 ``True`` if the property matches.
177 """
178 return self.compare(self.get_property(obj, flt.property), flt.value, operator.le)
180 def match_propertyisgreaterthanorequalto(self, flt, obj):
181 """Check if a property is greater than or equal to the filter value.
183 Args:
184 flt: Search filter with the ``PropertyIsGreaterThanOrEqualTo`` operator.
185 obj: Object to match.
187 Returns:
188 ``True`` if the property matches.
189 """
190 return self.compare(self.get_property(obj, flt.property), flt.value, operator.ge)
192 def compare(self, a, b, op):
193 """Compare a property value with a filter value.
195 If the property value is a list, it matches if any of its elements matches.
197 Args:
198 a: Property value.
199 b: Filter value.
200 op: Comparison function, like ``operator.eq``.
202 Returns:
203 ``True`` if the comparison succeeds, ``False`` if the property value is ``None``.
204 """
205 if a is None:
206 return False
207 if isinstance(a, list):
208 # @TODO matchAction
209 return any(op(x, b) for x in a)
210 return op(a, b)
212 ##
214 """
215 @TODO
216 Equals
217 Disjoint
218 Touches
219 Within
220 Overlaps
221 Crosses
222 Intersects
223 Contains
224 DWithin
225 Beyond
227 """
229 def match_bbox(self, flt, obj):
230 """Check if the object geometry intersects the filter box.
232 Args:
233 flt: Search filter with the ``BBOX`` operator.
234 obj: Object to match.
236 Returns:
237 ``True`` if the geometry intersects the box, ``False`` if the object has no geometry.
238 """
239 shape = self.get_shape(obj)
240 if not shape:
241 return False
242 return shape.intersects(flt.shape)
245##
248def from_fes_string(src: str) -> gws.SearchFilter:
249 """Parse an FES filter from an XML string.
251 Namespaces are removed before parsing.
253 Args:
254 src: XML string with a filter element.
256 Returns:
257 A search filter.
259 Raises:
260 ``Error``: If the XML is invalid or the filter is invalid or not supported.
261 """
262 try:
263 el = xmlx.from_string(src, gws.XmlOptions(removeNamespaces=True))
264 except Exception as exc:
265 raise Error('invalid XML') from exc
266 return from_fes_element(el)
269def from_fes_element(el: gws.XmlElement) -> gws.SearchFilter:
270 """Parse an FES filter from an XML element.
272 The element can be a ``Filter`` root element with exactly one predicate, a logical
273 operator (``And``, ``Or``, ``Not``) or a supported comparison or ``BBOX`` predicate.
274 ``And`` and ``Or`` with a single operand are reduced to that operand. A comparison
275 requires a ``ValueReference`` or ``PropertyName`` and a ``Literal``, ``BBOX``
276 requires a property name and a GML ``Envelope``.
278 Args:
279 el: XML element.
281 Returns:
282 A search filter.
284 Raises:
285 ``Error``: If the filter is invalid or not supported.
286 """
287 op = el.name.lower()
288 sub = el.children()
290 if op == 'filter':
291 # root element, only allow a single child predicate
292 if len(sub) != 1:
293 raise Error(f'invalid root predicate')
294 return from_fes_element(sub[0])
296 if op == 'and':
297 if len(sub) == 0:
298 raise Error(f'invalid and predicate')
299 if len(sub) == 1:
300 return from_fes_element(sub[0])
301 return gws.SearchFilter(operator=gws.SearchFilterOperator.And, subFilters=[from_fes_element(s) for s in sub])
303 if op == 'or':
304 if len(sub) == 0:
305 raise Error(f'invalid or predicate')
306 if len(sub) == 1:
307 return from_fes_element(sub[0])
308 return gws.SearchFilter(operator=gws.SearchFilterOperator.Or, subFilters=[from_fes_element(s) for s in sub])
310 if op == 'not':
311 if len(sub) != 1:
312 raise Error(f'invalid not predicate')
313 return gws.SearchFilter(operator=gws.SearchFilterOperator.Not, subFilters=[from_fes_element(s) for s in sub])
315 if op not in _SUPPORTED_OPS:
316 raise Error(f'unsupported filter operation {el.name!r}')
318 flt = gws.SearchFilter(
319 operator=_SUPPORTED_OPS[op],
320 )
322 # @TODO support "prop = prop"
323 # @TODO support matchCase, matchAction
325 v = el.findfirst('ValueReference', 'PropertyName')
326 if not v or not v.text:
327 raise Error(f'invalid property name or value reference')
329 # we only support `propName` or `ns:propName`
330 m = re.match(r'^(\w+:)?(\w+)$', v.text)
331 if not m:
332 raise Error(f'invalid property name {v.text!r}')
333 flt.property = m.group(2)
335 if op == 'bbox':
336 v = el.findfirst('Envelope')
337 if not v:
338 raise Error(f'invalid envelope')
339 bounds = gws.lib.gml.parse_envelope(v)
340 flt.shape = gws.lib.shape.from_bounds(bounds)
341 return flt
343 v = el.findfirst('Literal')
344 if v:
345 flt.value = v.text.strip()
346 return flt
348 raise Error(f'unsupported filter')