| import re | |
| AGGREGATE_FUNCTIONS = ( | |
| "COUNT", | |
| "SUM", | |
| "AVG", | |
| "MAX_VALUE", | |
| "MIN_VALUE", | |
| "FIRST_VALUE", | |
| "LAST_VALUE", | |
| "MAX", | |
| "MIN", | |
| ) | |
| def _split_top_level_csv(expr: str) -> list[str]: | |
| parts: list[str] = [] | |
| cur: list[str] = [] | |
| depth = 0 | |
| in_single_quote = False | |
| idx = 0 | |
| while idx < len(expr): | |
| ch = expr[idx] | |
| if ch == "'": | |
| in_single_quote = not in_single_quote | |
| cur.append(ch) | |
| elif not in_single_quote and ch == "(": | |
| depth += 1 | |
| cur.append(ch) | |
| elif not in_single_quote and ch == ")": | |
| depth = max(0, depth - 1) | |
| cur.append(ch) | |
| elif not in_single_quote and ch == "," and depth == 0: | |
| part = "".join(cur).strip() | |
| if part: | |
| parts.append(part) | |
| cur = [] | |
| else: | |
| cur.append(ch) | |
| idx += 1 | |
| tail = "".join(cur).strip() | |
| if tail: | |
| parts.append(tail) | |
| return parts | |
| def _strip_alias(expr: str) -> str: | |
| out = re.sub(r"\s+AS\s+[A-Z_][A-Z0-9_]*\s*$", "", expr, flags=re.IGNORECASE) | |
| m = re.match(r"^(.*\S)\s+([A-Z_][A-Z0-9_]*)\s*$", out, flags=re.IGNORECASE) | |
| if m and "(" in m.group(1): | |
| return m.group(1) | |
| return out | |
| def _strip_identifier_quotes(identifier: str) -> str: | |
| value = identifier.strip() | |
| if len(value) >= 2 and value[0] == value[-1] and value[0] in {"`", '"'}: | |
| return value[1:-1].strip() | |
| return value | |
| def _extract_projection_expr(sql: str) -> str: | |
| match = re.search( | |
| r"\bSELECT\b\s+(?:LAST\s+)?(.+?)\s+\bFROM\b", | |
| sql, | |
| flags=re.IGNORECASE | re.DOTALL, | |
| ) | |
| if not match: | |
| return "" | |
| return match.group(1).strip() | |
| def _extract_from_identifier(sql: str) -> str: | |
| match = re.search(r"\bFROM\s+([^\s,;]+)", sql, flags=re.IGNORECASE) | |
| if not match: | |
| return "" | |
| identifier = match.group(1).strip() | |
| if not identifier or identifier.startswith("("): | |
| return "" | |
| return _strip_identifier_quotes(identifier.rstrip(",;")) | |
| def _projection_measurement_names(sql: str) -> set[str]: | |
| projection = _extract_projection_expr(sql) | |
| if not projection: | |
| return set() | |
| aggregate_re = re.compile( | |
| rf"^(?:{'|'.join(AGGREGATE_FUNCTIONS)})\s*\((.*)\)$", | |
| flags=re.IGNORECASE | re.DOTALL, | |
| ) | |
| simple_identifier_re = re.compile( | |
| r"^[`\"]?[A-Za-z_][A-Za-z0-9_]*[`\"]?" | |
| r"(?:\.[`\"]?[A-Za-z_][A-Za-z0-9_]*[`\"]?)*$" | |
| ) | |
| measurements: set[str] = set() | |
| for raw_item in _split_top_level_csv(projection): | |
| item = _strip_alias(raw_item.strip()) | |
| aggregate = aggregate_re.match(item) | |
| if aggregate: | |
| item = aggregate.group(1).strip() | |
| if item == "*" or not simple_identifier_re.fullmatch(item): | |
| continue | |
| measurement = _strip_identifier_quotes(item.rsplit(".", 1)[-1]) | |
| if measurement: | |
| measurements.add(measurement.lower()) | |
| return measurements | |
| def validate_tree_query_shape(sql: str) -> list[dict[str, object]]: | |
| """Return high-confidence Tree SQL shape issues before runtime execution. | |
| This intentionally covers only patterns that are almost certainly wrong in | |
| ordinary Tree reads. It does not try to infer schema or rewrite SQL. | |
| """ | |
| from_identifier = _extract_from_identifier(sql) | |
| if not from_identifier.lower().startswith("root.") or "*" in from_identifier: | |
| return [] | |
| path_segments = [_strip_identifier_quotes(part) for part in from_identifier.split(".") if part] | |
| if len(path_segments) < 4: | |
| return [] | |
| from_tail = path_segments[-1].lower() | |
| projection_measurements = _projection_measurement_names(sql) | |
| if from_tail not in projection_measurements: | |
| return [] | |
| device_path = ".".join(path_segments[:-1]) | |
| measurement = path_segments[-1] | |
| return [ | |
| { | |
| "code": "tree_dialect_from_must_use_device_path", | |
| "message": ( | |
| "Tree SQL should use a device/path-pattern in FROM and project " | |
| "measurement names in SELECT. The FROM path appears to include " | |
| f"measurement `{measurement}`." | |
| ), | |
| "rewrite_hint": ( | |
| f"Use FROM {device_path} and keep `{measurement}` in the SELECT " | |
| "projection or aggregate expression." | |
| ), | |
| "from_path": from_identifier, | |
| "suggested_from_path": device_path, | |
| "measurement": measurement, | |
| } | |
| ] | |
| def tree_from_wildcard_runtime_hint(sql: str, *, row_count: int | None = None) -> dict[str, object] | None: | |
| """Return a non-blocking hint for device-path wildcard FROM shapes. | |
| IoTDB Tree accepts prefix paths such as ``root.sg.d1.*`` syntactically, but | |
| agents often build that shape by appending ``.*`` to a device path copied | |
| from schema metadata. For ordinary measurement projections, the canonical | |
| query shape is ``SELECT measurement FROM root.sg.d1``. This helper is a | |
| runtime diagnostic only; it does not reject broad scans such as ``root.**``. | |
| """ | |
| from_identifier = _extract_from_identifier(sql) | |
| if not from_identifier.lower().startswith("root."): | |
| return None | |
| if not from_identifier.endswith(".*"): | |
| return None | |
| suggested_from_path = from_identifier[:-2] | |
| if not suggested_from_path or suggested_from_path.endswith("."): | |
| return None | |
| projection_measurements = _projection_measurement_names(sql) | |
| if not projection_measurements: | |
| return None | |
| qualifier = " If this query returned zero rows," if row_count == 0 else "" | |
| return { | |
| "code": "tree_dialect_from_path_wildcard_noncanonical", | |
| "message": ( | |
| f"Tree SQL `FROM {from_identifier}` is a path-pattern form, not the " | |
| "canonical device-scoped form for ordinary measurement reads." | |
| f"{qualifier} retry with the device path in FROM and keep measurement names in SELECT." | |
| ), | |
| "rewrite_hint": ( | |
| f"Use FROM {suggested_from_path}; metadata `paths`/`timeseries` values are full " | |
| "timeseries paths and should not be copied as FROM path.* clauses." | |
| ), | |
| "from_path": from_identifier, | |
| "suggested_from_path": suggested_from_path, | |
| } | |
| def assert_tree_query_shape(sql: str) -> None: | |
| issues = validate_tree_query_shape(sql) | |
| if not issues: | |
| return | |
| issue = issues[0] | |
| raise ValueError( | |
| f"{issue['code']}: {issue['message']} {issue['rewrite_hint']}" | |
| ) |