Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/grade_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
150 changes: 143 additions & 7 deletions .github/workflows/sql_assignment_runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:
Expand All @@ -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 += `<details> <summary>Click to expand/collapse assignment queries execution results</summary>\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 += `</details>`

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

Expand Down Expand Up @@ -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 += `<details>\n<summary>SQL and output</summary>\n\n${details}\n</details>\n\n`;
} else {
resultSection += details;
}
resultSection += '\n';
if (!appendSection(resultSection)) break reportLoop;
}
Expand Down
1 change: 1 addition & 0 deletions 02_activities/assignments/DC_Cohort/Assignment2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions 03_instructional_team/tests/test_assignment_1.py
Original file line number Diff line number Diff line change
@@ -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 <n>
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 <n>' 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)

Loading