SEN2104 · Week 5 · Relational Algebra

Relational algebra: joins, division and grouping

Writing multi-table queries with theta, natural and outer joins, division, assignment and grouping (γ); solution patterns for “for every” and “for each” questions.

theta joinequijoinnatural joinleft outer joinright outer joinfull outer joindivisionassignmentgeneralized projectionaggregate functiongrouping

Last week you saw that you can write any query with six fundamental operators. But writing the σ(R×S)\sigma(R \times S) pattern every time is both long and error-prone. This week brings the shorthands: joins, outer joins, division and grouping (aggregation). Two patterns that earn the most points in the exam are here: multi-table joins and “for every” = division.

Golden rules:

  1. A natural join equates all columns with the same name. Not only the one you want.
  2. NULL equals no value. A row with NULL disappears in an inner join; an outer join brings it back.
  3. The moment you see “every / all”, think division.
  4. Grouping builds groups only from values that exist. A value that never appears does not show up with 0.

The tables are the sample database from last week: student, course, takes, instructor, department, teaches.

Theta join and equijoin

A theta join is the product and selection written with a single symbol:

R⋈θS=σθ(R×S)R \bowtie_{\theta} S = \sigma_{\theta}(R \times S)

θ\theta can be any condition (<,≠,≥<, \neq, \ge …). If the condition consists only of equalities, it is called an equijoin.

Example: pairs of instructors in the same department where one earns more than the other. Since we join the table with itself, ρ is required:

πi.name, j.name(ρi(instructor)⋈i.dept_name=j.dept_name∧i.salary>j.salaryρj(instructor))\pi_{i.name,\, j.name}(\rho_{i}(instructor) \bowtie_{i.dept\_name = j.dept\_name \land i.salary > j.salary} \rho_{j}(instructor))

i.namej.name
AksoyDemir
CelikSahin

EE and BIO have only one instructor each, so no pair appears. If you had written ≠\neq instead of >>, each pair would come twice: (Aksoy, Demir) and (Demir, Aksoy).

Natural join: ⋈

The natural join R⋈SR \bowtie S does three things:

  1. take R×SR \times S,
  2. select equality for every pair of columns with the same name,
  3. keep repeated columns once.

instructor⋈teachesinstructor \bowtie teaches: the only common column is inst_id.

inst_idnamedept_namesalarycourse_id
I1AksoyCS95000CS101
I1AksoyCS95000CS305
I2DemirCS80000CS201
I3KayaEE85000EE210
I4SahinMATH70000MA101
I5CelikMATH72000MA202

6 rows, 5 columns. Yildiz (I6) is not there because she has no match. If there is no common column at all, the natural join turns into a plain product.

PITFALL · a common column you did not want. When you write student⋈takes⋈coursestudent \bowtie takes \bowtie course, student.dept_name and course.dept_name are equated too. The result is 9 records, not 14: only the courses that students took from their own department remain. Ada’s MA101, Cem’s MA101 and CS101, Defne’s CS305, and everything of Gizem’s disappear. Fix: before the join, drop the extra column with πcourse_id, title, credits(course)\pi_{course\_id,\, title,\, credits}(course), or write an explicit theta condition.

The natural join is commutative and associative: (R⋈S)⋈T=R⋈(S⋈T)(R \bowtie S) \bowtie T = R \bowtie (S \bowtie T). The column order may change, but the content stays the same.

Chained joins

Instructors who teach the courses of the departments in the Galata building: teaches → course (course_id) → department (dept_name).

πinst_id(teaches⋈πcourse_id, dept_name(course)⋈σbuilding=’Galata’(department))\pi_{inst\_id}(teaches \bowtie \pi_{course\_id,\, dept\_name}(course) \bowtie \sigma_{building = \text{'Galata'}}(department))

Galata has CS and EE. The courses of these departments are taught by I1, I2 and I3.

Outer joins

An inner join drops rows that have no partner. Sometimes that row is exactly what you want: “instructors who teach no course should also appear in the list”. An outer join keeps the unmatched rows and fills the missing columns with NULL.

SymbolNamePreserved
⟕left outer joinunmatched rows on the left
⟖right outer joinunmatched rows on the right
⟗full outer jointhe ones on both sides

instructor ⟕ teaches (some of the columns):

inst_idnamecourse_id
I1AksoyCS101
I1AksoyCS305
I2DemirCS201
I3KayaEE210
I4SahinMA101
I5CelikMA202
I6YildizNULL

teaches ⟖ course, on the other hand, brings BI100 (inst_id = NULL). A right outer join is a left outer join with the sides swapped: R ⟖ S = S ⟕ R (except for column order).

student ⟗ department (on dept_name):

stu_idnamedept_namebuilding
S1AdaCSGalata
S2BoraCSGalata
S3CemEEGalata
S4DefneMATHRumeli
S5EceCSGalata
S6FiratEEGalata
S7GizemNULLNULL
NULLNULLBIOKadikoy

Gizem is unmatched on the left (NULL department), BIO is unmatched on the right (it has no students). 6 + 1 + 1 = 8 rows.

PITFALL · SQL dialects. Standard SQL has FULL OUTER JOIN. SQLite has supported it since version 3.39; older SQLite and MySQL do not. In that case you imitate it by taking a left outer join and a UNION with the unmatched rows on the right side. The result on this page was verified both ways.

Division: ÷

Division answers the question: “Which A values are matched with every value in B?” The result of R(A,B)÷S(B)R(A, B) \div S(B) is the aa values for which (a,b)∈R(a, b) \in R holds for every bb in SS.

Example: students who took all the CS courses.

πstu_id, course_id(takes)÷πcourse_id(σdept_name=’CS’(course))\pi_{stu\_id,\, course\_id}(takes) \div \pi_{course\_id}(\sigma_{dept\_name = \text{'CS'}}(course))

The divisor is {CS101, CS201, CS305}. Result: S1. Bora is eliminated because he did not take CS305, and Ece because she did not take CS201 and CS305.

Deriving it from the fundamental operators

Division is not fundamental:

R÷S=πA(R)−πA((πA(R)×S)−R)R \div S = \pi_{A}(R) - \pi_{A}((\pi_{A}(R) \times S) - R)

The idea: everyone minus “those who are missing at least one”. Step by step (T=πstu_id,course_id(takes)T = \pi_{stu\_id, course\_id}(takes), CSCS = the CS courses):

1) π_stu_id(T)                   = {S1, S2, S3, S4, S5, S7}       candidate students
2) π_stu_id(T) × CS              = 6 · 3 = 18 rows                if everyone had taken every CS course
3) (2) − T                       = 10 rows                         the missing (student, course) pairs
     S2-CS305  S3-CS201  S3-CS305  S4-CS101  S4-CS201
     S5-CS201  S5-CS305  S7-CS101  S7-CS201  S7-CS305
4) π_stu_id(3)                   = {S2, S3, S4, S5, S7}           those with something missing
5) (1) − (4)                     = {S1}

You write the same query in SQL with a double NOT EXISTS (“a student for whom there is no CS course they did not take”). You will see it in week 7.

PITFALL · the columns of the dividend. RR should have only the columns AA and BB. If you write takes÷...takes \div ... and leave grade in, AA becomes (stu_id, grade) and the query asks something completely different. Always narrow with π\pi before the division.

Assignment: ←

To break up long expressions, you define temporary relations:

cs_courses ← π_course_id(σ_{dept_name='CS'}(course))
enrol      ← π_stu_id,course_id(takes)
result     ← enrol ÷ cs_courses

Assignment adds no new power; it adds readability. Using it in complex exam questions does not cost points, and most of the time it keeps you from making mistakes.

Generalized projection

Generalized projection allows arithmetic expressions in the π list. A 10% raise for the MATH instructors:

πname,  salary⋅1.1(σdept_name=’MATH’(instructor))\pi_{name,\; salary \cdot 1.1}(\sigma_{dept\_name = \text{'MATH'}}(instructor))

Result: Sahin 77000, Celik 79200.

Grouping and aggregate functions: 𝒢

Aggregate functions produce a single value from a set of values: count, sum, avg, min, max. The grouping symbol:

G1,…,GnGF1(A1),…(E){}_{G_1, \dots, G_n}\mathcal{G}_{F_1(A_1), \dots}(E)

The subscript on the left holds the grouping columns, and on the right are the aggregate functions. Some sources use the letter γ\gamma, with the same meaning.

Average salary and number of instructors per department:

dept_nameGavg(salary), count(inst_id)(instructor){}_{dept\_name}\mathcal{G}_{\text{avg}(salary),\ \text{count}(inst\_id)}(instructor)

dept_nameavg(salary)count(inst_id)
CS875002
EE850001
MATH710002
BIO600001

If you write no grouping column, the whole table becomes one group: Gsum(salary), max(salary)(instructor)\mathcal{G}_{\text{sum}(salary),\ \text{max}(salary)}(instructor) = (462000, 95000).

Rules:

  • Aggregate functions skip NULL values. Gcount(dept_name)(student)\mathcal{G}_{\text{count}(dept\_name)}(student) = 6, while the table has 7 rows.
  • For a condition on the group result (HAVING in SQL), you write σ on top of the grouping. For this, name the aggregate result with as: σavg_sal>75000(dept_nameGavg(salary) as avg_sal(instructor))\sigma_{avg\_sal > 75000}({}_{dept\_name}\mathcal{G}_{\text{avg}(salary) \text{ as } avg\_sal}(instructor)) → CS, EE.
  • No group is created for a value that does not appear in the input.

Which operator for which question

Phrase in the questionPattern
“… with their department’s building”⋈ (natural or equi)
“including those who have none”outer join
“pairs of … in the same …”self theta join with ρ
“took every / all …”÷
“never / no …”difference
“for each department, the number of …”deptGcount{}_{dept}\mathcal{G}_{\text{count}}
“departments where the average … exceeds”𝒢 then σ

Common mistakes

Wrong ideaThe truth
⋈ equates only the key columnIt equates all columns with the same name
student⋈takes⋈coursestudent \bowtie takes \bowtie course gives all recordsdept_name is matched too, 9 rows remain
The number of rows of an outer join = the number of rows of the left tableA row can match many times; Aksoy appears twice
NULL = NULL matchesIt does not; Gizem matched no department
Solving an “every” question with ∩The size of the divisor set can change; use ÷
Skipping π before divisionExtra columns get mixed into AA and the result breaks
Grouping also shows a group with count 0A value that never appears does not show up (no BI100)
count(A) is the number of rowsIt skips NULLs; use count(*) for rows

Test yourself

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

Question 1easy

How many tuples are in the result of instructor ⟕ teaches (left outer join on inst_id)?

Show answer
7

The inner join instructor⋈teachesinstructor \bowtie teaches gives 6 rows (Aksoy appears twice because he teaches two courses). The left outer join also adds the unmatched left rows with NULL. The only instructor who teaches no course is I6 (Yildiz): 6 + 1 = 7.

Question 2easy

Write a relational algebra expression for the names of instructors who teach CS305. Give the result.

Show answer
π_name(σ_{course_id='CS305'}(instructor ⋈ teaches)) = Aksoy

instructorinstructor and teachesteaches join naturally on their only common column, inst_id. Then the row condition, and the projection last:πname(σcourse_id=’CS305’(instructor⋈teaches))\pi_{name}(\sigma_{course\_id = \text{'CS305'}}(instructor \bowtie teaches)). Only I1 teaches CS305: Aksoy.

Question 3medium

How many tuples does student⋈takes⋈coursestudent \bowtie takes \bowtie course (natural joins) return on the sample data? Why is it not 14?

Show answer
9

student⋈takesstudent \bowtie takes gives 14 rows on the common column stu_id. But the columns common with course are course_id and dept_name. The natural join equates both, so only the courses from the student's own department remain: Ada 3, Bora 2, Cem 1 (EE210), Defne 2, Ece 1. Total 9. Gizem's department is NULL, so she never matches. If you want all the records, first narrow course with πcourse_id,title,credits\pi_{course\_id, title, credits}.

Question 4medium

Write a relational algebra expression for the IDs of students who took every MATH course. Give the result.

Show answer
π_{stu_id,course_id}(takes) ÷ π_course_id(σ_{dept_name='MATH'}(course)) = S4

The “every / all” pattern is division. The divisor: the MATH courses {MA101, MA202}.πstu_id,course_id(takes)÷πcourse_id(σdept_name=’MATH’(course))\pi_{stu\_id, course\_id}(takes) \div \pi_{course\_id}(\sigma_{dept\_name = \text{'MATH'}}(course)).
Students who took MA101: S1, S3, S4. Students who took MA202: S4, S7. Only S4 took both.

Question 5medium

Give the result of course_idGcount(stu_id)(takes){}_{course\_id}\mathcal{G}_{\text{count}(stu\_id)}(takes). Does BI100 appear?

Show answer
CS101 4, CS201 2, CS305 2, EE210 1, MA101 3, MA202 2; BI100 does not appear

The takes rows are grouped by course_id, and the rows in each group are counted. CS101: S1, S2, S3, S5 = 4. CS201: 2. CS305: 2. EE210: 1. MA101: S1, S3, S4 = 3. MA202: 2.
Grouping creates groups only from values that exist in the input. BI100 does not appear in takes at all, so it does not show up even with 0. If you want to see 0, you must do an outer join first.

Question 6medium

How many students in CS101 have a grade, i.e. what is Gcount(grade)(σcourse_id=’CS101’(takes)){}\mathcal{G}_{\text{count}(grade)}(\sigma_{course\_id = \text{'CS101'}}(takes))?

Show answer
3

There are 4 records in CS101: S1 (A), S2 (B), S3 (NULL), S5 (F). count(grade) does not count NULL values: 3. If you wanted the number of rows, you would get 4 with count(stu_id) or with COUNT(*) in SQL.

Question 7medium

Write a relational algebra expression for the departments that have no instructor teaching any course. Give the result.

Show answer
π_dept_name(department) − π_dept_name(instructor ⋈ teaches) = BIO

The departments of instructors who teach: πdept_name(instructor⋈teaches)\pi_{dept\_name}(instructor \bowtie teaches) = {CS, EE, MATH}. Subtract from all departments: BIO. BIO has the instructor Yildiz but she teaches nothing, so BIO is in the result.

Question 8medium

How many tuples does student ⟗ department (full outer join on dept_name) have?

Show answer
8

Matching rows: the 6 students who have a department (S1..S6), each with their own department. Unmatched left row: Gizem (dept_name is NULL, and NULL equals nothing). Unmatched right row: BIO (it has no students). 6 + 1 + 1 = 8.

Question 9hard

Write a relational algebra expression for the IDs of courses taken by all CS students. Give the result.

Show answer
π_{course_id,stu_id}(takes) ÷ π_stu_id(σ_{dept_name='CS'}(student)) = CS101

This time “every student” is on the divisor side: the CS students {S1, S2, S5}. The dividend is πcourse_id,stu_id(takes)\pi_{course\_id, stu\_id}(takes), and the result is course_id.
CS101 was taken by S1, S2, S5: it passes. S5 did not take CS201, and S2 and S5 did not take CS305. Result: CS101.

Question 10hard

Give the result of stu_idGsum(credits)(takes⋈πcourse_id,credits(course)){}_{stu\_id}\mathcal{G}_{\text{sum}(credits)}(takes \bowtie \pi_{course\_id, credits}(course)).

Show answer
S1 15, S2 8, S3 11, S4 10, S5 4, S7 3

The credits of the course are attached to each record, then summed per student. S1: 4+4+3+4 = 15. S2: 4+4 = 8. S3: 3+4+4 = 11. S4: 4+3+3 = 10. S5: 4. S7: 3. S6 has no enrollment, so it has no group.
We narrowed course with π\pi. Here the only common column between takes and course is already course_id, but as soon as student is added to the expression, dept_name causes trouble. Make narrowing a habit.

Question 11easy

What is Gcount(dept_name)(student){}\mathcal{G}_{\text{count}(dept\_name)}(student)? Compare with the number of tuples in studentstudent.

Show answer
6 (student has 7 tuples)

count(A) counts only the non-NULL values of A. Gizem's department is NULL and is not counted: 6. The table has 7 rows. In SQL, COUNT(dept_name) = 6 and COUNT(*) = 7.

Question 12hard

Write an expression for the departments whose average instructor salary is greater than 75000. Give the result.

Show answer
π_dept_name(σ_{avg_sal>75000}(dept_name G avg(salary) as avg_sal (instructor))) = CS, EE

First group and name the average: dept_nameGavg(salary) as avg_sal(instructor){}_{dept\_name}\mathcal{G}_{\text{avg}(salary) \text{ as } avg\_sal}(instructor) → CS 87500, EE 85000, MATH 71000, BIO 60000.
Then a selection on the group result: σavg_sal>75000\sigma_{avg\_sal > 75000}, and πdept_name\pi_{dept\_name} on the outside. The result is CS and EE. This is the algebra counterpart of HAVING in SQL.

Verification: All result tables were computed in verify/SEN2104/05.mjs with lib.mjs (njoin, theta, leftOuter, rightOuter, fullOuter, divide, gamma); division was also built step by step from the fundamental operators. The SQL equivalent of every expression was run on node:sqlite (SQLite 3.53, with FULL OUTER JOIN support) and gave the same result (56 checks, 0 errors).

Review card

R ⋈_θ S = σ_θ(R × S) if it is only equalities, it is an equijoin R ⋈ S (natural): equates ALL columns with the SAME NAME, the common column is written once If there is no common column, R ⋈ S = R × S PITFALL: student ⋈ takes ⋈ course → dept_name is equated too (9 rows, not 14) ⟕ left: unmatched left rows with NULL ⟖ right ⟗ both NULL equals nothing, a row with NULL is dropped in an inner join "for every / all" → DIVISION: R(A,B) ÷ S(B) = the A's that match every B R ÷ S = π_A(R) − π_A((π_A(R) × S) − R) temp ← expression (assignment): split up a long query π_{name, salary*1.1}: generalized projection, computed column G group_{func}(R): count sum avg min max; skip NULLs Grouping creates no empty group (there is no row that says 0) HAVING = σ ON TOP of the grouping