From 3fe0023c0af279b275f746948ad08134a631a25f Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 9 Oct 2026 15:07:52 -0400 Subject: [PATCH 01/19] Add Assignment 2 automated marking --- .github/scripts/grade_assignments.py | 47 ++ .github/workflows/sql_assignment_runner.yml | 168 ++--- .gitignore | 2 + .../Microcredential_Cohort/assignment2.sql | 2 +- .../tests}/conftest.py | 12 +- .../tests/test_assignment.py | 615 ++++++++++++++++++ .../tests/test_grader_regressions.py | 269 ++++++++ tests/test_assignment.py | 112 ---- 8 files changed, 1030 insertions(+), 197 deletions(-) create mode 100644 .github/scripts/grade_assignments.py rename {tests => 03_instructional_team/tests}/conftest.py (55%) create mode 100644 03_instructional_team/tests/test_assignment.py create mode 100644 03_instructional_team/tests/test_grader_regressions.py delete mode 100644 tests/test_assignment.py diff --git a/.github/scripts/grade_assignments.py b/.github/scripts/grade_assignments.py new file mode 100644 index 000000000..0d5a3942c --- /dev/null +++ b/.github/scripts/grade_assignments.py @@ -0,0 +1,47 @@ +"""Run each changed A2 submission and always produce a fresh combined report.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def grade_files(files, output): + """Grade exactly one changed Assignment 2 SQL file and write its report.""" + if len(files) != 1: + if files: + error = "More than one assignment2.sql file changed. Submit only your cohort's file." + else: + error = "No changed assignment2.sql file was found." + reports = [{"file": "Submission selection", "results": [], "runner_error": error}] + Path(output).write_text(json.dumps(reports, indent=2)) + return 1 + + file_path = files[0] + with tempfile.TemporaryDirectory(prefix="a2-reports-") as folder: + report = Path(folder) / "report.json" + error = None + try: + result = subprocess.run( + [sys.executable, "-m", "pytest", "03_instructional_team/tests/test_assignment.py", + f"--file_path={file_path}", f"--report_path={report}", + "--tb=short", "--disable-warnings"], timeout=120, check=False, + ) + if result.returncode: + error = f"Grading runner exited with status {result.returncode}." + answers = json.loads(report.read_text()) if report.exists() else [] + if not isinstance(answers, list) or len(answers) != 12: + raise ValueError("Grading runner did not produce a complete 12-question report.") + except Exception as exception: + answers = [] + error = str(exception) + + reports = [{"file": file_path, "results": answers, "runner_error": error}] + Path(output).write_text(json.dumps(reports, indent=2)) + return int(error is not None) + + +if __name__ == "__main__": + changed = Path(sys.argv[1]).read_bytes().decode().split("\0") + files = sorted({path for path in changed if Path(path).name == "assignment2.sql"}) + sys.exit(grade_files(files, sys.argv[2])) diff --git a/.github/workflows/sql_assignment_runner.yml b/.github/workflows/sql_assignment_runner.yml index 928761494..4beb330cc 100644 --- a/.github/workflows/sql_assignment_runner.yml +++ b/.github/workflows/sql_assignment_runner.yml @@ -1,12 +1,16 @@ name: SQL Unit Tests on: + pull_request: + branches: + - main + types: [opened, reopened, synchronize] workflow_dispatch: # Manual trigger from GitHub UI inputs: branch: - description: 'Branch to use to run assignment for, i.e. assignment-one, assignment-2' + description: 'Assignment 2 branch to grade, usually assignment-two' required: true - default: assignment-one + default: assignment-two type: string pr_number: description: 'Pull request number. Extract a number from the end of pull request url, i.e. https://github.com/khsergvl/sql/pull/17' @@ -19,8 +23,10 @@ permissions: jobs: run-assignment-queries: - # uncomment once a change to PR trigger will be implemented - # if: startsWith(github.head_ref, 'assignment-') + if: >- + github.event_name == 'workflow_dispatch' || + ((github.head_ref == 'assignment-two' || github.head_ref == 'assignment-2') && + github.repository_owner != 'UofT-DSI') runs-on: ubuntu-latest permissions: contents: read @@ -30,7 +36,8 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ inputs.branch }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.event.pull_request.head.sha }} + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -41,95 +48,92 @@ jobs: run: | pip install -r ./.github/scripts/requirements.txt + - name: Verify grader regressions + run: python -m pytest 03_instructional_team/tests/test_grader_regressions.py -q + - name: Get changed files - id: changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - git fetch - git diff --name-only origin/main > changed_files.txt - - echo "Changed files:" - cat changed_files.txt - # The goal here to search for a file modified during PR that is match to a pattern - # and give priority in next order 2, 1 as it quite often happens when students submit - # PR without following assignments order and git flow. It will to run latest / greatest - assignment_changed="" - - # Priority: assignment2 > assignment1 - if grep -qE '(^|/)assignment2\.sql$' changed_files.txt; then - assignment_changed=$(grep -E '(^|/)assignment2\.sql$' changed_files.txt | head -n1) - elif grep -qE '(^|/)assignment1\.sql$' changed_files.txt; then - assignment_changed=$(grep -E '(^|/)assignment1\.sql$' changed_files.txt | head -n1) - fi - - if [ -n "$assignment_changed" ]; then - echo "assignment_changed=$assignment_changed" >> "$GITHUB_OUTPUT" - fi + base_ref="${BASE_SHA:-origin/main}" + git diff --name-only -z "$base_ref"...HEAD > changed_files.txt - - name: Run tests - id: pytest - run: | - pytest tests/test_assignment.py --file_path="${{steps.changes.outputs.assignment_changed}}" --tb=short --disable-warnings \ - --junitxml=pytest-report.xml || true + - name: Grade changed assignments + id: grading + continue-on-error: true + run: python .github/scripts/grade_assignments.py changed_files.txt test-results.json - - name: Post test results to PR + - name: Post grading feedback to PR + if: always() && steps.grading.outcome != 'skipped' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} with: script: | - function jsonToMarkdownTable(data) { - if (!Array.isArray(data) || data.length === 0) { - return 'No data'; - } - - const headers = Object.keys(data[0]); - - // Header row - const headerRow = `| ${headers.join(' | ')} |`; - - // Separator row - const separatorRow = `| ${headers.map(() => '---').join(' | ')} |`; - - // Data rows - const rows = data.map(row => - `| ${headers.map(h => formatValue(row[h])).join(' | ')} |` - ); - - return [headerRow, separatorRow, ...rows].join('\n'); + const fs = require('fs'); + const labels = { + correct: '✅ Correct', incorrect: '❌ Incorrect', error: '❌ Error', + missing: '❌ Missing answer', blocked: '⚪ Cannot assess: prerequisite failed', + manual_review: '⚪ Instructor review needed' + }; + function fenced(value, language = '') { + const text = String(value); + const runs = text.match(/`+/g) || []; + const fence = '`'.repeat(Math.max(3, ...runs.map(run => run.length + 1))); + return `${fence}${language}\n${text}\n${fence}\n`; } - - function formatValue(value) { - if (value === null || value === undefined) return ''; - if (typeof value === 'object') return `\`${JSON.stringify(value)}\``; - return String(value); + let reports; + try { + reports = JSON.parse(fs.readFileSync('test-results.json', 'utf8')); + if (!Array.isArray(reports)) throw new Error('Invalid report format'); + } catch (error) { + reports = [{file: 'Grading runner', results: [], runner_error: error.message}]; } - - const fs = require('fs') - const file_read_result = fs.readFileSync('test-results.json', 'utf8') - const results = JSON.parse(file_read_result) - - let body = `### 🧪 SQL Queries Run Results (up to 3 rows)\n\n` - body += `
Click to expand/collapse assignment queries execution results\n\n` - - for (const result of results) { - const isArrayResult = Array.isArray(result.result); - - if (result.error) { - body += `❌ Query ${result.number}: \n\n *${result.query}*, \n\n **Error**: \n${result.error}\n\n` - } else if (isArrayResult && result.result.length === 0) { - body += `⚪ Query ${result.number}: \n\n *${result.query}*, \n\n No rows returned \n\n` - } else if (isArrayResult && result.result.length > 0) { - body += `✅ Query ${result.number}: \n\n *${result.query}*, \n\n **Results**: \n` - const table = jsonToMarkdownTable(result.result) - body += `${table} \n\n` + if (!reports.length) return; + let body = '### SQL Assignment 2 feedback\n\n'; + body += 'SQL checks only. ERDs, written responses, and rubric point totals require instructor marking.\n\n'; + const truncationNotice = '\nFeedback truncated; inspect the workflow report for remaining results.'; + const maxBodyLength = 60000; + let truncated = false; + function appendSection(section) { + if (body.length + section.length + truncationNotice.length > maxBodyLength) { + truncated = true; + return false; } - - body += `-------------------------------------------------------- \n`; + body += section; + return true; } - - body += `
` - + reportLoop: + for (const report of reports) { + let reportSection = fenced(report.file); + if (report.runner_error) reportSection += `Grading could not finish: ${report.runner_error}\n\n`; + if (!appendSection(reportSection)) break; + for (const result of report.results) { + let resultSection = `**Query ${result.number}: ${labels[result.status] || '⚪ Instructor review needed'}**\n\n`; + resultSection += `${result.feedback}\n\n`; + if (result.error) resultSection += fenced(result.error); + if (result.query) resultSection += fenced(result.query, 'sql'); + if (result.result?.length) resultSection += fenced(JSON.stringify(result.result, null, 2), 'json'); + resultSection += '\n'; + if (!appendSection(resultSection)) break reportLoop; + } + } + if (truncated) body += truncationNotice; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ inputs.pr_number }}, + issue_number: Number(process.env.PR_NUMBER), body - }) + }); + + - name: Upload grading reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: a2-grading-results + path: test-results.json + if-no-files-found: ignore + + - name: Preserve runner failure status + if: always() && steps.grading.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index b7ffb5651..ec819d823 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .vscode/ *.db-journal .venv +__pycache__/ +*.pyc diff --git a/02_activities/assignments/Microcredential_Cohort/assignment2.sql b/02_activities/assignments/Microcredential_Cohort/assignment2.sql index 4079c18ae..f4a2b999a 100644 --- a/02_activities/assignments/Microcredential_Cohort/assignment2.sql +++ b/02_activities/assignments/Microcredential_Cohort/assignment2.sql @@ -62,7 +62,7 @@ HINT: Do not use the previous visit dates filter. */ /* 3. Using a COUNT() window function, include a value along with each row of the customer_purchases table that indicates how many different times that customer has purchased that product_id. -You can make this a running count by including an ORDER BY within the PARTITION BY if desired. +You can make this a running count by including an ORDER BY in the window function if desired. Filter the visits to dates before April 29, 2022. */ --QUERY 4 diff --git a/tests/conftest.py b/03_instructional_team/tests/conftest.py similarity index 55% rename from tests/conftest.py rename to 03_instructional_team/tests/conftest.py index 5415915f8..f17c8dd5e 100644 --- a/tests/conftest.py +++ b/03_instructional_team/tests/conftest.py @@ -4,12 +4,13 @@ @pytest.fixture def sqlite_db(): + """Provide each test with an in-memory copy of the course database.""" source_db = Path("05_src/sql/farmersmarket.db") # Create in-memory DB conn = sqlite3.connect(":memory:") # Load schema.db into memory - disk_db = sqlite3.connect(source_db) + disk_db = sqlite3.connect(f"file:{source_db}?mode=ro", uri=True) conn.row_factory = sqlite3.Row disk_db.backup(conn) disk_db.close() @@ -18,9 +19,16 @@ def sqlite_db(): conn.close() def pytest_addoption(parser): - parser.addoption("--file_path", action="store", default="02_activities/assignments/DC_Cohort/assignment1.sql") + """Add the command-line option that selects the Assignment 2 SQL file.""" + parser.addoption("--report_path", action="store", default="test-results.json") + parser.addoption("--file_path", action="store", default="02_activities/assignments/DC_Cohort/assignment2.sql") def pytest_generate_tests(metafunc): + """Pass the selected assignment path into tests that request file_path.""" option_value = metafunc.config.option.file_path if 'file_path' in metafunc.fixturenames and option_value is not None: metafunc.parametrize("file_path", [option_value]) + +@pytest.fixture +def report_path(request): + return request.config.getoption("--report_path") diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py new file mode 100644 index 000000000..c73292f46 --- /dev/null +++ b/03_instructional_team/tests/test_assignment.py @@ -0,0 +1,615 @@ +import json +from datetime import datetime, timedelta, timezone +import time +import re +import sqlite3 +from collections import Counter +from pathlib import Path + + +PRODUCT_COLUMNS = ( + "product_id", "product_name", "product_size", + "product_category_id", "product_qty_type", +) + + +def load_queries(sql_file): + """Extract exactly one closed answer block per required question.""" + queries = {} + number = None + buffer = [] + for line in Path(sql_file).read_text().splitlines(): + stripped = line.strip() + start = re.fullmatch(r"--\s*QUERY\s+(\d+)\s*", stripped, re.I) + end = re.fullmatch(r"--\s*END\s+QUERY\s*", stripped, re.I) + if start: + if number is not None: + raise ValueError("An answer block was not closed before the next QUERY marker.") + number = int(start[1]) + if number not in range(1, 13) or number in queries: + raise ValueError(f"Unknown or duplicate QUERY number: {number}.") + buffer = [] + elif end: + if number is None: + raise ValueError("END QUERY has no matching QUERY marker.") + queries[number] = "\n".join(buffer).strip() + number = None + elif re.match(r"--\s*(QUERY|END\s+QUERY)\b", stripped, re.I): + raise ValueError(f"Invalid answer marker: {stripped}") + elif number is not None: + buffer.append(line) + if number is not None: + raise ValueError(f"QUERY {number} has no END QUERY marker.") + return [{"number": n, "query": queries.get(n, "")} for n in range(1, 13)] + + +def sql_tokens(query): + """Return unquoted SQL tokens, excluding comments and string literals.""" + pattern = r"--[^\n]*|/\*[\s\S]*?\*/|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`[^`]*`|\[[^]]*\]|[A-Za-z_][A-Za-z_0-9]*|[^\s]" + return [part.upper() for part in re.findall(pattern, query) + if not part.startswith(("--", "/*", "'", '\"', "`", "["))] + + +def technique_feedback(number, query): + """Confirm required syntax independently from result correctness.""" + tokens = sql_tokens(query) + pairs = set(zip(tokens, tokens[1:])) + requirements = {1: ("COALESCE", "("), 4: ("COUNT", "("), + 5: ("SUBSTR", "("), 8: ("CROSS", "JOIN"), + 10: ("INSERT", "INTO"), 11: ("DELETE", "FROM")} + required = requirements.get(number) + if required and required not in pairs: + return f"The required SQL technique ({required[0]}) was not found." + if number in (2, 3, 4) and ("OVER", "(") not in pairs: + return "Use a window function for this answer." + if number == 6 and "REGEXP" not in tokens: + return "Use REGEXP as requested." + if number == 7 and "UNION" not in tokens: + return "Use UNION as requested." + if number == 12 and "UPDATE" not in tokens: + return "Use UPDATE as requested." + return None + + +def register_functions(conn, clock=None): + """Add the REGEXP function required by Assignment 2 to SQLite.""" + # Python's SQLite does not include REGEXP by default, but Assignment 2 Question 6 requires it. + conn.create_function( + "REGEXP", 2, + lambda pattern, value: value is not None and re.search(pattern, str(value)) is not None, + ) + + if clock is not None: + conn.create_function("current_timestamp", 0, lambda: clock[0]) + conn.create_function("current_date", 0, lambda: clock[0].split()[0]) + conn.create_function("current_time", 0, lambda: clock[0].split()[1]) + # Delegate SQLite date semantics while making 'now' deterministic. + for name in ("date", "time", "datetime", "strftime", "julianday", "unixepoch"): + def date_function(*args, function=name): + values = tuple(clock[0] if arg == "now" else arg for arg in args) + if not values and function != "strftime": + values = (clock[0],) + placeholders = ",".join("?" for _ in values) + delegate = sqlite3.connect(":memory:") + try: + return delegate.execute(f"SELECT {function}({placeholders})", values).fetchone()[0] + finally: + delegate.close() + conn.create_function(name, -1, date_function) + + +def clone_database(source, clock=None): + """Create an isolated in-memory copy of the course database.""" + clone = sqlite3.connect(":memory:") + clone.row_factory = sqlite3.Row + source.backup(clone) + register_functions(clone, clock) + clone.set_authorizer(lambda action, *_: sqlite3.SQLITE_DENY + if action in (sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH) + else sqlite3.SQLITE_OK) + return clone + + +def run_query(conn, query): + """Execute SQL statements without splitting quoted semicolons or comments.""" + deadline = time.monotonic() + 10 + conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000) + cursor = conn.cursor() + buffer = "" + try: + for character in query: + buffer += character + if character == ";" and sqlite3.complete_statement(buffer): + if any(token != ";" for token in sql_tokens(buffer)): + cursor.execute(buffer) + buffer = "" + if any(token != ";" for token in sql_tokens(buffer)): + cursor.execute(buffer) + return [dict(row) for row in cursor.fetchall()] + finally: + conn.set_progress_handler(None, 0) + + +def fetch(conn, query, parameters=()): + """Run an internal grading query and return its rows as dictionaries.""" + return [dict(row) for row in conn.execute(query, parameters).fetchall()] + + +def normalize_value(value): + """Normalize floating-point values so result comparisons are reliable.""" + return round(value, 6) if isinstance(value, float) else value + + +def count_result_rows(rows, keys): + """Count normalized result rows using only the requested columns.""" + return Counter(tuple(normalize_value(row[key]) for key in keys) for row in rows) + + +def extract_result_rows(rows, columns, last_fallback=False): + """Extract comparable columns from student results using accepted column names.""" + if not rows: + return Counter() + actual_keys = [] + lower_keys = {key.lower(): key for key in rows[0]} + for index, choices in enumerate(columns): + key = next((lower_keys[name.lower()] for name in choices if name.lower() in lower_keys), None) + if key is None and last_fallback and index == len(columns) - 1: + key = list(rows[0])[-1] + if key is None: + return None + actual_keys.append(key) + return Counter(tuple(normalize_value(row[key]) for key in actual_keys) for row in rows) + + +def grading_outcome(passed, success, failure): + """Return a grading status and the matching feedback message.""" + return ("correct", success) if passed else ("incorrect", failure) + + +def grade_question_1(rows, reference): + """Grade the COALESCE product-description answer.""" + expected = fetch(reference, """ + SELECT product_name || ', ' || COALESCE(product_size, '') || + ' (' || COALESCE(product_qty_type, 'unit') || ')' AS value + FROM product + """) + # Each expected row contains one complete product description. + expected_descriptions = [] + for row in expected: + expected_descriptions.append(row["value"]) + + # The student must also return one description column per row. + has_one_column_per_row = bool(rows) + for row in rows: + if len(row) != 1: + has_one_column_per_row = False + break + + actual_descriptions = None + if has_one_column_per_row: + actual_descriptions = [] + for row in rows: + values = list(row.values()) + description = values[0] + actual_descriptions.append(normalize_value(description)) + + # Count descriptions so duplicates matter, while row order does not. + answers_match = False + if actual_descriptions is not None: + actual_counts = Counter(actual_descriptions) + expected_counts = Counter(expected_descriptions) + answers_match = actual_counts == expected_counts + + return grading_outcome( + answers_match, + "All product descriptions and NULL replacements are correct.", + "The formatted product descriptions do not match the expected results.", + ) + + +def grade_question_2(rows, reference): + """Grade the ascending customer visit-number answer.""" + actual = extract_result_rows( + rows, (("customer_id",), ("market_date",), ("visit_number",)), True + ) + full = fetch(reference, """ + SELECT customer_id, market_date, + DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number + FROM customer_purchases WHERE market_date < '2022-04-29' + """) + unique = fetch(reference, """ + SELECT customer_id, market_date, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number + FROM (SELECT DISTINCT customer_id, market_date FROM customer_purchases + WHERE market_date < '2022-04-29') + """) + keys = ("customer_id", "market_date", "visit_number") + accepted = [count_result_rows(result, keys) for result in (full, unique)] + return grading_outcome( + actual in accepted, + "Visit dates are numbered correctly for each customer.", + "The visit numbers or returned dates are incorrect.", + ) + + +def grade_question_3(rows, reference): + """Grade the most-recent customer visit answer.""" + actual = extract_result_rows( + rows, (("customer_id",), ("market_date",), ("visit_number",)), True + ) + full = fetch(reference, """ + SELECT customer_id, market_date, 1 AS visit_number + FROM customer_purchases AS p + WHERE market_date = (SELECT MAX(market_date) FROM customer_purchases + WHERE customer_id = p.customer_id) + """) + unique = fetch(reference, """ + SELECT customer_id, MAX(market_date) AS market_date, 1 AS visit_number + FROM customer_purchases GROUP BY customer_id + """) + keys = ("customer_id", "market_date", "visit_number") + accepted = [count_result_rows(result, keys) for result in (full, unique)] + return grading_outcome( + actual in accepted, + "Only each customer's most recent visit is returned.", + "The result does not contain only each customer's most recent visit.", + ) + + +def grade_question_4(rows, reference): + """Grade the customer product-purchase count answer.""" + columns = ( + ("customer_id",), ("vendor_id",), ("product_id",), + ("market_date",), ("transaction_time",), ("product_purchase_count",), + ) + actual = extract_result_rows(rows, columns, True) + fixed = fetch(reference, """ + SELECT customer_id, vendor_id, product_id, market_date, transaction_time, + COUNT(*) OVER (PARTITION BY customer_id, product_id) AS product_purchase_count + FROM customer_purchases WHERE market_date < '2022-04-29' + """) + running = fetch(reference, """ + SELECT customer_id, vendor_id, product_id, market_date, transaction_time, + COUNT(*) OVER (PARTITION BY customer_id, product_id + ORDER BY market_date, transaction_time + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS product_purchase_count + FROM customer_purchases WHERE market_date < '2022-04-29' + """) + keys = tuple(item[0] for item in columns) + accepted = [count_result_rows(result, keys) for result in (fixed, running)] + return grading_outcome( + actual in accepted, + "The purchase count is correct for every customer and product.", + "The purchase counts or returned rows are incorrect.", + ) + + +def grade_question_5(rows, reference): + """Grade the product-name description extraction answer.""" + actual = extract_result_rows(rows, (("product_name",), ("description",))) + expected = fetch(reference, """ + SELECT product_name, + CASE WHEN INSTR(product_name, '-') > 0 + THEN TRIM(SUBSTR(product_name, INSTR(product_name, '-') + 1)) + ELSE NULL END AS description + FROM product + """) + return grading_outcome( + actual == count_result_rows(expected, ("product_name", "description")), + "Product descriptions are extracted and trimmed correctly.", + "The descriptions do not match the text after each product-name hyphen.", + ) + + +def grade_question_6(rows, reference): + """Grade the numeric product-size REGEXP answer.""" + actual = extract_result_rows(rows, (("product_size",),)) + if actual is None and rows and all(len(row) == 1 for row in rows): + actual = Counter((normalize_value(next(iter(row.values()))),) for row in rows) + expected = fetch( + reference, "SELECT product_size FROM product WHERE product_size REGEXP '[0-9]'" + ) + return grading_outcome( + actual == count_result_rows(expected, ("product_size",)), + "Only product sizes containing a number are returned.", + "The result does not match all product sizes containing a number.", + ) + + +def grade_question_7(rows, reference): + """Grade the highest and lowest market-sales answer.""" + actual = extract_result_rows(rows, (("market_date",), ("sales", "total_sales", "revenue")), True) + expected = fetch(reference, """ + WITH daily AS ( + SELECT market_date, ROUND(SUM(quantity * cost_per_quantity), 2) AS sales + FROM customer_purchases GROUP BY market_date + ) + SELECT market_date, sales FROM daily + WHERE sales = (SELECT MIN(sales) FROM daily) + OR sales = (SELECT MAX(sales) FROM daily) + """) + if rows and all(len(row) == 1 for row in rows): + actual_dates = Counter(next(iter(row.values())) for row in rows) + return grading_outcome(actual_dates == Counter(row["market_date"] for row in expected), + "The highest and lowest sales dates are correct.", + "The highest and lowest sales dates are incorrect.") + if actual != count_result_rows(expected, ("market_date", "sales")) and rows: + for column in rows[0]: + if column == "market_date": + continue + candidate = extract_result_rows(rows, (("market_date",), (column,))) + if candidate == count_result_rows(expected, ("market_date", "sales")): + actual = candidate + break + return grading_outcome( + actual == count_result_rows(expected, ("market_date", "sales")), + "The highest and lowest sales dates and totals are correct.", + "The result does not match the highest and lowest sales dates.", + ) + + +def grade_question_8(rows, reference): + """Grade the possible vendor revenue answer.""" + actual = extract_result_rows( + rows, + (("vendor_name",), ("product_name",), ("possible_revenue", "sum(price)", "revenue")), + True, + ) + expected = fetch(reference, """ + SELECT vendor_name, product_name, SUM(price) AS possible_revenue + FROM ( + SELECT DISTINCT vendor_name, product_name, 5 * original_price AS price + FROM vendor_inventory JOIN vendor USING (vendor_id) JOIN product USING (product_id) + ) + CROSS JOIN (SELECT DISTINCT customer_id FROM customer) + GROUP BY vendor_name, product_name + """) + return grading_outcome( + actual == count_result_rows(expected, ("vendor_name", "product_name", "possible_revenue")), + "Possible revenue is correct for every vendor and product.", + "The possible revenue does not match five items per customer.", + ) + + +SELECT_GRADERS = { + 1: grade_question_1, + 2: grade_question_2, + 3: grade_question_3, + 4: grade_question_4, + 5: grade_question_5, + 6: grade_question_6, + 7: grade_question_7, + 8: grade_question_8, +} + + +def grade_select(number, rows, reference): + """Dispatch Questions 1 through 8 to their grading functions.""" + grader = SELECT_GRADERS.get(number) + if grader is None: + return "incorrect", "This query number is not part of Assignment 2." + return grader(rows, reference) + + +def product_units_state(conn): + """Return the product_units table structure and data when the table exists.""" + exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='product_units'" + ).fetchone() + if not exists: + return None + columns = [row["name"] for row in fetch(conn, "PRAGMA table_info(product_units)")] + rows = fetch(conn, "SELECT * FROM product_units") + try: + identities = fetch(conn, "SELECT rowid AS identity FROM product_units") + rowids = [row["identity"] for row in identities] + except sqlite3.OperationalError: + rowids = None + return {"columns": columns, "rows": rows, "rowids": rowids} + + +def count_table_rows(rows, columns): + """Convert selected row values into a countable form for state comparisons.""" + return Counter( + tuple(normalize_value(row[column]) for column in columns) + for row in rows + ) + + +def timestamp_value(value): + timestamp = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if timestamp.tzinfo is not None: + timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None) + return timestamp + + +def valid_timestamp(value, context): + try: + return abs((timestamp_value(value) - timestamp_value(context["execution_time"])).total_seconds()) <= 5 + except (ValueError, TypeError): + return False + + +def grade_question_9(conn, reference, context, before): + state = product_units_state(conn) + required = set(PRODUCT_COLUMNS) | {"snapshot_timestamp"} + expected = fetch(reference, "SELECT * FROM product WHERE product_qty_type='unit'") + passed = (state is not None and set(state["columns"]) == required + and count_table_rows(state["rows"], PRODUCT_COLUMNS) == count_table_rows(expected, PRODUCT_COLUMNS) + and all(valid_timestamp(row["snapshot_timestamp"], context) for row in state["rows"])) + return grading_outcome(passed, "Unit products and current snapshot timestamps are correct.", + "Check the columns, unit products, and current snapshot timestamps.") + + +def grade_question_10(conn, reference, context, before): + state = product_units_state(conn) + if before is None or state is None or set(state["columns"]) != set(before["columns"]): + return "incorrect", "The product_units schema changed or is missing." + columns = before["columns"] + old = count_table_rows(before["rows"], columns) + new = count_table_rows(state["rows"], columns) + added = new - old + if old - new or sum(added.values()) != 1: + return "incorrect", "Insert exactly one row without changing existing rows." + row = dict(zip(columns, next(iter(added)))) + passed = (row["product_id"] is not None and row["product_category_id"] is not None + and isinstance(row["product_name"], str) and bool(row["product_name"].strip()) + and isinstance(row["product_qty_type"], str) and bool(row["product_qty_type"].strip()) + and valid_timestamp(row["snapshot_timestamp"], context)) + if passed: + previous_copies = [existing for existing in before["rows"] + if existing["product_id"] == row["product_id"]] + passed = all(timestamp_value(row["snapshot_timestamp"]) >= timestamp_value(existing["snapshot_timestamp"]) + for existing in previous_copies) + # Save the inserted row for Q11 so we can check that it survives the deletion. + if passed: + context["inserted_row"] = row + if before["rowids"] is not None and state["rowids"] is not None: + added_ids = set(state["rowids"]) - set(before["rowids"]) + if len(added_ids) == 1: + context["inserted_rowid"] = added_ids.pop() + return grading_outcome(passed, "One product with a current timestamp was inserted.", + "Check the inserted product fields and updated timestamp.") + + +def grade_question_11(conn, reference, context, before): + state = product_units_state(conn) + inserted = context.get("inserted_row") + if before is None or state is None or inserted is None: + return "blocked", "A valid preceding insertion is needed to assess the deletion." + columns = before["columns"] + if set(columns) != set(state["columns"]): + return "incorrect", "Deleting a record must preserve the table schema." + before_rows = count_table_rows(before["rows"], columns) + after_rows = count_table_rows(state["rows"], columns) + inserted_key = tuple(normalize_value(inserted[column]) for column in columns) + candidates = before_rows.copy() + candidates.subtract({inserted_key: 1}) + old_rows = [dict(zip(columns, key)) for key, count in candidates.items() + if count > 0 and key[columns.index("product_id")] == inserted["product_id"]] + if not old_rows: + return "manual_review", "The inserted product has no older copy; the deletion needs instructor review." + removed = before_rows - after_rows + if after_rows - before_rows or sum(removed.values()) != 1: + return "incorrect", "Remove exactly the older copy and retain the newer copy and other rows." + oldest = min(timestamp_value(row["snapshot_timestamp"]) for row in old_rows) + removed_row = dict(zip(columns, next(iter(removed)))) + passed = (removed_row["product_id"] == inserted["product_id"] + and timestamp_value(removed_row["snapshot_timestamp"]) == oldest + and after_rows[inserted_key] >= 1) + if context.get("inserted_rowid") is not None and state["rowids"] is not None: + passed = passed and context["inserted_rowid"] in state["rowids"] + return grading_outcome(passed, "Only the older copy was removed; the inserted copy remains.", + "Retain the inserted copy and remove only the older copy.") + + +def grade_question_12(conn, reference, context, before): + state = product_units_state(conn) + if before is None or state is None or set(state["columns"]) != set(before["columns"]) | {"current_quantity"}: + return "incorrect", "Add current_quantity while preserving the existing table columns." + if count_table_rows(before["rows"], before["columns"]) != count_table_rows(state["rows"], before["columns"]): + return "incorrect", "UPDATE must preserve all existing rows and non-quantity values." + for row in state["rows"]: + latest = fetch(reference, """ + SELECT COALESCE(quantity, 0) AS quantity FROM vendor_inventory + WHERE product_id=? AND market_date=( + SELECT MAX(market_date) FROM vendor_inventory WHERE product_id=?) + """, (row["product_id"], row["product_id"])) + allowed = {normalize_value(item["quantity"]) for item in latest} or {0} + if normalize_value(row["current_quantity"]) not in allowed: + return "incorrect", f"Product {row['product_id']} does not have its latest quantity." + return "correct", "All rows remain and current_quantity matches the latest inventory." + + +MUTATION_GRADERS = { + 9: grade_question_9, + 10: grade_question_10, + 11: grade_question_11, + 12: grade_question_12, +} + + +def grade_mutation(number, conn, reference, context, before): + """Dispatch Questions 9 through 12 to their grading functions.""" + grader = MUTATION_GRADERS.get(number) + if grader is None: + return "incorrect", "This query number is not part of Assignment 2." + return grader(conn, reference, context, before) + + +def build_result(number, query, status, feedback, rows=None, error=None): + """Build one consistently shaped entry for the JSON grading report.""" + return { + "number": number, + "query": query, + "status": status, + "feedback": feedback, + "result": (rows or [])[:3], + "error": str(error) if error is not None else None, + } + + +def test_assignment(sqlite_db, file_path, report_path): + """Provide the pytest entry point for grading the submitted assignment file.""" + run_assignment(sqlite_db, file_path, report_path) + + +def run_assignment(sqlite_db, file_path, report_path="test-results.json"): + """Write complete feedback, including malformed submissions and dependencies.""" + results = [] + reference = mutations = None + # Real grading can give Q9 and Q10 the same timestamp, so we control the clock and deliberately advance it. + clock = [datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0).isoformat(" ")] + start = datetime.fromisoformat(clock[0]) + context = {} + statuses = {} + try: + if Path(file_path).name.lower() != "assignment2.sql": + raise ValueError("This grader only supports Assignment 2.") + answers = load_queries(file_path) + reference = clone_database(sqlite_db) + mutations = clone_database(sqlite_db, clock) + for parsed in answers: + number, query = parsed["number"], parsed["query"] + rows, error = [], None + conn = None + try: + if not sql_tokens(query): + status, feedback = "missing", "No SQL answer was submitted." + elif number >= 10 and statuses.get(9) != "correct": + status, feedback = "blocked", "Question 9 must create a valid snapshot first." + elif number == 11 and statuses.get(10) != "correct": + status, feedback = "blocked", "Question 10 must insert a valid product first." + elif number == 12 and statuses.get(11) not in ("correct", "manual_review"): + status, feedback = "blocked", "Question 11 must preserve the intended product rows first." + else: + # Q9 starts at zero; each later mutation advances one second so older rows are identifiable. + clock[0] = (start + timedelta(seconds=max(0, number - 9))).isoformat(" ") + context["execution_time"] = clock[0] + conn = clone_database(sqlite_db, clock) if number < 9 else mutations + before = product_units_state(conn) if number >= 10 else None + rows = run_query(conn, query) + if number < 9: + status, feedback = grade_select(number, rows, reference) + else: + status, feedback = grade_mutation(number, conn, reference, context, before) + technique = technique_feedback(number, query) + if status == "correct" and technique: + status, feedback = "incorrect", technique + except Exception as exception: + status, feedback, error = "error", "The SQL could not be executed or assessed.", exception + finally: + if conn is not None and number < 9: + conn.close() + statuses[number] = status + results.append(build_result(number, query, status, feedback, rows, error)) + except Exception as exception: + results = [build_result(n, "", "error", "Submission could not be parsed or loaded.", error=exception) + for n in range(1, 13)] + finally: + if reference is not None: + reference.close() + if mutations is not None: + mutations.close() + Path(report_path).write_text(json.dumps(results, indent=2)) + return results diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py new file mode 100644 index 000000000..0dbe453f0 --- /dev/null +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -0,0 +1,269 @@ +"""Check that the autograder makes the right marking decisions. + +Public checks cover runner safety without exposing answers. When the private staff +rubric is supplied through A2_SOLUTION_RUBRIC, the same suite also creates correct +and deliberately wrong submissions to test every marking decision end to end. +All sample submissions and reports are created in temporary folders. +""" +from pathlib import Path +import hashlib +import json +import os +import re +import runpy +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +# Find the repository root, regardless of where this test file is launched from. +REPO = Path(__file__).resolve().parents[2] +# Load the real grader functions so these tests exercise the code used for marking. +G = runpy.run_path(str(Path(__file__).with_name("test_assignment.py"))) +SOLUTION_RUBRIC = os.environ.get("A2_SOLUTION_RUBRIC") +requires_private_solution = unittest.skipUnless( + SOLUTION_RUBRIC, + "Set A2_SOLUTION_RUBRIC to run private solution-based regression tests.", +) + + +class GraderRegressionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Prepare the course database and optional private answers once for all tests.""" + cls.path = REPO / "05_src/sql/farmersmarket.db" + # Save a fingerprint of the database so we can detect accidental changes. + cls.original = hashlib.sha256(cls.path.read_bytes()).hexdigest() + # Read-only access protects the original; the grader works on copies. + cls.source = sqlite3.connect(f"file:{cls.path}?mode=ro", uri=True) + cls.blocks = [] + cls.valid = {} + if SOLUTION_RUBRIC: + # The private rubric includes explanations and multiple valid answers. + rubric = Path(SOLUTION_RUBRIC).read_text() + cls.blocks = re.findall(r"```\n(.*?)```", rubric, re.S) + # These positions pick one correct answer for each of the 12 questions. + cls.valid = dict(zip(range(1, 13), [cls.blocks[i].strip() for i in + (1, 2, 4, 6, 8, 9, 10, 11, 12, 13, 14, 16)])) + + @classmethod + def tearDownClass(cls): + """Close the database and confirm that testing did not change the original file.""" + cls.source.close() + assert hashlib.sha256(cls.path.read_bytes()).hexdigest() == cls.original + + def run_submission(self, changes=None, text=None): + """Make a temporary submission, run the grader, and return its feedback by question.""" + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + submission = folder / "assignment2.sql" + # Start with correct answers and replace only the questions being tested. + # Supplying raw text instead lets us test missing or broken query markers. + answers = self.valid | (changes or {}) + if text is None: + if not self.valid: + raise RuntimeError("Private solution rubric is required for this test.") + text = "\n".join(f"--QUERY {n}\n{sql}\n--END QUERY" for n, sql in answers.items()) + submission.write_text(text) + report = folder / "report.json" + # Plant an old report first: every run must replace it with fresh feedback. + report.write_text('[{"stale": true}]') + G["run_assignment"](self.source, submission, report) + results = json.loads(report.read_text()) + # Every report must account for all 12 questions, even if answers are missing. + self.assertEqual([r["number"] for r in results], list(range(1, 13))) + return {r["number"]: r for r in results} + + @requires_private_solution + def test_all_instructor_answers_pass_without_timestamp_sleep(self): + """Correct instructor answers should all pass, even when run immediately in sequence.""" + self.assertTrue(all(r["status"] == "correct" for r in self.run_submission().values())) + + @requires_private_solution + def test_wrong_answers_are_rejected_with_valid_prerequisites(self): + """Give each question a wrong answer while keeping the other answers correct.""" + wrong = { + 1: 'SELECT product_name FROM product', + 2: 'SELECT customer_id,market_date,99 AS visit_number FROM customer_purchases', + 3: 'SELECT customer_id,market_date,99 AS visit_number FROM customer_purchases', + 4: 'SELECT *,99 AS product_purchase_count FROM customer_purchases', + 5: 'SELECT product_name,NULL AS description FROM product', + 6: 'SELECT * FROM product', + 7: 'SELECT market_date,0 AS sales FROM customer_purchases GROUP BY market_date LIMIT 2', + 8: 'SELECT vendor_name,product_name,0 AS revenue FROM vendor CROSS JOIN product', + 9: self.valid[9].replace('WHERE product_qty_type = "unit"', ''), + 10: 'SELECT 1', 11: 'DELETE FROM product_units WHERE 1=0', + 12: 'ALTER TABLE product_units ADD current_quantity INT; UPDATE product_units SET current_quantity=-1;', + } + # Test one wrong answer at a time so an earlier failure cannot hide the result. + for number, sql in wrong.items(): + with self.subTest(question=number): + self.assertEqual(self.run_submission({number: sql})[number]['status'], 'incorrect') + + def test_missing_blank_and_comment_only_answers_are_explicit(self): + """An empty file, comments, or omitted questions must be reported as missing answers.""" + for text in ('', '--QUERY 1\n-- unanswered\n--END QUERY'): + with self.subTest(text=text): + self.assertTrue(all(r['status'] == 'missing' for r in self.run_submission(text=text).values())) + results = self.run_submission(text='--QUERY 1\nSELECT 1\n--END QUERY') + self.assertEqual(results[12]['status'], 'missing') + + def test_malformed_submissions_always_replace_stale_report(self): + """Broken query markers must produce fresh errors rather than reuse an old report.""" + for text in ('--QUERY 1\nSELECT 1', '--QUERY 1\nSELECT 1\n--END QUERY\n--QUERY 1\nSELECT 1\n--END QUERY', + '--QUERY 99\nSELECT 1\n--END QUERY', '--QUERY nope', '--END QUERY', + '--QUERY 1\n--QUERY 2'): + with self.subTest(text=text): + self.assertTrue(all(r['status'] == 'error' for r in self.run_submission(text=text).values())) + + @requires_private_solution + def test_valid_window_alternatives(self): + """Accept the different visit-numbering and purchase-count approaches allowed by A2.""" + for number, index in ((2, 3), (3, 5), (4, 7)): + with self.subTest(question=number): + self.assertEqual(self.run_submission({number: self.blocks[index]})[number]['status'], 'correct') + + def test_semicolons_inside_literals_and_comments(self): + """A semicolon in a product name or comment must not break a valid SQL command.""" + conn = G['clone_database'](self.source) + try: + rows = G['run_query'](conn, "CREATE TEMP TABLE sample(value); INSERT INTO sample VALUES ('Pie; special'); -- comment;\n SELECT * FROM sample;; -- trailing;\n;") + self.assertEqual(rows, [{'value': 'Pie; special'}]) + finally: + conn.close() + + @requires_private_solution + def test_techniques_cannot_be_satisfied_by_comments_or_literals(self): + """Mentioning REGEXP or UNION in comments or text does not count as using it.""" + sql = "SELECT * FROM product WHERE product_size GLOB '*[0-9]*' -- REGEXP" + self.assertEqual(self.run_submission({6: sql})[6]['status'], 'incorrect') + self.assertIsNotNone(G['technique_feedback'](7, "SELECT 'UNION' /* UNION */")) + self.assertIsNone(G['technique_feedback'](7, self.valid[7])) + + @requires_private_solution + def test_sales_alias_and_dates_only_are_accepted(self): + """Accept a different sales column name or an answer showing just the requested dates.""" + alias = self.valid[7].replace('sales, sales_rank_desc AS sales_rank', 'sales AS gross_income, sales_rank_desc AS sales_rank') + date_only = re.sub(r'SELECT market_date, sales, sales_rank_desc AS sales_rank', 'SELECT market_date', self.valid[7]) + for sql in (alias, date_only): + self.assertEqual(self.run_submission({7: sql})[7]['status'], 'correct') + + @requires_private_solution + def test_reordered_snapshot_columns(self): + """Putting the required product columns in a different order should still pass.""" + sql = self.valid[9].replace('p.*', 'product_name,product_id,product_size,product_category_id,product_qty_type') + self.assertEqual(self.run_submission({9: sql})[9]['status'], 'correct') + + @requires_private_solution + def test_invalid_and_stale_timestamps_fail(self): + """Reject timestamps that are meaningless text or dates far in the past.""" + for n, value in ((9, "'not a timestamp'"), (10, "'1900-01-01'")): + self.assertEqual(self.run_submission({n: self.valid[n].replace('CURRENT_TIMESTAMP', value)})[n]['status'], 'incorrect') + + @requires_private_solution + def test_current_datetime_forms_use_the_same_controlled_clock(self): + """Equivalent ways to get the current time must follow the same grading clock.""" + for expression in ("DATETIME('now')", "STRFTIME('%Y-%m-%d %H:%M:%S','now')"): + changes = {n: self.valid[n].replace('CURRENT_TIMESTAMP', expression) for n in (9, 10)} + results = self.run_submission(changes) + self.assertTrue(all(results[n]['status'] == 'correct' for n in (9, 10, 11))) + + @requires_private_solution + def test_inserted_copy_cannot_have_an_older_timestamp(self): + """A newly inserted copy must not be dated earlier than the original copy.""" + expression = "DATETIME((SELECT MIN(snapshot_timestamp) FROM product_units WHERE product_id=7), '-1 second')" + results = self.run_submission({10: self.valid[10].replace('CURRENT_TIMESTAMP', expression)}) + self.assertEqual(results[10]['status'], 'incorrect') + + @requires_private_solution + def test_new_product_is_accepted_and_ambiguous_delete_needs_review(self): + """Allow a new product; ask an instructor to assess deletion when no older copy exists.""" + results = self.run_submission({10: "INSERT INTO product_units VALUES (999,'New Pie','10 inch',3,'unit',CURRENT_TIMESTAMP)", + 11: 'DELETE FROM product_units WHERE 1=0'}) + self.assertEqual(results[10]['status'], 'correct') + self.assertEqual(results[11]['status'], 'manual_review') + self.assertEqual(results[12]['status'], 'correct') + + @requires_private_solution + def test_delete_must_keep_inserted_copy(self): + """Deleting both product copies must fail: the newer copy needs to remain.""" + self.assertEqual(self.run_submission({11: 'DELETE FROM product_units WHERE product_id=7'})[11]['status'], 'incorrect') + + @requires_private_solution + def test_equal_timestamps_allow_one_copy_to_survive(self): + """When timestamps tie, accept deleting the original row and reject deleting the newer row.""" + same = self.valid[10].replace('CURRENT_TIMESTAMP', '(SELECT MIN(snapshot_timestamp) FROM product_units WHERE product_id=7)') + one = 'DELETE FROM product_units WHERE rowid=(SELECT MIN(rowid) FROM product_units WHERE product_id=7)' + self.assertEqual(self.run_submission({10: same, 11: one})[11]['status'], 'correct') + self.assertEqual(self.run_submission({10: same})[11]['status'], 'incorrect') + newer = one.replace('MIN(rowid)', 'MAX(rowid)') + self.assertEqual(self.run_submission({10: same, 11: newer})[11]['status'], 'incorrect') + + @requires_private_solution + def test_update_cannot_delete_or_modify_other_values(self): + """Updating quantities must not erase products or change their other details.""" + for sql in ('ALTER TABLE product_units ADD current_quantity INT; DELETE FROM product_units;', + self.valid[12] + "; UPDATE product_units SET product_name='Changed';"): + self.assertEqual(self.run_submission({12: sql})[12]['status'], 'incorrect') + + def test_prerequisite_failure_is_distinguished_from_wrong_answer(self): + """If table creation fails, explain that later questions cannot be assessed yet.""" + text = '--QUERY 9\nCREATE TABLE product_units AS SELECT * FROM missing_table\n--END QUERY' + text += ''.join(f'\n--QUERY {n}\nSELECT 1;\n--END QUERY' for n in (10, 11, 12)) + results = self.run_submission(text=text) + self.assertEqual(results[9]['status'], 'error') + self.assertTrue(all(results[n]['status'] == 'blocked' for n in (10, 11, 12))) + + def test_sql_cannot_attach_disk_database(self): + """Student SQL must not open another database file and write to it.""" + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / 'disk.db' + text = f"--QUERY 1\nATTACH DATABASE '{target}' AS disk; CREATE TABLE disk.test(value);\n--END QUERY" + results = self.run_submission(text=text) + self.assertEqual(results[1]['status'], 'error') + self.assertFalse(target.exists()) + + def test_long_sql_is_interrupted(self): + """Stop an excessively long query instead of letting it run without a time limit.""" + conn = G['clone_database'](self.source) + # Pretend time has advanced past the limit, avoiding a real ten-second wait. + ticks = iter((0, 11)) + try: + with patch('time.monotonic', side_effect=lambda: next(ticks, 11)): + with self.assertRaises(sqlite3.OperationalError): + G['run_query'](conn, "WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<10000000) SELECT SUM(x) FROM n;") + finally: + conn.close() + + def test_runner_requires_one_file_and_reports_failures(self): + """Require one submission file and give explicit feedback for selection or runner failures.""" + runner = runpy.run_path(str(REPO / '.github/scripts/grade_assignments.py')) + with tempfile.TemporaryDirectory() as folder: + output = Path(folder) / 'combined.json' + calls = [] + # Stand in for pytest here so we can inspect which files the runner selects. + # The other tests exercise the actual grader; this one tests orchestration. + def fake_run(command, **kwargs): + calls.append(command) + report = Path(next(arg.split('=', 1)[1] for arg in command if arg.startswith('--report_path='))) + report.write_text(json.dumps([{'number': n} for n in range(1, 13)])) + return type('Result', (), {'returncode': 0})() + with patch('subprocess.run', fake_run): + self.assertEqual(runner['grade_files'](['DC/assignment2.sql'], output), 0) + self.assertEqual(len(calls), 1) + self.assertEqual(len(json.loads(output.read_text())), 1) + for files, message in (([], 'No changed'), + (['DC/assignment2.sql', 'Micro/assignment2.sql'], 'More than one')): + with self.subTest(files=files), patch('subprocess.run', fake_run): + calls.clear() + self.assertEqual(runner['grade_files'](files, output), 1) + self.assertFalse(calls) + self.assertIn(message, json.loads(output.read_text())[0]['runner_error']) + # Simulate a runner crash and check that an old success report is replaced. + with patch('subprocess.run', side_effect=RuntimeError('Runner unavailable')): + self.assertEqual(runner['grade_files'](['DC/assignment2.sql'], output), 1) + self.assertIn('Runner unavailable', json.loads(output.read_text())[0]['runner_error']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_assignment.py b/tests/test_assignment.py deleted file mode 100644 index 510b410a6..000000000 --- a/tests/test_assignment.py +++ /dev/null @@ -1,112 +0,0 @@ -import json -from pathlib import Path -import sqlite3 -import re - -def load_queries(sql_file): - lines = Path(sql_file).read_text().splitlines() - - queries = [] - current_query = None - buffer = [] - - for line in lines: - stripped = line.strip() - - # Start marker: -- QUERY - if stripped.lower().startswith("--query"): - if current_query is not None: - raise AssertionError("Nested QUERY blocks are not allowed") - - parts = stripped.split() - if len(parts) < 2: - raise AssertionError(f"Invalid QUERY marker: {line}") - - try: - current_query = int(parts[1]) - except ValueError: - raise AssertionError(f"Invalid QUERY number: {line}") - - buffer = [] - continue - - # End marker: -- END QUERY or -- END STATEMENT - if stripped.lower().startswith("--end"): - if current_query is None: - continue # ignore stray END - - query = "\n".join(buffer).strip().rstrip(";") - - queries.append({ - "number": current_query, - "query": query - }) - - current_query = None - buffer = [] - continue - - # Collect lines inside a query block - if current_query is not None: - buffer.append(line) - - if not queries: - raise AssertionError( - "No queries found. Use '-- QUERY ' and '-- END QUERY' markers." - ) - - return queries - -def run_query(conn, query): - cursor = conn.cursor() - try: - cursor.execute(query) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - except sqlite3.ProgrammingError as e: - if "one statement at a time" in str(e).lower(): - # fallback: handle multiple statements - statements = [s.strip() for s in query.split(";") if s.strip()] - - if len(statements) == 0: - return [] - - for stmt in statements[:-1]: - cursor.execute(stmt) - - cursor.execute(statements[-1]) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - else: - raise - -def test_assignment(sqlite_db, file_path): - run_assignment(sqlite_db, file_path) - -def run_assignment(sqlite_db, file_path): - with open("test-results.json", "w") as json_file: - queries = load_queries(file_path) - test_result = [] - for parsed_query in queries: - try: - rows = run_query(sqlite_db, parsed_query["query"]) - test_result.append({ - "number": parsed_query["number"], - "query": parsed_query["query"], - "result": rows[0:3], - "error": None - }) - except Exception as e: - test_result.append({ - "number": parsed_query["number"], - "query": parsed_query["query"], - "result": [], - "error": str(e) - }) - json.dump(test_result, json_file, indent=2) - - # The purpose of it to have report in the future in case DSI will want to go with unit testing style. - # assert True, "test execution query {} result {}".format(queries, test_result) - From 28e6196c6b2e98493435b4e854768acc656aa1e9 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 9 Oct 2026 15:08:14 -0400 Subject: [PATCH 02/19] Accept alternative Q5 description column names --- 03_instructional_team/tests/test_assignment.py | 10 ++++++++-- 03_instructional_team/tests/test_grader_regressions.py | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index c73292f46..d0492ac04 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -286,7 +286,6 @@ def grade_question_4(rows, reference): def grade_question_5(rows, reference): """Grade the product-name description extraction answer.""" - actual = extract_result_rows(rows, (("product_name",), ("description",))) expected = fetch(reference, """ SELECT product_name, CASE WHEN INSTR(product_name, '-') > 0 @@ -294,8 +293,15 @@ def grade_question_5(rows, reference): ELSE NULL END AS description FROM product """) + expected_rows = count_result_rows(expected, ("product_name", "description")) + # Students may name the description column differently; compare its values with each product. + matches = any( + extract_result_rows(rows, (("product_name",), (column,))) == expected_rows + for column in (rows[0] if rows else ()) + if column.lower() != "product_name" + ) return grading_outcome( - actual == count_result_rows(expected, ("product_name", "description")), + matches, "Product descriptions are extracted and trimmed correctly.", "The descriptions do not match the text after each product-name hyphen.", ) diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 0dbe453f0..8be05fdd7 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -140,6 +140,14 @@ def test_techniques_cannot_be_satisfied_by_comments_or_literals(self): self.assertIsNotNone(G['technique_feedback'](7, "SELECT 'UNION' /* UNION */")) self.assertIsNone(G['technique_feedback'](7, self.valid[7])) + @requires_private_solution + def test_description_alias_is_accepted(self): + """Accept correct Q5 descriptions when the student chooses a different column alias.""" + sql, replacements = re.subn(r'\bAS\s+description\b', 'AS product_description', + self.valid[5], flags=re.I) + self.assertEqual(replacements, 1) + self.assertEqual(self.run_submission({5: sql})[5]['status'], 'correct') + @requires_private_solution def test_sales_alias_and_dates_only_are_accepted(self): """Accept a different sales column name or an answer showing just the requested dates.""" From ab7519f805aaab910d80197cded24be57736b103 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 9 Oct 2026 15:45:32 -0400 Subject: [PATCH 03/19] Test Assignment 2 with mixed correct and incorrect answers --- .../assignments/DC_Cohort/assignment2.sql | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index 4079c18ae..758058261 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -23,8 +23,7 @@ Edit the appropriate columns -- you're making two edits -- and the NULL rows wil All the other rows will remain the same. */ --QUERY 1 - - +SELECT product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')' AS product_details FROM product; --END QUERY @@ -41,8 +40,7 @@ HINT: One of these approaches uses ROW_NUMBER() and one uses DENSE_RANK(). Filter the visits to dates before April 29, 2022. */ --QUERY 2 - - +SELECT customer_id, market_date, DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number FROM customer_purchases WHERE market_date < '2022-04-29'; --END QUERY @@ -53,8 +51,7 @@ only the customer’s most recent visit. HINT: Do not use the previous visit dates filter. */ --QUERY 3 - - +SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; --END QUERY @@ -66,8 +63,7 @@ You can make this a running count by including an ORDER BY within the PARTITION Filter the visits to dates before April 29, 2022. */ --QUERY 4 - - +SELECT cp.*, COUNT(*) OVER (PARTITION BY customer_id, product_id) AS product_purchase_count FROM customer_purchases cp WHERE market_date < '2022-04-29'; --END QUERY @@ -85,8 +81,7 @@ Remove any trailing or leading whitespaces. Don't just use a case statement for Hint: you might need to use INSTR(product_name,'-') to find the hyphens. INSTR will help split the column. */ --QUERY 5 - - +SELECT product_name, NULL AS product_description FROM product; --END QUERY @@ -94,8 +89,7 @@ Hint: you might need to use INSTR(product_name,'-') to find the hyphens. INSTR w /* 2. Filter the query to show any product_size value that contain a number with REGEXP. */ --QUERY 6 - - +SELECT product_size FROM product WHERE product_size REGEXP '[0-9]'; --END QUERY @@ -111,8 +105,7 @@ HINT: There are a possibly a few ways to do this query, but if you're struggling with a UNION binding them. */ --QUERY 7 - - +SELECT market_date, 0 AS total_sales FROM customer_purchases GROUP BY market_date LIMIT 2; --END QUERY @@ -132,8 +125,7 @@ How many customers are there (y). Before your final group by you should have the product of those two queries (x*y). */ --QUERY 8 - - +SELECT vendor_name, product_name, 0 AS possible_revenue FROM vendor CROSS JOIN product; --END QUERY @@ -145,8 +137,7 @@ It should use all of the columns from the product table, as well as a new column Name the timestamp column `snapshot_timestamp`. */ --QUERY 9 - - +CREATE TABLE product_units AS SELECT product.*, CURRENT_TIMESTAMP AS snapshot_timestamp FROM product WHERE product_qty_type = 'unit'; --END QUERY @@ -155,8 +146,7 @@ Name the timestamp column `snapshot_timestamp`. */ This can be any product you desire (e.g. add another record for Apple Pie). */ --QUERY 10 - - +INSERT INTO product_units SELECT product.*, CURRENT_TIMESTAMP AS snapshot_timestamp FROM product WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit'); --END QUERY @@ -167,8 +157,7 @@ This can be any product you desire (e.g. add another record for Apple Pie). */ HINT: If you don't specify a WHERE clause, you are going to have a bad time.*/ --QUERY 11 - - +DELETE FROM product_units WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit') AND snapshot_timestamp = (SELECT MIN(snapshot_timestamp) FROM product_units WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit')); --END QUERY @@ -191,8 +180,8 @@ Finally, make sure you have a WHERE statement to update the right row, When you have all of these components, you can run the update statement. */ --QUERY 12 - - +ALTER TABLE product_units ADD current_quantity INT; +UPDATE product_units SET current_quantity = COALESCE((SELECT quantity FROM vendor_inventory WHERE vendor_inventory.product_id = product_units.product_id ORDER BY market_date DESC LIMIT 1), 0); --END QUERY From f6890018847842b8181ab70c3952116ccd9989ab Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 9 Oct 2026 15:49:01 -0400 Subject: [PATCH 04/19] Correct Q5 to verify grading reruns on updates --- 02_activities/assignments/DC_Cohort/assignment2.sql | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index 758058261..c43509868 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -81,7 +81,11 @@ Remove any trailing or leading whitespaces. Don't just use a case statement for Hint: you might need to use INSTR(product_name,'-') to find the hyphens. INSTR will help split the column. */ --QUERY 5 -SELECT product_name, NULL AS product_description FROM product; +SELECT product_name, + CASE WHEN INSTR(product_name, '-') > 0 + THEN TRIM(SUBSTR(product_name, INSTR(product_name, '-') + 1)) + ELSE NULL END AS product_description +FROM product; --END QUERY From 9d83169f7a04a2f4c0d335966f6f1384d0ebea3d Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 9 Oct 2026 15:52:48 -0400 Subject: [PATCH 05/19] Remind A2 students to enable GitHub Actions in their forks --- 02_activities/assignments/DC_Cohort/Assignment2.md | 1 + 02_activities/assignments/Microcredential_Cohort/Assignment2.md | 1 + 2 files changed, 2 insertions(+) diff --git a/02_activities/assignments/DC_Cohort/Assignment2.md b/02_activities/assignments/DC_Cohort/Assignment2.md index be0eff3a5..47a854540 100644 --- a/02_activities/assignments/DC_Cohort/Assignment2.md +++ b/02_activities/assignments/DC_Cohort/Assignment2.md @@ -14,6 +14,7 @@ * Open a private window in your browser. Copy and paste the link to your pull request into the address bar. Make sure you can see your pull request properly. This helps the technical facilitator and learning support staff review your submission easily. Checklist: +- [ ] 🚨🚨🚨 After forking, open the **Actions** tab in your own SQL repository. If prompted, click **I understand my workflows, go ahead and enable them** before opening your assignment pull request. This enables automatic SQL feedback on your pull request. - [ ] Create a branch called `assignment-two`. - [ ] Ensure that the repository is public. - [ ] Review [the PR description guidelines](https://github.com/UofT-DSI/onboarding/blob/main/onboarding_documents/submissions.md#guidelines-for-pull-request-descriptions) and adhere to them. diff --git a/02_activities/assignments/Microcredential_Cohort/Assignment2.md b/02_activities/assignments/Microcredential_Cohort/Assignment2.md index 2c39be5ff..2ff945602 100644 --- a/02_activities/assignments/Microcredential_Cohort/Assignment2.md +++ b/02_activities/assignments/Microcredential_Cohort/Assignment2.md @@ -14,6 +14,7 @@ * Open a private window in your browser. Copy and paste the link to your pull request into the address bar. Make sure you can see your pull request properly. This helps the technical facilitator and learning support staff review your submission easily. Checklist: +- [ ] 🚨🚨🚨 After forking, open the **Actions** tab in your own SQL repository. If prompted, click **I understand my workflows, go ahead and enable them** before opening your assignment pull request. This enables automatic SQL feedback on your pull request. - [ ] Create a branch called `assignment-two`. - [ ] Ensure that the repository is public. - [ ] Review [the PR description guidelines](https://github.com/UofT-DSI/onboarding/blob/main/onboarding_documents/submissions.md#guidelines-for-pull-request-descriptions) and adhere to them. From 21a9272fbe5bdc32d38c71d243173a8f3be03478 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Thu, 15 Oct 2026 10:53:16 -0400 Subject: [PATCH 06/19] Clarify Assignment 2 output column names --- 02_activities/assignments/DC_Cohort/Assignment2.md | 12 ++++++++++++ .../Microcredential_Cohort/Assignment2.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/02_activities/assignments/DC_Cohort/Assignment2.md b/02_activities/assignments/DC_Cohort/Assignment2.md index 47a854540..d0254291a 100644 --- a/02_activities/assignments/DC_Cohort/Assignment2.md +++ b/02_activities/assignments/DC_Cohort/Assignment2.md @@ -74,6 +74,18 @@ Steps to complete this part of the assignment: ### Write SQL +**Column names for automatic feedback (Sections 2 and 3):** Keep the original names of existing database columns in your results, such as `customer_id`, `market_date`, and `product_name`. Use `AS` to name calculated columns as follows: + +| SQL question | Calculated column name | +|--------------|------------------------| +| Q2 and Q3: visit numbering | `visit_number` | +| Q4: purchase count | `product_purchase_count` | +| Q5: extracted description | `description` | +| Q7: total sales, if included | `sales` | +| Q8: possible revenue | `possible_revenue` | + +For example, name a calculated count with `COUNT(*) OVER (...) AS product_purchase_count`. Q1's combined product description may have any column name. For Q9-12, use the table and column names specified in the questions. + #### COALESCE 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! We tell them, no problem! We can produce a list with all of the appropriate details. diff --git a/02_activities/assignments/Microcredential_Cohort/Assignment2.md b/02_activities/assignments/Microcredential_Cohort/Assignment2.md index 2ff945602..54b46fb90 100644 --- a/02_activities/assignments/Microcredential_Cohort/Assignment2.md +++ b/02_activities/assignments/Microcredential_Cohort/Assignment2.md @@ -74,6 +74,18 @@ Steps to complete this part of the assignment: ### Write SQL +**Column names for automatic feedback (Sections 2 and 3):** Keep the original names of existing database columns in your results, such as `customer_id`, `market_date`, and `product_name`. Use `AS` to name calculated columns as follows: + +| SQL question | Calculated column name | +|--------------|------------------------| +| Q2 and Q3: visit numbering | `visit_number` | +| Q4: purchase count | `product_purchase_count` | +| Q5: extracted description | `description` | +| Q7: total sales, if included | `sales` | +| Q8: possible revenue | `possible_revenue` | + +For example, name a calculated count with `COUNT(*) OVER (...) AS product_purchase_count`. Q1's combined product description may have any column name. For Q9-12, use the table and column names specified in the questions. + #### COALESCE 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! We tell them, no problem! We can produce a list with all of the appropriate details. From e126332161213352d968483b90afee9d94578afa Mon Sep 17 00:00:00 2001 From: Daniel R Date: Thu, 15 Oct 2026 11:03:55 -0400 Subject: [PATCH 07/19] Accept Q4 answers without optional purchase details --- .../tests/test_assignment.py | 14 ++++--- .../tests/test_grader_regressions.py | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index d0492ac04..5a58d452a 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -258,10 +258,12 @@ def grade_question_3(rows, reference): def grade_question_4(rows, reference): """Grade the customer product-purchase count answer.""" - columns = ( - ("customer_id",), ("vendor_id",), ("product_id",), - ("market_date",), ("transaction_time",), ("product_purchase_count",), - ) + # Purchase details are optional, but validate them when students include them. + returned_names = {key.lower() for key in rows[0]} if rows else set() + keys = ["customer_id", "product_id", "market_date"] + keys.extend(key for key in ("vendor_id", "transaction_time") if key in returned_names) + keys.append("product_purchase_count") + columns = tuple((key,) for key in keys) actual = extract_result_rows(rows, columns, True) fixed = fetch(reference, """ SELECT customer_id, vendor_id, product_id, market_date, transaction_time, @@ -275,12 +277,12 @@ def grade_question_4(rows, reference): ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS product_purchase_count FROM customer_purchases WHERE market_date < '2022-04-29' """) - keys = tuple(item[0] for item in columns) accepted = [count_result_rows(result, keys) for result in (fixed, running)] return grading_outcome( actual in accepted, "The purchase count is correct for every customer and product.", - "The purchase counts or returned rows are incorrect.", + "Include customer_id, product_id, market_date, and a purchase count for every purchase row. " + "Check the counts and the filter to dates before April 29, 2022; vendor_id and transaction_time are optional.", ) diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 8be05fdd7..94e8028c7 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -132,6 +132,45 @@ def test_semicolons_inside_literals_and_comments(self): finally: conn.close() + def test_purchase_counts_allow_optional_purchase_details(self): + """Accept reduced Q4 output without losing duplicate-row or count validation.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + try: + conn.execute("CREATE TABLE customer_purchases " + "(customer_id, vendor_id, product_id, market_date, transaction_time)") + conn.executemany("INSERT INTO customer_purchases VALUES (?, ?, ?, ?, ?)", [ + (1, 2, 3, '2022-04-01', '09:00'), + (1, 2, 3, '2022-04-01', '10:00'), + ]) + rows = [dict(customer_id=1, product_id=3, market_date='2022-04-01', cnt=2) + for _ in range(2)] + grade = G['grade_question_4'] + self.assertEqual(grade(rows, conn)[0], 'correct') + count = "COUNT(*) OVER (PARTITION BY customer_id, product_id)" + projections = { + 'reordered columns': + f"{count} AS product_purchase_count, market_date, product_id, customer_id", + 'optional purchase details': + f"customer_id, product_id, market_date, vendor_id, transaction_time, {count} AS cnt", + 'alternative count alias': + f"customer_id, product_id, market_date, {count} AS total_purchases", + } + for case, projection in projections.items(): + with self.subTest(case=case): + selected = G['fetch']( + conn, f"SELECT {projection} FROM customer_purchases " + "WHERE market_date < '2022-04-29'" + ) + self.assertEqual(grade(selected, conn)[0], 'correct') + self.assertEqual(grade(rows[:1], conn)[0], 'incorrect') + self.assertEqual(grade([dict(row, cnt=99) for row in rows], conn)[0], 'incorrect') + self.assertEqual(grade([dict(row, vendor_id=99) for row in rows], conn)[0], 'incorrect') + self.assertEqual(grade([dict(row, transaction_time='wrong') for row in rows], conn)[0], + 'incorrect') + finally: + conn.close() + @requires_private_solution def test_techniques_cannot_be_satisfied_by_comments_or_literals(self): """Mentioning REGEXP or UNION in comments or text does not count as using it.""" From 1e7d3d4f0de983c40931aa87de7de282d144971f Mon Sep 17 00:00:00 2001 From: Daniel R Date: Thu, 15 Oct 2026 11:08:50 -0400 Subject: [PATCH 08/19] Allow descriptive query comments in assignment answers --- .../tests/test_assignment.py | 2 -- .../tests/test_grader_regressions.py | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index 5a58d452a..cccbe85fe 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -34,8 +34,6 @@ def load_queries(sql_file): raise ValueError("END QUERY has no matching QUERY marker.") queries[number] = "\n".join(buffer).strip() number = None - elif re.match(r"--\s*(QUERY|END\s+QUERY)\b", stripped, re.I): - raise ValueError(f"Invalid answer marker: {stripped}") elif number is not None: buffer.append(line) if number is not None: diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 94e8028c7..36d2ef7d8 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -111,11 +111,28 @@ def test_missing_blank_and_comment_only_answers_are_explicit(self): def test_malformed_submissions_always_replace_stale_report(self): """Broken query markers must produce fresh errors rather than reuse an old report.""" for text in ('--QUERY 1\nSELECT 1', '--QUERY 1\nSELECT 1\n--END QUERY\n--QUERY 1\nSELECT 1\n--END QUERY', - '--QUERY 99\nSELECT 1\n--END QUERY', '--QUERY nope', '--END QUERY', + '--QUERY 99\nSELECT 1\n--END QUERY', '--END QUERY', '--QUERY 1\n--QUERY 2'): with self.subTest(text=text): self.assertTrue(all(r['status'] == 'error' for r in self.run_submission(text=text).values())) + def test_query_comments_are_not_answer_markers(self): + """Only complete marker lines delimit answers; descriptive comments remain ordinary SQL.""" + comments = ('-- query to count purchases', '-- QUERY results below', + '-- query 1 explains this approach', '-- end query explanation', '--QUERY nope') + with tempfile.TemporaryDirectory() as folder: + submission = Path(folder) / 'assignment2.sql' + sql = '\n'.join((*comments, 'SELECT 1;', *comments)) + submission.write_text('-- query overview\n--QUERY 1\n' + sql + + '\n--END QUERY\n-- query closing notes') + queries = G['load_queries'](submission) + self.assertEqual(queries[0], {'number': 1, 'query': sql}) + self.assertTrue(all(not item['query'] for item in queries[1:])) + results = self.run_submission(text=submission.read_text()) + self.assertEqual(results[1]['status'], 'incorrect') + self.assertTrue(all(item['status'] == 'missing' + for number, item in results.items() if number != 1)) + @requires_private_solution def test_valid_window_alternatives(self): """Accept the different visit-numbering and purchase-count approaches allowed by A2.""" From a3cd52820b24b4ee67936cd39c880fb0e4f063f5 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:00:54 -0400 Subject: [PATCH 09/19] Accept Q4 running counts that group same-day purchases --- .../tests/test_assignment.py | 12 +++++- .../tests/test_grader_regressions.py | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index cccbe85fe..cbaff9adb 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -275,7 +275,17 @@ def grade_question_4(rows, reference): ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS product_purchase_count FROM customer_purchases WHERE market_date < '2022-04-29' """) - accepted = [count_result_rows(result, keys) for result in (fixed, running)] + # SQLite's default RANGE frame counts tied ordering values together. Both + # date-only ordering and date/time ordering are valid running-count answers. + tied_running = [] + for ordering in ("market_date", "market_date, transaction_time"): + tied_running.append(fetch(reference, f""" + SELECT customer_id, vendor_id, product_id, market_date, transaction_time, + COUNT(*) OVER (PARTITION BY customer_id, product_id + ORDER BY {ordering}) AS product_purchase_count + FROM customer_purchases WHERE market_date < '2022-04-29' + """)) + accepted = [count_result_rows(result, keys) for result in (fixed, running, *tied_running)] return grading_outcome( actual in accepted, "The purchase count is correct for every customer and product.", diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 36d2ef7d8..2513747ef 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -140,6 +140,43 @@ def test_valid_window_alternatives(self): with self.subTest(question=number): self.assertEqual(self.run_submission({number: self.blocks[index]})[number]['status'], 'correct') + def test_running_purchase_counts_accept_date_peers(self): + """Accept date-only running counts while rejecting wrong counts and missing purchases.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + try: + conn.execute("CREATE TABLE customer_purchases " + "(customer_id, vendor_id, product_id, market_date, transaction_time)") + conn.executemany("INSERT INTO customer_purchases VALUES (?, ?, ?, ?, ?)", [ + (1, 2, 3, '2022-04-01', '09:00'), + (1, 2, 3, '2022-04-01', '09:00'), + (1, 2, 3, '2022-04-01', '10:00'), + (1, 2, 3, '2022-04-02', '09:00'), + (1, 2, 4, '2022-04-01', '09:00'), + (2, 2, 3, '2022-04-01', '09:00'), + (1, 2, 3, '2022-04-29', '09:00'), + ]) + for ordering, expected in ( + ('market_date', [3, 3, 3, 4, 1, 1]), + ('market_date, transaction_time', [2, 2, 3, 4, 1, 1]), + ): + for details in ('customer_id, product_id, market_date', '*'): + with self.subTest(ordering=ordering, details=details): + rows = G['fetch'](conn, f""" + SELECT {details}, COUNT(*) OVER ( + PARTITION BY customer_id, product_id ORDER BY {ordering} + ) AS product_purchase_count + FROM customer_purchases WHERE market_date < '2022-04-29' + ORDER BY rowid + """) + self.assertEqual([row['product_purchase_count'] for row in rows], expected) + self.assertEqual(G['grade_question_4'](rows, conn)[0], 'correct') + self.assertEqual(G['grade_question_4'](rows[:-1], conn)[0], 'incorrect') + rows[0]['product_purchase_count'] = 99 + self.assertEqual(G['grade_question_4'](rows, conn)[0], 'incorrect') + finally: + conn.close() + def test_semicolons_inside_literals_and_comments(self): """A semicolon in a product name or comment must not break a valid SQL command.""" conn = G['clone_database'](self.source) From 387711b52e026d2591f571cf2cc60bce7731417d Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:09:05 -0400 Subject: [PATCH 10/19] Accept Q3 latest visits without a displayed rank column --- .../tests/test_assignment.py | 9 ++++---- .../tests/test_grader_regressions.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index cbaff9adb..9e3c73f3d 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -232,9 +232,11 @@ def grade_question_2(rows, reference): def grade_question_3(rows, reference): """Grade the most-recent customer visit answer.""" - actual = extract_result_rows( - rows, (("customer_id",), ("market_date",), ("visit_number",)), True - ) + # The rank can be used to filter a subquery without appearing in its output. + keys = ("customer_id", "market_date") + if rows and len(rows[0]) > 2: + keys += ("visit_number",) + actual = extract_result_rows(rows, tuple((key,) for key in keys), True) full = fetch(reference, """ SELECT customer_id, market_date, 1 AS visit_number FROM customer_purchases AS p @@ -245,7 +247,6 @@ def grade_question_3(rows, reference): SELECT customer_id, MAX(market_date) AS market_date, 1 AS visit_number FROM customer_purchases GROUP BY customer_id """) - keys = ("customer_id", "market_date", "visit_number") accepted = [count_result_rows(result, keys) for result in (full, unique)] return grading_outcome( actual in accepted, diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 2513747ef..7b31d1bc8 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -140,6 +140,27 @@ def test_valid_window_alternatives(self): with self.subTest(question=number): self.assertEqual(self.run_submission({number: self.blocks[index]})[number]['status'], 'correct') + @requires_private_solution + def test_latest_visits_without_displayed_rank(self): + """Accept an internal rank while still rejecting wrong dates or missing customers.""" + for sql in (self.valid[3], self.blocks[5]): + inner = sql.strip().rstrip(';') + projected = f"SELECT market_date, customer_id FROM ({inner})" + with self.subTest(sql=sql): + self.assertEqual(self.run_submission({3: projected})[3]['status'], 'correct') + wrong_dates = ("SELECT customer_id, date(market_date, '-1 day') AS market_date " + f"FROM ({inner})") + self.assertEqual(self.run_submission({3: wrong_dates})[3]['status'], 'incorrect') + self.assertEqual(self.run_submission({3: projected + ' LIMIT 1'})[3]['status'], + 'incorrect') + wrong_rank = ("SELECT customer_id, market_date, 99 AS visit_number " + f"FROM ({inner})") + self.assertEqual(self.run_submission({3: wrong_rank})[3]['status'], 'incorrect') + self.assertIsNotNone(G['technique_feedback']( + 3, 'SELECT customer_id, MAX(market_date) AS market_date ' + 'FROM customer_purchases GROUP BY customer_id' + )) + def test_running_purchase_counts_accept_date_peers(self): """Accept date-only running counts while rejecting wrong counts and missing purchases.""" conn = sqlite3.connect(":memory:") From 15be64bdabff9fb44cbcdc16c9cd8f1e9ebe5079 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:22:06 -0400 Subject: [PATCH 11/19] Accept Q8 revenue aliases in any column position --- .../tests/test_assignment.py | 14 +++++++----- .../tests/test_grader_regressions.py | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index 9e3c73f3d..841f906f0 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -367,11 +367,6 @@ def grade_question_7(rows, reference): def grade_question_8(rows, reference): """Grade the possible vendor revenue answer.""" - actual = extract_result_rows( - rows, - (("vendor_name",), ("product_name",), ("possible_revenue", "sum(price)", "revenue")), - True, - ) expected = fetch(reference, """ SELECT vendor_name, product_name, SUM(price) AS possible_revenue FROM ( @@ -381,8 +376,15 @@ def grade_question_8(rows, reference): CROSS JOIN (SELECT DISTINCT customer_id FROM customer) GROUP BY vendor_name, product_name """) + expected_rows = count_result_rows(expected, ("vendor_name", "product_name", "possible_revenue")) + # Match revenue values per vendor/product, regardless of alias or column order. + matches = any( + extract_result_rows(rows, (("vendor_name",), ("product_name",), (column,))) == expected_rows + for column in (rows[0] if rows else ()) + if column.lower() not in ("vendor_name", "product_name") + ) return grading_outcome( - actual == count_result_rows(expected, ("vendor_name", "product_name", "possible_revenue")), + matches, "Possible revenue is correct for every vendor and product.", "The possible revenue does not match five items per customer.", ) diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index 7b31d1bc8..bddd1b104 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -270,6 +270,28 @@ def test_sales_alias_and_dates_only_are_accepted(self): for sql in (alias, date_only): self.assertEqual(self.run_submission({7: sql})[7]['status'], 'correct') + @requires_private_solution + def test_revenue_alias_and_position_are_accepted(self): + """Accept Q8 revenue anywhere while checking its values and vendor/product pairing.""" + inner = self.valid[8].strip().rstrip(';') + for projection in ( + '"sum(price)" AS gross_income, product_name, vendor_name', + 'vendor_name, "sum(price)" AS gross_income, product_name, 0 AS extra', + ): + with self.subTest(projection=projection): + sql = f'SELECT {projection} FROM ({inner})' + self.assertEqual(self.run_submission({8: sql})[8]['status'], 'correct') + for projection in ( + 'vendor_name, product_name, "sum(price)" + 1 AS gross_income, 0 AS extra', + "vendor_name, 'wrong product' AS product_name, \"sum(price)\" AS gross_income", + 'vendor_name, product_name, 0 AS extra', + ): + with self.subTest(projection=projection): + sql = f'SELECT {projection} FROM ({inner})' + self.assertEqual(self.run_submission({8: sql})[8]['status'], 'incorrect') + sql = f'SELECT vendor_name, product_name, "sum(price)" AS gross_income FROM ({inner}) LIMIT 1' + self.assertEqual(self.run_submission({8: sql})[8]['status'], 'incorrect') + @requires_private_solution def test_reordered_snapshot_columns(self): """Putting the required product columns in a different order should still pass.""" From 97ea818596392ae74fa99ba86700398526e74b69 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:45:14 -0400 Subject: [PATCH 12/19] Support temporary product snapshots in Q9 through Q12 --- .../tests/test_assignment.py | 5 ++++- .../tests/test_grader_regressions.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index 841f906f0..7c055ca38 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -412,8 +412,11 @@ def grade_select(number, rows, reference): def product_units_state(conn): """Return the product_units table structure and data when the table exists.""" + # Q9-12 share a connection, so a TEMP snapshot is valid throughout those answers. exists = conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='product_units'" + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='product_units' " + "UNION ALL " + "SELECT 1 FROM sqlite_temp_master WHERE type='table' AND name='product_units'" ).fetchone() if not exists: return None diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index bddd1b104..b2fd1ca02 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -298,6 +298,26 @@ def test_reordered_snapshot_columns(self): sql = self.valid[9].replace('p.*', 'product_name,product_id,product_size,product_category_id,product_qty_type') self.assertEqual(self.run_submission({9: sql})[9]['status'], 'correct') + @requires_private_solution + def test_temporary_snapshot_supports_all_mutations(self): + """A valid temporary Q9 snapshot must remain usable through Q10-12.""" + for creation in ('CREATE TEMP TABLE', 'CREATE TEMPORARY TABLE', 'CREATE TABLE temp.'): + replacement = creation if creation.endswith('.') else creation + ' ' + sql, replacements = re.subn(r'\bCREATE\s+TABLE\s+', replacement, + self.valid[9], count=1, flags=re.I) + self.assertEqual(replacements, 1) + with self.subTest(creation=creation): + results = self.run_submission({9: sql}) + self.assertEqual([results[n]['status'] for n in range(9, 13)], ['correct'] * 4) + + @requires_private_solution + def test_invalid_temporary_snapshot_still_blocks_dependents(self): + """TEMP changes table lifetime, not the required snapshot contents.""" + sql = 'CREATE TEMP TABLE product_units AS SELECT * FROM product' + results = self.run_submission({9: sql}) + self.assertEqual(results[9]['status'], 'incorrect') + self.assertEqual([results[n]['status'] for n in range(10, 13)], ['blocked'] * 3) + @requires_private_solution def test_invalid_and_stale_timestamps_fail(self): """Reject timestamps that are meaningless text or dates far in the past.""" From 301107df02918922b33e9f40ec1b692aa5e44da5 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:54:49 -0400 Subject: [PATCH 13/19] Exercise reviewer cases and collapsible grading feedback --- .github/workflows/sql_assignment_runner.yml | 10 +- .../assignments/DC_Cohort/assignment2.sql | 285 ++++++++---------- 2 files changed, 142 insertions(+), 153 deletions(-) diff --git a/.github/workflows/sql_assignment_runner.yml b/.github/workflows/sql_assignment_runner.yml index 4beb330cc..65dfabe2f 100644 --- a/.github/workflows/sql_assignment_runner.yml +++ b/.github/workflows/sql_assignment_runner.yml @@ -112,8 +112,14 @@ jobs: let resultSection = `**Query ${result.number}: ${labels[result.status] || '⚪ Instructor review needed'}**\n\n`; resultSection += `${result.feedback}\n\n`; if (result.error) resultSection += fenced(result.error); - if (result.query) resultSection += fenced(result.query, 'sql'); - if (result.result?.length) resultSection += fenced(JSON.stringify(result.result, null, 2), 'json'); + let details = ''; + if (result.query) details += fenced(result.query, 'sql'); + if (result.result?.length) details += fenced(JSON.stringify(result.result, null, 2), 'json'); + if (details && result.status === 'correct') { + resultSection += `
\nSQL and output\n\n${details}\n
\n\n`; + } else { + resultSection += details; + } resultSection += '\n'; if (!appendSection(resultSection)) break reportLoop; } diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index c43509868..66c2a91d4 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -1,193 +1,176 @@ -/* ASSIGNMENT 2 */ ---Please write responses between the QUERY # and END QUERY blocks -/* SECTION 2 */ - --- COALESCE -/* 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! -We tell them, no problem! We can produce a list with all of the appropriate details. - -Using the following syntax you create our super cool and not at all needy manager a list: - -SELECT -product_name || ', ' || product_size|| ' (' || product_qty_type || ')' -FROM product - - -But wait! The product table has some bad data (a few NULL values). -Find the NULLs and then using COALESCE, replace the NULL with a blank for the first column with -nulls, and 'unit' for the second column with nulls. - -**HINT**: keep the syntax the same, but edited the correct components with the string. -The `||` values concatenate the columns into strings. -Edit the appropriate columns -- you're making two edits -- and the NULL rows will be fixed. -All the other rows will remain the same. */ --QUERY 1 - -SELECT product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')' AS product_details FROM product; - +-- query for question 1: student answer +SELECT product_name FROM product; -- deliberately incorrect --END QUERY - ---Windowed Functions -/* 1. Write a query that selects from the customer_purchases table and numbers each customer’s -visits to the farmer’s market (labeling each market date with a different number). -Each customer’s first visit is labeled 1, second visit is labeled 2, etc. - -You can either display all rows in the customer_purchases table, with the counter changing on -each new market date for each customer, or select only the unique market dates per customer -(without purchase details) and number those visits. -HINT: One of these approaches uses ROW_NUMBER() and one uses DENSE_RANK(). -Filter the visits to dates before April 29, 2022. */ --QUERY 2 +-- query for question 2: student answer +SELECT customer_id, market_date, +ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number -SELECT customer_id, market_date, DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number FROM customer_purchases WHERE market_date < '2022-04-29'; +FROM customer_purchases +WHERE market_date < '2022-04-29' +GROUP BY customer_id, market_date +ORDER BY customer_id, market_date --END QUERY - -/* 2. Reverse the numbering of the query so each customer’s most recent visit is labeled 1, -then write another query that uses this one as a subquery (or temp table) and filters the results to -only the customer’s most recent visit. -HINT: Do not use the previous visit dates filter. */ --QUERY 3 - -SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; - +-- query for question 3: student answer +SELECT customer_id, market_date FROM ( + SELECT DISTINCT customer_id, market_date, + DENSE_RANK() OVER ( + PARTITION BY customer_id ORDER BY market_date DESC + ) AS visit_number + FROM customer_purchases + ) x WHERE visit_number = 1; --END QUERY - -/* 3. Using a COUNT() window function, include a value along with each row of the -customer_purchases table that indicates how many different times that customer has purchased that product_id. - -You can make this a running count by including an ORDER BY within the PARTITION BY if desired. -Filter the visits to dates before April 29, 2022. */ --QUERY 4 - -SELECT cp.*, COUNT(*) OVER (PARTITION BY customer_id, product_id) AS product_purchase_count FROM customer_purchases cp WHERE market_date < '2022-04-29'; - +-- query for question 4: student answer +SELECT customer_id, product_id, market_date, + COUNT(*) OVER (PARTITION BY customer_id, product_id) AS cnt + FROM customer_purchases WHERE market_date < '2022-04-29'; --END QUERY - --- String manipulations -/* 1. Some product names in the product table have descriptions like "Jar" or "Organic". -These are separated from the product name with a hyphen. -Create a column using SUBSTR (and a couple of other commands) that captures these, but is otherwise NULL. -Remove any trailing or leading whitespaces. Don't just use a case statement for each product! - -| product_name | description | -|----------------------------|-------------| -| Habanero Peppers - Organic | Organic | - -Hint: you might need to use INSTR(product_name,'-') to find the hyphens. INSTR will help split the column. */ --QUERY 5 +-- query for question 5: student answer +SELECT *, +CASE WHEN INSTR(product_name,'-') > 0 + THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) + ELSE NULL END AS description -SELECT product_name, - CASE WHEN INSTR(product_name, '-') > 0 - THEN TRIM(SUBSTR(product_name, INSTR(product_name, '-') + 1)) - ELSE NULL END AS product_description -FROM product; - +FROM product --END QUERY - -/* 2. Filter the query to show any product_size value that contain a number with REGEXP. */ --QUERY 6 +-- query for question 6: student answer +SELECT *, +CASE WHEN INSTR(product_name,'-') > 0 + THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) + ELSE NULL END AS description -SELECT product_size FROM product WHERE product_size REGEXP '[0-9]'; +FROM product +WHERE product_size REGEXP( '[0-9]') --END QUERY - --- UNION -/* 1. Using a UNION, write a query that displays the market dates with the highest and lowest total sales. - -HINT: There are a possibly a few ways to do this query, but if you're struggling, try the following: -1) Create a CTE/Temp Table to find sales values grouped dates; -2) Create another CTE/Temp table with a rank windowed function on the previous query to create -"best day" and "worst day"; -3) Query the second temp table twice, once for the best day, once for the worst day, -with a UNION binding them. */ --QUERY 7 - -SELECT market_date, 0 AS total_sales FROM customer_purchases GROUP BY market_date LIMIT 2; - +-- query for question 7: student answer +;WITH sales_per_market AS ( +SELECT +market_date, +ROUND(SUM(quantity * cost_per_quantity),2) AS sales + +FROM customer_purchases +GROUP BY market_date +) + +,market_dates_ranked_by_sales AS ( +SELECT +market_date, +sales, +RANK() OVER (ORDER BY sales) AS sales_rank_asc, +RANK() OVER (ORDER BY sales DESC) AS sales_rank_desc + +FROM sales_per_market +) + +SELECT market_date, sales, sales_rank_desc AS sales_rank +FROM market_dates_ranked_by_sales +WHERE sales_rank_asc = 1 + +UNION + +SELECT market_date, sales, sales_rank_desc AS sales_rank +FROM market_dates_ranked_by_sales +WHERE sales_rank_desc = 1 --END QUERY - - -/* SECTION 3 */ - --- Cross Join -/*1. Suppose every vendor in the `vendor_inventory` table had 5 of each of their products to sell to **every** -customer on record. How much money would each vendor make per product? -Show this by vendor_name and product name, rather than using the IDs. - -HINT: Be sure you select only relevant columns and rows. -Remember, CROSS JOIN will explode your table rows, so CROSS JOIN should likely be a subquery. -Think a bit about the row counts: how many distinct vendors, product names are there (x)? -How many customers are there (y). -Before your final group by you should have the product of those two queries (x*y). */ --QUERY 8 - -SELECT vendor_name, product_name, 0 AS possible_revenue FROM vendor CROSS JOIN product; - +-- query for question 8: student answer +SELECT vendor_name, "sum(price)" AS gross_income, product_name, 0 AS extra FROM (SELECT vendor_name, product_name, sum(price) +FROM ( + SELECT DISTINCT + vendor_name, + product_name, + 5 * original_price as price + + FROM vendor_inventory vi + JOIN vendor v on v.vendor_id = vi.vendor_id + JOIN product p on p.product_id = vi.product_id + ) x +CROSS JOIN + (SELECT DISTINCT + customer_id + FROM customer) +GROUP BY vendor_name, product_name); --END QUERY - --- INSERT -/*1. Create a new table "product_units". -This table will contain only products where the `product_qty_type = 'unit'`. -It should use all of the columns from the product table, as well as a new column for the `CURRENT_TIMESTAMP`. -Name the timestamp column `snapshot_timestamp`. */ --QUERY 9 +-- query for question 9: student answer +CREATE TEMP TABLE product_units AS -CREATE TABLE product_units AS SELECT product.*, CURRENT_TIMESTAMP AS snapshot_timestamp FROM product WHERE product_qty_type = 'unit'; - + SELECT p.*, + CURRENT_TIMESTAMP AS snapshot_timestamp + FROM product AS p + WHERE product_qty_type = "unit" --END QUERY - -/*2. Using `INSERT`, add a new row to the product_units table (with an updated timestamp). -This can be any product you desire (e.g. add another record for Apple Pie). */ --QUERY 10 - -INSERT INTO product_units SELECT product.*, CURRENT_TIMESTAMP AS snapshot_timestamp FROM product WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit'); - +-- query for question 10: student answer +INSERT INTO product_units (product_id, product_name, + product_size, product_category_id, product_qty_type, snapshot_timestamp) + SELECT + product_id, + product_name, + product_size, + product_category_id, + product_qty_type, + CURRENT_TIMESTAMP + FROM product AS p + WHERE product_id = 7 --END QUERY - --- DELETE -/* 1. Delete the older record for whatever product you added. - -HINT: If you don't specify a WHERE clause, you are going to have a bad time.*/ --QUERY 11 - -DELETE FROM product_units WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit') AND snapshot_timestamp = (SELECT MIN(snapshot_timestamp) FROM product_units WHERE product_id = (SELECT MIN(product_id) FROM product WHERE product_qty_type = 'unit')); - +-- query for question 11: student answer +WITH older_record AS +(SELECT product_id + ,MIN(snapshot_timestamp) AS snapshot_timestamp + FROM product_units + WHERE product_id = 7) + +DELETE FROM product_units +WHERE (product_id = + (SELECT product_id + FROM older_record + ) +AND snapshot_timestamp = + (SELECT snapshot_timestamp + FROM older_record + )) --END QUERY - --- UPDATE -/* 1.We want to add the current_quantity to the product_units table. -First, add a new column, current_quantity to the table using the following syntax. - +--QUERY 12 +-- query for question 12: student answer ALTER TABLE product_units ADD current_quantity INT; -Then, using UPDATE, change the current_quantity equal to the last quantity value from the vendor_inventory details. - -HINT: This one is pretty hard. -First, determine how to get the "last" quantity per product. -Second, coalesce null values to 0 (if you don't have null values, figure out how to rearrange your query so you do.) -Third, SET current_quantity = (...your select statement...), remembering that WHERE can only accommodate one column. -Finally, make sure you have a WHERE statement to update the right row, - you'll need to use product_units.product_id to refer to the correct row within the product_units table. -When you have all of these components, you can run the update statement. */ ---QUERY 12 - -ALTER TABLE product_units ADD current_quantity INT; -UPDATE product_units SET current_quantity = COALESCE((SELECT quantity FROM vendor_inventory WHERE vendor_inventory.product_id = product_units.product_id ORDER BY market_date DESC LIMIT 1), 0); - ---END QUERY - - - +UPDATE product_units +SET current_quantity = ( + SELECT current_quantity + FROM ( + SELECT p.product_id, COALESCE(quantity,0) as current_quantity + + FROM product_units p + LEFT JOIN ( + SELECT * + ,ROW_NUMBER() OVER( PARTITION BY vi.product_id ORDER BY market_date DESC) AS rn + FROM vendor_inventory vi + ) vi ON p.product_id = vi.product_id + + WHERE rn = 1 + OR rn IS NULL + ) p +WHERE product_units.product_id = p.product_id); +--END QUERY \ No newline at end of file From 4ba9a74f583c2464f5f034dcb37aafa6cbd4f171 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Fri, 16 Oct 2026 16:56:56 -0400 Subject: [PATCH 14/19] Test date-only Q4 running count after reviewer fixes --- 02_activities/assignments/DC_Cohort/assignment2.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index 66c2a91d4..44c40b692 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -29,7 +29,7 @@ SELECT customer_id, market_date FROM ( --QUERY 4 -- query for question 4: student answer SELECT customer_id, product_id, market_date, - COUNT(*) OVER (PARTITION BY customer_id, product_id) AS cnt + COUNT(*) OVER (PARTITION BY customer_id, product_id ORDER BY market_date) AS cnt FROM customer_purchases WHERE market_date < '2022-04-29'; --END QUERY From bb2e41bab675587fa0db237e9f156f948382a182 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Tue, 20 Oct 2026 11:36:57 -0400 Subject: [PATCH 15/19] Collapse SQL and output for correct assignment answers --- .github/workflows/sql_assignment_runner.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sql_assignment_runner.yml b/.github/workflows/sql_assignment_runner.yml index 4beb330cc..65dfabe2f 100644 --- a/.github/workflows/sql_assignment_runner.yml +++ b/.github/workflows/sql_assignment_runner.yml @@ -112,8 +112,14 @@ jobs: let resultSection = `**Query ${result.number}: ${labels[result.status] || '⚪ Instructor review needed'}**\n\n`; resultSection += `${result.feedback}\n\n`; if (result.error) resultSection += fenced(result.error); - if (result.query) resultSection += fenced(result.query, 'sql'); - if (result.result?.length) resultSection += fenced(JSON.stringify(result.result, null, 2), 'json'); + let details = ''; + if (result.query) details += fenced(result.query, 'sql'); + if (result.result?.length) details += fenced(JSON.stringify(result.result, null, 2), 'json'); + if (details && result.status === 'correct') { + resultSection += `
\nSQL and output\n\n${details}\n
\n\n`; + } else { + resultSection += details; + } resultSection += '\n'; if (!appendSection(resultSection)) break reportLoop; } From 18c75f892e0dd6f094108c32654d26048468e256 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Tue, 20 Oct 2026 12:30:37 -0400 Subject: [PATCH 16/19] Address Sergii's feedback: match result columns by values, not names --- .../assignments/DC_Cohort/Assignment2.md | 13 +- .../Microcredential_Cohort/Assignment2.md | 13 +- .../tests/test_assignment.py | 135 ++++++++++-------- .../tests/test_grader_regressions.py | 43 ++++++ 4 files changed, 121 insertions(+), 83 deletions(-) diff --git a/02_activities/assignments/DC_Cohort/Assignment2.md b/02_activities/assignments/DC_Cohort/Assignment2.md index d0254291a..72f8c71fd 100644 --- a/02_activities/assignments/DC_Cohort/Assignment2.md +++ b/02_activities/assignments/DC_Cohort/Assignment2.md @@ -70,22 +70,11 @@ Steps to complete this part of the assignment: - from [Github](./02_activities/assignments/assignment2.sql) - or, from your local forked repository - Complete each question, by writing responses between the QUERY # and END QUERY blocks +- Some questions build on your earlier queries or tables. Keep track of the column names you use so you can reference them correctly in later questions. ### Write SQL -**Column names for automatic feedback (Sections 2 and 3):** Keep the original names of existing database columns in your results, such as `customer_id`, `market_date`, and `product_name`. Use `AS` to name calculated columns as follows: - -| SQL question | Calculated column name | -|--------------|------------------------| -| Q2 and Q3: visit numbering | `visit_number` | -| Q4: purchase count | `product_purchase_count` | -| Q5: extracted description | `description` | -| Q7: total sales, if included | `sales` | -| Q8: possible revenue | `possible_revenue` | - -For example, name a calculated count with `COUNT(*) OVER (...) AS product_purchase_count`. Q1's combined product description may have any column name. For Q9-12, use the table and column names specified in the questions. - #### COALESCE 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! We tell them, no problem! We can produce a list with all of the appropriate details. diff --git a/02_activities/assignments/Microcredential_Cohort/Assignment2.md b/02_activities/assignments/Microcredential_Cohort/Assignment2.md index 54b46fb90..f1329042f 100644 --- a/02_activities/assignments/Microcredential_Cohort/Assignment2.md +++ b/02_activities/assignments/Microcredential_Cohort/Assignment2.md @@ -70,22 +70,11 @@ Steps to complete this part of the assignment: - from [Github](./02_activities/assignments/assignment2.sql) - or, from your local forked repository - Complete each question, by writing responses between the QUERY # and END QUERY blocks +- Some questions build on your earlier queries or tables. Keep track of the column names you use so you can reference them correctly in later questions. ### Write SQL -**Column names for automatic feedback (Sections 2 and 3):** Keep the original names of existing database columns in your results, such as `customer_id`, `market_date`, and `product_name`. Use `AS` to name calculated columns as follows: - -| SQL question | Calculated column name | -|--------------|------------------------| -| Q2 and Q3: visit numbering | `visit_number` | -| Q4: purchase count | `product_purchase_count` | -| Q5: extracted description | `description` | -| Q7: total sales, if included | `sales` | -| Q8: possible revenue | `possible_revenue` | - -For example, name a calculated count with `COUNT(*) OVER (...) AS product_purchase_count`. Q1's combined product description may have any column name. For Q9-12, use the table and column names specified in the questions. - #### COALESCE 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! We tell them, no problem! We can produce a list with all of the appropriate details. diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment.py index 7c055ca38..fb9b31d52 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment.py @@ -108,6 +108,29 @@ def clone_database(source, clock=None): return clone +def unique_column_names(names): + """Give repeated output names distinct labels so no column values are lost. + + For example, ['value', 'value'] becomes ['value', 'value [2]']. + Existing names are kept: ['value', 'value', 'value [2]'] becomes + ['value', 'value [3]', 'value [2]'] to avoid another duplicate. + These labels are only used in the results; the SQL itself is not changed. + """ + used = set(names) + keys = [] + for index, name in enumerate(names): + key = name + if key in keys: + suffix = index + 1 + key = f"{name} [{suffix}]" + while key in used: + suffix += 1 + key = f"{name} [{suffix}]" + used.add(key) + keys.append(key) + return keys + + def run_query(conn, query): """Execute SQL statements without splitting quoted semicolons or comments.""" deadline = time.monotonic() + 10 @@ -123,7 +146,10 @@ def run_query(conn, query): buffer = "" if any(token != ";" for token in sql_tokens(buffer)): cursor.execute(buffer) - return [dict(row) for row in cursor.fetchall()] + # SQLite supplies final names, including AS aliases. + names = [column[0] for column in (cursor.description or ())] + keys = unique_column_names(names) + return [dict(zip(keys, row)) for row in cursor.fetchall()] finally: conn.set_progress_handler(None, 0) @@ -143,20 +169,47 @@ def count_result_rows(rows, keys): return Counter(tuple(normalize_value(row[key]) for key in keys) for row in rows) -def extract_result_rows(rows, columns, last_fallback=False): - """Extract comparable columns from student results using accepted column names.""" +def result_rows_match(rows, expected, keys): + """Match distinct output columns by values, preserving whole rows and duplicates.""" + if len(rows) != len(expected): + return False if not rows: - return Counter() - actual_keys = [] - lower_keys = {key.lower(): key for key in rows[0]} - for index, choices in enumerate(columns): - key = next((lower_keys[name.lower()] for name in choices if name.lower() in lower_keys), None) - if key is None and last_fallback and index == len(columns) - 1: - key = list(rows[0])[-1] - if key is None: - return None - actual_keys.append(key) - return Counter(tuple(normalize_value(row[key]) for key in actual_keys) for row in rows) + return True + if len(rows[0]) < len(keys): + return False + candidates = [] + actual_counts = {column: count_result_rows(rows, (column,)) for column in rows[0]} + for key in keys: + counts = count_result_rows(expected, (key,)) + candidates.append([column for column, actual in actual_counts.items() if actual == counts]) + if any(not choices for choices in candidates): + return False + + def match_columns(selected): + """Add one column at a time, checking the chosen columns together. + + Recursively try the next column; if a combination fails, try another choice. + Return True when all required columns match, or False if no combination works. + """ + index = len(selected) + if index == len(keys): + return True + expected_prefix = count_result_rows(expected, keys[:index + 1]) + # Identical column vectors are interchangeable; avoid redundant branches. + tried = set() + for column in candidates[index]: + if column in selected: + continue + vector = tuple(normalize_value(row[column]) for row in rows) + if vector in tried: + continue + tried.add(vector) + projected = (*selected, column) + if count_result_rows(rows, projected) == expected_prefix and match_columns(projected): + return True + return False + + return match_columns(()) def grading_outcome(passed, success, failure): @@ -207,9 +260,6 @@ def grade_question_1(rows, reference): def grade_question_2(rows, reference): """Grade the ascending customer visit-number answer.""" - actual = extract_result_rows( - rows, (("customer_id",), ("market_date",), ("visit_number",)), True - ) full = fetch(reference, """ SELECT customer_id, market_date, DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number @@ -222,9 +272,8 @@ def grade_question_2(rows, reference): WHERE market_date < '2022-04-29') """) keys = ("customer_id", "market_date", "visit_number") - accepted = [count_result_rows(result, keys) for result in (full, unique)] return grading_outcome( - actual in accepted, + any(result_rows_match(rows, result, keys) for result in (full, unique)), "Visit dates are numbered correctly for each customer.", "The visit numbers or returned dates are incorrect.", ) @@ -236,7 +285,6 @@ def grade_question_3(rows, reference): keys = ("customer_id", "market_date") if rows and len(rows[0]) > 2: keys += ("visit_number",) - actual = extract_result_rows(rows, tuple((key,) for key in keys), True) full = fetch(reference, """ SELECT customer_id, market_date, 1 AS visit_number FROM customer_purchases AS p @@ -247,9 +295,8 @@ def grade_question_3(rows, reference): SELECT customer_id, MAX(market_date) AS market_date, 1 AS visit_number FROM customer_purchases GROUP BY customer_id """) - accepted = [count_result_rows(result, keys) for result in (full, unique)] return grading_outcome( - actual in accepted, + any(result_rows_match(rows, result, keys) for result in (full, unique)), "Only each customer's most recent visit is returned.", "The result does not contain only each customer's most recent visit.", ) @@ -262,8 +309,6 @@ def grade_question_4(rows, reference): keys = ["customer_id", "product_id", "market_date"] keys.extend(key for key in ("vendor_id", "transaction_time") if key in returned_names) keys.append("product_purchase_count") - columns = tuple((key,) for key in keys) - actual = extract_result_rows(rows, columns, True) fixed = fetch(reference, """ SELECT customer_id, vendor_id, product_id, market_date, transaction_time, COUNT(*) OVER (PARTITION BY customer_id, product_id) AS product_purchase_count @@ -286,11 +331,10 @@ def grade_question_4(rows, reference): ORDER BY {ordering}) AS product_purchase_count FROM customer_purchases WHERE market_date < '2022-04-29' """)) - accepted = [count_result_rows(result, keys) for result in (fixed, running, *tied_running)] return grading_outcome( - actual in accepted, + any(result_rows_match(rows, result, keys) for result in (fixed, running, *tied_running)), "The purchase count is correct for every customer and product.", - "Include customer_id, product_id, market_date, and a purchase count for every purchase row. " + "Include the customer ID, product ID, market date, and a purchase count for every purchase row. " "Check the counts and the filter to dates before April 29, 2022; vendor_id and transaction_time are optional.", ) @@ -304,15 +348,8 @@ def grade_question_5(rows, reference): ELSE NULL END AS description FROM product """) - expected_rows = count_result_rows(expected, ("product_name", "description")) - # Students may name the description column differently; compare its values with each product. - matches = any( - extract_result_rows(rows, (("product_name",), (column,))) == expected_rows - for column in (rows[0] if rows else ()) - if column.lower() != "product_name" - ) return grading_outcome( - matches, + result_rows_match(rows, expected, ("product_name", "description")), "Product descriptions are extracted and trimmed correctly.", "The descriptions do not match the text after each product-name hyphen.", ) @@ -320,14 +357,11 @@ def grade_question_5(rows, reference): def grade_question_6(rows, reference): """Grade the numeric product-size REGEXP answer.""" - actual = extract_result_rows(rows, (("product_size",),)) - if actual is None and rows and all(len(row) == 1 for row in rows): - actual = Counter((normalize_value(next(iter(row.values()))),) for row in rows) expected = fetch( reference, "SELECT product_size FROM product WHERE product_size REGEXP '[0-9]'" ) return grading_outcome( - actual == count_result_rows(expected, ("product_size",)), + result_rows_match(rows, expected, ("product_size",)), "Only product sizes containing a number are returned.", "The result does not match all product sizes containing a number.", ) @@ -335,7 +369,6 @@ def grade_question_6(rows, reference): def grade_question_7(rows, reference): """Grade the highest and lowest market-sales answer.""" - actual = extract_result_rows(rows, (("market_date",), ("sales", "total_sales", "revenue")), True) expected = fetch(reference, """ WITH daily AS ( SELECT market_date, ROUND(SUM(quantity * cost_per_quantity), 2) AS sales @@ -346,20 +379,11 @@ def grade_question_7(rows, reference): OR sales = (SELECT MAX(sales) FROM daily) """) if rows and all(len(row) == 1 for row in rows): - actual_dates = Counter(next(iter(row.values())) for row in rows) - return grading_outcome(actual_dates == Counter(row["market_date"] for row in expected), + return grading_outcome(result_rows_match(rows, expected, ("market_date",)), "The highest and lowest sales dates are correct.", "The highest and lowest sales dates are incorrect.") - if actual != count_result_rows(expected, ("market_date", "sales")) and rows: - for column in rows[0]: - if column == "market_date": - continue - candidate = extract_result_rows(rows, (("market_date",), (column,))) - if candidate == count_result_rows(expected, ("market_date", "sales")): - actual = candidate - break return grading_outcome( - actual == count_result_rows(expected, ("market_date", "sales")), + result_rows_match(rows, expected, ("market_date", "sales")), "The highest and lowest sales dates and totals are correct.", "The result does not match the highest and lowest sales dates.", ) @@ -376,15 +400,8 @@ def grade_question_8(rows, reference): CROSS JOIN (SELECT DISTINCT customer_id FROM customer) GROUP BY vendor_name, product_name """) - expected_rows = count_result_rows(expected, ("vendor_name", "product_name", "possible_revenue")) - # Match revenue values per vendor/product, regardless of alias or column order. - matches = any( - extract_result_rows(rows, (("vendor_name",), ("product_name",), (column,))) == expected_rows - for column in (rows[0] if rows else ()) - if column.lower() not in ("vendor_name", "product_name") - ) return grading_outcome( - matches, + result_rows_match(rows, expected, ("vendor_name", "product_name", "possible_revenue")), "Possible revenue is correct for every vendor and product.", "The possible revenue does not match five items per customer.", ) diff --git a/03_instructional_team/tests/test_grader_regressions.py b/03_instructional_team/tests/test_grader_regressions.py index b2fd1ca02..6c28381e3 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -79,6 +79,49 @@ def test_all_instructor_answers_pass_without_timestamp_sleep(self): """Correct instructor answers should all pass, even when run immediately in sequence.""" self.assertTrue(all(r["status"] == "correct" for r in self.run_submission().values())) + def test_column_matching_preserves_row_relationships(self): + """Aliases may vary, but values must match together with distinct columns and duplicate rows.""" + match = G['result_rows_match'] + expected = [{'customer': 1, 'product': 2}, {'customer': 2, 'product': 1}] + self.assertTrue(match([{'b': 1, 'a': 2}, {'b': 2, 'a': 1}], + expected, ('customer', 'product'))) + self.assertFalse(match([{'a': 1, 'b': 1}, {'a': 2, 'b': 2}], + expected, ('customer', 'product'))) + repeated = [{'customer': 1, 'product': 1}, {'customer': 2, 'product': 2}] + self.assertFalse(match([{'only': 1}, {'only': 2}], repeated, ('customer', 'product'))) + self.assertTrue(match([{'a': 1, 'b': 1}, {'a': 2, 'b': 2}], + repeated, ('customer', 'product'))) + self.assertFalse(match([{'a': 1, 'b': 2}] * 2, expected, ('customer', 'product'))) + + def test_duplicate_output_names_preserve_values(self): + """Repeated AS aliases must not discard one of the student's selected values.""" + conn = G['clone_database'](self.source) + try: + rows = G['run_query'](conn, 'SELECT 1 AS label, 2 AS label, 3 AS "label [2]"') + self.assertEqual(list(rows[0].values()), [1, 2, 3]) + self.assertEqual(len(rows[0]), 3) + finally: + conn.close() + + @requires_private_solution + def test_renamed_and_reordered_output_columns(self): + """Accept renamed original and calculated columns across all SELECT questions.""" + for number in range(1, 9): + conn = G['clone_database'](self.source) + try: + rows = G['run_query'](conn, self.valid[number]) + finally: + conn.close() + self.assertTrue(rows) + projection = ', '.join( + '"' + name.replace('"', '""') + f'" AS "student column {index}"' + for index, name in reversed(list(enumerate(rows[0]))) + ) + inner = self.valid[number].strip().strip(';') + sql = f'SELECT {projection} FROM ({inner})' + with self.subTest(question=number): + self.assertEqual(self.run_submission({number: sql})[number]['status'], 'correct') + @requires_private_solution def test_wrong_answers_are_rejected_with_valid_prerequisites(self): """Give each question a wrong answer while keeping the other answers correct.""" From 60fab85c62fbfebee4bf58f1c7aaff0db3555662 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Tue, 20 Oct 2026 12:40:39 -0400 Subject: [PATCH 17/19] Test arbitrary output aliases and column order across Q1 through Q8 --- .../assignments/DC_Cohort/assignment2.sql | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index 44c40b692..c3d19666e 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -1,10 +1,13 @@ --QUERY 1 --- query for question 1: student answer -SELECT product_name FROM product; -- deliberately incorrect +-- query 1: arbitrary aliases and reordered output +SELECT "product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')'" AS "q1_field_0" FROM (SELECT +product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')' +FROM product); --END QUERY --QUERY 2 --- query for question 2: student answer +-- query 2: arbitrary aliases and reordered output +SELECT "visit_number" AS "q2_field_2", "market_date" AS "q2_field_1", "customer_id" AS "q2_field_0" FROM (-- query for question 2: student answer SELECT customer_id, market_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number @@ -12,39 +15,43 @@ FROM customer_purchases WHERE market_date < '2022-04-29' GROUP BY customer_id, market_date -ORDER BY customer_id, market_date +ORDER BY customer_id, market_date); --END QUERY --QUERY 3 --- query for question 3: student answer +-- query 3: arbitrary aliases and reordered output +SELECT "market_date" AS "q3_field_1", "customer_id" AS "q3_field_0" FROM (-- query for question 3: student answer SELECT customer_id, market_date FROM ( SELECT DISTINCT customer_id, market_date, DENSE_RANK() OVER ( PARTITION BY customer_id ORDER BY market_date DESC ) AS visit_number FROM customer_purchases - ) x WHERE visit_number = 1; + ) x WHERE visit_number = 1); --END QUERY --QUERY 4 --- query for question 4: student answer +-- query 4: arbitrary aliases and reordered output +SELECT "cnt" AS "q4_field_3", "market_date" AS "q4_field_2", "product_id" AS "q4_field_1", "customer_id" AS "q4_field_0" FROM (-- query for question 4: student answer SELECT customer_id, product_id, market_date, COUNT(*) OVER (PARTITION BY customer_id, product_id ORDER BY market_date) AS cnt - FROM customer_purchases WHERE market_date < '2022-04-29'; + FROM customer_purchases WHERE market_date < '2022-04-29'); --END QUERY --QUERY 5 --- query for question 5: student answer +-- query 5: arbitrary aliases and reordered output +SELECT "description" AS "q5_field_5", "product_qty_type" AS "q5_field_4", "product_category_id" AS "q5_field_3", "product_size" AS "q5_field_2", "product_name" AS "q5_field_1", "product_id" AS "q5_field_0" FROM (-- query for question 5: student answer SELECT *, CASE WHEN INSTR(product_name,'-') > 0 THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) ELSE NULL END AS description -FROM product +FROM product); --END QUERY --QUERY 6 --- query for question 6: student answer +-- query 6: arbitrary aliases and reordered output +SELECT "description" AS "q6_field_5", "product_qty_type" AS "q6_field_4", "product_category_id" AS "q6_field_3", "product_size" AS "q6_field_2", "product_name" AS "q6_field_1", "product_id" AS "q6_field_0" FROM (-- query for question 6: student answer SELECT *, CASE WHEN INSTR(product_name,'-') > 0 THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) @@ -52,12 +59,13 @@ CASE WHEN INSTR(product_name,'-') > 0 FROM product -WHERE product_size REGEXP( '[0-9]') +WHERE product_size REGEXP( '[0-9]')); --END QUERY --QUERY 7 --- query for question 7: student answer -;WITH sales_per_market AS ( +-- query 7: arbitrary aliases and reordered output +SELECT "sales_rank" AS "q7_field_2", "sales" AS "q7_field_1", "market_date" AS "q7_field_0" FROM (-- query for question 7: student answer +WITH sales_per_market AS ( SELECT market_date, ROUND(SUM(quantity * cost_per_quantity),2) AS sales @@ -84,11 +92,12 @@ UNION SELECT market_date, sales, sales_rank_desc AS sales_rank FROM market_dates_ranked_by_sales -WHERE sales_rank_desc = 1 +WHERE sales_rank_desc = 1); --END QUERY --QUERY 8 --- query for question 8: student answer +-- query 8: arbitrary aliases and reordered output +SELECT "extra" AS "q8_field_3", "product_name" AS "q8_field_2", "gross_income" AS "q8_field_1", "vendor_name" AS "q8_field_0" FROM (-- query for question 8: student answer SELECT vendor_name, "sum(price)" AS gross_income, product_name, 0 AS extra FROM (SELECT vendor_name, product_name, sum(price) FROM ( SELECT DISTINCT @@ -104,10 +113,11 @@ CROSS JOIN (SELECT DISTINCT customer_id FROM customer) -GROUP BY vendor_name, product_name); +GROUP BY vendor_name, product_name)); --END QUERY --QUERY 9 +-- query 9: arbitrary aliases and reordered output -- query for question 9: student answer CREATE TEMP TABLE product_units AS @@ -118,6 +128,7 @@ CREATE TEMP TABLE product_units AS --END QUERY --QUERY 10 +-- query 10: arbitrary aliases and reordered output -- query for question 10: student answer INSERT INTO product_units (product_id, product_name, product_size, product_category_id, product_qty_type, snapshot_timestamp) @@ -133,6 +144,7 @@ INSERT INTO product_units (product_id, product_name, --END QUERY --QUERY 11 +-- query 11: arbitrary aliases and reordered output -- query for question 11: student answer WITH older_record AS (SELECT product_id @@ -152,6 +164,7 @@ AND snapshot_timestamp = --END QUERY --QUERY 12 +-- query 12: arbitrary aliases and reordered output -- query for question 12: student answer ALTER TABLE product_units ADD current_quantity INT; From c77b822d5e4c143faa1e2f1678d67e9e4af0c0ed Mon Sep 17 00:00:00 2001 From: Daniel R Date: Tue, 20 Oct 2026 12:41:55 -0400 Subject: [PATCH 18/19] Verify renamed columns with wrong row relationships still fail --- 02_activities/assignments/DC_Cohort/assignment2.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index c3d19666e..e6845fd1c 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -7,7 +7,10 @@ FROM product); --QUERY 2 -- query 2: arbitrary aliases and reordered output -SELECT "visit_number" AS "q2_field_2", "market_date" AS "q2_field_1", "customer_id" AS "q2_field_0" FROM (-- query for question 2: student answer +SELECT "q2_field_2", + COALESCE(LEAD("q2_field_1") OVER (ORDER BY "q2_field_0", "q2_field_1"), + FIRST_VALUE("q2_field_1") OVER (ORDER BY "q2_field_0", "q2_field_1")) AS "q2_field_1", + "q2_field_0" FROM (SELECT "visit_number" AS "q2_field_2", "market_date" AS "q2_field_1", "customer_id" AS "q2_field_0" FROM (-- query for question 2: student answer SELECT customer_id, market_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number @@ -15,7 +18,7 @@ FROM customer_purchases WHERE market_date < '2022-04-29' GROUP BY customer_id, market_date -ORDER BY customer_id, market_date); +ORDER BY customer_id, market_date)); --END QUERY --QUERY 3 From 7ca2edf7896286a4f532966e3a6f0d4128ede491 Mon Sep 17 00:00:00 2001 From: Daniel R Date: Tue, 20 Oct 2026 14:58:24 -0400 Subject: [PATCH 19/19] Test an entirely incorrect Assignment 2 submission --- .../assignments/DC_Cohort/assignment2.sql | 172 ++---------------- 1 file changed, 14 insertions(+), 158 deletions(-) diff --git a/02_activities/assignments/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index e6845fd1c..836162703 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -1,192 +1,48 @@ --QUERY 1 --- query 1: arbitrary aliases and reordered output -SELECT "product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')'" AS "q1_field_0" FROM (SELECT -product_name || ', ' || COALESCE(product_size, '') || ' (' || COALESCE(product_qty_type, 'unit') || ')' -FROM product); +SELECT product_name FROM product; --END QUERY --QUERY 2 --- query 2: arbitrary aliases and reordered output -SELECT "q2_field_2", - COALESCE(LEAD("q2_field_1") OVER (ORDER BY "q2_field_0", "q2_field_1"), - FIRST_VALUE("q2_field_1") OVER (ORDER BY "q2_field_0", "q2_field_1")) AS "q2_field_1", - "q2_field_0" FROM (SELECT "visit_number" AS "q2_field_2", "market_date" AS "q2_field_1", "customer_id" AS "q2_field_0" FROM (-- query for question 2: student answer -SELECT customer_id, market_date, -ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY market_date) AS visit_number - -FROM customer_purchases -WHERE market_date < '2022-04-29' - -GROUP BY customer_id, market_date -ORDER BY customer_id, market_date)); +SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; --END QUERY --QUERY 3 --- query 3: arbitrary aliases and reordered output -SELECT "market_date" AS "q3_field_1", "customer_id" AS "q3_field_0" FROM (-- query for question 3: student answer -SELECT customer_id, market_date FROM ( - SELECT DISTINCT customer_id, market_date, - DENSE_RANK() OVER ( - PARTITION BY customer_id ORDER BY market_date DESC - ) AS visit_number - FROM customer_purchases - ) x WHERE visit_number = 1); +SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; --END QUERY --QUERY 4 --- query 4: arbitrary aliases and reordered output -SELECT "cnt" AS "q4_field_3", "market_date" AS "q4_field_2", "product_id" AS "q4_field_1", "customer_id" AS "q4_field_0" FROM (-- query for question 4: student answer -SELECT customer_id, product_id, market_date, - COUNT(*) OVER (PARTITION BY customer_id, product_id ORDER BY market_date) AS cnt - FROM customer_purchases WHERE market_date < '2022-04-29'); +SELECT *, 99 AS product_purchase_count FROM customer_purchases; --END QUERY --QUERY 5 --- query 5: arbitrary aliases and reordered output -SELECT "description" AS "q5_field_5", "product_qty_type" AS "q5_field_4", "product_category_id" AS "q5_field_3", "product_size" AS "q5_field_2", "product_name" AS "q5_field_1", "product_id" AS "q5_field_0" FROM (-- query for question 5: student answer -SELECT *, -CASE WHEN INSTR(product_name,'-') > 0 - THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) - ELSE NULL END AS description - -FROM product); +SELECT product_name, NULL AS description FROM product; --END QUERY --QUERY 6 --- query 6: arbitrary aliases and reordered output -SELECT "description" AS "q6_field_5", "product_qty_type" AS "q6_field_4", "product_category_id" AS "q6_field_3", "product_size" AS "q6_field_2", "product_name" AS "q6_field_1", "product_id" AS "q6_field_0" FROM (-- query for question 6: student answer -SELECT *, -CASE WHEN INSTR(product_name,'-') > 0 - THEN LTRIM(RTRIM(SUBSTR(product_name,INSTR(product_name,'-')+1))) - ELSE NULL END AS description - - -FROM product -WHERE product_size REGEXP( '[0-9]')); +SELECT * FROM product; --END QUERY --QUERY 7 --- query 7: arbitrary aliases and reordered output -SELECT "sales_rank" AS "q7_field_2", "sales" AS "q7_field_1", "market_date" AS "q7_field_0" FROM (-- query for question 7: student answer -WITH sales_per_market AS ( -SELECT -market_date, -ROUND(SUM(quantity * cost_per_quantity),2) AS sales - -FROM customer_purchases -GROUP BY market_date -) - -,market_dates_ranked_by_sales AS ( -SELECT -market_date, -sales, -RANK() OVER (ORDER BY sales) AS sales_rank_asc, -RANK() OVER (ORDER BY sales DESC) AS sales_rank_desc - -FROM sales_per_market -) - -SELECT market_date, sales, sales_rank_desc AS sales_rank -FROM market_dates_ranked_by_sales -WHERE sales_rank_asc = 1 - -UNION - -SELECT market_date, sales, sales_rank_desc AS sales_rank -FROM market_dates_ranked_by_sales -WHERE sales_rank_desc = 1); +SELECT market_date, 0 AS sales FROM customer_purchases GROUP BY market_date LIMIT 2; --END QUERY --QUERY 8 --- query 8: arbitrary aliases and reordered output -SELECT "extra" AS "q8_field_3", "product_name" AS "q8_field_2", "gross_income" AS "q8_field_1", "vendor_name" AS "q8_field_0" FROM (-- query for question 8: student answer -SELECT vendor_name, "sum(price)" AS gross_income, product_name, 0 AS extra FROM (SELECT vendor_name, product_name, sum(price) -FROM ( - SELECT DISTINCT - vendor_name, - product_name, - 5 * original_price as price - - FROM vendor_inventory vi - JOIN vendor v on v.vendor_id = vi.vendor_id - JOIN product p on p.product_id = vi.product_id - ) x -CROSS JOIN - (SELECT DISTINCT - customer_id - FROM customer) -GROUP BY vendor_name, product_name)); +SELECT vendor_name, product_name, 0 AS revenue FROM vendor CROSS JOIN product; --END QUERY --QUERY 9 --- query 9: arbitrary aliases and reordered output --- query for question 9: student answer -CREATE TEMP TABLE product_units AS - - SELECT p.*, - CURRENT_TIMESTAMP AS snapshot_timestamp - FROM product AS p - WHERE product_qty_type = "unit" +CREATE TEMP TABLE product_units AS SELECT * FROM product; --END QUERY --QUERY 10 --- query 10: arbitrary aliases and reordered output --- query for question 10: student answer -INSERT INTO product_units (product_id, product_name, - product_size, product_category_id, product_qty_type, snapshot_timestamp) - SELECT - product_id, - product_name, - product_size, - product_category_id, - product_qty_type, - CURRENT_TIMESTAMP - FROM product AS p - WHERE product_id = 7 +SELECT 1; --END QUERY --QUERY 11 --- query 11: arbitrary aliases and reordered output --- query for question 11: student answer -WITH older_record AS -(SELECT product_id - ,MIN(snapshot_timestamp) AS snapshot_timestamp - FROM product_units - WHERE product_id = 7) - -DELETE FROM product_units -WHERE (product_id = - (SELECT product_id - FROM older_record - ) -AND snapshot_timestamp = - (SELECT snapshot_timestamp - FROM older_record - )) +DELETE FROM product_units WHERE 1 = 0; --END QUERY --QUERY 12 --- query 12: arbitrary aliases and reordered output --- query for question 12: student answer -ALTER TABLE product_units -ADD current_quantity INT; - -UPDATE product_units -SET current_quantity = ( - SELECT current_quantity - FROM ( - SELECT p.product_id, COALESCE(quantity,0) as current_quantity - - FROM product_units p - LEFT JOIN ( - SELECT * - ,ROW_NUMBER() OVER( PARTITION BY vi.product_id ORDER BY market_date DESC) AS rn - FROM vendor_inventory vi - ) vi ON p.product_id = vi.product_id - - WHERE rn = 1 - OR rn IS NULL - ) p -WHERE product_units.product_id = p.product_id); ---END QUERY \ No newline at end of file +ALTER TABLE product_units ADD current_quantity INT; +UPDATE product_units SET current_quantity = -1; +--END QUERY