Add sql_drills engine package
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run

Shared MySQL drill engine extracted from testsAndMisc: sandboxed
per-run db and user, exact-match cell diff, predict drills (builtin
theory set incl. an error drill), save_generated validation.

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:53:39 +01:00
parent 1fb04ce59d
commit f865508eb4
143 changed files with 4142 additions and 0 deletions

View File

@ -0,0 +1,73 @@
name: sql-drills pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' sql_drills/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-sql-drills-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
restore-keys: mypy-sql-drills-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config sql_drills/.pre-commit-config.yaml
--all-files --show-diff-on-failure

34
.github/workflows/sql-drills-tests.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: sql-drills tests
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sql_drills
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: sql_drills/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -0,0 +1,143 @@
# Pre-commit Configuration for sql_drills
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# Update: pre-commit autoupdate
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config sql_drills/.pre-commit-config.yaml --all-files
files: ^sql_drills/
default_language_version:
python: python3
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: [--maxkb=2000]
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- id: name-tests-test
args: [--pytest-test-first]
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- id: requirements-txt-fixer
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh sql_drills
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
language: pygrep
types: [python]
- id: no-ruff-noqa
name: Block ruff noqa file-level comments
entry: '(?i)#\s*ruff:\s*noqa'
language: pygrep
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.2
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
args:
- --ignore-missing-imports
- --no-error-summary
- --disable-error-code=no-untyped-def
- --disable-error-code=no-untyped-call
- --disable-error-code=var-annotated
- --disable-error-code=no-any-unimported
- --disable-error-code=type-arg
- --disable-error-code=no-any-return
- --disable-error-code=misc
- --disable-error-code=unused-ignore
- --disable-error-code=unreachable
- --disable-error-code=assignment
- --disable-error-code=no-redef
- --disable-error-code=attr-defined
- --disable-error-code=arg-type
- --disable-error-code=union-attr
- --disable-error-code=call-overload
- --disable-error-code=return-value
- --disable-error-code=redundant-cast
- --disable-error-code=empty-body
- --disable-error-code=list-item
- repo: https://github.com/pylint-dev/pylint
rev: v4.0.8
hooks:
- id: pylint
args:
- --rcfile=sql_drills/pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- sql_drills/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(sql_drills/sql_drills/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh sql_drills
language: script
types: [python]
pass_filenames: false
require_serial: true
stages: [pre-push]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv

3
sql_drills/README.md Normal file
View File

@ -0,0 +1,3 @@
# sql_drills
MySQL drill engine: `import sql_drills as sd`. Data: `$SQL_DRILLS_DATA`. Install: `./install.sh`.

12
sql_drills/install.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Editable user install so /usr/bin/python3 (the tutor server, the CLI) can
# `import sql_drills`. Stdlib only; idempotent.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_DIR
readonly SYSTEM_PYTHON="/usr/bin/python3"
"$SYSTEM_PYTHON" -m pip install --user --no-deps --break-system-packages -e "$REPO_DIR"
cd /
"$SYSTEM_PYTHON" -c 'import sql_drills; print(sql_drills.__file__)'

188
sql_drills/pyproject.toml Normal file
View File

@ -0,0 +1,188 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "sql_drills"
version = "0.1.0"
description = "MySQL SQL drills: sandboxed grading engine with exact-match cell diff"
requires-python = ">=3.14"
# Stdlib only on purpose: the tutor server imports it from the system python.
dependencies = []
[tool.setuptools.packages.find]
include = ["sql_drills*"]
[tool.setuptools.package-data]
sql_drills = ["py.typed", "fixtures/*/*"]
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"CPY001",
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D107",
"D203",
"D213",
"COM812",
"ISC001",
"S603",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = ["INP001"]
# Table names come from information_schema of a throwaway database.
"**/_sandbox.py" = ["S608"]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["sql_drills"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
docstring-code-format = true
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
disallow_any_unimported = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_subclassing_any = true
strict_equality = true
extra_checks = true
ignore_missing_imports = true
show_error_codes = true
color_output = true
exclude = [".venv/"]
[tool.pylint.main]
# The repo root on the path, so pylint resolves the package's own imports the
# same way pytest does instead of reporting import-error for them.
init-hook = "import sys; sys.path.insert(0, 'sql_drills')"
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
ignore-paths = ["^(.*/)?tests/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
enable = "all"
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"use-implicit-booleaness-not-comparison-to-zero",
]
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
[tool.pylint.typecheck]
generated-members = [
".*\\.assert_called_once_with",
".*\\.assert_called_once",
".*\\.assert_called",
".*\\.assert_not_called",
".*\\.assert_any_call",
".*\\.call_args",
".*\\.call_args_list",
".*\\.call_count",
]
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
[tool.pytest.ini_options]
markers = ["docker: needs the live sql-drills MySQL container (skipped when it is down)"]
testpaths = ["sql_drills/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=sql_drills",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["sql_drills"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"raise AssertionError",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]
partial_branches = ["pragma: no branch"]

View File

@ -0,0 +1,10 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
ruff==0.17.3

View File

@ -0,0 +1,49 @@
"""Rebuild expected.txt, tables.json and error metadata of builtin drills.
Needs the live container. Reference = the saved solution (write drills) or the
query itself (predict drills); run it, then store what MySQL printed.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
from sql_drills import _paths, _sandbox, ensure_running
SOLUTIONS = Path(__file__).parents[1] / "sql_drills" / "tests" / "solutions"
def _reference(folder: Path, kind: str) -> str:
if kind == "predict":
return (folder / "query.sql").read_text()
return (SOLUTIONS / f"{folder.name}.sql").read_text()
def _regen(folder: Path) -> None:
meta_path = folder / "meta.json"
meta = json.loads(meta_path.read_text())
with _sandbox.sandbox((folder / "schema.sql").read_text()) as box:
done = box.execute(_reference(folder, meta["kind"]))
tables = box.tables()
if done.ok:
rows = "".join("\t".join(r) + "\n" for r in done.rows)
else:
meta |= {"error_code": done.code, "error_message": done.error}
rows = f"ERROR {done.code}\n"
meta_path.write_text(json.dumps(meta) + "\n")
(folder / "expected.txt").write_text(rows)
payload = {name: table.__dict__ for name, table in tables.items()}
(folder / "tables.json").write_text(json.dumps(payload, indent=1) + "\n")
def main() -> None:
ensure_running()
for folder in sorted(_paths.builtin_dir().iterdir()):
_regen(folder)
sys.stdout.write(f"{folder.name}\n")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,46 @@
"""MySQL SQL drills: a sandboxed exact-match grading engine."""
from __future__ import annotations
from sql_drills._engine import CONTAINER, IMAGE, EngineError, ensure_running, stop
from sql_drills._generate import DrillSpec, SpecError, save_generated
from sql_drills._grade import check_prediction, run
from sql_drills._model import (
TOPICS,
CellDiff,
Drill,
DrillKindError,
DrillMeta,
PredResult,
RunResult,
Table,
UnknownDrillError,
)
from sql_drills._paths import data_dir
from sql_drills._store import get_drill, list_drills, topics
__all__ = [
"CONTAINER",
"IMAGE",
"TOPICS",
"CellDiff",
"Drill",
"DrillKindError",
"DrillMeta",
"DrillSpec",
"EngineError",
"PredResult",
"RunResult",
"SpecError",
"Table",
"UnknownDrillError",
"check_prediction",
"data_dir",
"ensure_running",
"get_drill",
"list_drills",
"run",
"save_generated",
"stop",
"topics",
]

View File

@ -0,0 +1,129 @@
"""Docker wrapper around one long-lived pinned ``mysql:8.0.33`` container."""
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"
NETWORK = "sql-drills-net"
NETWORK_ALIAS = "db"
READY_TIMEOUT = 45.0
QUERY_TIMEOUT = 30.0
_DOCKER_TIMEOUT = 50.0
class EngineError(RuntimeError):
pass
@dataclass(frozen=True)
class SqlResult:
returncode: int
stdout: str
stderr: str
def _docker(
*args: str, stdin: str | None = None, timeout: float = _DOCKER_TIMEOUT
) -> subprocess.CompletedProcess[str]:
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:
res = _docker("inspect", "-f", "{{.State.Status}}", CONTAINER)
return res.stdout.strip() if res.returncode == 0 else "missing"
def is_ready() -> bool:
res = _docker(
"exec", CONTAINER, "mysqladmin", "ping", "-h127.0.0.1", "-uroot", "--silent"
)
return res.returncode == 0
def _create() -> subprocess.CompletedProcess[str]:
net = _docker("network", "inspect", NETWORK)
shared = ["--network", NETWORK, "--network-alias", NETWORK_ALIAS]
return _docker(
"run", "-d", "--name", CONTAINER,
"--tmpfs", "/var/lib/mysql:rw,size=512m",
"--memory", "1500m",
"-e", "MYSQL_ALLOW_EMPTY_PASSWORD=yes",
*(shared if net.returncode == 0 else []),
IMAGE,
) # fmt: skip
def ensure_running(
timeout: float = READY_TIMEOUT,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""Create or resume the container if needed, wait (bounded) for MySQL."""
state = container_state()
if state == "missing":
res = _create()
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 to keep waiting"
raise EngineError(msg)
sleep(1.0)
def stop() -> None:
_docker("rm", "-f", CONTAINER)
def run_sql(
sql: str,
database: str | None = None,
user: str = "root",
*,
headers: bool = False,
timeout: float = QUERY_TIMEOUT,
) -> SqlResult:
argv = ["exec", "-i", CONTAINER, "mysql", f"-u{user}"]
argv += ["--default-character-set=utf8mb4", "-B"]
if not headers:
argv.append("-N")
if database:
argv.append(database)
try:
res = _docker(*argv, stdin=sql, timeout=timeout)
except EngineError as exc:
if "timed out" in str(exc):
msg = f"query timed out after {timeout:.0f}s"
raise EngineError(msg) from exc
raise
return SqlResult(res.returncode, res.stdout, res.stderr)

View File

@ -0,0 +1,142 @@
"""Validate and save a generated drill; the reference SQL is never stored."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
from typing import TYPE_CHECKING, Any
from sql_drills import _paths, _sandbox
from sql_drills._model import TOPICS, Table
if TYPE_CHECKING:
from collections.abc import Mapping
from sql_drills._normalize import Rows
KINDS = ("write", "predict")
PREDICT_HOWTO = (
"Predict the exact output of the query before running it: one grid row per\n"
"result row, `NULL` for SQL NULL, no header. If the query fails, type `ERROR`\n"
"in the first cell."
)
_TEXT_FIELDS = ("title", "task_md", "schema_sql", "reference_sql")
class SpecError(ValueError):
pass
@dataclass(frozen=True)
class DrillSpec:
kind: str
topic: str
title: str
task_md: str
schema_sql: str
reference_sql: str # write: the solution; predict: the query to predict
expect_error: bool = False
def __post_init__(self) -> None:
for name in _TEXT_FIELDS:
value = getattr(self, name)
if not isinstance(value, str) or not value.strip():
msg = f"{name} must be a non-empty string"
raise SpecError(msg)
if self.kind not in KINDS:
msg = f"kind must be one of {KINDS}"
raise SpecError(msg)
if self.topic not in TOPICS:
msg = f"topic must be one of {TOPICS}"
raise SpecError(msg)
if self.expect_error and self.kind != "predict":
msg = "expect_error only applies to a predict drill"
raise SpecError(msg)
@classmethod
def from_mapping(cls, raw: Mapping[str, object]) -> DrillSpec:
missing = [k for k in ("kind", "topic", *_TEXT_FIELDS) if k not in raw]
if missing:
msg = f"missing field: {', '.join(missing)}"
raise SpecError(msg)
known = ("kind", "topic", "expect_error", *_TEXT_FIELDS)
fields: dict[str, Any] = {k: raw[k] for k in known if k in raw}
return cls(**fields)
def digest(self) -> str:
blob = json.dumps(self.__dict__, sort_keys=True).encode()
return hashlib.sha256(blob).hexdigest()[:8]
@dataclass(frozen=True)
class _Reference:
ok: bool
code: int | None
error: str | None
rows: Rows
tables: dict[str, Table]
def _run_reference(spec: DrillSpec) -> _Reference:
with _sandbox.sandbox(spec.schema_sql) as box:
done = box.execute(spec.reference_sql)
tables = box.tables()
return _Reference(done.ok, done.code, done.error, done.rows, tables)
def _validated(spec: DrillSpec) -> _Reference:
first = _run_reference(spec)
if not first.ok and not spec.expect_error:
msg = f"reference SQL failed: {first.error}"
raise SpecError(msg)
if first.ok and spec.expect_error:
msg = "reference SQL did not fail but expect_error is set"
raise SpecError(msg)
if not first.ok and first.code is None:
msg = f"no MySQL error code in: {first.error}"
raise SpecError(msg)
if first.ok and not first.rows:
msg = "reference output is empty"
raise SpecError(msg)
again = _run_reference(spec)
if (again.ok, again.code, again.rows) != (first.ok, first.code, first.rows):
msg = "reference output is not deterministic"
raise SpecError(msg)
return first
def _write(spec: DrillSpec, ref: _Reference, drill_id: str) -> None:
root = _paths.generated_dir()
root.mkdir(parents=True, exist_ok=True)
staging = root / f".tmp-{drill_id}"
staging.mkdir(exist_ok=True)
predict = spec.kind == "predict"
info: dict[str, object] = {"kind": spec.kind, "topic": spec.topic}
expected = "".join("\t".join(r) + "\n" for r in ref.rows)
if not ref.ok:
info |= {"error_code": ref.code, "error_message": ref.error}
expected = f"ERROR {ref.code}\n"
task = f"# {spec.title}\n\n{spec.task_md.strip()}\n"
files = {
"meta.json": json.dumps(info),
"DOCS-task.md": task + (f"\n{PREDICT_HOWTO}\n" if predict else ""),
"schema.sql": spec.schema_sql.strip() + "\n",
"expected.txt": expected,
"tables.json": json.dumps({n: t.__dict__ for n, t in ref.tables.items()}),
}
if predict:
files["query.sql"] = spec.reference_sql.strip() + "\n"
for name, text in files.items():
(staging / name).write_text(text)
staging.rename(root / drill_id)
def save_generated(spec: DrillSpec | Mapping[str, object]) -> str:
"""Return the id of the saved drill; raise ``SpecError`` if it is unusable."""
parsed = spec if isinstance(spec, DrillSpec) else DrillSpec.from_mapping(spec)
ref = _validated(parsed)
drill_id = f"g-{parsed.topic}-{parsed.digest()}"
if not (_paths.generated_dir() / drill_id).exists():
_write(parsed, ref, drill_id)
return drill_id

View File

@ -0,0 +1,62 @@
"""Grade a query run or a typed prediction against a drill's expected rows."""
from __future__ import annotations
from sql_drills import _sandbox, _store
from sql_drills._model import DrillKindError, PredResult, RunResult
from sql_drills._normalize import Rows, clean_grid, compare
def _actual(done: _sandbox.Executed, rec: _store.Record) -> Rows:
if done.ok:
return done.rows
if rec.error_code is not None and done.code is not None:
return [[f"ERROR {done.code}"]]
return []
def run(drill_id: str, sql: str) -> RunResult:
rec = _store.find(drill_id)
with _sandbox.sandbox(rec.text("schema.sql")) as box:
done = box.execute(sql)
shown = done.ok and bool(done.rows)
columns = box.columns_of(sql, len(done.rows)) if shown else []
expected = rec.expected_rows()
actual = _actual(done, rec)
cmp = compare(expected, actual)
return RunResult(
ok=done.ok,
error=done.error,
columns=columns,
rows=actual,
expected_rows=expected,
match=cmp.match,
cell_diff=cmp.cell_diff,
extra_rows=cmp.extra_rows,
missing_rows=cmp.missing_rows,
)
def _typed(grid: Rows, rec: _store.Record) -> Rows:
rows = clean_grid(grid)
typed = [cell.casefold() for row in rows for cell in row if cell]
if rec.error_code is not None and typed == ["error"]:
return [[f"ERROR {rec.error_code}"]]
return rows
def check_prediction(drill_id: str, grid: Rows) -> PredResult:
rec = _store.find(drill_id)
if rec.meta.kind != "predict":
msg = f"{drill_id} is a write drill; use run()"
raise DrillKindError(msg)
expected = rec.expected_rows()
cmp = compare(expected, _typed(grid, rec))
return PredResult(
match=cmp.match,
cell_diff=cmp.cell_diff,
actual_rows=expected,
error=rec.error_message,
extra_rows=cmp.extra_rows,
missing_rows=cmp.missing_rows,
)

View File

@ -0,0 +1,77 @@
"""Public data types; none can carry a drill's reference SQL."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
Kind = Literal["write", "predict"]
Source = Literal["builtin", "generated"]
CellDiff = tuple[int, int, str | None, str | None] # row, col, expected, actual
TOPICS: tuple[str, ...] = (
"basics",
"pivot",
"dates",
"number-format",
"joins-null",
"group-having",
"window",
"strings",
)
class UnknownDrillError(KeyError):
pass
class DrillKindError(ValueError):
pass
@dataclass(frozen=True)
class DrillMeta:
id: str
title: str
topic: str
kind: Kind
source: Source
@dataclass(frozen=True)
class Table:
columns: list[str]
rows: list[list[str]]
@dataclass(frozen=True)
class Drill:
meta: DrillMeta
task_md: str
schema_sql: str
tables: dict[str, Table]
expected_preview: list[list[str]] # write drills only; [] for predict
query: str | None # predict drills only: the SQL to predict
@dataclass(frozen=True)
class RunResult:
ok: bool
error: str | None
columns: list[str]
rows: list[list[str]]
expected_rows: list[list[str]]
match: bool
cell_diff: list[CellDiff] = field(default_factory=list)
extra_rows: list[list[str]] = field(default_factory=list)
missing_rows: list[list[str]] = field(default_factory=list)
@dataclass(frozen=True)
class PredResult:
match: bool
cell_diff: list[CellDiff]
actual_rows: list[list[str]]
error: str | None = None # the real MySQL message, error drills only
extra_rows: list[list[str]] = field(default_factory=list)
missing_rows: list[list[str]] = field(default_factory=list)

View File

@ -0,0 +1,74 @@
"""Exact-match judge rules (tabs to spaces, rstrip) plus a cell-level diff."""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from itertools import zip_longest
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sql_drills._model import CellDiff
Rows = list[list[str]]
def parse_rows(text: str) -> Rows:
return [ln.split("\t") for ln in text.splitlines()]
def line(cells: list[str]) -> str:
return "\t".join(cells).replace("\t", " ").rstrip()
def clean_grid(grid: Rows) -> Rows:
rows = [[cell.strip() for cell in row] for row in grid]
while rows and not any(rows[-1]):
rows.pop()
return rows
def _trim(cells: list[str] | None) -> list[str]:
out = [c.rstrip() for c in cells or []]
while out and not out[-1]:
out.pop()
return out
@dataclass(frozen=True)
class Comparison:
match: bool
cell_diff: list[CellDiff]
extra_rows: Rows
missing_rows: Rows
def _cell_diff(expected: Rows, actual: Rows) -> list[CellDiff]:
diffs: list[CellDiff] = []
for r, (exp_row, act_row) in enumerate(zip_longest(expected, actual)):
pairs = zip_longest(_trim(exp_row), _trim(act_row))
diffs.extend((r, c, e, a) for c, (e, a) in enumerate(pairs) if e != a)
return diffs
def _surplus(left: Rows, right: Rows) -> Rows:
spare = Counter(line(r) for r in right)
out: Rows = []
for row in left:
key = line(row)
if spare[key] > 0:
spare[key] -= 1
else:
out.append(row)
return out
def compare(expected: Rows, actual: Rows) -> Comparison:
if [line(r) for r in expected] == [line(r) for r in actual]:
return Comparison(match=True, cell_diff=[], extra_rows=[], missing_rows=[])
return Comparison(
match=False,
cell_diff=_cell_diff(expected, actual),
extra_rows=_surplus(actual, expected),
missing_rows=_surplus(expected, actual),
)

View File

@ -0,0 +1,23 @@
"""Shipped fixtures and the generated-drill data dir (resolved per call)."""
from __future__ import annotations
import os
from pathlib import Path
ENV_VAR = "SQL_DRILLS_DATA"
def data_dir() -> Path:
override = os.environ.get(ENV_VAR, "")
if override:
return Path(override)
return Path.home() / ".local" / "share" / "sql_drills"
def builtin_dir() -> Path:
return Path(__file__).parent / "fixtures"
def generated_dir() -> Path:
return data_dir() / "drills"

View File

@ -0,0 +1,106 @@
"""A throwaway database plus a user that can touch nothing else."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
import re
import secrets
from typing import TYPE_CHECKING
from sql_drills import _engine
from sql_drills._model import Table
from sql_drills._normalize import Rows, parse_rows
if TYPE_CHECKING:
from collections.abc import Iterator
SNAPSHOT_ROWS = 200
_MAX_EXECUTION_MS = 20000
_CODE = re.compile(r"ERROR (\d+)")
_COLUMNS_SQL = (
"SELECT table_name, column_name FROM information_schema.columns "
"WHERE table_schema = DATABASE() ORDER BY table_name, ordinal_position;"
)
@dataclass(frozen=True)
class Executed:
ok: bool
error: str | None
code: int | None
rows: Rows
def _outcome(res: _engine.SqlResult) -> Executed:
if res.returncode != 0:
error = res.stderr.strip()
found = _CODE.search(error)
return Executed(
ok=False, error=error, code=int(found[1]) if found else None, rows=[]
)
return Executed(ok=True, error=None, code=None, rows=parse_rows(res.stdout))
@dataclass(frozen=True)
class Sandbox:
db: str
user: str
def _run(self, sql: str, *, headers: bool) -> _engine.SqlResult:
limit = f"SET SESSION max_execution_time={_MAX_EXECUTION_MS}; "
return _engine.run_sql(limit + sql, self.db, self.user, headers=headers)
def execute(self, sql: str) -> Executed:
return _outcome(self._run(sql, headers=False))
def columns_of(self, sql: str, row_count: int) -> list[str]:
"""Column names of ``sql``'s output; a name may itself span lines."""
lines = self._run(sql, headers=True).stdout.splitlines()
head = len(lines) - row_count
return "\n".join(lines[:head]).split("\t") if head > 0 else []
def tables(self) -> dict[str, Table]:
listing = self.execute(_COLUMNS_SQL)
if not listing.ok:
msg = f"could not list tables: {listing.error}"
raise _engine.EngineError(msg)
columns: dict[str, list[str]] = {}
for name, column in listing.rows:
columns.setdefault(name, []).append(column)
return {name: self._table(name, cols) for name, cols in columns.items()}
def _table(self, name: str, columns: list[str]) -> Table:
got = self.execute(f"SELECT * FROM `{name}` LIMIT {SNAPSHOT_ROWS};")
if not got.ok:
msg = f"could not read table {name}: {got.error}"
raise _engine.EngineError(msg)
return Table(columns, got.rows)
def _provision(db: str, user: str) -> str:
return (
f"CREATE DATABASE `{db}`; CREATE USER '{user}'@'localhost'; "
f"GRANT ALL ON `{db}`.* TO '{user}'@'localhost';"
)
@contextmanager
def sandbox(schema_sql: str) -> Iterator[Sandbox]:
suffix = secrets.token_hex(4)
box = Sandbox(f"drill_{suffix}", f"u_{suffix}")
try:
made = _engine.run_sql(_provision(box.db, box.user))
if made.returncode != 0:
msg = f"could not create sandbox: {made.stderr.strip()}"
raise _engine.EngineError(msg)
loaded = _engine.run_sql(schema_sql, box.db, box.user)
if loaded.returncode != 0:
msg = f"drill schema failed: {loaded.stderr.strip()}"
raise _engine.EngineError(msg)
yield box
finally:
_engine.run_sql(
f"DROP DATABASE IF EXISTS `{box.db}`; "
f"DROP USER IF EXISTS '{box.user}'@'localhost';"
)

View File

@ -0,0 +1,96 @@
"""Drill folders: ``meta.json``, ``DOCS-task.md``, ``schema.sql``, ``expected.txt``."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING
from sql_drills import _paths
from sql_drills._model import (
TOPICS,
Drill,
DrillMeta,
Table,
UnknownDrillError,
)
from sql_drills._normalize import Rows, parse_rows
if TYPE_CHECKING:
from pathlib import Path
from sql_drills._model import Source
PREVIEW_ROWS = 5
def topics() -> list[str]:
return list(TOPICS)
@dataclass(frozen=True)
class Record:
meta: DrillMeta
folder: Path
error_code: int | None
error_message: str | None
def text(self, name: str) -> str:
return (self.folder / name).read_text()
def expected_rows(self) -> Rows:
return parse_rows(self.text("expected.txt"))
def _record(folder: Path, source: Source) -> Record:
info = json.loads((folder / "meta.json").read_text())
title = (folder / "DOCS-task.md").read_text().splitlines()[0].lstrip("# ")
meta = DrillMeta(folder.name, title, info["topic"], info["kind"], source)
return Record(meta, folder, info.get("error_code"), info.get("error_message"))
def _scan(root: Path, source: Source) -> list[Record]:
if not root.is_dir():
return []
return [
_record(folder, source)
for folder in sorted(root.iterdir())
if (folder / "meta.json").is_file()
]
def records() -> list[Record]:
return _scan(_paths.builtin_dir(), "builtin") + _scan(
_paths.generated_dir(), "generated"
)
def find(key: str) -> Record:
found = records()
for rec in found:
if rec.meta.id == key:
return rec
for rec in found:
if rec.meta.source == "builtin" and rec.meta.id.split("_", 1)[0] == key:
return rec
msg = f"unknown drill: {key}"
raise UnknownDrillError(msg)
def list_drills() -> list[DrillMeta]:
return [rec.meta for rec in records()]
def get_drill(key: str) -> Drill:
rec = find(key)
raw = json.loads(rec.text("tables.json"))
tables = {name: Table(t["columns"], t["rows"]) for name, t in raw.items()}
predict = rec.meta.kind == "predict"
return Drill(
meta=rec.meta,
task_md=rec.text("DOCS-task.md"),
schema_sql=rec.text("schema.sql"),
tables=tables,
expected_preview=[] if predict else rec.expected_rows()[:PREVIEW_ROWS],
query=rec.text("query.sql").strip() if predict else None,
)

View File

@ -0,0 +1,9 @@
# Pivot quarters into columns with SUM(CASE WHEN)
`shop_sales` holds one row per sale. Report the total amount per region with
one column per quarter.
Output columns (no header): `region`, `Q1`, `Q2`, `Q3`, `Q4`.
- A region with no sales in a quarter shows `0`, never `NULL`.
- Order by `region` ascending.

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "pivot"}

View File

@ -0,0 +1,12 @@
CREATE TABLE shop_sales (
id INT PRIMARY KEY,
region VARCHAR(20) NOT NULL,
quarter CHAR(2) NOT NULL,
amount INT NOT NULL
);
INSERT INTO shop_sales (id, region, quarter, amount) VALUES
(1, 'North', 'Q1', 120), (2, 'North', 'Q1', 80),
(3, 'North', 'Q2', 150), (4, 'North', 'Q4', 200),
(5, 'South', 'Q1', 90), (6, 'South', 'Q3', 60),
(7, 'South', 'Q3', 40), (8, 'East', 'Q2', 300),
(9, 'East', 'Q4', 25), (10, 'West', 'Q1', 10);

View File

@ -0,0 +1,72 @@
{
"shop_sales": {
"columns": [
"id",
"region",
"quarter",
"amount"
],
"rows": [
[
"1",
"North",
"Q1",
"120"
],
[
"2",
"North",
"Q1",
"80"
],
[
"3",
"North",
"Q2",
"150"
],
[
"4",
"North",
"Q4",
"200"
],
[
"5",
"South",
"Q1",
"90"
],
[
"6",
"South",
"Q3",
"60"
],
[
"7",
"South",
"Q3",
"40"
],
[
"8",
"East",
"Q2",
"300"
],
[
"9",
"East",
"Q4",
"25"
],
[
"10",
"West",
"Q1",
"10"
]
]
}
}

View File

@ -0,0 +1,10 @@
# Pivot text attributes with MAX(CASE WHEN)
`person_attrs` stores one attribute per row (`email`, `phone`, `city`) in a
text column. Turn it into one row per person.
Output columns (no header): `person`, `email`, `phone`, `city`.
- A missing attribute prints as `NULL`.
- Order by `person` ascending.
- Hint: `SUM` cannot pivot text; think about which aggregate can.

View File

@ -0,0 +1,4 @@
Ida ida@example.org 555-0103 Krakow
Max NULL 555-0102 Lodz
Ola ola@example.org NULL NULL
Zed zed@example.org 555-0101 Gdansk

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "pivot"}

View File

@ -0,0 +1,12 @@
CREATE TABLE person_attrs (
person VARCHAR(20) NOT NULL,
attr VARCHAR(10) NOT NULL,
val VARCHAR(40) NOT NULL,
PRIMARY KEY (person, attr)
);
INSERT INTO person_attrs (person, attr, val) VALUES
('Zed', 'email', 'zed@example.org'), ('Zed', 'city', 'Gdansk'),
('Zed', 'phone', '555-0101'), ('Ola', 'email', 'ola@example.org'),
('Max', 'city', 'Lodz'), ('Max', 'phone', '555-0102'),
('Ida', 'phone', '555-0103'), ('Ida', 'email', 'ida@example.org'),
('Ida', 'city', 'Krakow');

View File

@ -0,0 +1,56 @@
{
"person_attrs": {
"columns": [
"person",
"attr",
"val"
],
"rows": [
[
"Ida",
"city",
"Krakow"
],
[
"Ida",
"email",
"ida@example.org"
],
[
"Ida",
"phone",
"555-0103"
],
[
"Max",
"city",
"Lodz"
],
[
"Max",
"phone",
"555-0102"
],
[
"Ola",
"email",
"ola@example.org"
],
[
"Zed",
"city",
"Gdansk"
],
[
"Zed",
"email",
"zed@example.org"
],
[
"Zed",
"phone",
"555-0101"
]
]
}
}

View File

@ -0,0 +1,11 @@
# Month buckets with DATE_FORMAT and conditional aggregation
`orders` has one row per order with a status of `shipped`, `pending` or
`cancelled`. Summarise per calendar month.
Output columns (no header): `month` (as `YYYY-MM`), total orders, shipped
orders, cancelled orders, revenue of shipped orders only.
- Revenue is `0.00` for a month with no shipped orders.
- Only months that have at least one order appear.
- Order by `month` ascending.

View File

@ -0,0 +1,5 @@
2022-12 1 1 0 10.00
2023-01 3 2 1 125.50
2023-02 3 1 1 80.00
2023-04 3 2 1 215.25
2023-06 1 0 0 0.00

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "dates"}

View File

@ -0,0 +1,19 @@
CREATE TABLE orders (
id INT PRIMARY KEY,
customer VARCHAR(20) NOT NULL,
order_date DATETIME NOT NULL,
status VARCHAR(10) NOT NULL,
total DECIMAL(8, 2) NOT NULL
);
INSERT INTO orders (id, customer, order_date, status, total) VALUES
(1, 'Ann', '2023-01-05 10:00:00', 'shipped', 100.00),
(2, 'Bob', '2023-01-20 11:30:00', 'cancelled', 50.00),
(3, 'Ann', '2023-01-31 23:59:59', 'shipped', 25.50),
(4, 'Cy', '2023-02-01 00:00:00', 'shipped', 80.00),
(5, 'Bob', '2023-02-14 09:15:00', 'pending', 60.00),
(6, 'Dan', '2023-02-28 18:00:00', 'cancelled', 30.00),
(7, 'Ann', '2023-04-02 08:00:00', 'shipped', 200.00),
(8, 'Cy', '2023-04-30 23:00:00', 'shipped', 15.25),
(9, 'Dan', '2023-04-30 12:00:00', 'cancelled', 99.99),
(10, 'Bob', '2022-12-31 22:00:00', 'shipped', 10.00),
(11, 'Eve', '2023-06-10 10:00:00', 'pending', 70.00);

View File

@ -0,0 +1,90 @@
{
"orders": {
"columns": [
"id",
"customer",
"order_date",
"status",
"total"
],
"rows": [
[
"1",
"Ann",
"2023-01-05 10:00:00",
"shipped",
"100.00"
],
[
"2",
"Bob",
"2023-01-20 11:30:00",
"cancelled",
"50.00"
],
[
"3",
"Ann",
"2023-01-31 23:59:59",
"shipped",
"25.50"
],
[
"4",
"Cy",
"2023-02-01 00:00:00",
"shipped",
"80.00"
],
[
"5",
"Bob",
"2023-02-14 09:15:00",
"pending",
"60.00"
],
[
"6",
"Dan",
"2023-02-28 18:00:00",
"cancelled",
"30.00"
],
[
"7",
"Ann",
"2023-04-02 08:00:00",
"shipped",
"200.00"
],
[
"8",
"Cy",
"2023-04-30 23:00:00",
"shipped",
"15.25"
],
[
"9",
"Dan",
"2023-04-30 12:00:00",
"cancelled",
"99.99"
],
[
"10",
"Bob",
"2022-12-31 22:00:00",
"shipped",
"10.00"
],
[
"11",
"Eve",
"2023-06-10 10:00:00",
"pending",
"70.00"
]
]
}
}

View File

@ -0,0 +1,11 @@
# Date formatting: %i is minutes, %M is the month name
`events` has a `DATETIME` column. Print each event in two display formats.
Output columns (no header): `id`, then the start as
`Friday, 05 March 2021 09:07` (weekday name, 2-digit day, month name, year,
24-hour `HH:MM`), then the 12-hour time as `09:07 AM`.
- Order by `id` ascending.
- Trap: mixing up `%i` (minutes) with `%M` (month name), and `%h` (12-hour)
with `%H` (24-hour).

View File

@ -0,0 +1,5 @@
1 Friday, 05 March 2021 09:07 09:07 AM
2 Wednesday, 30 November 2022 17:45 05:45 PM
3 Saturday, 29 February 2020 00:05 12:05 AM
4 Tuesday, 04 July 2023 12:00 12:00 PM
5 Tuesday, 31 December 2019 23:59 11:59 PM

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "dates"}

View File

@ -0,0 +1,11 @@
CREATE TABLE events (
id INT PRIMARY KEY,
name VARCHAR(30) NOT NULL,
starts_at DATETIME NOT NULL
);
INSERT INTO events (id, name, starts_at) VALUES
(1, 'Launch party', '2021-03-05 09:07:00'),
(2, 'Retro', '2022-11-30 17:45:10'),
(3, 'Offsite', '2020-02-29 00:05:00'),
(4, 'Standup', '2023-07-04 12:00:00'),
(5, 'Closing', '2019-12-31 23:59:59');

View File

@ -0,0 +1,36 @@
{
"events": {
"columns": [
"id",
"name",
"starts_at"
],
"rows": [
[
"1",
"Launch party",
"2021-03-05 09:07:00"
],
[
"2",
"Retro",
"2022-11-30 17:45:10"
],
[
"3",
"Offsite",
"2020-02-29 00:05:00"
],
[
"4",
"Standup",
"2023-07-04 12:00:00"
],
[
"5",
"Closing",
"2019-12-31 23:59:59"
]
]
}
}

View File

@ -0,0 +1,10 @@
# Parse text dates with STR_TO_DATE
`signups.signup_text` stores a timestamp as text in the US layout
`MM/DD/YYYY HH:MM` (for example `03/14/2022 18:45`).
Output columns (no header): `id`, then the signup date as `YYYY-MM-DD`.
- Keep only signups on or after 2022-03-01 (compare real dates, not text).
- Order by the parsed timestamp ascending.
- Trap: ordering the raw text sorts by month first, across years.

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "dates"}

View File

@ -0,0 +1,13 @@
CREATE TABLE signups (
id INT PRIMARY KEY,
email VARCHAR(40) NOT NULL,
signup_text VARCHAR(20) NOT NULL
);
INSERT INTO signups (id, email, signup_text) VALUES
(1, 'a@example.org', '03/14/2022 18:45'),
(2, 'b@example.org', '12/01/2021 08:00'),
(3, 'c@example.org', '03/02/2022 23:10'),
(4, 'd@example.org', '11/30/2022 00:00'),
(5, 'e@example.org', '01/09/2022 12:30'),
(6, 'f@example.org', '03/01/2022 00:00'),
(7, 'g@example.org', '02/28/2023 07:05');

View File

@ -0,0 +1,46 @@
{
"signups": {
"columns": [
"id",
"email",
"signup_text"
],
"rows": [
[
"1",
"a@example.org",
"03/14/2022 18:45"
],
[
"2",
"b@example.org",
"12/01/2021 08:00"
],
[
"3",
"c@example.org",
"03/02/2022 23:10"
],
[
"4",
"d@example.org",
"11/30/2022 00:00"
],
[
"5",
"e@example.org",
"01/09/2022 12:30"
],
[
"6",
"f@example.org",
"03/01/2022 00:00"
],
[
"7",
"g@example.org",
"02/28/2023 07:05"
]
]
}
}

View File

@ -0,0 +1,15 @@
# Number traps: AVG scale, ROUND, CAST AS DECIMAL
`team_results` has one row per team per season.
Output columns (no header), per team:
1. `team`
2. average wins, `ROUND`ed to 2 decimals
3. average games, NOT rounded (print whatever MySQL gives you)
4. win percentage `100 * SUM(wins) / SUM(games)` as `DECIMAL(5,1)`
via `CAST`
- Order by `team` ascending.
- Trap: `AVG()` of an integer column returns 4 decimals (`2.5000`), and
`/` is decimal division in MySQL (use `DIV` for the integer kind).

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "number-format"}

View File

@ -0,0 +1,11 @@
CREATE TABLE team_results (
id INT PRIMARY KEY,
team VARCHAR(20) NOT NULL,
season INT NOT NULL,
wins INT NOT NULL,
games INT NOT NULL
);
INSERT INTO team_results (id, team, season, wins, games) VALUES
(1, 'Hawks', 2021, 7, 12), (2, 'Hawks', 2022, 8, 12),
(3, 'Hawks', 2023, 10, 14), (4, 'Lions', 2021, 5, 10),
(5, 'Lions', 2022, 6, 10), (6, 'Owls', 2022, 0, 8);

View File

@ -0,0 +1,55 @@
{
"team_results": {
"columns": [
"id",
"team",
"season",
"wins",
"games"
],
"rows": [
[
"1",
"Hawks",
"2021",
"7",
"12"
],
[
"2",
"Hawks",
"2022",
"8",
"12"
],
[
"3",
"Hawks",
"2023",
"10",
"14"
],
[
"4",
"Lions",
"2021",
"5",
"10"
],
[
"5",
"Lions",
"2022",
"6",
"10"
],
[
"6",
"Owls",
"2022",
"0",
"8"
]
]
}
}

View File

@ -0,0 +1,13 @@
# Thousands separators with FORMAT
`invoices` has one row per invoice.
Output columns (no header), per customer:
1. `customer`
2. total amount with thousands separators and 2 decimals (`35,200.59`)
3. average amount with thousands separators and 0 decimals
- Order by the numeric total descending (not the formatted text).
- Trap: `FORMAT` returns text, so sort on the `SUM`, and `FORMAT(x, 0)` is
not the same string as `ROUND(x)` (no separators).

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "number-format"}

View File

@ -0,0 +1,9 @@
CREATE TABLE invoices (
id INT PRIMARY KEY,
customer VARCHAR(20) NOT NULL,
amount DECIMAL(12, 2) NOT NULL
);
INSERT INTO invoices (id, customer, amount) VALUES
(1, 'Acme', 1200.50), (2, 'Acme', 33999.99), (3, 'Acme', 0.10),
(4, 'Bolt', 999.99), (5, 'Cog', 1000000.00), (6, 'Cog', 2500.25),
(7, 'Dyn', 5.00), (8, 'Dyn', 4.00);

View File

@ -0,0 +1,51 @@
{
"invoices": {
"columns": [
"id",
"customer",
"amount"
],
"rows": [
[
"1",
"Acme",
"1200.50"
],
[
"2",
"Acme",
"33999.99"
],
[
"3",
"Acme",
"0.10"
],
[
"4",
"Bolt",
"999.99"
],
[
"5",
"Cog",
"1000000.00"
],
[
"6",
"Cog",
"2500.25"
],
[
"7",
"Dyn",
"5.00"
],
[
"8",
"Dyn",
"4.00"
]
]
}
}

View File

@ -0,0 +1,12 @@
# LEFT JOIN anti-join and NULL handling
List customers who have never placed a valid order. An order is valid when
its status is not `cancelled`; a customer with only cancelled orders (or no
orders) qualifies.
Output columns (no header): `name`, `phone`, where a missing phone prints as
`n/a`.
- Order by `name` ascending.
- Trap: put the status condition in the `ON` clause, not in `WHERE`, or the
cancelled-only customers vanish.

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "joins-null"}

View File

@ -0,0 +1,16 @@
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
phone VARCHAR(20) NULL
);
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT NOT NULL,
status VARCHAR(10) NOT NULL
);
INSERT INTO customers (id, name, phone) VALUES
(1, 'Alma', '555-0101'), (2, 'Boris', NULL), (3, 'Cleo', '555-0103'),
(4, 'Dara', NULL), (5, 'Egon', '555-0105');
INSERT INTO orders (id, customer_id, status) VALUES
(1, 1, 'shipped'), (2, 3, 'cancelled'), (3, 3, 'cancelled'),
(4, 5, 'pending'), (5, 1, 'cancelled');

View File

@ -0,0 +1,70 @@
{
"customers": {
"columns": [
"id",
"name",
"phone"
],
"rows": [
[
"1",
"Alma",
"555-0101"
],
[
"2",
"Boris",
"NULL"
],
[
"3",
"Cleo",
"555-0103"
],
[
"4",
"Dara",
"NULL"
],
[
"5",
"Egon",
"555-0105"
]
]
},
"orders": {
"columns": [
"id",
"customer_id",
"status"
],
"rows": [
[
"1",
"1",
"shipped"
],
[
"2",
"3",
"cancelled"
],
[
"3",
"3",
"cancelled"
],
[
"4",
"5",
"pending"
],
[
"5",
"1",
"cancelled"
]
]
}
}

View File

@ -0,0 +1,9 @@
# GROUP BY with WHERE versus HAVING
`tickets` holds support tickets with hours spent. Ignoring `low` priority
tickets, find teams with at least 3 tickets AND at least 10 total hours.
Output columns (no header): `team`, ticket count, total hours.
- Order by total hours descending, then `team` ascending.
- Row filters (`priority`) go in `WHERE`; aggregate filters in `HAVING`.

View File

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

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "group-having"}

View File

@ -0,0 +1,16 @@
CREATE TABLE tickets (
id INT PRIMARY KEY,
team VARCHAR(10) NOT NULL,
priority VARCHAR(10) NOT NULL,
hours DECIMAL(5, 1) NOT NULL
);
INSERT INTO tickets (id, team, priority, hours) VALUES
(1, 'Alpha', 'high', 5.0), (2, 'Alpha', 'medium', 3.5),
(3, 'Alpha', 'low', 9.0), (4, 'Alpha', 'high', 2.5),
(5, 'Beta', 'medium', 4.0), (6, 'Beta', 'high', 6.0),
(7, 'Gamma', 'high', 4.0), (8, 'Gamma', 'medium', 4.0),
(9, 'Gamma', 'high', 4.0), (10, 'Gamma', 'medium', 4.0),
(11, 'Delta', 'medium', 1.0), (12, 'Delta', 'medium', 1.0),
(13, 'Delta', 'low', 50.0), (14, 'Delta', 'high', 1.0),
(15, 'Eps', 'medium', 3.5), (16, 'Eps', 'medium', 3.5),
(17, 'Eps', 'high', 3.0);

View File

@ -0,0 +1,114 @@
{
"tickets": {
"columns": [
"id",
"team",
"priority",
"hours"
],
"rows": [
[
"1",
"Alpha",
"high",
"5.0"
],
[
"2",
"Alpha",
"medium",
"3.5"
],
[
"3",
"Alpha",
"low",
"9.0"
],
[
"4",
"Alpha",
"high",
"2.5"
],
[
"5",
"Beta",
"medium",
"4.0"
],
[
"6",
"Beta",
"high",
"6.0"
],
[
"7",
"Gamma",
"high",
"4.0"
],
[
"8",
"Gamma",
"medium",
"4.0"
],
[
"9",
"Gamma",
"high",
"4.0"
],
[
"10",
"Gamma",
"medium",
"4.0"
],
[
"11",
"Delta",
"medium",
"1.0"
],
[
"12",
"Delta",
"medium",
"1.0"
],
[
"13",
"Delta",
"low",
"50.0"
],
[
"14",
"Delta",
"high",
"1.0"
],
[
"15",
"Eps",
"medium",
"3.5"
],
[
"16",
"Eps",
"medium",
"3.5"
],
[
"17",
"Eps",
"high",
"3.0"
]
]
}
}

View File

@ -0,0 +1,9 @@
# Top-N per group with DENSE_RANK
Show employees whose salary is among the two highest DISTINCT salaries in
their department (ties share a rank, so more than two people can appear).
Output columns (no header): `dept`, `name`, `salary`, the dense rank.
- Order by `dept`, then rank, then `name` ascending.
- `RANK()` would skip numbers after ties; this drill wants `DENSE_RANK()`.

View File

@ -0,0 +1,8 @@
Eng Ava 150 1
Eng Ben 150 1
Eng Cal 130 2
Ops Fay 90 1
Ops Gus 80 2
Sales Ivy 100 1
Sales Jon 100 1
Sales Kim 100 1

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "window"}

View File

@ -0,0 +1,11 @@
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(10) NOT NULL,
dept VARCHAR(10) NOT NULL,
salary INT NOT NULL
);
INSERT INTO employees (id, name, dept, salary) VALUES
(1, 'Ava', 'Eng', 150), (2, 'Ben', 'Eng', 150), (3, 'Cal', 'Eng', 130),
(4, 'Dev', 'Eng', 120), (5, 'Eli', 'Eng', 120), (6, 'Fay', 'Ops', 90),
(7, 'Gus', 'Ops', 80), (8, 'Hal', 'Ops', 70), (9, 'Ivy', 'Sales', 100),
(10, 'Jon', 'Sales', 100), (11, 'Kim', 'Sales', 100);

View File

@ -0,0 +1,78 @@
{
"employees": {
"columns": [
"id",
"name",
"dept",
"salary"
],
"rows": [
[
"1",
"Ava",
"Eng",
"150"
],
[
"2",
"Ben",
"Eng",
"150"
],
[
"3",
"Cal",
"Eng",
"130"
],
[
"4",
"Dev",
"Eng",
"120"
],
[
"5",
"Eli",
"Eng",
"120"
],
[
"6",
"Fay",
"Ops",
"90"
],
[
"7",
"Gus",
"Ops",
"80"
],
[
"8",
"Hal",
"Ops",
"70"
],
[
"9",
"Ivy",
"Sales",
"100"
],
[
"10",
"Jon",
"Sales",
"100"
],
[
"11",
"Kim",
"Sales",
"100"
]
]
}
}

View File

@ -0,0 +1,10 @@
# LAG and DATEDIFF: find long gaps between logins
`logins` has one row per user per day they logged in. Find every pair of
consecutive logins by the same user that are 7 or more days apart.
Output columns (no header): `user_name`, previous login date, login date,
gap in days.
- Order by `user_name`, then login date ascending.
- Window functions cannot appear in `WHERE`; wrap them in a CTE or subquery.

View File

@ -0,0 +1,4 @@
amy 2023-01-02 2023-01-10 8
amy 2023-01-10 2023-01-17 7
amy 2023-01-17 2023-02-01 15
dan 2024-02-20 2024-03-01 10

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "window"}

View File

@ -0,0 +1,10 @@
CREATE TABLE logins (
user_name VARCHAR(10) NOT NULL,
login_on DATE NOT NULL,
PRIMARY KEY (user_name, login_on)
);
INSERT INTO logins (user_name, login_on) VALUES
('amy', '2023-01-01'), ('amy', '2023-01-02'), ('amy', '2023-01-10'),
('amy', '2023-01-17'), ('amy', '2023-02-01'), ('bo', '2023-03-01'),
('bo', '2023-03-05'), ('cat', '2023-01-05'), ('dan', '2024-02-20'),
('dan', '2024-03-01');

View File

@ -0,0 +1,50 @@
{
"logins": {
"columns": [
"user_name",
"login_on"
],
"rows": [
[
"amy",
"2023-01-01"
],
[
"amy",
"2023-01-02"
],
[
"amy",
"2023-01-10"
],
[
"amy",
"2023-01-17"
],
[
"amy",
"2023-02-01"
],
[
"bo",
"2023-03-01"
],
[
"bo",
"2023-03-05"
],
[
"cat",
"2023-01-05"
],
[
"dan",
"2024-02-20"
],
[
"dan",
"2024-03-01"
]
]
}
}

View File

@ -0,0 +1,11 @@
# CONCAT and percentage formatting
`poll_votes` has the vote count per option for several polls. For each
option print its share of its own poll's votes with one decimal.
Output columns (no header): `poll_id`, then text like `Red: 42.9%`.
- Order by `poll_id`, then votes descending, then option name ascending.
- A share of exactly 50 prints `50.0%` and zero prints `0.0%`.
- Trap: `ROUND(x, 1)` keeps the trailing `.0` only on a DECIMAL, so do the
arithmetic in decimal, not by dividing integers with `DIV`.

View File

@ -0,0 +1,7 @@
1 Green: 42.9%
1 Red: 42.9%
1 Blue: 14.3%
2 No: 50.0%
2 Yes: 50.0%
3 Sure: 100.0%
3 Maybe: 0.0%

View File

@ -0,0 +1 @@
{"kind": "write", "topic": "strings"}

View File

@ -0,0 +1,10 @@
CREATE TABLE poll_votes (
poll_id INT NOT NULL,
option_name VARCHAR(10) NOT NULL,
votes INT NOT NULL,
PRIMARY KEY (poll_id, option_name)
);
INSERT INTO poll_votes (poll_id, option_name, votes) VALUES
(1, 'Red', 3), (1, 'Green', 3), (1, 'Blue', 1),
(2, 'Yes', 5), (2, 'No', 5),
(3, 'Maybe', 0), (3, 'Sure', 4);

View File

@ -0,0 +1,46 @@
{
"poll_votes": {
"columns": [
"poll_id",
"option_name",
"votes"
],
"rows": [
[
"1",
"Blue",
"1"
],
[
"1",
"Green",
"3"
],
[
"1",
"Red",
"3"
],
[
"2",
"No",
"5"
],
[
"2",
"Yes",
"5"
],
[
"3",
"Maybe",
"0"
],
[
"3",
"Sure",
"4"
]
]
}
}

View File

@ -0,0 +1,7 @@
# Predict: NULL and !=
Which customers does a `!=` filter keep when some `referee` values are NULL?
Predict the exact output of the query before running it: one grid row per
result row, `NULL` for SQL NULL, no header. If the query fails, type `ERROR`
in the first cell.

View File

@ -0,0 +1 @@
Bob

View File

@ -0,0 +1 @@
{"kind": "predict", "topic": "basics"}

View File

@ -0,0 +1 @@
SELECT name FROM customers WHERE referee != 2 ORDER BY id;

View File

@ -0,0 +1,4 @@
CREATE TABLE customers (id INT, name VARCHAR(10), referee INT);
INSERT INTO customers VALUES (1, 'Ann', NULL), (2, 'Bob', 1), (3, 'Cid', 2), (4, 'Dee', NULL);
CREATE TABLE orders (id INT, customer_id INT, amount INT, status VARCHAR(10));
INSERT INTO orders VALUES (10, 1, 50, 'paid'), (11, 1, 70, 'refunded'), (12, 2, 30, 'paid');

View File

@ -0,0 +1,59 @@
{
"customers": {
"columns": [
"id",
"name",
"referee"
],
"rows": [
[
"1",
"Ann",
"NULL"
],
[
"2",
"Bob",
"1"
],
[
"3",
"Cid",
"2"
],
[
"4",
"Dee",
"NULL"
]
]
},
"orders": {
"columns": [
"id",
"customer_id",
"amount",
"status"
],
"rows": [
[
"10",
"1",
"50",
"paid"
],
[
"11",
"1",
"70",
"refunded"
],
[
"12",
"2",
"30",
"paid"
]
]
}
}

View File

@ -0,0 +1,7 @@
# Predict: NULL needs IS NULL
The same filter, now with an explicit NULL branch. How many rows, and why does it differ from the plain `!=` version?
Predict the exact output of the query before running it: one grid row per
result row, `NULL` for SQL NULL, no header. If the query fails, type `ERROR`
in the first cell.

View File

@ -0,0 +1,3 @@
Ann
Bob
Dee

View File

@ -0,0 +1 @@
{"kind": "predict", "topic": "basics"}

View File

@ -0,0 +1 @@
SELECT name FROM customers WHERE referee != 2 OR referee IS NULL ORDER BY id;

View File

@ -0,0 +1,4 @@
CREATE TABLE customers (id INT, name VARCHAR(10), referee INT);
INSERT INTO customers VALUES (1, 'Ann', NULL), (2, 'Bob', 1), (3, 'Cid', 2), (4, 'Dee', NULL);
CREATE TABLE orders (id INT, customer_id INT, amount INT, status VARCHAR(10));
INSERT INTO orders VALUES (10, 1, 50, 'paid'), (11, 1, 70, 'refunded'), (12, 2, 30, 'paid');

View File

@ -0,0 +1,59 @@
{
"customers": {
"columns": [
"id",
"name",
"referee"
],
"rows": [
[
"1",
"Ann",
"NULL"
],
[
"2",
"Bob",
"1"
],
[
"3",
"Cid",
"2"
],
[
"4",
"Dee",
"NULL"
]
]
},
"orders": {
"columns": [
"id",
"customer_id",
"amount",
"status"
],
"rows": [
[
"10",
"1",
"50",
"paid"
],
[
"11",
"1",
"70",
"refunded"
],
[
"12",
"2",
"30",
"paid"
]
]
}
}

View File

@ -0,0 +1,7 @@
# Predict: LEFT JOIN condition in ON
The extra condition sits in the `ON` clause. Which customers appear, and what is in `o.id` for each?
Predict the exact output of the query before running it: one grid row per
result row, `NULL` for SQL NULL, no header. If the query fails, type `ERROR`
in the first cell.

View File

@ -0,0 +1,4 @@
Ann 10
Bob 12
Cid NULL
Dee NULL

View File

@ -0,0 +1 @@
{"kind": "predict", "topic": "basics"}

View File

@ -0,0 +1,3 @@
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
ORDER BY c.id, o.id;

View File

@ -0,0 +1,4 @@
CREATE TABLE customers (id INT, name VARCHAR(10), referee INT);
INSERT INTO customers VALUES (1, 'Ann', NULL), (2, 'Bob', 1), (3, 'Cid', 2), (4, 'Dee', NULL);
CREATE TABLE orders (id INT, customer_id INT, amount INT, status VARCHAR(10));
INSERT INTO orders VALUES (10, 1, 50, 'paid'), (11, 1, 70, 'refunded'), (12, 2, 30, 'paid');

View File

@ -0,0 +1,59 @@
{
"customers": {
"columns": [
"id",
"name",
"referee"
],
"rows": [
[
"1",
"Ann",
"NULL"
],
[
"2",
"Bob",
"1"
],
[
"3",
"Cid",
"2"
],
[
"4",
"Dee",
"NULL"
]
]
},
"orders": {
"columns": [
"id",
"customer_id",
"amount",
"status"
],
"rows": [
[
"10",
"1",
"50",
"paid"
],
[
"11",
"1",
"70",
"refunded"
],
[
"12",
"2",
"30",
"paid"
]
]
}
}

View File

@ -0,0 +1,7 @@
# Predict: LEFT JOIN condition in WHERE
The same condition moved to `WHERE`. Which customers disappear, and why?
Predict the exact output of the query before running it: one grid row per
result row, `NULL` for SQL NULL, no header. If the query fails, type `ERROR`
in the first cell.

View File

@ -0,0 +1,2 @@
Ann 10
Bob 12

View File

@ -0,0 +1 @@
{"kind": "predict", "topic": "basics"}

View File

@ -0,0 +1,4 @@
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id, o.id;

View File

@ -0,0 +1,4 @@
CREATE TABLE customers (id INT, name VARCHAR(10), referee INT);
INSERT INTO customers VALUES (1, 'Ann', NULL), (2, 'Bob', 1), (3, 'Cid', 2), (4, 'Dee', NULL);
CREATE TABLE orders (id INT, customer_id INT, amount INT, status VARCHAR(10));
INSERT INTO orders VALUES (10, 1, 50, 'paid'), (11, 1, 70, 'refunded'), (12, 2, 30, 'paid');

Some files were not shown because too many files have changed in this diff Show More