mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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
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:
parent
1fb04ce59d
commit
f865508eb4
73
.github/workflows/sql-drills-precommit.yml
vendored
Normal file
73
.github/workflows/sql-drills-precommit.yml
vendored
Normal 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
34
.github/workflows/sql-drills-tests.yml
vendored
Normal 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
|
||||||
143
sql_drills/.pre-commit-config.yaml
Normal file
143
sql_drills/.pre-commit-config.yaml
Normal 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
3
sql_drills/README.md
Normal 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
12
sql_drills/install.sh
Executable 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
188
sql_drills/pyproject.toml
Normal 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"]
|
||||||
10
sql_drills/requirements.txt
Normal file
10
sql_drills/requirements.txt
Normal 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
|
||||||
49
sql_drills/scripts/regen_fixtures.py
Normal file
49
sql_drills/scripts/regen_fixtures.py
Normal 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()
|
||||||
46
sql_drills/sql_drills/__init__.py
Normal file
46
sql_drills/sql_drills/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
129
sql_drills/sql_drills/_engine.py
Normal file
129
sql_drills/sql_drills/_engine.py
Normal 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)
|
||||||
142
sql_drills/sql_drills/_generate.py
Normal file
142
sql_drills/sql_drills/_generate.py
Normal 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
|
||||||
62
sql_drills/sql_drills/_grade.py
Normal file
62
sql_drills/sql_drills/_grade.py
Normal 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,
|
||||||
|
)
|
||||||
77
sql_drills/sql_drills/_model.py
Normal file
77
sql_drills/sql_drills/_model.py
Normal 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)
|
||||||
74
sql_drills/sql_drills/_normalize.py
Normal file
74
sql_drills/sql_drills/_normalize.py
Normal 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),
|
||||||
|
)
|
||||||
23
sql_drills/sql_drills/_paths.py
Normal file
23
sql_drills/sql_drills/_paths.py
Normal 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"
|
||||||
106
sql_drills/sql_drills/_sandbox.py
Normal file
106
sql_drills/sql_drills/_sandbox.py
Normal 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';"
|
||||||
|
)
|
||||||
96
sql_drills/sql_drills/_store.py
Normal file
96
sql_drills/sql_drills/_store.py
Normal 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,
|
||||||
|
)
|
||||||
9
sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md
Normal file
9
sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Pivot quarters into columns with SUM(CASE WHEN)
|
||||||
|
|
||||||
|
`shop_sales` holds one row per sale. Report the total amount per region with
|
||||||
|
one column per quarter.
|
||||||
|
|
||||||
|
Output columns (no header): `region`, `Q1`, `Q2`, `Q3`, `Q4`.
|
||||||
|
|
||||||
|
- A region with no sales in a quarter shows `0`, never `NULL`.
|
||||||
|
- Order by `region` ascending.
|
||||||
4
sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt
Normal file
4
sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
East 0 300 0 25
|
||||||
|
North 200 150 0 200
|
||||||
|
South 90 0 100 0
|
||||||
|
West 10 0 0 0
|
||||||
1
sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "pivot"}
|
||||||
12
sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql
Normal file
12
sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE shop_sales (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
region VARCHAR(20) NOT NULL,
|
||||||
|
quarter CHAR(2) NOT NULL,
|
||||||
|
amount INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO shop_sales (id, region, quarter, amount) VALUES
|
||||||
|
(1, 'North', 'Q1', 120), (2, 'North', 'Q1', 80),
|
||||||
|
(3, 'North', 'Q2', 150), (4, 'North', 'Q4', 200),
|
||||||
|
(5, 'South', 'Q1', 90), (6, 'South', 'Q3', 60),
|
||||||
|
(7, 'South', 'Q3', 40), (8, 'East', 'Q2', 300),
|
||||||
|
(9, 'East', 'Q4', 25), (10, 'West', 'Q1', 10);
|
||||||
72
sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json
Normal file
72
sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
10
sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md
Normal file
10
sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Pivot text attributes with MAX(CASE WHEN)
|
||||||
|
|
||||||
|
`person_attrs` stores one attribute per row (`email`, `phone`, `city`) in a
|
||||||
|
text column. Turn it into one row per person.
|
||||||
|
|
||||||
|
Output columns (no header): `person`, `email`, `phone`, `city`.
|
||||||
|
|
||||||
|
- A missing attribute prints as `NULL`.
|
||||||
|
- Order by `person` ascending.
|
||||||
|
- Hint: `SUM` cannot pivot text; think about which aggregate can.
|
||||||
4
sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt
Normal file
4
sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Ida ida@example.org 555-0103 Krakow
|
||||||
|
Max NULL 555-0102 Lodz
|
||||||
|
Ola ola@example.org NULL NULL
|
||||||
|
Zed zed@example.org 555-0101 Gdansk
|
||||||
1
sql_drills/sql_drills/fixtures/02_pivot_max/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/02_pivot_max/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "pivot"}
|
||||||
12
sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql
Normal file
12
sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE person_attrs (
|
||||||
|
person VARCHAR(20) NOT NULL,
|
||||||
|
attr VARCHAR(10) NOT NULL,
|
||||||
|
val VARCHAR(40) NOT NULL,
|
||||||
|
PRIMARY KEY (person, attr)
|
||||||
|
);
|
||||||
|
INSERT INTO person_attrs (person, attr, val) VALUES
|
||||||
|
('Zed', 'email', 'zed@example.org'), ('Zed', 'city', 'Gdansk'),
|
||||||
|
('Zed', 'phone', '555-0101'), ('Ola', 'email', 'ola@example.org'),
|
||||||
|
('Max', 'city', 'Lodz'), ('Max', 'phone', '555-0102'),
|
||||||
|
('Ida', 'phone', '555-0103'), ('Ida', 'email', 'ida@example.org'),
|
||||||
|
('Ida', 'city', 'Krakow');
|
||||||
56
sql_drills/sql_drills/fixtures/02_pivot_max/tables.json
Normal file
56
sql_drills/sql_drills/fixtures/02_pivot_max/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md
Normal file
11
sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Month buckets with DATE_FORMAT and conditional aggregation
|
||||||
|
|
||||||
|
`orders` has one row per order with a status of `shipped`, `pending` or
|
||||||
|
`cancelled`. Summarise per calendar month.
|
||||||
|
|
||||||
|
Output columns (no header): `month` (as `YYYY-MM`), total orders, shipped
|
||||||
|
orders, cancelled orders, revenue of shipped orders only.
|
||||||
|
|
||||||
|
- Revenue is `0.00` for a month with no shipped orders.
|
||||||
|
- Only months that have at least one order appear.
|
||||||
|
- Order by `month` ascending.
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
2022-12 1 1 0 10.00
|
||||||
|
2023-01 3 2 1 125.50
|
||||||
|
2023-02 3 1 1 80.00
|
||||||
|
2023-04 3 2 1 215.25
|
||||||
|
2023-06 1 0 0 0.00
|
||||||
1
sql_drills/sql_drills/fixtures/03_month_bucket/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/03_month_bucket/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "dates"}
|
||||||
19
sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql
Normal file
19
sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
customer VARCHAR(20) NOT NULL,
|
||||||
|
order_date DATETIME NOT NULL,
|
||||||
|
status VARCHAR(10) NOT NULL,
|
||||||
|
total DECIMAL(8, 2) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO orders (id, customer, order_date, status, total) VALUES
|
||||||
|
(1, 'Ann', '2023-01-05 10:00:00', 'shipped', 100.00),
|
||||||
|
(2, 'Bob', '2023-01-20 11:30:00', 'cancelled', 50.00),
|
||||||
|
(3, 'Ann', '2023-01-31 23:59:59', 'shipped', 25.50),
|
||||||
|
(4, 'Cy', '2023-02-01 00:00:00', 'shipped', 80.00),
|
||||||
|
(5, 'Bob', '2023-02-14 09:15:00', 'pending', 60.00),
|
||||||
|
(6, 'Dan', '2023-02-28 18:00:00', 'cancelled', 30.00),
|
||||||
|
(7, 'Ann', '2023-04-02 08:00:00', 'shipped', 200.00),
|
||||||
|
(8, 'Cy', '2023-04-30 23:00:00', 'shipped', 15.25),
|
||||||
|
(9, 'Dan', '2023-04-30 12:00:00', 'cancelled', 99.99),
|
||||||
|
(10, 'Bob', '2022-12-31 22:00:00', 'shipped', 10.00),
|
||||||
|
(11, 'Eve', '2023-06-10 10:00:00', 'pending', 70.00);
|
||||||
90
sql_drills/sql_drills/fixtures/03_month_bucket/tables.json
Normal file
90
sql_drills/sql_drills/fixtures/03_month_bucket/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md
Normal file
11
sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Date formatting: %i is minutes, %M is the month name
|
||||||
|
|
||||||
|
`events` has a `DATETIME` column. Print each event in two display formats.
|
||||||
|
|
||||||
|
Output columns (no header): `id`, then the start as
|
||||||
|
`Friday, 05 March 2021 09:07` (weekday name, 2-digit day, month name, year,
|
||||||
|
24-hour `HH:MM`), then the 12-hour time as `09:07 AM`.
|
||||||
|
|
||||||
|
- Order by `id` ascending.
|
||||||
|
- Trap: mixing up `%i` (minutes) with `%M` (month name), and `%h` (12-hour)
|
||||||
|
with `%H` (24-hour).
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
1 Friday, 05 March 2021 09:07 09:07 AM
|
||||||
|
2 Wednesday, 30 November 2022 17:45 05:45 PM
|
||||||
|
3 Saturday, 29 February 2020 00:05 12:05 AM
|
||||||
|
4 Tuesday, 04 July 2023 12:00 12:00 PM
|
||||||
|
5 Tuesday, 31 December 2019 23:59 11:59 PM
|
||||||
1
sql_drills/sql_drills/fixtures/04_date_format/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/04_date_format/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "dates"}
|
||||||
11
sql_drills/sql_drills/fixtures/04_date_format/schema.sql
Normal file
11
sql_drills/sql_drills/fixtures/04_date_format/schema.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE events (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(30) NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO events (id, name, starts_at) VALUES
|
||||||
|
(1, 'Launch party', '2021-03-05 09:07:00'),
|
||||||
|
(2, 'Retro', '2022-11-30 17:45:10'),
|
||||||
|
(3, 'Offsite', '2020-02-29 00:05:00'),
|
||||||
|
(4, 'Standup', '2023-07-04 12:00:00'),
|
||||||
|
(5, 'Closing', '2019-12-31 23:59:59');
|
||||||
36
sql_drills/sql_drills/fixtures/04_date_format/tables.json
Normal file
36
sql_drills/sql_drills/fixtures/04_date_format/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
10
sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md
Normal file
10
sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Parse text dates with STR_TO_DATE
|
||||||
|
|
||||||
|
`signups.signup_text` stores a timestamp as text in the US layout
|
||||||
|
`MM/DD/YYYY HH:MM` (for example `03/14/2022 18:45`).
|
||||||
|
|
||||||
|
Output columns (no header): `id`, then the signup date as `YYYY-MM-DD`.
|
||||||
|
|
||||||
|
- Keep only signups on or after 2022-03-01 (compare real dates, not text).
|
||||||
|
- Order by the parsed timestamp ascending.
|
||||||
|
- Trap: ordering the raw text sorts by month first, across years.
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
6 2022-03-01
|
||||||
|
3 2022-03-02
|
||||||
|
1 2022-03-14
|
||||||
|
4 2022-11-30
|
||||||
|
7 2023-02-28
|
||||||
1
sql_drills/sql_drills/fixtures/05_str_to_date/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/05_str_to_date/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "dates"}
|
||||||
13
sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql
Normal file
13
sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE signups (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
email VARCHAR(40) NOT NULL,
|
||||||
|
signup_text VARCHAR(20) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO signups (id, email, signup_text) VALUES
|
||||||
|
(1, 'a@example.org', '03/14/2022 18:45'),
|
||||||
|
(2, 'b@example.org', '12/01/2021 08:00'),
|
||||||
|
(3, 'c@example.org', '03/02/2022 23:10'),
|
||||||
|
(4, 'd@example.org', '11/30/2022 00:00'),
|
||||||
|
(5, 'e@example.org', '01/09/2022 12:30'),
|
||||||
|
(6, 'f@example.org', '03/01/2022 00:00'),
|
||||||
|
(7, 'g@example.org', '02/28/2023 07:05');
|
||||||
46
sql_drills/sql_drills/fixtures/05_str_to_date/tables.json
Normal file
46
sql_drills/sql_drills/fixtures/05_str_to_date/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md
Normal file
15
sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Number traps: AVG scale, ROUND, CAST AS DECIMAL
|
||||||
|
|
||||||
|
`team_results` has one row per team per season.
|
||||||
|
|
||||||
|
Output columns (no header), per team:
|
||||||
|
|
||||||
|
1. `team`
|
||||||
|
2. average wins, `ROUND`ed to 2 decimals
|
||||||
|
3. average games, NOT rounded (print whatever MySQL gives you)
|
||||||
|
4. win percentage `100 * SUM(wins) / SUM(games)` as `DECIMAL(5,1)`
|
||||||
|
via `CAST`
|
||||||
|
|
||||||
|
- Order by `team` ascending.
|
||||||
|
- Trap: `AVG()` of an integer column returns 4 decimals (`2.5000`), and
|
||||||
|
`/` is decimal division in MySQL (use `DIV` for the integer kind).
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
Hawks 8.33 12.6667 65.8
|
||||||
|
Lions 5.50 10.0000 55.0
|
||||||
|
Owls 0.00 8.0000 0.0
|
||||||
1
sql_drills/sql_drills/fixtures/06_number_traps/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/06_number_traps/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "number-format"}
|
||||||
11
sql_drills/sql_drills/fixtures/06_number_traps/schema.sql
Normal file
11
sql_drills/sql_drills/fixtures/06_number_traps/schema.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE team_results (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
team VARCHAR(20) NOT NULL,
|
||||||
|
season INT NOT NULL,
|
||||||
|
wins INT NOT NULL,
|
||||||
|
games INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO team_results (id, team, season, wins, games) VALUES
|
||||||
|
(1, 'Hawks', 2021, 7, 12), (2, 'Hawks', 2022, 8, 12),
|
||||||
|
(3, 'Hawks', 2023, 10, 14), (4, 'Lions', 2021, 5, 10),
|
||||||
|
(5, 'Lions', 2022, 6, 10), (6, 'Owls', 2022, 0, 8);
|
||||||
55
sql_drills/sql_drills/fixtures/06_number_traps/tables.json
Normal file
55
sql_drills/sql_drills/fixtures/06_number_traps/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md
Normal file
13
sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Thousands separators with FORMAT
|
||||||
|
|
||||||
|
`invoices` has one row per invoice.
|
||||||
|
|
||||||
|
Output columns (no header), per customer:
|
||||||
|
|
||||||
|
1. `customer`
|
||||||
|
2. total amount with thousands separators and 2 decimals (`35,200.59`)
|
||||||
|
3. average amount with thousands separators and 0 decimals
|
||||||
|
|
||||||
|
- Order by the numeric total descending (not the formatted text).
|
||||||
|
- Trap: `FORMAT` returns text, so sort on the `SUM`, and `FORMAT(x, 0)` is
|
||||||
|
not the same string as `ROUND(x)` (no separators).
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
Cog 1,002,500.25 501,250
|
||||||
|
Acme 35,200.59 11,734
|
||||||
|
Bolt 999.99 1,000
|
||||||
|
Dyn 9.00 5
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "number-format"}
|
||||||
@ -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);
|
||||||
51
sql_drills/sql_drills/fixtures/07_number_format/tables.json
Normal file
51
sql_drills/sql_drills/fixtures/07_number_format/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md
Normal file
12
sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# LEFT JOIN anti-join and NULL handling
|
||||||
|
|
||||||
|
List customers who have never placed a valid order. An order is valid when
|
||||||
|
its status is not `cancelled`; a customer with only cancelled orders (or no
|
||||||
|
orders) qualifies.
|
||||||
|
|
||||||
|
Output columns (no header): `name`, `phone`, where a missing phone prints as
|
||||||
|
`n/a`.
|
||||||
|
|
||||||
|
- Order by `name` ascending.
|
||||||
|
- Trap: put the status condition in the `ON` clause, not in `WHERE`, or the
|
||||||
|
cancelled-only customers vanish.
|
||||||
3
sql_drills/sql_drills/fixtures/08_anti_join/expected.txt
Normal file
3
sql_drills/sql_drills/fixtures/08_anti_join/expected.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Boris n/a
|
||||||
|
Cleo 555-0103
|
||||||
|
Dara n/a
|
||||||
1
sql_drills/sql_drills/fixtures/08_anti_join/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/08_anti_join/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "joins-null"}
|
||||||
16
sql_drills/sql_drills/fixtures/08_anti_join/schema.sql
Normal file
16
sql_drills/sql_drills/fixtures/08_anti_join/schema.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE customers (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(20) NOT NULL,
|
||||||
|
phone VARCHAR(20) NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
status VARCHAR(10) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO customers (id, name, phone) VALUES
|
||||||
|
(1, 'Alma', '555-0101'), (2, 'Boris', NULL), (3, 'Cleo', '555-0103'),
|
||||||
|
(4, 'Dara', NULL), (5, 'Egon', '555-0105');
|
||||||
|
INSERT INTO orders (id, customer_id, status) VALUES
|
||||||
|
(1, 1, 'shipped'), (2, 3, 'cancelled'), (3, 3, 'cancelled'),
|
||||||
|
(4, 5, 'pending'), (5, 1, 'cancelled');
|
||||||
70
sql_drills/sql_drills/fixtures/08_anti_join/tables.json
Normal file
70
sql_drills/sql_drills/fixtures/08_anti_join/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
# GROUP BY with WHERE versus HAVING
|
||||||
|
|
||||||
|
`tickets` holds support tickets with hours spent. Ignoring `low` priority
|
||||||
|
tickets, find teams with at least 3 tickets AND at least 10 total hours.
|
||||||
|
|
||||||
|
Output columns (no header): `team`, ticket count, total hours.
|
||||||
|
|
||||||
|
- Order by total hours descending, then `team` ascending.
|
||||||
|
- Row filters (`priority`) go in `WHERE`; aggregate filters in `HAVING`.
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
Gamma 4 16.0
|
||||||
|
Alpha 3 11.0
|
||||||
|
Eps 3 10.0
|
||||||
1
sql_drills/sql_drills/fixtures/09_group_having/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/09_group_having/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "group-having"}
|
||||||
16
sql_drills/sql_drills/fixtures/09_group_having/schema.sql
Normal file
16
sql_drills/sql_drills/fixtures/09_group_having/schema.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE tickets (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
team VARCHAR(10) NOT NULL,
|
||||||
|
priority VARCHAR(10) NOT NULL,
|
||||||
|
hours DECIMAL(5, 1) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO tickets (id, team, priority, hours) VALUES
|
||||||
|
(1, 'Alpha', 'high', 5.0), (2, 'Alpha', 'medium', 3.5),
|
||||||
|
(3, 'Alpha', 'low', 9.0), (4, 'Alpha', 'high', 2.5),
|
||||||
|
(5, 'Beta', 'medium', 4.0), (6, 'Beta', 'high', 6.0),
|
||||||
|
(7, 'Gamma', 'high', 4.0), (8, 'Gamma', 'medium', 4.0),
|
||||||
|
(9, 'Gamma', 'high', 4.0), (10, 'Gamma', 'medium', 4.0),
|
||||||
|
(11, 'Delta', 'medium', 1.0), (12, 'Delta', 'medium', 1.0),
|
||||||
|
(13, 'Delta', 'low', 50.0), (14, 'Delta', 'high', 1.0),
|
||||||
|
(15, 'Eps', 'medium', 3.5), (16, 'Eps', 'medium', 3.5),
|
||||||
|
(17, 'Eps', 'high', 3.0);
|
||||||
114
sql_drills/sql_drills/fixtures/09_group_having/tables.json
Normal file
114
sql_drills/sql_drills/fixtures/09_group_having/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
# Top-N per group with DENSE_RANK
|
||||||
|
|
||||||
|
Show employees whose salary is among the two highest DISTINCT salaries in
|
||||||
|
their department (ties share a rank, so more than two people can appear).
|
||||||
|
|
||||||
|
Output columns (no header): `dept`, `name`, `salary`, the dense rank.
|
||||||
|
|
||||||
|
- Order by `dept`, then rank, then `name` ascending.
|
||||||
|
- `RANK()` would skip numbers after ties; this drill wants `DENSE_RANK()`.
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
Eng Ava 150 1
|
||||||
|
Eng Ben 150 1
|
||||||
|
Eng Cal 130 2
|
||||||
|
Ops Fay 90 1
|
||||||
|
Ops Gus 80 2
|
||||||
|
Sales Ivy 100 1
|
||||||
|
Sales Jon 100 1
|
||||||
|
Sales Kim 100 1
|
||||||
1
sql_drills/sql_drills/fixtures/10_window_topn/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/10_window_topn/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "window"}
|
||||||
11
sql_drills/sql_drills/fixtures/10_window_topn/schema.sql
Normal file
11
sql_drills/sql_drills/fixtures/10_window_topn/schema.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE employees (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(10) NOT NULL,
|
||||||
|
dept VARCHAR(10) NOT NULL,
|
||||||
|
salary INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO employees (id, name, dept, salary) VALUES
|
||||||
|
(1, 'Ava', 'Eng', 150), (2, 'Ben', 'Eng', 150), (3, 'Cal', 'Eng', 130),
|
||||||
|
(4, 'Dev', 'Eng', 120), (5, 'Eli', 'Eng', 120), (6, 'Fay', 'Ops', 90),
|
||||||
|
(7, 'Gus', 'Ops', 80), (8, 'Hal', 'Ops', 70), (9, 'Ivy', 'Sales', 100),
|
||||||
|
(10, 'Jon', 'Sales', 100), (11, 'Kim', 'Sales', 100);
|
||||||
78
sql_drills/sql_drills/fixtures/10_window_topn/tables.json
Normal file
78
sql_drills/sql_drills/fixtures/10_window_topn/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
10
sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md
Normal file
10
sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# LAG and DATEDIFF: find long gaps between logins
|
||||||
|
|
||||||
|
`logins` has one row per user per day they logged in. Find every pair of
|
||||||
|
consecutive logins by the same user that are 7 or more days apart.
|
||||||
|
|
||||||
|
Output columns (no header): `user_name`, previous login date, login date,
|
||||||
|
gap in days.
|
||||||
|
|
||||||
|
- Order by `user_name`, then login date ascending.
|
||||||
|
- Window functions cannot appear in `WHERE`; wrap them in a CTE or subquery.
|
||||||
4
sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt
Normal file
4
sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
amy 2023-01-02 2023-01-10 8
|
||||||
|
amy 2023-01-10 2023-01-17 7
|
||||||
|
amy 2023-01-17 2023-02-01 15
|
||||||
|
dan 2024-02-20 2024-03-01 10
|
||||||
1
sql_drills/sql_drills/fixtures/11_lag_gap/meta.json
Normal file
1
sql_drills/sql_drills/fixtures/11_lag_gap/meta.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "window"}
|
||||||
10
sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql
Normal file
10
sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE logins (
|
||||||
|
user_name VARCHAR(10) NOT NULL,
|
||||||
|
login_on DATE NOT NULL,
|
||||||
|
PRIMARY KEY (user_name, login_on)
|
||||||
|
);
|
||||||
|
INSERT INTO logins (user_name, login_on) VALUES
|
||||||
|
('amy', '2023-01-01'), ('amy', '2023-01-02'), ('amy', '2023-01-10'),
|
||||||
|
('amy', '2023-01-17'), ('amy', '2023-02-01'), ('bo', '2023-03-01'),
|
||||||
|
('bo', '2023-03-05'), ('cat', '2023-01-05'), ('dan', '2024-02-20'),
|
||||||
|
('dan', '2024-03-01');
|
||||||
50
sql_drills/sql_drills/fixtures/11_lag_gap/tables.json
Normal file
50
sql_drills/sql_drills/fixtures/11_lag_gap/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
# CONCAT and percentage formatting
|
||||||
|
|
||||||
|
`poll_votes` has the vote count per option for several polls. For each
|
||||||
|
option print its share of its own poll's votes with one decimal.
|
||||||
|
|
||||||
|
Output columns (no header): `poll_id`, then text like `Red: 42.9%`.
|
||||||
|
|
||||||
|
- Order by `poll_id`, then votes descending, then option name ascending.
|
||||||
|
- A share of exactly 50 prints `50.0%` and zero prints `0.0%`.
|
||||||
|
- Trap: `ROUND(x, 1)` keeps the trailing `.0` only on a DECIMAL, so do the
|
||||||
|
arithmetic in decimal, not by dividing integers with `DIV`.
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
1 Green: 42.9%
|
||||||
|
1 Red: 42.9%
|
||||||
|
1 Blue: 14.3%
|
||||||
|
2 No: 50.0%
|
||||||
|
2 Yes: 50.0%
|
||||||
|
3 Sure: 100.0%
|
||||||
|
3 Maybe: 0.0%
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "write", "topic": "strings"}
|
||||||
10
sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql
Normal file
10
sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE poll_votes (
|
||||||
|
poll_id INT NOT NULL,
|
||||||
|
option_name VARCHAR(10) NOT NULL,
|
||||||
|
votes INT NOT NULL,
|
||||||
|
PRIMARY KEY (poll_id, option_name)
|
||||||
|
);
|
||||||
|
INSERT INTO poll_votes (poll_id, option_name, votes) VALUES
|
||||||
|
(1, 'Red', 3), (1, 'Green', 3), (1, 'Blue', 1),
|
||||||
|
(2, 'Yes', 5), (2, 'No', 5),
|
||||||
|
(3, 'Maybe', 0), (3, 'Sure', 4);
|
||||||
46
sql_drills/sql_drills/fixtures/12_concat_percent/tables.json
Normal file
46
sql_drills/sql_drills/fixtures/12_concat_percent/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -0,0 +1 @@
|
|||||||
|
Bob
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "predict", "topic": "basics"}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
SELECT name FROM customers WHERE referee != 2 ORDER BY id;
|
||||||
@ -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');
|
||||||
@ -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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
Ann
|
||||||
|
Bob
|
||||||
|
Dee
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "predict", "topic": "basics"}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
SELECT name FROM customers WHERE referee != 2 OR referee IS NULL ORDER BY id;
|
||||||
@ -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');
|
||||||
59
sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json
Normal file
59
sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
Ann 10
|
||||||
|
Bob 12
|
||||||
|
Cid NULL
|
||||||
|
Dee NULL
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "predict", "topic": "basics"}
|
||||||
@ -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;
|
||||||
@ -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');
|
||||||
59
sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json
Normal file
59
sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
Ann 10
|
||||||
|
Bob 12
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"kind": "predict", "topic": "basics"}
|
||||||
@ -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;
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user