From ee78ae44cc688c20651285ddbbd9d84623710be8 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 21:13:18 +0100 Subject: [PATCH] Add sql_drills MySQL exact-match harness Docker mysql:8.0.33 container, judge-style output grading, 12 original drills with live-verified references kept in tests only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZewj4WS5X6AZyjWDp --- .../sql-drills-harness-2026-11-01.json | 15 ++ .../sql-drills-harness-2026-11-01.json | 33 +++++ meta/pyproject.toml | 3 + python_pkg/sql_drills/README.md | 32 ++++ python_pkg/sql_drills/__init__.py | 5 + python_pkg/sql_drills/__main__.py | 68 +++++++++ python_pkg/sql_drills/_check.py | 67 +++++++++ python_pkg/sql_drills/_drills.py | 65 +++++++++ python_pkg/sql_drills/_engine.py | 126 ++++++++++++++++ python_pkg/sql_drills/_normalize.py | 41 ++++++ .../fixtures/01_pivot_sum/DOCS-task.md | 9 ++ .../fixtures/01_pivot_sum/expected.txt | 4 + .../fixtures/01_pivot_sum/schema.sql | 12 ++ .../fixtures/02_pivot_max/DOCS-task.md | 10 ++ .../fixtures/02_pivot_max/expected.txt | 4 + .../fixtures/02_pivot_max/schema.sql | 12 ++ .../fixtures/03_month_bucket/DOCS-task.md | 11 ++ .../fixtures/03_month_bucket/expected.txt | 5 + .../fixtures/03_month_bucket/schema.sql | 19 +++ .../fixtures/04_date_format/DOCS-task.md | 11 ++ .../fixtures/04_date_format/expected.txt | 5 + .../fixtures/04_date_format/schema.sql | 11 ++ .../fixtures/05_str_to_date/DOCS-task.md | 10 ++ .../fixtures/05_str_to_date/expected.txt | 5 + .../fixtures/05_str_to_date/schema.sql | 13 ++ .../fixtures/06_number_traps/DOCS-task.md | 15 ++ .../fixtures/06_number_traps/expected.txt | 3 + .../fixtures/06_number_traps/schema.sql | 11 ++ .../fixtures/07_number_format/DOCS-task.md | 13 ++ .../fixtures/07_number_format/expected.txt | 4 + .../fixtures/07_number_format/schema.sql | 9 ++ .../fixtures/08_anti_join/DOCS-task.md | 12 ++ .../fixtures/08_anti_join/expected.txt | 3 + .../fixtures/08_anti_join/schema.sql | 16 ++ .../fixtures/09_group_having/DOCS-task.md | 9 ++ .../fixtures/09_group_having/expected.txt | 3 + .../fixtures/09_group_having/schema.sql | 16 ++ .../fixtures/10_window_topn/DOCS-task.md | 9 ++ .../fixtures/10_window_topn/expected.txt | 8 + .../fixtures/10_window_topn/schema.sql | 11 ++ .../fixtures/11_lag_gap/DOCS-task.md | 10 ++ .../fixtures/11_lag_gap/expected.txt | 4 + .../sql_drills/fixtures/11_lag_gap/schema.sql | 10 ++ .../fixtures/12_concat_percent/DOCS-task.md | 11 ++ .../fixtures/12_concat_percent/expected.txt | 7 + .../fixtures/12_concat_percent/schema.sql | 10 ++ python_pkg/sql_drills/tests/__init__.py | 0 .../tests/solutions/01_pivot_sum.sql | 8 + .../tests/solutions/02_pivot_max.sql | 7 + .../tests/solutions/03_month_bucket.sql | 8 + .../tests/solutions/04_date_format.sql | 5 + .../tests/solutions/05_str_to_date.sql | 4 + .../tests/solutions/06_number_traps.sql | 7 + .../tests/solutions/07_number_format.sql | 4 + .../tests/solutions/08_anti_join.sql | 5 + .../tests/solutions/09_group_having.sql | 6 + .../tests/solutions/10_window_topn.sql | 8 + .../sql_drills/tests/solutions/11_lag_gap.sql | 10 ++ .../tests/solutions/12_concat_percent.sql | 5 + python_pkg/sql_drills/tests/test_check.py | 105 ++++++++++++++ python_pkg/sql_drills/tests/test_cli.py | 67 +++++++++ python_pkg/sql_drills/tests/test_drills.py | 68 +++++++++ python_pkg/sql_drills/tests/test_engine.py | 137 ++++++++++++++++++ python_pkg/sql_drills/tests/test_live.py | 61 ++++++++ python_pkg/sql_drills/tests/test_normalize.py | 76 ++++++++++ 65 files changed, 1381 insertions(+) create mode 100644 docs/superpowers/contracts/sql-drills-harness-2026-11-01.json create mode 100644 docs/superpowers/evidence/sql-drills-harness-2026-11-01.json create mode 100644 python_pkg/sql_drills/README.md create mode 100644 python_pkg/sql_drills/__init__.py create mode 100644 python_pkg/sql_drills/__main__.py create mode 100644 python_pkg/sql_drills/_check.py create mode 100644 python_pkg/sql_drills/_drills.py create mode 100644 python_pkg/sql_drills/_engine.py create mode 100644 python_pkg/sql_drills/_normalize.py create mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/04_date_format/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/04_date_format/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/07_number_format/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/07_number_format/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/09_group_having/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/09_group_having/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql create mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md create mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt create mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql create mode 100644 python_pkg/sql_drills/tests/__init__.py create mode 100644 python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql create mode 100644 python_pkg/sql_drills/tests/solutions/02_pivot_max.sql create mode 100644 python_pkg/sql_drills/tests/solutions/03_month_bucket.sql create mode 100644 python_pkg/sql_drills/tests/solutions/04_date_format.sql create mode 100644 python_pkg/sql_drills/tests/solutions/05_str_to_date.sql create mode 100644 python_pkg/sql_drills/tests/solutions/06_number_traps.sql create mode 100644 python_pkg/sql_drills/tests/solutions/07_number_format.sql create mode 100644 python_pkg/sql_drills/tests/solutions/08_anti_join.sql create mode 100644 python_pkg/sql_drills/tests/solutions/09_group_having.sql create mode 100644 python_pkg/sql_drills/tests/solutions/10_window_topn.sql create mode 100644 python_pkg/sql_drills/tests/solutions/11_lag_gap.sql create mode 100644 python_pkg/sql_drills/tests/solutions/12_concat_percent.sql create mode 100644 python_pkg/sql_drills/tests/test_check.py create mode 100644 python_pkg/sql_drills/tests/test_cli.py create mode 100644 python_pkg/sql_drills/tests/test_drills.py create mode 100644 python_pkg/sql_drills/tests/test_engine.py create mode 100644 python_pkg/sql_drills/tests/test_live.py create mode 100644 python_pkg/sql_drills/tests/test_normalize.py diff --git a/docs/superpowers/contracts/sql-drills-harness-2026-11-01.json b/docs/superpowers/contracts/sql-drills-harness-2026-11-01.json new file mode 100644 index 00000000..85129672 --- /dev/null +++ b/docs/superpowers/contracts/sql-drills-harness-2026-11-01.json @@ -0,0 +1,15 @@ +{ + "title": "sql_drills: MySQL 8.0.33 exact-output practice harness", + "objective": "A CLI grades a SQL file against an original drill on a pinned mysql:8.0.33 container by exact judge-style output match, with 12 drills ordered by priority.", + "acceptance_criteria": [ + "start/stop/list/show/check work; check exits 0 pass, 1 mismatch (line diff), 2 SQL or engine error", + "Normaliser and diff are unit-tested without Docker; package coverage is 100% branch", + "Every drill's reference solution (tests/solutions only) reproduces expected.txt on live MySQL; those tests skip when the container is down", + "show and check never print reference SQL" + ], + "out_of_scope": [ + "Bumping the pinned image version", + "python_pkg/app_icons and AGENTS.md changes already in the working tree" + ], + "verifier": "python3 -m python_pkg.sql_drills start; ~/.claude/scripts/capped.sh python3 -m pytest -q -o addopts='--strict-markers --strict-config' python_pkg/sql_drills" +} diff --git a/docs/superpowers/evidence/sql-drills-harness-2026-11-01.json b/docs/superpowers/evidence/sql-drills-harness-2026-11-01.json new file mode 100644 index 00000000..b68b0fed --- /dev/null +++ b/docs/superpowers/evidence/sql-drills-harness-2026-11-01.json @@ -0,0 +1,33 @@ +{ + "intent": "Add a Docker-backed MySQL 8.0.33 practice harness that grades SQL by exact output match, with 12 original drills.", + "scope": [ + "python_pkg/sql_drills (new package, fixtures, tests, README)", + "meta/pyproject.toml: register the docker pytest marker", + "Non-goal: bumping the pinned mysql image; copying third-party problems" + ], + "changes": [ + "CLI start/stop/list/show/check over one long-lived tmpfs container", + "Judge-style normaliser and line diff, unit-tested without Docker", + "12 original drills with live-verified reference solutions kept in tests only" + ], + "verification": [ + { + "command": "python3 -m pytest -q python_pkg/sql_drills (container running)", + "result": "pass", + "evidence": "71 passed, 16 of them live against mysql:8.0.33; package coverage 100% branch" + }, + { + "command": "python3 -m python_pkg.sql_drills check for all 12 drills", + "result": "pass", + "evidence": "every drill prints PASS; wrong query prints a line diff, syntax error exits 2" + } + ], + "risks": [ + "Local MySQL image must be pulled once; start fails with a clear error otherwise", + "A runaway query is cut off client-side after 30 s but may keep running in the container until stop" + ], + "rollback": [ + "git revert the commit; run python3 -m python_pkg.sql_drills stop first", + "Check that pytest still collects with the marker line removed" + ] +} diff --git a/meta/pyproject.toml b/meta/pyproject.toml index 7c218ea9..35b06a03 100644 --- a/meta/pyproject.toml +++ b/meta/pyproject.toml @@ -297,6 +297,9 @@ addopts = [ "--cov-report=term-missing", "--cov-report=lcov", ] +markers = [ + "docker: needs the live sql-drills MySQL container (skipped when it is down)", +] filterwarnings = [ "error", "ignore::DeprecationWarning", diff --git a/python_pkg/sql_drills/README.md b/python_pkg/sql_drills/README.md new file mode 100644 index 00000000..b9f9d5e5 --- /dev/null +++ b/python_pkg/sql_drills/README.md @@ -0,0 +1,32 @@ +# sql_drills + +MySQL 8 practice drills graded like an online judge: the query's output must +match the expected text exactly. Engine: Docker image `mysql:8.0.33` +(pinned on purpose; do not bump). Needs Docker and your user in the `docker` +group. + +```bash +python3 -m python_pkg.sql_drills start # container + wait (<60 s) +python3 -m python_pkg.sql_drills list # ids and titles +python3 -m python_pkg.sql_drills show 01 # task, schema, output sample +python3 -m python_pkg.sql_drills check 01 my.sql # grade my.sql (exit 0 = pass) +python3 -m python_pkg.sql_drills stop # remove the container +``` + +Drills are addressed by full id (`01_pivot_sum`) or leading number (`01`). + +## Grading rules + +Output is `mysql -B -N` style: no header, tab separators become single +spaces, `NULL` prints as `NULL`, trailing whitespace is stripped per line. +Nothing else is normalised: case, decimal places and row order are strict, so +add the `ORDER BY` the task asks for. On mismatch you get an expected vs +actual diff per line. A fresh database is built for every check. + +## Layout + +- `fixtures//` holds `DOCS-task.md`, `schema.sql`, `expected.txt`. +- Reference solutions live only in `tests/solutions/`; `show` and `check` + never print them. +- `tests/test_live.py` runs every reference against the container (marker + `docker`, skipped when the container is down). diff --git a/python_pkg/sql_drills/__init__.py b/python_pkg/sql_drills/__init__.py new file mode 100644 index 00000000..73bfee49 --- /dev/null +++ b/python_pkg/sql_drills/__init__.py @@ -0,0 +1,5 @@ +"""MySQL 8 SQL drills graded like an online judge: exact output match. + +``python3 -m python_pkg.sql_drills start|stop|list|show|check`` -- see the +README in this folder. The engine is a pinned ``mysql:8.0.33`` container. +""" diff --git a/python_pkg/sql_drills/__main__.py b/python_pkg/sql_drills/__main__.py new file mode 100644 index 00000000..21184cbf --- /dev/null +++ b/python_pkg/sql_drills/__main__.py @@ -0,0 +1,68 @@ +"""CLI: ``python3 -m python_pkg.sql_drills start|stop|list|show|check``.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from python_pkg.sql_drills import _check, _drills, _engine + +_EXIT_CODES = {"pass": 0, "fail": 1, "error": 2} + + +def _out(line: str) -> None: + sys.stdout.write(f"{line}\n") + + +def _err(line: str) -> None: + sys.stderr.write(f"{line}\n") + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="sql_drills", description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("start", help="start the MySQL 8.0.33 container and wait") + sub.add_parser("stop", help="remove the container") + sub.add_parser("list", help="list drills in priority order") + show = sub.add_parser("show", help="print task, schema, expected sample") + show.add_argument("drill", help="drill id or number, e.g. 03") + check = sub.add_parser("check", help="grade a .sql file against a drill") + check.add_argument("drill", help="drill id or number, e.g. 03") + check.add_argument("sql_file", type=Path) + return parser + + +def _dispatch(args: argparse.Namespace) -> int: + if args.cmd == "start": + _engine.start() + _out(f"{_engine.CONTAINER} ready ({_engine.IMAGE})") + elif args.cmd == "stop": + _engine.stop() + _out(f"{_engine.CONTAINER} removed") + elif args.cmd == "list": + for drill in _drills.list_drills(): + _out(f"{drill.id} {_drills.title_of(drill)}") + elif args.cmd == "show": + sys.stdout.write(_drills.render_show(_drills.get_drill(args.drill))) + else: + result = _check.run_check(_drills.get_drill(args.drill), args.sql_file) + _out(result.message) + return _EXIT_CODES[result.status] + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Entry point; returns the process exit code.""" + args = _build_parser().parse_args(argv) + try: + return _dispatch(args) + except _drills.UnknownDrillError as exc: + _err(exc.args[0]) + except _engine.EngineError as exc: + _err(f"error: {exc}") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python_pkg/sql_drills/_check.py b/python_pkg/sql_drills/_check.py new file mode 100644 index 00000000..e2f16c14 --- /dev/null +++ b/python_pkg/sql_drills/_check.py @@ -0,0 +1,67 @@ +"""Grade one query: fresh database, drill schema, user's SQL, exact compare.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from python_pkg.sql_drills import _engine +from python_pkg.sql_drills._normalize import diff, normalize + +if TYPE_CHECKING: + from pathlib import Path + + from python_pkg.sql_drills._drills import Drill + + +@dataclass(frozen=True) +class CheckResult: + """``status`` is ``pass``, ``fail`` (wrong output) or ``error``.""" + + status: str + message: str + + +class _StepError(Exception): + """A pipeline step failed; the message is shown to the user.""" + + +def _step(result: _engine.SqlResult, what: str) -> str: + """Return stdout of a successful step or raise with its stderr.""" + if result.returncode != 0: + msg = f"{what}: {result.stderr.strip()}" + raise _StepError(msg) + return result.stdout + + +def _execute(drill: Drill, query_file: Path) -> str: + """Fresh DB, drill schema, then the user's SQL; return the raw output.""" + if not _engine.is_ready(): + msg = "MySQL is not running; run `start` first" + raise _StepError(msg) + if not query_file.is_file(): + msg = f"query file not found: {query_file}" + raise _StepError(msg) + db = f"drill_{drill.id}" + reset = _engine.run_sql(f"DROP DATABASE IF EXISTS {db}; CREATE DATABASE {db};") + _step(reset, "could not reset database") + _step(_engine.run_sql(drill.schema.read_text(), database=db), "drill schema failed") + result = _engine.run_sql(query_file.read_text(), database=db) + return _step(result, "SQL error") + + +def run_check(drill: Drill, query_file: Path) -> CheckResult: + """Load the drill into a fresh DB, run the query, compare to expected.""" + try: + output = _execute(drill, query_file) + except _StepError as exc: + return CheckResult("error", str(exc)) + expected = normalize(drill.expected.read_text()) + actual = normalize(output) + if expected == actual: + return CheckResult("pass", f"PASS {drill.id} ({len(actual)} rows)") + return CheckResult( + "fail", + f"FAIL {drill.id}: expected {len(expected)} rows, got {len(actual)}\n" + f"{diff(expected, actual)}", + ) diff --git a/python_pkg/sql_drills/_drills.py b/python_pkg/sql_drills/_drills.py new file mode 100644 index 00000000..9dc4181a --- /dev/null +++ b/python_pkg/sql_drills/_drills.py @@ -0,0 +1,65 @@ +"""Drill discovery: one folder per drill under ``fixtures/``. + +Each folder holds ``DOCS-task.md`` (statement), ``schema.sql`` (tables and data) +and ``expected.txt`` (the exact judge output). Reference solutions are NOT +here; they live under ``tests/solutions`` so learning mode never shows them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +DRILLS_DIR = Path(__file__).parent / "fixtures" +SAMPLE_ROWS = 5 + + +class UnknownDrillError(KeyError): + """Raised when a drill id or number prefix matches nothing.""" + + +@dataclass(frozen=True) +class Drill: + """A drill's id and the three files that define it.""" + + id: str + task: Path + schema: Path + expected: Path + + +def list_drills(root: Path = DRILLS_DIR) -> list[Drill]: + """All drills under ``root``, sorted by id (the priority order).""" + if not root.is_dir(): + return [] + return [ + Drill(d.name, d / "DOCS-task.md", d / "schema.sql", d / "expected.txt") + for d in sorted(root.iterdir()) + if d.is_dir() + ] + + +def get_drill(key: str, root: Path = DRILLS_DIR) -> Drill: + """Find a drill by full id or by its leading number (``03``).""" + for drill in list_drills(root): + if key in (drill.id, drill.id.split("_", 1)[0]): + return drill + msg = f"unknown drill: {key}" + raise UnknownDrillError(msg) + + +def title_of(drill: Drill) -> str: + """The task's first heading, without the leading ``#``.""" + return drill.task.read_text().splitlines()[0].lstrip("# ") + + +def render_show(drill: Drill) -> str: + """Task text, schema, and the first rows of the expected output.""" + rows = drill.expected.read_text().splitlines() + sample = "\n".join(rows[:SAMPLE_ROWS]) + more = f"\n... ({len(rows)} rows in total)" if len(rows) > SAMPLE_ROWS else "" + return ( + f"{drill.task.read_text().strip()}\n\n" + f"## Schema\n\n{drill.schema.read_text().strip()}\n\n" + f"## Expected output (sample)\n\n{sample}{more}\n" + ) diff --git a/python_pkg/sql_drills/_engine.py b/python_pkg/sql_drills/_engine.py new file mode 100644 index 00000000..5922ddff --- /dev/null +++ b/python_pkg/sql_drills/_engine.py @@ -0,0 +1,126 @@ +"""Thin Docker wrapper around one long-lived ``mysql:8.0.33`` container. + +The version is pinned on purpose (the one the judge documents); do not bump +it. The data dir is a tmpfs, so ``stop`` leaves nothing behind. Every call +here is short: ``start`` waits a bounded time and says "rerun" if MySQL is +still initialising, rather than blocking. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import shutil +import subprocess +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +IMAGE = "mysql:8.0.33" +CONTAINER = "sql-drills-mysql" +READY_TIMEOUT = 45.0 +QUERY_TIMEOUT = 30.0 +_DOCKER_TIMEOUT = 50.0 +_MYSQL = ["mysql", "-uroot", "--default-character-set=utf8mb4", "-B", "-N"] + + +class EngineError(RuntimeError): + """Docker or MySQL is unavailable or misbehaving.""" + + +@dataclass(frozen=True) +class SqlResult: + """Outcome of one ``mysql`` client invocation.""" + + returncode: int + stdout: str + stderr: str + + +def _docker( + *args: str, stdin: str | None = None, timeout: float = _DOCKER_TIMEOUT +) -> subprocess.CompletedProcess[str]: + """Run ``docker ``; map a missing binary or a timeout to EngineError.""" + exe = shutil.which("docker") + if exe is None: + msg = "docker binary not found" + raise EngineError(msg) + try: + return subprocess.run( + [exe, *args], + input=stdin, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + msg = f"docker {args[0]} timed out after {timeout:.0f}s" + raise EngineError(msg) from exc + + +def container_state() -> str: + """``running``, ``exited``, ... or ``missing`` when no such container.""" + res = _docker("inspect", "-f", "{{.State.Status}}", CONTAINER) + return res.stdout.strip() if res.returncode == 0 else "missing" + + +def is_ready() -> bool: + """True once MySQL accepts TCP connections (the init server does not).""" + res = _docker( + "exec", CONTAINER, "mysqladmin", "ping", "-h127.0.0.1", "-uroot", "--silent" + ) + return res.returncode == 0 + + +def start( + timeout: float = READY_TIMEOUT, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> None: + """Create or resume the container and wait (bounded) until it is ready.""" + state = container_state() + if state == "missing": + res = _docker( + "run", "-d", "--name", CONTAINER, + "--tmpfs", "/var/lib/mysql:rw,size=512m", + "--memory", "1500m", + "-e", "MYSQL_ALLOW_EMPTY_PASSWORD=yes", + IMAGE, + ) # fmt: skip + elif state != "running": + res = _docker("start", CONTAINER) + else: + res = subprocess.CompletedProcess([], 0, "", "") + if res.returncode != 0: + msg = f"could not start container: {res.stderr.strip()}" + raise EngineError(msg) + deadline = clock() + timeout + while not is_ready(): + if clock() >= deadline: + msg = f"MySQL not ready after {timeout:.0f}s; rerun `start` to keep waiting" + raise EngineError(msg) + sleep(1.0) + + +def stop() -> None: + """Remove the container (and its tmpfs data). Idempotent.""" + _docker("rm", "-f", CONTAINER) + + +def run_sql( + sql: str, database: str | None = None, timeout: float = QUERY_TIMEOUT +) -> SqlResult: + """Pipe ``sql`` into the mysql client, ``-B -N`` style.""" + argv = ["exec", "-i", CONTAINER, *_MYSQL] + if database: + argv.append(database) + try: + res = _docker(*argv, stdin=sql, timeout=timeout) + except EngineError as exc: + if "timed out" in str(exc): + msg = f"query timed out after {timeout:.0f}s" + raise EngineError(msg) from exc + raise + return SqlResult(res.returncode, res.stdout, res.stderr) diff --git a/python_pkg/sql_drills/_normalize.py b/python_pkg/sql_drills/_normalize.py new file mode 100644 index 00000000..eb49d1e5 --- /dev/null +++ b/python_pkg/sql_drills/_normalize.py @@ -0,0 +1,41 @@ +"""Output normalisation and diffing, mirroring an exact-match judge. + +The engine prints ``mysql -B -N`` output (no header, tab-separated, ``NULL`` +literal). Before comparing we turn each tab into one space and strip trailing +whitespace per line -- nothing else. Case, decimals and row order stay strict. +""" + +from __future__ import annotations + +from itertools import zip_longest + +_ABSENT = "" + + +def normalize(text: str) -> list[str]: + """Return judge-style lines: tabs to single spaces, right-stripped.""" + return [line.replace("\t", " ").rstrip() for line in text.splitlines()] + + +def matches(expected: str, actual: str) -> bool: + """True when both outputs normalise to identical line lists.""" + return normalize(expected) == normalize(actual) + + +def _show(line: str | None) -> str: + """Render a line for the diff, making odd spacing visible via repr.""" + if line is None: + return _ABSENT + if line.startswith(" ") or " " in line: + return repr(line) + return line + + +def diff(expected: list[str], actual: list[str]) -> str: + """Line-by-line expected/actual report for differing lines only.""" + blocks = [ + f"line {no}:\n expected: {_show(exp)}\n actual : {_show(act)}" + for no, (exp, act) in enumerate(zip_longest(expected, actual), start=1) + if exp != act + ] + return "\n".join(blocks) diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md b/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md new file mode 100644 index 00000000..139c877c --- /dev/null +++ b/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md @@ -0,0 +1,9 @@ +# Pivot quarters into columns with SUM(CASE WHEN) + +`shop_sales` holds one row per sale. Report the total amount per region with +one column per quarter. + +Output columns (no header): `region`, `Q1`, `Q2`, `Q3`, `Q4`. + +- A region with no sales in a quarter shows `0`, never `NULL`. +- Order by `region` ascending. diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt b/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt new file mode 100644 index 00000000..2848247a --- /dev/null +++ b/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt @@ -0,0 +1,4 @@ +East 0 300 0 25 +North 200 150 0 200 +South 90 0 100 0 +West 10 0 0 0 diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql b/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql new file mode 100644 index 00000000..2596736b --- /dev/null +++ b/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE shop_sales ( + id INT PRIMARY KEY, + region VARCHAR(20) NOT NULL, + quarter CHAR(2) NOT NULL, + amount INT NOT NULL +); +INSERT INTO shop_sales (id, region, quarter, amount) VALUES + (1, 'North', 'Q1', 120), (2, 'North', 'Q1', 80), + (3, 'North', 'Q2', 150), (4, 'North', 'Q4', 200), + (5, 'South', 'Q1', 90), (6, 'South', 'Q3', 60), + (7, 'South', 'Q3', 40), (8, 'East', 'Q2', 300), + (9, 'East', 'Q4', 25), (10, 'West', 'Q1', 10); diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md b/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md new file mode 100644 index 00000000..61702955 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md @@ -0,0 +1,10 @@ +# Pivot text attributes with MAX(CASE WHEN) + +`person_attrs` stores one attribute per row (`email`, `phone`, `city`) in a +text column. Turn it into one row per person. + +Output columns (no header): `person`, `email`, `phone`, `city`. + +- A missing attribute prints as `NULL`. +- Order by `person` ascending. +- Hint: `SUM` cannot pivot text; think about which aggregate can. diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt b/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt new file mode 100644 index 00000000..afa1fe4e --- /dev/null +++ b/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt @@ -0,0 +1,4 @@ +Ida ida@example.org 555-0103 Krakow +Max NULL 555-0102 Lodz +Ola ola@example.org NULL NULL +Zed zed@example.org 555-0101 Gdansk diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql b/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql new file mode 100644 index 00000000..cc163ee8 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE person_attrs ( + person VARCHAR(20) NOT NULL, + attr VARCHAR(10) NOT NULL, + val VARCHAR(40) NOT NULL, + PRIMARY KEY (person, attr) +); +INSERT INTO person_attrs (person, attr, val) VALUES + ('Zed', 'email', 'zed@example.org'), ('Zed', 'city', 'Gdansk'), + ('Zed', 'phone', '555-0101'), ('Ola', 'email', 'ola@example.org'), + ('Max', 'city', 'Lodz'), ('Max', 'phone', '555-0102'), + ('Ida', 'phone', '555-0103'), ('Ida', 'email', 'ida@example.org'), + ('Ida', 'city', 'Krakow'); diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md b/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md new file mode 100644 index 00000000..678e61c4 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md @@ -0,0 +1,11 @@ +# Month buckets with DATE_FORMAT and conditional aggregation + +`orders` has one row per order with a status of `shipped`, `pending` or +`cancelled`. Summarise per calendar month. + +Output columns (no header): `month` (as `YYYY-MM`), total orders, shipped +orders, cancelled orders, revenue of shipped orders only. + +- Revenue is `0.00` for a month with no shipped orders. +- Only months that have at least one order appear. +- Order by `month` ascending. diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt b/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt new file mode 100644 index 00000000..59260e92 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt @@ -0,0 +1,5 @@ +2022-12 1 1 0 10.00 +2023-01 3 2 1 125.50 +2023-02 3 1 1 80.00 +2023-04 3 2 1 215.25 +2023-06 1 0 0 0.00 diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql b/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql new file mode 100644 index 00000000..1324575c --- /dev/null +++ b/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql @@ -0,0 +1,19 @@ +CREATE TABLE orders ( + id INT PRIMARY KEY, + customer VARCHAR(20) NOT NULL, + order_date DATETIME NOT NULL, + status VARCHAR(10) NOT NULL, + total DECIMAL(8, 2) NOT NULL +); +INSERT INTO orders (id, customer, order_date, status, total) VALUES + (1, 'Ann', '2023-01-05 10:00:00', 'shipped', 100.00), + (2, 'Bob', '2023-01-20 11:30:00', 'cancelled', 50.00), + (3, 'Ann', '2023-01-31 23:59:59', 'shipped', 25.50), + (4, 'Cy', '2023-02-01 00:00:00', 'shipped', 80.00), + (5, 'Bob', '2023-02-14 09:15:00', 'pending', 60.00), + (6, 'Dan', '2023-02-28 18:00:00', 'cancelled', 30.00), + (7, 'Ann', '2023-04-02 08:00:00', 'shipped', 200.00), + (8, 'Cy', '2023-04-30 23:00:00', 'shipped', 15.25), + (9, 'Dan', '2023-04-30 12:00:00', 'cancelled', 99.99), + (10, 'Bob', '2022-12-31 22:00:00', 'shipped', 10.00), + (11, 'Eve', '2023-06-10 10:00:00', 'pending', 70.00); diff --git a/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md b/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md new file mode 100644 index 00000000..9be709db --- /dev/null +++ b/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md @@ -0,0 +1,11 @@ +# Date formatting: %i is minutes, %M is the month name + +`events` has a `DATETIME` column. Print each event in two display formats. + +Output columns (no header): `id`, then the start as +`Friday, 05 March 2021 09:07` (weekday name, 2-digit day, month name, year, +24-hour `HH:MM`), then the 12-hour time as `09:07 AM`. + +- Order by `id` ascending. +- Trap: mixing up `%i` (minutes) with `%M` (month name), and `%h` (12-hour) + with `%H` (24-hour). diff --git a/python_pkg/sql_drills/fixtures/04_date_format/expected.txt b/python_pkg/sql_drills/fixtures/04_date_format/expected.txt new file mode 100644 index 00000000..2539e607 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/04_date_format/expected.txt @@ -0,0 +1,5 @@ +1 Friday, 05 March 2021 09:07 09:07 AM +2 Wednesday, 30 November 2022 17:45 05:45 PM +3 Saturday, 29 February 2020 00:05 12:05 AM +4 Tuesday, 04 July 2023 12:00 12:00 PM +5 Tuesday, 31 December 2019 23:59 11:59 PM diff --git a/python_pkg/sql_drills/fixtures/04_date_format/schema.sql b/python_pkg/sql_drills/fixtures/04_date_format/schema.sql new file mode 100644 index 00000000..1db92402 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/04_date_format/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE events ( + id INT PRIMARY KEY, + name VARCHAR(30) NOT NULL, + starts_at DATETIME NOT NULL +); +INSERT INTO events (id, name, starts_at) VALUES + (1, 'Launch party', '2021-03-05 09:07:00'), + (2, 'Retro', '2022-11-30 17:45:10'), + (3, 'Offsite', '2020-02-29 00:05:00'), + (4, 'Standup', '2023-07-04 12:00:00'), + (5, 'Closing', '2019-12-31 23:59:59'); diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md b/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md new file mode 100644 index 00000000..e7994c86 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md @@ -0,0 +1,10 @@ +# Parse text dates with STR_TO_DATE + +`signups.signup_text` stores a timestamp as text in the US layout +`MM/DD/YYYY HH:MM` (for example `03/14/2022 18:45`). + +Output columns (no header): `id`, then the signup date as `YYYY-MM-DD`. + +- Keep only signups on or after 2022-03-01 (compare real dates, not text). +- Order by the parsed timestamp ascending. +- Trap: ordering the raw text sorts by month first, across years. diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt b/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt new file mode 100644 index 00000000..fdb4a0cc --- /dev/null +++ b/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt @@ -0,0 +1,5 @@ +6 2022-03-01 +3 2022-03-02 +1 2022-03-14 +4 2022-11-30 +7 2023-02-28 diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql b/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql new file mode 100644 index 00000000..b7cf3f1e --- /dev/null +++ b/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql @@ -0,0 +1,13 @@ +CREATE TABLE signups ( + id INT PRIMARY KEY, + email VARCHAR(40) NOT NULL, + signup_text VARCHAR(20) NOT NULL +); +INSERT INTO signups (id, email, signup_text) VALUES + (1, 'a@example.org', '03/14/2022 18:45'), + (2, 'b@example.org', '12/01/2021 08:00'), + (3, 'c@example.org', '03/02/2022 23:10'), + (4, 'd@example.org', '11/30/2022 00:00'), + (5, 'e@example.org', '01/09/2022 12:30'), + (6, 'f@example.org', '03/01/2022 00:00'), + (7, 'g@example.org', '02/28/2023 07:05'); diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md b/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md new file mode 100644 index 00000000..5aa54bd4 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md @@ -0,0 +1,15 @@ +# Number traps: AVG scale, ROUND, CAST AS DECIMAL + +`team_results` has one row per team per season. + +Output columns (no header), per team: + +1. `team` +2. average wins, `ROUND`ed to 2 decimals +3. average games, NOT rounded (print whatever MySQL gives you) +4. win percentage `100 * SUM(wins) / SUM(games)` as `DECIMAL(5,1)` + via `CAST` + +- Order by `team` ascending. +- Trap: `AVG()` of an integer column returns 4 decimals (`2.5000`), and + `/` is decimal division in MySQL (use `DIV` for the integer kind). diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt b/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt new file mode 100644 index 00000000..b62fbff8 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt @@ -0,0 +1,3 @@ +Hawks 8.33 12.6667 65.8 +Lions 5.50 10.0000 55.0 +Owls 0.00 8.0000 0.0 diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql b/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql new file mode 100644 index 00000000..b0ab0d70 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE team_results ( + id INT PRIMARY KEY, + team VARCHAR(20) NOT NULL, + season INT NOT NULL, + wins INT NOT NULL, + games INT NOT NULL +); +INSERT INTO team_results (id, team, season, wins, games) VALUES + (1, 'Hawks', 2021, 7, 12), (2, 'Hawks', 2022, 8, 12), + (3, 'Hawks', 2023, 10, 14), (4, 'Lions', 2021, 5, 10), + (5, 'Lions', 2022, 6, 10), (6, 'Owls', 2022, 0, 8); diff --git a/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md b/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md new file mode 100644 index 00000000..882439dd --- /dev/null +++ b/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md @@ -0,0 +1,13 @@ +# Thousands separators with FORMAT + +`invoices` has one row per invoice. + +Output columns (no header), per customer: + +1. `customer` +2. total amount with thousands separators and 2 decimals (`35,200.59`) +3. average amount with thousands separators and 0 decimals + +- Order by the numeric total descending (not the formatted text). +- Trap: `FORMAT` returns text, so sort on the `SUM`, and `FORMAT(x, 0)` is + not the same string as `ROUND(x)` (no separators). diff --git a/python_pkg/sql_drills/fixtures/07_number_format/expected.txt b/python_pkg/sql_drills/fixtures/07_number_format/expected.txt new file mode 100644 index 00000000..10603ba8 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/07_number_format/expected.txt @@ -0,0 +1,4 @@ +Cog 1,002,500.25 501,250 +Acme 35,200.59 11,734 +Bolt 999.99 1,000 +Dyn 9.00 5 diff --git a/python_pkg/sql_drills/fixtures/07_number_format/schema.sql b/python_pkg/sql_drills/fixtures/07_number_format/schema.sql new file mode 100644 index 00000000..1d7f70c9 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/07_number_format/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE invoices ( + id INT PRIMARY KEY, + customer VARCHAR(20) NOT NULL, + amount DECIMAL(12, 2) NOT NULL +); +INSERT INTO invoices (id, customer, amount) VALUES + (1, 'Acme', 1200.50), (2, 'Acme', 33999.99), (3, 'Acme', 0.10), + (4, 'Bolt', 999.99), (5, 'Cog', 1000000.00), (6, 'Cog', 2500.25), + (7, 'Dyn', 5.00), (8, 'Dyn', 4.00); diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md b/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md new file mode 100644 index 00000000..a74f35b0 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md @@ -0,0 +1,12 @@ +# LEFT JOIN anti-join and NULL handling + +List customers who have never placed a valid order. An order is valid when +its status is not `cancelled`; a customer with only cancelled orders (or no +orders) qualifies. + +Output columns (no header): `name`, `phone`, where a missing phone prints as +`n/a`. + +- Order by `name` ascending. +- Trap: put the status condition in the `ON` clause, not in `WHERE`, or the + cancelled-only customers vanish. diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt b/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt new file mode 100644 index 00000000..e63b85c7 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt @@ -0,0 +1,3 @@ +Boris n/a +Cleo 555-0103 +Dara n/a diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql b/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql new file mode 100644 index 00000000..f243b342 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE customers ( + id INT PRIMARY KEY, + name VARCHAR(20) NOT NULL, + phone VARCHAR(20) NULL +); +CREATE TABLE orders ( + id INT PRIMARY KEY, + customer_id INT NOT NULL, + status VARCHAR(10) NOT NULL +); +INSERT INTO customers (id, name, phone) VALUES + (1, 'Alma', '555-0101'), (2, 'Boris', NULL), (3, 'Cleo', '555-0103'), + (4, 'Dara', NULL), (5, 'Egon', '555-0105'); +INSERT INTO orders (id, customer_id, status) VALUES + (1, 1, 'shipped'), (2, 3, 'cancelled'), (3, 3, 'cancelled'), + (4, 5, 'pending'), (5, 1, 'cancelled'); diff --git a/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md b/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md new file mode 100644 index 00000000..bd8f5c8f --- /dev/null +++ b/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md @@ -0,0 +1,9 @@ +# GROUP BY with WHERE versus HAVING + +`tickets` holds support tickets with hours spent. Ignoring `low` priority +tickets, find teams with at least 3 tickets AND at least 10 total hours. + +Output columns (no header): `team`, ticket count, total hours. + +- Order by total hours descending, then `team` ascending. +- Row filters (`priority`) go in `WHERE`; aggregate filters in `HAVING`. diff --git a/python_pkg/sql_drills/fixtures/09_group_having/expected.txt b/python_pkg/sql_drills/fixtures/09_group_having/expected.txt new file mode 100644 index 00000000..0332b82b --- /dev/null +++ b/python_pkg/sql_drills/fixtures/09_group_having/expected.txt @@ -0,0 +1,3 @@ +Gamma 4 16.0 +Alpha 3 11.0 +Eps 3 10.0 diff --git a/python_pkg/sql_drills/fixtures/09_group_having/schema.sql b/python_pkg/sql_drills/fixtures/09_group_having/schema.sql new file mode 100644 index 00000000..ed149005 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/09_group_having/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE tickets ( + id INT PRIMARY KEY, + team VARCHAR(10) NOT NULL, + priority VARCHAR(10) NOT NULL, + hours DECIMAL(5, 1) NOT NULL +); +INSERT INTO tickets (id, team, priority, hours) VALUES + (1, 'Alpha', 'high', 5.0), (2, 'Alpha', 'medium', 3.5), + (3, 'Alpha', 'low', 9.0), (4, 'Alpha', 'high', 2.5), + (5, 'Beta', 'medium', 4.0), (6, 'Beta', 'high', 6.0), + (7, 'Gamma', 'high', 4.0), (8, 'Gamma', 'medium', 4.0), + (9, 'Gamma', 'high', 4.0), (10, 'Gamma', 'medium', 4.0), + (11, 'Delta', 'medium', 1.0), (12, 'Delta', 'medium', 1.0), + (13, 'Delta', 'low', 50.0), (14, 'Delta', 'high', 1.0), + (15, 'Eps', 'medium', 3.5), (16, 'Eps', 'medium', 3.5), + (17, 'Eps', 'high', 3.0); diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md b/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md new file mode 100644 index 00000000..ce696e4b --- /dev/null +++ b/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md @@ -0,0 +1,9 @@ +# Top-N per group with DENSE_RANK + +Show employees whose salary is among the two highest DISTINCT salaries in +their department (ties share a rank, so more than two people can appear). + +Output columns (no header): `dept`, `name`, `salary`, the dense rank. + +- Order by `dept`, then rank, then `name` ascending. +- `RANK()` would skip numbers after ties; this drill wants `DENSE_RANK()`. diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt b/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt new file mode 100644 index 00000000..dc2e726a --- /dev/null +++ b/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt @@ -0,0 +1,8 @@ +Eng Ava 150 1 +Eng Ben 150 1 +Eng Cal 130 2 +Ops Fay 90 1 +Ops Gus 80 2 +Sales Ivy 100 1 +Sales Jon 100 1 +Sales Kim 100 1 diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql b/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql new file mode 100644 index 00000000..4dc2e157 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE employees ( + id INT PRIMARY KEY, + name VARCHAR(10) NOT NULL, + dept VARCHAR(10) NOT NULL, + salary INT NOT NULL +); +INSERT INTO employees (id, name, dept, salary) VALUES + (1, 'Ava', 'Eng', 150), (2, 'Ben', 'Eng', 150), (3, 'Cal', 'Eng', 130), + (4, 'Dev', 'Eng', 120), (5, 'Eli', 'Eng', 120), (6, 'Fay', 'Ops', 90), + (7, 'Gus', 'Ops', 80), (8, 'Hal', 'Ops', 70), (9, 'Ivy', 'Sales', 100), + (10, 'Jon', 'Sales', 100), (11, 'Kim', 'Sales', 100); diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md b/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md new file mode 100644 index 00000000..4098918c --- /dev/null +++ b/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md @@ -0,0 +1,10 @@ +# LAG and DATEDIFF: find long gaps between logins + +`logins` has one row per user per day they logged in. Find every pair of +consecutive logins by the same user that are 7 or more days apart. + +Output columns (no header): `user_name`, previous login date, login date, +gap in days. + +- Order by `user_name`, then login date ascending. +- Window functions cannot appear in `WHERE`; wrap them in a CTE or subquery. diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt b/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt new file mode 100644 index 00000000..8b5438b4 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt @@ -0,0 +1,4 @@ +amy 2023-01-02 2023-01-10 8 +amy 2023-01-10 2023-01-17 7 +amy 2023-01-17 2023-02-01 15 +dan 2024-02-20 2024-03-01 10 diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql b/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql new file mode 100644 index 00000000..9eeeedcb --- /dev/null +++ b/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE logins ( + user_name VARCHAR(10) NOT NULL, + login_on DATE NOT NULL, + PRIMARY KEY (user_name, login_on) +); +INSERT INTO logins (user_name, login_on) VALUES + ('amy', '2023-01-01'), ('amy', '2023-01-02'), ('amy', '2023-01-10'), + ('amy', '2023-01-17'), ('amy', '2023-02-01'), ('bo', '2023-03-01'), + ('bo', '2023-03-05'), ('cat', '2023-01-05'), ('dan', '2024-02-20'), + ('dan', '2024-03-01'); diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md b/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md new file mode 100644 index 00000000..ab461d6c --- /dev/null +++ b/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md @@ -0,0 +1,11 @@ +# CONCAT and percentage formatting + +`poll_votes` has the vote count per option for several polls. For each +option print its share of its own poll's votes with one decimal. + +Output columns (no header): `poll_id`, then text like `Red: 42.9%`. + +- Order by `poll_id`, then votes descending, then option name ascending. +- A share of exactly 50 prints `50.0%` and zero prints `0.0%`. +- Trap: `ROUND(x, 1)` keeps the trailing `.0` only on a DECIMAL, so do the + arithmetic in decimal, not by dividing integers with `DIV`. diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt b/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt new file mode 100644 index 00000000..97f3e67f --- /dev/null +++ b/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt @@ -0,0 +1,7 @@ +1 Green: 42.9% +1 Red: 42.9% +1 Blue: 14.3% +2 No: 50.0% +2 Yes: 50.0% +3 Sure: 100.0% +3 Maybe: 0.0% diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql b/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql new file mode 100644 index 00000000..f7dbfa02 --- /dev/null +++ b/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE poll_votes ( + poll_id INT NOT NULL, + option_name VARCHAR(10) NOT NULL, + votes INT NOT NULL, + PRIMARY KEY (poll_id, option_name) +); +INSERT INTO poll_votes (poll_id, option_name, votes) VALUES + (1, 'Red', 3), (1, 'Green', 3), (1, 'Blue', 1), + (2, 'Yes', 5), (2, 'No', 5), + (3, 'Maybe', 0), (3, 'Sure', 4); diff --git a/python_pkg/sql_drills/tests/__init__.py b/python_pkg/sql_drills/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql b/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql new file mode 100644 index 00000000..79040de2 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql @@ -0,0 +1,8 @@ +SELECT region, + SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) +FROM shop_sales +GROUP BY region +ORDER BY region; diff --git a/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql b/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql new file mode 100644 index 00000000..b41f0163 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql @@ -0,0 +1,7 @@ +SELECT person, + MAX(CASE WHEN attr = 'email' THEN val END), + MAX(CASE WHEN attr = 'phone' THEN val END), + MAX(CASE WHEN attr = 'city' THEN val END) +FROM person_attrs +GROUP BY person +ORDER BY person; diff --git a/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql b/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql new file mode 100644 index 00000000..621a6e0b --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql @@ -0,0 +1,8 @@ +SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, + COUNT(*), + SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'shipped' THEN total ELSE 0 END) +FROM orders +GROUP BY month +ORDER BY month; diff --git a/python_pkg/sql_drills/tests/solutions/04_date_format.sql b/python_pkg/sql_drills/tests/solutions/04_date_format.sql new file mode 100644 index 00000000..3fccebdf --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/04_date_format.sql @@ -0,0 +1,5 @@ +SELECT id, + DATE_FORMAT(starts_at, '%W, %d %M %Y %H:%i'), + DATE_FORMAT(starts_at, '%h:%i %p') +FROM events +ORDER BY id; diff --git a/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql b/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql new file mode 100644 index 00000000..6227cd4b --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql @@ -0,0 +1,4 @@ +SELECT id, DATE_FORMAT(STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), '%Y-%m-%d') +FROM signups +WHERE STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i') >= '2022-03-01' +ORDER BY STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), id; diff --git a/python_pkg/sql_drills/tests/solutions/06_number_traps.sql b/python_pkg/sql_drills/tests/solutions/06_number_traps.sql new file mode 100644 index 00000000..cc4cdaf1 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/06_number_traps.sql @@ -0,0 +1,7 @@ +SELECT team, + ROUND(AVG(wins), 2), + AVG(games), + CAST(100 * SUM(wins) / SUM(games) AS DECIMAL(5, 1)) +FROM team_results +GROUP BY team +ORDER BY team; diff --git a/python_pkg/sql_drills/tests/solutions/07_number_format.sql b/python_pkg/sql_drills/tests/solutions/07_number_format.sql new file mode 100644 index 00000000..197c68b7 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/07_number_format.sql @@ -0,0 +1,4 @@ +SELECT customer, FORMAT(SUM(amount), 2), FORMAT(AVG(amount), 0) +FROM invoices +GROUP BY customer +ORDER BY SUM(amount) DESC; diff --git a/python_pkg/sql_drills/tests/solutions/08_anti_join.sql b/python_pkg/sql_drills/tests/solutions/08_anti_join.sql new file mode 100644 index 00000000..b7c6af59 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/08_anti_join.sql @@ -0,0 +1,5 @@ +SELECT c.name, COALESCE(c.phone, 'n/a') +FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled' +WHERE o.id IS NULL +ORDER BY c.name; diff --git a/python_pkg/sql_drills/tests/solutions/09_group_having.sql b/python_pkg/sql_drills/tests/solutions/09_group_having.sql new file mode 100644 index 00000000..87304bfd --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/09_group_having.sql @@ -0,0 +1,6 @@ +SELECT team, COUNT(*), SUM(hours) +FROM tickets +WHERE priority <> 'low' +GROUP BY team +HAVING COUNT(*) >= 3 AND SUM(hours) >= 10 +ORDER BY SUM(hours) DESC, team; diff --git a/python_pkg/sql_drills/tests/solutions/10_window_topn.sql b/python_pkg/sql_drills/tests/solutions/10_window_topn.sql new file mode 100644 index 00000000..63b98bb0 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/10_window_topn.sql @@ -0,0 +1,8 @@ +SELECT dept, name, salary, rnk +FROM ( + SELECT dept, name, salary, + DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk + FROM employees +) ranked +WHERE rnk <= 2 +ORDER BY dept, rnk, name; diff --git a/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql b/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql new file mode 100644 index 00000000..b27739f1 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql @@ -0,0 +1,10 @@ +WITH gaps AS ( + SELECT user_name, + LAG(login_on) OVER (PARTITION BY user_name ORDER BY login_on) AS prev_on, + login_on + FROM logins +) +SELECT user_name, prev_on, login_on, DATEDIFF(login_on, prev_on) +FROM gaps +WHERE DATEDIFF(login_on, prev_on) >= 7 +ORDER BY user_name, login_on; diff --git a/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql b/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql new file mode 100644 index 00000000..ddd68d45 --- /dev/null +++ b/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql @@ -0,0 +1,5 @@ +SELECT poll_id, + CONCAT(option_name, ': ', + ROUND(100 * votes / SUM(votes) OVER (PARTITION BY poll_id), 1), '%') +FROM poll_votes +ORDER BY poll_id, votes DESC, option_name; diff --git a/python_pkg/sql_drills/tests/test_check.py b/python_pkg/sql_drills/tests/test_check.py new file mode 100644 index 00000000..1d1b36f1 --- /dev/null +++ b/python_pkg/sql_drills/tests/test_check.py @@ -0,0 +1,105 @@ +"""``run_check`` orchestration with the engine mocked.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from python_pkg.sql_drills import _check, _drills, _engine +from python_pkg.sql_drills._engine import SqlResult + + +def _drill(tmp_path: Path, expected: str = "a 1\n") -> _drills.Drill: + (tmp_path / "DOCS-task.md").write_text("# T\n") + (tmp_path / "schema.sql").write_text("CREATE TABLE t (a INT);") + (tmp_path / "expected.txt").write_text(expected) + return _drills.Drill( + "09_x", + tmp_path / "DOCS-task.md", + tmp_path / "schema.sql", + tmp_path / "expected.txt", + ) + + +def _query(tmp_path: Path) -> Path: + q = tmp_path / "q.sql" + q.write_text("SELECT 'a', 1;") + return q + + +def _patch_engine(*results: SqlResult, ready: bool = True) -> tuple[Any, Any]: + return ( + patch.object(_engine, "is_ready", return_value=ready), + patch.object(_engine, "run_sql", side_effect=list(results)), + ) + + +OK = SqlResult(0, "", "") + + +def test_pass(tmp_path: Path) -> None: + ready, run = _patch_engine(OK, OK, SqlResult(0, "a\t1\n", "")) + with ready, run as run_sql: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "pass" + assert run_sql.call_args_list[0].args[0].startswith("DROP DATABASE IF EXISTS") + assert run_sql.call_args_list[2].kwargs["database"] == "drill_09_x" + + +def test_mismatch_includes_diff(tmp_path: Path) -> None: + ready, run = _patch_engine(OK, OK, SqlResult(0, "a\t2\n", "")) + with ready, run: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "fail" + assert "expected: a 1" in res.message + assert "actual : a 2" in res.message + + +def test_sql_error_reports_stderr(tmp_path: Path) -> None: + ready, run = _patch_engine(OK, OK, SqlResult(1, "", "ERROR 1064: syntax")) + with ready, run: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "error" + assert "ERROR 1064" in res.message + + +def test_not_ready_tells_user_to_start(tmp_path: Path) -> None: + ready, run = _patch_engine(ready=False) + with ready, run: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "error" + assert "start" in res.message + + +def test_schema_failure_is_error(tmp_path: Path) -> None: + ready, run = _patch_engine(OK, SqlResult(1, "", "bad schema")) + with ready, run: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "error" + assert "bad schema" in res.message + + +def test_reset_failure_is_error(tmp_path: Path) -> None: + ready, run = _patch_engine(SqlResult(1, "", "no perms")) + with ready, run: + res = _check.run_check(_drill(tmp_path), _query(tmp_path)) + assert res.status == "error" + + +def test_missing_query_file(tmp_path: Path) -> None: + ready, run = _patch_engine() + with ready, run: + res = _check.run_check(_drill(tmp_path), tmp_path / "nope.sql") + assert res.status == "error" + assert "nope.sql" in res.message + + +def test_engine_error_propagates_to_cli(tmp_path: Path) -> None: + with ( + patch.object(_engine, "is_ready", side_effect=_engine.EngineError("no docker")), + pytest.raises(_engine.EngineError), + ): + _check.run_check(_drill(tmp_path), _query(tmp_path)) diff --git a/python_pkg/sql_drills/tests/test_cli.py b/python_pkg/sql_drills/tests/test_cli.py new file mode 100644 index 00000000..6fd0e88b --- /dev/null +++ b/python_pkg/sql_drills/tests/test_cli.py @@ -0,0 +1,67 @@ +"""CLI wiring: subcommands, exit codes, output.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from python_pkg.sql_drills import __main__ as cli +from python_pkg.sql_drills import _check, _engine + + +def test_list_prints_ids_and_titles(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["list"]) == 0 + out = capsys.readouterr().out + assert "01_" in out + assert len(out.strip().splitlines()) >= 10 + + +def test_show_prints_task(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["show", "01"]) == 0 + assert "CREATE TABLE" in capsys.readouterr().out + + +def test_show_unknown_drill_exit_2(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["show", "zzz"]) == 2 + assert "unknown drill" in capsys.readouterr().err + + +def test_start_and_stop_call_engine(capsys: pytest.CaptureFixture[str]) -> None: + with patch.object(_engine, "start") as start, patch.object(_engine, "stop") as stop: + assert cli.main(["start"]) == 0 + assert cli.main(["stop"]) == 0 + start.assert_called_once_with() + stop.assert_called_once_with() + assert "ready" in capsys.readouterr().out + + +def test_engine_error_is_exit_2(capsys: pytest.CaptureFixture[str]) -> None: + with patch.object(_engine, "start", side_effect=_engine.EngineError("boom")): + assert cli.main(["start"]) == 2 + assert "boom" in capsys.readouterr().err + + +@pytest.mark.parametrize(("status", "code"), [("pass", 0), ("fail", 1), ("error", 2)]) +def test_check_exit_codes( + status: str, code: int, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _check.CheckResult(status, "MSG") + with patch.object(_check, "run_check", return_value=result): + assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == code + assert "MSG" in capsys.readouterr().out + + +def test_check_unknown_drill(tmp_path: Path) -> None: + assert cli.main(["check", "zzz", str(tmp_path / "q.sql")]) == 2 + + +def test_check_engine_error_exit_2(tmp_path: Path) -> None: + with patch.object(_check, "run_check", side_effect=_engine.EngineError("x")): + assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == 2 + + +def test_no_subcommand_is_usage_error() -> None: + with pytest.raises(SystemExit): + cli.main([]) diff --git a/python_pkg/sql_drills/tests/test_drills.py b/python_pkg/sql_drills/tests/test_drills.py new file mode 100644 index 00000000..fee33476 --- /dev/null +++ b/python_pkg/sql_drills/tests/test_drills.py @@ -0,0 +1,68 @@ +"""Drill discovery and rendering over the bundled drills.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.sql_drills import _drills + +SOLUTIONS = Path(__file__).parent / "solutions" + + +def test_drills_are_listed_in_priority_order() -> None: + ids = [d.id for d in _drills.list_drills()] + assert ids == sorted(ids) + assert len(ids) >= 10 + + +def test_every_drill_has_all_parts_and_a_reference() -> None: + for d in _drills.list_drills(): + assert d.schema.read_text().strip(), d.id + assert d.task.read_text().strip(), d.id + assert d.expected.read_text().strip(), d.id + assert (SOLUTIONS / f"{d.id}.sql").is_file(), d.id + + +def test_get_by_full_id_and_by_number_prefix() -> None: + first = _drills.list_drills()[0] + assert _drills.get_drill(first.id) == first + assert _drills.get_drill(first.id.split("_")[0]) == first + + +def test_unknown_drill_raises() -> None: + with pytest.raises(_drills.UnknownDrillError): + _drills.get_drill("nope") + + +def test_show_has_task_schema_and_sample_but_no_solution() -> None: + d = _drills.list_drills()[0] + text = _drills.render_show(d) + assert d.task.read_text().strip() in text + assert "CREATE TABLE" in text + first_expected = d.expected.read_text().splitlines()[0] + assert first_expected in text + assert (SOLUTIONS / f"{d.id}.sql").read_text().strip() not in text + + +def test_show_truncates_long_expected_output(tmp_path: Path) -> None: + exp = tmp_path / "expected.txt" + exp.write_text("\n".join(f"row{i}" for i in range(20)) + "\n") + task = tmp_path / "DOCS-task.md" + task.write_text("T") + schema = tmp_path / "schema.sql" + schema.write_text("S") + text = _drills.render_show(_drills.Drill("x", task, schema, exp)) + assert "row0" in text + assert "row19" not in text + assert "20 rows in total" in text + + +def test_title_is_first_heading() -> None: + d = _drills.list_drills()[0] + assert _drills.title_of(d) == d.task.read_text().splitlines()[0].lstrip("# ") + + +def test_missing_drills_dir_lists_nothing(tmp_path: Path) -> None: + assert _drills.list_drills(tmp_path / "absent") == [] diff --git a/python_pkg/sql_drills/tests/test_engine.py b/python_pkg/sql_drills/tests/test_engine.py new file mode 100644 index 00000000..efc7df55 --- /dev/null +++ b/python_pkg/sql_drills/tests/test_engine.py @@ -0,0 +1,137 @@ +"""Docker plumbing with ``subprocess.run`` mocked: no daemon needed.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +import shutil +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from python_pkg.sql_drills import _engine + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture(autouse=True) +def _fake_docker_on_path() -> Iterator[None]: + with patch.object(shutil, "which", return_value="docker"): + yield + + +def _cp(rc: int = 0, out: str = "", err: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], rc, out, err) + + +def _run( + *results: subprocess.CompletedProcess[str], +) -> AbstractContextManager[MagicMock | AsyncMock]: + return patch.object(subprocess, "run", side_effect=list(results)) + + +def test_docker_missing_binary_is_engine_error() -> None: + with ( + patch.object(shutil, "which", return_value=None), + pytest.raises(_engine.EngineError, match="docker"), + ): + _engine.container_state() + + +def test_container_state_running() -> None: + with _run(_cp(0, "running\n")): + assert _engine.container_state() == "running" + + +def test_container_state_missing_on_inspect_failure() -> None: + with _run(_cp(1, "", "No such object")): + assert _engine.container_state() == "missing" + + +def test_is_ready_follows_ping_exit_code() -> None: + with _run(_cp(0)): + assert _engine.is_ready() + with _run(_cp(1)): + assert not _engine.is_ready() + + +def test_start_creates_container_when_missing_then_waits() -> None: + with _run(_cp(1), _cp(0, "id"), _cp(1), _cp(0)) as run: + _engine.start(timeout=5, sleep=lambda _s: None) + run_args = run.call_args_list[1].args[0] + assert run_args[:3] == ["docker", "run", "-d"] + assert "--tmpfs" in run_args + assert _engine.IMAGE in run_args + assert "--name" in run_args + assert _engine.CONTAINER in run_args + + +def test_start_resumes_exited_container() -> None: + with _run(_cp(0, "exited\n"), _cp(0), _cp(0)) as run: + _engine.start(timeout=5, sleep=lambda _s: None) + assert run.call_args_list[1].args[0][:2] == ["docker", "start"] + + +def test_start_when_already_running_only_waits() -> None: + with _run(_cp(0, "running\n"), _cp(0)) as run: + _engine.start(timeout=5, sleep=lambda _s: None) + assert run.call_count == 2 + + +def test_start_run_failure_is_reported() -> None: + with ( + _run(_cp(1), _cp(125, "", "pull access denied")), + pytest.raises(_engine.EngineError, match="pull access denied"), + ): + _engine.start(timeout=5, sleep=lambda _s: None) + + +def test_start_times_out_with_rerun_hint() -> None: + ticks = iter([0.0, 1.0, 2.0, 99.0, 100.0]) + results = [_cp(0, "running\n")] + [_cp(1)] * 10 + with _run(*results), pytest.raises(_engine.EngineError, match="rerun"): + _engine.start(timeout=10, sleep=lambda _s: None, clock=lambda: next(ticks)) + + +def test_stop_removes_container() -> None: + with _run(_cp(0)) as run: + _engine.stop() + assert run.call_args.args[0] == ["docker", "rm", "-f", _engine.CONTAINER] + + +def test_run_sql_passes_stdin_and_database() -> None: + with _run(_cp(0, "1\n", "")) as run: + res = _engine.run_sql("SELECT 1;", database="d1") + argv = run.call_args.args[0] + assert argv[:3] == ["docker", "exec", "-i"] + assert "-B" in argv + assert "-N" in argv + assert argv[-1] == "d1" + assert run.call_args.kwargs["input"] == "SELECT 1;" + assert res.returncode == 0 + assert res.stdout == "1\n" + + +def test_run_sql_without_database() -> None: + with _run(_cp(0)) as run: + _engine.run_sql("SELECT 1;") + assert run.call_args.args[0][-1] == "-N" + + +def test_run_sql_timeout_becomes_engine_error() -> None: + boom = subprocess.TimeoutExpired(cmd="docker", timeout=1) + with ( + patch.object(subprocess, "run", side_effect=boom), + pytest.raises(_engine.EngineError, match="timed out"), + ): + _engine.run_sql("SELECT SLEEP(99);", timeout=1) + + +def test_run_sql_missing_docker_is_not_relabelled() -> None: + with ( + patch.object(shutil, "which", return_value=None), + pytest.raises(_engine.EngineError, match="docker binary"), + ): + _engine.run_sql("SELECT 1;") diff --git a/python_pkg/sql_drills/tests/test_live.py b/python_pkg/sql_drills/tests/test_live.py new file mode 100644 index 00000000..38dc3dc8 --- /dev/null +++ b/python_pkg/sql_drills/tests/test_live.py @@ -0,0 +1,61 @@ +"""Live E2E against the real container; skipped when it is not running. + +Start it first with ``python3 -m python_pkg.sql_drills start``. These tests are +the only place reference solutions are used, so learning mode never shows them. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.sql_drills import _check, _drills, _engine + +SOLUTIONS = Path(__file__).parent / "solutions" +DRILL_IDS = [d.id for d in _drills.list_drills()] + + +def _engine_up() -> bool: + try: + return _engine.is_ready() + except _engine.EngineError: + return False + + +pytestmark = [ + pytest.mark.docker, + pytest.mark.skipif(not _engine_up(), reason="sql-drills-mysql is not running"), +] + + +@pytest.mark.parametrize("drill_id", DRILL_IDS) +def test_reference_solution_matches_expected(drill_id: str) -> None: + result = _check.run_check( + _drills.get_drill(drill_id), SOLUTIONS / f"{drill_id}.sql" + ) + assert result.status == "pass", result.message + + +def test_wrong_query_fails_with_diff(tmp_path: Path) -> None: + query = tmp_path / "q.sql" + query.write_text("SELECT region, 1, 2, 3, 4 FROM shop_sales GROUP BY region;") + result = _check.run_check(_drills.get_drill("01"), query) + assert result.status == "fail" + assert "expected:" in result.message + + +def test_syntax_error_is_error(tmp_path: Path) -> None: + query = tmp_path / "q.sql" + query.write_text("SELECT * FROM;") + result = _check.run_check(_drills.get_drill("01"), query) + assert result.status == "error" + assert "1064" in result.message + + +def test_checks_are_isolated(tmp_path: Path) -> None: + query = tmp_path / "q.sql" + query.write_text("DELETE FROM shop_sales;") + _check.run_check(_drills.get_drill("01"), query) + again = _check.run_check(_drills.get_drill("01"), SOLUTIONS / "01_pivot_sum.sql") + assert again.status == "pass" diff --git a/python_pkg/sql_drills/tests/test_normalize.py b/python_pkg/sql_drills/tests/test_normalize.py new file mode 100644 index 00000000..daf0fd40 --- /dev/null +++ b/python_pkg/sql_drills/tests/test_normalize.py @@ -0,0 +1,76 @@ +"""Output normaliser and diff: pure functions, no Docker.""" + +from __future__ import annotations + +from python_pkg.sql_drills._normalize import diff, matches, normalize + + +def test_tabs_become_single_spaces() -> None: + assert normalize("a\tb\tc\n") == ["a b c"] + + +def test_trailing_whitespace_stripped_per_line() -> None: + assert normalize("a b \nc\t\n") == ["a b", "c"] + + +def test_leading_whitespace_kept() -> None: + assert normalize(" x\n") == [" x"] + + +def test_null_stays_literal() -> None: + assert normalize("1\tNULL\n") == ["1 NULL"] + + +def test_crlf_handled() -> None: + assert normalize("a\r\nb\r\n") == ["a", "b"] + + +def test_empty_output_is_no_lines() -> None: + assert normalize("") == [] + + +def test_matches_ignores_trailing_blank_final_newline() -> None: + assert matches("a\tb\n", "a b") + + +def test_case_is_strict() -> None: + assert not matches("Alice\n", "alice\n") + + +def test_decimals_are_strict() -> None: + assert not matches("2.5\n", "2.5000\n") + + +def test_row_order_is_strict() -> None: + assert not matches("a\nb\n", "b\na\n") + + +def test_diff_empty_when_equal() -> None: + assert diff(["a"], ["a"]) == "" + + +def test_diff_marks_differing_line() -> None: + out = diff(["a", "b"], ["a", "c"]) + assert "line 2" in out + assert "expected: b" in out + assert "actual : c" in out + assert "line 1" not in out + + +def test_diff_reports_missing_rows() -> None: + out = diff(["a", "b"], ["a"]) + assert "line 2" in out + assert "expected: b" in out + assert "actual : " in out + + +def test_diff_reports_extra_rows() -> None: + out = diff(["a"], ["a", "z"]) + assert "expected: " in out + assert "actual : z" in out + + +def test_diff_shows_whitespace_visibly() -> None: + out = diff(["a b"], [" a b"]) + assert "expected: a b" in out + assert "actual : ' a b'" in out