diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index 499138c66..20235d45a 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -131,6 +131,8 @@ title: OpenCode - local: environments/sophistry_bench_sprint title: Sophistry Bench Sprint + - local: environments/sql_optim + title: SQL Query Optimization title: Environments - isExpanded: false sections: diff --git a/docs/source/environments.md b/docs/source/environments.md index beb19be62..2dc7cfd89 100644 --- a/docs/source/environments.md +++ b/docs/source/environments.md @@ -265,6 +265,13 @@ The OpenEnv community has built a catalog of ready-to-run environments that cove šŸ“„ Docs +
+
SQL Query Optimization
+

sql_optim_env rewards an agent for rewriting slow SQL: it executes both the original and optimized query against an in-memory DuckDB seeded with synthetic data and scores measured speedup plus result-correctness across five anti-pattern tasks.

+
+ šŸ“„ Docs +
+
diff --git a/docs/source/environments/sql_optim.md b/docs/source/environments/sql_optim.md new file mode 100644 index 000000000..7e2529a7b --- /dev/null +++ b/docs/source/environments/sql_optim.md @@ -0,0 +1,95 @@ + +# SQL Query Optimization Environment + +An **execution-grounded** environment for training agents to write fast SQL. The +agent is given a slow query plus its schema and must return a rewrite. Reward is +**not** a keyword heuristic: the environment runs both the original and the +optimized query against a real in-memory **DuckDB** database seeded with +realistic synthetic data (10k users, 500k orders, 1k products, 1M events), then +scores the agent on measured speedup and result-correctness. + +Five tasks scale from basic anti-patterns to expert window-function audits. + +## Quick Start + +```python +from sql_optim_env import SQLOptimAction, SQLOptimEnv + +with SQLOptimEnv(base_url="http://localhost:8000").sync() as env: + obs = env.reset(task_id="task_1_basic_antipatterns").observation + print(obs.sql_query) # the slow query to fix + print(obs.schema_info) # tables, row counts + + result = env.step( + SQLOptimAction( + suggestions=[ + {"issue_type": "select_star", "line": 1, + "description": "SELECT * reads every column", "severity": "high", + "fix": "project only the needed columns"}, + ], + optimized_query="SELECT id FROM orders WHERE customer_id = 5000", + summary="Dropped SELECT * and the non-sargable CAST.", + estimated_improvement="~10x faster", + approved=False, + ) + ) + print(result.reward, result.done) + print(result.metadata["reward_breakdown"]) + print(result.metadata["execution"]["speedup"]) # real DuckDB speedup +``` + +Async usage mirrors the other environments: + +```python +async with SQLOptimEnv(base_url="http://localhost:8000") as env: + result = await env.reset(task_id="task_1_basic_antipatterns") +``` + +## Action + +| Field | Type | Meaning | +|-------|------|---------| +| `suggestions` | `list[dict]` | Issues found: `issue_type`, `line`, `description`, `severity`, `fix` | +| `optimized_query` | `str` | The rewritten SQL — executed for real | +| `summary` | `str` | Overall analysis | +| `estimated_improvement` | `str` | Agent's own speedup estimate | +| `approved` | `bool` | `True` if the query is already optimal | + +## Observation + +`sql_query`, `schema_info`, `task_description`, `difficulty`, `step_count`, +`max_steps`, `issues_found_so_far`, and `last_execution` (the previous step's +real timing comparison, so the agent can refine its rewrite). The step reward is +on `reward`; the composite `reward_breakdown`, `feedback`, and full `execution` +comparison are under `metadata`. + +## Reward + +Composite score in `[0, 1]`, computed inside the environment: + +| Component | Weight | Source | +|-----------|--------|--------| +| Execution speedup | 35% | real DuckDB timing ratio | +| Result correctness | 20% | do both queries return identical rows? | +| Issue detection | 25% | flagged issues vs. ground truth | +| Approval correctness | 8% | correctly judged the query bad/good | +| Summary quality | 7% | thoroughness of the written analysis | +| Severity labels | 5% | severities present | + +## Tasks + +Five tasks (`task_1_basic_antipatterns` … `task_5_*`) covering `SELECT *`, +non-sargable predicates, functions on filter columns, join ordering, and window +functions, spanning `easy` → `expert`. + +## Run the server locally + +```bash +uvicorn server.app:app --host 0.0.0.0 --port 8000 +``` + +## Tests + +```bash +PYTHONPATH=src:envs uv run pytest tests/envs/test_sql_optim_environment.py -v +``` diff --git a/envs/sql_optim_env/README.md b/envs/sql_optim_env/README.md new file mode 100644 index 000000000..20d72e633 --- /dev/null +++ b/envs/sql_optim_env/README.md @@ -0,0 +1,110 @@ +--- +title: SQL Query Optimization Environment +emoji: šŸ—„ļø +colorFrom: indigo +colorTo: blue +sdk: docker +pinned: false +app_port: 8000 +base_path: /web +tags: + - openenv + - sql + - duckdb + - optimization +--- + +# SQL Query Optimization Environment + +An **execution-grounded** environment for training agents to write fast SQL. The +agent is given a slow query plus its schema and must return a rewrite. Reward is +**not** a keyword heuristic: the environment runs both the original and the +optimized query against a real in-memory **DuckDB** database seeded with +realistic synthetic data (10k users, 500k orders, 1k products, 1M events), then +scores the agent on measured speedup and result-correctness. + +Five tasks scale from basic anti-patterns to expert window-function audits. + +## Quick Start + +```python +from sql_optim_env import SQLOptimAction, SQLOptimEnv + +with SQLOptimEnv(base_url="http://localhost:8000").sync() as env: + obs = env.reset(task_id="task_1_basic_antipatterns").observation + print(obs.sql_query) # the slow query to fix + print(obs.schema_info) # tables, row counts + + result = env.step( + SQLOptimAction( + suggestions=[ + {"issue_type": "select_star", "line": 1, + "description": "SELECT * reads every column", "severity": "high", + "fix": "project only the needed columns"}, + ], + optimized_query="SELECT id FROM orders WHERE customer_id = 5000", + summary="Dropped SELECT * and the non-sargable CAST.", + estimated_improvement="~10x faster", + approved=False, + ) + ) + print(result.reward, result.done) + print(result.metadata["reward_breakdown"]) + print(result.metadata["execution"]["speedup"]) # real DuckDB speedup +``` + +Async usage mirrors the other environments: + +```python +async with SQLOptimEnv(base_url="http://localhost:8000") as env: + result = await env.reset(task_id="task_1_basic_antipatterns") +``` + +## Action + +| Field | Type | Meaning | +|-------|------|---------| +| `suggestions` | `list[dict]` | Issues found: `issue_type`, `line`, `description`, `severity`, `fix` | +| `optimized_query` | `str` | The rewritten SQL — executed for real | +| `summary` | `str` | Overall analysis | +| `estimated_improvement` | `str` | Agent's own speedup estimate | +| `approved` | `bool` | `True` if the query is already optimal | + +## Observation + +`sql_query`, `schema_info`, `task_description`, `difficulty`, `step_count`, +`max_steps`, `issues_found_so_far`, and `last_execution` (the previous step's +real timing comparison, so the agent can refine its rewrite). The step reward is +on `reward`; the composite `reward_breakdown`, `feedback`, and full `execution` +comparison are under `metadata`. + +## Reward + +Composite score in `[0, 1]`, computed inside the environment: + +| Component | Weight | Source | +|-----------|--------|--------| +| Execution speedup | 35% | real DuckDB timing ratio | +| Result correctness | 20% | do both queries return identical rows? | +| Issue detection | 25% | flagged issues vs. ground truth | +| Approval correctness | 8% | correctly judged the query bad/good | +| Summary quality | 7% | thoroughness of the written analysis | +| Severity labels | 5% | severities present | + +## Tasks + +Five tasks (`task_1_basic_antipatterns` … `task_5_*`) covering `SELECT *`, +non-sargable predicates, functions on filter columns, join ordering, and window +functions, spanning `easy` → `expert`. + +## Run the server locally + +```bash +uvicorn server.app:app --host 0.0.0.0 --port 8000 +``` + +## Tests + +```bash +PYTHONPATH=src:envs uv run pytest tests/envs/test_sql_optim_environment.py -v +``` diff --git a/envs/sql_optim_env/__init__.py b/envs/sql_optim_env/__init__.py new file mode 100644 index 000000000..71a4f4450 --- /dev/null +++ b/envs/sql_optim_env/__init__.py @@ -0,0 +1,34 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +SQL Query Optimization Environment for OpenEnv. + +An execution-grounded RL environment: the agent rewrites slow SQL and is +rewarded by real DuckDB execution speedup plus result-correctness across five +tasks that scale from basic anti-patterns to expert window-function audits. + +Examples: + +```python +from envs.sql_optim_env import SQLOptimEnv, SQLOptimAction + +with SQLOptimEnv(base_url="http://localhost:8000").sync() as env: + obs = env.reset(task_id="task_1_basic_antipatterns").observation + result = env.step(SQLOptimAction(optimized_query="SELECT id FROM orders ...")) + print(result.reward, result.done) +``` +""" + +from .client import SQLOptimEnv +from .models import SQLOptimAction, SQLOptimObservation, SQLOptimState + +__all__ = [ + "SQLOptimEnv", + "SQLOptimAction", + "SQLOptimObservation", + "SQLOptimState", +] diff --git a/envs/sql_optim_env/client.py b/envs/sql_optim_env/client.py new file mode 100644 index 000000000..0ac468319 --- /dev/null +++ b/envs/sql_optim_env/client.py @@ -0,0 +1,72 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Client for the SQL Query Optimization environment. + +Maintains a persistent WebSocket connection to the environment server for +efficient multi-step episodes. Async by default; use `.sync()` for a +synchronous wrapper. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient + +from .models import SQLOptimAction, SQLOptimObservation, SQLOptimState + + +class SQLOptimEnv(EnvClient[SQLOptimAction, SQLOptimObservation, SQLOptimState]): + """ + Client for the SQL Query Optimization environment. + + The agent receives a slow SQL query plus its schema, returns a + [`SQLOptimAction`][envs.sql_optim_env.models.SQLOptimAction] with a + rewritten `optimized_query`, and is rewarded by real DuckDB execution + speedup and result-correctness. + + Examples: + + ```python + with SQLOptimEnv(base_url="http://localhost:8000").sync() as env: + obs = env.reset(task_id="task_1_basic_antipatterns").observation + result = env.step( + SQLOptimAction( + optimized_query="SELECT id FROM orders WHERE customer_id = 5000", + summary="Dropped SELECT * and the non-sargable CAST.", + ) + ) + print(result.reward, result.done) + ``` + """ + + def _step_payload(self, action: SQLOptimAction) -> Dict[str, Any]: + """Serialize a [`SQLOptimAction`] into the step request payload.""" + return { + "suggestions": action.suggestions, + "optimized_query": action.optimized_query, + "summary": action.summary, + "estimated_improvement": action.estimated_improvement, + "approved": action.approved, + } + + def _parse_result(self, payload: Dict[str, Any]) -> StepResult[SQLOptimObservation]: + """Parse a server step/reset response into a `StepResult`.""" + obs_data = payload.get("observation", {}) + observation = SQLOptimObservation(**obs_data) + return StepResult( + observation=observation, + reward=payload.get("reward", observation.reward), + done=payload.get("done", observation.done), + metadata=payload.get("metadata", observation.metadata), + ) + + def _parse_state(self, payload: Dict[str, Any]) -> SQLOptimState: + """Parse a server state response into a [`SQLOptimState`].""" + return SQLOptimState(**payload) diff --git a/envs/sql_optim_env/models.py b/envs/sql_optim_env/models.py new file mode 100644 index 000000000..d8cfdd413 --- /dev/null +++ b/envs/sql_optim_env/models.py @@ -0,0 +1,123 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Data models for the SQL Query Optimization environment. + +Defines the `Action`, `Observation`, and `State` wire types exchanged between +the [`SQLOptimEnv`][envs.sql_optim_env.client.SQLOptimEnv] client and the +environment server. All three inherit from the OpenEnv base types so they +serialize over the WebSocket/HTTP transport. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from openenv.core.env_server import Action, Observation, State +from pydantic import Field + + +class SQLOptimAction(Action): + """ + Action emitted by the agent for one optimization step. + + Attributes: + suggestions (`list[dict]`): + Detected issues. Each entry is a dict with `issue_type`, `line`, + `description`, `severity`, and `fix` keys. + optimized_query (`str`): + The complete rewritten SQL. It is executed against the real DuckDB + data so the measured speedup can be rewarded. + summary (`str`): + Overall analysis and performance profile of the query. + estimated_improvement (`str`): + The agent's own expected speedup (e.g. `"10x faster"`). + approved (`bool`): + `True` if the agent judges the query already optimal, `False` if it + still needs changes. + """ + + suggestions: List[Dict[str, Any]] = Field(default_factory=list) + optimized_query: str = "" + summary: str = "" + estimated_improvement: str = "" + approved: bool = False + + +class SQLOptimObservation(Observation): + """ + Observation returned to the agent after `reset` and each `step`. + + Inherits `done`, `reward`, and `metadata` from + [`~openenv.core.env_server.Observation`]. The composite reward breakdown, + per-criterion feedback, and full execution comparison are attached under + `metadata` (keys `reward_breakdown`, `feedback`, `execution`). + + Attributes: + task_id (`str`): + Identifier of the active task. + task_name (`str`): + Human-readable task name. + task_description (`str`): + What the agent must do for this task. + sql_query (`str`): + The slow SQL query to analyze and rewrite. + schema_info (`str`): + Table schema, row counts, and index notes. + dialect (`str`): + SQL dialect the query targets. + difficulty (`str`): + One of `easy`, `medium`, `hard`, `expert`. + step_count (`int`): + Steps taken so far in this episode. + max_steps (`int`): + Maximum steps allowed for this task. + issues_found_so_far (`list[str]`): + Issue types the agent flagged in previous steps. + last_execution (`dict`, *optional*): + Execution comparison from the previous step, so the agent can refine + its `optimized_query`. + """ + + task_id: str = "" + task_name: str = "" + task_description: str = "" + sql_query: str = "" + schema_info: str = "" + dialect: str = "duckdb/postgresql" + difficulty: str = "" + step_count: int = 0 + max_steps: int = 5 + issues_found_so_far: List[str] = Field(default_factory=list) + last_execution: Optional[Dict[str, Any]] = None + + +class SQLOptimState(State): + """ + Internal environment state for the current episode. + + Inherits `episode_id` and `step_count` from + [`~openenv.core.env_server.State`]. + + Attributes: + task_id (`str`): + Identifier of the active task (`"none"` when no episode is active). + max_steps (`int`): + Maximum steps allowed for the active task. + episode_done (`bool`): + Whether the current episode has terminated. + cumulative_reward (`float`): + Sum of per-step rewards in this episode. + current_task (`str`): + Human-readable name of the active task. + """ + + task_id: str = "none" + max_steps: int = 0 + episode_done: bool = True + cumulative_reward: float = 0.0 + current_task: str = "No active episode" diff --git a/envs/sql_optim_env/openenv.yaml b/envs/sql_optim_env/openenv.yaml new file mode 100644 index 000000000..2aff14057 --- /dev/null +++ b/envs/sql_optim_env/openenv.yaml @@ -0,0 +1,6 @@ +spec_version: 1 +name: sql_optim_env +type: space +runtime: fastapi +app: server.app:app +port: 8000 diff --git a/envs/sql_optim_env/pyproject.toml b/envs/sql_optim_env/pyproject.toml new file mode 100644 index 000000000..7820ff355 --- /dev/null +++ b/envs/sql_optim_env/pyproject.toml @@ -0,0 +1,37 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-sql-optim-env" +version = "0.1.0" +description = "SQL Query Optimization Environment for OpenEnv - execution-grounded RL with real DuckDB timing" +requires-python = ">=3.10" +dependencies = [ + "openenv>=0.2.3", + "fastapi>=0.115.0", + "pydantic>=2.0.0", + "uvicorn>=0.24.0", + "requests>=2.31.0", + "duckdb>=0.10.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +server = "sql_optim_env.server.app:main" + +[tool.setuptools] +include-package-data = true +packages = ["sql_optim_env", "sql_optim_env.server"] +package-dir = { "sql_optim_env" = ".", "sql_optim_env.server" = "server" } diff --git a/envs/sql_optim_env/server/Dockerfile b/envs/sql_optim_env/server/Dockerfile new file mode 100644 index 000000000..85aa71064 --- /dev/null +++ b/envs/sql_optim_env/server/Dockerfile @@ -0,0 +1,55 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +ARG BASE_IMAGE=ghcr.io/huggingface/openenv-base:latest +FROM ${BASE_IMAGE} AS builder + +WORKDIR /app + + +COPY . /app/env + +WORKDIR /app/env + +RUN if ! command -v uv >/dev/null 2>&1; then \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx; \ + fi + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ -f uv.lock ]; then \ + uv sync --frozen --no-install-project --no-editable; \ + else \ + uv sync --no-install-project --no-editable; \ + fi + +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ -f uv.lock ]; then \ + uv sync --frozen --no-editable; \ + else \ + uv sync --no-editable; \ + fi + +# Final runtime stage +FROM ${BASE_IMAGE} + +WORKDIR /app + +COPY --from=builder /app/env/.venv /app/.venv +COPY --from=builder /app/env /app/env + +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONPATH="/app/env:$PYTHONPATH" + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"] diff --git a/envs/sql_optim_env/server/__init__.py b/envs/sql_optim_env/server/__init__.py new file mode 100644 index 000000000..00ea1c181 --- /dev/null +++ b/envs/sql_optim_env/server/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""SQL Query Optimization environment server.""" + +from .sql_optim_environment import SQLOptimEnvironment + +__all__ = ["SQLOptimEnvironment"] diff --git a/envs/sql_optim_env/server/app.py b/envs/sql_optim_env/server/app.py new file mode 100644 index 000000000..9472cac7a --- /dev/null +++ b/envs/sql_optim_env/server/app.py @@ -0,0 +1,39 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""FastAPI application for the SQL Query Optimization environment.""" + +from openenv.core.env_server import create_app + +# Support both in-repo and standalone imports +try: + # In-repo imports (running from the OpenEnv repository) + from ..models import SQLOptimAction, SQLOptimObservation + from .sql_optim_environment import SQLOptimEnvironment +except ImportError as e: + if "relative import" not in str(e) and "no known parent package" not in str(e): + raise + # Standalone imports (running via uvicorn server.app:app) + from models import SQLOptimAction, SQLOptimObservation + from server.sql_optim_environment import SQLOptimEnvironment + +# Pass the class (factory) rather than an instance for WebSocket session support. +app = create_app( + SQLOptimEnvironment, + SQLOptimAction, + SQLOptimObservation, + env_name="sql_optim_env", +) + + +def main(): + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) + + +if __name__ == "__main__": + main() diff --git a/envs/sql_optim_env/server/executor.py b/envs/sql_optim_env/server/executor.py new file mode 100644 index 000000000..29f45ae58 --- /dev/null +++ b/envs/sql_optim_env/server/executor.py @@ -0,0 +1,562 @@ +""" +executor.py — DuckDB In-Memory SQL Execution Engine +===================================================== +The core innovation of this environment: instead of keyword-matching +heuristics, we ACTUALLY execute both the original and optimized queries +against realistic synthetic data and measure real performance differences. + +Tables populated: + users — 10,000 rows + orders — 500,000 rows + products — 1,000 rows + events — 1,000,000 rows +""" + +import atexit +import contextlib +import os +import re +import shutil +import tempfile +import threading +import time +from typing import Any, Dict, Iterator, List, Optional, Tuple + +import duckdb + +_instance: Optional["QueryExecutor"] = None +_lock = threading.Lock() + +# Result sets larger than this are never pulled into Python: the row count and +# the correctness check are computed inside DuckDB instead. `SELECT * FROM +# events` is a million rows, and materializing that (three times, for the +# timing median) both dominated the measurement and held the whole result in +# memory. The cap matches the threshold `compare()` already used to pick the +# precise row-by-row comparison, so behaviour for small results is unchanged. +_MAX_MATERIALIZED_ROWS = 50_000 +_FETCH_BATCH = 10_000 +_TIMING_RUNS = 3 +# `EXPLAIN ANALYZE` prints seconds with four decimals, so 0.1 ms is the smallest +# value it can express. Used as a floor so a sub-resolution query can never make +# the speedup ratio divide by zero. +_MIN_MEASURABLE_MS = 0.1 +_TOTAL_TIME_RE = re.compile(r"Total Time:\s*([0-9]*\.?[0-9]+)\s*s") + +# Opening the connection read-only stops the agent from changing the *database*, +# but on its own it leaves DuckDB's filesystem escape hatches open: `COPY ... TO` +# writes arbitrary files, `read_csv`/`read_text`/`glob` read them, `ATTACH` +# mounts other databases and `INSTALL`/`LOAD` pull in extensions. Since the query +# under measurement is agent-authored, external access is disabled outright, and +# the configuration is locked so a rewrite cannot `SET` its way back out (DuckDB +# also refuses to re-enable external access on a running database). +# +# The same reasoning applies to resources. The agent chooses the SQL, so a +# rewrite like `FROM events a, events b` is a trillion-row cross join: without +# a ceiling it can exhaust memory, fill the spill directory, and hold the +# execution lock for hours while every other session waits behind it. DuckDB has +# no statement-timeout option, so the limits below bound memory and spill space +# and `_deadline()` bounds time. +_DUCKDB_CONFIG = { + "threads": "2", + "enable_external_access": "false", + "lock_configuration": "true", + "memory_limit": "1GB", + "max_temp_directory_size": "1GB", +} +# Generous next to the real tasks (the slowest is ~0.6s) and short enough that a +# runaway rewrite cannot wedge the server. +_QUERY_TIMEOUT_S = 15.0 +_TIMEOUT_ERROR = ( + f"Query cancelled: exceeded the {_QUERY_TIMEOUT_S:.0f}s execution limit" +) + + +def _strip_terminators(query: str) -> str: + """Trim surrounding whitespace and any trailing semicolons. + + Trailing ``;`` breaks queries that are wrapped in a subquery + (``SELECT COUNT(*) FROM () t``) for correctness checksums. + """ + return query.strip().rstrip(";").strip() + + +def _as_subquery(query: str, select: str) -> str: + """Build ``SELECT