From f865508eb4499073732e2db7b644a22886949570 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 13:53:39 +0100 Subject: [PATCH] Add sql_drills engine package 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 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- .github/workflows/sql-drills-precommit.yml | 73 +++++++ .github/workflows/sql-drills-tests.yml | 34 ++++ sql_drills/.pre-commit-config.yaml | 143 +++++++++++++ sql_drills/README.md | 3 + sql_drills/install.sh | 12 ++ sql_drills/pyproject.toml | 188 ++++++++++++++++++ sql_drills/requirements.txt | 10 + sql_drills/scripts/regen_fixtures.py | 49 +++++ sql_drills/sql_drills/__init__.py | 46 +++++ sql_drills/sql_drills/_engine.py | 129 ++++++++++++ sql_drills/sql_drills/_generate.py | 142 +++++++++++++ sql_drills/sql_drills/_grade.py | 62 ++++++ sql_drills/sql_drills/_model.py | 77 +++++++ sql_drills/sql_drills/_normalize.py | 74 +++++++ sql_drills/sql_drills/_paths.py | 23 +++ sql_drills/sql_drills/_sandbox.py | 106 ++++++++++ sql_drills/sql_drills/_store.py | 96 +++++++++ .../fixtures/01_pivot_sum/DOCS-task.md | 9 + .../fixtures/01_pivot_sum/expected.txt | 4 + .../fixtures/01_pivot_sum/meta.json | 1 + .../fixtures/01_pivot_sum/schema.sql | 12 ++ .../fixtures/01_pivot_sum/tables.json | 72 +++++++ .../fixtures/02_pivot_max/DOCS-task.md | 10 + .../fixtures/02_pivot_max/expected.txt | 4 + .../fixtures/02_pivot_max/meta.json | 1 + .../fixtures/02_pivot_max/schema.sql | 12 ++ .../fixtures/02_pivot_max/tables.json | 56 ++++++ .../fixtures/03_month_bucket/DOCS-task.md | 11 + .../fixtures/03_month_bucket/expected.txt | 5 + .../fixtures/03_month_bucket/meta.json | 1 + .../fixtures/03_month_bucket/schema.sql | 19 ++ .../fixtures/03_month_bucket/tables.json | 90 +++++++++ .../fixtures/04_date_format/DOCS-task.md | 11 + .../fixtures/04_date_format/expected.txt | 5 + .../fixtures/04_date_format/meta.json | 1 + .../fixtures/04_date_format/schema.sql | 11 + .../fixtures/04_date_format/tables.json | 36 ++++ .../fixtures/05_str_to_date/DOCS-task.md | 10 + .../fixtures/05_str_to_date/expected.txt | 5 + .../fixtures/05_str_to_date/meta.json | 1 + .../fixtures/05_str_to_date/schema.sql | 13 ++ .../fixtures/05_str_to_date/tables.json | 46 +++++ .../fixtures/06_number_traps/DOCS-task.md | 15 ++ .../fixtures/06_number_traps/expected.txt | 3 + .../fixtures/06_number_traps/meta.json | 1 + .../fixtures/06_number_traps/schema.sql | 11 + .../fixtures/06_number_traps/tables.json | 55 +++++ .../fixtures/07_number_format/DOCS-task.md | 13 ++ .../fixtures/07_number_format/expected.txt | 4 + .../fixtures/07_number_format/meta.json | 1 + .../fixtures/07_number_format/schema.sql | 9 + .../fixtures/07_number_format/tables.json | 51 +++++ .../fixtures/08_anti_join/DOCS-task.md | 12 ++ .../fixtures/08_anti_join/expected.txt | 3 + .../fixtures/08_anti_join/meta.json | 1 + .../fixtures/08_anti_join/schema.sql | 16 ++ .../fixtures/08_anti_join/tables.json | 70 +++++++ .../fixtures/09_group_having/DOCS-task.md | 9 + .../fixtures/09_group_having/expected.txt | 3 + .../fixtures/09_group_having/meta.json | 1 + .../fixtures/09_group_having/schema.sql | 16 ++ .../fixtures/09_group_having/tables.json | 114 +++++++++++ .../fixtures/10_window_topn/DOCS-task.md | 9 + .../fixtures/10_window_topn/expected.txt | 8 + .../fixtures/10_window_topn/meta.json | 1 + .../fixtures/10_window_topn/schema.sql | 11 + .../fixtures/10_window_topn/tables.json | 78 ++++++++ .../fixtures/11_lag_gap/DOCS-task.md | 10 + .../fixtures/11_lag_gap/expected.txt | 4 + .../sql_drills/fixtures/11_lag_gap/meta.json | 1 + .../sql_drills/fixtures/11_lag_gap/schema.sql | 10 + .../fixtures/11_lag_gap/tables.json | 50 +++++ .../fixtures/12_concat_percent/DOCS-task.md | 11 + .../fixtures/12_concat_percent/expected.txt | 7 + .../fixtures/12_concat_percent/meta.json | 1 + .../fixtures/12_concat_percent/schema.sql | 10 + .../fixtures/12_concat_percent/tables.json | 46 +++++ .../fixtures/t01_null_not_equal/DOCS-task.md | 7 + .../fixtures/t01_null_not_equal/expected.txt | 1 + .../fixtures/t01_null_not_equal/meta.json | 1 + .../fixtures/t01_null_not_equal/query.sql | 1 + .../fixtures/t01_null_not_equal/schema.sql | 4 + .../fixtures/t01_null_not_equal/tables.json | 59 ++++++ .../fixtures/t02_null_is_null/DOCS-task.md | 7 + .../fixtures/t02_null_is_null/expected.txt | 3 + .../fixtures/t02_null_is_null/meta.json | 1 + .../fixtures/t02_null_is_null/query.sql | 1 + .../fixtures/t02_null_is_null/schema.sql | 4 + .../fixtures/t02_null_is_null/tables.json | 59 ++++++ .../fixtures/t03_left_join_on/DOCS-task.md | 7 + .../fixtures/t03_left_join_on/expected.txt | 4 + .../fixtures/t03_left_join_on/meta.json | 1 + .../fixtures/t03_left_join_on/query.sql | 3 + .../fixtures/t03_left_join_on/schema.sql | 4 + .../fixtures/t03_left_join_on/tables.json | 59 ++++++ .../fixtures/t04_left_join_where/DOCS-task.md | 7 + .../fixtures/t04_left_join_where/expected.txt | 2 + .../fixtures/t04_left_join_where/meta.json | 1 + .../fixtures/t04_left_join_where/query.sql | 4 + .../fixtures/t04_left_join_where/schema.sql | 4 + .../fixtures/t04_left_join_where/tables.json | 59 ++++++ .../fixtures/t05_anti_join/DOCS-task.md | 7 + .../fixtures/t05_anti_join/expected.txt | 2 + .../fixtures/t05_anti_join/meta.json | 1 + .../fixtures/t05_anti_join/query.sql | 4 + .../fixtures/t05_anti_join/schema.sql | 4 + .../fixtures/t05_anti_join/tables.json | 59 ++++++ .../fixtures/t06_alias_in_where/DOCS-task.md | 7 + .../fixtures/t06_alias_in_where/expected.txt | 1 + .../fixtures/t06_alias_in_where/meta.json | 1 + .../fixtures/t06_alias_in_where/query.sql | 1 + .../fixtures/t06_alias_in_where/schema.sql | 4 + .../fixtures/t06_alias_in_where/tables.json | 59 ++++++ .../fixtures/t07_having_alias/DOCS-task.md | 7 + .../fixtures/t07_having_alias/expected.txt | 1 + .../fixtures/t07_having_alias/meta.json | 1 + .../fixtures/t07_having_alias/query.sql | 1 + .../fixtures/t07_having_alias/schema.sql | 4 + .../fixtures/t07_having_alias/tables.json | 59 ++++++ sql_drills/sql_drills/py.typed | 0 sql_drills/sql_drills/tests/__init__.py | 0 sql_drills/sql_drills/tests/conftest.py | 79 ++++++++ .../tests/solutions/01_pivot_sum.sql | 8 + .../tests/solutions/02_pivot_max.sql | 7 + .../tests/solutions/03_month_bucket.sql | 8 + .../tests/solutions/04_date_format.sql | 5 + .../tests/solutions/05_str_to_date.sql | 4 + .../tests/solutions/06_number_traps.sql | 7 + .../tests/solutions/07_number_format.sql | 4 + .../tests/solutions/08_anti_join.sql | 5 + .../tests/solutions/09_group_having.sql | 6 + .../tests/solutions/10_window_topn.sql | 8 + .../sql_drills/tests/solutions/11_lag_gap.sql | 10 + .../tests/solutions/12_concat_percent.sql | 5 + sql_drills/sql_drills/tests/test_engine.py | 148 ++++++++++++++ sql_drills/sql_drills/tests/test_fixtures.py | 62 ++++++ sql_drills/sql_drills/tests/test_generate.py | 177 +++++++++++++++++ sql_drills/sql_drills/tests/test_grade.py | 151 ++++++++++++++ sql_drills/sql_drills/tests/test_live.py | 127 ++++++++++++ sql_drills/sql_drills/tests/test_normalize.py | 86 ++++++++ sql_drills/sql_drills/tests/test_paths.py | 33 +++ sql_drills/sql_drills/tests/test_sandbox.py | 167 ++++++++++++++++ sql_drills/sql_drills/tests/test_store.py | 131 ++++++++++++ 143 files changed, 4142 insertions(+) create mode 100644 .github/workflows/sql-drills-precommit.yml create mode 100644 .github/workflows/sql-drills-tests.yml create mode 100644 sql_drills/.pre-commit-config.yaml create mode 100644 sql_drills/README.md create mode 100755 sql_drills/install.sh create mode 100644 sql_drills/pyproject.toml create mode 100644 sql_drills/requirements.txt create mode 100644 sql_drills/scripts/regen_fixtures.py create mode 100644 sql_drills/sql_drills/__init__.py create mode 100644 sql_drills/sql_drills/_engine.py create mode 100644 sql_drills/sql_drills/_generate.py create mode 100644 sql_drills/sql_drills/_grade.py create mode 100644 sql_drills/sql_drills/_model.py create mode 100644 sql_drills/sql_drills/_normalize.py create mode 100644 sql_drills/sql_drills/_paths.py create mode 100644 sql_drills/sql_drills/_sandbox.py create mode 100644 sql_drills/sql_drills/_store.py create mode 100644 sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json create mode 100644 sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json create mode 100644 sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/02_pivot_max/meta.json create mode 100644 sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/02_pivot_max/tables.json create mode 100644 sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/03_month_bucket/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/03_month_bucket/meta.json create mode 100644 sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/03_month_bucket/tables.json create mode 100644 sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/04_date_format/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/04_date_format/meta.json create mode 100644 sql_drills/sql_drills/fixtures/04_date_format/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/04_date_format/tables.json create mode 100644 sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/05_str_to_date/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/05_str_to_date/meta.json create mode 100644 sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/05_str_to_date/tables.json create mode 100644 sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/06_number_traps/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/06_number_traps/meta.json create mode 100644 sql_drills/sql_drills/fixtures/06_number_traps/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/06_number_traps/tables.json create mode 100644 sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/07_number_format/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/07_number_format/meta.json create mode 100644 sql_drills/sql_drills/fixtures/07_number_format/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/07_number_format/tables.json create mode 100644 sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/08_anti_join/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/08_anti_join/meta.json create mode 100644 sql_drills/sql_drills/fixtures/08_anti_join/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/08_anti_join/tables.json create mode 100644 sql_drills/sql_drills/fixtures/09_group_having/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/09_group_having/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/09_group_having/meta.json create mode 100644 sql_drills/sql_drills/fixtures/09_group_having/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/09_group_having/tables.json create mode 100644 sql_drills/sql_drills/fixtures/10_window_topn/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/10_window_topn/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/10_window_topn/meta.json create mode 100644 sql_drills/sql_drills/fixtures/10_window_topn/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/10_window_topn/tables.json create mode 100644 sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/11_lag_gap/meta.json create mode 100644 sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/11_lag_gap/tables.json create mode 100644 sql_drills/sql_drills/fixtures/12_concat_percent/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/12_concat_percent/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/12_concat_percent/meta.json create mode 100644 sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/12_concat_percent/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t01_null_not_equal/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t04_left_join_where/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t05_anti_join/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t06_alias_in_where/tables.json create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/DOCS-task.md create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/expected.txt create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/meta.json create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/query.sql create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/schema.sql create mode 100644 sql_drills/sql_drills/fixtures/t07_having_alias/tables.json create mode 100644 sql_drills/sql_drills/py.typed create mode 100644 sql_drills/sql_drills/tests/__init__.py create mode 100644 sql_drills/sql_drills/tests/conftest.py create mode 100644 sql_drills/sql_drills/tests/solutions/01_pivot_sum.sql create mode 100644 sql_drills/sql_drills/tests/solutions/02_pivot_max.sql create mode 100644 sql_drills/sql_drills/tests/solutions/03_month_bucket.sql create mode 100644 sql_drills/sql_drills/tests/solutions/04_date_format.sql create mode 100644 sql_drills/sql_drills/tests/solutions/05_str_to_date.sql create mode 100644 sql_drills/sql_drills/tests/solutions/06_number_traps.sql create mode 100644 sql_drills/sql_drills/tests/solutions/07_number_format.sql create mode 100644 sql_drills/sql_drills/tests/solutions/08_anti_join.sql create mode 100644 sql_drills/sql_drills/tests/solutions/09_group_having.sql create mode 100644 sql_drills/sql_drills/tests/solutions/10_window_topn.sql create mode 100644 sql_drills/sql_drills/tests/solutions/11_lag_gap.sql create mode 100644 sql_drills/sql_drills/tests/solutions/12_concat_percent.sql create mode 100644 sql_drills/sql_drills/tests/test_engine.py create mode 100644 sql_drills/sql_drills/tests/test_fixtures.py create mode 100644 sql_drills/sql_drills/tests/test_generate.py create mode 100644 sql_drills/sql_drills/tests/test_grade.py create mode 100644 sql_drills/sql_drills/tests/test_live.py create mode 100644 sql_drills/sql_drills/tests/test_normalize.py create mode 100644 sql_drills/sql_drills/tests/test_paths.py create mode 100644 sql_drills/sql_drills/tests/test_sandbox.py create mode 100644 sql_drills/sql_drills/tests/test_store.py diff --git a/.github/workflows/sql-drills-precommit.yml b/.github/workflows/sql-drills-precommit.yml new file mode 100644 index 0000000..1a1f6c9 --- /dev/null +++ b/.github/workflows/sql-drills-precommit.yml @@ -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 diff --git a/.github/workflows/sql-drills-tests.yml b/.github/workflows/sql-drills-tests.yml new file mode 100644 index 0000000..37ef52e --- /dev/null +++ b/.github/workflows/sql-drills-tests.yml @@ -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 diff --git a/sql_drills/.pre-commit-config.yaml b/sql_drills/.pre-commit-config.yaml new file mode 100644 index 0000000..73920ff --- /dev/null +++ b/sql_drills/.pre-commit-config.yaml @@ -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 diff --git a/sql_drills/README.md b/sql_drills/README.md new file mode 100644 index 0000000..0e0e4b5 --- /dev/null +++ b/sql_drills/README.md @@ -0,0 +1,3 @@ +# sql_drills + +MySQL drill engine: `import sql_drills as sd`. Data: `$SQL_DRILLS_DATA`. Install: `./install.sh`. diff --git a/sql_drills/install.sh b/sql_drills/install.sh new file mode 100755 index 0000000..576babb --- /dev/null +++ b/sql_drills/install.sh @@ -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__)' diff --git a/sql_drills/pyproject.toml b/sql_drills/pyproject.toml new file mode 100644 index 0000000..e26570f --- /dev/null +++ b/sql_drills/pyproject.toml @@ -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"] diff --git a/sql_drills/requirements.txt b/sql_drills/requirements.txt new file mode 100644 index 0000000..d69c161 --- /dev/null +++ b/sql_drills/requirements.txt @@ -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 diff --git a/sql_drills/scripts/regen_fixtures.py b/sql_drills/scripts/regen_fixtures.py new file mode 100644 index 0000000..464b39a --- /dev/null +++ b/sql_drills/scripts/regen_fixtures.py @@ -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() diff --git a/sql_drills/sql_drills/__init__.py b/sql_drills/sql_drills/__init__.py new file mode 100644 index 0000000..3f04e7b --- /dev/null +++ b/sql_drills/sql_drills/__init__.py @@ -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", +] diff --git a/sql_drills/sql_drills/_engine.py b/sql_drills/sql_drills/_engine.py new file mode 100644 index 0000000..c5d42b0 --- /dev/null +++ b/sql_drills/sql_drills/_engine.py @@ -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) diff --git a/sql_drills/sql_drills/_generate.py b/sql_drills/sql_drills/_generate.py new file mode 100644 index 0000000..baf81fb --- /dev/null +++ b/sql_drills/sql_drills/_generate.py @@ -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 diff --git a/sql_drills/sql_drills/_grade.py b/sql_drills/sql_drills/_grade.py new file mode 100644 index 0000000..d4969ed --- /dev/null +++ b/sql_drills/sql_drills/_grade.py @@ -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, + ) diff --git a/sql_drills/sql_drills/_model.py b/sql_drills/sql_drills/_model.py new file mode 100644 index 0000000..d7b08ec --- /dev/null +++ b/sql_drills/sql_drills/_model.py @@ -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) diff --git a/sql_drills/sql_drills/_normalize.py b/sql_drills/sql_drills/_normalize.py new file mode 100644 index 0000000..13a652c --- /dev/null +++ b/sql_drills/sql_drills/_normalize.py @@ -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), + ) diff --git a/sql_drills/sql_drills/_paths.py b/sql_drills/sql_drills/_paths.py new file mode 100644 index 0000000..b10d1a7 --- /dev/null +++ b/sql_drills/sql_drills/_paths.py @@ -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" diff --git a/sql_drills/sql_drills/_sandbox.py b/sql_drills/sql_drills/_sandbox.py new file mode 100644 index 0000000..48e6da8 --- /dev/null +++ b/sql_drills/sql_drills/_sandbox.py @@ -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';" + ) diff --git a/sql_drills/sql_drills/_store.py b/sql_drills/sql_drills/_store.py new file mode 100644 index 0000000..b2d775b --- /dev/null +++ b/sql_drills/sql_drills/_store.py @@ -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, + ) diff --git a/sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md b/sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md new file mode 100644 index 0000000..139c877 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/01_pivot_sum/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt b/sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt new file mode 100644 index 0000000..18b2502 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/01_pivot_sum/expected.txt @@ -0,0 +1,4 @@ +East 0 300 0 25 +North 200 150 0 200 +South 90 0 100 0 +West 10 0 0 0 diff --git a/sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json b/sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json new file mode 100644 index 0000000..d81bd73 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/01_pivot_sum/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "pivot"} diff --git a/sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql b/sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql new file mode 100644 index 0000000..2596736 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/01_pivot_sum/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json b/sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json new file mode 100644 index 0000000..7614aa0 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/01_pivot_sum/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md b/sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md new file mode 100644 index 0000000..6170295 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/02_pivot_max/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt b/sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt new file mode 100644 index 0000000..de8f36f --- /dev/null +++ b/sql_drills/sql_drills/fixtures/02_pivot_max/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/02_pivot_max/meta.json b/sql_drills/sql_drills/fixtures/02_pivot_max/meta.json new file mode 100644 index 0000000..d81bd73 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/02_pivot_max/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "pivot"} diff --git a/sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql b/sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql new file mode 100644 index 0000000..cc163ee --- /dev/null +++ b/sql_drills/sql_drills/fixtures/02_pivot_max/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/02_pivot_max/tables.json b/sql_drills/sql_drills/fixtures/02_pivot_max/tables.json new file mode 100644 index 0000000..867d7a6 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/02_pivot_max/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md b/sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md new file mode 100644 index 0000000..678e61c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/03_month_bucket/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/03_month_bucket/expected.txt b/sql_drills/sql_drills/fixtures/03_month_bucket/expected.txt new file mode 100644 index 0000000..9de7444 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/03_month_bucket/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/03_month_bucket/meta.json b/sql_drills/sql_drills/fixtures/03_month_bucket/meta.json new file mode 100644 index 0000000..7a78f6e --- /dev/null +++ b/sql_drills/sql_drills/fixtures/03_month_bucket/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "dates"} diff --git a/sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql b/sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql new file mode 100644 index 0000000..1324575 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/03_month_bucket/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/03_month_bucket/tables.json b/sql_drills/sql_drills/fixtures/03_month_bucket/tables.json new file mode 100644 index 0000000..80e64dd --- /dev/null +++ b/sql_drills/sql_drills/fixtures/03_month_bucket/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md b/sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md new file mode 100644 index 0000000..9be709d --- /dev/null +++ b/sql_drills/sql_drills/fixtures/04_date_format/DOCS-task.md @@ -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). diff --git a/sql_drills/sql_drills/fixtures/04_date_format/expected.txt b/sql_drills/sql_drills/fixtures/04_date_format/expected.txt new file mode 100644 index 0000000..1c25d2f --- /dev/null +++ b/sql_drills/sql_drills/fixtures/04_date_format/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/04_date_format/meta.json b/sql_drills/sql_drills/fixtures/04_date_format/meta.json new file mode 100644 index 0000000..7a78f6e --- /dev/null +++ b/sql_drills/sql_drills/fixtures/04_date_format/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "dates"} diff --git a/sql_drills/sql_drills/fixtures/04_date_format/schema.sql b/sql_drills/sql_drills/fixtures/04_date_format/schema.sql new file mode 100644 index 0000000..1db9240 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/04_date_format/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/04_date_format/tables.json b/sql_drills/sql_drills/fixtures/04_date_format/tables.json new file mode 100644 index 0000000..0e595d9 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/04_date_format/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md b/sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md new file mode 100644 index 0000000..e7994c8 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/05_str_to_date/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/05_str_to_date/expected.txt b/sql_drills/sql_drills/fixtures/05_str_to_date/expected.txt new file mode 100644 index 0000000..c59fafc --- /dev/null +++ b/sql_drills/sql_drills/fixtures/05_str_to_date/expected.txt @@ -0,0 +1,5 @@ +6 2022-03-01 +3 2022-03-02 +1 2022-03-14 +4 2022-11-30 +7 2023-02-28 diff --git a/sql_drills/sql_drills/fixtures/05_str_to_date/meta.json b/sql_drills/sql_drills/fixtures/05_str_to_date/meta.json new file mode 100644 index 0000000..7a78f6e --- /dev/null +++ b/sql_drills/sql_drills/fixtures/05_str_to_date/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "dates"} diff --git a/sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql b/sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql new file mode 100644 index 0000000..b7cf3f1 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/05_str_to_date/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/05_str_to_date/tables.json b/sql_drills/sql_drills/fixtures/05_str_to_date/tables.json new file mode 100644 index 0000000..16bf407 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/05_str_to_date/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md b/sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md new file mode 100644 index 0000000..5aa54bd --- /dev/null +++ b/sql_drills/sql_drills/fixtures/06_number_traps/DOCS-task.md @@ -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). diff --git a/sql_drills/sql_drills/fixtures/06_number_traps/expected.txt b/sql_drills/sql_drills/fixtures/06_number_traps/expected.txt new file mode 100644 index 0000000..b04e2bf --- /dev/null +++ b/sql_drills/sql_drills/fixtures/06_number_traps/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/06_number_traps/meta.json b/sql_drills/sql_drills/fixtures/06_number_traps/meta.json new file mode 100644 index 0000000..a6ba89c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/06_number_traps/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "number-format"} diff --git a/sql_drills/sql_drills/fixtures/06_number_traps/schema.sql b/sql_drills/sql_drills/fixtures/06_number_traps/schema.sql new file mode 100644 index 0000000..b0ab0d7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/06_number_traps/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/06_number_traps/tables.json b/sql_drills/sql_drills/fixtures/06_number_traps/tables.json new file mode 100644 index 0000000..ae09bb2 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/06_number_traps/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md b/sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md new file mode 100644 index 0000000..882439d --- /dev/null +++ b/sql_drills/sql_drills/fixtures/07_number_format/DOCS-task.md @@ -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). diff --git a/sql_drills/sql_drills/fixtures/07_number_format/expected.txt b/sql_drills/sql_drills/fixtures/07_number_format/expected.txt new file mode 100644 index 0000000..3971a5a --- /dev/null +++ b/sql_drills/sql_drills/fixtures/07_number_format/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/07_number_format/meta.json b/sql_drills/sql_drills/fixtures/07_number_format/meta.json new file mode 100644 index 0000000..a6ba89c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/07_number_format/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "number-format"} diff --git a/sql_drills/sql_drills/fixtures/07_number_format/schema.sql b/sql_drills/sql_drills/fixtures/07_number_format/schema.sql new file mode 100644 index 0000000..1d7f70c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/07_number_format/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/07_number_format/tables.json b/sql_drills/sql_drills/fixtures/07_number_format/tables.json new file mode 100644 index 0000000..c9a3c18 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/07_number_format/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md b/sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md new file mode 100644 index 0000000..a74f35b --- /dev/null +++ b/sql_drills/sql_drills/fixtures/08_anti_join/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/08_anti_join/expected.txt b/sql_drills/sql_drills/fixtures/08_anti_join/expected.txt new file mode 100644 index 0000000..95d66ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/08_anti_join/expected.txt @@ -0,0 +1,3 @@ +Boris n/a +Cleo 555-0103 +Dara n/a diff --git a/sql_drills/sql_drills/fixtures/08_anti_join/meta.json b/sql_drills/sql_drills/fixtures/08_anti_join/meta.json new file mode 100644 index 0000000..d8cac95 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/08_anti_join/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "joins-null"} diff --git a/sql_drills/sql_drills/fixtures/08_anti_join/schema.sql b/sql_drills/sql_drills/fixtures/08_anti_join/schema.sql new file mode 100644 index 0000000..f243b34 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/08_anti_join/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/08_anti_join/tables.json b/sql_drills/sql_drills/fixtures/08_anti_join/tables.json new file mode 100644 index 0000000..dea6f32 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/08_anti_join/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/09_group_having/DOCS-task.md b/sql_drills/sql_drills/fixtures/09_group_having/DOCS-task.md new file mode 100644 index 0000000..bd8f5c8 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/09_group_having/DOCS-task.md @@ -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`. diff --git a/sql_drills/sql_drills/fixtures/09_group_having/expected.txt b/sql_drills/sql_drills/fixtures/09_group_having/expected.txt new file mode 100644 index 0000000..7dc4449 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/09_group_having/expected.txt @@ -0,0 +1,3 @@ +Gamma 4 16.0 +Alpha 3 11.0 +Eps 3 10.0 diff --git a/sql_drills/sql_drills/fixtures/09_group_having/meta.json b/sql_drills/sql_drills/fixtures/09_group_having/meta.json new file mode 100644 index 0000000..eb6f053 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/09_group_having/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "group-having"} diff --git a/sql_drills/sql_drills/fixtures/09_group_having/schema.sql b/sql_drills/sql_drills/fixtures/09_group_having/schema.sql new file mode 100644 index 0000000..ed14900 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/09_group_having/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/09_group_having/tables.json b/sql_drills/sql_drills/fixtures/09_group_having/tables.json new file mode 100644 index 0000000..cf590ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/09_group_having/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/10_window_topn/DOCS-task.md b/sql_drills/sql_drills/fixtures/10_window_topn/DOCS-task.md new file mode 100644 index 0000000..ce696e4 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/10_window_topn/DOCS-task.md @@ -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()`. diff --git a/sql_drills/sql_drills/fixtures/10_window_topn/expected.txt b/sql_drills/sql_drills/fixtures/10_window_topn/expected.txt new file mode 100644 index 0000000..e7dabb1 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/10_window_topn/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/10_window_topn/meta.json b/sql_drills/sql_drills/fixtures/10_window_topn/meta.json new file mode 100644 index 0000000..ba97699 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/10_window_topn/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "window"} diff --git a/sql_drills/sql_drills/fixtures/10_window_topn/schema.sql b/sql_drills/sql_drills/fixtures/10_window_topn/schema.sql new file mode 100644 index 0000000..4dc2e15 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/10_window_topn/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/10_window_topn/tables.json b/sql_drills/sql_drills/fixtures/10_window_topn/tables.json new file mode 100644 index 0000000..67c073c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/10_window_topn/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md b/sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md new file mode 100644 index 0000000..4098918 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/11_lag_gap/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt b/sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt new file mode 100644 index 0000000..9bebc93 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/11_lag_gap/expected.txt @@ -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 diff --git a/sql_drills/sql_drills/fixtures/11_lag_gap/meta.json b/sql_drills/sql_drills/fixtures/11_lag_gap/meta.json new file mode 100644 index 0000000..ba97699 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/11_lag_gap/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "window"} diff --git a/sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql b/sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql new file mode 100644 index 0000000..9eeeedc --- /dev/null +++ b/sql_drills/sql_drills/fixtures/11_lag_gap/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/11_lag_gap/tables.json b/sql_drills/sql_drills/fixtures/11_lag_gap/tables.json new file mode 100644 index 0000000..85c0309 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/11_lag_gap/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/12_concat_percent/DOCS-task.md b/sql_drills/sql_drills/fixtures/12_concat_percent/DOCS-task.md new file mode 100644 index 0000000..ab461d6 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/12_concat_percent/DOCS-task.md @@ -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`. diff --git a/sql_drills/sql_drills/fixtures/12_concat_percent/expected.txt b/sql_drills/sql_drills/fixtures/12_concat_percent/expected.txt new file mode 100644 index 0000000..13f8d11 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/12_concat_percent/expected.txt @@ -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% diff --git a/sql_drills/sql_drills/fixtures/12_concat_percent/meta.json b/sql_drills/sql_drills/fixtures/12_concat_percent/meta.json new file mode 100644 index 0000000..195b222 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/12_concat_percent/meta.json @@ -0,0 +1 @@ +{"kind": "write", "topic": "strings"} diff --git a/sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql b/sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql new file mode 100644 index 0000000..f7dbfa0 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/12_concat_percent/schema.sql @@ -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); diff --git a/sql_drills/sql_drills/fixtures/12_concat_percent/tables.json b/sql_drills/sql_drills/fixtures/12_concat_percent/tables.json new file mode 100644 index 0000000..b43a94d --- /dev/null +++ b/sql_drills/sql_drills/fixtures/12_concat_percent/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/DOCS-task.md b/sql_drills/sql_drills/fixtures/t01_null_not_equal/DOCS-task.md new file mode 100644 index 0000000..8bb1199 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/expected.txt b/sql_drills/sql_drills/fixtures/t01_null_not_equal/expected.txt new file mode 100644 index 0000000..92e9edc --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/expected.txt @@ -0,0 +1 @@ +Bob diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/meta.json b/sql_drills/sql_drills/fixtures/t01_null_not_equal/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/query.sql b/sql_drills/sql_drills/fixtures/t01_null_not_equal/query.sql new file mode 100644 index 0000000..8e965dd --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/query.sql @@ -0,0 +1 @@ +SELECT name FROM customers WHERE referee != 2 ORDER BY id; diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/schema.sql b/sql_drills/sql_drills/fixtures/t01_null_not_equal/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t01_null_not_equal/tables.json b/sql_drills/sql_drills/fixtures/t01_null_not_equal/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t01_null_not_equal/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/DOCS-task.md b/sql_drills/sql_drills/fixtures/t02_null_is_null/DOCS-task.md new file mode 100644 index 0000000..09f27b5 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/expected.txt b/sql_drills/sql_drills/fixtures/t02_null_is_null/expected.txt new file mode 100644 index 0000000..1d6cc88 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/expected.txt @@ -0,0 +1,3 @@ +Ann +Bob +Dee diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/meta.json b/sql_drills/sql_drills/fixtures/t02_null_is_null/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/query.sql b/sql_drills/sql_drills/fixtures/t02_null_is_null/query.sql new file mode 100644 index 0000000..b3b6a67 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/query.sql @@ -0,0 +1 @@ +SELECT name FROM customers WHERE referee != 2 OR referee IS NULL ORDER BY id; diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/schema.sql b/sql_drills/sql_drills/fixtures/t02_null_is_null/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json b/sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t02_null_is_null/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/DOCS-task.md b/sql_drills/sql_drills/fixtures/t03_left_join_on/DOCS-task.md new file mode 100644 index 0000000..c304413 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/expected.txt b/sql_drills/sql_drills/fixtures/t03_left_join_on/expected.txt new file mode 100644 index 0000000..fad3022 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/expected.txt @@ -0,0 +1,4 @@ +Ann 10 +Bob 12 +Cid NULL +Dee NULL diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/meta.json b/sql_drills/sql_drills/fixtures/t03_left_join_on/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/query.sql b/sql_drills/sql_drills/fixtures/t03_left_join_on/query.sql new file mode 100644 index 0000000..0efaa65 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/query.sql @@ -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; diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/schema.sql b/sql_drills/sql_drills/fixtures/t03_left_join_on/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json b/sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t03_left_join_on/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/DOCS-task.md b/sql_drills/sql_drills/fixtures/t04_left_join_where/DOCS-task.md new file mode 100644 index 0000000..0a727a5 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/DOCS-task.md @@ -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. diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/expected.txt b/sql_drills/sql_drills/fixtures/t04_left_join_where/expected.txt new file mode 100644 index 0000000..e0ef644 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/expected.txt @@ -0,0 +1,2 @@ +Ann 10 +Bob 12 diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/meta.json b/sql_drills/sql_drills/fixtures/t04_left_join_where/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/query.sql b/sql_drills/sql_drills/fixtures/t04_left_join_where/query.sql new file mode 100644 index 0000000..306524a --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/query.sql @@ -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; diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/schema.sql b/sql_drills/sql_drills/fixtures/t04_left_join_where/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t04_left_join_where/tables.json b/sql_drills/sql_drills/fixtures/t04_left_join_where/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t04_left_join_where/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/DOCS-task.md b/sql_drills/sql_drills/fixtures/t05_anti_join/DOCS-task.md new file mode 100644 index 0000000..8849c9b --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/DOCS-task.md @@ -0,0 +1,7 @@ +# Predict: Anti-join + +Customers with no order at all. + +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. diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/expected.txt b/sql_drills/sql_drills/fixtures/t05_anti_join/expected.txt new file mode 100644 index 0000000..9e22427 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/expected.txt @@ -0,0 +1,2 @@ +Cid +Dee diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/meta.json b/sql_drills/sql_drills/fixtures/t05_anti_join/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/query.sql b/sql_drills/sql_drills/fixtures/t05_anti_join/query.sql new file mode 100644 index 0000000..be37376 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/query.sql @@ -0,0 +1,4 @@ +SELECT c.name FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id +WHERE o.id IS NULL +ORDER BY c.id; diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/schema.sql b/sql_drills/sql_drills/fixtures/t05_anti_join/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t05_anti_join/tables.json b/sql_drills/sql_drills/fixtures/t05_anti_join/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t05_anti_join/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/DOCS-task.md b/sql_drills/sql_drills/fixtures/t06_alias_in_where/DOCS-task.md new file mode 100644 index 0000000..841c38e --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/DOCS-task.md @@ -0,0 +1,7 @@ +# Predict: Execution order: alias in WHERE + +Think about the logical order FROM, JOIN, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. + +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. diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/expected.txt b/sql_drills/sql_drills/fixtures/t06_alias_in_where/expected.txt new file mode 100644 index 0000000..5667e23 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/expected.txt @@ -0,0 +1 @@ +ERROR 1054 diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/meta.json b/sql_drills/sql_drills/fixtures/t06_alias_in_where/meta.json new file mode 100644 index 0000000..3f1d60f --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics", "error_code": 1054, "error_message": "ERROR 1054 (42S22) at line 1: Unknown column 'total' in 'where clause'"} diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/query.sql b/sql_drills/sql_drills/fixtures/t06_alias_in_where/query.sql new file mode 100644 index 0000000..6303cb0 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/query.sql @@ -0,0 +1 @@ +SELECT customer_id, SUM(amount) AS total FROM orders WHERE total > 60 GROUP BY customer_id; diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/schema.sql b/sql_drills/sql_drills/fixtures/t06_alias_in_where/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t06_alias_in_where/tables.json b/sql_drills/sql_drills/fixtures/t06_alias_in_where/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t06_alias_in_where/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/DOCS-task.md b/sql_drills/sql_drills/fixtures/t07_having_alias/DOCS-task.md new file mode 100644 index 0000000..281fc45 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/DOCS-task.md @@ -0,0 +1,7 @@ +# Predict: HAVING sees the alias + +The fixed form of the previous query. Why does `HAVING` see `total` when `WHERE` could not? + +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. diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/expected.txt b/sql_drills/sql_drills/fixtures/t07_having_alias/expected.txt new file mode 100644 index 0000000..2c7ff5c --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/expected.txt @@ -0,0 +1 @@ +1 120 diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/meta.json b/sql_drills/sql_drills/fixtures/t07_having_alias/meta.json new file mode 100644 index 0000000..c63f2ae --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/meta.json @@ -0,0 +1 @@ +{"kind": "predict", "topic": "basics"} diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/query.sql b/sql_drills/sql_drills/fixtures/t07_having_alias/query.sql new file mode 100644 index 0000000..ac2f898 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/query.sql @@ -0,0 +1 @@ +SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id HAVING total > 60 ORDER BY customer_id; diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/schema.sql b/sql_drills/sql_drills/fixtures/t07_having_alias/schema.sql new file mode 100644 index 0000000..a772da7 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/schema.sql @@ -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'); diff --git a/sql_drills/sql_drills/fixtures/t07_having_alias/tables.json b/sql_drills/sql_drills/fixtures/t07_having_alias/tables.json new file mode 100644 index 0000000..f5e4911 --- /dev/null +++ b/sql_drills/sql_drills/fixtures/t07_having_alias/tables.json @@ -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" + ] + ] + } +} diff --git a/sql_drills/sql_drills/py.typed b/sql_drills/sql_drills/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/sql_drills/sql_drills/tests/__init__.py b/sql_drills/sql_drills/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sql_drills/sql_drills/tests/conftest.py b/sql_drills/sql_drills/tests/conftest.py new file mode 100644 index 0000000..a87831e --- /dev/null +++ b/sql_drills/sql_drills/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared fixtures: redirect the data dir, and a scripted fake MySQL.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import pytest + +from sql_drills import _engine + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(autouse=True) +def _redirect_data(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Generated drills never land in the real ``~/.local/share/sql_drills``.""" + monkeypatch.setenv("SQL_DRILLS_DATA", str(tmp_path / "data")) + + +@dataclass +class FakeMysql: + """Stands in for ``_engine.run_sql``; answers by substring of the SQL.""" + + replies: dict[str, _engine.SqlResult] = field(default_factory=dict) + heads: dict[str, str] = field(default_factory=dict) + sequences: dict[str, list[_engine.SqlResult]] = field(default_factory=dict) + calls: list[dict[str, object]] = field(default_factory=list) + + def say( + self, + needle: str, + stdout: str = "", + stderr: str = "", + rc: int = 0, + header: str | None = None, + ) -> None: + """Answer SQL containing ``needle``; ``header`` shows in headers mode.""" + self.replies[needle] = _engine.SqlResult(rc, stdout, stderr) + if header is not None: + self.heads[needle] = header + + def say_seq(self, needle: str, outs: list[str]) -> None: + """Answer ``needle`` with successive stdout values, one per call.""" + self.sequences[needle] = [_engine.SqlResult(0, out, "") for out in outs] + + def __call__( + self, + sql: str, + database: str | None = None, + user: str = "root", + *, + headers: bool = False, + timeout: float = 30.0, + ) -> _engine.SqlResult: + self.calls.append( + {"sql": sql, "database": database, "user": user, "headers": headers} + ) + for needle, queue in self.sequences.items(): + if needle in sql and queue: + return queue.pop(0) + for needle, reply in self.replies.items(): + if needle in sql: + head = self.heads.get(needle) + if headers and head is not None: + return _engine.SqlResult( + reply.returncode, f"{head}\n{reply.stdout}", "" + ) + return reply + return _engine.SqlResult(0, "", "") + + +@pytest.fixture +def fake_mysql(monkeypatch: pytest.MonkeyPatch) -> FakeMysql: + """Replace the engine's ``run_sql`` with a scripted fake (no Docker).""" + fake = FakeMysql() + monkeypatch.setattr(_engine, "run_sql", fake) + return fake diff --git a/sql_drills/sql_drills/tests/solutions/01_pivot_sum.sql b/sql_drills/sql_drills/tests/solutions/01_pivot_sum.sql new file mode 100644 index 0000000..79040de --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/01_pivot_sum.sql @@ -0,0 +1,8 @@ +SELECT region, + SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END), + SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) +FROM shop_sales +GROUP BY region +ORDER BY region; diff --git a/sql_drills/sql_drills/tests/solutions/02_pivot_max.sql b/sql_drills/sql_drills/tests/solutions/02_pivot_max.sql new file mode 100644 index 0000000..b41f016 --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/02_pivot_max.sql @@ -0,0 +1,7 @@ +SELECT person, + MAX(CASE WHEN attr = 'email' THEN val END), + MAX(CASE WHEN attr = 'phone' THEN val END), + MAX(CASE WHEN attr = 'city' THEN val END) +FROM person_attrs +GROUP BY person +ORDER BY person; diff --git a/sql_drills/sql_drills/tests/solutions/03_month_bucket.sql b/sql_drills/sql_drills/tests/solutions/03_month_bucket.sql new file mode 100644 index 0000000..621a6e0 --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/03_month_bucket.sql @@ -0,0 +1,8 @@ +SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, + COUNT(*), + SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'shipped' THEN total ELSE 0 END) +FROM orders +GROUP BY month +ORDER BY month; diff --git a/sql_drills/sql_drills/tests/solutions/04_date_format.sql b/sql_drills/sql_drills/tests/solutions/04_date_format.sql new file mode 100644 index 0000000..3fccebd --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/04_date_format.sql @@ -0,0 +1,5 @@ +SELECT id, + DATE_FORMAT(starts_at, '%W, %d %M %Y %H:%i'), + DATE_FORMAT(starts_at, '%h:%i %p') +FROM events +ORDER BY id; diff --git a/sql_drills/sql_drills/tests/solutions/05_str_to_date.sql b/sql_drills/sql_drills/tests/solutions/05_str_to_date.sql new file mode 100644 index 0000000..6227cd4 --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/05_str_to_date.sql @@ -0,0 +1,4 @@ +SELECT id, DATE_FORMAT(STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), '%Y-%m-%d') +FROM signups +WHERE STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i') >= '2022-03-01' +ORDER BY STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), id; diff --git a/sql_drills/sql_drills/tests/solutions/06_number_traps.sql b/sql_drills/sql_drills/tests/solutions/06_number_traps.sql new file mode 100644 index 0000000..cc4cdaf --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/06_number_traps.sql @@ -0,0 +1,7 @@ +SELECT team, + ROUND(AVG(wins), 2), + AVG(games), + CAST(100 * SUM(wins) / SUM(games) AS DECIMAL(5, 1)) +FROM team_results +GROUP BY team +ORDER BY team; diff --git a/sql_drills/sql_drills/tests/solutions/07_number_format.sql b/sql_drills/sql_drills/tests/solutions/07_number_format.sql new file mode 100644 index 0000000..197c68b --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/07_number_format.sql @@ -0,0 +1,4 @@ +SELECT customer, FORMAT(SUM(amount), 2), FORMAT(AVG(amount), 0) +FROM invoices +GROUP BY customer +ORDER BY SUM(amount) DESC; diff --git a/sql_drills/sql_drills/tests/solutions/08_anti_join.sql b/sql_drills/sql_drills/tests/solutions/08_anti_join.sql new file mode 100644 index 0000000..b7c6af5 --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/08_anti_join.sql @@ -0,0 +1,5 @@ +SELECT c.name, COALESCE(c.phone, 'n/a') +FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled' +WHERE o.id IS NULL +ORDER BY c.name; diff --git a/sql_drills/sql_drills/tests/solutions/09_group_having.sql b/sql_drills/sql_drills/tests/solutions/09_group_having.sql new file mode 100644 index 0000000..87304bf --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/09_group_having.sql @@ -0,0 +1,6 @@ +SELECT team, COUNT(*), SUM(hours) +FROM tickets +WHERE priority <> 'low' +GROUP BY team +HAVING COUNT(*) >= 3 AND SUM(hours) >= 10 +ORDER BY SUM(hours) DESC, team; diff --git a/sql_drills/sql_drills/tests/solutions/10_window_topn.sql b/sql_drills/sql_drills/tests/solutions/10_window_topn.sql new file mode 100644 index 0000000..63b98bb --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/10_window_topn.sql @@ -0,0 +1,8 @@ +SELECT dept, name, salary, rnk +FROM ( + SELECT dept, name, salary, + DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk + FROM employees +) ranked +WHERE rnk <= 2 +ORDER BY dept, rnk, name; diff --git a/sql_drills/sql_drills/tests/solutions/11_lag_gap.sql b/sql_drills/sql_drills/tests/solutions/11_lag_gap.sql new file mode 100644 index 0000000..b27739f --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/11_lag_gap.sql @@ -0,0 +1,10 @@ +WITH gaps AS ( + SELECT user_name, + LAG(login_on) OVER (PARTITION BY user_name ORDER BY login_on) AS prev_on, + login_on + FROM logins +) +SELECT user_name, prev_on, login_on, DATEDIFF(login_on, prev_on) +FROM gaps +WHERE DATEDIFF(login_on, prev_on) >= 7 +ORDER BY user_name, login_on; diff --git a/sql_drills/sql_drills/tests/solutions/12_concat_percent.sql b/sql_drills/sql_drills/tests/solutions/12_concat_percent.sql new file mode 100644 index 0000000..ddd68d4 --- /dev/null +++ b/sql_drills/sql_drills/tests/solutions/12_concat_percent.sql @@ -0,0 +1,5 @@ +SELECT poll_id, + CONCAT(option_name, ': ', + ROUND(100 * votes / SUM(votes) OVER (PARTITION BY poll_id), 1), '%') +FROM poll_votes +ORDER BY poll_id, votes DESC, option_name; diff --git a/sql_drills/sql_drills/tests/test_engine.py b/sql_drills/sql_drills/tests/test_engine.py new file mode 100644 index 0000000..4470eb1 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_engine.py @@ -0,0 +1,148 @@ +"""Docker plumbing with ``subprocess.run`` mocked: no daemon needed.""" + +from __future__ import annotations + +import shutil +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from sql_drills import _engine + +if TYPE_CHECKING: + from collections.abc import Iterator + from contextlib import AbstractContextManager + + +@pytest.fixture(autouse=True) +def _fake_docker_on_path() -> Iterator[None]: + with patch.object(shutil, "which", return_value="docker"): + yield + + +def _cp(rc: int = 0, out: str = "", err: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], rc, out, err) + + +def _run( + *results: subprocess.CompletedProcess[str], +) -> AbstractContextManager[MagicMock]: + return patch.object(subprocess, "run", side_effect=list(results)) + + +def _no_sleep(_s: float) -> None: + return None + + +def test_docker_missing_binary_is_engine_error() -> None: + with ( + patch.object(shutil, "which", return_value=None), + pytest.raises(_engine.EngineError, match="docker"), + ): + _engine.container_state() + + +def test_container_state_running_and_missing() -> None: + with _run(_cp(0, "running\n")): + assert _engine.container_state() == "running" + with _run(_cp(1, "", "No such object")): + assert _engine.container_state() == "missing" + + +def test_is_ready_follows_ping_exit_code() -> None: + with _run(_cp(0)): + assert _engine.is_ready() + with _run(_cp(1)): + assert not _engine.is_ready() + + +def test_creates_container_on_shared_network_when_it_exists() -> None: + # inspect(missing), network inspect(ok), run, ping. + with _run(_cp(1), _cp(0), _cp(0, "id"), _cp(0)) as run: + _engine.ensure_running(timeout=5, sleep=_no_sleep) + argv = run.call_args_list[2].args[0] + assert argv[:3] == ["docker", "run", "-d"] + assert "--tmpfs" in argv + assert _engine.IMAGE in argv + assert argv[argv.index("--network") + 1] == _engine.NETWORK + assert argv[argv.index("--network-alias") + 1] == _engine.NETWORK_ALIAS + + +def test_creates_container_plain_when_network_absent() -> None: + with _run(_cp(1), _cp(1), _cp(0, "id"), _cp(0)) as run: + _engine.ensure_running(timeout=5, sleep=_no_sleep) + assert "--network" not in run.call_args_list[2].args[0] + + +def test_resumes_exited_container() -> None: + with _run(_cp(0, "exited\n"), _cp(0), _cp(0)) as run: + _engine.ensure_running(timeout=5, sleep=_no_sleep) + assert run.call_args_list[1].args[0][:2] == ["docker", "start"] + + +def test_running_container_is_never_recreated() -> None: + with _run(_cp(0, "running\n"), _cp(0)) as run: + _engine.ensure_running(timeout=5, sleep=_no_sleep) + verbs = [c.args[0][1] for c in run.call_args_list] + assert verbs == ["inspect", "exec"] + + +def test_create_failure_is_reported() -> None: + with ( + _run(_cp(1), _cp(1), _cp(125, "", "pull access denied")), + pytest.raises(_engine.EngineError, match="pull access denied"), + ): + _engine.ensure_running(timeout=5, sleep=_no_sleep) + + +def test_times_out_with_rerun_hint() -> None: + ticks = iter([0.0, 1.0, 2.0, 99.0, 100.0]) + results = [_cp(0, "running\n")] + [_cp(1)] * 10 + with _run(*results), pytest.raises(_engine.EngineError, match="rerun"): + _engine.ensure_running(timeout=10, sleep=_no_sleep, clock=lambda: next(ticks)) + + +def test_stop_removes_container() -> None: + with _run(_cp(0)) as run: + _engine.stop() + assert run.call_args.args[0] == ["docker", "rm", "-f", _engine.CONTAINER] + + +def test_run_sql_defaults_to_root_and_no_headers() -> None: + with _run(_cp(0, "1\n", "")) as run: + res = _engine.run_sql("SELECT 1;", database="d1") + argv = run.call_args.args[0] + assert argv[:4] == ["docker", "exec", "-i", _engine.CONTAINER] + assert "-uroot" in argv + assert "-N" in argv + assert argv[-1] == "d1" + assert run.call_args.kwargs["input"] == "SELECT 1;" + assert (res.returncode, res.stdout) == (0, "1\n") + + +def test_run_sql_headers_user_and_no_database() -> None: + with _run(_cp(0)) as run: + _engine.run_sql("SELECT 1;", user="u_x", headers=True) + argv = run.call_args.args[0] + assert "-uu_x" in argv + assert "-N" not in argv + assert argv[-1] == "-B" + + +def test_run_sql_timeout_becomes_engine_error() -> None: + boom = subprocess.TimeoutExpired(cmd="docker", timeout=1) + with ( + patch.object(subprocess, "run", side_effect=boom), + pytest.raises(_engine.EngineError, match="query timed out"), + ): + _engine.run_sql("SELECT SLEEP(99);", timeout=1) + + +def test_run_sql_missing_docker_is_not_relabelled() -> None: + with ( + patch.object(shutil, "which", return_value=None), + pytest.raises(_engine.EngineError, match="docker binary"), + ): + _engine.run_sql("SELECT 1;") diff --git a/sql_drills/sql_drills/tests/test_fixtures.py b/sql_drills/sql_drills/tests/test_fixtures.py new file mode 100644 index 0000000..f537f4b --- /dev/null +++ b/sql_drills/sql_drills/tests/test_fixtures.py @@ -0,0 +1,62 @@ +"""Static integrity of the shipped builtin drills (no Docker).""" + +from __future__ import annotations + +import json +import re + +import pytest + +import sql_drills as sd +from sql_drills import _paths + +ROOT = _paths.builtin_dir() +IDS = [m.id for m in sd.list_drills()] +# Drill content stays generic: no judge or employer names in what users read. +_BRANDS = re.compile(r"hackerrank|leetcode|google|amazon|meta\b|company", re.IGNORECASE) + + +@pytest.mark.parametrize("drill_id", IDS) +def test_folder_has_every_file(drill_id: str) -> None: + names = {p.name for p in (ROOT / drill_id).iterdir()} + required = { + "meta.json", + "DOCS-task.md", + "schema.sql", + "expected.txt", + "tables.json", + } + assert required <= names + kind = json.loads((ROOT / drill_id / "meta.json").read_text())["kind"] + assert ("query.sql" in names) == (kind == "predict") + + +@pytest.mark.parametrize("drill_id", IDS) +def test_text_is_generic(drill_id: str) -> None: + for path in (ROOT / drill_id).glob("*"): + assert not _BRANDS.search(path.read_text()), path + + +@pytest.mark.parametrize("drill_id", IDS) +def test_expected_is_not_empty_and_tables_cover_schema(drill_id: str) -> None: + folder = ROOT / drill_id + assert (folder / "expected.txt").read_text().strip() + schema = (folder / "schema.sql").read_text() + for name in sd.get_drill(drill_id).tables: + assert f"CREATE TABLE {name}" in schema + + +def test_every_write_drill_has_a_reference_solution() -> None: + solutions = ROOT.parent / "tests" / "solutions" + for meta in sd.list_drills(): + if meta.kind == "write": + assert (solutions / f"{meta.id}.sql").is_file() + + +def test_exactly_one_builtin_error_drill() -> None: + errors = [ + i + for i in IDS + if "error_code" in json.loads((ROOT / i / "meta.json").read_text()) + ] + assert errors == ["t06_alias_in_where"] diff --git a/sql_drills/sql_drills/tests/test_generate.py b/sql_drills/sql_drills/tests/test_generate.py new file mode 100644 index 0000000..75b6421 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_generate.py @@ -0,0 +1,177 @@ +"""``save_generated`` validates by running the reference on a fresh sandbox.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest + +import sql_drills as sd +from sql_drills import _paths + +if TYPE_CHECKING: + from sql_drills.tests.conftest import FakeMysql + +_COLS = "emp\tid\nemp\tpay\n" + + +def _spec(**over: object) -> dict[str, Any]: + base: dict[str, Any] = { + "kind": "write", + "topic": "window", + "title": "Top earner", + "task_md": "Return the best paid person.", + "schema_sql": "CREATE TABLE emp (id INT, pay INT);", + "reference_sql": "SELECT SECRET_REFERENCE;", + } + base.update(over) + return base + + +def _wire(fake: FakeMysql, out: str = "2\n") -> None: + fake.say("SECRET_REFERENCE", stdout=out) + fake.say("information_schema", stdout=_COLS) + fake.say("FROM `emp`", stdout="1\t5\n2\t9\n") + + +def test_valid_write_spec_is_saved_and_listed(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql) + drill_id = sd.save_generated(_spec()) + assert drill_id.startswith("g-window-") + meta = next(m for m in sd.list_drills() if m.id == drill_id) + assert (meta.kind, meta.source, meta.topic, meta.title) == ( + "write", + "generated", + "window", + "Top earner", + ) + drill = sd.get_drill(drill_id) + assert drill.expected_preview == [["2"]] + assert drill.tables["emp"].rows == [["1", "5"], ["2", "9"]] + assert drill.query is None + + +def test_reference_sql_is_never_written_to_disk(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql) + drill_id = sd.save_generated(_spec()) + folder = _paths.data_dir() / "drills" / drill_id + assert sorted(p.name for p in folder.iterdir()) == [ + "DOCS-task.md", + "expected.txt", + "meta.json", + "schema.sql", + "tables.json", + ] + assert all("SECRET_REFERENCE" not in p.read_text() for p in folder.iterdir()) + + +def test_expected_is_the_reference_output_and_is_gradable( + fake_mysql: FakeMysql, +) -> None: + _wire(fake_mysql, "2\t9\n") + drill_id = sd.save_generated(_spec()) + fake_mysql.say("my answer", stdout="2\t9\n", header="id\tpay") + assert sd.run(drill_id, "my answer").match + + +def test_saving_twice_is_idempotent(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql) + first = sd.save_generated(_spec()) + assert sd.save_generated(_spec()) == first + assert len([m for m in sd.list_drills() if m.source == "generated"]) == 1 + + +def test_spec_dataclass_is_accepted(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql) + spec = sd.DrillSpec(**_spec()) + assert sd.save_generated(spec).startswith("g-window-") + + +def test_empty_reference_output_is_rejected(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql, "") + with pytest.raises(sd.SpecError, match="empty"): + sd.save_generated(_spec()) + assert not (_paths.data_dir() / "drills").exists() or not any( + (_paths.data_dir() / "drills").iterdir() + ) + + +def test_erroring_reference_is_rejected(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SECRET_REFERENCE", stderr="ERROR 1064: syntax", rc=1) + with pytest.raises(sd.SpecError, match=r"reference.*1064"): + sd.save_generated(_spec()) + + +def test_nondeterministic_reference_is_rejected(fake_mysql: FakeMysql) -> None: + fake_mysql.say_seq("SECRET_REFERENCE", ["1\n", "2\n"]) + with pytest.raises(sd.SpecError, match="deterministic"): + sd.save_generated(_spec()) + + +@pytest.mark.parametrize( + ("over", "msg"), + [ + ({"topic": "cooking"}, "topic"), + ({"kind": "chat"}, "kind"), + ({"title": " "}, "title"), + ({"reference_sql": ""}, "reference_sql"), + ({"task_md": 5}, "task_md"), + ({"expect_error": True}, "predict"), + ], +) +def test_bad_specs_fail_before_touching_mysql( + fake_mysql: FakeMysql, over: dict[str, Any], msg: str +) -> None: + with pytest.raises(sd.SpecError, match=msg): + sd.save_generated(_spec(**over)) + assert fake_mysql.calls == [] + + +def test_missing_field_is_rejected(fake_mysql: FakeMysql) -> None: + spec = _spec() + del spec["schema_sql"] + with pytest.raises(sd.SpecError, match="schema_sql"): + sd.save_generated(spec) + + +def test_predict_spec_stores_the_query_and_hides_expected( + fake_mysql: FakeMysql, +) -> None: + _wire(fake_mysql, "2\n") + drill_id = sd.save_generated(_spec(kind="predict")) + drill = sd.get_drill(drill_id) + assert drill.query == "SELECT SECRET_REFERENCE;" + assert drill.expected_preview == [] + assert "Predict the exact output" in drill.task_md + assert sd.check_prediction(drill_id, [["2"]]).match + + +def test_predict_spec_may_expect_an_error(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SECRET_REFERENCE", stderr="ERROR 1054 (42S22): Unknown", rc=1) + drill_id = sd.save_generated(_spec(kind="predict", expect_error=True)) + res = sd.check_prediction(drill_id, [["ERROR"]]) + assert res.match + assert res.error == "ERROR 1054 (42S22): Unknown" + meta = json.loads( + (_paths.data_dir() / "drills" / drill_id / "meta.json").read_text() + ) + assert meta["error_code"] == 1054 + + +def test_predict_error_without_flag_is_rejected(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SECRET_REFERENCE", stderr="ERROR 1054: x", rc=1) + with pytest.raises(sd.SpecError, match="reference"): + sd.save_generated(_spec(kind="predict")) + + +def test_expected_error_that_does_not_happen_is_rejected(fake_mysql: FakeMysql) -> None: + _wire(fake_mysql) + with pytest.raises(sd.SpecError, match="did not fail"): + sd.save_generated(_spec(kind="predict", expect_error=True)) + + +def test_expected_error_without_code_is_rejected(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SECRET_REFERENCE", stderr="odd failure", rc=1) + with pytest.raises(sd.SpecError, match="error code"): + sd.save_generated(_spec(kind="predict", expect_error=True)) diff --git a/sql_drills/sql_drills/tests/test_grade.py b/sql_drills/sql_drills/tests/test_grade.py new file mode 100644 index 0000000..f58d1a1 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_grade.py @@ -0,0 +1,151 @@ +"""``run`` and ``check_prediction`` against builtin drills and a fake MySQL.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import sql_drills as sd +from sql_drills import _engine, _paths + +if TYPE_CHECKING: + from sql_drills.tests.conftest import FakeMysql + +_PIVOT = "01_pivot_sum" +_PIVOT_ROWS = [ + ["East", "0", "300", "0", "25"], + ["North", "200", "150", "0", "200"], + ["South", "90", "0", "100", "0"], + ["West", "10", "0", "0", "0"], +] +_HEADER = "region\tQ1\tQ2\tQ3\tQ4" + + +def _tsv(rows: list[list[str]]) -> str: + return "".join("\t".join(r) + "\n" for r in rows) + + +def test_fixture_expected_is_tab_separated() -> None: + text = (_paths.builtin_dir() / _PIVOT / "expected.txt").read_text() + assert text == _tsv(_PIVOT_ROWS) + + +def test_run_match(fake_mysql: FakeMysql) -> None: + fake_mysql.say("my query", stdout=_tsv(_PIVOT_ROWS), header=_HEADER) + res = sd.run(_PIVOT, "my query") + assert res.ok + assert res.match + assert res.error is None + assert res.columns == ["region", "Q1", "Q2", "Q3", "Q4"] + assert res.rows == res.expected_rows == _PIVOT_ROWS + assert res.cell_diff == [] + + +def test_run_builds_the_drill_schema_first(fake_mysql: FakeMysql) -> None: + sd.run(_PIVOT, "my query") + assert fake_mysql.calls[1]["sql"] == sd.get_drill(_PIVOT).schema_sql + assert str(fake_mysql.calls[-2]["sql"]).endswith("my query") + assert "DROP DATABASE" in str(fake_mysql.calls[-1]["sql"]) + + +def test_run_wrong_cell_is_located(fake_mysql: FakeMysql) -> None: + wrong = [list(r) for r in _PIVOT_ROWS] + wrong[1][2] = "151" + fake_mysql.say("my query", stdout=_tsv(wrong), header=_HEADER) + res = sd.run(_PIVOT, "my query") + assert res.ok + assert not res.match + assert res.cell_diff == [(1, 2, "150", "151")] + assert res.extra_rows == [wrong[1]] + assert res.missing_rows == [_PIVOT_ROWS[1]] + + +def test_run_sql_error_is_visible(fake_mysql: FakeMysql) -> None: + fake_mysql.say("my query", stderr="ERROR 1064 (42000): syntax", rc=1) + res = sd.run(_PIVOT, "my query") + assert not res.ok + assert not res.match + assert res.error == "ERROR 1064 (42000): syntax" + assert res.rows == [] + assert res.expected_rows == _PIVOT_ROWS + + +def test_run_on_error_drill_matches_by_error_code(fake_mysql: FakeMysql) -> None: + fake_mysql.say("bad", stderr="ERROR 1054 (42S22): Unknown column", rc=1) + res = sd.run("t06_alias_in_where", "bad") + assert not res.ok + assert res.match + assert res.rows == res.expected_rows == [["ERROR 1054"]] + + +def test_run_on_error_drill_with_other_error_code(fake_mysql: FakeMysql) -> None: + fake_mysql.say("bad", stderr="ERROR 1146 (42S02): no table", rc=1) + res = sd.run("t06_alias_in_where", "bad") + assert not res.match + assert res.cell_diff == [(0, 0, "ERROR 1054", "ERROR 1146")] + + +def test_run_on_error_drill_with_codeless_failure(fake_mysql: FakeMysql) -> None: + fake_mysql.say("bad", stderr="gone", rc=1) + res = sd.run("t06_alias_in_where", "bad") + assert not res.match + assert res.rows == [] + + +def test_run_unknown_drill() -> None: + with pytest.raises(sd.UnknownDrillError): + sd.run("nope", "SELECT 1") + + +def test_run_engine_failure_propagates(fake_mysql: FakeMysql) -> None: + fake_mysql.say("CREATE DATABASE", stderr="Cannot connect", rc=1) + with pytest.raises(_engine.EngineError): + sd.run(_PIVOT, "SELECT 1") + + +def test_prediction_match_needs_no_engine(fake_mysql: FakeMysql) -> None: + res = sd.check_prediction("t01_null_not_equal", [["Bob"], ["", ""]]) + assert res.match + assert res.cell_diff == [] + assert res.actual_rows == [["Bob"]] + assert res.error is None + assert fake_mysql.calls == [] + + +def test_prediction_mismatch_is_located() -> None: + res = sd.check_prediction("t01_null_not_equal", [["Ann"], ["Bob"]]) + assert not res.match + assert res.cell_diff == [(0, 0, "Bob", "Ann"), (1, 0, None, "Bob")] + assert res.actual_rows == [["Bob"]] + assert res.extra_rows == [["Ann"]] + assert res.missing_rows == [] + + +def test_prediction_is_case_strict() -> None: + res = sd.check_prediction("t05_anti_join", [["Cid"], ["Dee"]]) + assert res.match + assert not sd.check_prediction("t05_anti_join", [["Cid"], ["dee"]]).match + + +@pytest.mark.parametrize("typed", ["ERROR", "error", " Error ", "ERROR 1054"]) +def test_error_prediction_accepts_error_with_or_without_code(typed: str) -> None: + res = sd.check_prediction("t06_alias_in_where", [[typed]]) + assert res.match + assert res.actual_rows == [["ERROR 1054"]] + assert res.error is not None + assert "total" in res.error + + +def test_wrong_error_code_or_rows_do_not_match() -> None: + assert not sd.check_prediction("t06_alias_in_where", [["ERROR 1146"]]).match + assert not sd.check_prediction("t06_alias_in_where", [["1", "50"]]).match + + +def test_error_word_is_only_special_on_error_drills() -> None: + assert not sd.check_prediction("t01_null_not_equal", [["ERROR"]]).match + + +def test_prediction_on_write_drill_is_refused() -> None: + with pytest.raises(sd.DrillKindError, match="write"): + sd.check_prediction(_PIVOT, [["x"]]) diff --git a/sql_drills/sql_drills/tests/test_live.py b/sql_drills/sql_drills/tests/test_live.py new file mode 100644 index 0000000..4d0ba7b --- /dev/null +++ b/sql_drills/sql_drills/tests/test_live.py @@ -0,0 +1,127 @@ +"""Live checks against the real ``sql-drills-mysql`` container. + +Skipped when the container is down. They never stop or recreate it and never +touch any database but their own throwaway ``drill_*`` ones. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import sql_drills as sd +from sql_drills import _engine, _paths, _sandbox + +if TYPE_CHECKING: + from pathlib import Path + +SOLUTIONS = _paths.builtin_dir().parent / "tests" / "solutions" +WRITE_IDS = [m.id for m in sd.list_drills() if m.kind == "write"] +PREDICT_IDS = [m.id for m in sd.list_drills() if m.kind == "predict"] + + +def _engine_up() -> bool: + try: + return _engine.is_ready() + except _engine.EngineError: + return False + + +pytestmark = [ + pytest.mark.docker, + pytest.mark.skipif(not _engine_up(), reason="sql-drills-mysql is not running"), +] + + +def _container_id() -> str: + return _engine._docker("inspect", "-f", "{{.Id}}", _engine.CONTAINER).stdout.strip() + + +def _databases() -> set[str]: + res = _engine.run_sql("SHOW DATABASES;") + return set(res.stdout.split()) + + +@pytest.mark.parametrize("drill_id", WRITE_IDS) +def test_reference_solution_matches_expected(drill_id: str) -> None: + res = sd.run(drill_id, (SOLUTIONS / f"{drill_id}.sql").read_text()) + assert res.match, (res.error, res.cell_diff) + + +@pytest.mark.parametrize("drill_id", PREDICT_IDS) +def test_predict_query_reproduces_the_stored_expected(drill_id: str) -> None: + drill = sd.get_drill(drill_id) + assert drill.query is not None + assert sd.run(drill_id, drill.query).match + + +@pytest.mark.parametrize("drill_id", [m.id for m in sd.list_drills()]) +def test_stored_tables_match_the_live_schema(drill_id: str) -> None: + drill = sd.get_drill(drill_id) + with _sandbox.sandbox(drill.schema_sql) as box: + assert box.tables() == drill.tables + + +def test_wrong_query_reports_cells() -> None: + res = sd.run( + "01_pivot_sum", "SELECT region, 1, 2, 3, 4 FROM shop_sales GROUP BY region;" + ) + assert not res.match + assert res.cell_diff + + +def test_syntax_error_is_reported() -> None: + res = sd.run("01_pivot_sum", "SELECT * FROM;") + assert not res.ok + assert "1064" in (res.error or "") + + +def test_runs_are_isolated_and_leave_nothing_behind() -> None: + before = _databases() + sd.run("01_pivot_sum", "DELETE FROM shop_sales;") + again = sd.run("01_pivot_sum", (SOLUTIONS / "01_pivot_sum.sql").read_text()) + assert again.match + assert _databases() == before + + +def test_user_sql_cannot_see_or_drop_other_databases() -> None: + _engine.run_sql("CREATE DATABASE IF NOT EXISTS drill_canary_guard;") + try: + drill = sd.get_drill("01_pivot_sum") + with _sandbox.sandbox(drill.schema_sql) as box: + seen = {r[0] for r in box.execute("SHOW DATABASES;").rows} + assert "drill_canary_guard" not in seen + assert not box.execute("DROP DATABASE drill_canary_guard;").ok + assert "drill_canary_guard" in _databases() + finally: + _engine.run_sql("DROP DATABASE IF EXISTS drill_canary_guard;") + + +def test_ensure_running_does_not_recreate_a_running_container() -> None: + before = _container_id() + sd.ensure_running() + sd.ensure_running() + assert _container_id() == before + + +def test_save_generated_end_to_end(tmp_path: Path) -> None: + spec = { + "kind": "write", + "topic": "group-having", + "title": "Busy tags", + "task_md": "Tags used at least twice, most used first (then tag).", + "schema_sql": ( + "CREATE TABLE uses (id INT PRIMARY KEY, tag VARCHAR(10));" + "INSERT INTO uses VALUES (1,'a'),(2,'b'),(3,'a'),(4,'c'),(5,'b'),(6,'a');" + ), + "reference_sql": ( + "SELECT tag, COUNT(*) FROM uses GROUP BY tag HAVING COUNT(*) >= 2 " + "ORDER BY COUNT(*) DESC, tag;" + ), + } + drill_id = sd.save_generated(spec) + assert _paths.data_dir().is_relative_to(tmp_path) + assert sd.get_drill(drill_id).expected_preview == [["a", "3"], ["b", "2"]] + assert sd.run(drill_id, str(spec["reference_sql"])).match + assert not sd.run(drill_id, "SELECT tag, 1 FROM uses;").match diff --git a/sql_drills/sql_drills/tests/test_normalize.py b/sql_drills/sql_drills/tests/test_normalize.py new file mode 100644 index 0000000..0402941 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_normalize.py @@ -0,0 +1,86 @@ +"""Judge-style normalisation and the cell-level diff.""" + +from __future__ import annotations + +from sql_drills import _normalize as n + + +def test_parse_rows_splits_on_tabs() -> None: + assert n.parse_rows("a\tb\nc\t\n") == [["a", "b"], ["c", ""]] + assert n.parse_rows("") == [] + + +def test_line_turns_tabs_into_spaces_and_rstrips() -> None: + assert n.line(["a", "b "]) == "a b" + assert n.line(["a", ""]) == "a" + assert n.line(["a", "", "b"]) == "a b" + + +def test_clean_grid_strips_cells_and_trailing_blank_rows() -> None: + grid = [[" a ", "b"], ["", " "], ["c", ""], ["", ""], [""]] + assert n.clean_grid(grid) == [["a", "b"], ["", ""], ["c", ""]] + + +def test_clean_grid_all_blank_is_empty() -> None: + assert n.clean_grid([[""], [" "]]) == [] + + +def test_equal_outputs_match() -> None: + cmp = n.compare([["a", "1"]], [["a", "1"]]) + assert cmp.match + assert cmp.cell_diff == [] + assert cmp.extra_rows == cmp.missing_rows == [] + + +def test_trailing_blank_cell_is_judge_equal() -> None: + assert n.compare([["a"]], [["a", ""]]).match + + +def test_single_cell_difference_is_located() -> None: + cmp = n.compare([["a", "1"], ["b", "2"]], [["a", "1"], ["b", "3"]]) + assert not cmp.match + assert cmp.cell_diff == [(1, 1, "2", "3")] + assert cmp.extra_rows == [["b", "3"]] + assert cmp.missing_rows == [["b", "2"]] + + +def test_case_and_decimals_are_strict() -> None: + assert not n.compare([["Ann"]], [["ann"]]).match + assert not n.compare([["5.50"]], [["5.5"]]).match + + +def test_row_order_is_strict_but_not_extra_or_missing() -> None: + cmp = n.compare([["a"], ["b"]], [["b"], ["a"]]) + assert not cmp.match + assert cmp.cell_diff == [(0, 0, "a", "b"), (1, 0, "b", "a")] + assert cmp.extra_rows == cmp.missing_rows == [] + + +def test_extra_actual_row_gets_none_expected_cells() -> None: + cmp = n.compare([["a"]], [["a"], ["z", "9"]]) + assert cmp.cell_diff == [(1, 0, None, "z"), (1, 1, None, "9")] + assert cmp.extra_rows == [["z", "9"]] + + +def test_missing_actual_row_gets_none_actual_cells() -> None: + cmp = n.compare([["a"], ["b"]], [["a"]]) + assert cmp.cell_diff == [(1, 0, "b", None)] + assert cmp.missing_rows == [["b"]] + + +def test_wider_row_marks_the_extra_column() -> None: + cmp = n.compare([["a"]], [["a", "x"]]) + assert not cmp.match + assert cmp.cell_diff == [(0, 1, None, "x")] + + +def test_judge_line_rule_beats_cell_boundaries() -> None: + # "a b" and "ab" are the same line to an exact-match judge. + cmp = n.compare([["a b"]], [["a", "b"]]) + assert cmp.match + assert cmp.cell_diff == [] + + +def test_trailing_blank_cells_do_not_show_in_the_diff() -> None: + cmp = n.compare([["a", ""]], [["b"]]) + assert cmp.cell_diff == [(0, 0, "a", "b")] diff --git a/sql_drills/sql_drills/tests/test_paths.py b/sql_drills/sql_drills/tests/test_paths.py new file mode 100644 index 0000000..fa11df8 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_paths.py @@ -0,0 +1,33 @@ +"""Data-dir resolution is a function, so tests (and users) can redirect it.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sql_drills import _paths, data_dir + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_env_wins(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("SQL_DRILLS_DATA", str(tmp_path / "x")) + assert data_dir() == tmp_path / "x" + + +def test_default_is_under_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("SQL_DRILLS_DATA") + monkeypatch.setenv("HOME", str(tmp_path)) + assert data_dir() == tmp_path / ".local" / "share" / "sql_drills" + + +def test_empty_env_falls_back(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("SQL_DRILLS_DATA", "") + monkeypatch.setenv("HOME", str(tmp_path)) + assert data_dir().name == "sql_drills" + + +def test_builtin_dir_ships_fixtures() -> None: + assert (_paths.builtin_dir() / "01_pivot_sum" / "meta.json").is_file() diff --git a/sql_drills/sql_drills/tests/test_sandbox.py b/sql_drills/sql_drills/tests/test_sandbox.py new file mode 100644 index 0000000..a85e497 --- /dev/null +++ b/sql_drills/sql_drills/tests/test_sandbox.py @@ -0,0 +1,167 @@ +"""Per-run database + restricted user, against the scripted fake MySQL.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from sql_drills import _engine, _sandbox + +if TYPE_CHECKING: + from sql_drills.tests.conftest import FakeMysql + + +def test_lifecycle_provisions_loads_schema_and_cleans_up( + fake_mysql: FakeMysql, +) -> None: + with _sandbox.sandbox("CREATE TABLE t (a INT);") as box: + assert box.db.startswith("drill_") + assert box.user.startswith("u_") + assert box.db != "theory" + calls = fake_mysql.calls + assert "CREATE DATABASE" in str(calls[0]["sql"]) + assert calls[0]["user"] == "root" + assert calls[1]["sql"] == "CREATE TABLE t (a INT);" + assert (calls[1]["user"], calls[1]["database"]) == (box.user, box.db) + assert "DROP DATABASE IF EXISTS" in str(calls[-1]["sql"]) + assert "DROP USER IF EXISTS" in str(calls[-1]["sql"]) + + +def test_every_sandbox_gets_fresh_names(fake_mysql: FakeMysql) -> None: + with _sandbox.sandbox("") as one, _sandbox.sandbox("") as two: + assert one.db != two.db + assert one.user != two.user + + +def _fail_inside() -> None: + with _sandbox.sandbox(""): + msg = "boom" + raise RuntimeError(msg) + + +def test_cleanup_runs_when_the_body_raises(fake_mysql: FakeMysql) -> None: + with pytest.raises(RuntimeError, match="boom"): + _fail_inside() + assert "DROP DATABASE" in str(fake_mysql.calls[-1]["sql"]) + + +def test_provision_failure_is_engine_error_and_still_cleans( + fake_mysql: FakeMysql, +) -> None: + fake_mysql.say("CREATE DATABASE", stderr="ERROR 1007: exists", rc=1) + with pytest.raises(_engine.EngineError, match="sandbox"): + _sandbox.sandbox("").__enter__() + assert "DROP DATABASE" in str(fake_mysql.calls[-1]["sql"]) + + +def test_schema_failure_is_engine_error(fake_mysql: FakeMysql) -> None: + fake_mysql.say("BAD SCHEMA", stderr="ERROR 1064 (42000) at line 1: syntax", rc=1) + with ( + pytest.raises(_engine.EngineError, match=r"drill schema failed.*1064"), + _sandbox.sandbox("BAD SCHEMA"), + ): + pytest.fail("body must not run") + assert "DROP DATABASE" in str(fake_mysql.calls[-1]["sql"]) + + +def test_execute_parses_rows(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SELECT 1", stdout="1\tNULL\n") + with _sandbox.sandbox("") as box: + out = box.execute("SELECT 1;") + assert out.ok + assert out.rows == [["1", "NULL"]] + last_run = fake_mysql.calls[-2] + assert last_run["headers"] is False + assert str(last_run["sql"]).endswith("SELECT 1;") + assert str(last_run["sql"]).startswith("SET SESSION max_execution_time=") + + +def test_columns_of_uses_headers_mode(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SELECT 1", stdout="1\tNULL\n", header="a\tb") + with _sandbox.sandbox("") as box: + assert box.columns_of("SELECT 1;", 1) == ["a", "b"] + assert fake_mysql.calls[-2]["headers"] is True + + +def test_columns_of_keeps_a_multiline_name_whole(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SELECT 1", stdout="x\n", header="CONCAT(a,\n b)") + with _sandbox.sandbox("") as box: + assert box.columns_of("SELECT 1;", 1) == ["CONCAT(a,\n b)"] + + +def test_columns_of_without_a_header_is_empty(fake_mysql: FakeMysql) -> None: + fake_mysql.say("SELECT 1", stdout="1\n") + with _sandbox.sandbox("") as box: + assert box.columns_of("SELECT 1;", 1) == [] + + +def test_execute_with_no_output(fake_mysql: FakeMysql) -> None: + with _sandbox.sandbox("") as box: + out = box.execute("SELECT 1 WHERE 0;") + assert out.ok + assert out.rows == [] + + +def test_execute_error_carries_message_and_code(fake_mysql: FakeMysql) -> None: + fake_mysql.say( + "nope", stderr="ERROR 1054 (42S22) at line 2: Unknown column\n", rc=1 + ) + with _sandbox.sandbox("") as box: + out = box.execute("nope") + assert not out.ok + assert out.code == 1054 + assert out.error == "ERROR 1054 (42S22) at line 2: Unknown column" + assert out.rows == [] + + +def test_execute_error_without_code(fake_mysql: FakeMysql) -> None: + fake_mysql.say("weird", stderr="something odd", rc=1) + with _sandbox.sandbox("") as box: + out = box.execute("weird") + assert (out.ok, out.code, out.error) == (False, None, "something odd") + + +_COLUMNS = "customers\tid\ncustomers\tname\nempty\tx\n" + + +def test_tables_snapshot_reads_columns_and_rows(fake_mysql: FakeMysql) -> None: + fake_mysql.say("information_schema", stdout=_COLUMNS) + fake_mysql.say("FROM `customers`", stdout="1\tAnn\n2\tBob\n") + with _sandbox.sandbox("") as box: + tables = box.tables() + assert list(tables) == ["customers", "empty"] + assert tables["customers"].columns == ["id", "name"] + assert tables["customers"].rows == [["1", "Ann"], ["2", "Bob"]] + assert "LIMIT" in str(fake_mysql.calls[-3]["sql"]) + + +def test_tables_snapshot_of_empty_table_keeps_columns(fake_mysql: FakeMysql) -> None: + fake_mysql.say("information_schema", stdout=_COLUMNS) + with _sandbox.sandbox("") as box: + empty = box.tables()["empty"] + assert (empty.columns, empty.rows) == (["x"], []) + + +def test_tables_snapshot_of_schema_without_tables(fake_mysql: FakeMysql) -> None: + with _sandbox.sandbox("") as box: + assert box.tables() == {} + + +def test_tables_listing_error_is_engine_error(fake_mysql: FakeMysql) -> None: + fake_mysql.say("information_schema", stderr="ERROR 1: x", rc=1) + with ( + _sandbox.sandbox("") as box, + pytest.raises(_engine.EngineError, match="list tables"), + ): + box.tables() + + +def test_tables_row_error_is_engine_error(fake_mysql: FakeMysql) -> None: + fake_mysql.say("information_schema", stdout=_COLUMNS) + fake_mysql.say("FROM `customers`", stderr="ERROR 2: y", rc=1) + with ( + _sandbox.sandbox("") as box, + pytest.raises(_engine.EngineError, match="customers"), + ): + box.tables() diff --git a/sql_drills/sql_drills/tests/test_store.py b/sql_drills/sql_drills/tests/test_store.py new file mode 100644 index 0000000..001455e --- /dev/null +++ b/sql_drills/sql_drills/tests/test_store.py @@ -0,0 +1,131 @@ +"""Drill discovery over the builtin fixtures and the generated data dir.""" + +from __future__ import annotations + +from dataclasses import asdict +import json +from typing import TYPE_CHECKING + +import pytest + +import sql_drills as sd +from sql_drills import _paths + +if TYPE_CHECKING: + from pathlib import Path + +SOLUTIONS = _paths.builtin_dir().parent / "tests" / "solutions" + + +def _make_generated(root: Path, drill_id: str, *, kind: str = "write") -> None: + folder = root / "drills" / drill_id + folder.mkdir(parents=True) + (folder / "meta.json").write_text(json.dumps({"kind": kind, "topic": "window"})) + (folder / "DOCS-task.md").write_text("# Made up\n\nBody\n") + (folder / "schema.sql").write_text("CREATE TABLE t (a INT);") + (folder / "expected.txt").write_text("1\n2\n") + (folder / "tables.json").write_text( + json.dumps({"t": {"columns": ["a"], "rows": [["1"], ["2"]]}}) + ) + + +def test_topics_are_the_contract_list() -> None: + assert sd.topics() == [ + "basics", + "pivot", + "dates", + "number-format", + "joins-null", + "group-having", + "window", + "strings", + ] + + +def test_builtin_list_has_twelve_write_and_seven_predict() -> None: + metas = sd.list_drills() + assert [m.kind for m in metas].count("write") == 12 + assert [m.kind for m in metas].count("predict") == 7 + assert {m.source for m in metas} == {"builtin"} + assert {m.topic for m in metas} <= set(sd.TOPICS) + assert [m.id for m in metas] == sorted(m.id for m in metas) + + +def test_titles_come_from_the_task_heading() -> None: + by_id = {m.id: m for m in sd.list_drills()} + assert by_id["01_pivot_sum"].title.startswith("Pivot quarters") + assert by_id["t01_null_not_equal"].title == "Predict: NULL and !=" + + +def test_theory_drills_are_basics() -> None: + topics = {m.topic for m in sd.list_drills() if m.id.startswith("t")} + assert topics == {"basics"} + + +def test_generated_drills_follow_builtin_ones() -> None: + _make_generated(_paths.data_dir(), "g-window-abc12345") + metas = sd.list_drills() + assert metas[-1].id == "g-window-abc12345" + assert metas[-1].source == "generated" + assert metas[-1].title == "Made up" + + +def test_dirs_without_meta_are_ignored() -> None: + (_paths.data_dir() / "drills" / ".tmp-partial").mkdir(parents=True) + assert all(m.source == "builtin" for m in sd.list_drills()) + + +def test_get_write_drill() -> None: + drill = sd.get_drill("01_pivot_sum") + assert drill.meta.kind == "write" + assert "CREATE TABLE shop_sales" in drill.schema_sql + assert drill.task_md.startswith("# Pivot") + assert drill.query is None + assert 0 < len(drill.expected_preview) <= 5 + assert drill.tables["shop_sales"].columns == ["id", "region", "quarter", "amount"] + assert len(drill.tables["shop_sales"].rows) == 10 + + +def test_get_predict_drill_shows_query_but_no_expected() -> None: + drill = sd.get_drill("t03_left_join_on") + assert drill.meta.kind == "predict" + assert drill.query is not None + assert "LEFT JOIN" in drill.query + assert drill.expected_preview == [] + assert set(drill.tables) == {"customers", "orders"} + + +def test_number_prefix_resolves_builtin_ids() -> None: + assert sd.get_drill("03").meta.id == "03_month_bucket" + assert sd.get_drill("t05").meta.id == "t05_anti_join" + + +def test_generated_drill_by_exact_id_only() -> None: + _make_generated(_paths.data_dir(), "g-window-abc12345") + assert sd.get_drill("g-window-abc12345").meta.source == "generated" + with pytest.raises(sd.UnknownDrillError): + sd.get_drill("g") + + +def test_unknown_drill_raises() -> None: + with pytest.raises(sd.UnknownDrillError, match="nope"): + sd.get_drill("nope") + + +def _strings(obj: object) -> list[str]: + if isinstance(obj, str): + return [" ".join(obj.split())] + if isinstance(obj, dict): + return [t for v in obj.values() for t in _strings(v)] + if isinstance(obj, list): + return [t for v in obj for t in _strings(v)] + return [] + + +def test_public_drills_never_contain_reference_sql() -> None: + texts = [t for m in sd.list_drills() for t in _strings(asdict(sd.get_drill(m.id)))] + solutions = list(SOLUTIONS.glob("*.sql")) + assert len(solutions) == 12 + for solution in solutions: + body = " ".join(solution.read_text().split()) + assert not any(body in text for text in texts)