SEN2104 · Week 7 · Structured Query Language (SQL)

Advanced SQL: GROUP BY, NULL, subqueries, DML and views

Aggregate functions, GROUP BY/HAVING, the three-valued logic of NULL, IN/EXISTS/correlated subqueries, set operations, INSERT/UPDATE/DELETE and views. The hardest SQL questions on the midterm come from here.

aggregate functionCOUNT(*)GROUP BYHAVINGNULLthree-valued logicunknownIS NULLsubquerynested queryIN / NOT INEXISTS / NOT EXISTScorrelated subqueryscalar subqueryALL / SOMEdivisionUNIONINTERSECTEXCEPTUNION ALLINSERTUPDATEDELETECASEview

In week six you selected and joined individual rows. This week we summarize rows (how many, what is the average), put queries inside queries, and change the data. The hard questions in the SQL part of the midterm almost always rest on one of these three things: the GROUP BY/HAVING distinction, the strange behavior of NULL, and the “those who did every X” (division) question.

Golden rules:

  1. WHERE filters rows, HAVING filters groups. An aggregate function cannot go inside WHERE.
  2. A comparison with NULL gives UNKNOWN, not TRUE or FALSE. WHERE keeps only the rows that are TRUE.
  3. NOT IN and NULL together are poison. If there is a NULL in the subquery, NOT IN returns no rows.
  4. A question with “every/all” is a division question. Write it with a double NOT EXISTS.
  5. COUNT(*) counts rows, COUNT(column) counts non-NULL values.

The sample database is the same as in week 6 (department, instructor, student, course, teaches, takes).

Aggregate functions

There are five basic aggregates: COUNT, SUM, AVG, MIN, MAX. They reduce the many values in a column to one value.

SELECT COUNT(*), COUNT(grade), COUNT(DISTINCT stu_id) FROM takes;
COUNT(*)COUNT(grade)COUNT(DISTINCT stu_id)
14136

There are 14 records. One has a NULL grade (Cem, CS101), so COUNT(grade) is 13. The number of distinct students who take courses is 6 (Firat is missing).

SELECT SUM(salary), AVG(salary), MIN(salary), MAX(salary) FROM instructor;
SUM(salary)AVG(salary)MIN(salary)MAX(salary)
462000770006000095000

In SQLite, AVG always returns REAL (typeof = real), and SUM stays INTEGER on an integer column. That is why AVG(credits) gives 3.4285714285714284. But 7/2 is still 3: only AVG switches to decimals on its own.

On an empty set COUNT returns 0 and the others return NULL: with WHERE salary > 100000, COUNT(*), SUM(salary), AVG(salary) gives 0, NULL, NULL.

GROUP BY and HAVING

GROUP BY splits rows into groups by a common value, and the aggregate is applied to each group separately.

SELECT dept_name, COUNT(*) AS n, AVG(salary) AS avg_sal
FROM instructor GROUP BY dept_name;
dept_namenavg_sal
BIO160000
CS287500
EE185000
MATH271000

HAVING puts a condition on the groups:

SELECT dept_name, AVG(salary) AS avg_sal
FROM instructor GROUP BY dept_name HAVING AVG(salary) > 80000;
dept_nameavg_sal
CS87500
EE85000

WHERE and HAVING together

“Departments that have at least 2 students among those with more than 20 credits”:

SELECT dept_name, COUNT(*) AS n
FROM student
WHERE tot_cred > 20
GROUP BY dept_name
HAVING COUNT(*) >= 2;
dept_namen
CS2

Step by step: WHERE first drops Ece (15), Firat (0) and Gizem (10). The rest are grouped: CS 2 (Ada, Bora), EE 1 (Cem), MATH 1 (Defne). HAVING leaves only CS.

FROM student          7 rows
WHERE tot_cred > 20   4 rows: Ada, Bora, Cem, Defne
GROUP BY dept_name    CS{Ada,Bora}  EE{Cem}  MATH{Defne}
HAVING COUNT(*) >= 2  CS
SELECT                CS, 2

PITFALL · an ungrouped column. In standard SQL, every column in the SELECT list that is not an aggregate must be in GROUP BY. SELECT dept_name, name, MAX(salary) FROM instructor GROUP BY dept_name gives an error in PostgreSQL. SQLite gives no error and picks a row from the group for name (when used with MAX, the name of the highest paid: Aksoy, Kaya, Celik, Yildiz). In the exam, count this query as wrong.

Counting zeros

The number of students in each course, including those with no students:

SELECT c.course_id, COUNT(t.stu_id) AS enrolled
FROM course c LEFT JOIN takes t ON c.course_id = t.course_id
GROUP BY c.course_id;
course_idenrolled
BI1000
CS1014
CS2012
CS3052
EE2101
MA1013
MA2022

If you had written COUNT(*), BI100 would give 1, because LEFT JOIN brings it with the row (BI100, NULL, NULL).

NULL and three-valued logic

NULL means “unknown” or “absent”. In arithmetic it is contagious: 5 + NULL = NULL. In comparison the result is UNKNOWN: even NULL = NULL is UNKNOWN.

ANDTRUEUNKNOWNFALSE
TRUETRUEUNKNOWNFALSE
UNKNOWNUNKNOWNUNKNOWNFALSE
FALSEFALSEFALSEFALSE

For OR: TRUE OR UNKNOWN = TRUE, FALSE OR UNKNOWN = UNKNOWN. NOT UNKNOWN = UNKNOWN. In SQLite, SELECT NULL = NULL, NULL AND 0, NULL OR 1, NULL AND 1, NOT NULL gives NULL, 0, 1, NULL, NULL (0 = FALSE, 1 = TRUE).

Consequences:

  • WHERE dept_name = NULL returns no rows. The right way is WHERE dept_name IS NULL → Gizem.
  • WHERE dept_name <> 'CS' returns 3 rows (Cem, Defne, Firat). Gizem does not come: NULL <> 'CS' is UNKNOWN.
  • WHERE grade = 'A' OR grade <> 'A' gives 13 rows, not 14. A condition that looks like “everything” misses NULL.
  • GROUP BY, on the other hand, treats NULLs as one group: GROUP BY dept_name on students gives NULL 1, CS 3, EE 2, MATH 1.

Subqueries

IN

Students who took a course taught by I1 (Aksoy):

SELECT name FROM student
WHERE stu_id IN (SELECT t.stu_id FROM takes t JOIN teaches te ON t.course_id = te.course_id
                 WHERE te.inst_id = 'I1');

Result: Ada, Bora, Cem, Defne, Ece. Aksoy teaches CS101 and CS305; CS101 was taken by S1, S2, S3, S5, and CS305 by S1, S4. IN produces no duplicates, so Ada appears once.

The NOT IN trap and NOT EXISTS

Let us look for departments with no students:

SELECT dept_name FROM department
WHERE dept_name NOT IN (SELECT dept_name FROM student);

The result is empty. The subquery also returns Gizem’s NULL. 'BIO' NOT IN (CS, EE, MATH, NULL) actually means 'BIO' <> 'CS' AND ... AND 'BIO' <> NULL, and the last term is UNKNOWN. The right way:

SELECT d.dept_name FROM department d
WHERE NOT EXISTS (SELECT * FROM student s WHERE s.dept_name = d.dept_name);
dept_name
BIO

PITFALL · NOT IN + NULL. If the subquery returns even a single NULL, NOT IN is never TRUE. Either use NOT EXISTS or add WHERE ... IS NOT NULL to the subquery. IN has no such problem.

EXISTS and the correlated subquery

EXISTS (subquery) is TRUE if the subquery returns at least one row. If the subquery refers to the outer row, it is called correlated; it is re-evaluated for each outer row.

SELECT c.course_id FROM course c
WHERE EXISTS (SELECT * FROM takes t WHERE t.course_id = c.course_id AND t.grade = 'A');

Result: CS101, CS305, EE210, MA101, MA202 (courses in which at least one A was given).

Those who earn more than their own department’s average:

SELECT i.name, i.salary FROM instructor i
WHERE i.salary > (SELECT AVG(i2.salary) FROM instructor i2 WHERE i2.dept_name = i.dept_name);
namesalary
Aksoy95000
Celik72000

Scalar subquery

A subquery that returns one row and one column can be used like a value, even in the SELECT list:

SELECT d.dept_name,
       (SELECT COUNT(*) FROM instructor i WHERE i.dept_name = d.dept_name) AS n_inst
FROM department d;

Result: BIO 1, CS 2, EE 1, MATH 2. A subquery can also be in FROM: SELECT ... FROM (SELECT dept_name, AVG(salary) AS avg_sal FROM instructor GROUP BY dept_name) WHERE avg_sal > 80000 gives the same result as the HAVING example.

ALL and SOME

In standard SQL, x > ALL (set) means “greater than every element of the set”, and x > SOME (set) (or ANY) means “greater than at least one”.

Standard SQLEquivalent (works in SQLite)Result for the MATH salaries
salary > ALL (MATH salaries)salary > (SELECT MAX(...))Aksoy, Demir, Kaya
salary > SOME (MATH salaries)salary > (SELECT MIN(...))Aksoy, Demir, Kaya, Celik
= SOMEIN
<> ALLNOT IN

PITFALL · no ALL/SOME in SQLite. > ALL (...) gives a syntax error in SQLite. In the exam, know the standard form, and in SQLite write it with MAX/MIN. Celik at 72000 is greater than the 70000 in MATH but not greater than 72000 (himself): he is in SOME, not in ALL.

Division: “those who took all of them”

There is no direct SQL counterpart of the ÷\div operation in relational algebra. Translate the question “students who took all CS courses” into “students for whom there is no CS course not taken”:

SELECT s.name FROM student s
WHERE NOT EXISTS (
  SELECT c.course_id FROM course c WHERE c.dept_name = 'CS'
  EXCEPT
  SELECT t.course_id FROM takes t WHERE t.stu_id = s.stu_id);
name
Ada

For each student, “the CS courses minus the ones they took” is computed. If the set is empty, the student took all of them. The same query can also be written with a double NOT EXISTS (the questions section has the MATH example).

Set operations

OperationMeaningDuplicates
UNION∪\cupremoves
INTERSECT∩\capremoves
EXCEPT (Oracle: MINUS)−-removes
UNION ALLmultiset unionkeeps
SELECT course_id FROM takes WHERE stu_id = 'S1'
INTERSECT
SELECT course_id FROM takes WHERE stu_id = 'S2';

Result: CS101, CS201. If you turn it into EXCEPT (S1 minus S2), you get CS305, MA101. SELECT course_id FROM course EXCEPT SELECT course_id FROM takes gives the course nobody took: BI100.

SELECT dept_name FROM instructor UNION SELECT dept_name FROM student returns 5 rows (NULL, BIO, CS, EE, MATH); with UNION ALL, 6 + 7 = 13. The two sides must have compatible numbers of columns and types. Standard SQL also has INTERSECT ALL and EXCEPT ALL; SQLite does not.

Changing data: INSERT, UPDATE, DELETE

Each example was run on a fresh copy of the database.

INSERT INTO course (course_id, title, dept_name, credits)
VALUES ('CS410', 'Machine Learning', 'CS', 3);

-- enroll the CS students with 30+ credits in the new course
INSERT INTO takes (stu_id, course_id, grade)
SELECT stu_id, 'CS410', NULL FROM student WHERE dept_name = 'CS' AND tot_cred >= 30;

The second statement inserts two rows, for S1 and S2.

UPDATE instructor SET salary = salary * 1.05 WHERE salary < 75000;

Sahin becomes 73500, Celik 75600, Yildiz 63000, and the others do not change. If there are two different rules, use CASE in a single statement; if you write two separate UPDATEs, a row raised by the first may fall under the condition of the second:

UPDATE instructor
SET salary = CASE WHEN salary < 80000 THEN salary + 5000 ELSE salary + 2000 END;

Result: Aksoy 97000, Demir 82000, Kaya 87000, Sahin 75000, Celik 77000, Yildiz 65000.

DELETE FROM takes WHERE grade = 'F';                     -- 14 -> 13 rows
DELETE FROM takes
WHERE stu_id IN (SELECT stu_id FROM student WHERE tot_cred < 20);   -- 13 -> 12

DELETE FROM instructor WHERE salary < (SELECT AVG(salary) FROM instructor) is interesting: does the average change during the deletion? No. SQL computes the subquery once before deleting (77000) and then deletes. On a copy with FK checking off, Sahin, Celik and Yildiz are deleted. With FK on, the statement is rejected, because Sahin and Celik teach courses in the teaches table.

Views

A view is a saved query that has a name. It copies no data; its definition runs again every time it is used.

CREATE VIEW cs_roster AS
SELECT s.name, t.course_id, t.grade
FROM student s JOIN takes t ON s.stu_id = t.stu_id
WHERE s.dept_name = 'CS';

SELECT course_id, COUNT(*) AS n FROM cs_roster GROUP BY course_id;
course_idn
CS1013
CS2012
CS3051
MA1011

When a record for a new CS student is added to the takes table, the view grows from 7 to 8 rows on its own. Views are used for security (showing a user only some columns) and for simplifying complex queries. INSERT through a view is not possible in every system; SQLite says cannot modify cs_roster because it is a view.

Common mistakes

WrongRight
WHERE AVG(salary) > 75000An aggregate condition goes in HAVING
SELECT dept_name, name, MAX(salary) ... GROUP BY dept_namename is not grouped; an error in standard SQL
Inner join + COUNT(*) for groups with zerosLEFT JOIN + COUNT(right.column)
WHERE x = NULLWHERE x IS NULL
NOT IN (subquery) when the subquery contains NULLNOT EXISTS or an IS NOT NULL filter
Counting rows with COUNT(grade)NULL grades are not counted; use COUNT(*) for rows
IN for “took all of them”IN gives “at least one”; for division use a double NOT EXISTS
> SOME instead of > ALLALL = greater than the largest, SOME = greater than the smallest
Thinking UNION keeps duplicatesThe one that keeps them is UNION ALL
Doing two rules with two separate UPDATEsThey can interact; use a single UPDATE + CASE
Thinking a view is a copy of a tableIt is a saved query; the data is recomputed every time

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

What does the query below return?

SELECT COUNT(*), COUNT(grade)
FROM takes
WHERE course_id = 'CS101';
Show answer
4, 3

Those who took CS101: S1 (A), S2 (B), S3 (NULL), S5 (F). Four rows, so COUNT(*) = 4.COUNT(grade) counts the non-NULL values. Cem's grade is NULL, so it is not counted: 3.

Question 2medium

Write an SQL query that lists each department name with the average salary of its instructors, but only for departments whose average salary is above 75000.

Show answer
SELECT dept_name, AVG(salary) FROM instructor GROUP BY dept_name HAVING AVG(salary) > 75000;

Because the condition is per group, it is HAVING, not WHERE.
Group averages: BIO 60000, CS (95000 + 80000)/2 = 87500, EE 85000, MATH (70000 + 72000)/2 = 71000.
Above 75000: CS 87500, EE 85000.

Question 3medium

Write an SQL query that lists every course_id with the number of students enrolled in it. Courses with no students must appear with 0.

Show answer
SELECT c.course_id, COUNT(t.stu_id) FROM course c LEFT JOIN takes t ON c.course_id = t.course_id GROUP BY c.course_id;

There are two traps. If you use an inner join, BI100 never appears, so use LEFT JOIN.
If you write COUNT(*), BI100 gives 1, because LEFT JOIN brings it as a row full of NULLs. COUNT(t.stu_id) does not count NULL and gives 0.
Result: BI100 0, CS101 4, CS201 2, CS305 2, EE210 1, MA101 3, MA202 2.

Question 4hard

The query below is meant to list departments that have no students. What does it return on our database, why, and how do you fix it?

SELECT dept_name FROM department
WHERE dept_name NOT IN (SELECT dept_name FROM student);
Show answer
Empty result, because the subquery contains a NULL (Gizem); fix with NOT EXISTS or add WHERE dept_name IS NOT NULL in the subquery (then BIO)

The subquery returns: CS, CS, EE, MATH, CS, EE, NULL.
For BIO, 'BIO' NOT IN (..., NULL) means 'BIO' <> 'CS' AND ... AND 'BIO' <> NULL. The last term is unknown. TRUE AND unknown = unknown, so the row is not selected. No department gets TRUE: the result is empty.
Fix 1: NOT EXISTS (SELECT * FROM student s WHERE s.dept_name = d.dept_name).
Fix 2: add WHERE dept_name IS NOT NULL to the subquery. Both return BIO.

Question 5medium

Write an SQL query using NOT EXISTS that lists the names of students who have not taken any course.

Show answer
SELECT name FROM student s WHERE NOT EXISTS (SELECT * FROM takes t WHERE t.stu_id = s.stu_id);

A correlated subquery: for each outer row s, the inner query looks for that student's records. If there are no rows, NOT EXISTS is TRUE.
The only student with no records: Firat (S6).

Question 6hard

Write an SQL query that lists the names of students who have taken every course offered by the MATH department.

Show answer
SELECT s.name FROM student s WHERE NOT EXISTS (SELECT * FROM course c WHERE c.dept_name = 'MATH' AND NOT EXISTS (SELECT * FROM takes t WHERE t.stu_id = s.stu_id AND t.course_id = c.course_id));

An "every" question is a division question. In SQL it is written with a double NOT EXISTS: "students for whom there is no MATH course they did not take".
The MATH courses: MA101, MA202. Those who took MA101: S1, S3, S4; those who took MA202: S4, S7. Only S4 took both: Defne.
The same question can also be written as NOT EXISTS (MATH courses EXCEPT the student's courses). In relational algebra, πstu_id,course_id(takes)÷πcourse_id(σdept_name=′MATH′(course))\pi_{stu\_id,course\_id}(takes) \div \pi_{course\_id}(\sigma_{dept\_name='MATH'}(course)) also gave S4.

Question 7medium

In standard SQL, the query below lists instructors earning more than every MATH instructor. SQLite rejects it. Rewrite it so it runs in SQLite and give the result.

SELECT name FROM instructor
WHERE salary > ALL (SELECT salary FROM instructor
                    WHERE dept_name = 'MATH');
Show answer
SELECT name FROM instructor WHERE salary > (SELECT MAX(salary) FROM instructor WHERE dept_name = 'MATH'); -- Aksoy, Demir, Kaya

> ALL (set) means being greater than the largest value in the set. So it is the same as > (SELECT MAX(...)). (> SOME becomes > MIN.)
The MATH salaries are 70000 and 72000, and the largest is 72000. Those above it: Aksoy 95000, Demir 80000, Kaya 85000.
SQLite does not support ALL/SOME/ANY comparisons (syntax error). PostgreSQL and Oracle do.
Note: if MATH had no instructors at all, > ALL would give TRUE for every row, while > MAX (a comparison with NULL) would give no rows.

Question 8easy

teaches has 6 rows and takes has 14 rows. How many rows does each query return? (a) SELECT course_id FROM teaches UNION ALL SELECT course_id FROM takes; (b) the same with UNION.

Show answer
(a) 20, (b) 6

UNION ALL keeps duplicates: 6 + 14 = 20.UNION removes duplicates. The distinct course_id values in the two tables are CS101, CS201, CS305, EE210, MA101, MA202, that is, 6. BI100 is in neither.

Question 9medium

Write an SQL query that lists the name and salary of instructors who earn more than the average salary of their own department.

Show answer
SELECT i.name, i.salary FROM instructor i WHERE i.salary > (SELECT AVG(i2.salary) FROM instructor i2 WHERE i2.dept_name = i.dept_name);

Because it says "their own department", the subquery depends on the outer row: a correlated subquery. The inner i2.dept_name = i.dept_name makes that link.
Department averages: CS 87500, EE 85000, MATH 71000, BIO 60000.
Those above the average: Aksoy (95000 > 87500), Celik (72000 > 71000). Kaya and Yildiz are the only instructors of their departments, so they are equal to the average, not greater.

Question 10medium

After running the statement below, what is Ada's (S1) tot_cred? Which other students change?

UPDATE student
SET tot_cred = tot_cred + 5
WHERE stu_id IN (SELECT stu_id FROM takes WHERE grade = 'A');
Show answer
65; Cem (S3) becomes 50 and Defne (S4) becomes 95

The subquery finds those with an A: S1 (two of them), S3, S4. IN asks for membership, so S1 having two A grades does not update it twice.
Ada 60 + 5 = 65, Cem 45 + 5 = 50, Defne 90 + 5 = 95. The others do not change.

Question 11easy

Find the bug and fix it.

SELECT dept_name
FROM instructor
WHERE AVG(salary) > 75000
GROUP BY dept_name;
Show answer
Aggregates are not allowed in WHERE; use: SELECT dept_name FROM instructor GROUP BY dept_name HAVING AVG(salary) > 75000;

WHERE is applied to individual rows before grouping. At that point there are no groups yet, so AVG cannot be computed. SQLite message: misuse of aggregate function AVG(). (Also, WHERE cannot be written after GROUP BY.)
The group condition is written with HAVING. The corrected query returns CS and EE.

Question 12medium

Give the result table of SELECT grade, COUNT(*) FROM takes GROUP BY grade ORDER BY grade;

Show answer
NULL 1; A 5; B 5; C 2; F 1

GROUP BY collects NULL values into a single group (unlike the = logic in WHERE). Since COUNT(*) is used here, the NULL group counts 1 (Cem's CS101). With COUNT(grade) that row would be 0.
A: S1-CS101, S1-CS305, S3-EE210, S4-MA101, S4-MA202 = 5. B: S1-CS201, S1-MA101, S2-CS101, S4-CS305, S7-MA202 = 5. C: S2-CS201, S3-MA101 = 2. F: S5-CS101 = 1.
In ascending order SQLite puts NULL first. The total 1 + 5 + 5 + 2 + 1 = 14 = the number of rows in takes.

Verification: Every SQL statement on the page and every question answer was run with node:sqlite (SQLite 3.53.4) on the sample university database; each DML example on a fresh copy of the database. That ALL/SOME give an error in SQLite and that the MAX/MIN equivalents give the same result was verified with both SQL and JS; the division query was compared with the relational algebra division in JS (verify/SEN2104/07.mjs).

Review card

COUNT(*) counts rows; COUNT(col), SUM, AVG, MIN, MAX skip NULLs On an empty set COUNT = 0, SUM/AVG/MIN/MAX = NULL Order: FROM -> WHERE (row) -> GROUP BY -> HAVING (group) -> SELECT -> ORDER BY Every non-aggregate column in SELECT must be in GROUP BY (SQLite does not enforce it) "show the zeros too" = LEFT JOIN + COUNT(right_table.col) Every comparison with NULL is UNKNOWN; WHERE keeps only TRUE TRUE AND U = U, FALSE AND U = FALSE, TRUE OR U = TRUE, NOT U = U x NOT IN (..., NULL) is never TRUE -> use NOT EXISTS "every/all" question = double NOT EXISTS (or NOT EXISTS (A EXCEPT B)) > ALL = > MAX, > SOME = > MIN (no ALL/SOME in SQLite) UNION/INTERSECT/EXCEPT remove duplicates; UNION ALL keeps them UPDATE ... SET x = CASE WHEN ... THEN ... ELSE ... END The subquery in DELETE is computed once before deleting VIEW = saved query, holds no data, runs again every time it is read