Day 14: Week 2 review & complex queries
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — 2025 location performance pack
- F. Independent practice
- G. Solutions and explanations
- H. Knowledge check (Week 2)
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Select the right Week 2 technique — join type, set operator,
APPLY,PIVOT, calendar or temporal query, string function — for each part of a multi-part business request. - Build a complex report as a sequence of named, validated CTEs at explicit grains.
- Combine pivoted figures, year-on-year comparisons, a per-group top item (
APPLY) and a delimited list (STRING_AGG) in one result without fan-out. - Group by ISO week correctly by including the ISO year.
- Produce human-readable text from numbers and dates with
FORMATandCONCAT, knowing the cost. - Use a temporal table's full history in a report.
A. Lesson overview
| Week | 2 — Joins, set operations and advanced retrieval (review) |
| Level | Advanced |
| Estimated time | 2 h 30 min essential + 45 min optional stretch |
| Prerequisites | Days 1–13; the Day 12 objects (Lab.Calendar, Lab.ProductListPrice) must exist |
| Syllabus topic | Week 2 Review & Complex Queries — integration of Week 2 concepts |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Week 2 in one page: what each technique is for | 15 min |
| Part 2 — A method for building complex queries | 15 min |
Part 3 — Three integration traps (ISO weeks, grain after APPLY, formatting) |
15 min |
| Worked examples | 20 min |
| Guided lab: location performance pack | 45 min |
| Independent practice | 30 min |
| Knowledge check (Week 2) and wrap-up | 10 min |
| Stretch challenge (optional) | 45 min |
Business scenario. The commercial director wants a one-page “location performance pack” for 2025 — each shop and the online channel side by side, with quarterly sales, growth on 2024, customer reach, the best-selling product and who staffed the shop. Merchandising wants a readable price-history note per product, and Operations wants a weekly heat map of order activity. Each request uses several Week 2 techniques at once.
B. Theory explained clearly
Part 1 — Week 2 in one page
| Day | Technique | Use it when… | Watch out for |
|---|---|---|---|
| 8 | INNER / LEFT / FULL / CROSS / self / non-equi joins |
Combining columns from related tables; building scaffolds of all combinations | Fan-out; NULL keys; filters on outer-joined tables in WHERE |
| 9 | UNION [ALL], INTERSECT, EXCEPT |
Comparing or stacking whole result sets | Column order and types; NULLs treated as equal; duplicates removed |
| 10 | CROSS / OUTER APPLY, inline TVFs |
“For each row, run this query” — top N per row, several related aggregates, named calculations | CROSS drops rows with no result; aggregates always return a row |
| 11 | PIVOT, UNPIVOT, dynamic SQL |
Cross-tabs; wide ↔ long reshaping; column lists from data | Implicit grouping; NULL loss in UNPIVOT; quote identifiers, parameterise values |
| 12 | Date functions, calendar tables, temporal tables | Periods, gaps, working days, “as it was on” | DATEDIFF boundaries; half-open ranges; system time ≠ business time |
| 13 | String functions, LIKE, REGEXP_LIKE, STRING_SPLIT, STRING_AGG |
Cleaning, validating, splitting and listing text | “Not found” positions; ordering of splits and aggregates; collation |
Part 2 — A method for complex queries
- Write the output specification first. One row per what? Which columns, from which tables, at which grain?
- Identify the grains involved. For the location pack: order lines → orders → (location, quarter) → location. Each change of grain is a CTE.
- Build and validate one CTE at a time. Run each on its own; compare with a known control total (2025 net sales = 63,741.94).
- Join only at matching grains. Aggregate child data to the parent grain before joining (Day 8).
APPLYwith an aggregate orTOP (1)keeps the outer grain. - Add presentation last. Rounding, labels, formatting and ordering belong in the final
SELECT. - Reconcile. A final check (totals match, row counts as expected) is part of the query, not an afterthought.
Part 3 — Three integration traps
ISO weeks belong to an ISO year
ISO weeks run Monday–Sunday, and week 1 is the week containing the first Thursday of the year. So 29–31 December 2025 belong to ISO week 1 of 2026, and 1–4 January 2026 do too; meanwhile 30 December 2024–5 January 2025 is week 1 of 2025. Grouping 2025 dates by DATEPART(ISO_WEEK, d) alone merges late-December 2025 days into “week 1” with early-January 2025 days. Always group by (ISO year, ISO week). The ISO year is the calendar year of the week’s Thursday:
YEAR(DATEADD(DAY, 4 - IsoDayOfWeek, CalendarDate)) -- IsoDayOfWeek: 1 = Monday … 7 = Sunday
APPLY keeps the grain only if it returns one row
OUTER APPLY (SELECT TOP (1) …) and OUTER APPLY (SELECT COUNT(*), SUM(…) …) return exactly one row per outer row. An APPLY that can return several rows multiplies the outer row — fine when intended (top 3 per store), fan-out when not.
FORMAT is for presentation, and it is slow
FORMAT(1446.29, 'C', 'en-GB') gives £1,446.29; FORMAT(d, 'd MMM yyyy', 'en-GB') gives 29 Dec 2025. FORMAT uses .NET formatting and is much slower than CONVERT/CAST on large results, and it returns nvarchar — so you can no longer sort or sum the result numerically. Format only in the final SELECT, only for display, ideally in the reporting tool.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor) plus the Day 12 script |
| Database | RetailLab |
| Tables used | Sales.SalesOrder, Sales.SalesOrderLine, Sales.Store, Sales.Customer, HR.Employee, Inventory.Product, Lab.Calendar, Lab.ProductListPrice (temporal) |
| Permissions | SELECT on these tables; running sp_executesql for the stretch |
| Objects created today | None — Day 14 is read-only |
Preparation.
- If
RetailLabmay have been changed, runscripts/setup/04_reset_training_data.sqlin SQLCMD Mode (destructive), thenscripts/lessons/day12_calendar_and_temporal.sql(download it). - If you only changed prices on Day 12, re-running
day12_calendar_and_temporal.sqlis enough.
Validation. Expected: 1096, 29, 10, 63741.94.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Lab.Calendar) AS CalendarDays,
(SELECT COUNT(*) FROM Lab.ProductListPrice) AS CurrentPrices,
(SELECT COUNT(*) FROM Lab.ProductListPriceHistory) AS PriceHistoryRows,
(SELECT CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2))
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01') AS NetSales2025;
If Lab.Calendar is missing (Invalid object name), run the Day 12 script.
D. Worked examples
Worked example 1 — The ISO week trap
-- Wrong: groups by ISO week number only
SELECT c.IsoWeek, COUNT(o.OrderID) AS Orders, MIN(c.CalendarDate) AS FirstDay, MAX(c.CalendarDate) AS LastDay
FROM Lab.Calendar AS c
LEFT JOIN Sales.SalesOrder AS o ON o.OrderDate = c.CalendarDate AND o.OrderStatus <> 'Cancelled'
WHERE c.CalendarYear = 2025
GROUP BY c.IsoWeek
HAVING c.IsoWeek = 1;
-- Right: group by ISO year and ISO week
SELECT iy.IsoYear, c.IsoWeek, COUNT(o.OrderID) AS Orders, MIN(c.CalendarDate) AS FirstDay, MAX(c.CalendarDate) AS LastDay
FROM Lab.Calendar AS c
CROSS APPLY (VALUES (YEAR(DATEADD(DAY, 4 - c.IsoDayOfWeek, c.CalendarDate)))) AS iy (IsoYear)
LEFT JOIN Sales.SalesOrder AS o ON o.OrderDate = c.CalendarDate AND o.OrderStatus <> 'Cancelled'
WHERE c.CalendarYear = 2025
GROUP BY iy.IsoYear, c.IsoWeek
HAVING c.IsoWeek = 1
ORDER BY iy.IsoYear;
Expected result: the wrong query returns one “week 1” from 2025-01-01 to 2025-12-31 with 6 orders. The right query returns two rows: ISO year 2025, week 1 (1–5 January) with 2 orders, and ISO year 2026, week 1 (29–31 December 2025 in this filter) with 4 orders.
Worked example 2 — Readable price history from the temporal table
WITH Versions AS
(
SELECT ProductID, ListPrice, ValidFrom
FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL
)
SELECT v.ProductID,
p.ProductCode,
COUNT(*) AS Versions,
STRING_AGG(CAST(v.ListPrice AS nvarchar(12)), N' → ') WITHIN GROUP (ORDER BY v.ValidFrom) AS PriceHistory
FROM Versions AS v
JOIN Inventory.Product AS p ON p.ProductID = v.ProductID
GROUP BY v.ProductID, p.ProductCode
HAVING COUNT(*) > 1
ORDER BY v.ProductID;
Expected result: 7 rows — product 1 169.00 → 179.00 → 189.00; 2 329.00 → 349.00; 5 279.00 → 299.00; 9 5.50 → 5.99 → 6.50; 10 and 11 175.00 → 189.00; 22 319.00 → 299.00. The arrow is a Unicode character, so the query uses an N' → ' literal and nvarchar casts. With a varchar literal, → would be converted to the database code page (1252), which has no arrow, and would display as ?.
Worked example 3 — Formatting for a sentence
WITH DecSummary AS
(
SELECT o.CustomerID,
COUNT(DISTINCT o.OrderID) AS Orders,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales,
MAX(o.OrderDate) AS LastOrder
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.CustomerID
)
SELECT TOP (3)
CONCAT(c.FirstName, N' ', c.LastName, N': ', d.Orders,
CASE WHEN d.Orders = 1 THEN N' order, ' ELSE N' orders, ' END,
FORMAT(d.NetSales, 'C', 'en-GB'), N', last on ',
FORMAT(d.LastOrder, 'd MMM yyyy', 'en-GB')) AS Summary
FROM DecSummary AS d
JOIN Sales.Customer AS c ON c.CustomerID = d.CustomerID
ORDER BY d.NetSales DESC, d.CustomerID; -- sort on the number, not the text
Expected result:
Alice Morgan: 3 orders, £1,446.29, last on 29 Dec 2025Xavier Lopez: 1 order, £1,153.30, last on 13 Dec 2025Ben Carter: 2 orders, £904.00, last on 22 Dec 2025
E. Guided practical lab — 2025 location performance pack
Output specification: one row per location (three shops + Online) with Q1–Q4 2025 net sales, Net2025, Net2024, YoYPct, Customers2025, TopProduct2025 and Staff2025 (first names of salespeople who served there in 2025, alphabetical). Then a total row check.
Step 1 — Order grain: one row per non-cancelled order with its location
- Do: Build the base CTE.
- Run:
USE RetailLab; GO WITH OrderBase AS ( SELECT o.OrderID, o.CustomerID, o.EmployeeID, o.OrderDate, COALESCE(s.StoreName, N'Online') AS Location, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID LEFT JOIN Sales.Store AS s ON s.StoreID = o.StoreID WHERE o.OrderStatus <> 'Cancelled' GROUP BY o.OrderID, o.CustomerID, o.EmployeeID, o.OrderDate, s.StoreName ) SELECT Location, COUNT(*) AS Orders, CAST(SUM(OrderNet) AS decimal(12,2)) AS NetAllTime FROM OrderBase GROUP BY Location ORDER BY Location; - Observe: 4 rows; the
NetAllTimevalues add up to 122354.88. - Why it matters:
LEFT JOINtoSales.Storekeeps online orders (NULLStoreID), labelled withCOALESCE— safe becauseStoreNameis never NULL in a real match. - Confirm: 369 orders in total.
Step 2 — Quarterly pivot for 2025
-
Do: Add a
QuarterlyCTE that pivots 2025 order values by quarter. -
Run: replace the final
SELECTwith:, Quarterly AS ( SELECT Location, [1] AS Q1, [2] AS Q2, [3] AS Q3, [4] AS Q4 FROM (SELECT Location, DATEPART(QUARTER, OrderDate) AS Qtr, OrderNet FROM OrderBase WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01') AS src PIVOT (SUM(OrderNet) FOR Qtr IN ([1], [2], [3], [4])) AS p ) SELECT Location, CAST(Q1 AS decimal(12,2)) AS Q1, CAST(Q2 AS decimal(12,2)) AS Q2, CAST(Q3 AS decimal(12,2)) AS Q3, CAST(Q4 AS decimal(12,2)) AS Q4 FROM Quarterly ORDER BY Location; -
Observe:
Location Q1 Q2 Q3 Q4 Edinburgh Old Town 2145.89 2642.30 382.29 4804.08 London Covent Garden 2516.16 1048.27 2534.27 1025.65 Manchester Northern Quarter 3035.90 5330.42 805.94 3080.83 Online 6830.51 9036.81 8988.47 9534.14 -
Why it matters: The pivot source has exactly three columns (Day 11’s implicit-grouping rule).
-
Confirm: every quarter column adds up to the Day 2 quarterly totals (14528.46, 18057.81, 12710.98, 18444.70).
Step 3 — Year comparison and reach (conditional aggregation)
- Do: Add a
YearlyCTE at location grain. - Run: add after
Quarterly:
and test it with, Yearly AS ( SELECT Location, SUM(CASE WHEN OrderDate < '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2024, SUM(CASE WHEN OrderDate >= '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2025, COUNT(DISTINCT CASE WHEN OrderDate >= '2025-01-01' THEN CustomerID END) AS Customers2025 FROM OrderBase GROUP BY Location )SELECT * FROM Yearly ORDER BY Location;as the final statement. - Observe: Edinburgh 3363.97 → 9974.56 (17 customers); London 7785.37 → 7124.35 (15); Manchester 12776.43 → 12253.09 (22); Online 34687.16 → 34389.94 (30).
- Why it matters:
COUNT(DISTINCT CASE … THEN CustomerID END)counts 2025 customers only — theCASEreturns NULL for 2024 orders, whichCOUNTignores. - Confirm:
Net2025sums to 63741.94.
Step 4 — Top product and staff list with APPLY
-
Do: Write the final
SELECT, joining the two location-grain CTEs and adding twoOUTER APPLYs that each return one row per location. -
Run:
SELECT q.Location, CAST(q.Q1 AS decimal(12,2)) AS Q1, CAST(q.Q2 AS decimal(12,2)) AS Q2, CAST(q.Q3 AS decimal(12,2)) AS Q3, CAST(q.Q4 AS decimal(12,2)) AS Q4, CAST(y.Net2025 AS decimal(12,2)) AS Net2025, CAST(y.Net2024 AS decimal(12,2)) AS Net2024, CAST(100.0 * (y.Net2025 - y.Net2024) / NULLIF(y.Net2024, 0) AS decimal(6,1)) AS YoYPct, y.Customers2025, tp.ProductName AS TopProduct2025, st.Staff AS Staff2025 FROM Quarterly AS q JOIN Yearly AS y ON y.Location = q.Location OUTER APPLY ( SELECT TOP (1) p.ProductName FROM OrderBase AS ob JOIN Sales.SalesOrderLine AS l ON l.OrderID = ob.OrderID JOIN Inventory.Product AS p ON p.ProductID = l.ProductID WHERE ob.Location = q.Location AND ob.OrderDate >= '2025-01-01' AND ob.OrderDate < '2026-01-01' GROUP BY p.ProductName, p.ProductCode ORDER BY SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) DESC, p.ProductCode ) AS tp OUTER APPLY ( SELECT STRING_AGG(e.FirstName, N', ') WITHIN GROUP (ORDER BY e.FirstName) AS Staff FROM (SELECT DISTINCT ob.EmployeeID FROM OrderBase AS ob WHERE ob.Location = q.Location AND ob.EmployeeID IS NOT NULL AND ob.OrderDate >= '2025-01-01' AND ob.OrderDate < '2026-01-01') AS d JOIN HR.Employee AS e ON e.EmployeeID = d.EmployeeID ) AS st ORDER BY CASE WHEN q.Location = N'Online' THEN 1 ELSE 0 END, q.Location; -
Observe:
Location Net2025 Net2024 YoYPct Customers2025 TopProduct2025 Staff2025 Edinburgh Old Town 9974.56 3363.97 196.5 17 Handheld GPS Unit Chen, Daniel, James, Liam, Megan, Oliver, Sophie London Covent Garden 7124.35 7785.37 -8.5 15 Summit 4-Person Tent Chen, Daniel, Liam, Megan, Oliver, Sophie Manchester Northern Quarter 12253.09 12776.43 -4.1 22 Summit 4-Person Tent Chen, Daniel, James, Liam, Megan, Oliver, Sophie Online 34389.94 34687.16 -0.9 30 Summit 4-Person Tent NULL -
Why it matters: Edinburgh’s +196.5% reflects a full year of trading against four months in 2024 — the number is right, the comparison is not like-for-like. A good report says so. The staff list for Online is NULL:
STRING_AGGover no rows returns NULL. -
Confirm: 4 rows;
Net2025adds up to 63741.94.
Step 5 — Built-in reconciliation
- Do: Add a final check that the pack reconciles to the source.
- Run: replace the final
SELECTwith:SELECT CAST(SUM(COALESCE(Q1,0) + COALESCE(Q2,0) + COALESCE(Q3,0) + COALESCE(Q4,0)) AS decimal(12,2)) AS PivotTotal, (SELECT CAST(SUM(Net2025) AS decimal(12,2)) FROM Yearly) AS YearlyTotal, CASE WHEN SUM(COALESCE(Q1,0) + COALESCE(Q2,0) + COALESCE(Q3,0) + COALESCE(Q4,0)) = (SELECT SUM(Net2025) FROM Yearly) THEN 'Reconciled' ELSE 'MISMATCH' END AS CheckResult FROM Quarterly; - Observe:
63741.94,63741.94,Reconciled. - Why it matters: Two independently built CTEs agree — evidence that neither the pivot nor the conditional aggregation lost or duplicated anything.
- Confirm:
Reconciled.
F. Independent practice
Task 1 — Quiet weeks in 2025
Using Lab.Calendar, return every (ISO year, ISO week) that contains 2025 dates and had no non-cancelled orders, with the first and last 2025 date of that week.
Success criteria: 1 row — ISO year 2025, week 33, 2025-08-11 to 2025-08-17 (the warehouse migration). There are 53 (ISO year, week) groups in total for 2025’s dates; grouping by week number alone gives 52.
Task 2 — Price history notes
Return one row for every product that appears in the temporal table (current or historical), with ProductCode, Versions, PriceHistory (prices in order, separated by N' → ') and Status (Current if the product has a current row, otherwise Removed).
Success criteria: 30 rows. Product 9: 3 versions, 5.50 → 5.99 → 6.50, Current. Product 30 (TNT-004): 1 version, 599.00, Removed.
Hint
FOR SYSTEM_TIME ALL for the history, EXISTS against the current table for the status.
Task 3 — Sold everywhere in 2025
Using INTERSECT, find products sold (non-cancelled) at all four locations in 2025. Return the product codes.
Success criteria: 15 products: BPK-001, BPK-002, BSL-001, BSL-003, COK-001, COK-002, COK-003, JKT-001, JKT-002, NAV-001, NAV-002, NAV-003, TNT-001, TNT-002, TNT-003.
Hint
Define one CTE of (Location, ProductCode) pairs and intersect four filtered copies of it — or, more scalably, GROUP BY ProductCode HAVING COUNT(DISTINCT Location) = 4. Write both and compare.
Task 4 — December sentence summaries
Produce Worked example 3’s sentence for all 20 customers who ordered in December 2025, sorted by net sales descending. Add a second column, NetSales (numeric), so the result can still be sorted and totalled.
Success criteria: 20 rows; first Alice Morgan: 3 orders, £1,446.29, last on 29 Dec 2025; NetSales adds up to 9718.65.
Stretch challenge — Q4 2025 weekly heat map (dynamic or static pivot)
Show orders per day for Q4 2025 as a grid: one row per (ISO year, ISO week), columns Mon…Sun, with 0 for days with no orders (including days outside the quarter in the first and last weeks), plus a Total column.
Success criteria: 14 rows from 2025-W40 to 2026-W01. 2025-W52 (22–28 December) is the busiest week: 2, 2, 1, 2, 1, 1, 1 = 10. The Total column adds up to 56.
Hint
Start from Lab.Calendar (dates in Q4), left-join orders, group by ISO year, week and day name, then pivot on the day name. COALESCE the pivoted cells to 0.
G. Solutions and explanations
Show solution 1
WITH DaysWithOrders AS
(
SELECT c.CalendarDate, c.IsoWeek,
YEAR(DATEADD(DAY, 4 - c.IsoDayOfWeek, c.CalendarDate)) AS IsoYear,
COUNT(o.OrderID) AS Orders
FROM Lab.Calendar AS c
LEFT JOIN Sales.SalesOrder AS o
ON o.OrderDate = c.CalendarDate AND o.OrderStatus <> 'Cancelled'
WHERE c.CalendarYear = 2025
GROUP BY c.CalendarDate, c.IsoWeek, c.IsoDayOfWeek
)
SELECT IsoYear, IsoWeek, MIN(CalendarDate) AS FirstDate, MAX(CalendarDate) AS LastDate
FROM DaysWithOrders
GROUP BY IsoYear, IsoWeek
HAVING SUM(Orders) = 0
ORDER BY IsoYear, IsoWeek;
Result: 2025, 33, 2025-08-11, 2025-08-17.
Reasoning. Grouping by day first and then by week keeps each step simple. The LEFT JOIN from the calendar is what makes an empty week visible at all.
Show solution 2
WITH Versions AS
(
SELECT ProductID, ListPrice, ValidFrom
FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL
)
SELECT p.ProductCode,
COUNT(*) AS Versions,
STRING_AGG(CAST(v.ListPrice AS nvarchar(12)), N' → ') WITHIN GROUP (ORDER BY v.ValidFrom) AS PriceHistory,
CASE WHEN EXISTS (SELECT 1 FROM Lab.ProductListPrice AS cur WHERE cur.ProductID = v.ProductID)
THEN 'Current' ELSE 'Removed' END AS Status
FROM Versions AS v
JOIN Inventory.Product AS p ON p.ProductID = v.ProductID
GROUP BY v.ProductID, p.ProductCode
ORDER BY p.ProductCode;
Reasoning. The EXISTS subquery refers to v.ProductID, which is a grouping column, so it is valid in the grouped SELECT. 23 products have a single version; 7 have several; one (TNT-004) is Removed.
Show solution 3
WITH Sold AS
(
SELECT DISTINCT COALESCE(s.StoreName, N'Online') AS Location, p.ProductCode
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
LEFT JOIN Sales.Store AS s ON s.StoreID = o.StoreID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
)
SELECT ProductCode FROM Sold WHERE Location = N'Online'
INTERSECT
SELECT ProductCode FROM Sold WHERE Location = N'London Covent Garden'
INTERSECT
SELECT ProductCode FROM Sold WHERE Location = N'Manchester Northern Quarter'
INTERSECT
SELECT ProductCode FROM Sold WHERE Location = N'Edinburgh Old Town'
ORDER BY ProductCode;
-- Scalable alternative
WITH Sold AS ( /* same as above */
SELECT DISTINCT COALESCE(s.StoreName, N'Online') AS Location, p.ProductCode
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
LEFT JOIN Sales.Store AS s ON s.StoreID = o.StoreID
WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
)
SELECT ProductCode
FROM Sold
GROUP BY ProductCode
HAVING COUNT(DISTINCT Location) = (SELECT COUNT(DISTINCT Location) FROM Sold)
ORDER BY ProductCode;
Result: the 15 codes listed. The INTERSECT chain hard-codes locations; the HAVING version adapts automatically if a fourth shop opens.
Show solution 4
WITH DecSummary AS
(
SELECT o.CustomerID,
COUNT(DISTINCT o.OrderID) AS Orders,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales,
MAX(o.OrderDate) AS LastOrder
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.CustomerID
)
SELECT CONCAT(c.FirstName, N' ', c.LastName, N': ', d.Orders,
CASE WHEN d.Orders = 1 THEN N' order, ' ELSE N' orders, ' END,
FORMAT(d.NetSales, 'C', 'en-GB'), N', last on ',
FORMAT(d.LastOrder, 'd MMM yyyy', 'en-GB')) AS Summary,
CAST(d.NetSales AS decimal(12,2)) AS NetSales
FROM DecSummary AS d
JOIN Sales.Customer AS c ON c.CustomerID = d.CustomerID
ORDER BY d.NetSales DESC, d.CustomerID;
Reasoning. Keeping a numeric column alongside formatted text lets the report tool sort, total and filter correctly. FORMAT(…, 'C', 'en-GB') rounds to two decimals for display.
Show the stretch solution
WITH Q4Days AS
(
SELECT c.CalendarDate,
YEAR(DATEADD(DAY, 4 - c.IsoDayOfWeek, c.CalendarDate)) AS IsoYear,
c.IsoWeek,
LEFT(c.DayName, 3) AS Dy,
COUNT(o.OrderID) AS Orders
FROM Lab.Calendar AS c
LEFT JOIN Sales.SalesOrder AS o
ON o.OrderDate = c.CalendarDate AND o.OrderStatus <> 'Cancelled'
WHERE c.CalendarDate >= '2025-10-01' AND c.CalendarDate < '2026-01-01'
GROUP BY c.CalendarDate, c.IsoDayOfWeek, c.IsoWeek, c.DayName
)
SELECT CONCAT(IsoYear, '-W', RIGHT(CONCAT('0', IsoWeek), 2)) AS IsoYearWeek,
COALESCE([Mon], 0) AS Mon, COALESCE([Tue], 0) AS Tue, COALESCE([Wed], 0) AS Wed,
COALESCE([Thu], 0) AS Thu, COALESCE([Fri], 0) AS Fri, COALESCE([Sat], 0) AS Sat,
COALESCE([Sun], 0) AS Sun,
COALESCE([Mon],0) + COALESCE([Tue],0) + COALESCE([Wed],0) + COALESCE([Thu],0)
+ COALESCE([Fri],0) + COALESCE([Sat],0) + COALESCE([Sun],0) AS Total
FROM (SELECT IsoYear, IsoWeek, Dy, Orders FROM Q4Days) AS src
PIVOT (SUM(Orders) FOR Dy IN ([Mon], [Tue], [Wed], [Thu], [Fri], [Sat], [Sun])) AS p
ORDER BY IsoYear, IsoWeek;
Result (first and last rows):
| IsoYearWeek | Mon | Tue | Wed | Thu | Fri | Sat | Sun | Total |
|---|---|---|---|---|---|---|---|---|
| 2025-W40 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 2 |
| 2025-W41 | 0 | 0 | 0 | 0 | 1 | 2 | 2 | 5 |
| … | ||||||||
| 2025-W52 | 2 | 2 | 1 | 2 | 1 | 1 | 1 | 10 |
| 2026-W01 | 1 | 2 | 1 | 0 | 0 | 0 | 0 | 4 |
Reasoning. The pivot’s source is (IsoYear, IsoWeek, Dy, Orders), so grouping is by year and week. W40’s Monday and Tuesday (29–30 September) are outside Q4 and simply have no source rows, so COALESCE shows 0 — label them differently if “outside the period” must be distinguished from “no orders”. RIGHT(CONCAT('0', IsoWeek), 2) zero-pads the week number so the labels sort correctly as text.
H. Knowledge check (Week 2)
-
Understanding. A report joins
Sales.SalesOrderto bothSales.SalesOrderLineandSales.Paymentand sums payments. What goes wrong, and what is the standard fix? -
Query interpretation.
SELECT Email FROM Sales.Customer INTERSECT SELECT TRIM(Email) FROM Staging.CustomerImport;returns one row more than you expected. Which row, and why? -
Debugging. A
PIVOTof monthly sales by channel returns hundreds of rows. What do you check first? -
Understanding. When would you choose
OUTER APPLY (SELECT TOP (1) …)overROW_NUMBER()in a CTE? -
Mixed. Name the technique for each: (a) all product–warehouse combinations including ones with no stock row; (b) the price list as it stood on 1 March 2023; (c) the second word of a name; (d) one row per tag from a comma-separated column; (e) days between order and shipment excluding weekends and bank holidays.
Show the answers
-
Fan-out: every payment is repeated once per order line (and vice versa), inflating sums. Aggregate each child table to the order grain in separate CTEs (or
APPLYs), then join. -
A NULL row: both inputs contain a NULL email and set operators treat NULLs as equal. Filter
WHERE Email IS NOT NULL. -
The pivot source: it must contain only the grouping, spreading and value columns. Any extra column becomes an implicit grouping column.
-
When the outer set is small and an index supports the inner
ORDER BY, so SQL Server can seek the top row per outer row; also when the logic reads more naturally as “for each…”.ROW_NUMBERis often better when most rows of the inner table are needed anyway. -
(a)
CROSS JOINscaffold +LEFT JOIN. (b)FOR SYSTEM_TIME AS OF '2023-03-01'on the temporal table. (c)CHARINDEX/SUBSTRING(orSTRING_SPLITwith ordinal 2). (d)CROSS APPLY STRING_SPLIT(…, ',', 1). (e) CountIsWorkingDayrows in a calendar table between the two dates.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Invalid object name ‘Lab.Calendar’ / ‘Lab.ProductListPrice’ | Day 12 script not run since the last reset | Run scripts/lessons/day12_calendar_and_temporal.sql |
| Week 1 or week 52/53 totals look too high | Grouping by ISO week without ISO year | Group by both |
| Totals don’t reconcile between CTEs | Grain mismatch or fan-out in one branch | Validate each CTE against the control total |
→ shows as ? |
varchar literal for a Unicode character |
N' → ' and nvarchar casts |
| Sorting “£1,446.29” text gives odd order | Sorting formatted text | Sort by the numeric column |
Query slow when adding FORMAT over many rows |
FORMAT cost |
Format only final rows, or use CONVERT/the reporting tool |
STRING_AGG returns NULL for a group |
No rows aggregated (e.g. no staff for Online) | COALESCE(…, N'—') if a label is needed |
Cleanup. Day 14 is read-only; nothing to clean up. Keep the Day 12 objects. Week 3 changes data, so each lesson provides its own safe starting state.
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 showed one merged “week 1” (6 orders) and two correct week-1 rows (2 and 4).
- Your location pack shows four rows and reconciles (63741.94, Reconciled).
- Tasks 1–4 match the success criteria (1, 30, 15 and 20 rows).
- You can explain why Edinburgh’s +196.5% is not a like-for-like comparison.
- You answered the Week 2 knowledge check without looking back.
Week 2 recap
| Day | Core idea |
|---|---|
| 8 | Join types, cardinality, NULL keys, filter placement, scaffolds, fan-out |
| 9 | Set operators, NULL equality, two-way EXCEPT, TRY_CAST |
| 10 | CROSS/OUTER APPLY, inline TVFs, top-N per row, named calculations |
| 11 | PIVOT/UNPIVOT, implicit grouping, safe dynamic SQL |
| 12 | Date functions and traps, half-open ranges, calendar tables, temporal tables |
| 13 | String functions, LIKE, REGEXP_LIKE, STRING_SPLIT/STRING_AGG |
| 14 | Building complex queries from validated CTEs at explicit grains |
Next: Week 3 begins with Day 15 — Advanced INSERT, UPDATE and DELETE: the OUTPUT clause, MERGE (and its caveats), table variables and safe modification habits.