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
0059 MAX_QUERIES = 10
0060
0061 MAX_ROWS = 1000
0062
0063 MAX_KEYS = 1000
0064
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
0090 query: int
0091
0092 column: str
0093
0094
0095 @dataclass(frozen=True)
0096 class Condition:
0097
0098 column: str
0099
0100 op: str
0101
0102 value: Any
0103
0104
0105 @dataclass(frozen=True)
0106 class GroupKey:
0107 column: str
0108
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
0119 function: str
0120
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
0131 name: str
0132 descending: bool
0133
0134
0135 @dataclass(frozen=True)
0136 class Query:
0137 tables: tuple[AllowedTable, ...]
0138 where: tuple[Condition, ...]
0139
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
0159 where: tuple[Condition, ...]
0160
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
0178 if isinstance(value, dict) and set(value) == {"__datetime__"}:
0179 value = value["__datetime__"]
0180
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
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
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
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
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
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
0369
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
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
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
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
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
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
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
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
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
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
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
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