sql_drills: thin wrapper over utils package
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Has been cancelled
Python tests / test (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / lib-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

Engine, fixtures and reference solutions moved to kuhyx/utils
sql_drills (tag sql-drills-v0.1.0); the CLI keeps its commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 13:57:47 +01:00
parent ed9fb48c51
commit e2bccc4b3c
66 changed files with 173 additions and 1265 deletions

View File

@ -273,6 +273,7 @@ repos:
# js_anki builds the Anki .apkg; same isolated-venv reason as above.
- genanki
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
- sql_drills @ git+https://github.com/kuhyx/utils@sql-drills-v0.1.0#subdirectory=sql_drills
- anthropic
- rich
- typer

View File

@ -1,48 +0,0 @@
{
"intent": "Give the new signal-bot phone app (~/signal-bot/app) a launcher icon from the shared family, so it stops shipping the stock Flutter beach icon.",
"scope": [
"python_pkg/app_icons/_glyph_art_more.py - the new SVG fragment",
"python_pkg/app_icons/glyphs.py - register the glyph in GLYPHS",
"python_pkg/app_icons/apps.py - register the app so --app signalbot_remote passes argument validation",
"Non-goal: no change to the shared accent #B8862E or the charcoal field; a glyph is distinguished by silhouette only",
"Non-goal: no change to any existing app's icon",
"Non-goal: the app itself, its Flutter code and its Cloudflare transport all live in ~/signal-bot and are committed there"
],
"changes": [
"Added _REMOTE_WAVE: a filled handset (x=412..612, y=440..792, r=56) with two punched-out buttons (r=40 at 512,540 and 512,680) under a detached stroked arc (r=132 about 512,440, drawn 135deg to 45deg). Total ink y=272..792, x=383..641, inside the 560 safe box.",
"The arc is detached from the body on purpose: an arc that meets the body is a padlock shackle, and padlock-closed is already in this family.",
"The body is filled rather than stroked, for the same reason as _QUILL_NIB and _ANVIL: a 200-wide stroked body leaves a 56px interior at STROKE_WIDTH=72 and fills in solid at 48dp.",
"Registered Glyph('remote-wave', ...) in GLYPHS and AppIcon(key='signalbot_remote', glyph='remote-wave', linux=False) in APPS, pointing at ~/signal-bot/app."
],
"verification": [
{
"command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons list",
"result": "pass",
"evidence": "remote-wave appears under Glyphs; 'signalbot_remote #B8862E remote-wave /home/kuhy/signal-bot/app' appears under Apps."
},
{
"command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app signalbot_remote --android",
"result": "pass",
"evidence": "wrote 6 asset files to /home/kuhy/signal-bot/app/assets/icon and regenerated the Android mipmaps. The generator's safe-box and negative-space checks did not fire. A first attempt failed because flutter_launcher_icons was not yet in the app's pubspec; the config block was added and it then succeeded."
},
{
"command": "visual review of the rendered preview sheet (full size, circle-masked, 48dp)",
"result": "pass",
"evidence": "Reads as a handset with a signal arc at all three sizes. The two buttons stay separate at 48dp (60px edge gap against MIN_NEGATIVE_SPACE=36) and the arc keeps it from reading as a domino."
},
{
"command": "python3 -m coverage run --branch --source=python_pkg/app_icons -m pytest python_pkg/app_icons/tests",
"result": "pass",
"evidence": "44 passed; python_pkg/app_icons at 100% line and branch coverage. The registry invariants (every app references a real glyph, accents are shared, no glyph paints the background) iterate over APPS and GLYPHS, so the new entries are exercised by the existing tests."
}
],
"risks": [
"The glyph is new artwork, so only a visual check can confirm it reads correctly at 48dp. Mitigated by rendering and reviewing the actual preview sheet rather than trusting the SVG source.",
"Not yet verified on the phone: kuhy's device is unavailable until 2026-09-10, so the launcher has not rendered this icon on real hardware.",
"_glyph_art_more.py is now 160 lines. Together with _glyph_art.py it exists solely to stay under the 250-line cap, so a third file will be needed before long."
],
"rollback": [
"git revert the commit. The three edits are additive - no existing glyph, app or shared token is touched - so a revert cannot change any other app's icon.",
"After rollback, re-run 'python3 -m python_pkg.app_icons list' and confirm the remaining 16 glyphs still resolve. The already-generated PNGs in ~/signal-bot/app would then have no source in this repo, so revert that repo's icon commit too or regenerate from a restored glyph."
]
}

View File

@ -0,0 +1,33 @@
{
"intent": "python_pkg.sql_drills CLI keeps its commands but imports the shared sql_drills package from kuhyx/utils.",
"scope": [
"python_pkg/sql_drills (engine, fixtures, solutions, engine tests removed)",
"meta/requirements.txt pin sql-drills-v0.1.0",
"Non-goal: changing CLI commands or exit codes"
],
"changes": [
"__main__.py calls sql_drills.ensure_running/stop/list_drills/get_drill/run",
"Fixtures, normaliser, docker engine and live tests moved to utils/sql_drills",
"check prints per-cell diffs instead of per-line diffs"
],
"verification": [
{
"command": "python3 -m pytest -q python_pkg/sql_drills --cov=python_pkg/sql_drills --cov-branch",
"result": "pass",
"evidence": "18 passed, 100% branch coverage"
},
{
"command": "python3 -m python_pkg.sql_drills check 06 <reference.sql> (live container)",
"result": "pass",
"evidence": "PASS 06 (3 rows), exit 0; wrong query: FAIL with cell diffs, exit 1"
}
],
"risks": [
"CI needs the utils git pin to resolve",
"The shared package must be installed in any python running the CLI"
],
"rollback": [
"git revert this commit to restore the in-repo engine",
"Re-run python3 -m python_pkg.sql_drills list and check 01 on a reference"
]
}

View File

@ -243,6 +243,9 @@ min-similarity-lines = 10
spelling-dict = ""
[tool.pylint.typecheck]
# The init-hook puts python_pkg/ on sys.path, so the shared `sql_drills`
# package (kuhyx/utils) resolves to the CLI folder python_pkg/sql_drills.
ignored-modules = ["sql_drills"]
# cv2 (OpenCV) dynamically loads members from C extension at runtime.
# unittest.mock.MagicMock generates assertion/introspection methods at runtime.
# wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the

View File

@ -83,6 +83,7 @@ ruff==0.17.3
# which would hold the typer this repo actually imports below latest.
secretstorage==3.5.0
selenium==4.52.0
sql_drills @ git+https://github.com/kuhyx/utils@sql-drills-v0.1.0#subdirectory=sql_drills
typer==0.27.3
types-python-dateutil==2.9.0.20261029
types-PyYAML==6.0.12.20261030

View File

@ -1,9 +1,7 @@
# 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.
CLI over the shared `sql_drills` package (kuhyx/utils, tag `sql-drills-v0.1.0`):
drills, fixtures, reference solutions and grading rules live there.
```bash
python3 -m python_pkg.sql_drills start # container + wait (<60 s)
@ -12,21 +10,3 @@ python3 -m python_pkg.sql_drills show 01 # task, schema, output samp
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

@ -1,4 +1,7 @@
"""CLI: ``python3 -m python_pkg.sql_drills start|stop|list|show|check``."""
"""CLI: ``python3 -m python_pkg.sql_drills start|stop|list|show|check``.
A thin wrapper over the shared ``sql_drills`` package (kuhyx/utils).
"""
from __future__ import annotations
@ -8,9 +11,8 @@ import logging
from pathlib import Path
import sys
from python_pkg.sql_drills import _check, _drills, _engine
import sql_drills as sd
_EXIT_CODES = {"pass": 0, "fail": 1, "error": 2}
_LOG = logging.getLogger(__name__)
@ -18,8 +20,7 @@ def _heartbeat() -> None:
"""Tell the optional focus tracker that practice is happening.
Soft dependency: absent module -> silent no-op; a tracker error is logged
(file or registry errors) and never changes the drill's result or
exit code.
and never changes the drill's result or exit code.
"""
try:
tracker = importlib.import_module("focus_project")
@ -53,24 +54,50 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
def _render_show(drill: sd.Drill) -> str:
tail = "## Query to predict" if drill.query else "## Expected output (sample)"
body = drill.query or "\n".join("\t".join(r) for r in drill.expected_preview)
return (
f"{drill.task_md.strip()}\n\n## Schema\n\n{drill.schema_sql.strip()}\n\n"
f"{tail}\n\n{body}\n"
)
def _check(drill_id: str, sql_file: Path) -> int:
if not sql_file.is_file():
_out(f"query file not found: {sql_file}")
return 2
res = sd.run(drill_id, sql_file.read_text())
if res.error is not None and not res.match:
_out(f"SQL error: {res.error}")
return 2
if res.match:
_out(f"PASS {drill_id} ({len(res.rows)} rows)")
return 0
_out(
f"FAIL {drill_id}: expected {len(res.expected_rows)} rows, got {len(res.rows)}"
)
for row, col, want, got in res.cell_diff:
_out(f"line {row + 1} cell {col + 1}: expected {want!r}, actual {got!r}")
return 1
def _dispatch(args: argparse.Namespace) -> int:
if args.cmd == "start":
_engine.start()
_out(f"{_engine.CONTAINER} ready ({_engine.IMAGE})")
sd.ensure_running()
_out(f"{sd.CONTAINER} ready ({sd.IMAGE})")
elif args.cmd == "stop":
_engine.stop()
_out(f"{_engine.CONTAINER} removed")
sd.stop()
_out(f"{sd.CONTAINER} removed")
elif args.cmd == "list":
for drill in _drills.list_drills():
_out(f"{drill.id} {_drills.title_of(drill)}")
for meta in sd.list_drills():
_out(f"{meta.id} {meta.title}")
elif args.cmd == "show":
_heartbeat()
sys.stdout.write(_drills.render_show(_drills.get_drill(args.drill)))
sys.stdout.write(_render_show(sd.get_drill(args.drill)))
else:
_heartbeat()
result = _check.run_check(_drills.get_drill(args.drill), args.sql_file)
_out(result.message)
return _EXIT_CODES[result.status]
return _check(args.drill, args.sql_file)
return 0
@ -79,9 +106,9 @@ def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
try:
return _dispatch(args)
except _drills.UnknownDrillError as exc:
except sd.UnknownDrillError as exc:
_err(exc.args[0])
except _engine.EngineError as exc:
except sd.EngineError as exc:
_err(f"error: {exc}")
return 2

View File

@ -1,67 +0,0 @@
"""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

@ -1,65 +0,0 @@
"""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

@ -1,126 +0,0 @@
"""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

@ -1,41 +0,0 @@
"""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

@ -1,9 +0,0 @@
# 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

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

View File

@ -1,12 +0,0 @@
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

@ -1,10 +0,0 @@
# 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

@ -1,4 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,11 +0,0 @@
# 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

@ -1,5 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,11 +0,0 @@
# 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

@ -1,5 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,10 +0,0 @@
# 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

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

View File

@ -1,13 +0,0 @@
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

@ -1,15 +0,0 @@
# 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

@ -1,3 +0,0 @@
Hawks 8.33 12.6667 65.8
Lions 5.50 10.0000 55.0
Owls 0.00 8.0000 0.0

View File

@ -1,11 +0,0 @@
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

@ -1,13 +0,0 @@
# 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

@ -1,4 +0,0 @@
Cog 1,002,500.25 501,250
Acme 35,200.59 11,734
Bolt 999.99 1,000
Dyn 9.00 5

View File

@ -1,9 +0,0 @@
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

@ -1,12 +0,0 @@
# 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

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

View File

@ -1,16 +0,0 @@
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

@ -1,9 +0,0 @@
# 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

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

View File

@ -1,16 +0,0 @@
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

@ -1,9 +0,0 @@
# 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

@ -1,8 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,10 +0,0 @@
# 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

@ -1,4 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,11 +0,0 @@
# 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

@ -1,7 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,7 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,5 +0,0 @@
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

@ -1,4 +0,0 @@
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

@ -1,7 +0,0 @@
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

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

View File

@ -1,5 +0,0 @@
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

@ -1,6 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,5 +0,0 @@
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

@ -1,105 +0,0 @@
"""``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

@ -1,4 +1,4 @@
"""CLI wiring: subcommands, exit codes, output."""
"""CLI wiring over the shared package: subcommands, exit codes, output."""
from __future__ import annotations
@ -6,9 +6,33 @@ from pathlib import Path
from unittest.mock import patch
import pytest
import sql_drills as sd
from python_pkg.sql_drills import __main__ as cli
from python_pkg.sql_drills import _check, _engine
def _result(
*,
match: bool,
ok: bool = True,
error: str | None = None,
diff: list[sd.CellDiff] | None = None,
) -> sd.RunResult:
return sd.RunResult(
ok=ok,
error=error,
columns=[],
rows=[["a"]],
expected_rows=[["b"]],
match=match,
cell_diff=diff or [],
)
def _sql(tmp_path: Path) -> str:
query = tmp_path / "q.sql"
query.write_text("SELECT 1;")
return str(query)
def test_list_prints_ids_and_titles(capsys: pytest.CaptureFixture[str]) -> None:
@ -18,9 +42,22 @@ def test_list_prints_ids_and_titles(capsys: pytest.CaptureFixture[str]) -> None:
assert len(out.strip().splitlines()) >= 10
def test_show_prints_task(capsys: pytest.CaptureFixture[str]) -> None:
def test_show_prints_task_and_expected_sample(
capsys: pytest.CaptureFixture[str],
) -> None:
assert cli.main(["show", "01"]) == 0
assert "CREATE TABLE" in capsys.readouterr().out
out = capsys.readouterr().out
assert "CREATE TABLE" in out
assert "## Expected output (sample)" in out
def test_show_predict_drill_prints_the_query(
capsys: pytest.CaptureFixture[str],
) -> None:
assert cli.main(["show", "t03"]) == 0
out = capsys.readouterr().out
assert "## Query to predict" in out
assert "LEFT JOIN" in out
def test_show_unknown_drill_exit_2(capsys: pytest.CaptureFixture[str]) -> None:
@ -29,7 +66,10 @@ def test_show_unknown_drill_exit_2(capsys: pytest.CaptureFixture[str]) -> None:
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:
with (
patch.object(sd, "ensure_running") as start,
patch.object(sd, "stop") as stop,
):
assert cli.main(["start"]) == 0
assert cli.main(["stop"]) == 0
start.assert_called_once_with()
@ -38,28 +78,53 @@ def test_start_and_stop_call_engine(capsys: pytest.CaptureFixture[str]) -> None:
def test_engine_error_is_exit_2(capsys: pytest.CaptureFixture[str]) -> None:
with patch.object(_engine, "start", side_effect=_engine.EngineError("boom")):
with patch.object(sd, "ensure_running", side_effect=sd.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]
def test_check_pass_is_exit_0(
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
with patch.object(sd, "run", return_value=_result(match=True)):
assert cli.main(["check", "01", _sql(tmp_path)]) == 0
assert "PASS 01" in capsys.readouterr().out
def test_check_fail_prints_cell_diff_exit_1(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
res = _result(match=False, diff=[(0, 1, "x", "y")])
with patch.object(sd, "run", return_value=res):
assert cli.main(["check", "01", _sql(tmp_path)]) == 1
out = capsys.readouterr().out
assert "FAIL 01" in out
assert "line 1 cell 2: expected 'x', actual 'y'" in out
def test_check_sql_error_is_exit_2(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
res = _result(match=False, ok=False, error="ERROR 1064")
with patch.object(sd, "run", return_value=res):
assert cli.main(["check", "01", _sql(tmp_path)]) == 2
assert "ERROR 1064" in capsys.readouterr().out
def test_check_missing_file_is_exit_2(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["check", "01", str(tmp_path / "nope.sql")]) == 2
assert "not found" in capsys.readouterr().out
def test_check_unknown_drill(tmp_path: Path) -> None:
assert cli.main(["check", "zzz", str(tmp_path / "q.sql")]) == 2
assert cli.main(["check", "zzz", _sql(tmp_path)]) == 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
with patch.object(sd, "run", side_effect=sd.EngineError("x")):
assert cli.main(["check", "01", _sql(tmp_path)]) == 2
def test_no_subcommand_is_usage_error() -> None:

View File

@ -1,68 +0,0 @@
"""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

@ -1,137 +0,0 @@
"""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

@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, cast
from unittest.mock import patch
import pytest
import sql_drills as sd
from python_pkg.sql_drills import __main__ as cli
from python_pkg.sql_drills import _check
if TYPE_CHECKING:
from pathlib import Path
@ -42,9 +42,13 @@ def test_show_sends_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None:
def test_check_sends_heartbeat(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
calls = _fake_tracker(monkeypatch)
result = _check.CheckResult("fail", "MSG")
with patch.object(_check, "run_check", return_value=result):
assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == 1
result = sd.RunResult(
ok=True, error=None, columns=[], rows=[], expected_rows=[["x"]], match=False
)
with patch.object(sd, "run", return_value=result):
query = tmp_path / "q.sql"
query.write_text("SELECT 1;")
assert cli.main(["check", "01", str(query)]) == 1
assert calls == [("sql_drills", "heartbeat")]

View File

@ -1,61 +0,0 @@
"""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

@ -1,76 +0,0 @@
"""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