diff --git a/.github/scripts/grade_assignments.py b/.github/scripts/grade_assignments.py index 0d5a3942c..45acd0075 100644 --- a/.github/scripts/grade_assignments.py +++ b/.github/scripts/grade_assignments.py @@ -23,7 +23,7 @@ def grade_files(files, output): error = None try: result = subprocess.run( - [sys.executable, "-m", "pytest", "03_instructional_team/tests/test_assignment.py", + [sys.executable, "-m", "pytest", "03_instructional_team/tests/test_assignment_2.py", f"--file_path={file_path}", f"--report_path={report}", "--tb=short", "--disable-warnings"], timeout=120, check=False, ) diff --git a/.github/workflows/sql_assignment_runner.yml b/.github/workflows/sql_assignment_runner.yml index 4beb330cc..cbfc90fb6 100644 --- a/.github/workflows/sql_assignment_runner.yml +++ b/.github/workflows/sql_assignment_runner.yml @@ -8,9 +8,8 @@ on: workflow_dispatch: # Manual trigger from GitHub UI inputs: branch: - description: 'Assignment 2 branch to grade, usually assignment-two' + description: 'Assignment branch, e.g. assignment-one or assignment-two' required: true - 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' @@ -22,10 +21,10 @@ permissions: pull-requests: write jobs: - run-assignment-queries: + run-assignment-one: if: >- - github.event_name == 'workflow_dispatch' || - ((github.head_ref == 'assignment-two' || github.head_ref == 'assignment-2') && + (github.event_name == 'workflow_dispatch' && (inputs.branch == 'assignment-one' || inputs.branch == 'assignment-1')) || + ((github.head_ref == 'assignment-one' || github.head_ref == 'assignment-1') && github.repository_owner != 'UofT-DSI') runs-on: ubuntu-latest permissions: @@ -48,6 +47,137 @@ jobs: run: | pip install -r ./.github/scripts/requirements.txt + - name: Get changed files + id: changes + run: | + git fetch + git diff --name-only origin/main > changed_files.txt + + echo "Changed files:" + cat changed_files.txt + assignment_changed="" + + if 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 + + - name: Run Assignment 1 queries + id: assignment1 + if: steps.changes.outputs.assignment_changed != '' + continue-on-error: true + env: + FILE_PATH: ${{ steps.changes.outputs.assignment_changed }} + run: | + pytest 03_instructional_team/tests/test_assignment_1.py --file_path="$FILE_PATH" --tb=short --disable-warnings \ + --junitxml=pytest-report.xml + + - name: Post Assignment 1 query results to PR + if: always() && steps.assignment1.outcome == 'success' + 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'); + } + + function formatValue(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return `\`${JSON.stringify(value)}\``; + return String(value); + } + + 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` + } + + body += `-------------------------------------------------------- \n`; + } + + body += `
` + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body + }) + + - name: Upload query results + if: always() + uses: actions/upload-artifact@v4 + with: + name: a1-query-results + path: test-results.json + if-no-files-found: ignore + + - name: Preserve runner failure status + if: always() && steps.assignment1.outcome == 'failure' + run: exit 1 + + run-assignment-two: + if: >- + (github.event_name == 'workflow_dispatch' && (inputs.branch == 'assignment-two' || inputs.branch == 'assignment-2')) || + ((github.head_ref == 'assignment-two' || github.head_ref == 'assignment-2') && + github.repository_owner != 'UofT-DSI') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + 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 + with: + python-version: '3.11' + + - name: Install dependencies + 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 @@ -112,8 +242,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.md b/02_activities/assignments/DC_Cohort/Assignment2.md index 47a854540..72f8c71fd 100644 --- a/02_activities/assignments/DC_Cohort/Assignment2.md +++ b/02_activities/assignments/DC_Cohort/Assignment2.md @@ -70,6 +70,7 @@ 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 diff --git a/02_activities/assignments/Microcredential_Cohort/Assignment2.md b/02_activities/assignments/Microcredential_Cohort/Assignment2.md index 2ff945602..f1329042f 100644 --- a/02_activities/assignments/Microcredential_Cohort/Assignment2.md +++ b/02_activities/assignments/Microcredential_Cohort/Assignment2.md @@ -70,6 +70,7 @@ 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 diff --git a/03_instructional_team/tests/test_assignment_1.py b/03_instructional_team/tests/test_assignment_1.py new file mode 100644 index 000000000..510b410a6 --- /dev/null +++ b/03_instructional_team/tests/test_assignment_1.py @@ -0,0 +1,112 @@ +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) + diff --git a/03_instructional_team/tests/test_assignment.py b/03_instructional_team/tests/test_assignment_2.py similarity index 83% rename from 03_instructional_team/tests/test_assignment.py rename to 03_instructional_team/tests/test_assignment_2.py index d0492ac04..fb9b31d52 100644 --- a/03_instructional_team/tests/test_assignment.py +++ b/03_instructional_team/tests/test_assignment_2.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: @@ -110,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 @@ -125,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) @@ -145,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): @@ -209,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 @@ -224,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.", ) @@ -234,9 +281,10 @@ 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",) full = fetch(reference, """ SELECT customer_id, market_date, 1 AS visit_number FROM customer_purchases AS p @@ -247,10 +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 """) - 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)), "Only each customer's most recent visit is returned.", "The result does not contain only each customer's most recent visit.", ) @@ -258,11 +304,11 @@ 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",), - ) - actual = extract_result_rows(rows, columns, True) + # 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") 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 @@ -275,12 +321,21 @@ 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)] + # 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' + """)) 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.", - "The purchase counts or returned rows are incorrect.", + "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.", ) @@ -293,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.", ) @@ -309,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.", ) @@ -324,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 @@ -335,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.", ) @@ -356,11 +391,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 ( @@ -371,7 +401,7 @@ def grade_question_8(rows, reference): GROUP BY vendor_name, product_name """) return grading_outcome( - actual == count_result_rows(expected, ("vendor_name", "product_name", "possible_revenue")), + 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.", ) @@ -399,8 +429,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 8be05fdd7..0f2bb19e8 100644 --- a/03_instructional_team/tests/test_grader_regressions.py +++ b/03_instructional_team/tests/test_grader_regressions.py @@ -19,7 +19,7 @@ # 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"))) +G = runpy.run_path(str(Path(__file__).with_name("test_assignment_2.py"))) SOLUTION_RUBRIC = os.environ.get("A2_SOLUTION_RUBRIC") requires_private_solution = unittest.skipUnless( SOLUTION_RUBRIC, @@ -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.""" @@ -111,11 +154,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.""" @@ -123,6 +183,64 @@ 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:") + 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) @@ -132,6 +250,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.""" @@ -156,12 +313,54 @@ 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.""" 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."""