Track contentsDay 7 of 30

Day 7: Week 1 review & integration

Intermediate2 hrs 30 minWorks in: SQL Server 2025, SSMS 22
On this page
  1. A. Lesson overview
  2. B. Theory explained clearly
  3. C. Dataset and starting state
  4. D. Worked examples
  5. E. Guided practical lab — Customer scorecard
  6. F. Independent practice
  7. G. Solutions and explanations
  8. H. Knowledge check (Week 1)
  9. I. Troubleshooting and cleanup
  10. J. Completion checklist, recap and references

By the end of this lesson you can

  • List the logical processing order of a SELECT statement from FROM to OFFSET … FETCH, and use it to predict which aliases and functions each clause can see.
  • Return the first n rows of an ordered result with TOP (n), include tied rows with WITH TIES, and explain why TOP without ORDER BY is arbitrary.
  • Page through results with OFFSET … FETCH using a deterministic order.
  • Decide, for a given business question, whether to use a subquery, derived table, CTE, GROUP BY, ranking function or framed aggregate.
  • Build a multi-step analytical query that combines CTEs, aggregation, conditional aggregation, window functions and CASE, and validate it against a control total.

A. Lesson overview

Week 1 — Advanced querying foundations (review)
Level Intermediate → Advanced
Estimated time 2 h 30 min essential + 45 min optional stretch
Prerequisites Days 1–6
Syllabus topic Week 1 Review & Integration — combine all Week 1 concepts
Foundations added today The complete logical query processing order; TOP (with WITH TIES and PERCENT); paging with OFFSET … FETCH

Activity breakdown

Activity Time
Part 1 — Logical query processing: the full picture 20 min
Part 2 — TOP and OFFSET … FETCH 20 min
Part 3 — Choosing the right tool: a Week 1 decision guide 15 min
Worked examples 20 min
Guided lab: customer scorecard 40 min
Independent practice 30 min
Knowledge check (Week 1) and wrap-up 5 min
Stretch challenge (optional) 45 min

Business scenario. The commercial director wants a customer scorecard for the 2026 planning meeting: one row per customer with their order history, value, recency, rank, value quartile and a simple segment label (Champion, At risk, New, Regular). It must reconcile to the finance figures. It is a realistic piece of analyst work that uses almost everything from Week 1.


B. Theory explained clearly

Part 1 — Logical query processing: the full picture

SQL Server is free to execute a query in any physical order it likes, but the result must be as if the clauses were processed in this logical order:

Step Clause Produces Can use
1 FROM, including each JOIN … ON from left to right The working set of rows Table and table aliases
2 WHERE Rows that are TRUE Columns from step 1 — not SELECT aliases, aggregates or window functions
3 GROUP BY One row per group Expressions over step-2 columns
4 HAVING Groups that are TRUE Grouped columns and aggregates — not SELECT aliases or window functions
5 SELECT Output expressions, aliases, window functions Everything above; aliases defined here are not visible to each other in the same SELECT
6 DISTINCT Unique output rows —
7 ORDER BY Sorted result SELECT aliases, window functions, columns (with restrictions when DISTINCT is used)
8 TOP / OFFSET … FETCH A subset of the sorted rows —

This one table explains most of the “why does this fail?” moments of Week 1:

Symptom Explanation
WHERE UnitMargin > 100 fails UnitMargin is defined at step 5; WHERE is step 2
HAVING UnitsSold > 40 fails Step 4 runs before the alias exists at step 5
WHERE RANK() OVER (…) <= 3 fails Window functions are computed at step 5
ORDER BY UnitMargin works Step 7 runs after step 5
A LEFT JOIN behaves like an inner join A WHERE filter (step 2) on the right-hand table removes the NULL rows produced at step 1
PctOfTotal ignores filtered rows Window functions (step 5) only see rows that survived step 2

Within a single SELECT list, one expression cannot refer to another’s alias: SELECT a + b AS c, c * 2 AS d fails. Use a CTE, a derived table or CROSS APPLY (Day 10).

When DISTINCT is used, ORDER BY may only refer to columns that appear in the SELECT list, because sorting happens after duplicates are removed.

Part 2 — TOP and OFFSET … FETCH

B1. TOP

SELECT TOP (5) ProductCode, ListPrice
FROM   Inventory.Product
ORDER BY ListPrice DESC, ProductCode;
Form Returns
TOP (n) The first n rows of the ORDER BY
TOP (n) WITH TIES The first n rows plus any further rows that tie with the n-th on the ORDER BY columns
TOP (n) PERCENT The first n% of rows, rounded up

Rules:

  • Always use ORDER BY with TOP. Without it, SQL Server returns some n rows — whichever are cheapest to produce. They may differ between runs.
  • WITH TIES requires ORDER BY, and ties are judged only on the ORDER BY columns. If you include a unique tie-breaker in ORDER BY, there can be no ties, so WITH TIES has no effect.
  • Use parentheses: TOP (5). They are required when n is a variable or expression (TOP (@n)), and recommended always.
  • TOP is T-SQL syntax. The ANSI equivalent is OFFSET 0 ROWS FETCH NEXT n ROWS ONLY.

TOP versus ROW_NUMBER: TOP returns the first n rows of the whole result. For “top n per group” you need the Day 5 pattern (ROW_NUMBER in a CTE), or CROSS APPLY with TOP (Day 10).

B2. OFFSET … FETCH

SELECT  CustomerID, LastName, FirstName
FROM    Sales.Customer
ORDER BY LastName, CustomerID
OFFSET 20 ROWS                -- skip the first 20
FETCH NEXT 10 ROWS ONLY;      -- return the next 10  (page 3 of 10 rows)
  • OFFSET … FETCH is part of ORDER BY; it cannot be used without it.
  • OFFSET 0 ROWS is valid; FETCH is optional (omit it to return everything after the offset).
  • Values can be variables: OFFSET (@PageNumber - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY.
  • The order must be unique, or a row can appear on two pages, or on none, as tied rows shuffle between executions. LastName alone is not unique (customers 3 and others may share surnames in future data); LastName, CustomerID is.
  • Each page re-runs the query and skips rows; page 1,000 has to read and discard 9,990 rows first. For deep paging over large tables, keyset paging (WHERE (LastName > @LastSeenName) OR (LastName = @LastSeenName AND CustomerID > @LastSeenID)) scales better. You will revisit this on Day 24.
  • Rows inserted or deleted between page requests shift later pages. Paging is a presentation technique, not a consistent snapshot.

Part 3 — Choosing the right tool

Question shape First choice Alternatives
Compare a row with one overall value Scalar subquery Windowed aggregate OVER ()
“Has at least one / has none” EXISTS / NOT EXISTS IN; LEFT JOIN … IS NULL
Aggregate of an aggregate (average order value) CTE or derived table SUM ÷ COUNT(DISTINCT …)
Define a calculation once, filter on it CTE Derived table; CROSS APPLY (Day 10)
Use one intermediate result twice CTE Temporary table if expensive (Day 15)
Walk a hierarchy Recursive CTE hierarchyid (not covered)
One row per group with subtotals GROUP BY + ROLLUP/CUBE/GROUPING SETS UNION ALL of grouped queries
Several totals side by side Conditional aggregation SUM(CASE …) PIVOT (Day 11)
Rank or number rows, top-N per group, de-duplicate ROW_NUMBER/RANK/DENSE_RANK in a CTE Correlated MAX subquery
Share of a group total SUM(…) OVER (PARTITION BY …) Join to a grouped CTE
Previous/next row, gaps between events LAG / LEAD Self-join on a key
Running total, moving average Aggregate OVER (ORDER BY … ROWS …) Correlated subquery (slow)
First n rows overall, or a page TOP / OFFSET … FETCH ROW_NUMBER

And three habits that apply to every query:

  1. Know the grain of every intermediate result (one row per what?).
  2. Validate against a control total before building on a step.
  3. Make ordering deterministic wherever order matters — in ORDER BY, OVER (ORDER BY …), TOP and paging.

C. Dataset and starting state

Dataset Shared synthetic dataset (Ridgeway Outdoor), core data only
Database RetailLab
Tables used Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, Inventory.Category
Reference date 31 December 2025 (“today” for recency calculations)
Permissions SELECT on these tables
Objects created today None — Day 7 is read-only

Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive) or scripts 00 → 03.

Validation. Expected: 35, 369, 122354.88.

USE RetailLab;
GO

SELECT  COUNT(DISTINCT o.CustomerID)                                              AS OrderingCustomers,
        COUNT(DISTINCT o.OrderID)                                                 AS NonCancelledOrders,
        CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales
FROM    Sales.SalesOrder     AS o
JOIN    Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE   o.OrderStatus <> 'Cancelled';

D. Worked examples

Worked example 1 — TOP with and without ties

WITH CustomerOrders AS
(
    SELECT CustomerID, COUNT(*) AS Orders
    FROM   Sales.SalesOrder
    WHERE  OrderStatus <> 'Cancelled'
    GROUP BY CustomerID
)
SELECT TOP (4) CustomerID, Orders
FROM   CustomerOrders
ORDER BY Orders DESC;                    -- non-deterministic at the 4th row!

WITH CustomerOrders AS
(
    SELECT CustomerID, COUNT(*) AS Orders
    FROM   Sales.SalesOrder
    WHERE  OrderStatus <> 'Cancelled'
    GROUP BY CustomerID
)
SELECT TOP (4) WITH TIES CustomerID, Orders
FROM   CustomerOrders
ORDER BY Orders DESC;

Expected result: the first query returns 4 rows: customers 1, 12 and 18 (27 orders each) and one of customers 6 or 26 (21 orders) — which one is not guaranteed. The second returns 5 rows, including both 6 and 26, because they tie on the ORDER BY value.

What to notice: adding CustomerID to the ORDER BY would make the first query deterministic (customer 6 would be chosen) but would make WITH TIES pointless. Decide which behaviour the business wants: “exactly four” or “everyone tied for the top four positions”.

Worked example 2 — Page 3 of the customer list

DECLARE @PageNumber int = 3,
        @PageSize   int = 10;

SELECT  CustomerID, LastName, FirstName
FROM    Sales.Customer
ORDER BY LastName, CustomerID
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;

Expected result: 10 rows — Kelly (14), Lefèvre (33), Lindqvist (38), Lopez (23), MacDonald (8), Martin (11), Mehta (39), Morgan (1), Murphy (9), Okoye (28). Page 4 (@PageNumber = 4) returns the last 10 customers.

Worked example 3 — Alias visibility within a SELECT

-- Fails: Invalid column name 'UnitMargin'.
SELECT  ProductCode,
        ListPrice - StandardCost              AS UnitMargin,
        UnitMargin / NULLIF(ListPrice, 0)     AS MarginRatio
FROM    Inventory.Product;

-- Works: define the alias in one layer, use it in the next.
WITH m AS
(
    SELECT ProductCode, ListPrice, ListPrice - StandardCost AS UnitMargin
    FROM   Inventory.Product
)
SELECT  ProductCode, UnitMargin,
        CAST(UnitMargin / NULLIF(ListPrice, 0) AS decimal(5,3)) AS MarginRatio
FROM    m
ORDER BY MarginRatio DESC, ProductCode;

Expected result: the first statement fails; the second returns 30 rows. All aliases in one SELECT list are created “at the same time”, so none can see another.

Worked example 4 — One question, three correct solutions

“Which customers placed an order in December 2025 worth more than £600?”

-- A: EXISTS with a correlated aggregate
SELECT c.CustomerID
FROM   Sales.Customer AS c
WHERE  EXISTS (SELECT 1
               FROM   Sales.SalesOrder     AS o
               JOIN   Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
               WHERE  o.CustomerID = c.CustomerID
                 AND  o.OrderStatus <> 'Cancelled'
                 AND  o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
               GROUP BY o.OrderID
               HAVING SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) > 600)
ORDER BY c.CustomerID;

-- B: CTE + DISTINCT
WITH DecOrders AS
(
    SELECT  o.CustomerID, o.OrderID,
            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
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
    GROUP BY o.CustomerID, o.OrderID
)
SELECT DISTINCT CustomerID FROM DecOrders WHERE OrderNet > 600 ORDER BY CustomerID;

-- C: window function — largest December order per customer, then filter
WITH DecLines AS
(
    SELECT  o.CustomerID, o.OrderID,
            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
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
    GROUP BY o.CustomerID, o.OrderID
),
Biggest AS
(
    SELECT CustomerID, MAX(OrderNet) OVER (PARTITION BY CustomerID) AS MaxDecOrder
    FROM   DecLines
)
SELECT DISTINCT CustomerID FROM Biggest WHERE MaxDecOrder > 600 ORDER BY CustomerID;

Expected result (all three): 6 customers — 1, 13, 17, 20, 23 and 28.

What to notice: all three are correct; A states the question most directly; B is the easiest to debug (run the CTE alone); C is overkill here but shows that window functions and grouping can be layered. Choose the version a colleague will understand.

If your result differs, run DecOrders on its own and inspect OrderNet: the six customers are those with at least one December order worth more than 600.00.


E. Guided practical lab — Customer scorecard

Build one row per ordering customer with: orders, lifetime net, average order value, first and last order dates, days since the last order (as at 31 December 2025), 2024 and 2025 net sales, value rank, value quartile and a segment.

Segment rules (agreed with the commercial director)

Segment Rule (first match wins)
Champion Value quartile 1 and last order within 60 days
At risk Value quartile 1 or 2 and last order more than 90 days ago
New First order on or after 1 July 2025
Regular Everyone else

Step 1 — One row per order (grain: order)

  1. Do: Create the base CTE.
  2. Run:
    USE RetailLab;
    GO
    
    DECLARE @AsAt date = '2025-12-31';
    
    WITH OrderTotals AS
    (
        SELECT  o.CustomerID, o.OrderID, o.OrderDate,
                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
        WHERE   o.OrderStatus <> 'Cancelled'
        GROUP BY o.CustomerID, o.OrderID, o.OrderDate
    )
    SELECT COUNT(*) AS Orders, CAST(SUM(OrderNet) AS decimal(12,2)) AS NetSales
    FROM   OrderTotals;
  3. Observe: 369, 122354.88.
  4. Why it matters: Control total: the scorecard must add up to this.
  5. Confirm: matches the validation query.

Step 2 — One row per customer (grain: customer), with conditional aggregation

  1. Do: Add a CustomerStats CTE after OrderTotals.
  2. Run: replace the final SELECT:
    , CustomerStats AS
    (
        SELECT  CustomerID,
                COUNT(*)                                                     AS Orders,
                SUM(OrderNet)                                                AS LifetimeNet,
                MIN(OrderDate)                                               AS FirstOrder,
                MAX(OrderDate)                                               AS LastOrder,
                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
        FROM    OrderTotals
        GROUP BY CustomerID
    )
    SELECT COUNT(*) AS Customers, CAST(SUM(LifetimeNet) AS decimal(12,2)) AS NetSales,
           CAST(SUM(Net2024) AS decimal(12,2)) AS Net2024, CAST(SUM(Net2025) AS decimal(12,2)) AS Net2025
    FROM   CustomerStats;
  3. Observe: 35, 122354.88, 58612.94, 63741.94.
  4. Why it matters: Two grains, two control checks — the year split also matches Day 2.
  5. Confirm: the three totals match.

Step 3 — Add recency, rank and quartile (window functions over customers)

  1. Do: Add a Scored CTE.
  2. Run: replace the final SELECT:
    , Scored AS
    (
        SELECT  *,
                DATEDIFF(DAY, LastOrder, @AsAt)                                AS DaysSinceLast,
                RANK()   OVER (ORDER BY LifetimeNet DESC)                      AS ValueRank,
                NTILE(4) OVER (ORDER BY LifetimeNet DESC, CustomerID)          AS ValueQuartile
        FROM    CustomerStats
    )
    SELECT CustomerID, Orders, CAST(LifetimeNet AS decimal(12,2)) AS LifetimeNet,
           DaysSinceLast, ValueRank, ValueQuartile
    FROM   Scored
    ORDER BY ValueRank, CustomerID;
  3. Observe: 35 rows. Customer 1 is rank 1 (9301.71, 2 days since last order); customer 35 is rank 35.
  4. Why it matters: Window functions run on the 35 customer rows, after GROUP BY in the previous CTE.
  5. Confirm: quartile sizes 9, 9, 9, 8.

Step 4 — Apply the segment rules and finish the scorecard

  1. Do: Replace the final SELECT with the full output.

  2. Run:

    SELECT  s.CustomerID,
            CONCAT_WS(N' ', c.FirstName, c.LastName)                 AS CustomerName,
            s.Orders,
            CAST(s.LifetimeNet AS decimal(12,2))                     AS LifetimeNet,
            CAST(s.LifetimeNet / s.Orders AS decimal(10,2))          AS AvgOrderValue,
            s.FirstOrder,
            s.LastOrder,
            s.DaysSinceLast,
            CAST(s.Net2024 AS decimal(12,2))                         AS Net2024,
            CAST(s.Net2025 AS decimal(12,2))                         AS Net2025,
            s.ValueRank,
            s.ValueQuartile,
            CASE
                WHEN s.ValueQuartile = 1  AND s.DaysSinceLast <= 60 THEN 'Champion'
                WHEN s.ValueQuartile <= 2 AND s.DaysSinceLast >  90 THEN 'At risk'
                WHEN s.FirstOrder >= '2025-07-01'                    THEN 'New'
                ELSE                                                      'Regular'
            END                                                      AS Segment
    FROM    Scored          AS s
    JOIN    Sales.Customer  AS c ON c.CustomerID = s.CustomerID
    ORDER BY s.ValueRank, s.CustomerID;

    (Highlight from DECLARE to the end, so @AsAt is defined.)

  3. Observe: 35 rows. The first ten:

    CustomerID CustomerName Orders LifetimeNet AvgOrderValue DaysSinceLast Net2024 Net2025 ValueRank ValueQuartile Segment
    1 Alice Morgan 27 9301.71 344.51 2 4387.48 4914.22 1 1 Champion
    18 Sofia Rossi 27 8883.52 329.02 26 6053.05 2830.47 2 1 Champion
    12 Zoë Bennett 27 8810.01 326.30 8 5042.62 3767.39 3 1 Champion
    6 Farah Ali 21 7178.50 341.83 24 3011.75 4166.75 4 1 Champion
    26 Amelia Brooks 21 6561.86 312.47 1 3897.90 2663.96 5 1 Champion
    29 Henry Shaw 16 6323.12 395.20 69 1843.87 4479.25 6 1 Regular
    13 Mohammed Rahman 19 6062.85 319.10 4 1729.00 4333.85 7 1 Champion
    4 David Thompson 18 5840.29 324.46 23 3497.60 2342.69 8 1 Champion
    23 Xavier Lopez 18 5670.92 315.05 18 2767.74 2903.18 9 1 Champion
    9 Jack Murphy 14 4905.59 350.40 20 4239.64 665.95 10 2 Regular
  4. Why it matters: Every column traces back to a validated step. The JOIN to Sales.Customer happens last, at the customer grain, so it cannot multiply rows.

  5. Confirm: segment counts — 8 Champion, 2 At risk (customers 27 and 25), 1 New (customer 35), 24 Regular.

Step 5 — Challenge the rules

  1. Do: Look at customer 14 (Niamh Kelly, 554 days since last order, quartile 3) and customer 9 (Jack Murphy, 2025 sales down 84%). Both are Regular.
  2. Run: no new SQL — or filter the step-4 output with an outer query on Segment = 'Regular' AND DaysSinceLast > 180.
  3. Observe: the agreed rules miss lost lower-value customers and sharply declining high-value ones.
  4. Why it matters: SQL implements the rules precisely; whether the rules are good is a business question. Task 4 below quantifies the decliners.
  5. Confirm: you can propose one extra rule and say where in the CASE it should go (order matters: first match wins).

F. Independent practice

Task 1 — Top products with ties

Return the 2025 products with the top 4 unit totals, including any products tied with the fourth (non-cancelled orders). Return ProductCode, ProductName and Units, sorted by units descending, then product code.

Success criteria: 4 rows — COK-003 (38), FTW-001 (30), JKT-001 (27) and TNT-002 (26). No other product sold 26 units, so WITH TIES adds nothing here. Then change the query to TOP (7) WITH TIES: it returns 9 rows, because three products tie on the seventh value, 22 units (BPK-003, JKT-003, TNT-003).

Hint

You need ORDER BY Units DESC alone in the TOP query for WITH TIES to work; then wrap it in an outer query (or a CTE) to sort the output by units and product code.

Task 2 — Paging a product catalogue

Using variables @PageNumber and @PageSize, return a page of products sorted by ListPrice descending then ProductCode. Return ProductCode, ProductName, ListPrice and RowNo (the position in the full sorted list).

Success criteria: with page size 8, page 2 returns positions 9–16: JKT-002 (189.00), TNT-001 (189.00), JKT-003, JKT-004 (159.00), FTW-001, FTW-002 (139.00), SLP-001 (129.00), FTW-003 (109.00). Page 4 returns 6 rows.

Hint

ROW_NUMBER() OVER (ORDER BY ListPrice DESC, ProductCode) gives RowNo; the same order in the final ORDER BY … OFFSET … FETCH keeps them consistent.

Task 3 — Each category’s two best months in 2025

For each top-level category, return its two best months of 2025 by net sales: TopCategory, MonthNo, NetSales, MonthRank and PctOfCategoryYear (1 decimal place). Sort by category and rank.

Success criteria: 6 rows. Accessories: December (2907.17, 24.8%) and June (1704.60, 14.5%). Camping: April (3826.05) and December (3626.90). Clothing: May (3349.25) and December (3184.58).

Hint

Day 4’s CategoryTree → monthly sales per top category (GROUP BY) → RANK() and SUM() OVER (PARTITION BY …) in a third CTE → filter.

Task 4 — Sharply declining customers (challenging)

Find customers who bought in 2024 and whose 2025 net sales are less than half of their 2024 net sales (including customers with no 2025 sales). Return CustomerID, Net2024, Net2025, ChangePct (1 decimal place) and DeclineRank (1 = largest drop in pounds). Sort by DeclineRank.

Success criteria: 9 rows. Rank 1: customer 9 (4239.64 → 665.95, −84.3%). Rank 2: customer 18 (6053.05 → 2830.47, −53.2%). Customer 14 appears with 0.00 and −100.0%.

Hint

Reuse OrderTotals and conditional aggregation from the lab. The rank orders by Net2024 - Net2025 descending.

Stretch challenge — Cohort retention

Group ordering customers into cohorts by the quarter of their first order (e.g. 2024-Q1). For each cohort return Cohort, Customers, ActiveH2_2025 (customers with at least one order from 1 July 2025) and RetentionPct. Sort by cohort.

Success criteria: 6 cohorts. 2024-Q1: 19 customers, 16 active, 84.2%. Every later cohort shows 100.0%. Cohorts with no members (2025-Q2, 2025-Q3) do not appear.

Hint

CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder)) builds the label. Count active customers with conditional aggregation over a per-customer CTE.


G. Solutions and explanations

Show solution 1
WITH Units2025 AS
(
    SELECT  p.ProductCode, p.ProductName, SUM(l.Quantity) AS Units
    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
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
    GROUP BY p.ProductCode, p.ProductName
),
TopUnits AS
(
    SELECT TOP (4) WITH TIES ProductCode, ProductName, Units
    FROM   Units2025
    ORDER BY Units DESC                     -- no tie-breaker here, on purpose
)
SELECT ProductCode, ProductName, Units
FROM   TopUnits
ORDER BY Units DESC, ProductCode;           -- deterministic presentation order

Result: 4 rows — COK-003 (38), FTW-001 (30), JKT-001 (27), TNT-002 (26). With TOP (7) WITH TIES: 9 rows — the six products with 38, 30, 27, 26, 25 (BPK-001) and 24 (COK-002) units, plus all three products with 22 units (BPK-003, JKT-003, TNT-003), because the seventh row’s value is shared. TOP (6) WITH TIES would return exactly 6 rows: the sixth value, 24, is not tied.

Reasoning. A TOP inside a CTE is allowed together with ORDER BY; the outer ORDER BY decides the displayed order. Always check whether ties actually exist at the boundary before relying on WITH TIES.

Show solution 2
DECLARE @PageNumber int = 2,
        @PageSize   int = 8;

SELECT  ProductCode,
        ProductName,
        ListPrice,
        ROW_NUMBER() OVER (ORDER BY ListPrice DESC, ProductCode) AS RowNo
FROM    Inventory.Product
ORDER BY ListPrice DESC, ProductCode
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;

Result: positions 9 to 16 as listed in the success criteria. ROW_NUMBER is computed at step 5 over all 30 rows, before OFFSET … FETCH (step 8) removes rows, so RowNo shows the global position. Page 4 starts at position 25 and returns the last 6 products. The two orderings must match; if ROW_NUMBER used a different order from the ORDER BY, the numbers would look scrambled.

Show solution 3
WITH CategoryTree AS
(
    SELECT CategoryID, CategoryName AS TopCategory
    FROM   Inventory.Category WHERE ParentCategoryID IS NULL
    UNION ALL
    SELECT c.CategoryID, t.TopCategory
    FROM   Inventory.Category AS c
    JOIN   CategoryTree       AS t ON c.ParentCategoryID = t.CategoryID
),
MonthlyCat AS
(
    SELECT  t.TopCategory,
            MONTH(o.OrderDate)                                   AS MonthNo,
            SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))  AS NetSales
    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
    JOIN    CategoryTree         AS t ON t.CategoryID = p.CategoryID
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
    GROUP BY t.TopCategory, MONTH(o.OrderDate)
),
Ranked AS
(
    SELECT  *,
            RANK() OVER (PARTITION BY TopCategory ORDER BY NetSales DESC) AS MonthRank,
            100.0 * NetSales / SUM(NetSales) OVER (PARTITION BY TopCategory) AS PctOfCategoryYear
    FROM    MonthlyCat
)
SELECT  TopCategory, MonthNo,
        CAST(NetSales AS decimal(12,2))          AS NetSales,
        MonthRank,
        CAST(PctOfCategoryYear AS decimal(5,1))  AS PctOfCategoryYear
FROM    Ranked
WHERE   MonthRank <= 2
ORDER BY TopCategory, MonthRank;
TopCategory MonthNo NetSales MonthRank PctOfCategoryYear
Accessories 12 2907.17 1 24.8
Accessories 6 1704.60 2 14.5
Camping 4 3826.05 1 13.9
Camping 12 3626.90 2 13.2
Clothing 5 3349.25 1 13.7
Clothing 12 3184.58 2 13.0

Reasoning. The percentage is computed before filtering, so it reflects the whole year (Day 5’s trap avoided). December is in every category’s top two — Christmas — while each category also has its own peak.

Show solution 4
WITH OrderTotals AS
(
    SELECT  o.CustomerID, o.OrderDate,
            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
    WHERE   o.OrderStatus <> 'Cancelled'
    GROUP BY o.CustomerID, o.OrderID, o.OrderDate
),
YearSplit AS
(
    SELECT  CustomerID,
            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
    FROM    OrderTotals
    GROUP BY CustomerID
)
SELECT  CustomerID,
        CAST(Net2024 AS decimal(12,2))                                   AS Net2024,
        CAST(Net2025 AS decimal(12,2))                                   AS Net2025,
        CAST(100.0 * (Net2025 - Net2024) / Net2024 AS decimal(6,1))      AS ChangePct,
        RANK() OVER (ORDER BY Net2024 - Net2025 DESC)                    AS DeclineRank
FROM    YearSplit
WHERE   Net2024 > 0
  AND   Net2025 < 0.5 * Net2024
ORDER BY DeclineRank, CustomerID;
CustomerID Net2024 Net2025 ChangePct DeclineRank
9 4239.64 665.95 -84.3 1
18 6053.05 2830.47 -53.2 2
14 2086.00 0.00 -100.0 3
3 1766.98 351.99 -80.1 4
31 1449.70 159.00 -89.0 5
25 1993.99 834.99 -58.1 6
21 1229.00 577.98 -53.0 7
11 795.70 157.98 -80.1 8
7 397.00 159.00 -59.9 9

Reasoning. Net2024 > 0 both enforces “bought in 2024” and protects the division. Customer 18 is a Champion in the scorecard yet more than halved their spend — a strong argument for adding a trend rule to the segments. The window function ranks over the filtered rows, which is what “rank among decliners” means.

Show the stretch solution
WITH CustomerActivity AS
(
    SELECT  CustomerID,
            MIN(OrderDate)                                            AS FirstOrder,
            MAX(CASE WHEN OrderDate >= '2025-07-01' THEN 1 ELSE 0 END) AS ActiveH2_2025
    FROM    Sales.SalesOrder
    WHERE   OrderStatus <> 'Cancelled'
    GROUP BY CustomerID
)
SELECT  CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder))      AS Cohort,
        COUNT(*)                                                           AS Customers,
        SUM(ActiveH2_2025)                                                 AS ActiveH2_2025,
        CAST(100.0 * SUM(ActiveH2_2025) / COUNT(*) AS decimal(5,1))        AS RetentionPct
FROM    CustomerActivity
GROUP BY CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder))
ORDER BY Cohort;
Cohort Customers ActiveH2_2025 RetentionPct
2024-Q1 19 16 84.2
2024-Q2 8 8 100.0
2024-Q3 5 5 100.0
2024-Q4 1 1 100.0
2025-Q1 1 1 100.0
2025-Q4 1 1 100.0

Reasoning. MAX(CASE … THEN 1 ELSE 0 END) turns “any order in H2 2025” into a 0/1 flag per customer. Sorting the text label works because it is zero-padded by design (2024-Q1 … 2025-Q4). Small cohorts make 100% figures meaningless — always show the denominator. A full cohort matrix (cohort × activity quarter) is a natural extension using conditional aggregation or PIVOT (Day 11).


H. Knowledge check (Week 1)

  1. Understanding. Put these clauses in logical processing order: ORDER BY, HAVING, SELECT, FROM, WHERE, GROUP BY, OFFSET … FETCH.

  2. Query interpretation. What does SELECT TOP (3) WITH TIES ProductCode FROM Inventory.Product ORDER BY ListPrice DESC; return, and how many rows?

  3. Debugging. A paging query uses ORDER BY LastName OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY. Users report a customer appearing on pages 1 and 2. Why?

  4. Debugging. Why does SELECT CustomerID, COUNT(*) AS Orders FROM Sales.SalesOrder WHERE Orders > 5 GROUP BY CustomerID; fail, and what are two correct alternatives?

  5. Mixed. For each of these, name the Week 1 technique you would reach for first: (a) each employee’s whole management chain; (b) month-on-month change; (c) the three most expensive products per category; (d) customers who never bought a jacket; (e) sales by year with a grand total row.

Show the answers
  1. FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → OFFSET … FETCH.

  2. The products tied at the top three prices: TNT-004 (599.00), then NAV-004 and TNT-002 (349.00) — the third row’s price is 349.00 and there are no further 349.00 products, so 3 rows. (If the third and fourth were tied, WITH TIES would add the fourth.)

  3. LastName is not unique; tied rows can be returned in a different order on each execution, so one may cross the page boundary. Add a unique tie-breaker such as CustomerID.

  4. Orders is a SELECT alias (step 5) and an aggregate cannot be used in WHERE (step 2) anyway. Use HAVING COUNT(*) > 5, or compute the count in a CTE and filter WHERE Orders > 5 in the outer query.

  5. (a) Recursive CTE (upwards). (b) LAG. (c) ROW_NUMBER/RANK partitioned by category, in a CTE. (d) NOT EXISTS. (e) GROUP BY ROLLUP.


I. Troubleshooting and cleanup

Error or symptom Cause Fix
The TOP N WITH TIES clause is not allowed without a corresponding ORDER BY clause. WITH TIES without ORDER BY Add ORDER BY
WITH TIES returns exactly n rows despite visible ties A unique tie-breaker in ORDER BY Remove it from the TOP query; sort in an outer query
Invalid usage of the option NEXT in the FETCH statement. / syntax errors near OFFSET OFFSET … FETCH without ORDER BY, or in the wrong position It must follow ORDER BY
Rows duplicated or missing across pages Non-unique ORDER BY Add a unique tie-breaker
TOP returns different rows each run No ORDER BY Always order TOP queries
Invalid column name for an alias in the same SELECT Aliases in one SELECT are created simultaneously Layer with a CTE or derived table
Scorecard totals don’t reconcile Grain change (joining lines after aggregating, or customers before) Validate each CTE; join descriptive tables last

Cleanup. Day 7 is read-only; nothing to clean up. RetailLab stays in its core state for Week 2.


J. Completion checklist, recap and references

Completion checklist

  • You can reproduce the 8-step logical processing table from memory.
  • Worked example 1 showed 4 rows without and 5 rows with WITH TIES.
  • Your scorecard has 35 rows, reconciles to 122354.88 and shows 8 / 2 / 1 / 24 segment counts.
  • Tasks 1–4 match the success criteria (4, 8, 6 and 9 rows).
  • You answered the Week 1 knowledge check without looking back.

Week 1 recap

Day Core idea
1 Filtering, CASE, three-valued logic and NULL functions
2 Aggregation, grain, HAVING, ROLLUP/CUBE/GROUPING SETS, conditional aggregation
3 Subqueries, EXISTS, the NOT IN NULL trap, derived tables, LEFT JOIN anti-joins
4 CTEs, recursion over hierarchies and date series
5 Ranking functions, top-N per group, de-duplication, shares of totals
6 LAG/LEAD, running totals, frames (ROWS vs RANGE), moving averages
7 Logical processing order, TOP/OFFSET … FETCH, choosing and combining techniques

Next: Day 8 — Advanced JOIN techniques: every join type, row multiplication, unmatched rows, NULLs in join conditions and where to put filters.

References (Microsoft Learn)