عرض عملي: الإجابة عن أسئلة المكتبة باستخدام SQL
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
You now know every statement this module teaches. Knowing the tools is not the same as using them: a real question arrives in plain language ("who is late returning books?"), and you have to turn it into a query. This is a demo lesson: we walk through the translation step by step, make one planned mistake on purpose, and fix it. It prepares you for the module lab, 25 graded queries against a seeded library database.
In the library picture, the librarian does not hand you a query. A member walks up and asks a question, and the skill is hearing the question in the structure of the tables.
The seeded library used here is the one from earlier lessons: 5 authors, 11 books, 5 members, 8 loans. If you can, follow along in your own PostgreSQL client with the schema and data from Lesson 2 and the module's seed data, and run each query yourself. Menus and commands differ between tools, so use whatever your course setup provides.
This lesson builds on Lessons 1 to 7.
Learning Objectives
By the end of this lesson you will be able to:
- Turn a plain-language question into a query using a repeatable method.
- Choose the right clauses (
WHERE,GROUP BY,HAVING,JOIN) from the wording of the question. - Recognize the error message for an aggregate in
WHERE, and fix it. - Compute "overdue" from a due date and a missing return date.
- Check a result for plausibility against the data.
- Build a query in small, tested steps.
Written Explanation
A method for turning questions into queries
Work through these five questions in order, writing a small query at each step:
- What is one row of the answer? "A book", "a member", "a loan", "an author with a number". This decides the main table, and whether you need
GROUP BY. - Which tables hold the data? List them, and the key that connects each pair (foreign key = primary key).
- Which rows should be kept? Conditions on single rows go in
WHERE. - Is there a summary? "How many", "most", "average" means an aggregate; "for each" means
GROUP BY; a condition on the summary goes inHAVING. - In what order, and how many?
ORDER BY, andLIMITfor "top n".
Then build in small steps: start with the table that holds the row you need, add one join, run it, add the next condition, run again. Never write the whole thing and then try to debug it.
Reading the wording
| Words in the question | Usually means |
|---|---|
| "each", "per", "for every" | GROUP BY that thing |
| "how many", "total", "average", "most" | aggregate function |
| "with at least N", "more than N" (about a count) | HAVING COUNT(*) ... |
| "published after", "joined in 2024" (about one row) | WHERE |
| "who have never", "with no" | left join + IS NULL, or NOT IN |
| "including those without", "all X and their Y" | left join from X |
| "not yet returned" | returned_on IS NULL |
"Overdue" is a definition, not a column
The schema has no overdue column, and should not: it would go stale every night. A loan is overdue when it has not been returned (returned_on IS NULL) and its due date has passed (due_on < today). In PostgreSQL, CURRENT_DATE is today's date, and subtracting one date from another gives a number of days. For the demo, we use a fixed date, DATE '2025-11-05', so everyone gets identical results.
When a question is ambiguous
Plain-language questions leave things out: "most borrowed author": by number of loans, or by number of books? "Late": still out, or returned after the due date? Decide, say what you decided, and write it in a comment. A confident answer to the wrong question is the commonest way to lose marks in the lab.
Examples
All results are real output from PostgreSQL 18.3 on the seeded library.
Example 1: a simple filter, then a grouped count (simple)
Question: "Which books were published after 2000? Sort by title."
One row is a book, one table, one row-level condition, one sort:
SELECT title, published_year
FROM books
WHERE published_year > 2000
ORDER BY title; title | published_year
-----------+----------------
Fledgling | 2005
Lavinia | 2008
(2 rows)Question: "How many loans has each member made?" One row is a member with a number, so group by member. The names live in members, the loans in loans, joined by loans.member_id = members.id:
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
ORDER BY loans DESC, m.name; name | loans
-------+-------
Amal | 3
Bilal | 2
Carla | 2
Dina | 1
(4 rows)Sanity check: 3 + 2 + 2 + 1 = 8, the number of loans in the table. Emil is missing, because he has no loans and this is an inner join. That is acceptable for this question ("how many loans has each member made"), but not for "every member with their count", which would need a left join and COUNT(l.id).
Example 2: the planned mistake (practical)
Question: "Which members have made at least two loans?" The tempting version puts the condition on the count in WHERE:
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
WHERE COUNT(*) >= 2
GROUP BY m.id, m.name;ERROR: aggregate functions are not allowed in WHERERead the message. It says what the lesson on HAVING said: WHERE runs before the groups exist. "At least two loans" is a statement about a group, so it belongs in HAVING:
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
HAVING COUNT(*) >= 2
ORDER BY loans DESC, m.name; name | loans
-------+-------
Amal | 3
Bilal | 2
Carla | 2
(3 rows)Dina, with one loan, is gone. Notice how little changed: one clause moved. This is a good rule for debugging: the error message, read slowly, usually tells you the fix.
Example 3: overdue loans and the most-borrowed authors (realistic)
Question: "Which loans are overdue, who has them, and how late are they?" Using the definition above (not returned, and due date before "today", here 5 November 2025). One row is a loan, and we want names from two other tables, so three tables are joined through the junction loans:
SELECT m.name, b.title, l.due_on,
DATE '2025-11-05' - l.due_on AS days_late
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE l.returned_on IS NULL
AND l.due_on < DATE '2025-11-05'
ORDER BY l.due_on; name | title | due_on | days_late
-------+----------------------+------------+-----------
Amal | A Wizard of Earthsea | 2025-09-15 | 51
Bilal | Kindred | 2025-10-04 | 32
Carla | The Dispossessed | 2025-10-24 | 12
Dina | Palace Walk | 2025-11-03 | 2
(4 rows)All four open loans are overdue on that date. Subtracting two dates giving a number of days is PostgreSQL behaviour; other databases differ. In real use you would replace the fixed date with CURRENT_DATE, which gives different rows on different days.
Question: "Which authors are borrowed most?" Decision: count loans, not books. One row is an author with a number. The chain is loans to books to authors:
SELECT a.name, COUNT(*) AS times_borrowed
FROM loans l
JOIN books b ON b.id = l.book_id
JOIN authors a ON a.id = b.author_id
GROUP BY a.id, a.name
ORDER BY times_borrowed DESC, a.name; name | times_borrowed
----------------+----------------
Ursula Le Guin | 4
Octavia Butler | 2
Chinua Achebe | 1
Naguib Mahfouz | 1
(4 rows)Check: 4 + 2 + 1 + 1 = 8 loans. The anonymous poet has no loans and is not listed, which is fine for "most borrowed". For "top three", add LIMIT 3 after the ORDER BY; the name in the sort breaks ties, so the result is stable.
Backend Insight
This translation skill is most of what "writing the data layer" means. A product manager asks for "customers who haven't ordered in 90 days", and you find the row, the tables, the conditions, the summary. The five-question method is also how you would explain a query to a colleague in code review.
Two habits from the demo carry over. First, check totals: the loan counts add up to the table's row count, an overdue list should be a subset of the open loans. Cheap sanity checks catch most silent errors, including inner joins that drop rows. Second, write down the definition of business words like "overdue", "active", or "top": the SQL is only as right as the definition, and the definition belongs in a comment or a ticket, not in someone's head.
Common Mistakes
Mistake 1: Writing the whole query at once
Write the one-table version first, run it, then add a join and run again. When something breaks, you know which step broke it.
Mistake 2: Filtering on an aggregate in WHERE
You saw the error in Example 2. Row condition: WHERE. Group condition: HAVING.
Mistake 3: Choosing an inner join when "including those without" is in the question
The words "all", "including", and "even if" point to a left join.
Mistake 4: Answering a different question than asked
"Most borrowed" by books or by loans? Decide and note it. Check with a total that adds up.
Mistake 5: Computing "overdue" without checking returned_on
Comparing only due_on to today flags loans that were returned on time long ago. A loan is overdue only while it is not returned.
Exercises
Try each exercise yourself before opening the solutions. Use the seeded library.
Level A: Foundation
A1. Write a query for the titles of books by author 4, newest first.
أظهر الحل A1
A1. SELECT title FROM books WHERE author_id = 4 ORDER BY published_year DESC;
A2. Write a query counting how many loans are not yet returned.
أظهر الحل A2
A2. SELECT COUNT(*) FROM loans WHERE returned_on IS NULL; returns 4 on the seeded data.
A3. In your own words: what are the two conditions that make a loan overdue?
أظهر الحل A3
A3. It has not been returned (returned_on IS NULL), and its due date is before today (due_on < CURRENT_DATE).
Level B: Application
B1. Which members have borrowed at least one book published before 1970? (Join through loans and books.) Hint: use DISTINCT after SELECT so a member appears once. This is a preview, as DISTINCT was not covered.
أظهر الحل B1
B1.
SELECT DISTINCT m.name
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE b.published_year < 1970;On the seeded data: Amal (books 2 and 3), Carla (book 4), and Dina (book 6). Without DISTINCT, Amal would appear twice.
B2. List each author's name with the number of books they have, only for authors with at least two books, most books first.
أظهر الحل B2
B2.
SELECT a.name, COUNT(*) AS books
FROM authors a
JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(*) >= 2
ORDER BY books DESC;Three rows: Ursula Le Guin (4), Octavia Butler (3), Chinua Achebe (2).
B3. List loans that were returned late (after due_on), with member name and book title.
أظهر الحل B3
B3.
SELECT m.name, b.title, l.due_on, l.returned_on
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE l.returned_on > l.due_on;On the seeded data, one row: Carla returned Things Fall Apart four days late. Loans with a NULL return date are excluded by the comparison, which is right: they are not returned at all.
Level C: Challenge
C1. "Show every member with the number of loans still open, including members with none (showing 0)." Write the query and say what you checked.
أظهر الحل C1
C1.
SELECT m.name, COUNT(l.id) AS open_loans
FROM members m
LEFT JOIN loans l ON l.member_id = m.id AND l.returned_on IS NULL
GROUP BY m.id, m.name
ORDER BY m.id;The condition on returned_on is in ON, so members without open loans remain. COUNT(l.id) gives 0 for them. Check: five rows, one per member; the open counts add up to 4, the number of open loans.
C2. A colleague's query for "the average number of loans per member" is SELECT AVG(loans) FROM (SELECT COUNT(*) AS loans FROM loans GROUP BY member_id) per_member;. It returns 2.0000000000000000. Explain what is being averaged and why the result may not answer "per member" for the whole membership. (A subquery in FROM is a preview.)
أظهر الحل C2
C2. The inner query counts loans per member_id in loans, so only members who have at least one loan appear (four of them: 3, 2, 2, 1). The average of those four numbers is 2. Emil, with no loans, is not in the list, so the "per member" average over all five members would be 8 / 5 = 1.6. The lesson: a GROUP BY over a table only sees groups that exist. A left join from members would include everyone.
Mini Challenge
Answer these three questions with queries, using the five-question method. For each, write (a) what one row of the answer is, (b) the tables, and (c) one sanity check.
- "Which members have a book out that is more than 14 days overdue as of 5 November 2025?"
- "How many books has each author, including authors whose books were never borrowed?"
- "Which authors have never had a book borrowed?"
Check your work: query 1 combines returned_on IS NULL with a date comparison (due_on < DATE '2025-10-22', or a date subtraction); query 2 is a plain count of books; query 3 uses LEFT JOIN or NOT IN through books and loans and returns the anonymous poet on the seeded data.
Summary & Next Lesson
Key takeaways
- Translate questions with five steps: the row of the answer, the tables, the row conditions, the summary, the order.
- "Each" means
GROUP BY; a condition on a count meansHAVING; "including those without" means a left join. - Aggregates in
WHEREgiveaggregate functions are not allowed in WHERE; move them toHAVING. - "Overdue" means not returned and past due.
- Build in small steps and check totals.
Next lesson
You have seen joins used correctly. The next lesson, "Demo: joins that silently drop rows", shows what happens when an inner join is the wrong choice, and gives you a way to detect it.
Self-check
- What are the five questions in the method?
- Which clause holds
COUNT(*) >= 2? - How is "overdue" defined for a loan?
- Which join do the words "including those without" suggest?
- What cheap check catches many wrong results?
أظهر الإجابات
- What one answer row is; which tables; which row conditions; whether there is a summary; the order and number of rows.
HAVING.- Not returned (
returned_on IS NULL) and due date before today. - A left join from the table whose rows must all appear.
- Comparing totals: for example, whether the counts add up to the table's row count.
References
- PostgreSQL documentation, Table Expressions: GROUP BY and HAVING. Supports the
WHERE-before-grouping andHAVING-after-grouping rule used in Example 2. - PostgreSQL documentation, Joined Tables. Supports the inner and left join behaviour used throughout.
- The five-question method, the wording table, and the definition of "overdue" are this course's own teaching framing. All query results and error messages are real output from PostgreSQL 18.3. Date subtraction returning days was observed in a run and is not tied to a fetched page.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.