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.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.py b/03_instructional_team/tests/test_assignment.py index d0492ac04..fb9b31d52 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: @@ -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..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.""" @@ -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."""