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

1"""OGC Filter Encoding 2.0 parser and matcher.""" 

2 

3import re 

4import operator 

5 

6import gws 

7import gws.lib.shape 

8import gws.lib.bounds 

9import gws.lib.gml 

10import gws.lib.xmlx as xmlx 

11 

12 

13class Error(gws.Error): 

14 """Invalid or unsupported filter.""" 

15 

16 pass 

17 

18 

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} 

28 

29 

30## 

31 

32class Matcher: 

33 """Evaluates a search filter against Python objects. 

34 

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 """ 

39 

40 def get_property(self, obj, prop): 

41 """Return a property value of an object. 

42 

43 Args: 

44 obj: Object to match. 

45 prop: Property name. 

46 

47 Returns: 

48 The attribute value, or ``None`` if the object has no such attribute. 

49 """ 

50 return getattr(obj, prop, None) 

51 

52 def get_shape(self, obj): 

53 """Return the geometry of an object. 

54 

55 Args: 

56 obj: Object to match. 

57 

58 Returns: 

59 The ``shape`` attribute, or ``None`` if the object has none. 

60 """ 

61 return getattr(obj, 'shape', None) 

62 

63 def matches(self, flt: gws.SearchFilter, obj): 

64 """Check if an object matches a filter. 

65 

66 Calls the ``match_<operator>`` method for the filter operator. 

67 

68 Args: 

69 flt: Search filter. 

70 obj: Object to match. 

71 

72 Returns: 

73 ``True`` if the object matches the filter. 

74 

75 Raises: 

76 ``AttributeError``: If the filter operator is not supported. 

77 """ 

78 return getattr(self, f'match_{flt.operator}'.lower())(flt, obj) 

79 

80 ## 

81 

82 def match_and(self, flt, obj): 

83 """Check if an object matches all sub-filters. 

84 

85 Args: 

86 flt: Search filter with the ``And`` operator. 

87 obj: Object to match. 

88 

89 Returns: 

90 ``True`` if all sub-filters match. 

91 """ 

92 return all(self.matches(sf, obj) for sf in flt.subFilters) 

93 

94 def match_or(self, flt, obj): 

95 """Check if an object matches any sub-filter. 

96 

97 Args: 

98 flt: Search filter with the ``Or`` operator. 

99 obj: Object to match. 

100 

101 Returns: 

102 ``True`` if at least one sub-filter matches. 

103 """ 

104 return any(self.matches(sf, obj) for sf in flt.subFilters) 

105 

106 def match_not(self, flt, obj): 

107 """Check if an object does not match the first sub-filter. 

108 

109 Args: 

110 flt: Search filter with the ``Not`` operator. 

111 obj: Object to match. 

112 

113 Returns: 

114 ``True`` if the first sub-filter does not match. 

115 """ 

116 return not (self.matches(flt.subFilters[0], obj)) 

117 

118 ## 

119 

120 def match_propertyisequalto(self, flt, obj): 

121 """Check if a property is equal to the filter value. 

122 

123 Args: 

124 flt: Search filter with the ``PropertyIsEqualTo`` operator. 

125 obj: Object to match. 

126 

127 Returns: 

128 ``True`` if the property matches. 

129 """ 

130 return self.compare(self.get_property(obj, flt.property), flt.value, operator.eq) 

131 

132 def match_propertyisnotequalto(self, flt, obj): 

133 """Check if a property is not equal to the filter value. 

134 

135 Args: 

136 flt: Search filter with the ``PropertyIsNotEqualTo`` operator. 

137 obj: Object to match. 

138 

139 Returns: 

140 ``True`` if the property matches. 

141 """ 

142 return self.compare(self.get_property(obj, flt.property), flt.value, operator.ne) 

143 

144 def match_propertyislessthan(self, flt, obj): 

145 """Check if a property is less than the filter value. 

146 

147 Args: 

148 flt: Search filter with the ``PropertyIsLessThan`` operator. 

149 obj: Object to match. 

150 

151 Returns: 

152 ``True`` if the property matches. 

153 """ 

154 return self.compare(self.get_property(obj, flt.property), flt.value, operator.lt) 

155 

156 def match_propertyisgreaterthan(self, flt, obj): 

157 """Check if a property is greater than the filter value. 

158 

159 Args: 

160 flt: Search filter with the ``PropertyIsGreaterThan`` operator. 

161 obj: Object to match. 

162 

163 Returns: 

164 ``True`` if the property matches. 

165 """ 

166 return self.compare(self.get_property(obj, flt.property), flt.value, operator.gt) 

167 

168 def match_propertyislessthanorequalto(self, flt, obj): 

169 """Check if a property is less than or equal to the filter value. 

170 

171 Args: 

172 flt: Search filter with the ``PropertyIsLessThanOrEqualTo`` operator. 

173 obj: Object to match. 

174 

175 Returns: 

176 ``True`` if the property matches. 

177 """ 

178 return self.compare(self.get_property(obj, flt.property), flt.value, operator.le) 

179 

180 def match_propertyisgreaterthanorequalto(self, flt, obj): 

181 """Check if a property is greater than or equal to the filter value. 

182 

183 Args: 

184 flt: Search filter with the ``PropertyIsGreaterThanOrEqualTo`` operator. 

185 obj: Object to match. 

186 

187 Returns: 

188 ``True`` if the property matches. 

189 """ 

190 return self.compare(self.get_property(obj, flt.property), flt.value, operator.ge) 

191 

192 def compare(self, a, b, op): 

193 """Compare a property value with a filter value. 

194 

195 If the property value is a list, it matches if any of its elements matches. 

196 

197 Args: 

198 a: Property value. 

199 b: Filter value. 

200 op: Comparison function, like ``operator.eq``. 

201 

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) 

211 

212 ## 

213 

214 """ 

215 @TODO 

216 Equals 

217 Disjoint 

218 Touches 

219 Within 

220 Overlaps 

221 Crosses 

222 Intersects 

223 Contains 

224 DWithin 

225 Beyond 

226  

227 """ 

228 

229 def match_bbox(self, flt, obj): 

230 """Check if the object geometry intersects the filter box. 

231 

232 Args: 

233 flt: Search filter with the ``BBOX`` operator. 

234 obj: Object to match. 

235 

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) 

243 

244 

245## 

246 

247 

248def from_fes_string(src: str) -> gws.SearchFilter: 

249 """Parse an FES filter from an XML string. 

250 

251 Namespaces are removed before parsing. 

252 

253 Args: 

254 src: XML string with a filter element. 

255 

256 Returns: 

257 A search filter. 

258 

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) 

267 

268 

269def from_fes_element(el: gws.XmlElement) -> gws.SearchFilter: 

270 """Parse an FES filter from an XML element. 

271 

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``. 

277 

278 Args: 

279 el: XML element. 

280 

281 Returns: 

282 A search filter. 

283 

284 Raises: 

285 ``Error``: If the filter is invalid or not supported. 

286 """ 

287 op = el.name.lower() 

288 sub = el.children() 

289 

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]) 

295 

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]) 

302 

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]) 

309 

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]) 

314 

315 if op not in _SUPPORTED_OPS: 

316 raise Error(f'unsupported filter operation {el.name!r}') 

317 

318 flt = gws.SearchFilter( 

319 operator=_SUPPORTED_OPS[op], 

320 ) 

321 

322 # @TODO support "prop = prop" 

323 # @TODO support matchCase, matchAction 

324 

325 v = el.findfirst('ValueReference', 'PropertyName') 

326 if not v or not v.text: 

327 raise Error(f'invalid property name or value reference') 

328 

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) 

334 

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 

342 

343 v = el.findfirst('Literal') 

344 if v: 

345 flt.value = v.text.strip() 

346 return flt 

347 

348 raise Error(f'unsupported filter')