Coverage for src/mafw/db/db_filter.py: 99%

508 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Database filter module for MAFW. 

6 

7This module provides classes and utilities for creating and managing database filters 

8using Peewee ORM. It supports various filtering operations including simple conditions, 

9logical combinations, and conditional filters where one field's criteria depend on another. 

10 

11The module implements a flexible filter system that can handle: 

12 - Simple field comparisons (equality, inequality, greater/less than, etc.) 

13 - Complex logical operations (AND, OR, NOT) 

14 - Conditional filters with dependent criteria 

15 - Nested logical expressions 

16 - Support for various data types and operations 

17 

18Key components include: 

19 - :class:`FilterNode`: Abstract base class for filter nodes 

20 - :class:`ConditionNode`: Represents individual field conditions 

21 - :class:`LogicalNode`: Combines filter nodes with logical operators 

22 - :class:`ConditionalNode`: Wraps conditional filter conditions 

23 - :class:`ModelFilter`: Main class for building and applying filters to models 

24 - :class:`ProcessorFilter`: Container for multiple model filters in a processor 

25 

26The module uses a hierarchical approach to build filter expressions that can be converted 

27into Peewee expressions for database queries. It supports both simple and complex filtering 

28scenarios through a combination of direct field conditions and logical expressions. 

29 

30.. versionchanged:: v2.0.0 

31 Major overhaul introducing conditional filters and logical expression support. 

32 

33Example usage:: 

34 

35 from mafw.db.db_filter import ModelFilter 

36 

37 # Create a simple filter 

38 flt = ModelFilter( 

39 'Processor.__filter__.Model', 

40 field1='value1', 

41 field2={'op': 'IN', 'value': [1, 2, 3]}, 

42 ) 

43 

44 # Bind to a model and generate query 

45 flt.bind(MyModel) 

46 query = MyModel.select().where(flt.filter()) 

47 

48.. seealso:: 

49 

50 :link:`peewee` - The underlying ORM library used for database operations 

51 

52 :class:`~.mafw.enumerators.LogicalOp` - Logical operation enumerations used in filters 

53""" 

54 

55import logging 

56import operator 

57import re 

58from collections import OrderedDict, UserDict 

59from collections.abc import Iterable, Sequence 

60from copy import copy 

61from functools import reduce 

62from typing import TYPE_CHECKING, Any, Literal, Self, TypeAlias, cast 

63 

64import peewee 

65from peewee import Model 

66 

67from mafw.db.db_model import mafw_model_register 

68from mafw.enumerators import LogicalOp 

69 

70log = logging.getLogger(__name__) 

71 

72 

73Token = tuple[str, str] 

74"""Type definition for a logical expression token""" 

75 

76 

77def _format_expected(expected: str | Sequence[str]) -> str: 

78 """Format a description of expected tokens for diagnostics.""" 

79 if isinstance(expected, str): 79 ↛ 81line 79 didn't jump to line 81 because the condition on line 79 was always true

80 return expected 

81 return ' or '.join(expected) 

82 

83 

84# 1. An atom is a tuple of the literal string 'NAME' and the value 

85NameNode = tuple[Literal['NAME'], str] 

86"""An atom is a tuple of the literal string 'NAME' and the value""" 

87 

88# 2. A NOT node is a tuple of 'NOT' and a recursive node 

89# We use a string forward reference 'ExprNode' because it is defined below 

90NotNode = tuple[Literal['NOT'], 'ExprNode'] 

91"""A NOT node is a tuple of 'NOT' and a recursive node""" 

92 

93# 3. AND/OR nodes are tuples of the operator and two recursive nodes 

94BinaryNode = tuple[Literal['AND', 'OR'], 'ExprNode', 'ExprNode'] 

95"""AND/OR nodes are tuples of the operator and two recursive nodes""" 

96 

97# 4. The main recursive type combining all options 

98ExprNode: TypeAlias = NameNode | NotNode | BinaryNode 

99""" 

100The main recursive type combining all options 

101 

102This type represents the abstract syntax tree (AST) nodes used in logical expressions. 

103It can be one of: 

104 

105 - :data:`NameNode`: A named element (field name or filter name) 

106 - :data:`NotNode`: A negation operation 

107 - :data:`BinaryNode`: An AND/OR operation between two nodes 

108""" 

109 

110TOKEN_SPECIFICATION = [ 

111 ('LPAREN', r'\('), 

112 ('RPAREN', r'\)'), 

113 ('AND', r'\bAND\b'), 

114 ('OR', r'\bOR\b'), 

115 ('NOT', r'\bNOT\b'), 

116 ('NAME', r'[A-Za-z_][A-Za-z0-9_\.]*(?:\:[A-Za-z_][A-Za-z0-9_]*)?'), 

117 ('SKIP', r'[ \t\n\r]+'), 

118 ('MISMATCH', r'.'), 

119] 

120"""Token specifications""" 

121 

122MASTER_RE = re.compile('|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKEN_SPECIFICATION)) 

123"""Compiled regular expression to interpret the logical expression grammar""" 

124 

125 

126class ParseError(ValueError): 

127 """Base exception for logical expression parsing failures.""" 

128 

129 def __init__(self, message: str, *, position: int | None = None) -> None: 

130 super().__init__(message) 

131 self.position = position 

132 

133 

134class UnexpectedTokenError(ParseError): 

135 """Raised when a token is present but not valid in the current context.""" 

136 

137 def __init__( 

138 self, 

139 token: Token, 

140 *, 

141 expected: str | Sequence[str] | None = None, 

142 position: int | None = None, 

143 ) -> None: 

144 expected_desc = f'; expected {_format_expected(expected)}' if expected else '' 

145 message = f'Unexpected token {token[0]} ({token[1]}) at position {position}{expected_desc}' 

146 super().__init__(message, position=position) 

147 self.token = token 

148 self.expected = expected 

149 

150 

151class UnexpectedEndOfExpressionError(ParseError): 

152 """Raised when the expression ends before the parser could finish.""" 

153 

154 def __init__(self, *, expected: str | Sequence[str] | None = None, position: int | None = None) -> None: 

155 expected_desc = f'; expected {_format_expected(expected)}' if expected else '' 

156 message = f'Unexpected end of expression at position {position}{expected_desc}' 

157 super().__init__(message, position=position) 

158 self.expected = expected 

159 

160 

161class MissingTokenError(ParseError): 

162 """Raised when a specific token was required but missing.""" 

163 

164 def __init__(self, expected: str | Sequence[str], *, position: int | None = None) -> None: 

165 message = f'Expected {_format_expected(expected)} before end of expression at position {position}' 

166 super().__init__(message, position=position) 

167 self.expected = expected 

168 

169 

170class UnknownNameError(ParseError): 

171 """Raised when a NAME token is not in the supplied whitelist.""" 

172 

173 def __init__( 

174 self, 

175 name: str, 

176 *, 

177 valid_names: Iterable[str], 

178 position: int | None = None, 

179 ) -> None: 

180 valid_list = sorted(valid_names) 

181 allowed = ', '.join(valid_list[:5]) 

182 if len(valid_list) > 5: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 allowed = allowed + ', ...' 

184 message = f'Unknown name {name!r} at position {position}; valid names: {allowed}' 

185 super().__init__(message, position=position) 

186 self.name = name 

187 self.valid_names = tuple(valid_list) 

188 

189 

190def _tokenize_with_positions(text: str) -> tuple[list[Token], list[int]]: 

191 """Tokenize text while capturing the start offset of each token.""" 

192 tokens: list[Token] = [] 

193 positions: list[int] = [] 

194 for mo in MASTER_RE.finditer(text): 

195 kind = mo.lastgroup 

196 value = mo.group() 

197 if kind == 'SKIP': 

198 continue 

199 elif kind == 'MISMATCH': 

200 raise ParseError(f'Unexpected character {value!r}', position=mo.start()) 

201 else: 

202 assert kind is not None 

203 tokens.append((kind, value)) 

204 positions.append(mo.start()) 

205 return tokens, positions 

206 

207 

208def tokenize(text: str) -> list[Token]: 

209 """ 

210 Tokenize a logical expression string into a list of tokens. 

211 

212 This function breaks down a logical expression string into individual 

213 tokens based on the defined token specifications. It skips whitespace 

214 and raises a :exc:`ParseError` for unexpected characters. 

215 

216 :param text: The logical expression string to tokenize 

217 :type text: str 

218 :return: A list of tokens represented as (token_type, token_value) tuples 

219 :rtype: list[:data:`Token`] 

220 :raises ParseError: If an unexpected character is encountered in the text 

221 """ 

222 tokens, _ = _tokenize_with_positions(text) 

223 return tokens 

224 

225 

226class ExprParser: 

227 """ 

228 Recursive descent parser producing a simple Abstract Syntax Tree (AST). 

229 

230 The parser handles logical expressions with the following grammar: 

231 

232 .. code-block:: none 

233 

234 expr := or_expr 

235 or_expr := and_expr ("OR" and_expr)* 

236 and_expr:= not_expr ("AND" not_expr)* 

237 not_expr:= "NOT" not_expr | atom 

238 atom := NAME | "(" expr ")" 

239 

240 AST nodes are tuples representing different constructs: 

241 

242 - ("NAME", "token"): A named element (field name or filter name) 

243 - ("NOT", node): A negation operation 

244 - ("AND", left, right): An AND operation between two nodes 

245 - ("OR", left, right): An OR operation between two nodes 

246 

247 .. versionadded:: v2.0.0 

248 

249 To help users diagnose grammar and semantic problems, the parser now 

250 reports detailed error classes with character offsets and accepts 

251 an optional ``valid_names`` iterable to reject unknown identifiers early. 

252 """ 

253 

254 def __init__(self, text: str, *, valid_names: Iterable[str] | None = None) -> None: 

255 """ 

256 Initialize the expression parser with a logical expression string. 

257 

258 :param text: The logical expression to parse 

259 :type text: str 

260 :param valid_names: Optional whitelist of valid NAME tokens 

261 :type valid_names: Iterable[str] | None 

262 """ 

263 self._text = text 

264 self.tokens, self.token_positions = _tokenize_with_positions(text) 

265 self.pos = 0 

266 self._valid_names = frozenset(valid_names) if valid_names is not None else None 

267 

268 def peek(self) -> Token | None: 

269 """ 

270 Peek at the next token without consuming it. 

271 

272 :return: The next token if available, otherwise None 

273 :rtype: :data:`Token` | None 

274 """ 

275 if self.pos < len(self.tokens): 

276 return self.tokens[self.pos] 

277 return None 

278 

279 def accept(self, *kinds: str) -> Token | None: 

280 """ 

281 Accept and consume the next token if it matches one of the given types. 

282 

283 :param kinds: Token types to accept 

284 :type kinds: str 

285 :return: The consumed token if matched, otherwise None 

286 :rtype: :data:`Token` | None 

287 """ 

288 tok = self.peek() 

289 if tok and tok[0] in kinds: 

290 self.pos += 1 

291 return tok 

292 return None 

293 

294 def _current_position(self) -> int: 

295 """Return the character offset of the next token or the end of input.""" 

296 if self.pos < len(self.token_positions): 

297 return self.token_positions[self.pos] 

298 return len(self._text) 

299 

300 def expect(self, kind: str) -> 'Token': 

301 """ 

302 Expect and consume a specific token type. 

303 

304 :param kind: The expected token type 

305 :type kind: str 

306 :return: The consumed token 

307 :rtype: :data:`Token` 

308 :raises ParseError: If the expected token is not found 

309 """ 

310 tok = self.accept(kind) 

311 if tok: 

312 return tok 

313 position = self._current_position() 

314 current = self.peek() 

315 if not current: 

316 raise MissingTokenError(kind, position=position) 

317 raise UnexpectedTokenError(current, expected=kind, position=position) 

318 

319 def parse(self) -> 'ExprNode': 

320 """ 

321 Parse the entire logical expression and return the resulting AST. 

322 

323 :return: The abstract syntax tree representation of the expression 

324 :rtype: :data:`ExprNode` 

325 :raises ParseError: If the expression is malformed 

326 """ 

327 node = self.parse_or() 

328 if self.pos != len(self.tokens): 

329 token = self.tokens[self.pos] 

330 position = self.token_positions[self.pos] 

331 raise UnexpectedTokenError(token, expected='end of expression', position=position) 

332 return node 

333 

334 def parse_or(self) -> 'ExprNode': 

335 """ 

336 Parse an OR expression. 

337 

338 :return: The parsed OR expression AST node 

339 :rtype: :data:`ExprNode` 

340 """ 

341 left = self.parse_and() 

342 while self.accept('OR'): 

343 right = self.parse_and() 

344 left = ('OR', left, right) 

345 return left 

346 

347 def parse_and(self) -> 'ExprNode': 

348 """ 

349 Parse an AND expression. 

350 

351 :return: The parsed AND expression AST node 

352 :rtype: :data:`ExprNode` 

353 """ 

354 left = self.parse_not() 

355 while self.accept('AND'): 

356 right = self.parse_not() 

357 left = ('AND', left, right) 

358 return left 

359 

360 def parse_not(self) -> 'ExprNode': 

361 """ 

362 Parse a NOT expression. 

363 

364 :return: The parsed NOT expression AST node 

365 :rtype: :data:`ExprNode` 

366 """ 

367 if self.accept('NOT'): 

368 node = self.parse_not() 

369 return 'NOT', node 

370 return self.parse_atom() 

371 

372 def parse_atom(self) -> 'ExprNode': 

373 """ 

374 Parse an atomic expression (NAME or parenthesised expression). 

375 

376 :return: The parsed atomic expression AST node 

377 :rtype: :data:`ExprNode` 

378 :raises ParseError: If an unexpected token is encountered 

379 """ 

380 tok = self.peek() 

381 if not tok: 

382 raise UnexpectedEndOfExpressionError(position=self._current_position()) 

383 if tok[0] == 'LPAREN': 

384 self.accept('LPAREN') 

385 node = self.parse_or() 

386 self.expect('RPAREN') 

387 return node 

388 elif tok[0] == 'NAME': 

389 start_pos = self.token_positions[self.pos] 

390 self.accept('NAME') 

391 name = tok[1] 

392 valid_names = self._valid_names 

393 if valid_names is not None and name not in valid_names: 

394 raise UnknownNameError(name, valid_names=valid_names, position=start_pos) 

395 return 'NAME', name 

396 else: 

397 raise UnexpectedTokenError(tok, expected='NAME or LPAREN', position=self._current_position()) 

398 

399 

400def ast_to_string(ast: ExprNode) -> str: 

401 """ 

402 Convert an abstract syntax tree (AST) back to its string representation. 

403 

404 :param ast: The AST to convert 

405 :type ast: ExprNode 

406 :return: The string representation of the AST 

407 :rtype: str 

408 """ 

409 t = ast[0] 

410 if t == 'NAME': 

411 return cast(NameNode, ast)[1] 

412 elif t == 'NOT': 

413 inner = cast(NotNode, ast)[1] 

414 inner_str = ast_to_string(inner) 

415 if inner[0] in ('AND', 'OR'): 

416 return f'NOT ({inner_str})' 

417 return f'NOT {inner_str}' 

418 elif t == 'AND': 

419 bin_ast = cast(BinaryNode, ast) 

420 left, right = bin_ast[1], bin_ast[2] 

421 left_str = ast_to_string(left) 

422 right_str = ast_to_string(right) 

423 if left[0] == 'OR': 

424 left_str = f'({left_str})' 

425 if right[0] == 'OR': 

426 right_str = f'({right_str})' 

427 return f'{left_str} AND {right_str}' 

428 elif t == 'OR': 428 ↛ 434line 428 didn't jump to line 434 because the condition on line 428 was always true

429 bin_ast = cast(BinaryNode, ast) 

430 left_str = ast_to_string(bin_ast[1]) 

431 right_str = ast_to_string(bin_ast[2]) 

432 return f'{left_str} OR {right_str}' 

433 else: 

434 raise ValueError(f'Unsupported AST node type: {t}') 

435 

436 

437class FilterNode: 

438 """Abstract base for nodes.""" 

439 

440 def to_expression(self, model: type[Model]) -> peewee.Expression | bool: 

441 raise NotImplementedError # pragma: no cover 

442 

443 

444class ConditionNode(FilterNode): 

445 """ 

446 Represents a single condition node in a filter expression. 

447 

448 This class encapsulates a single filtering condition that can be applied 

449 to a model field. It supports various logical operations through the 

450 :class:`.LogicalOp` enumerator or string representations of operations. 

451 

452 .. versionadded:: v2.0.0 

453 """ 

454 

455 def __init__(self, field: str | None, operation: LogicalOp | str, value: Any, name: str | None = None): 

456 """ 

457 Initialize a condition node. 

458 

459 :param field: The name of the field to apply the condition to. 

460 :type field: str | None 

461 :param operation: The logical operation to perform. 

462 :type operation: LogicalOp | str 

463 :param value: The value to compare against. 

464 :type value: Any 

465 :param name: Optional name for this condition node. 

466 :type name: str | None, Optional 

467 """ 

468 self.field = field # may be None for some special nodes 

469 if isinstance(operation, str): 

470 try: 

471 self.operation = LogicalOp(operation) 

472 except ValueError: 

473 raise ValueError(f'Unsupported operation: {operation}') 

474 else: 

475 self.operation = operation 

476 self.value = value 

477 self.name = name 

478 

479 def to_expression(self, model: type[Model]) -> peewee.Expression: 

480 """ 

481 Convert this condition node to a Peewee expression. 

482 

483 This method translates the condition represented by this node into 

484 a Peewee expression that can be used in database queries. 

485 

486 :param model: The model class containing the field to filter. 

487 :type model: type[Model] 

488 :return: A Peewee expression representing this condition. 

489 :rtype: peewee.Expression 

490 :raises RuntimeError: If the node has no field to evaluate. 

491 :raises ValueError: If an unsupported operation is specified. 

492 :raises TypeError: If operation requirements are not met (e.g., IN operation requires list/tuple). 

493 """ 

494 if self.field is None: 

495 # Should not happen for standard ConditionNode 

496 raise RuntimeError('ConditionNode has no field to evaluate') 

497 model_field = getattr(model, self.field) 

498 op = self.operation 

499 val = self.value 

500 # the code is full of cast and redundant checks to make mypy happy. 

501 # I do not know to which extent they make the code safer, but for sure they make it less readable. 

502 if op == LogicalOp.EQ: 

503 return cast(peewee.Expression, cast(object, model_field == val)) 

504 elif op == LogicalOp.NE: 

505 return cast(peewee.Expression, cast(object, model_field != val)) 

506 elif op == LogicalOp.LT: 

507 return cast(peewee.Expression, cast(object, model_field < val)) 

508 elif op == LogicalOp.LE: 

509 return cast(peewee.Expression, cast(object, model_field <= val)) 

510 elif op == LogicalOp.GT: 

511 return cast(peewee.Expression, cast(object, model_field > val)) 

512 elif op == LogicalOp.GE: 

513 return cast(peewee.Expression, cast(object, model_field >= val)) 

514 elif op == LogicalOp.GLOB: 

515 return cast(peewee.Expression, model_field % val) 

516 elif op == LogicalOp.LIKE: 

517 return cast(peewee.Expression, model_field**val) 

518 elif op == LogicalOp.REGEXP: 

519 if hasattr(model_field, 'regexp') and callable(getattr(model_field, 'regexp')): 

520 return cast(peewee.Expression, getattr(model_field, 'regexp')(val)) 

521 else: 

522 raise ValueError(f'REGEXP operation not supported for field type {type(model_field)}') 

523 elif op == LogicalOp.IN: 

524 if not isinstance(val, (list, tuple)): 

525 raise TypeError(f'IN operation requires list/tuple, got {type(val)}') 

526 if hasattr(model_field, 'in_') and callable(getattr(model_field, 'in_')): 

527 return cast(peewee.Expression, getattr(model_field, 'in_')(val)) 

528 else: 

529 raise ValueError(f'IN operation not supported for field type {type(model_field)}') 

530 elif op == LogicalOp.NOT_IN: 

531 if not isinstance(val, (list, tuple)): 

532 raise TypeError(f'NOT_IN operation requires list/tuple, got {type(val)}') 

533 if hasattr(model_field, 'not_in') and callable(getattr(model_field, 'not_in')): 

534 return cast(peewee.Expression, getattr(model_field, 'not_in')(val)) 

535 else: 

536 raise ValueError(f'NOT_IN operation not supported for field type {type(model_field)}') 

537 elif op == LogicalOp.BETWEEN: 

538 if not isinstance(val, (list, tuple)) or len(val) != 2: 

539 raise TypeError(f'BETWEEN operation requires list/tuple of 2 elements, got {val}') 

540 if hasattr(model_field, 'between') and callable(getattr(model_field, 'between')): 

541 return cast(peewee.Expression, getattr(model_field, 'between')(val[0], val[1])) 

542 else: 

543 raise ValueError(f'BETWEEN operation not supported for field type {type(model_field)}') 

544 elif op == LogicalOp.BIT_AND: 

545 if hasattr(model_field, 'bin_and') and callable(getattr(model_field, 'bin_and')): 

546 return cast(peewee.Expression, cast(object, getattr(model_field, 'bin_and')(val) != 0)) 

547 else: 

548 raise ValueError(f'BIT_AND operation not supported for field type {type(model_field)}') 

549 elif op == LogicalOp.BIT_OR: 

550 if hasattr(model_field, 'bin_or') and callable(getattr(model_field, 'bin_or')): 

551 return cast(peewee.Expression, cast(object, getattr(model_field, 'bin_or')(val) != 0)) 

552 else: 

553 raise ValueError(f'BIT_OR operation not supported for field type {type(model_field)}') 

554 elif op == LogicalOp.IS_NULL: 

555 return cast(peewee.Expression, model_field.is_null()) 

556 elif op == LogicalOp.IS_NOT_NULL: 

557 return cast(peewee.Expression, model_field.is_null(False)) 

558 else: 

559 raise ValueError(f'Unsupported operation: {op}') 

560 

561 

562class ConditionalNode(FilterNode): 

563 """ 

564 Wraps :class:`ConditionalFilterCondition` behaviour as a :class:`FilterNode`. 

565 

566 This class serves as an adapter to integrate conditional filter conditions 

567 into the filter node hierarchy, allowing them to be treated uniformly with 

568 other filter nodes during expression evaluation. 

569 

570 .. versionadded:: v2.0.0 

571 """ 

572 

573 def __init__(self, conditional: 'ConditionalFilterCondition', name: str | None = None): 

574 """ 

575 Initialize a conditional node. 

576 

577 :param conditional: The conditional filter condition to wrap 

578 :type conditional: ConditionalFilterCondition 

579 :param name: Optional name for this conditional node 

580 :type name: str | None, Optional 

581 """ 

582 self.conditional = conditional 

583 self.name = name 

584 

585 def to_expression(self, model: type[Model]) -> peewee.Expression: 

586 """ 

587 Convert this conditional node to a Peewee expression. 

588 

589 This method delegates the conversion to the wrapped conditional filter 

590 condition's :meth:`to_expression` method. 

591 

592 :param model: The model class to generate the expression for 

593 :type model: type[Model] 

594 :return: A Peewee expression representing this conditional node 

595 :rtype: peewee.Expression 

596 """ 

597 return self.conditional.to_expression(model) 

598 

599 

600class LogicalNode(FilterNode): 

601 """ 

602 Logical combination of child nodes. 

603 

604 This class represents logical operations (AND, OR, NOT) applied to filter nodes. 

605 It enables building complex filter expressions by combining simpler filter nodes 

606 with logical operators. 

607 

608 .. versionadded:: v2.0.0 

609 """ 

610 

611 def __init__(self, op: str, *children: FilterNode): 

612 """ 

613 Initialize a logical node. 

614 

615 :param op: The logical operation ('AND', 'OR', 'NOT') 

616 :type op: str 

617 :param children: Child filter nodes to combine with the logical operation 

618 :type children: FilterNode 

619 """ 

620 self.op = op # 'AND', 'OR', 'NOT' 

621 self.children = list(children) 

622 

623 def to_expression(self, model: type[Model]) -> peewee.Expression | bool: 

624 """ 

625 Convert this logical node to a Peewee expression. 

626 

627 This method evaluates the logical operation on the child nodes and returns 

628 the corresponding Peewee expression. 

629 

630 :param model: The model class to generate the expression for 

631 :type model: type[Model] 

632 :return: A Peewee expression representing this logical node 

633 :rtype: peewee.Expression | bool 

634 :raises ValueError: If an unknown logical operation is specified 

635 """ 

636 if self.op == 'NOT': 

637 assert len(self.children) == 1 

638 inner = self.children[0].to_expression(model) 

639 return cast(peewee.Expression, ~inner) 

640 elif self.op == 'AND': 

641 expressions = [c.to_expression(model) for c in self.children] 

642 return cast(peewee.Expression, reduce(operator.and_, expressions)) 

643 elif self.op == 'OR': 

644 expressions = [c.to_expression(model) for c in self.children] 

645 return cast(peewee.Expression, reduce(operator.or_, expressions)) 

646 else: 

647 raise ValueError(f'Unknown logical op: {self.op}') 

648 

649 

650class ConditionalFilterCondition: 

651 """ 

652 Represents a conditional filter where one field's criteria depends on another. 

653 

654 This allows expressing logic like: 

655 "IF field_a IN [x, y] THEN field_b IN [1, 2] ELSE no constraint on field_b" 

656 

657 Example usage: 

658 

659 .. code-block:: python 

660 

661 # Filter: sample_id in [1,2] if composite_image_id in [100,101] 

662 condition = ConditionalFilterCondition( 

663 condition_field='composite_image_id', 

664 condition_op='IN', 

665 condition_value=[100, 101], 

666 then_field='sample_id', 

667 then_op='IN', 

668 then_value=[1, 2], 

669 ) 

670 

671 # This generates: 

672 # WHERE (composite_image_id IN (100, 101) AND sample_id IN (1, 2)) 

673 # OR (composite_image_id NOT IN (100, 101)) 

674 """ 

675 

676 def __init__( 

677 self, 

678 condition_field: str, 

679 condition_op: str | LogicalOp, 

680 condition_value: Any, 

681 then_field: str, 

682 then_op: str | LogicalOp, 

683 then_value: Any, 

684 else_field: str | None = None, 

685 else_op: str | LogicalOp | None = None, 

686 else_value: Any | None = None, 

687 name: str | None = None, 

688 ) -> None: 

689 """ 

690 Initialise a conditional filter condition. 

691 

692 :param condition_field: The field to check for the condition 

693 :type condition_field: str 

694 :param condition_op: The operation for the condition (e.g., 'IN', '==') 

695 :type condition_op: str | LogicalOp 

696 :param condition_value: The value(s) for the condition 

697 :type condition_value: Any 

698 :param then_field: The field to filter when condition is true 

699 :type then_field: str 

700 :param then_op: The operation to apply when condition is true 

701 :type then_op: str | LogicalOp 

702 :param then_value: The value(s) for the then clause 

703 :type then_value: Any 

704 :param else_field: Optional field to filter when condition is false 

705 :type else_field: str | None 

706 :param else_op: Optional operation when condition is false 

707 :type else_op: str | LogicalOp | None 

708 :param else_value: Optional value(s) for the else clause 

709 :type else_value: Any | None 

710 :param name: The name of this condition. Avoid name clashing with model fields. Defaults to None 

711 :type name: str | None, Optional 

712 """ 

713 self.condition_field = condition_field 

714 self.condition_op = condition_op 

715 self.condition_value = condition_value 

716 self.then_field = then_field 

717 self.then_op = then_op 

718 self.then_value = then_value 

719 self.else_field = else_field 

720 self.else_op = else_op 

721 self.else_value = else_value 

722 self.name = name 

723 

724 def to_expression(self, model: type[Model]) -> peewee.Expression: 

725 """ 

726 Convert this conditional filter to a Peewee expression. 

727 

728 The resulting expression is: 

729 (condition AND then_constraint) OR (NOT condition AND else_constraint) 

730 

731 Which logically means: 

732 

733 - When condition is true, apply then_constraint 

734 - When condition is false, apply else_constraint (or no constraint) 

735 

736 :param model: The model class containing the fields 

737 :type model: type[Model] 

738 :return: A Peewee expression 

739 :rtype: peewee.Expression 

740 """ 

741 # Build the condition expression 

742 condition_expr = ConditionNode(self.condition_field, self.condition_op, self.condition_value).to_expression( 

743 model 

744 ) 

745 

746 # Build the then expression 

747 then_expr = ConditionNode(self.then_field, self.then_op, self.then_value).to_expression(model) 

748 

749 # Build the else expression 

750 if self.else_field is not None and self.else_op is not None: 

751 else_expr = ConditionNode(self.else_field, self.else_op, self.else_value).to_expression(model) 

752 else: 

753 # No constraint in else clause - always true 

754 # the nested cast is needed to make mypy happy. 

755 else_expr = cast(peewee.Expression, cast(object, True)) 

756 

757 # Combine: (condition AND then) OR (NOT condition AND else) 

758 return cast(peewee.Expression, (condition_expr & then_expr) | (~condition_expr & else_expr)) 

759 

760 def __eq__(self, other: Any) -> bool: 

761 if not isinstance(other, ConditionalFilterCondition): 

762 return False 

763 

764 return vars(self) == vars(other) 

765 

766 

767class ModelFilter: 

768 r""" 

769 Class to filter rows from a model. 

770 

771 The filter object can be used to generate a where clause to be applied to Model.select(). 

772 

773 The construction of a ModelFilter is normally done via a configuration file using the :meth:`from_conf` class method. 

774 The name of the filter is playing a key role in this. If it follows a dot structure like: 

775 

776 *ProcessorName.__filter__.ModelName* 

777 

778 then the corresponding table from the TOML configuration object will be used. 

779 

780 For each processor, there might be many Filters, up to one for each Model used to get the input list. If a 

781 processor is joining together three Models when performing the input select, there will be up to three Filters 

782 collaborating on making the selection. 

783 

784 The filter configuration can contain the following key, value pair: 

785 

786 - key / string pairs, where the key is the name of a field in the corresponding Model 

787 

788 - key / numeric pairs 

789 

790 - key / arrays 

791 

792 - key / dict pairs with 'op' and 'value' keys for explicit operation specification 

793 

794 All fields from the configuration file will be added to the instance namespace, thus accessible with the dot 

795 notation. Moreover, the field names and their filter value will be added to a private dictionary to simplify the 

796 generation of the filter SQL code. 

797 

798 The user can use the filter object to store selection criteria. He can construct queries using the filter 

799 contents in the same way as he could use processor parameters. 

800 

801 If he wants to automatically generate valid filtering expression, he can use the :meth:`filter` method. In order 

802 for this to work, the ModelFilter object be :meth:`bound <bind>` to a Model. Without this binding the ModelFilter will not 

803 be able to automatically generate expressions. 

804 

805 For each field in the filter, one condition will be generated according to the following scheme: 

806 

807 ================= ================= ================== 

808 Filter field type Logical operation Example 

809 ================= ================= ================== 

810 Numeric, boolean == Field == 3.14 

811 String GLOB Field GLOB '\*ree' 

812 List IN Field IN [1, 2, 3] 

813 Dict (explicit) op from dict Field BIT_AND 5 

814 ================= ================= ================== 

815 

816 All conditions will be joined with a AND logic by default, but this can be changed. 

817 

818 The ModelFilter also supports logical expressions to combine multiple filter conditions using AND, OR, and NOT 

819 operators. These expressions can reference named filter conditions within the same filter or even combine 

820 conditions from different filters when used with :class:`ProcessorFilter`. 

821 

822 Conditional filters allow expressing logic like: 

823 "IF field_a IN [x, y] THEN field_b IN [1, 2] ELSE no constraint on field_b" 

824 

825 Consider the following example: 

826 

827 .. code-block:: python 

828 :linenos: 

829 

830 class MeasModel(MAFwBaseModel): 

831 meas_id = AutoField(primary_key=True) 

832 sample_name = TextField() 

833 successful = BooleanField() 

834 flags = IntegerField() 

835 composite_image_id = IntegerField() 

836 sample_id = IntegerField() 

837 

838 

839 # Traditional simplified usage 

840 flt = ModelFilter( 

841 'MyProcessor.__filter__.MyModel', 

842 sample_name='sample_00*', 

843 meas_id=[1, 2, 3], 

844 successful=True, 

845 ) 

846 

847 # New explicit operation usage 

848 flt = ModelFilter( 

849 'MyProcessor.__filter__.MyModel', 

850 sample_name={'op': 'LIKE', 'value': 'sample_00%'}, 

851 flags={'op': 'BIT_AND', 'value': 5}, 

852 meas_id={'op': 'IN', 'value': [1, 2, 3]}, 

853 ) 

854 

855 # Logical expression usage 

856 flt = ModelFilter( 

857 'MyProcessor.__filter__.MyModel', 

858 sample_name={'op': 'LIKE', 'value': 'sample_00%'}, 

859 flags={'op': 'BIT_AND', 'value': 5}, 

860 meas_id={'op': 'IN', 'value': [1, 2, 3]}, 

861 __logic__='sample_name AND (flags OR meas_id)', 

862 ) 

863 

864 # Conditional filter usage 

865 flt = ModelFilter( 

866 'MyProcessor.__filter__.MyModel', 

867 sample_name='sample_00*', 

868 composite_image_id=[100, 101], 

869 sample_id=[1, 2], 

870 __conditional__=[ 

871 { 

872 'condition_field': 'composite_image_id', 

873 'condition_op': 'IN', 

874 'condition_value': [100, 101], 

875 'then_field': 'sample_id', 

876 'then_op': 'IN', 

877 'then_value': [1, 2], 

878 } 

879 ], 

880 ) 

881 

882 flt.bind(MeasModel) 

883 filtered_query = MeasModel.select().where(flt.filter()) 

884 

885 The explicit operation format allows for bitwise operations and other advanced filtering. 

886 

887 TOML Configuration Examples: 

888 

889 .. code-block:: toml 

890 

891 [MyProcessor.__filter__.MyModel] 

892 sample_name = "sample_00*" # Traditional GLOB 

893 successful = true # Traditional equality 

894 

895 # Explicit operations 

896 flags = { op = "BIT_AND", value = 5 } 

897 score = { op = ">=", value = 75.0 } 

898 category = { op = "IN", value = ["A", "B", "C"] } 

899 date_range = { op = "BETWEEN", value = ["2024-01-01", "2024-12-31"] } 

900 

901 # Logical expression for combining conditions 

902 __logic__ = "sample_name AND (successful OR flags)" 

903 

904 # Conditional filters 

905 [[MyProcessor.__filter__.MyModel.__conditional__]] 

906 condition_field = "composite_image_id" 

907 condition_op = "IN" 

908 condition_value = [100, 101] 

909 then_field = "sample_id" 

910 then_op = "IN" 

911 then_value = [1, 2] 

912 

913 # Nested conditions with logical expressions 

914 [MyProcessor.__filter__.MyModel.nested_conditions] 

915 __logic__ = "a OR b" 

916 a = { op = "LIKE", value = "test%" } 

917 b = { op = "IN", value = [1, 2, 3] } 

918 

919 .. seealso:: 

920 

921 - :class:`mafw.db.db_filter.ProcessorFilter` - For combining multiple ModelFilters with logical expressions 

922 - :class:`mafw.db.db_filter.ConditionalFilterCondition` - For conditional filtering logic 

923 - :class:`mafw.db.db_filter.ExprParser` - For parsing logical expressions 

924 """ 

925 

926 logic_name = '__logic__' 

927 """ 

928 The logic keyword identifier. 

929  

930 This value cannot be used as field name in the filter bound model. 

931 """ 

932 conditional_name = '__conditional__' 

933 """ 

934 The conditional keyword identifier. 

935  

936 This value cannot be used as field name in the filter bound model. 

937 """ 

938 

939 def __init__(self, name_: str, **kwargs: Any) -> None: 

940 """ 

941 Constructor parameters: 

942 

943 :param `name_`: The name of the filter. It should be in dotted format to facilitate the configuration via the 

944 steering file. The _ is used to allow the user to have a keyword argument named name. 

945 :type `name_`: str 

946 :param kwargs: Keyword parameters corresponding to fields and filter values. 

947 

948 .. versionchanged:: v1.2.0 

949 The parameter *name* has been renamed as *name_*. 

950 

951 .. versionchanged:: v1.3.0 

952 Implementation of explicit operation. 

953 

954 .. versionchanged:: v2.0.0 

955 Introduction of conditional filters, logical expression and hierarchical structure. 

956 Introduction of autobinding for MAFwBaseModels 

957 

958 """ 

959 self.name = name_ 

960 self.model_name = name_.split('.')[-1] 

961 self.model: type[Model] | None = None 

962 self._model_bound = False 

963 

964 # attempt to autobind 

965 self._auto_bind() 

966 

967 # mapping name -> FilterNode 

968 self._nodes: 'OrderedDict[str, FilterNode]' = OrderedDict() 

969 # conditional nodes mapping (named) 

970 self._cond_nodes: 'OrderedDict[str, ConditionalNode]' = OrderedDict() 

971 # logic expression for this filter (combining top-level node names) 

972 self._logic_expr: str | None = None 

973 

974 # Extract conditional filters if present 

975 if self.conditional_name in kwargs: 

976 conditionals = kwargs.pop(self.conditional_name) 

977 if not isinstance(conditionals, list): 

978 conditionals = [conditionals] 

979 

980 for cond_dict in conditionals: 

981 self.add_conditional_from_dict(cond_dict) 

982 

983 # Extract logic for internal conditions, if provided 

984 if self.logic_name in kwargs: 

985 self._logic_expr = kwargs.pop(self.logic_name) 

986 

987 # now process remaining kwargs as either: 

988 # - simple/extended condition for a field 

989 # - or a nested mapping describing subconditions for field (field-level logic) 

990 for k, v in kwargs.items(): 

991 # simple types map to ConditionNode 

992 if isinstance(v, dict) and ('op' in v and 'value' in v): 

993 # explicit op/value for field k 

994 # extended operation condition 

995 node = ConditionNode(k, v['op'], v['value'], name=k) 

996 self._nodes[k] = node 

997 elif isinstance(v, dict) and any( 

998 isinstance(x, dict) or x == self.logic_name or x not in ['op', 'value'] 

999 for x in v.keys() 

1000 if isinstance(v, dict) 

1001 ): 

1002 # nested mapping: create sub-nodes for this field 

1003 # v expected like {'__logic__': 'a OR b', 'a': {'op':..., 'value':...}, 'b': ...} 

1004 subnodes: 'OrderedDict[str, FilterNode]' = OrderedDict() 

1005 sub_logic = v.get(self.logic_name, None) 

1006 for subk, subv in v.items(): 

1007 if subk == self.logic_name: 

1008 continue 

1009 if isinstance(subv, dict) and ('op' in subv and 'value' in subv): 

1010 subnode = ConditionNode(k, subv['op'], subv['value'], name=subk) 

1011 subnodes[subk] = subnode 

1012 else: 

1013 subnodes[subk] = self._create_condition_node_from_value(subv, k, subk) 

1014 # combine subnodes using sub_logic or AND by default 

1015 if sub_logic: 

1016 ast = ExprParser(sub_logic).parse() 

1017 ln = self._build_logical_node_from_ast(ast, subnodes, model_name_placeholder=k) 

1018 else: 

1019 # AND all subnodes 

1020 ln = LogicalNode('AND', *subnodes.values()) 

1021 self._nodes[k] = ln 

1022 else: 

1023 self._nodes[k] = self._create_condition_node_from_value(v, k, k) 

1024 

1025 def _auto_bind(self) -> None: 

1026 """ 

1027 Attempt to automatically bind the filter to a model. 

1028 

1029 This method tries to retrieve the model associated with the filter's model name from the 

1030 :mod:`mafw.db.db_model` registry and bind it using the :meth:`bind` method. 

1031 

1032 If the model cannot be found,a warning is logged indicating the failure to perform auto-binding. 

1033 

1034 This model is automatically invoked by the :class:`.ModelFilter` constructor. 

1035 """ 

1036 try: 

1037 model = mafw_model_register.get_model(self.model_name) 

1038 self.bind(model) # type: ignore[arg-type] 

1039 except KeyError: 

1040 log.warning(f'Impossible to perform auto-binding for model {self.model_name}') 

1041 

1042 def _build_logical_node_from_ast( 

1043 self, ast: ExprNode, name_to_nodes: dict[str, FilterNode], model_name_placeholder: str | None = None 

1044 ) -> FilterNode: 

1045 """Recursively build LogicalNode from AST using a mapping name->FilterNode.""" 

1046 t = ast[0] 

1047 if t == 'NAME': 

1048 named_ast = cast(NameNode, ast) 

1049 nm = named_ast[1] 

1050 if nm not in name_to_nodes: 

1051 raise KeyError(f'Unknown name {nm} in nested logic for field {model_name_placeholder}') 

1052 return name_to_nodes[nm] 

1053 elif t == 'NOT': 

1054 not_ast = cast(NotNode, ast) 

1055 child = self._build_logical_node_from_ast(not_ast[1], name_to_nodes, model_name_placeholder) 

1056 return LogicalNode('NOT', child) 

1057 elif t in ('AND', 'OR'): 

1058 bin_ast = cast(BinaryNode, ast) 

1059 left = self._build_logical_node_from_ast(bin_ast[1], name_to_nodes, model_name_placeholder) 

1060 right = self._build_logical_node_from_ast(bin_ast[2], name_to_nodes, model_name_placeholder) 

1061 return LogicalNode(t, left, right) 

1062 else: 

1063 raise ValueError(f'Unsupported AST node {t}') 

1064 

1065 @staticmethod 

1066 def _create_condition_node_from_value(value: Any, field_name: str, node_name: str | None = None) -> ConditionNode: 

1067 """ 

1068 Create a FilterCondition based on value type (backward compatibility). 

1069 

1070 :param value: The filter value 

1071 :param field_name: The field name 

1072 :return: A FilterCondition 

1073 """ 

1074 if isinstance(value, (int, float, bool)): 

1075 return ConditionNode(field_name, LogicalOp.EQ, value, node_name) 

1076 elif isinstance(value, str): 

1077 return ConditionNode(field_name, LogicalOp.GLOB, value, node_name) 

1078 elif isinstance(value, list): 

1079 return ConditionNode(field_name, LogicalOp.IN, value, node_name) 

1080 else: 

1081 raise TypeError(f'ModelFilter value of unsupported type {type(value)} for field {field_name}.') 

1082 

1083 def bind(self, model: type[Model]) -> None: 

1084 """ 

1085 Connects a filter to a Model class. 

1086 

1087 :param model: Model to be bound. 

1088 :type model: Model 

1089 """ 

1090 

1091 self.model = model 

1092 self._model_bound = True 

1093 

1094 if hasattr(self.model, self.logic_name) and self._model_bound: 

1095 if TYPE_CHECKING: 

1096 assert self.model is not None 

1097 

1098 log.warning( 

1099 f'Model {self.model.__name__} has a field named {self.logic_name}. This is ' 

1100 f'preventing the logic expression to work.' 

1101 ) 

1102 log.warning('Modify your model. Logic expression disabled.') 

1103 self._logic_expr = None 

1104 

1105 @property 

1106 def is_bound(self) -> bool: 

1107 """Returns true if the ModelFilter has been bound to a Model""" 

1108 return self._model_bound 

1109 

1110 def add_conditional(self, conditional: ConditionalFilterCondition) -> None: 

1111 """ 

1112 Add a conditional filter. 

1113 

1114 .. versionadded:: v2.0.0 

1115 

1116 :param conditional: The conditional filter condition 

1117 :type conditional: ConditionalFilterCondition 

1118 """ 

1119 condition_name = conditional.name 

1120 if condition_name is None: 

1121 # it means that the user did not specify any name for this condition. 

1122 # we will then assign one 

1123 increment = 0 

1124 while True: 

1125 condition_name = f'__cond{increment + len(self._cond_nodes)}__' 

1126 if condition_name not in self._cond_nodes: 

1127 break 

1128 else: 

1129 increment += 1 

1130 else: 

1131 # the user specified a name for this condition. we will use it but first we check if it is not yet used 

1132 if condition_name in self._cond_nodes: 

1133 raise KeyError( 

1134 f'A conditional filter named {condition_name} already exists. Please review your steering file.' 

1135 ) 

1136 

1137 node = ConditionalNode(conditional, name=condition_name) 

1138 self._cond_nodes[condition_name] = node 

1139 self._nodes[condition_name] = node 

1140 

1141 def add_conditional_from_dict(self, config: dict[str, Any]) -> None: 

1142 """ 

1143 Add a conditional filter from a configuration dictionary. 

1144 

1145 .. versionadded:: v2.0.0 

1146 

1147 :param config: Dictionary with conditional filter configuration 

1148 :type config: dict[str, Any] 

1149 """ 

1150 conditional = ConditionalFilterCondition( 

1151 condition_field=config['condition_field'], 

1152 condition_op=config['condition_op'], 

1153 condition_value=config['condition_value'], 

1154 then_field=config['then_field'], 

1155 then_op=config['then_op'], 

1156 then_value=config['then_value'], 

1157 else_field=config.get('else_field'), 

1158 else_op=config.get('else_op'), 

1159 else_value=config.get('else_value'), 

1160 name=config.get('name'), 

1161 ) 

1162 self.add_conditional(conditional) 

1163 

1164 @classmethod 

1165 def from_conf(cls, name: str, conf: dict[str, Any]) -> Self: 

1166 """ 

1167 Builds a Filter object from a steering file dictionary. 

1168 

1169 If the name is in dotted notation, then this should be corresponding to the table in the configuration file. 

1170 If a default configuration is provided, this will be used as a starting point for the filter, and it will be 

1171 updated by the actual configuration in ``conf``. 

1172 

1173 In normal use, you would provide the specific configuration via the conf parameter. 

1174 

1175 See details in the :class:`class documentation <ModelFilter>` 

1176 

1177 :param name: The name of the filter in dotted notation. 

1178 :type name: str 

1179 :param conf: The configuration dictionary. 

1180 :type conf: dict 

1181 :return: A Filter object 

1182 :rtype: ModelFilter 

1183 """ 

1184 param = {} 

1185 

1186 # split the name from dotted notation 

1187 # ProcessorName#123.ModelName.Filter 

1188 # the processor name is actually the processor replica name 

1189 names = name.split('.') 

1190 if len(names) == 3 and names[1] == '__filter__': 

1191 proc_name, _, model_name = names 

1192 if proc_name in conf and '__filter__' in conf[proc_name] and model_name in conf[proc_name]['__filter__']: 

1193 param.update(copy(conf[proc_name]['__filter__'][model_name])) 

1194 

1195 # if the name is not in the expected dotted notation, the use an empty filter. 

1196 return cls(name, **param) 

1197 

1198 def _evaluate_logic_ast(self, ast: ExprNode) -> peewee.Expression | bool: 

1199 """ 

1200 Evaluate an abstract syntax tree (AST) representing a logical expression. 

1201 

1202 This method recursively evaluates the AST nodes to produce a Peewee expression 

1203 or boolean value representing the logical combination of filter conditions. 

1204 

1205 :param ast: The abstract syntax tree node to evaluate 

1206 :type ast: Any 

1207 :return: A Peewee expression for logical operations or boolean True/False 

1208 :rtype: peewee.Expression | bool 

1209 :raises KeyError: If a referenced condition name is not found in the filter 

1210 :raises ValueError: If an unsupported AST node type is encountered 

1211 """ 

1212 t = ast[0] 

1213 if t == 'NAME': 

1214 named_ast = cast(NameNode, ast) 

1215 nm = named_ast[1] 

1216 if nm not in self._nodes: 

1217 raise KeyError(f"Unknown node '{nm}' in logic for filter {self.name}") 

1218 node = self._nodes[nm] 

1219 

1220 if TYPE_CHECKING: 

1221 assert self.model is not None 

1222 return node.to_expression(self.model) 

1223 elif t == 'NOT': 

1224 not_ast = cast(NotNode, ast) 

1225 val = self._evaluate_logic_ast(not_ast[1]) 

1226 return cast(peewee.Expression, ~val) 

1227 elif t == 'AND': 

1228 bin_ast = cast(BinaryNode, ast) 

1229 left = self._evaluate_logic_ast(bin_ast[1]) 

1230 right = self._evaluate_logic_ast(bin_ast[2]) 

1231 return cast(peewee.Expression, cast(object, left & right)) 

1232 elif t == 'OR': 

1233 bin_ast = cast(BinaryNode, ast) 

1234 left = self._evaluate_logic_ast(bin_ast[1]) 

1235 right = self._evaluate_logic_ast(bin_ast[2]) 

1236 return cast(peewee.Expression, cast(object, left | right)) 

1237 else: 

1238 raise ValueError(f'Unsupported AST node {t}') 

1239 

1240 def filter(self, join_with: Literal['AND', 'OR'] = 'AND') -> peewee.Expression | bool: 

1241 """ 

1242 Generates a filtering expression joining all filtering fields. 

1243 

1244 See details in the :class:`class documentation <ModelFilter>` 

1245 

1246 .. versionchanged:: v1.3.0 

1247 Add the possibility to specify a `join_with` function 

1248 

1249 .. versionchanged:: v2.0.0 

1250 Add support for conditional filters and for logical expression 

1251 

1252 :param join_with: How to join conditions ('AND' or 'OR'). Defaults to 'AND'. 

1253 :type join_with: Literal['AND', 'OR'], default 'AND' 

1254 :return: The filtering expression. 

1255 :rtype: peewee.Expression | bool 

1256 :raises TypeError: when the field value type is not supported. 

1257 :raises ValueError: when join_with is not 'AND' or 'OR'. 

1258 """ 

1259 if not self.is_bound: 

1260 log.warning('Unable to generate the filter. Did you bind the filter to the model?') 

1261 return True 

1262 

1263 if TYPE_CHECKING: 

1264 # if we get here, it means that we have a valid model 

1265 assert self.model is not None 

1266 

1267 # if logic provided for this filter, use it 

1268 if self._logic_expr: 

1269 try: 

1270 ast = ExprParser(self._logic_expr).parse() 

1271 except ParseError as e: 

1272 raise ValueError(f'Error parsing logic for filter {self.name}: {e}') 

1273 try: 

1274 return self._evaluate_logic_ast(ast) 

1275 except KeyError as e: 

1276 raise ValueError(f'Error evaluating logic for filter {self.name}: {e}') 

1277 

1278 # otherwise combine all top-level nodes (AND by default) 

1279 exprs = [n.to_expression(self.model) for n in self._nodes.values()] 

1280 if not exprs: 

1281 return True 

1282 if join_with not in ('AND', 'OR'): 

1283 raise ValueError("join_with must be 'AND' or 'OR'") 

1284 if join_with == 'AND': 

1285 return cast(peewee.Expression, reduce(operator.and_, exprs)) 

1286 return cast(peewee.Expression, reduce(operator.or_, exprs)) 

1287 

1288 

1289class ProcessorFilter(UserDict[str, ModelFilter]): 

1290 """ 

1291 A special dictionary to store all :class:`Filters <mafw.db.db_filter.ModelFilter>` in a processors. 

1292 

1293 It contains a publicly accessible dictionary with the configuration of each ModelFilter using the Model name as 

1294 keyword. 

1295 

1296 It contains a private dictionary with the global filter configuration as well. 

1297 The global filter is not directly accessible, but only some of its members will be exposed via properties. 

1298 In particular, the new_only flag that is relevant only at the Processor level can be accessed directly using the 

1299 :attr:`new_only`. If not specified in the configuration file, the new_only is by default True. 

1300 

1301 It is possible to assign a logic operation string to the register that is used to join all the filters together 

1302 when performing the :meth:`filter_all`. If no logic operation string is provided, the register will provide a join 

1303 condition using either AND (default) or OR. 

1304 """ 

1305 

1306 def __init__(self, data: dict[str, ModelFilter] | None = None, /, **kwargs: Any) -> None: 

1307 """ 

1308 Constructor parameters: 

1309 

1310 :param data: Initial data 

1311 :type data: dict 

1312 :param kwargs: Keywords arguments 

1313 """ 

1314 self._global_filter: dict[str, Any] = {} 

1315 self._logic: str | None = None 

1316 super().__init__(data, **kwargs) 

1317 

1318 @property 

1319 def new_only(self) -> bool: 

1320 """ 

1321 The new only flag. 

1322 

1323 :return: True, if only new items, not already in the output database table must be processed. 

1324 :rtype: bool 

1325 """ 

1326 return cast(bool, self._global_filter.get('new_only', True)) 

1327 

1328 @new_only.setter 

1329 def new_only(self, v: bool) -> None: 

1330 self._global_filter['new_only'] = v 

1331 

1332 def __setitem__(self, key: str, value: ModelFilter) -> None: 

1333 """ 

1334 Set a new value at key. 

1335 

1336 If value is not a Filter, then it will be automatically and silently discarded. 

1337 

1338 :param key: Dictionary key. Normally the name of the model linked to the filter. 

1339 :type key: str 

1340 :param value: The Filter. 

1341 :type value: ModelFilter 

1342 """ 

1343 if not isinstance(value, ModelFilter): 

1344 return 

1345 super().__setitem__(key, value) 

1346 

1347 def bind_all(self, models: list[type[Model]] | dict[str, type[Model]]) -> None: 

1348 """ 

1349 Binds all filters to their models. 

1350 

1351 The ``models`` list or dictionary should contain a valid model for all the ModelFilters in the registry. 

1352 In the case of a dictionary, the key value should be the model name. 

1353 

1354 :param models: List or dictionary of a databank of Models from which the ModelFilter can be bound. 

1355 :type models: list[type(Model)] | dict[str,type(Model)] 

1356 """ 

1357 if isinstance(models, list): 

1358 models = {m.__name__: m for m in models} 

1359 

1360 # check, if we have a filter for each listed models, if not create one using the default configuration. 

1361 for model_name in models.keys(): 

1362 if model_name not in self.data: 

1363 self.data[model_name] = ModelFilter.from_conf(f'{model_name}', conf={}) 

1364 

1365 for k, v in self.data.items(): 

1366 if k in self.data and k in models and not v.is_bound: 1366 ↛ 1365line 1366 didn't jump to line 1365 because the condition on line 1366 was always true

1367 v.bind(models[k]) 

1368 

1369 def filter_all(self, join_with: Literal['AND', 'OR'] = 'AND') -> peewee.Expression | bool: 

1370 """ 

1371 Generates a where clause joining all filters. 

1372 

1373 If a logic expression is present, it will be used to combine named filters. 

1374 Otherwise, fall back to the legacy behaviour using join_with. 

1375 

1376 :raise ValueError: If the parsing of the logical expression fails 

1377 :param join_with: Logical function to join the filters if no logic expression is provided. 

1378 :type join_with: Literal['AND', 'OR'], default: 'AND' 

1379 :return: ModelFilter expression 

1380 :rtype: peewee.Expression 

1381 """ 

1382 # If a logic expression is present at the global level, use it to combine filters 

1383 if self._logic: 

1384 try: 

1385 ast = ExprParser(self._logic).parse() 

1386 except ParseError as e: 

1387 raise ValueError(f'Error parsing global logic for ProcessorFilter: {e}') 

1388 

1389 def eval_ast(node: ExprNode) -> peewee.Expression | bool: 

1390 t = node[0] 

1391 if t == 'NAME': 

1392 named_node = cast(NameNode, node) 

1393 nm = named_node[1] 

1394 if nm not in self.data: 

1395 raise KeyError(f"Unknown filter name '{nm}' in processor logic") 

1396 flt = self.data[nm] 

1397 if not flt.is_bound: 

1398 log.warning(f"ModelFilter '{nm}' is not bound; using True for its expression") 

1399 return True 

1400 return flt.filter() 

1401 elif t == 'NOT': 

1402 not_node = cast(NotNode, node) 

1403 return cast(peewee.Expression, ~eval_ast(not_node[1])) 

1404 elif t == 'AND': 

1405 bin_node = cast(BinaryNode, node) 

1406 return cast(peewee.Expression, cast(object, eval_ast(bin_node[1]) & eval_ast(bin_node[2]))) 

1407 elif t == 'OR': 

1408 bin_node = cast(BinaryNode, node) 

1409 return cast(peewee.Expression, cast(object, eval_ast(bin_node[1]) | eval_ast(bin_node[2]))) 

1410 else: 

1411 raise ValueError(f'Unsupported AST node {t}') 

1412 

1413 try: 

1414 return eval_ast(ast) 

1415 except KeyError as e: 

1416 raise ValueError(f'Error evaluating processor logic: {e}') 

1417 

1418 # Legacy behaviour: combine all filters with join_with (AND/OR) 

1419 filter_list = [flt.filter() for flt in self.data.values() if flt.is_bound] 

1420 if join_with == 'AND': 

1421 return cast(peewee.Expression, cast(object, reduce(operator.and_, filter_list, True))) 

1422 else: 

1423 return cast(peewee.Expression, cast(object, reduce(operator.or_, filter_list, True)))