mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:27:03 +01:00
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
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:
parent
aea8838515
commit
ee78ae44cc
@ -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"
|
||||
}
|
||||
33
docs/superpowers/evidence/sql-drills-harness-2026-11-01.json
Normal file
33
docs/superpowers/evidence/sql-drills-harness-2026-11-01.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
32
python_pkg/sql_drills/README.md
Normal file
32
python_pkg/sql_drills/README.md
Normal 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).
|
||||
5
python_pkg/sql_drills/__init__.py
Normal file
5
python_pkg/sql_drills/__init__.py
Normal 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.
|
||||
"""
|
||||
68
python_pkg/sql_drills/__main__.py
Normal file
68
python_pkg/sql_drills/__main__.py
Normal 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())
|
||||
67
python_pkg/sql_drills/_check.py
Normal file
67
python_pkg/sql_drills/_check.py
Normal 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)}",
|
||||
)
|
||||
65
python_pkg/sql_drills/_drills.py
Normal file
65
python_pkg/sql_drills/_drills.py
Normal 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"
|
||||
)
|
||||
126
python_pkg/sql_drills/_engine.py
Normal file
126
python_pkg/sql_drills/_engine.py
Normal 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)
|
||||
41
python_pkg/sql_drills/_normalize.py
Normal file
41
python_pkg/sql_drills/_normalize.py
Normal 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)
|
||||
9
python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md
Normal file
9
python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md
Normal 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.
|
||||
4
python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt
Normal file
4
python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt
Normal 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
|
||||
12
python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql
Normal file
12
python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql
Normal 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);
|
||||
10
python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md
Normal file
10
python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md
Normal 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.
|
||||
4
python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt
Normal file
4
python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt
Normal 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
|
||||
12
python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql
Normal file
12
python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql
Normal 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');
|
||||
11
python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md
Normal file
11
python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md
Normal 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.
|
||||
@ -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
|
||||
19
python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql
Normal file
19
python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql
Normal 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);
|
||||
11
python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md
Normal file
11
python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md
Normal 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).
|
||||
@ -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
|
||||
11
python_pkg/sql_drills/fixtures/04_date_format/schema.sql
Normal file
11
python_pkg/sql_drills/fixtures/04_date_format/schema.sql
Normal 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');
|
||||
10
python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md
Normal file
10
python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md
Normal 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.
|
||||
@ -0,0 +1,5 @@
|
||||
6 2022-03-01
|
||||
3 2022-03-02
|
||||
1 2022-03-14
|
||||
4 2022-11-30
|
||||
7 2023-02-28
|
||||
13
python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql
Normal file
13
python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql
Normal 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');
|
||||
15
python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md
Normal file
15
python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md
Normal 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).
|
||||
@ -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
|
||||
11
python_pkg/sql_drills/fixtures/06_number_traps/schema.sql
Normal file
11
python_pkg/sql_drills/fixtures/06_number_traps/schema.sql
Normal 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);
|
||||
13
python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md
Normal file
13
python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md
Normal 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).
|
||||
@ -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
|
||||
@ -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);
|
||||
12
python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md
Normal file
12
python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md
Normal 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.
|
||||
3
python_pkg/sql_drills/fixtures/08_anti_join/expected.txt
Normal file
3
python_pkg/sql_drills/fixtures/08_anti_join/expected.txt
Normal file
@ -0,0 +1,3 @@
|
||||
Boris n/a
|
||||
Cleo 555-0103
|
||||
Dara n/a
|
||||
16
python_pkg/sql_drills/fixtures/08_anti_join/schema.sql
Normal file
16
python_pkg/sql_drills/fixtures/08_anti_join/schema.sql
Normal 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');
|
||||
@ -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`.
|
||||
@ -0,0 +1,3 @@
|
||||
Gamma 4 16.0
|
||||
Alpha 3 11.0
|
||||
Eps 3 10.0
|
||||
16
python_pkg/sql_drills/fixtures/09_group_having/schema.sql
Normal file
16
python_pkg/sql_drills/fixtures/09_group_having/schema.sql
Normal 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);
|
||||
@ -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()`.
|
||||
@ -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
|
||||
11
python_pkg/sql_drills/fixtures/10_window_topn/schema.sql
Normal file
11
python_pkg/sql_drills/fixtures/10_window_topn/schema.sql
Normal 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);
|
||||
10
python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md
Normal file
10
python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md
Normal 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.
|
||||
4
python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt
Normal file
4
python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt
Normal 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
|
||||
10
python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql
Normal file
10
python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql
Normal 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');
|
||||
@ -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`.
|
||||
@ -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%
|
||||
10
python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql
Normal file
10
python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql
Normal 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);
|
||||
0
python_pkg/sql_drills/tests/__init__.py
Normal file
0
python_pkg/sql_drills/tests/__init__.py
Normal file
8
python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql
Normal file
8
python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql
Normal 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;
|
||||
7
python_pkg/sql_drills/tests/solutions/02_pivot_max.sql
Normal file
7
python_pkg/sql_drills/tests/solutions/02_pivot_max.sql
Normal 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;
|
||||
@ -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;
|
||||
5
python_pkg/sql_drills/tests/solutions/04_date_format.sql
Normal file
5
python_pkg/sql_drills/tests/solutions/04_date_format.sql
Normal 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;
|
||||
4
python_pkg/sql_drills/tests/solutions/05_str_to_date.sql
Normal file
4
python_pkg/sql_drills/tests/solutions/05_str_to_date.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -0,0 +1,4 @@
|
||||
SELECT customer, FORMAT(SUM(amount), 2), FORMAT(AVG(amount), 0)
|
||||
FROM invoices
|
||||
GROUP BY customer
|
||||
ORDER BY SUM(amount) DESC;
|
||||
5
python_pkg/sql_drills/tests/solutions/08_anti_join.sql
Normal file
5
python_pkg/sql_drills/tests/solutions/08_anti_join.sql
Normal 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;
|
||||
@ -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;
|
||||
8
python_pkg/sql_drills/tests/solutions/10_window_topn.sql
Normal file
8
python_pkg/sql_drills/tests/solutions/10_window_topn.sql
Normal 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;
|
||||
10
python_pkg/sql_drills/tests/solutions/11_lag_gap.sql
Normal file
10
python_pkg/sql_drills/tests/solutions/11_lag_gap.sql
Normal 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;
|
||||
@ -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;
|
||||
105
python_pkg/sql_drills/tests/test_check.py
Normal file
105
python_pkg/sql_drills/tests/test_check.py
Normal 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))
|
||||
67
python_pkg/sql_drills/tests/test_cli.py
Normal file
67
python_pkg/sql_drills/tests/test_cli.py
Normal 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([])
|
||||
68
python_pkg/sql_drills/tests/test_drills.py
Normal file
68
python_pkg/sql_drills/tests/test_drills.py
Normal 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") == []
|
||||
137
python_pkg/sql_drills/tests/test_engine.py
Normal file
137
python_pkg/sql_drills/tests/test_engine.py
Normal 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;")
|
||||
61
python_pkg/sql_drills/tests/test_live.py
Normal file
61
python_pkg/sql_drills/tests/test_live.py
Normal 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"
|
||||
76
python_pkg/sql_drills/tests/test_normalize.py
Normal file
76
python_pkg/sql_drills/tests/test_normalize.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user