Add sql_drills MySQL exact-match harness
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZewj4WS5X6AZyjWDp
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 21:13:18 +01:00
parent aea8838515
commit ee78ae44cc
65 changed files with 1381 additions and 0 deletions

View File

@ -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"
}

View File

@ -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 <id> <reference> 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"
]
}

View File

@ -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",

View File

@ -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/<id>/` 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).

View File

@ -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.
"""

View File

@ -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())

View File

@ -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)}",
)

View File

@ -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"
)

View File

@ -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 <args>``; 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)

View File

@ -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 = "<no line>"
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)

View File

@ -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.

View File

@ -0,0 +1,4 @@
East 0 300 0 25
North 200 150 0 200
South 90 0 100 0
West 10 0 0 0

View File

@ -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);

View File

@ -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.

View File

@ -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

View File

@ -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');

View File

@ -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.

View File

@ -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

View File

@ -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);

View File

@ -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).

View File

@ -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

View File

@ -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');

View File

@ -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.

View File

@ -0,0 +1,5 @@
6 2022-03-01
3 2022-03-02
1 2022-03-14
4 2022-11-30
7 2023-02-28

View File

@ -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');

View File

@ -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).

View File

@ -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

View File

@ -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);

View File

@ -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).

View File

@ -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

View File

@ -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);

View File

@ -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.

View File

@ -0,0 +1,3 @@
Boris n/a
Cleo 555-0103
Dara n/a

View File

@ -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');

View File

@ -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`.

View File

@ -0,0 +1,3 @@
Gamma 4 16.0
Alpha 3 11.0
Eps 3 10.0

View File

@ -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);

View File

@ -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()`.

View File

@ -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

View File

@ -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);

View File

@ -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.

View File

@ -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

View File

@ -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');

View File

@ -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`.

View File

@ -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%

View File

@ -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);

View File

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,4 @@
SELECT customer, FORMAT(SUM(amount), 2), FORMAT(AVG(amount), 0)
FROM invoices
GROUP BY customer
ORDER BY SUM(amount) DESC;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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))

View File

@ -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([])

View File

@ -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") == []

View File

@ -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;")

View File

@ -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"

View File

@ -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 : <no line>" in out
def test_diff_reports_extra_rows() -> None:
out = diff(["a"], ["a", "z"])
assert "expected: <no line>" 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