Coverage for src/dataknobs_data/query.py: 41%
234 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 19:59 -0500
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 19:59 -0500
1from dataclasses import dataclass, field
2from enum import Enum
3from typing import Any, Dict, List, Union
6class Operator(Enum):
7 """Query operators for filtering."""
9 EQ = "=" # Equal
10 NEQ = "!=" # Not equal
11 GT = ">" # Greater than
12 GTE = ">=" # Greater than or equal
13 LT = "<" # Less than
14 LTE = "<=" # Less than or equal
15 IN = "in" # In list
16 NOT_IN = "not_in" # Not in list
17 LIKE = "like" # String pattern matching (SQL LIKE)
18 REGEX = "regex" # Regular expression matching
19 EXISTS = "exists" # Field exists
20 NOT_EXISTS = "not_exists" # Field does not exist
21 BETWEEN = "between" # Value between two bounds (inclusive)
22 NOT_BETWEEN = "not_between" # Value not between two bounds
25class SortOrder(Enum):
26 """Sort order for query results."""
28 ASC = "asc"
29 DESC = "desc"
32@dataclass
33class Filter:
34 """Represents a filter condition."""
36 field: str
37 operator: Operator
38 value: Any = None
40 def matches(self, record_value: Any) -> bool:
41 """Check if a record value matches this filter.
43 Supports type-aware comparisons for ranges and special handling
44 for datetime/date objects.
45 """
46 if self.operator == Operator.EXISTS:
47 return record_value is not None
48 elif self.operator == Operator.NOT_EXISTS:
49 return record_value is None
50 elif record_value is None:
51 return False
53 if self.operator == Operator.EQ:
54 return record_value == self.value
55 elif self.operator == Operator.NEQ:
56 return record_value != self.value
57 elif self.operator == Operator.GT:
58 return self._compare_values(record_value, self.value, lambda a, b: a > b)
59 elif self.operator == Operator.GTE:
60 return self._compare_values(record_value, self.value, lambda a, b: a >= b)
61 elif self.operator == Operator.LT:
62 return self._compare_values(record_value, self.value, lambda a, b: a < b)
63 elif self.operator == Operator.LTE:
64 return self._compare_values(record_value, self.value, lambda a, b: a <= b)
65 elif self.operator == Operator.IN:
66 return record_value in self.value
67 elif self.operator == Operator.NOT_IN:
68 return record_value not in self.value
69 elif self.operator == Operator.BETWEEN:
70 if not isinstance(self.value, (list, tuple)) or len(self.value) != 2:
71 return False
72 lower, upper = self.value
73 return self._compare_values(record_value, lower, lambda a, b: a >= b) and \
74 self._compare_values(record_value, upper, lambda a, b: a <= b)
75 elif self.operator == Operator.NOT_BETWEEN:
76 if not isinstance(self.value, (list, tuple)) or len(self.value) != 2:
77 return True
78 lower, upper = self.value
79 return not (self._compare_values(record_value, lower, lambda a, b: a >= b) and \
80 self._compare_values(record_value, upper, lambda a, b: a <= b))
81 elif self.operator == Operator.LIKE:
82 if not isinstance(record_value, str):
83 return False
84 import re
86 pattern = self.value.replace("%", ".*").replace("_", ".")
87 return bool(re.match(f"^{pattern}$", record_value))
88 elif self.operator == Operator.REGEX:
89 if not isinstance(record_value, str):
90 return False
91 import re
93 return bool(re.search(self.value, record_value))
95 return False
97 def _compare_values(self, a: Any, b: Any, comparator) -> bool:
98 """Compare two values with type awareness.
100 Handles special cases:
101 - Datetime strings are parsed for comparison
102 - Mixed numeric types are converted appropriately
103 - String comparisons are case-sensitive
104 """
105 from datetime import datetime, date
107 # Handle datetime/date comparisons
108 if isinstance(a, str) and isinstance(b, (datetime, date)):
109 try:
110 a = datetime.fromisoformat(a.replace("Z", "+00:00"))
111 except (ValueError, AttributeError):
112 return False
113 elif isinstance(b, str) and isinstance(a, (datetime, date)):
114 try:
115 b = datetime.fromisoformat(b.replace("Z", "+00:00"))
116 except (ValueError, AttributeError):
117 return False
118 elif isinstance(a, str) and isinstance(b, str):
119 # Check if both look like dates
120 if "T" in a or "-" in a:
121 try:
122 a = datetime.fromisoformat(a.replace("Z", "+00:00"))
123 b = datetime.fromisoformat(b.replace("Z", "+00:00"))
124 except (ValueError, AttributeError):
125 pass # Keep as strings
127 # Handle numeric comparisons
128 if isinstance(a, (int, float)) and isinstance(b, (int, float)):
129 return comparator(a, b)
131 # Try direct comparison
132 try:
133 return comparator(a, b)
134 except TypeError:
135 # Types not comparable
136 return False
138 def to_dict(self) -> Dict[str, Any]:
139 """Convert filter to dictionary representation."""
140 return {"field": self.field, "operator": self.operator.value, "value": self.value}
142 @classmethod
143 def from_dict(cls, data: Dict[str, Any]) -> "Filter":
144 """Create filter from dictionary representation."""
145 return cls(
146 field=data["field"], operator=Operator(data["operator"]), value=data.get("value")
147 )
150@dataclass
151class SortSpec:
152 """Represents a sort specification."""
154 field: str
155 order: SortOrder = SortOrder.ASC
157 def to_dict(self) -> Dict[str, str]:
158 """Convert sort spec to dictionary representation."""
159 return {"field": self.field, "order": self.order.value}
161 @classmethod
162 def from_dict(cls, data: Dict[str, str]) -> "SortSpec":
163 """Create sort spec from dictionary representation."""
164 return cls(field=data["field"], order=SortOrder(data.get("order", "asc")))
167@dataclass
168class Query:
169 """Represents a database query with filters, sorting, and pagination."""
171 filters: List[Filter] = field(default_factory=list)
172 sort_specs: List[SortSpec] = field(default_factory=list)
173 limit_value: int | None = None
174 offset_value: int | None = None
175 fields: List[str] | None = None # Field projection
177 @property
178 def sort_property(self) -> List[SortSpec]:
179 """Get sort specifications (backward compatibility)."""
180 return self.sort_specs
182 @property
183 def limit_property(self) -> int | None:
184 """Get limit value (backward compatibility)."""
185 return self.limit_value
187 @property
188 def offset_property(self) -> int | None:
189 """Get offset value (backward compatibility)."""
190 return self.offset_value
192 def filter(self, field: str, operator: Union[str, Operator], value: Any = None) -> "Query":
193 """Add a filter to the query (fluent interface).
195 Args:
196 field: The field name to filter on
197 operator: The operator (string or Operator enum)
198 value: The value to compare against
200 Returns:
201 Self for method chaining
202 """
203 if isinstance(operator, str):
204 op_map = {
205 "=": Operator.EQ,
206 "==": Operator.EQ,
207 "!=": Operator.NEQ,
208 ">": Operator.GT,
209 ">=": Operator.GTE,
210 "<": Operator.LT,
211 "<=": Operator.LTE,
212 "in": Operator.IN,
213 "IN": Operator.IN,
214 "not_in": Operator.NOT_IN,
215 "NOT IN": Operator.NOT_IN,
216 "like": Operator.LIKE,
217 "LIKE": Operator.LIKE,
218 "regex": Operator.REGEX,
219 "exists": Operator.EXISTS,
220 "not_exists": Operator.NOT_EXISTS,
221 "between": Operator.BETWEEN,
222 "BETWEEN": Operator.BETWEEN,
223 "not_between": Operator.NOT_BETWEEN,
224 "NOT BETWEEN": Operator.NOT_BETWEEN,
225 }
226 operator = op_map.get(operator, Operator.EQ)
228 self.filters.append(Filter(field=field, operator=operator, value=value))
229 return self
231 def sort_by(self, field: str, order: Union[str, SortOrder] = "asc") -> "Query":
232 """Add a sort specification to the query (fluent interface).
234 Args:
235 field: The field name to sort by
236 order: The sort order ("asc", "desc", or SortOrder enum)
238 Returns:
239 Self for method chaining
240 """
241 if isinstance(order, str):
242 order = SortOrder.ASC if order.lower() == "asc" else SortOrder.DESC
244 self.sort_specs.append(SortSpec(field=field, order=order))
245 return self
247 def sort(self, field: str, order: Union[str, SortOrder] = "asc") -> "Query":
248 """Add sorting (fluent interface)."""
249 return self.sort_by(field, order)
251 def set_limit(self, limit: int) -> "Query":
252 """Set the result limit (fluent interface).
254 Args:
255 limit: Maximum number of results
257 Returns:
258 Self for method chaining
259 """
260 self.limit_value = limit
261 return self
263 def limit(self, value: int) -> "Query":
264 """Set limit (fluent interface)."""
265 return self.set_limit(value)
267 def set_offset(self, offset: int) -> "Query":
268 """Set the result offset (fluent interface).
270 Args:
271 offset: Number of results to skip
273 Returns:
274 Self for method chaining
275 """
276 self.offset_value = offset
277 return self
279 def offset(self, value: int) -> "Query":
280 """Set offset (fluent interface)."""
281 return self.set_offset(value)
283 def select(self, *fields: str) -> "Query":
284 """Set field projection (fluent interface).
286 Args:
287 fields: Field names to include in results
289 Returns:
290 Self for method chaining
291 """
292 self.fields = list(fields) if fields else None
293 return self
295 def clear_filters(self) -> "Query":
296 """Clear all filters (fluent interface)."""
297 self.filters = []
298 return self
300 def clear_sort(self) -> "Query":
301 """Clear all sort specifications (fluent interface)."""
302 self.sort_specs = []
303 return self
305 def to_dict(self) -> Dict[str, Any]:
306 """Convert query to dictionary representation."""
307 result = {
308 "filters": [f.to_dict() for f in self.filters],
309 "sort": [s.to_dict() for s in self.sort_specs],
310 }
311 if self.limit_value is not None:
312 result["limit"] = self.limit_value
313 if self.offset_value is not None:
314 result["offset"] = self.offset_value
315 if self.fields is not None:
316 result["fields"] = self.fields
317 return result
319 @classmethod
320 def from_dict(cls, data: Dict[str, Any]) -> "Query":
321 """Create query from dictionary representation."""
322 query = cls()
324 for filter_data in data.get("filters", []):
325 query.filters.append(Filter.from_dict(filter_data))
327 for sort_data in data.get("sort", []):
328 query.sort_specs.append(SortSpec.from_dict(sort_data))
330 query.limit_value = data.get("limit")
331 query.offset_value = data.get("offset")
332 query.fields = data.get("fields")
334 return query
336 def copy(self) -> "Query":
337 """Create a copy of the query."""
338 import copy
340 return Query(
341 filters=copy.deepcopy(self.filters),
342 sort_specs=copy.deepcopy(self.sort_specs),
343 limit_value=self.limit_value,
344 offset_value=self.offset_value,
345 fields=self.fields.copy() if self.fields else None,
346 )
348 def or_(self, *filters: Union[Filter, "Query"]) -> "ComplexQuery":
349 """Create a ComplexQuery with OR logic.
351 The current query's filters become an AND group, combined with OR conditions.
352 Example: Query with filters [A, B] calling or_(C, D) creates: (A AND B) AND (C OR D)
354 Args:
355 filters: Filter objects or Query objects to OR together
357 Returns:
358 ComplexQuery with OR logic
359 """
360 from .query_logic import ComplexQuery, FilterCondition, LogicCondition, LogicOperator
362 # Build OR conditions from the arguments
363 or_conditions = []
364 for item in filters:
365 if isinstance(item, Filter):
366 or_conditions.append(FilterCondition(item))
367 elif isinstance(item, Query):
368 if len(item.filters) == 1:
369 or_conditions.append(FilterCondition(item.filters[0]))
370 elif item.filters:
371 and_cond = LogicCondition(operator=LogicOperator.AND)
372 for f in item.filters:
373 and_cond.conditions.append(FilterCondition(f))
374 or_conditions.append(and_cond)
376 # Create the OR condition group
377 or_group = None
378 if or_conditions:
379 if len(or_conditions) == 1:
380 or_group = or_conditions[0]
381 else:
382 or_group = LogicCondition(
383 operator=LogicOperator.OR,
384 conditions=or_conditions
385 )
387 # Combine with existing filters (if any) using AND
388 if self.filters:
389 # Create AND condition for existing filters
390 if len(self.filters) == 1:
391 existing = FilterCondition(self.filters[0])
392 else:
393 existing = LogicCondition(operator=LogicOperator.AND)
394 for f in self.filters:
395 existing.conditions.append(FilterCondition(f))
397 # Combine existing AND new OR group with AND
398 if or_group:
399 root_condition = LogicCondition(
400 operator=LogicOperator.AND,
401 conditions=[existing, or_group]
402 )
403 else:
404 root_condition = existing
405 else:
406 # No existing filters, just use OR group
407 root_condition = or_group
409 return ComplexQuery(
410 condition=root_condition,
411 sort_specs=self.sort_specs.copy(),
412 limit_value=self.limit_value,
413 offset_value=self.offset_value,
414 fields=self.fields.copy() if self.fields else None
415 )
417 def and_(self, *filters: Union[Filter, "Query"]) -> "Query":
418 """Add more filters with AND logic (convenience method).
420 Args:
421 filters: Filter objects or Query objects to AND together
423 Returns:
424 Self for chaining
425 """
426 for item in filters:
427 if isinstance(item, Filter):
428 self.filters.append(item)
429 elif isinstance(item, Query):
430 self.filters.extend(item.filters)
431 return self
433 def not_(self, filter: Filter) -> "ComplexQuery":
434 """Create a ComplexQuery with NOT logic.
436 Args:
437 filter: Filter to negate
439 Returns:
440 ComplexQuery with NOT logic
441 """
442 from .query_logic import ComplexQuery, FilterCondition, LogicCondition, LogicOperator
444 # Current filters as AND
445 conditions = []
446 if self.filters:
447 if len(self.filters) == 1:
448 conditions.append(FilterCondition(self.filters[0]))
449 else:
450 and_cond = LogicCondition(operator=LogicOperator.AND)
451 for f in self.filters:
452 and_cond.conditions.append(FilterCondition(f))
453 conditions.append(and_cond)
455 # Add NOT condition
456 not_cond = LogicCondition(
457 operator=LogicOperator.NOT,
458 conditions=[FilterCondition(filter)]
459 )
460 conditions.append(not_cond)
462 # Create root condition
463 if len(conditions) == 1:
464 root_condition = conditions[0]
465 else:
466 root_condition = LogicCondition(
467 operator=LogicOperator.AND,
468 conditions=conditions
469 )
471 return ComplexQuery(
472 condition=root_condition,
473 sort_specs=self.sort_specs.copy(),
474 limit_value=self.limit_value,
475 offset_value=self.offset_value,
476 fields=self.fields.copy() if self.fields else None
477 )