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 be0eff3a5..72f8c71fd 100644 --- a/02_activities/assignments/DC_Cohort/Assignment2.md +++ b/02_activities/assignments/DC_Cohort/Assignment2.md @@ -14,6 +14,7 @@ * Open a private window in your browser. Copy and paste the link to your pull request into the address bar. Make sure you can see your pull request properly. This helps the technical facilitator and learning support staff review your submission easily. Checklist: +- [ ] 🚨🚨🚨 After forking, open the **Actions** tab in your own SQL repository. If prompted, click **I understand my workflows, go ahead and enable them** before opening your assignment pull request. This enables automatic SQL feedback on your pull request. - [ ] Create a branch called `assignment-two`. - [ ] Ensure that the repository is public. - [ ] Review [the PR description guidelines](https://github.com/UofT-DSI/onboarding/blob/main/onboarding_documents/submissions.md#guidelines-for-pull-request-descriptions) and adhere to them. @@ -69,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/DC_Cohort/assignment2.sql b/02_activities/assignments/DC_Cohort/assignment2.sql index 4079c18ae..836162703 100644 --- a/02_activities/assignments/DC_Cohort/assignment2.sql +++ b/02_activities/assignments/DC_Cohort/assignment2.sql @@ -1,200 +1,48 @@ -/* ASSIGNMENT 2 */ ---Please write responses between the QUERY # and END QUERY blocks -/* SECTION 2 */ - --- COALESCE -/* 1. Our favourite manager wants a detailed long list of products, but is afraid of tables! -We tell them, no problem! We can produce a list with all of the appropriate details. - -Using the following syntax you create our super cool and not at all needy manager a list: - -SELECT -product_name || ', ' || product_size|| ' (' || product_qty_type || ')' -FROM product - - -But wait! The product table has some bad data (a few NULL values). -Find the NULLs and then using COALESCE, replace the NULL with a blank for the first column with -nulls, and 'unit' for the second column with nulls. - -**HINT**: keep the syntax the same, but edited the correct components with the string. -The `||` values concatenate the columns into strings. -Edit the appropriate columns -- you're making two edits -- and the NULL rows will be fixed. -All the other rows will remain the same. */ --QUERY 1 - - - - +SELECT product_name FROM product; --END QUERY - ---Windowed Functions -/* 1. Write a query that selects from the customer_purchases table and numbers each customer’s -visits to the farmer’s market (labeling each market date with a different number). -Each customer’s first visit is labeled 1, second visit is labeled 2, etc. - -You can either display all rows in the customer_purchases table, with the counter changing on -each new market date for each customer, or select only the unique market dates per customer -(without purchase details) and number those visits. -HINT: One of these approaches uses ROW_NUMBER() and one uses DENSE_RANK(). -Filter the visits to dates before April 29, 2022. */ --QUERY 2 - - - - +SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; --END QUERY - -/* 2. Reverse the numbering of the query so each customer’s most recent visit is labeled 1, -then write another query that uses this one as a subquery (or temp table) and filters the results to -only the customer’s most recent visit. -HINT: Do not use the previous visit dates filter. */ --QUERY 3 - - - - +SELECT customer_id, market_date, 99 AS visit_number FROM customer_purchases; --END QUERY - -/* 3. Using a COUNT() window function, include a value along with each row of the -customer_purchases table that indicates how many different times that customer has purchased that product_id. - -You can make this a running count by including an ORDER BY within the PARTITION BY if desired. -Filter the visits to dates before April 29, 2022. */ --QUERY 4 - - - - +SELECT *, 99 AS product_purchase_count FROM customer_purchases; --END QUERY - --- String manipulations -/* 1. Some product names in the product table have descriptions like "Jar" or "Organic". -These are separated from the product name with a hyphen. -Create a column using SUBSTR (and a couple of other commands) that captures these, but is otherwise NULL. -Remove any trailing or leading whitespaces. Don't just use a case statement for each product! - -| product_name | description | -|----------------------------|-------------| -| Habanero Peppers - Organic | Organic | - -Hint: you might need to use INSTR(product_name,'-') to find the hyphens. INSTR will help split the column. */ --QUERY 5 - - - - +SELECT product_name, NULL AS description FROM product; --END QUERY - -/* 2. Filter the query to show any product_size value that contain a number with REGEXP. */ --QUERY 6 - - - - +SELECT * FROM product; --END QUERY - --- UNION -/* 1. Using a UNION, write a query that displays the market dates with the highest and lowest total sales. - -HINT: There are a possibly a few ways to do this query, but if you're struggling, try the following: -1) Create a CTE/Temp Table to find sales values grouped dates; -2) Create another CTE/Temp table with a rank windowed function on the previous query to create -"best day" and "worst day"; -3) Query the second temp table twice, once for the best day, once for the worst day, -with a UNION binding them. */ --QUERY 7 - - - - +SELECT market_date, 0 AS sales FROM customer_purchases GROUP BY market_date LIMIT 2; --END QUERY - - -/* SECTION 3 */ - --- Cross Join -/*1. Suppose every vendor in the `vendor_inventory` table had 5 of each of their products to sell to **every** -customer on record. How much money would each vendor make per product? -Show this by vendor_name and product name, rather than using the IDs. - -HINT: Be sure you select only relevant columns and rows. -Remember, CROSS JOIN will explode your table rows, so CROSS JOIN should likely be a subquery. -Think a bit about the row counts: how many distinct vendors, product names are there (x)? -How many customers are there (y). -Before your final group by you should have the product of those two queries (x*y). */ --QUERY 8 - - - - +SELECT vendor_name, product_name, 0 AS revenue FROM vendor CROSS JOIN product; --END QUERY - --- INSERT -/*1. Create a new table "product_units". -This table will contain only products where the `product_qty_type = 'unit'`. -It should use all of the columns from the product table, as well as a new column for the `CURRENT_TIMESTAMP`. -Name the timestamp column `snapshot_timestamp`. */ --QUERY 9 - - - - +CREATE TEMP TABLE product_units AS SELECT * FROM product; --END QUERY - -/*2. Using `INSERT`, add a new row to the product_units table (with an updated timestamp). -This can be any product you desire (e.g. add another record for Apple Pie). */ --QUERY 10 - - - - +SELECT 1; --END QUERY - --- DELETE -/* 1. Delete the older record for whatever product you added. - -HINT: If you don't specify a WHERE clause, you are going to have a bad time.*/ --QUERY 11 - - - - +DELETE FROM product_units WHERE 1 = 0; --END QUERY - --- UPDATE -/* 1.We want to add the current_quantity to the product_units table. -First, add a new column, current_quantity to the table using the following syntax. - -ALTER TABLE product_units -ADD current_quantity INT; - -Then, using UPDATE, change the current_quantity equal to the last quantity value from the vendor_inventory details. - -HINT: This one is pretty hard. -First, determine how to get the "last" quantity per product. -Second, coalesce null values to 0 (if you don't have null values, figure out how to rearrange your query so you do.) -Third, SET current_quantity = (...your select statement...), remembering that WHERE can only accommodate one column. -Finally, make sure you have a WHERE statement to update the right row, - you'll need to use product_units.product_id to refer to the correct row within the product_units table. -When you have all of these components, you can run the update statement. */ --QUERY 12 - - - - +ALTER TABLE product_units ADD current_quantity INT; +UPDATE product_units SET current_quantity = -1; --END QUERY - - - diff --git a/02_activities/assignments/Microcredential_Cohort/Assignment2.md b/02_activities/assignments/Microcredential_Cohort/Assignment2.md index 2c39be5ff..f1329042f 100644 --- a/02_activities/assignments/Microcredential_Cohort/Assignment2.md +++ b/02_activities/assignments/Microcredential_Cohort/Assignment2.md @@ -14,6 +14,7 @@ * Open a private window in your browser. Copy and paste the link to your pull request into the address bar. Make sure you can see your pull request properly. This helps the technical facilitator and learning support staff review your submission easily. Checklist: +- [ ] 🚨🚨🚨 After forking, open the **Actions** tab in your own SQL repository. If prompted, click **I understand my workflows, go ahead and enable them** before opening your assignment pull request. This enables automatic SQL feedback on your pull request. - [ ] Create a branch called `assignment-two`. - [ ] Ensure that the repository is public. - [ ] Review [the PR description guidelines](https://github.com/UofT-DSI/onboarding/blob/main/onboarding_documents/submissions.md#guidelines-for-pull-request-descriptions) and adhere to them. @@ -69,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."""