Back to home page

EIC code displayed by LXR

 
 

    


Warning, file /panda-server/pandaserver/asyncprocess/db_read/request_shape.py was not indexed or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).

0001 """
0002 Shape and cost checks of the queries in an async db_read request.
0003 
0004 A request is {"queries": [query, ...]}. A query reads one or more tables of the allowlist
0005 (tables.py) with the same conditions, one statement per table, and has these fields:
0006 
0007     tables: names of the tables
0008     where: conditions, each [column, op] or [column, op, value], all of which must hold.
0009         op is one of =, !=, <, <=, >, >=, in, is null, is not null and prefix. The value of
0010         "in" is a list, or {"from_query": i, "column": c} for the values of column c in the
0011         answer to the earlier query i of the same request. "prefix" matches the start of a
0012         text, taken literally. A time is written in ISO 8601, and read as UTC when it has no
0013         offset; {"__datetime__": ...}, the way the API writes times, is read too
0014     columns: columns to return. By default every column but the large objects
0015     group_by: columns, or day(column) and hour(column) for a time column
0016     aggregates: [function, column], the function being count, min or max; ["count", "*"]
0017         counts rows
0018     order_by: names the query returns, each a name or [name, "asc" | "desc"]
0019     limit: most rows to return from each table
0020 
0021 Values must have the type of their column: a number for a number column, a text for a text
0022 column, a time for a time column. The database would convert the others, but converting a
0023 column rather than the value keeps it from using its index.
0024 
0025 The checks are two:
0026     parse_query: the shape, without the database
0027     check_query: against the data dictionary, that the columns exist and that the cost is
0028         within the budget. The cost is the index lookups: a lookup of a LOCAL index probes
0029         each partition its window touches, so it costs keys x partitions touched; a GLOBAL
0030         index, or a table that is not partitioned, costs keys. The partitions of a time key
0031         made ahead, which start after the present on the database's clock, are not counted:
0032         the time keys of these tables say when a row was written, so no row is in them yet. A
0033         query is read only through an index, so one with no = or in on the first column of
0034         an index of a table is refused for it, however small the table
0035 
0036 A query that fails either is rejected with the name of the rule it broke, and the other
0037 queries of the request still run.
0038 """
0039 
0040 import datetime
0041 import re
0042 from collections.abc import Sequence
0043 from dataclasses import dataclass
0044 from typing import Any
0045 
0046 from pandaserver.asyncprocess.db_read.schema_info import (
0047     KIND_LOB,
0048     KIND_NUMBER,
0049     KIND_OTHER,
0050     KIND_TEXT,
0051     KIND_TIME,
0052     Bound,
0053     PartitionInfo,
0054     TableInfo,
0055 )
0056 from pandaserver.asyncprocess.db_read.tables import AllowedTable, get_allowed_table
0057 
0058 # most queries in one request
0059 MAX_QUERIES = 10
0060 # most rows a query returns from each table, and the default
0061 MAX_ROWS = 1000
0062 # most values in one "in", which is also the most Oracle takes in one IN list
0063 MAX_KEYS = 1000
0064 # most conditions in one query
0065 MAX_CONDITIONS = 20
0066 
0067 OPS = ("=", "!=", "<", "<=", ">", ">=", "in", "is null", "is not null", "prefix")
0068 NULL_OPS = ("is null", "is not null")
0069 AGGREGATE_FUNCTIONS = ("count", "min", "max")
0070 TRUNCATIONS = ("day", "hour")
0071 
0072 _QUERY_FIELDS = ("tables", "where", "columns", "group_by", "aggregates", "order_by", "limit")
0073 _NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
0074 _TRUNCATED_NAME = re.compile(r"^(day|hour)\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)$", re.IGNORECASE)
0075 
0076 
0077 class QueryRejected(Exception):
0078     """A query broke a rule. The rule is a fixed name a caller can test, the message says how."""
0079 
0080     def __init__(self, rule: str, message: str, **detail: Any) -> None:
0081         super().__init__(message)
0082         self.rule = rule
0083         self.message = message
0084         self.detail = detail
0085 
0086 
0087 @dataclass(frozen=True)
0088 class FromQuery:
0089     # position of the earlier query in the request
0090     query: int
0091     # lower-case column of its answer
0092     column: str
0093 
0094 
0095 @dataclass(frozen=True)
0096 class Condition:
0097     # lower-case column
0098     column: str
0099     # one of OPS
0100     op: str
0101     # None for the null ops, a list or FromQuery for "in", else one value
0102     value: Any
0103 
0104 
0105 @dataclass(frozen=True)
0106 class GroupKey:
0107     column: str
0108     # None, or one of TRUNCATIONS
0109     truncation: str | None
0110 
0111     @property
0112     def name(self) -> str:
0113         return f"{self.truncation}({self.column})" if self.truncation else self.column
0114 
0115 
0116 @dataclass(frozen=True)
0117 class Aggregate:
0118     # one of AGGREGATE_FUNCTIONS
0119     function: str
0120     # None for count(*)
0121     column: str | None
0122 
0123     @property
0124     def name(self) -> str:
0125         return f"{self.function}({self.column or '*'})"
0126 
0127 
0128 @dataclass(frozen=True)
0129 class OrderKey:
0130     # a name the query returns
0131     name: str
0132     descending: bool
0133 
0134 
0135 @dataclass(frozen=True)
0136 class Query:
0137     tables: tuple[AllowedTable, ...]
0138     where: tuple[Condition, ...]
0139     # None for the default
0140     columns: tuple[str, ...] | None
0141     group_by: tuple[GroupKey, ...]
0142     aggregates: tuple[Aggregate, ...]
0143     order_by: tuple[OrderKey, ...]
0144     limit: int
0145 
0146     @property
0147     def grouped(self) -> bool:
0148         return bool(self.group_by or self.aggregates)
0149 
0150     def from_queries(self) -> list[FromQuery]:
0151         return [condition.value for condition in self.where if isinstance(condition.value, FromQuery)]
0152 
0153 
0154 @dataclass(frozen=True)
0155 class TablePlan:
0156     table: AllowedTable
0157     info: TableInfo
0158     # the conditions of the query, with the values read for the columns of this table
0159     where: tuple[Condition, ...]
0160     # index lookups the statement costs
0161     probes: int
0162 
0163 
0164 def _name(value: Any, what: str) -> str:
0165     if not isinstance(value, str) or not _NAME.match(value):
0166         raise QueryRejected("bad_shape", f"{what} must be a name of letters, digits and _, got {value!r}")
0167     return value.lower()
0168 
0169 
0170 def _list(value: Any, what: str) -> list[Any]:
0171     if not isinstance(value, list):
0172         raise QueryRejected("bad_shape", f"{what} must be a list")
0173     return value
0174 
0175 
0176 def _scalar(value: Any, what: str) -> Any:
0177     # a time as the API writes it
0178     if isinstance(value, dict) and set(value) == {"__datetime__"}:
0179         value = value["__datetime__"]
0180     # bool is an int to Python, and no column takes one
0181     if isinstance(value, bool) or not isinstance(value, (str, int, float)):
0182         raise QueryRejected("bad_value", f"{what} must be a number or a text, got {value!r}")
0183     return value
0184 
0185 
0186 def _parse_condition(raw: Any, position: int) -> Condition:
0187     if not isinstance(raw, list) or len(raw) not in (2, 3):
0188         raise QueryRejected("bad_shape", f"a condition must be [column, op] or [column, op, value], got {raw!r}")
0189     column = _name(raw[0], "the column of a condition")
0190     op = " ".join(str(raw[1]).lower().split()) if isinstance(raw[1], str) else None
0191     if op not in OPS:
0192         raise QueryRejected("bad_shape", f"op must be one of {', '.join(OPS)}, got {raw[1]!r}")
0193     if op in NULL_OPS:
0194         if len(raw) != 2:
0195             raise QueryRejected("bad_shape", f"{op} takes no value")
0196         return Condition(column, op, None)
0197     if len(raw) != 3:
0198         raise QueryRejected("bad_shape", f"{op} takes a value")
0199     value = raw[2]
0200     if op != "in":
0201         if isinstance(value, dict) and "from_query" in value:
0202             raise QueryRejected("bad_shape", f"values from a query go with in, not with {op}")
0203         value = _scalar(value, f"the value of {column} {op}")
0204         if op == "prefix" and (not isinstance(value, str) or not value):
0205             raise QueryRejected("bad_value", f"the value of {column} prefix must be a text")
0206         return Condition(column, op, value)
0207     if isinstance(value, dict) and "from_query" in value:
0208         if set(value) != {"from_query", "column"}:
0209             raise QueryRejected("bad_shape", 'a value from a query must be {"from_query": i, "column": c}')
0210         index = value["from_query"]
0211         if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < position:
0212             raise QueryRejected("bad_shape", f"from_query must be the position of an earlier query, got {index!r}")
0213         return Condition(column, op, FromQuery(index, _name(value["column"], "the column of from_query")))
0214     values = _list(value, f"the value of {column} in")
0215     if not values:
0216         raise QueryRejected("bad_value", f"the value of {column} in has no values")
0217     if len(values) > MAX_KEYS:
0218         raise QueryRejected("too_many_keys", f"{column} in has {len(values)} values", keys=len(values), max_keys=MAX_KEYS)
0219     return Condition(column, op, [_scalar(item, f"a value of {column} in") for item in values])
0220 
0221 
0222 def _parse_group_key(raw: Any) -> GroupKey:
0223     if isinstance(raw, str):
0224         match = _TRUNCATED_NAME.match(raw)
0225         if match:
0226             return GroupKey(match.group(2).lower(), match.group(1).lower())
0227     return GroupKey(_name(raw, "a group_by entry"), None)
0228 
0229 
0230 def _parse_aggregate(raw: Any) -> Aggregate:
0231     if not isinstance(raw, list) or len(raw) != 2:
0232         raise QueryRejected("bad_shape", f"an aggregate must be [function, column], got {raw!r}")
0233     function = raw[0].lower() if isinstance(raw[0], str) else None
0234     if function not in AGGREGATE_FUNCTIONS:
0235         raise QueryRejected("bad_shape", f"the function of an aggregate must be one of {', '.join(AGGREGATE_FUNCTIONS)}, got {raw[0]!r}")
0236     if raw[1] == "*":
0237         if function != "count":
0238             raise QueryRejected("bad_shape", f"{function} needs a column")
0239         return Aggregate(function, None)
0240     return Aggregate(function, _name(raw[1], "the column of an aggregate"))
0241 
0242 
0243 def _parse_order_key(raw: Any) -> OrderKey:
0244     direction = "asc"
0245     if isinstance(raw, list):
0246         if len(raw) != 2 or not isinstance(raw[1], str) or raw[1].lower() not in ("asc", "desc"):
0247             raise QueryRejected("bad_shape", f'an order_by entry must be a name or [name, "asc" | "desc"], got {raw!r}')
0248         raw, direction = raw[0], raw[1].lower()
0249     if not isinstance(raw, str):
0250         raise QueryRejected("bad_shape", f"an order_by entry must name what the query returns, got {raw!r}")
0251     name = raw.lower().replace(" ", "")
0252     return OrderKey(name, direction == "desc")
0253 
0254 
0255 def _unique(names: Sequence[str], what: str) -> None:
0256     if len(set(names)) != len(names):
0257         raise QueryRejected("bad_shape", f"{what} names something twice")
0258 
0259 
0260 def parse_query(raw: Any, position: int) -> Query:
0261     """
0262     Check the shape of a query, without the database.
0263 
0264     Args:
0265         raw(Any): the query as the request gives it
0266         position(int): its position in the request, which an "in" from an earlier query refers below
0267 
0268     Returns:
0269         Query: the query, names in lower case
0270 
0271     Raises:
0272         QueryRejected: when the query is not well formed
0273     """
0274     if not isinstance(raw, dict):
0275         raise QueryRejected("bad_shape", "a query must be an object")
0276     unknown = sorted(set(raw) - set(_QUERY_FIELDS))
0277     if unknown:
0278         raise QueryRejected("bad_shape", f"a query has no field {', '.join(map(str, unknown))}")
0279     # tables
0280     table_names = _list(raw.get("tables"), "tables")
0281     if not table_names:
0282         raise QueryRejected("bad_shape", "tables names no table")
0283     tables: list[AllowedTable] = []
0284     for name in table_names:
0285         table = get_allowed_table(name) if isinstance(name, str) else None
0286         if table is None:
0287             raise QueryRejected("unknown_table", f"{name!r} is not a table a request may read", table=name)
0288         tables.append(table)
0289     _unique([table.name for table in tables], "tables")
0290     # conditions
0291     raw_where = _list(raw.get("where", []), "where")
0292     if len(raw_where) > MAX_CONDITIONS:
0293         raise QueryRejected("bad_shape", f"a query has at most {MAX_CONDITIONS} conditions")
0294     where = tuple(_parse_condition(item, position) for item in raw_where)
0295     # what to return
0296     columns = None
0297     if "columns" in raw:
0298         columns = tuple(_name(item, "a column") for item in _list(raw["columns"], "columns"))
0299         if not columns:
0300             raise QueryRejected("bad_shape", "columns names no column")
0301         _unique(columns, "columns")
0302     group_by = tuple(_parse_group_key(item) for item in _list(raw.get("group_by", []), "group_by"))
0303     aggregates = tuple(_parse_aggregate(item) for item in _list(raw.get("aggregates", []), "aggregates"))
0304     _unique([key.name for key in group_by] + [aggregate.name for aggregate in aggregates], "group_by and aggregates")
0305     if columns is not None and (group_by or aggregates):
0306         raise QueryRejected("bad_shape", "a query with group_by or aggregates returns those, so it takes no columns")
0307     order_by = tuple(_parse_order_key(item) for item in _list(raw.get("order_by", []), "order_by"))
0308     _unique([key.name for key in order_by], "order_by")
0309     returned: Sequence[str] | None = columns
0310     if group_by or aggregates:
0311         returned = [key.name for key in group_by] + [aggregate.name for aggregate in aggregates]
0312     if returned is not None:
0313         for key in order_by:
0314             if key.name not in returned:
0315                 raise QueryRejected("bad_shape", f"order_by names {key.name}, which the query does not return")
0316     else:
0317         for key in order_by:
0318             _name(key.name, "an order_by entry of a query returning columns")
0319     # rows
0320     limit = raw.get("limit", MAX_ROWS)
0321     if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_ROWS:
0322         raise QueryRejected("bad_shape", f"limit must be from 1 to {MAX_ROWS}, got {limit!r}")
0323     return Query(tuple(tables), where, columns, group_by, aggregates, order_by, limit)
0324 
0325 
0326 def parse_time(value: str) -> datetime.datetime:
0327     """
0328     Read a time in ISO 8601 as the naive UTC time the database holds.
0329 
0330     Args:
0331         value(str): the time, read as UTC when it has no offset
0332 
0333     Returns:
0334         datetime: the time in UTC, without a time zone
0335 
0336     Raises:
0337         ValueError: when it is not a time
0338     """
0339     parsed = datetime.datetime.fromisoformat(value)
0340     if parsed.tzinfo is not None:
0341         parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
0342     return parsed
0343 
0344 
0345 def _read_value(value: Any, kind: str, table: str, column: str) -> Any:
0346     if kind == KIND_NUMBER and isinstance(value, (int, float)):
0347         return value
0348     if kind == KIND_TEXT and isinstance(value, str):
0349         return value
0350     if kind == KIND_TIME and isinstance(value, str):
0351         try:
0352             return parse_time(value)
0353         except ValueError:
0354             pass
0355     raise QueryRejected("bad_value", f"{value!r} is not a value of {table}.{column}, a {kind} column", table=table, column=column)
0356 
0357 
0358 def _compare(left: Any, right: Any) -> int | None:
0359     # -1, 0 or 1, or None when the two cannot be compared, such as a time with a number
0360     try:
0361         order: int = (left > right) - (left < right)
0362     except TypeError:
0363         return None
0364     return order
0365 
0366 
0367 def _may_be_above(value: Any, bound: Any, inclusive: bool) -> bool:
0368     # whether value may satisfy "> bound", or ">= bound" when inclusive. Values that cannot be
0369     # compared may, which counts a partition too many rather than one too few
0370     order = _compare(value, bound)
0371     return order is None or order == 1 or (inclusive and order == 0)
0372 
0373 
0374 def count_touched_partitions(partitioning: PartitionInfo, where: Sequence[Condition], now: datetime.datetime | None = None) -> int:
0375     """
0376     Count the partitions the conditions on the partition key leave to read.
0377 
0378     Args:
0379         partitioning(PartitionInfo): the partitions of the table
0380         where(list): conditions with values read for the table. A value that cannot be compared
0381             with the bounds, and an "in" from a query, leave every partition to read
0382         now(datetime): the present on the database's clock, in UTC, or None. A partition of a time
0383             key starting after it is made ahead and holds no row yet, so it is not counted
0384 
0385     Returns:
0386         int: partitions touched, at least 1
0387     """
0388     # the values the key may take, None for any, and the window it must be in
0389     points: set[Any] | None = None
0390     lowers: list[tuple[Any, bool]] = []
0391     uppers: list[tuple[Any, bool]] = []
0392     for condition in where:
0393         if condition.column != partitioning.key_column:
0394             continue
0395         if condition.op == "=":
0396             values = {condition.value}
0397         elif condition.op == "in" and isinstance(condition.value, list):
0398             values = set(condition.value)
0399         else:
0400             if condition.op in (">", ">="):
0401                 lowers.append((condition.value, condition.op == ">="))
0402             elif condition.op in ("<", "<="):
0403                 uppers.append((condition.value, condition.op == "<="))
0404             continue
0405         points = values if points is None else points & values
0406 
0407     def in_window(value: Any) -> bool:
0408         # value >= or > each lower, and value <= or < each upper
0409         return all(_may_be_above(value, bound, inclusive) for bound, inclusive in lowers) and all(
0410             _may_be_above(bound, value, inclusive) for bound, inclusive in uppers
0411         )
0412 
0413     def touched(bound_lower: Bound | None, bound_upper: Bound | None) -> bool:
0414         # the partition holds the values v with bound_lower <= v < bound_upper
0415         def in_partition(value: Any) -> bool:
0416             return (bound_lower is None or _may_be_above(value, bound_lower, True)) and (bound_upper is None or _may_be_above(bound_upper, value, False))
0417 
0418         if points is not None:
0419             return any(in_window(point) and in_partition(point) for point in points)
0420         # some v < bound_upper must pass each lower, and some v >= bound_lower each upper
0421         if bound_upper is not None and not all(_may_be_above(bound_upper, bound, False) for bound, _ in lowers):
0422             return False
0423         if bound_lower is not None and not all(_may_be_above(bound, bound_lower, inclusive) for bound, inclusive in uppers):
0424             return False
0425         return True
0426 
0427     def made_ahead(bound_lower: Bound | None) -> bool:
0428         # a bound that is not a time cannot be compared with now, which keeps the partition
0429         return now is not None and bound_lower is not None and _compare(bound_lower, now) == 1
0430 
0431     n_touched = sum(1 for bound_lower, bound_upper in partitioning.bounds if touched(bound_lower, bound_upper) and not made_ahead(bound_lower))
0432     return max(n_touched, 1)
0433 
0434 
0435 def _kind_of(table: AllowedTable, info: TableInfo, column: str) -> str:
0436     kind = info.columns.get(column)
0437     if kind is None:
0438         raise QueryRejected("unknown_column", f"{table.name} has no column {column}", table=table.name, column=column)
0439     return kind
0440 
0441 
0442 def check_query(
0443     query: Query, infos: dict[str, TableInfo], from_sizes: dict[FromQuery, int], budget: int, now: datetime.datetime | None = None
0444 ) -> list[TablePlan]:
0445     """
0446     Check a query against the data dictionary and price it.
0447 
0448     Args:
0449         query(Query): the query, from parse_query
0450         infos(dict): the dictionary entry of each table of the query, by the table's name
0451         from_sizes(dict): how many values each "in" from a query has, which the earlier answers tell
0452         budget(int): most index lookups the query may cost, over all its tables
0453         now(datetime): the present on the database's clock, in UTC, before which the partitions
0454             counted start, or None to count them all
0455 
0456     Returns:
0457         list: a plan for each table of the query, in its order
0458 
0459     Raises:
0460         QueryRejected: when the query names what a table does not have, gives a value of the wrong
0461             type, has no index to look up, or costs more than the budget
0462     """
0463     plans: list[TablePlan] = []
0464     for table in query.tables:
0465         info = infos[table.name]
0466 
0467         # what the query returns
0468         for column in query.columns or ():
0469             _kind_of(table, info, column)
0470         for key in query.group_by:
0471             if _kind_of(table, info, key.column) in (KIND_LOB, KIND_OTHER):
0472                 raise QueryRejected("bad_value", f"{table.name}.{key.column} cannot be grouped", table=table.name, column=key.column)
0473             if key.truncation and _kind_of(table, info, key.column) != KIND_TIME:
0474                 raise QueryRejected("bad_value", f"{key.name} needs a time column", table=table.name, column=key.column)
0475         for aggregate in query.aggregates:
0476             if aggregate.column is not None and _kind_of(table, info, aggregate.column) in (KIND_LOB, KIND_OTHER):
0477                 raise QueryRejected("bad_value", f"{aggregate.name} cannot be taken of {table.name}", table=table.name, column=aggregate.column)
0478         if not query.grouped and query.columns is None:
0479             for order_key in query.order_by:
0480                 _kind_of(table, info, order_key.name)
0481         # conditions, with their values read for the columns of this table
0482         where: list[Condition] = []
0483         for condition in query.where:
0484             kind = _kind_of(table, info, condition.column)
0485             if kind in (KIND_LOB, KIND_OTHER):
0486                 raise QueryRejected("bad_value", f"{table.name}.{condition.column} cannot be compared", table=table.name, column=condition.column)
0487             if condition.op == "prefix" and kind != KIND_TEXT:
0488                 raise QueryRejected(
0489                     "bad_value",
0490                     f"prefix needs a text column, and {table.name}.{condition.column} is a {kind} column",
0491                     table=table.name,
0492                     column=condition.column,
0493                 )
0494             value = condition.value
0495             if isinstance(value, list):
0496                 value = [_read_value(item, kind, table.name, condition.column) for item in value]
0497             elif condition.op not in NULL_OPS and not isinstance(value, FromQuery):
0498                 value = _read_value(value, kind, table.name, condition.column)
0499             where.append(Condition(condition.column, condition.op, value))
0500         # keys to look up, by column
0501         keys: dict[str, int] = {}
0502         for condition in where:
0503             if condition.op == "=":
0504                 n_keys = 1
0505             elif condition.op == "in":
0506                 n_keys = from_sizes[condition.value] if isinstance(condition.value, FromQuery) else len(condition.value)
0507                 if n_keys > MAX_KEYS:
0508                     # only an "in" from a query gets here, parse_query having refused a longer list
0509                     raise QueryRejected("too_many_keys", f"{condition.column} in has {n_keys} values from a query", keys=n_keys, max_keys=MAX_KEYS)
0510             else:
0511                 continue
0512             keys[condition.column] = min(n_keys, keys.get(condition.column, n_keys))
0513         # the cheapest index to look up
0514         probes: int | None = None
0515         partitioning = info.partitioning
0516         n_touched = count_touched_partitions(partitioning, where, now) if partitioning is not None else 1
0517         for index in info.indexes:
0518             if index.leading_column not in keys:
0519                 continue
0520             n_keys = keys[index.leading_column]
0521             if partitioning is not None and index.local and index.leading_column != partitioning.key_column:
0522                 cost = n_keys * n_touched
0523             else:
0524                 # a global index is one structure; and each key of the partition key is in one partition
0525                 cost = n_keys
0526             probes = cost if probes is None else min(probes, cost)
0527         if probes is None:
0528             raise QueryRejected(
0529                 "no_index_key",
0530                 f"{table.name} is read only with = or in on the first column of an index, which no condition gives",
0531                 table=table.name,
0532             )
0533         plans.append(TablePlan(table, info, tuple(where), probes))
0534     total = sum(plan.probes for plan in plans)
0535     if total > budget:
0536         raise QueryRejected(
0537             "probe_budget",
0538             f"the query costs {total} index lookups, over the budget of {budget}",
0539             probes={plan.table.name: plan.probes for plan in plans},
0540             budget=budget,
0541         )
0542     return plans