1"""manage PyTables query interface via Expressions"""
2
3from __future__ import annotations
4
5import ast
6from decimal import (
7 Decimal,
8 InvalidOperation,
9)
10from functools import partial
11from typing import (
12 TYPE_CHECKING,
13 Any,
14 ClassVar,
15 Self,
16 cast,
17)
18
19import numpy as np
20
21from pandas._libs import lib
22from pandas._libs.tslibs import (
23 Timedelta,
24 Timestamp,
25 iNaT,
26)
27from pandas.errors import UndefinedVariableError
28
29from pandas.core.dtypes.common import is_list_like
30
31import pandas.core.common as com
32from pandas.core.computation import (
33 expr,
34 ops,
35 scope as _scope,
36)
37from pandas.core.computation.common import ensure_decoded
38from pandas.core.computation.expr import BaseExprVisitor
39from pandas.core.computation.ops import (
40 ARITH_OPS_SYMS,
41 is_term,
42)
43from pandas.core.construction import extract_array
44from pandas.core.indexes.base import Index
45
46from pandas.io.formats.printing import (
47 pprint_thing,
48 pprint_thing_encoded,
49)
50
51if TYPE_CHECKING:
52 from pandas._typing import (
53 TimeUnit,
54 npt,
55 )
56
57
58class PyTablesScope(_scope.Scope):
59 __slots__ = ("queryables",)
60
61 queryables: dict[str, Any]
62
63 def __init__(
64 self,
65 level: int,
66 global_dict=None,
67 local_dict=None,
68 queryables: dict[str, Any] | None = None,
69 ) -> None:
70 super().__init__(level + 1, global_dict=global_dict, local_dict=local_dict)
71 self.queryables = queryables or {}
72
73
74class Term(ops.Term):
75 env: PyTablesScope
76
77 def __new__(cls, name, env, side=None, encoding=None):
78 if isinstance(name, str):
79 klass = cls
80 else:
81 klass = Constant
82 return object.__new__(klass)
83
84 def __init__(self, name, env: PyTablesScope, side=None, encoding=None) -> None:
85 super().__init__(name, env, side=side, encoding=encoding)
86
87 def _resolve_name(self):
88 # must be a queryables
89 if self.side == "left":
90 # Note: The behavior of __new__ ensures that self.name is a str here
91 if self.name not in self.env.queryables:
92 raise NameError(f"name {self.name!r} is not defined")
93 return self.name
94
95 # resolve the rhs (and allow it to be None)
96 try:
97 return self.env.resolve(self.name, is_local=False)
98 except UndefinedVariableError:
99 return self.name
100
101 # read-only property overwriting read/write property
102 @property # type: ignore[misc]
103 def value(self):
104 return self._value
105
106
107class Constant(Term):
108 def __init__(self, name, env: PyTablesScope, side=None, encoding=None) -> None:
109 assert isinstance(env, PyTablesScope), type(env)
110 super().__init__(name, env, side=side, encoding=encoding)
111
112 def _resolve_name(self):
113 return self._name
114
115
116class BinOp(ops.BinOp):
117 _max_selectors = 31
118
119 op: str
120 queryables: dict[str, Any]
121 condition: str | None
122
123 def __init__(self, op: str, lhs, rhs, queryables: dict[str, Any], encoding) -> None:
124 super().__init__(op, lhs, rhs)
125 self.queryables = queryables
126 self.encoding = encoding
127 self.condition = None
128
129 def _disallow_scalar_only_bool_ops(self) -> None:
130 pass
131
132 def prune(self, klass):
133 if self.op in ARITH_OPS_SYMS:
134 # GH#41100: arithmetic in a where-clause is not supported. PyTables
135 # support is in maintenance mode, so rather than grow the query
136 # grammar we raise with a pointer to a working alternative.
137 raise NotImplementedError(
138 "arithmetic operations are not supported inside an HDFStore "
139 "'where' filter; instead store a precomputed column as a "
140 "data_column and query that, or read the data and apply the "
141 "filter in pandas (e.g. df[df['A'] % 3 == 0])."
142 )
143
144 def pr(left, right):
145 """create and return a new specialized BinOp from myself"""
146 if left is None:
147 return right
148 elif right is None:
149 return left
150
151 k = klass
152 if isinstance(left, ConditionBinOp):
153 if isinstance(right, ConditionBinOp):
154 k = JointConditionBinOp
155 elif isinstance(left, k):
156 return left
157 elif isinstance(right, k):
158 return right
159
160 elif isinstance(left, FilterBinOp):
161 if isinstance(right, FilterBinOp):
162 k = JointFilterBinOp
163 elif isinstance(left, k):
164 return left
165 elif isinstance(right, k):
166 return right
167
168 return k(
169 self.op, left, right, queryables=self.queryables, encoding=self.encoding
170 ).evaluate()
171
172 left, right = self.lhs, self.rhs
173
174 if is_term(left) and is_term(right):
175 res = pr(left, right)
176 elif not is_term(left) and is_term(right):
177 res = pr(left.prune(klass), right)
178 elif is_term(left) and not is_term(right):
179 res = pr(left, right.prune(klass))
180 elif not (is_term(left) or is_term(right)):
181 res = pr(left.prune(klass), right.prune(klass))
182
183 return res
184
185 def conform(self, rhs):
186 """inplace conform rhs"""
187 rhs = rhs.value
188 if rhs is None:
189 # Guarded here rather than in convert_value so both query paths are
190 # covered; bool(None) is False, Timestamp(None) is NaT. GH#64348
191 raise TypeError(f"Cannot compare [{self.lhs.value}] to None")
192 if not is_list_like(rhs):
193 rhs = [rhs]
194 if isinstance(rhs, np.ndarray):
195 rhs = rhs.ravel()
196 return rhs
197
198 @property
199 def is_valid(self) -> bool:
200 """return True if this is a valid field"""
201 return self.lhs.value in self.queryables
202
203 @property
204 def is_in_table(self) -> bool:
205 """
206 return True if this is a valid column name for generation (e.g. an
207 actual column in the table)
208 """
209 return self.queryables.get(self.lhs.value) is not None
210
211 @property
212 def kind(self):
213 """the kind of my field"""
214 return getattr(self.queryables.get(self.lhs.value), "kind", None)
215
216 @property
217 def meta(self):
218 """the meta of my field"""
219 return getattr(self.queryables.get(self.lhs.value), "meta", None)
220
221 @property
222 def metadata(self):
223 """the metadata of my field"""
224 return getattr(self.queryables.get(self.lhs.value), "metadata", None)
225
226 @property
227 def ordered(self):
228 """whether my field is an ordered Categorical (None if unrecorded)"""
229 return getattr(self.queryables.get(self.lhs.value), "ordered", None)
230
231 def generate(self, v) -> str:
232 """create and return the op string for this TermValue"""
233 val = v.tostring(self.encoding)
234 lhs = self.lhs.value
235 if v.truncated:
236 # The requested value fell strictly between `val` and the next
237 # storable one, so no row can equal it. Compare against `val` with
238 # an adjusted operator rather than against the value we truncated to.
239 if self.op == "==":
240 # matches nothing
241 return f"(({lhs} > {val}) & ({lhs} <= {val}))"
242 if self.op == "!=":
243 # matches everything
244 return f"(({lhs} <= {val}) | ({lhs} > {val}))"
245 op = "<=" if self.op in ("<", "<=") else ">"
246 return f"({lhs} {op} {val})"
247 kind = ensure_decoded(self.kind) or ""
248 if self.op in ("<", "<=") and kind.startswith(("datetime", "timedelta")):
249 # datetime64/timedelta64 columns are stored as int64 with NaT as
250 # iNaT, which sorts below every real value, so an unguarded "less
251 # than" would match the NaT rows. ">"/">=" need no such guard.
252 return f"(({lhs} != {iNaT}) & ({lhs} {self.op} {val}))"
253 return f"({lhs} {self.op} {val})"
254
255 def convert_value(self, conv_val) -> TermValue:
256 """
257 convert the expression that is in the term to something that is
258 accepted by pytables
259 """
260
261 def stringify(value):
262 if self.encoding is not None:
263 return pprint_thing_encoded(value, encoding=self.encoding)
264 return pprint_thing(value)
265
266 kind = ensure_decoded(self.kind)
267 meta = ensure_decoded(self.meta)
268 if kind == "datetime" or (kind and kind.startswith("datetime64")):
269 if isinstance(conv_val, (int, float)):
270 conv_val = stringify(conv_val)
271 conv_val = ensure_decoded(conv_val)
272 unit: TimeUnit = "ns"
273 if "[" in kind:
274 unit = cast("TimeUnit", kind.split("[")[-1][:-1])
275 conv_val = Timestamp(conv_val).as_unit(unit)
276 if conv_val.tz is not None:
277 conv_val = conv_val.tz_convert("UTC")
278 return TermValue(conv_val, conv_val._value, kind)
279 elif kind.startswith("timedelta"):
280 unit = "ns"
281 if "[" in kind:
282 unit = cast("TimeUnit", kind.split("[")[-1][:-1])
283 if isinstance(conv_val, str):
284 conv_val = Timedelta(conv_val)
285 elif lib.is_integer(conv_val) or lib.is_float(conv_val):
286 conv_val = Timedelta(conv_val, unit="s")
287 else:
288 conv_val = Timedelta(conv_val)
289 conv_val = conv_val.as_unit(unit)._value
290 return TermValue(int(conv_val), conv_val, kind)
291
292 elif meta == "category":
293 # `ordered` may be a np.bool_, and is None for files written before
294 # the flag was recorded, where we cannot tell.
295 ordered = self.ordered
296 is_unordered = ordered is not None and not ordered
297 if is_unordered and self.op in ["<", "<=", ">", ">="]:
298 # GH#68040 the stored codes are orderable, but the categories
299 # they stand for are not; match the in-memory comparison.
300 raise TypeError(
301 "Unordered Categoricals can only compare equality or not"
302 )
303 metadata = extract_array(self.metadata, extract_numpy=True)
304 result: npt.NDArray[np.intp] | np.intp | int
305 if conv_val not in metadata:
306 # GH#22977 value is not a category; use -2 as a code that
307 # matches no row, unlike -1 which is the code for NaN values.
308 result = -2
309 else:
310 # Find the index of the first match of conv_val in metadata
311 result = np.flatnonzero(metadata == conv_val)[0]
312 return TermValue(result, result, "integer")
313 elif kind == "integer":
314 try:
315 v_dec = Decimal(conv_val)
316 except InvalidOperation:
317 # GH 54186
318 # convert v to float to raise float's ValueError
319 float(conv_val)
320 else:
321 # Round toward -inf rather than to nearest, and flag the loss, so
322 # that generate() can adjust the operator instead of silently
323 # querying for a neighboring integer.
324 floored = v_dec.to_integral_exact(rounding="ROUND_FLOOR")
325 truncated = floored != v_dec
326 conv_val = int(floored)
327 return TermValue(conv_val, conv_val, kind, truncated=truncated)
328 return TermValue(conv_val, conv_val, kind)
329 elif kind == "float":
330 conv_val = float(conv_val)
331 return TermValue(conv_val, conv_val, kind)
332 elif kind == "bool":
333 if isinstance(conv_val, str):
334 conv_val = conv_val.strip().lower() not in [
335 "false",
336 "f",
337 "no",
338 "n",
339 "none",
340 "0",
341 "[]",
342 "{}",
343 "",
344 ]
345 else:
346 conv_val = bool(conv_val)
347 return TermValue(conv_val, conv_val, kind)
348 elif isinstance(conv_val, str):
349 # string quoting
350 return TermValue(conv_val, stringify(conv_val), "string")
351 else:
352 raise TypeError(
353 f"Cannot compare {conv_val} of type {type(conv_val)} to {kind} column"
354 )
355
356 def convert_values(self) -> None:
357 pass
358
359
360class FilterBinOp(BinOp):
361 filter: tuple[Any, Any, Index] | None = None
362
363 def __repr__(self) -> str:
364 if self.filter is None:
365 return "Filter: Not Initialized"
366 return pprint_thing(f"[Filter : [{self.filter[0]}] -> [{self.filter[1]}]")
367
368 def invert(self) -> Self:
369 """invert the filter"""
370 if self.filter is not None:
371 self.filter = (
372 self.filter[0],
373 self.generate_filter_op(invert=True),
374 self.filter[2],
375 )
376 return self
377
378 def format(self):
379 """return the actual filter format"""
380 return [self.filter]
381
382 # error: Signature of "evaluate" incompatible with supertype "BinOp"
383 def evaluate(self) -> Self | None: # type: ignore[override]
384 if not self.is_valid:
385 raise ValueError(f"query term is not valid [{self}]")
386
387 rhs = self.conform(self.rhs)
388 values = list(rhs)
389
390 if self.op not in ["==", "!="]:
391 if not self.is_in_table:
392 raise TypeError(
393 f"passing a filterable condition to a non-table indexer [{self}]"
394 )
395 return None
396
397 if self.is_in_table and len(values) <= self._max_selectors:
398 return None
399 filter_op = self.generate_filter_op()
400 self.filter = (self.lhs.value, filter_op, Index(values))
401 return self
402
403 def generate_filter_op(self, invert: bool = False):
404 if (self.op == "!=" and not invert) or (self.op == "==" and invert):
405 return lambda axis, vals: ~axis.isin(vals)
406 else:
407 return lambda axis, vals: axis.isin(vals)
408
409
410class JointFilterBinOp(FilterBinOp):
411 def format(self):
412 raise NotImplementedError("unable to collapse Joint Filters")
413
414 # error: Signature of "evaluate" incompatible with supertype "BinOp"
415 def evaluate(self) -> Self: # type: ignore[override]
416 return self
417
418
419class ConditionBinOp(BinOp):
420 def __repr__(self) -> str:
421 return pprint_thing(f"[Condition : [{self.condition}]]")
422
423 def invert(self):
424 """invert the condition"""
425 # if self.condition is not None:
426 # self.condition = "~(%s)" % self.condition
427 # return self
428 raise NotImplementedError(
429 "cannot use an invert condition when passing to numexpr"
430 )
431
432 def format(self):
433 """return the actual ne format"""
434 return self.condition
435
436 # error: Signature of "evaluate" incompatible with supertype "BinOp"
437 def evaluate(self) -> Self | None: # type: ignore[override]
438 if not self.is_valid:
439 raise ValueError(f"query term is not valid [{self}]")
440
441 # convert values if we are in the table
442 if not self.is_in_table:
443 return None
444
445 rhs = self.conform(self.rhs)
446 values = [self.convert_value(v) for v in rhs]
447
448 # equality conditions
449 if self.op in ["==", "!="]:
450 # too many values to create the expression?
451 if len(values) <= self._max_selectors:
452 vs = [self.generate(v) for v in values]
453 # De Morgan: "col != [a, b]" means "col != a AND col != b".
454 # OR-joining "!=" comparisons is true for every row.
455 joiner = " & " if self.op == "!=" else " | "
456 self.condition = f"({joiner.join(vs)})"
457
458 # use a filter after reading
459 else:
460 return None
461 else:
462 self.condition = self.generate(values[0])
463
464 return self
465
466
467class JointConditionBinOp(ConditionBinOp):
468 # error: Signature of "evaluate" incompatible with supertype "BinOp"
469 def evaluate(self) -> Self: # type: ignore[override]
470 self.condition = f"({self.lhs.condition} {self.op} {self.rhs.condition})"
471 return self
472
473
474class UnaryOp(ops.UnaryOp):
475 def prune(self, klass):
476 if self.op != "~":
477 raise NotImplementedError("UnaryOp only support invert type ops")
478
479 operand = self.operand
480 operand = operand.prune(klass)
481
482 if operand is not None and (
483 (issubclass(klass, ConditionBinOp) and operand.condition is not None)
484 or (
485 not issubclass(klass, ConditionBinOp)
486 and issubclass(klass, FilterBinOp)
487 and operand.filter is not None
488 )
489 ):
490 return operand.invert()
491 return None
492
493
494class PyTablesExprVisitor(BaseExprVisitor):
495 const_type: ClassVar[type[ops.Term]] = Constant
496 term_type: ClassVar[type[Term]] = Term
497
498 def __init__(self, env, engine, parser, **kwargs) -> None:
499 super().__init__(env, engine, parser)
500 for bin_op in self.binary_ops:
501 bin_node = self.binary_op_nodes_map[bin_op]
502 setattr(
503 self,
504 f"visit_{bin_node}",
505 lambda node, bin_op=bin_op: partial(BinOp, bin_op, **kwargs),
506 )
507
508 def visit_UnaryOp(self, node, **kwargs) -> ops.Term | UnaryOp | None:
509 if isinstance(node.op, (ast.Not, ast.Invert)):
510 return UnaryOp("~", self.visit(node.operand))
511 elif isinstance(node.op, ast.USub):
512 return self.const_type(-self.visit(node.operand).value, self.env)
513 elif isinstance(node.op, ast.UAdd):
514 raise NotImplementedError("Unary addition not supported")
515 # TODO: return None might never be reached
516 return None
517
518 def visit_Index(self, node, **kwargs):
519 return self.visit(node.value).value
520
521 def visit_Assign(self, node, **kwargs):
522 cmpr = ast.Compare(
523 ops=[ast.Eq()], left=node.targets[0], comparators=[node.value]
524 )
525 return self.visit(cmpr)
526
527 def visit_Subscript(self, node, **kwargs) -> ops.Term:
528 # only allow simple subscripts
529
530 value = self.visit(node.value)
531 slobj = self.visit(node.slice)
532 try:
533 value = value.value
534 except AttributeError:
535 pass
536
537 if isinstance(slobj, Term):
538 # In py39 np.ndarray lookups with Term containing int raise
539 slobj = slobj.value
540
541 try:
542 return self.const_type(value[slobj], self.env)
543 except TypeError as err:
544 raise ValueError(f"cannot subscript {value!r} with {slobj!r}") from err
545
546 def visit_Attribute(self, node, **kwargs):
547 attr = node.attr
548 value = node.value
549
550 ctx = type(node.ctx)
551 if ctx == ast.Load:
552 # resolve the value
553 resolved = self.visit(value)
554
555 # try to get the value to see if we are another expression
556 try:
557 resolved = resolved.value
558 except AttributeError:
559 pass
560
561 try:
562 return self.term_type(getattr(resolved, attr), self.env)
563 except AttributeError:
564 # something like datetime.datetime where scope is overridden
565 if isinstance(value, ast.Name) and value.id == attr:
566 return resolved
567
568 raise ValueError(f"Invalid Attribute context {ctx.__name__}")
569
570 def translate_In(self, op):
571 return ast.Eq() if isinstance(op, ast.In) else op
572
573 def _rewrite_membership_op(self, node, left, right):
574 return self.visit(node.op), node.op, left, right
575
576
577def _validate_where(w):
578 """
579 Validate that the where statement is of the right type.
580
581 The type may either be String, Expr, or list-like of Exprs.
582
583 Parameters
584 ----------
585 w : String term expression, Expr, or list-like of Exprs.
586
587 Returns
588 -------
589 where : The original where clause if the check was successful.
590
591 Raises
592 ------
593 TypeError : An invalid data type was passed in for w (e.g. dict).
594 """
595 if not (isinstance(w, (PyTablesExpr, str)) or is_list_like(w)):
596 raise TypeError(
597 "where must be passed as a string, PyTablesExpr, "
598 "or list-like of PyTablesExpr"
599 )
600
601 return w
602
603
604class PyTablesExpr(expr.Expr):
605 """
606 Hold a pytables-like expression, comprised of possibly multiple 'terms'.
607
608 Parameters
609 ----------
610 where : string term expression, PyTablesExpr, or list-like of PyTablesExprs
611 queryables : a "kinds" map (dict of column name -> kind), or None if column
612 is non-indexable
613 encoding : an encoding that will encode the query terms
614
615 Returns
616 -------
617 a PyTablesExpr object
618
619 Examples
620 --------
621 'index>=date'
622 "columns=['A', 'D']"
623 'columns=A'
624 'columns==A'
625 "~(columns=['A','B'])"
626 'index>df.index[3] & string="bar"'
627 '(index>df.index[3] & index<=df.index[6]) | string="bar"'
628 "ts>=Timestamp('2012-02-01')"
629 "major_axis>=20130101"
630 """
631
632 _visitor: PyTablesExprVisitor | None
633 env: PyTablesScope
634 expr: str
635
636 def __init__(
637 self,
638 where,
639 queryables: dict[str, Any] | None = None,
640 encoding=None,
641 scope_level: int = 0,
642 ) -> None:
643 where = _validate_where(where)
644
645 self.encoding = encoding
646 self.condition = None
647 self.filter = None
648 self.terms = None
649 self._visitor = None
650
651 # capture the environment if needed
652 local_dict: _scope.DeepChainMap[Any, Any] | None = None
653
654 if isinstance(where, PyTablesExpr):
655 local_dict = where.env.scope
656 _where = where.expr
657
658 elif is_list_like(where):
659 where = list(where)
660 for idx, w in enumerate(where):
661 if isinstance(w, PyTablesExpr):
662 local_dict = w.env.scope
663 else:
664 where[idx] = _validate_where(w)
665 _where = " & ".join([f"({w})" for w in com.flatten(where)])
666 else:
667 # _validate_where ensures we otherwise have a string
668 _where = where
669
670 self.expr = _where
671 self.env = PyTablesScope(scope_level + 1, local_dict=local_dict)
672
673 if queryables is not None and isinstance(self.expr, str):
674 self.env.queryables.update(queryables)
675 self._visitor = PyTablesExprVisitor(
676 self.env,
677 queryables=queryables,
678 parser="pytables",
679 engine="pytables",
680 encoding=encoding,
681 )
682 self.terms = self.parse()
683
684 def __repr__(self) -> str:
685 if self.terms is not None:
686 return pprint_thing(self.terms)
687 return pprint_thing(self.expr)
688
689 def evaluate(self):
690 """create and return the numexpr condition and filter"""
691 try:
692 self.condition = self.terms.prune(ConditionBinOp)
693 except AttributeError as err:
694 raise ValueError(
695 f"cannot process expression [{self.expr}], [{self}] "
696 "is not a valid condition"
697 ) from err
698 try:
699 self.filter = self.terms.prune(FilterBinOp)
700 except AttributeError as err:
701 raise ValueError(
702 f"cannot process expression [{self.expr}], [{self}] "
703 "is not a valid filter"
704 ) from err
705
706 return self.condition, self.filter
707
708
709class TermValue:
710 """hold a term value that we use to construct a condition/filter"""
711
712 def __init__(self, value, converted, kind: str, truncated: bool = False) -> None:
713 assert isinstance(kind, str), kind
714 self.value = value
715 self.converted = converted
716 self.kind = kind
717 # whether `converted` lost information relative to the value the user
718 # asked for; see BinOp.generate
719 self.truncated = truncated
720
721 def tostring(self, encoding) -> str:
722 """quote the string if not encoded else encode and return"""
723 if self.kind == "string":
724 if encoding is not None:
725 return str(self.converted)
726 return f'"{self.converted}"'
727 elif self.kind == "float":
728 return repr(self.converted)
729 return str(self.converted)
730
731
732def maybe_expression(s) -> bool:
733 """loose checking if s is a pytables-acceptable expression"""
734 if not isinstance(s, str):
735 return False
736 operations = PyTablesExprVisitor.binary_ops + PyTablesExprVisitor.unary_ops + ("=",)
737
738 # make sure we have an op at least
739 return any(op in s for op in operations)