SQL you'll actually use
An ORM covers the daily create, read and update work. The questions a business asks, such as "top sellers per store" or "which stores dropped this week", need real SQL. This lesson covers the SQL patterns behind almost every shop report, run against the tables from Modeling data in tables.
By the end you can
- Pick INNER or LEFT JOIN on purpose, and spot the WHERE clause that quietly turns one into the other
- Group with HAVING and FILTER, answer "is there any?" with EXISTS, and handle NULL without surprises
- Rank rows within each store and compare a day with the day before, using window functions
- Write an upsert that is safe when a request is retried
- Run any of these from Prisma with
$queryRaw, typed and safe from SQL injection
The everyday picture
Imagine you want to know how the Riverside store did last week. You could ask the store to send every receipt to head office, then read 2,000 slips yourself and add them up. Or you could phone the store manager and ask one precise question: "Total sales per day last week, and your three best sellers." The manager has the books right there. You get back seven numbers and three names.
SQL is that precise question. The database already holds every receipt and knows how to scan, sort and add them quickly. When your NestJS code loads 50,000 rows into memory to sum them, it is reading every slip by hand. When it sends one good query, it gets back only the answer, and that matters most on a slow store connection or a busy server.
The shape of a query, and the order it really runs in
You write a query in one order, and Postgres works through it in a different, logical order. Knowing the second order explains most beginner errors.
SELECT sku, name, unit_price_cents -- 5. pick columns
FROM article -- 1. start from a table (and its JOINs)
WHERE category_id = 2 -- 2. keep matching rows
AND unit_price_cents BETWEEN 100 AND 300
ORDER BY unit_price_cents DESC -- 6. sort what is left
LIMIT 3; -- 7. keep the first 3
-- (3. GROUP BY and 4. HAVING come between WHERE and SELECT when present)Two practical results. WHERE runs before SELECT, so you cannot use a column alias from SELECT inside WHERE. And ORDER BY runs after SELECT, so there you can. Without ORDER BY, the row order is not guaranteed, even when it looks stable in testing. Any query with LIMIT, or any screen that shows a list, needs an explicit ORDER BY.
Quick check
SELECT total_cents / 100 AS dollars FROM sale WHERE dollars > 50 fails with "column dollars does not exist". Why?
Logically, rows are filtered before the select list is computed, so WHERE must repeat the expression: WHERE total_cents / 100 > 50. HAVING filters groups after GROUP BY, which is a different job.
Joins: which rows survive
A join pairs rows from two tables using a condition, usually "this foreign key equals that id". The type of join decides what happens to rows that find no partner.
- INNER JOIN keeps only matched pairs. A store with no sales today disappears from the result.
- LEFT JOIN keeps every row of the left table. Where there is no match, the right side's columns are NULL.
sale.site_id = site.id. INNER JOIN returns only matched pairs, so the store with no sales vanishes. LEFT JOIN keeps every store and fills the missing sale with NULL, which is what a "stores with zero sales" report needs.Here is the report that needs a LEFT JOIN: receipts per site today, including sites with none. That second group is often the most important, because a silent store may have a broken POS.
SELECT si.site_code, count(s.id) AS receipts_today -- count(s.id) skips NULLs, so 0
FROM site si
LEFT JOIN sale s
ON s.site_id = si.id
AND s.sale_date = current_date -- filter the right side HERE
GROUP BY si.site_code
ORDER BY receipts_today; site_code | receipts_today
-----------+----------------
DC01 | 0 <- a DC has no tills: expected
S004 | 128 <- a store at less than half its usual 300: look at it
P001 | 300
...Grouping, HAVING, and NULL traps
GROUP BY folds many rows into one row per group, and aggregates (count, sum, avg, min, max) summarise each group. WHERE filters rows before grouping; HAVING filters groups after. "Days where a store issued fewer than 200 receipts" is a filter on a count, so it must be HAVING:
SELECT site_id, sale_date, count(*) AS receipts
FROM sale
WHERE sale_date >= current_date - 7 -- rows first
GROUP BY site_id, sale_date
HAVING count(*) < 200 -- then groups
ORDER BY sale_date, site_id;NULL means "unknown". Any comparison with an unknown value gives unknown, and WHERE keeps only rows where the test is true. That produces three classic surprises:
SELECT NULL = NULL; -- NULL, not true. Use IS NULL instead
SELECT count(*), count(discount) -- count(col) skips NULLs
FROM (VALUES (10), (NULL), (5)) AS v(discount); -- 3 | 2
SELECT 1 WHERE 2 NOT IN (1, NULL); -- no row: "is 2 equal to unknown?" is unknown
SELECT coalesce(NULL, 0); -- 0: first non-NULL value
SELECT NULL IS DISTINCT FROM 1; -- true: a NULL-safe "not equal"The NOT IN case bites in real reports: "products not in this promotion list" returns nothing if the list has one NULL. Prefer NOT EXISTS for such checks. Use coalesce(x, 0) when a missing sum should display as zero.
Quick check
What does SELECT avg(x) FROM (VALUES (10), (NULL), (20)) AS v(x) return?
Like count(col) and sum, avg ignores NULLs, so it averages 10 and 20. If missing values should count as zero, say so with avg(coalesce(x, 0)), which gives 10.
Questions about "is there any?": EXISTS, DISTINCT ON and FILTER
Many business questions are yes-or-no checks about other rows. "Which products are in stock at Station Road but have not sold in a week?" is a question about the absence of sale lines. A subquery with EXISTS or NOT EXISTS answers it directly. The database stops looking as soon as it finds one matching row, and NULLs cannot spoil the answer the way they spoil NOT IN.
-- Dead stock: on the shelf at S004, no sale in the last 7 days
SELECT a.sku, a.name, st.qty
FROM stock st
JOIN article a ON a.id = st.article_id
WHERE st.site_id = (SELECT id FROM site WHERE site_code = 'S004') -- one value
AND st.qty > 0
AND NOT EXISTS ( -- "there is no sale line..."
SELECT 1
FROM sale s
JOIN sale_line sl ON sl.sale_id = s.id
WHERE s.site_id = st.site_id -- ...for this store
AND sl.article_id = st.article_id -- ...and this product
AND s.sale_date >= current_date - 7
)
ORDER BY st.qty DESC;The inner query refers to st from the outer query, so it runs once per stock row in principle. Postgres usually turns it into an efficient anti-join, which you can confirm with EXPLAIN (Reading query plans and fixing slow queries). On the practice data below, every product sells every day, so the result is empty until you add a product that has stock but no sales. That is a good habit in itself: create a row you expect the query to find, and check that it does.
Two more tools save a lot of code:
DISTINCT ON (col)keeps the first row of each group, in the order given byORDER BY. "The latest receipt from each store" becomesSELECT DISTINCT ON (site_id) … ORDER BY site_id, sold_at DESC. It is a Postgres extension to SQL, and theORDER BYmust start with the same columns.count(*) FILTER (WHERE …)counts only the rows that match, inside a normalGROUP BY. One pass over the data gives you several columns: all receipts, wallet receipts, and the share paid without cash.
SELECT si.site_code,
count(*) AS receipts,
count(*) FILTER (WHERE s.pay_method = 'WALLET') AS wallet,
round(100.0 * count(*) FILTER (WHERE s.pay_method IN ('CARD', 'WALLET'))
/ count(*), 1) AS cashless_pct
FROM sale s
JOIN site si ON si.id = s.site_id
WHERE s.sale_date >= current_date - 6
GROUP BY si.site_code
ORDER BY cashless_pct DESC; site_code | receipts | wallet | cashless_pct
-----------+----------+--------+--------------
P001 | 2100 | 720 | 68.0
S005 | 2100 | 716 | 67.2
S002 | 2100 | 723 | 66.9
...The same report written with one query per payment method would scan the sales three times and need code to stitch the results together. Note the 100.0: it makes the maths decimal. With only integers, Postgres drops the fraction.
CTEs and window functions: the report toolkit
A CTE (WITH name AS (…)) gives a name to one step of a query. It turns a deep nest of brackets into a readable recipe: first compute this week, then last week, then compare. In Postgres 12 and later the planner can merge a simple CTE into the main query, so naming a step usually costs nothing.
A window function calculates across a set of rows but, unlike GROUP BY, keeps every row. You describe the window with OVER (PARTITION BY … ORDER BY …): partition splits the rows into groups, and order sorts within each group. The ones you will use most:
row_number(): 1, 2, 3 within each partition. The answer to "top N per group".sum(x) OVER (ORDER BY day): a running total, such as month-to-date sales.lag(x): the value from the previous row, such as yesterday's sales next to today's.
PARTITION BY site_id splits the rows into one group per store, ORDER BY units DESC sorts inside each group, and row_number() restarts at 1 for every store. Keep WHERE rn <= 5 and you have the top five per store. Every row stays; nothing is folded together.Think first: why not write ORDER BY units DESC LIMIT 5 instead?
That gives the top five rows of the whole result, which would likely be five rows from the busiest store. You need five per store. LIMIT applies once to the whole query; a window function with PARTITION BY ranks inside each group. row_number() also breaks ties arbitrarily. If two products tie and you want both to share rank 3, use rank() or dense_rank(). Add a tie-breaker column (ORDER BY units DESC, article_id) when you need the same answer every run.
For time, bucket with date_trunc. It cuts a timestamp down to the hour, day, week or month. Convert to the store's time zone first; otherwise the buckets follow UTC, and "9 pm" lands in the 3 pm bucket.
SELECT date_trunc('hour', sold_at AT TIME ZONE 'Asia/Dhaka') AS hour_local,
count(*) AS receipts
FROM sale
WHERE site_id = 1 AND sale_date = current_date - 1
GROUP BY 1
ORDER BY 1;Quick check
sum(total_cents) / lag(sum(total_cents)) OVER (ORDER BY sale_date) returns whole numbers such as 1 or 0, never 1.08. Why?
sum() of an integer column is a bigint, and bigint divided by bigint is a whole number (7 / 2 is 3). Cast one side to numeric, as step 3 below does, to keep the fraction.
Upsert: insert or update in one safe step
An upsert answers "add this row, or update it if it already exists". The naive version is two queries: SELECT, then INSERT or UPDATE. Two handheld scanners posting the same delivery at the same time can both see "not there" and both insert. INSERT … ON CONFLICT does the check and the write as one atomic step, using a unique constraint as the referee.
ON CONFLICT (cols) DO UPDATE SET …updates the existing row. Inside it,EXCLUDEDmeans "the row you tried to insert".ON CONFLICT (cols) DO NOTHINGskips silently. That makes a retried upload harmless, as with theUNIQUE (site_id, local_txn_id)rule from Modeling data in tables.
The conflict target must match a unique index or constraint exactly. Transactions, ACID, isolation and locks covers what else can go wrong when two writers touch the same row.
Build it
We will load a month of practice data, then write four real reports and call one from NestJS through Prisma. To try the core patterns without installing anything, use the exercises in the Practice section, which run in your browser.
-
Load practice data
Run this on the tables from Modeling data in tables. It creates 8 sites, 40 products and 30 days of receipts. Low article ids sell more, so rankings look realistic. One store loses receipts in the last 3 days so the comparison report has something to find. Prices are in cents.
SQLINSERT INTO site (site_code, name, format, city) VALUES ('S001', 'Riverside', 'STORE', 'Bayside'), ('S002', 'Hill Road', 'STORE', 'Bayside'), ('S003', 'Market Square', 'STORE', 'Bayside'), ('S004', 'Station Road', 'STORE', 'Bayside'), ('S005', 'Old Town', 'STORE', 'Hilltown'), ('S006', 'Lakeview', 'STORE', 'Riverton'), ('P001', 'Ravi''s Corner Shop', 'PARTNER', 'Bayside'), ('DC01', 'North DC', 'DC', 'Bayside'); INSERT INTO category (name) VALUES ('Grocery'), ('Fresh'), ('Personal care'); INSERT INTO article (sku, name, category_id, unit_price_cents) SELECT 'SKU' || lpad(g::text, 5, '0'), 'Article ' || g, 1 + g % 3, 50 + floor(random() * 1950)::int -- 50 to 1999 cents FROM generate_series(1, 40) AS g; -- 300 receipts per day per selling site, 30 days INSERT INTO sale (site_id, local_txn_id, sale_date, sold_at, total_cents, pay_method) SELECT s.id, s.site_code || '-' || to_char(current_date - d, 'YYMMDD') || '-' || n, current_date - d, (current_date - d + time '09:00' + random() * interval '13 hours') AT TIME ZONE 'Asia/Dhaka', 0, (ARRAY['CASH','CARD','WALLET'])[1 + floor(random() * 3)::int]::pay_method FROM site s, generate_series(0, 29) AS d, generate_series(1, 300) AS n WHERE s.format <> 'DC';SQL-- 1 to 4 lines per receipt; power(random(), 2) favours low article ids INSERT INTO sale_line (sale_id, line_no, article_id, qty, unit_price_cents) SELECT s.id, l.n, a.id, l.qty, a.unit_price_cents FROM sale s CROSS JOIN LATERAL ( SELECT n, 1 + floor(power(random(), 2) * 40)::int AS article_id, 1 + floor(random() * 3)::int AS qty FROM generate_series(1, 1 + (s.id % 4)::int) AS n ) AS l JOIN article a ON a.id = l.article_id; UPDATE sale s SET total_cents = t.amount FROM (SELECT sale_id, sum(qty * unit_price_cents) AS amount FROM sale_line GROUP BY sale_id) AS t WHERE t.sale_id = s.id; INSERT INTO stock (site_id, article_id, qty) SELECT s.id, a.id, floor(random() * 200)::int FROM site s CROSS JOIN article a; DELETE FROM sale -- Station Road's POS "lost" receipts lately WHERE site_id = (SELECT id FROM site WHERE site_code = 'S004') AND sale_date > current_date - 3 AND random() < 0.6; ANALYZE; -- refresh planner statisticsANALYZErefreshes the statistics the planner uses to choose a plan; Reading query plans and fixing slow queries shows why that matters. -
Top 5 products per store, last 7 days
The first CTE sums units per store and product. The second ranks them inside each store. The final
SELECTkeeps the top five and adds names.SQLWITH sold AS ( SELECT s.site_id, sl.article_id, sum(sl.qty) AS units FROM sale s JOIN sale_line sl ON sl.sale_id = s.id WHERE s.sale_date >= current_date - 7 GROUP BY s.site_id, sl.article_id ), ranked AS ( SELECT site_id, article_id, units, row_number() OVER (PARTITION BY site_id ORDER BY units DESC, article_id) AS rn FROM sold ) SELECT si.site_code, a.name AS article, r.units, r.rn FROM ranked r JOIN site si ON si.id = r.site_id JOIN article a ON a.id = r.article_id WHERE r.rn <= 5 ORDER BY si.site_code, r.rn;Outputsite_code | article | units | rn -----------+-----------+-------+---- P001 | Article 1 | 1891 | 1 P001 | Article 2 | 800 | 2 P001 | Article 3 | 665 | 3 ... -
Daily sales with day-over-day change
lag()reads the previous day's total. Note the nesting:sum(total_cents)is the group aggregate, andlag(…) OVERruns on top of the grouped rows. The::numericcast keeps the fraction in the percentage.SQLSELECT sale_date, sum(total_cents) AS revenue_cents, sum(total_cents) - lag(sum(total_cents)) OVER (ORDER BY sale_date) AS change_cents, round(100.0 * (sum(total_cents)::numeric / lag(sum(total_cents)) OVER (ORDER BY sale_date) - 1), 1) AS change_pct FROM sale WHERE site_id = (SELECT id FROM site WHERE site_code = 'S004') AND sale_date >= current_date - 6 GROUP BY sale_date ORDER BY sale_date;Outputsale_date | revenue_cents | change_cents | change_pct ------------+---------------+--------------+------------ 2026-10-10 | 1587933 | | 2026-10-11 | 1708481 | 120548 | 7.6 2026-10-12 | 1604104 | -104377 | -6.1 2026-10-13 | 1641566 | 37462 | 2.3 2026-10-14 | 625247 | -1016319 | -61.9 <- the POS problem starts 2026-10-15 | 668784 | 43537 | 7.0Your numbers differ because the data is random. 1587933 cents is $15,879.33. The first row has no previous day, so
laggives NULL. To get month-to-date instead, usesum(sum(total_cents)) OVER (ORDER BY sale_date). -
Stores selling below last week
Two CTEs, one per week, then a comparison. The LEFT JOIN plus
coalescekeeps a store that sold nothing this week, which is the most alarming case.SQLWITH this_week AS ( SELECT site_id, sum(total_cents) AS revenue_cents FROM sale WHERE sale_date >= current_date - 6 -- last 7 days GROUP BY site_id ), last_week AS ( SELECT site_id, sum(total_cents) AS revenue_cents FROM sale WHERE sale_date >= current_date - 13 AND sale_date < current_date - 6 GROUP BY site_id ) SELECT si.site_code, si.name, lw.revenue_cents AS last_week, coalesce(tw.revenue_cents, 0) AS this_week, round(100.0 * (coalesce(tw.revenue_cents, 0)::numeric / lw.revenue_cents - 1), 1) AS change_pct FROM last_week lw JOIN site si ON si.id = lw.site_id LEFT JOIN this_week tw ON tw.site_id = lw.site_id WHERE coalesce(tw.revenue_cents, 0) < lw.revenue_cents * 0.9 -- down more than 10% ORDER BY change_pct;Outputsite_code | name | last_week | this_week | change_pct -----------+--------------+-----------+-----------+------------ S004 | Station Road | 11466371 | 8545846 | -25.5 -
Prove the dead-stock query works
A query that returns nothing may be correct, or may be broken. Plant a row you expect it to find, run it, and undo everything with
ROLLBACK, so your practice data stays clean. Paste the dead-stock query from the EXISTS section where marked.SQLBEGIN; -- Two new products arrive at Station Road but have not sold yet INSERT INTO article (sku, name, category_id, unit_price_cents) VALUES ('SKU00041', 'Coconut oil 1L', 1, 450), ('SKU00042', 'Basmati rice 1kg', 1, 290); INSERT INTO stock (site_id, article_id, qty) SELECT s.id, a.id, 24 FROM site s, article a WHERE s.site_code = 'S004' AND a.sku IN ('SKU00041', 'SKU00042'); -- ...paste the dead-stock query here... ROLLBACK; -- the test rows disappearOutputsku | name | qty ----------+------------------+----- SKU00041 | Coconut oil 1L | 24 SKU00042 | Basmati rice 1kg | 24Both planted products appear, and no real product does, because every real one sold in the last week. Now you trust the query. The same trick works for every report: before you rely on "no stores are below target", plant one that is.
-
Upsert stock after a delivery
When goods arrive (a goods received note, or GRN), add the quantity if the row exists, or create it if this site never stocked the product.
RETURNINGsends back the new row, so the app needs no second query.SQLINSERT INTO stock (site_id, article_id, qty) VALUES (1, 7, 24) ON CONFLICT (site_id, article_id) -- the primary key is the referee DO UPDATE SET qty = stock.qty + EXCLUDED.qty, -- EXCLUDED = the row we tried updated_at = now() RETURNING site_id, article_id, qty; -- A retried receipt upload: the second attempt inserts nothing INSERT INTO sale (site_id, local_txn_id, sale_date, total_cents, pay_method) VALUES (1, 'S001-261016-1', current_date, 4500, 'WALLET') ON CONFLICT (site_id, local_txn_id) DO NOTHING RETURNING id; -- zero rows means "already had it"In Prisma,
prisma.stock.upsert()covers the simple cases. For a single unique key with simple data, recent Prisma versions send it as oneON CONFLICTstatement; in other cases it reads first and then writes. When the exact SQL matters, write it yourself with$executeRaw. -
Call the report from NestJS with $queryRaw
Prisma's
$queryRawis a tagged template. Each${value}is sent to Postgres as a separate parameter, never pasted into the SQL text, so it is safe from SQL injection. The generic gives the result a type. Postgresbigint(which is whatsum()of an integer column androw_number()return) arrives as JavaScriptbigint.TypeScriptimport { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; type TopArticle = { site_code: string; sku: string; name: string; units: bigint; // sum(integer) is bigint in Postgres revenue_cents: bigint; // also a sum of integers: exact, and a bigint rn: bigint; // row_number() is bigint }; @Injectable() export class ReportsService { constructor(private readonly prisma: PrismaService) {} topArticles(siteCodes: string[], days: number, top = 5): Promise<TopArticle[]> { return this.prisma.$queryRaw<TopArticle[]>` WITH sold AS ( SELECT s.site_id, sl.article_id, sum(sl.qty) AS units, sum(sl.qty * sl.unit_price_cents) AS revenue_cents FROM sale s JOIN sale_line sl ON sl.sale_id = s.id JOIN site si ON si.id = s.site_id WHERE si.site_code = ANY(${siteCodes}) -- array becomes one parameter AND s.sale_date >= current_date - ${days}::int GROUP BY s.site_id, sl.article_id ), ranked AS ( SELECT *, row_number() OVER (PARTITION BY site_id ORDER BY units DESC) AS rn FROM sold ) SELECT si.site_code, a.sku, a.name, r.units, r.revenue_cents, r.rn FROM ranked r JOIN site si ON si.id = r.site_id JOIN article a ON a.id = r.article_id WHERE r.rn <= ${top} ORDER BY si.site_code, r.rn`; } }The type is a promise Prisma does not check at runtime. If you rename a column in the SQL, update the type too. Convert
bigintvalues to numbers (safe below 253) or strings in the controller's response DTO before sending JSON. -
See why $queryRawUnsafe is dangerous
$queryRawUnsafetakes a plain string. If you build that string with a template literal, user input becomes part of the SQL.TypeScriptconst input = "S001' OR '1'='1"; // typed into a search box await prisma.$queryRaw`SELECT count(*) FROM site WHERE site_code = ${input}`; // -> 0 rows matched: the whole string was treated as one site code await prisma.$queryRawUnsafe(`SELECT count(*) FROM site WHERE site_code = '${input}'`); // -> all 8 rows matched: the input rewrote the WHERE clause await prisma.$queryRawUnsafe('SELECT count(*) FROM site WHERE site_code = $1', input); // -> 0 rows: still safe, because the value is passed as a separate $1 parameterIf you need a dynamic column or table name, which parameters cannot provide, pick it from a fixed allow-list in code. Never pass it through from the request.
Bring it to your team
Ask these when a report is slow, wrong, or done in application code.
- "Is this aggregation done in SQL, or are we loading rows into Node and summing them there?"
- "Should stores with zero sales appear in this report? Then is it a LEFT JOIN, with the filter in ON?"
- "What does this report show when a value is NULL or missing? Did we test that case?"
- "This report says nothing is wrong. Did we plant a test row to prove it can find a problem?"
- "Does every list endpoint have an explicit ORDER BY with a tie-breaker?"
- "Is this insert-or-update an ON CONFLICT, or a read followed by a write that can race?"
- "Is any raw SQL built by string concatenation, or with $queryRawUnsafe and a template literal?"
Red flags
findMany()with nowhere, followed by areducein JavaScript to compute a total$queryRawUnsafewith${…}inside the string- "Stores with no sales" missing from a report that is supposed to list every store
- Hourly charts grouped on
sold_atwithout converting to the store's time zone NOT IN (subquery)where the subquery can return NULL
Practice
Receipts per store, including the silent ones
The tables hold four stores and a DC, and a few days of sales. For 15 October 2026, list every store (format = 'STORE') with its number of receipts that day, and include stores with no receipts as 0. Return the columns site_code and receipts. The starter forgets the silent store.
CREATE TABLE site (id int PRIMARY KEY, site_code text NOT NULL UNIQUE, format text NOT NULL); CREATE TABLE sale (id int PRIMARY KEY, site_id int NOT NULL REFERENCES site (id), sale_date date NOT NULL, total_cents int NOT NULL); INSERT INTO site VALUES (1, 'S001', 'STORE'), (2, 'S002', 'STORE'), (3, 'S003', 'STORE'), (4, 'S004', 'STORE'), (5, 'DC01', 'DC'); INSERT INTO sale VALUES (1, 1, '2026-10-15', 1200), (2, 1, '2026-10-15', 850), (3, 1, '2026-10-14', 400), (4, 2, '2026-10-15', 2300), (5, 3, '2026-10-14', 990), (6, 3, '2026-10-14', 150), (7, 4, '2026-10-15', 610), (8, 4, '2026-10-15', 75), (9, 4, '2026-10-15', 300);
-- Receipts per store on 15 Oct 2026, including stores with none
SELECT si.site_code, count(*) AS receipts
FROM site si
JOIN sale s ON s.site_id = si.id
WHERE s.sale_date = '2026-10-15'
GROUP BY si.site_code;
[{"site_code": "S001", "receipts": 2}, {"site_code": "S002", "receipts": 1}, {"site_code": "S003", "receipts": 0}, {"site_code": "S004", "receipts": 3}]
Show a solution
SELECT si.site_code, count(s.id) AS receipts -- count(s.id) skips the NULL row
FROM site si
LEFT JOIN sale s
ON s.site_id = si.id
AND s.sale_date = '2026-10-15' -- right-side filter goes in ON
WHERE si.format = 'STORE' -- left-side filter can stay in WHERE
GROUP BY si.site_code;If you kept count(*), S003 shows 1 instead of 0, because the NULL-filled row is still a row.
Top two products in each store
The table units_sold(site_code, article, units) holds last week's units per store and product. Return the two best sellers in each store with the columns site_code, article, units and rn (1 or 2), ordered by site_code and then rn. When two products sold the same number of units, the one whose name comes first alphabetically ranks higher.
CREATE TABLE units_sold (site_code text NOT NULL, article text NOT NULL, units int NOT NULL);
INSERT INTO units_sold VALUES
('S001', 'Rice 5kg', 120), ('S001', 'Sunflower oil 1L', 95), ('S001', 'Milk 1L', 95), ('S001', 'Eggs 12', 40),
('S002', 'Milk 1L', 210), ('S002', 'Bread', 180), ('S002', 'Rice 5kg', 60),
('S003', 'Bananas 1kg', 75), ('S003', 'Rice 5kg', 70), ('S003', 'Tea 250g', 20);
-- Top 2 products per store
SELECT site_code, article, units
FROM units_sold
ORDER BY units DESC
LIMIT 2;
[
{"site_code": "S001", "article": "Rice 5kg", "units": 120, "rn": 1},
{"site_code": "S001", "article": "Milk 1L", "units": 95, "rn": 2},
{"site_code": "S002", "article": "Milk 1L", "units": 210, "rn": 1},
{"site_code": "S002", "article": "Bread", "units": 180, "rn": 2},
{"site_code": "S003", "article": "Bananas 1kg", "units": 75, "rn": 1},
{"site_code": "S003", "article": "Rice 5kg", "units": 70, "rn": 2}
]
Show a solution
WITH ranked AS (
SELECT site_code, article, units,
row_number() OVER (PARTITION BY site_code
ORDER BY units DESC, article) AS rn -- article breaks ties
FROM units_sold
)
SELECT site_code, article, units, rn
FROM ranked
WHERE rn <= 2
ORDER BY site_code, rn;You cannot filter on rn in the same SELECT that computes it, because WHERE runs first. The CTE computes the rank, and the outer query filters it.
Apply a delivery with one upsert
A delivery for site 1 brings 24 units of article 1 (already stocked) and 12 units of article 3 (never stocked here). Rewrite the INSERT so existing rows add the new quantity and missing rows are created, in one statement. The starter fails with a duplicate key error. Keep the final SELECT.
CREATE TABLE stock (site_id int NOT NULL, article_id int NOT NULL, qty int NOT NULL CHECK (qty >= 0), PRIMARY KEY (site_id, article_id)); INSERT INTO stock VALUES (1, 1, 10), (1, 2, 0), (2, 1, 55);
-- The delivery: 24 of article 1 and 12 of article 3, for site 1
INSERT INTO stock (site_id, article_id, qty)
VALUES (1, 1, 24), (1, 3, 12);
SELECT site_id, article_id, qty FROM stock WHERE site_id = 1 ORDER BY article_id;
[{"site_id": 1, "article_id": 1, "qty": 34}, {"site_id": 1, "article_id": 2, "qty": 0}, {"site_id": 1, "article_id": 3, "qty": 12}]
Show a solution
INSERT INTO stock (site_id, article_id, qty)
VALUES (1, 1, 24), (1, 3, 12)
ON CONFLICT (site_id, article_id) -- the primary key is the referee
DO UPDATE SET qty = stock.qty + EXCLUDED.qty; -- EXCLUDED = the row we tried to insert
SELECT site_id, article_id, qty FROM stock WHERE site_id = 1 ORDER BY article_id;Check yourself
You write FROM site LEFT JOIN sale ON sale.site_id = site.id WHERE sale.sale_date = current_date. What happens to a store with no sales today?
The LEFT JOIN first produces the store with NULL sale columns, then WHERE tests NULL = current_date, which is unknown, so the row is dropped. Move the date test into ON to keep it. The first option is what you would get with the test in ON.
You need the 5 best-selling products for each store. Which tool fits?
A window function ranks inside each store without removing rows. LIMIT 5 applies to the whole result, so you get 5 rows in total. HAVING filters groups by their size, not by rank. DISTINCT ON keeps only one row per store.
Which call is open to SQL injection?
In option 2 the template literal is evaluated by JavaScript before Prisma sees it, so the input becomes SQL text. The tagged $queryRaw and the $1 form both send the value as a separate parameter, and normal Prisma queries are always parameterised.
Flashcards
Key takeaways
- Ask the database the precise question. Let SQL filter, group and sum, and send back only the answer.
- INNER JOIN drops unmatched rows; LEFT JOIN keeps them. Put conditions on the right-hand table in ON, not WHERE.
- NULL means unknown: use IS NULL, coalesce and NOT EXISTS, and remember that count(col) skips NULLs.
- CTEs name the steps; window functions (row_number, lag, running sum) rank and compare without losing rows.
- Use ON CONFLICT for insert-or-update, and the tagged $queryRaw for raw SQL from Prisma.
Sources
- Window functions tutorial, PostgreSQL documentation
- WITH queries (CTEs), PostgreSQL documentation
- INSERT, including ON CONFLICT, PostgreSQL documentation