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."""