📘 SQL Basics & Joins
Query structure & clauses
The core clause order every query follows, plus common NULL-handling functions.
Finding duplicates
COUNT(*) vs COUNT(column) — a subtle but important distinction.
Left Join — Anti-join pattern
Find rows in A whose key does NOT exist in B.
Joins & Subqueries overview
Quick reference for the join family.
🧮 Window Functions
Window function basics
The building blocks: PARTITION BY, ORDER BY, and the functions that use them.
💰 Aggregation, Spend & Averages
Case 1: Average DAILY spend exceeds 3
Two-step aggregation: sum per day first, then average the daily sums.
Sample data| customer_id | txn_date | txn_amount |
|---|---|---|
| 1 | 2025-08-01 | 1.00 |
| 1 | 2025-08-01 | 2.00 |
| 1 | 2025-08-02 | 5.00 |
| 2 | 2025-08-01 | 4.00 |
| 2 | 2025-08-02 | 2.00 |
| customer_id | daily sums | average | included? |
|---|---|---|---|
| 1 | {3.00, 5.00} | 4.00 | ✅ included |
| 2 | {4.00, 2.00} | 3.00 | ❌ excluded (not > 3) |
Case 2: Average PER TRANSACTION spend
Compare this to Case 1 — no need to group by day first.
Average days between transactions
A classic three-step LAG pattern for measuring the gap between events.
Sample data| customer_id | txn_date | txn_amount |
|---|---|---|
| C1 | 2024-01-10 | 20 |
| C1 | 2024-01-01 | 10 |
| C1 | 2024-01-05 | 15 |
| C2 | 2024-01-04 | 7 |
| C2 | 2024-01-02 | 5 |
| customer_id | avg_days_transactions |
|---|---|
| C1 | 4.5 |
| C2 | 2.0 |
Top 10 spenders in last 30 days
A simple filter + aggregate + rank pattern.
🔥 Consecutive Days
Consecutive 3-day logins (exact gap-of-1 method)
The classic 'row number minus date' trick to detect consecutive sequences.
Sample data| user_id | login_date |
|---|---|
| A | 2025-01-01 |
| A | 2025-01-02 |
| A | 2025-01-03 |
| A | 2025-01-05 |
| user_id | login_date | rk | flag_date |
|---|---|---|---|
| A | 2025-01-01 | 1 | 2024-12-31 |
| A | 2025-01-02 | 2 | 2024-12-31 |
| A | 2025-01-03 | 3 | 2024-12-31 |
| A | 2025-01-05 | 4 | 2025-01-01 |
▶ Show execution process
Variant: allow a 1-day gap
A more flexible version — tolerates small gaps instead of requiring exact consecutive dates.
Sample data| user_id | login_date |
|---|---|
| A | 2025-01-01 |
| A | 2025-01-03 |
| A | 2025-01-05 |
| A | 2025-01-06 |
WITH login_diffs AS (
SELECT
user_id,
login_date,
DATEDIFF(
login_date,
LAG(login_date) OVER (
PARTITION BY user_id
ORDER BY login_date
)
) AS diff_days
FROM table1
),
login_flags AS (
SELECT
user_id,
login_date,
CASE
WHEN diff_days IS NULL OR diff_days > 2 THEN 1
ELSE 0
END AS gap_flag
FROM login_diffs
),
segments AS (
SELECT
user_id,
login_date,
SUM(gap_flag) OVER (
PARTITION BY user_id
ORDER BY login_date
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS segment_id
FROM login_flags
)
SELECT
user_id,
segment_id,
MIN(login_date) AS min_login_date,
MAX(login_date) AS max_login_date,
COUNT(*) AS login_days,
DATEDIFF(MAX(login_date), MIN(login_date)) + 1 AS calendar_days
FROM segments
GROUP BY
user_id,
segment_id
HAVING COUNT(*) >= 3
ORDER BY
user_id,
min_login_date;Result| user_id | segment_id | min_login_date | max_login_date | login_days | calendar_days |
|---|---|---|---|---|---|
| A | 1 | 2025-01-01 | 2025-01-06 | 4 | 6 |
▶ Show execution process
🧩 Merge Date Ranges — Merge Intervals
Problem — Merge overlapping date ranges
Given cmp_id, start_date, and end_date, merge overlapping intervals for each campaign and return one row per merged interval.
If two ranges do not overlap, keep them as separate output rows.
This follows the same overlap rule as LeetCode 56: January 1–5 and January 6–8 do not overlap, so they remain separate.
Assume valid, non-null campaign IDs and dates, with start_date no later than end_date; validate or quarantine invalid rows first.
Memory: Sort → Previous MAX → Mark non-overlap → Running SUM → Merge.
This gaps-and-islands problem extends the preceding Consecutive Days chapter from individual dates to date ranges.
A1 · January 2026
A2 · February 2026
A1 outputs two merged intervals: January 1–10 and January 15–17.
A2 outputs one merged interval: February 1–10.
| cmp_id | start_date | end_date |
|---|---|---|
| A1 | 2026-01-01 | 2026-01-05 |
| A1 | 2026-01-04 | 2026-01-10 |
| A1 | 2026-01-15 | 2026-01-17 |
| A2 | 2026-02-01 | 2026-02-05 |
| A2 | 2026-02-04 | 2026-02-08 |
| A2 | 2026-02-07 | 2026-02-10 |
Sample order is only for readability. In the real case, table storage order is not guaranteed; the solution defines ORDER BY explicitly inside each window.
-- Sample is sorted only for readability.
-- Real table row order is not guaranteed.
CREATEORREPLACETEMPVIEWcampaignAS
SELECT
cmp_id,
start_date,
end_date
FROMVALUES
('A1',DATE'2026-01-01',DATE'2026-01-05'),
('A1',DATE'2026-01-04',DATE'2026-01-10'),
('A1',DATE'2026-01-15',DATE'2026-01-17'),
('A2',DATE'2026-02-01',DATE'2026-02-05'),
('A2',DATE'2026-02-04',DATE'2026-02-08'),
('A2',DATE'2026-02-07',DATE'2026-02-10')
ASdemo(cmp_id,start_date,end_date);Expected final result| cmp_id | start_date | end_date | interval_days |
|---|---|---|---|
| A1 | 2026-01-01 | 2026-01-10 | 10 |
| A1 | 2026-01-15 | 2026-01-17 | 3 |
| A2 | 2026-02-01 | 2026-02-10 | 10 |
A1 must output two rows. January 15–17 does not overlap January 1–10, so it stays as a separate merged interval.
Run Steps 1–6 in order in the same Spark session, or run the complete CTE query at the end.
How to Think About It — SQL vs Python
SQL describes the grouping rules and lets the database derive the groups. Python keeps the current merged state and scans the intervals one by one.
| Memory Point | SQL | Python |
|---|---|---|
| 1. Group |
PARTITION BY cmp_idGroup rows by campaign. |
defaultdict(list)Group intervals by cmp_id. |
| 2. Sort |
ORDER BY start_dateSort inside each partition. |
intervals.sort()Sort each campaign's intervals. |
| 3. Previous coverage |
previous_max_endFurthest end already covered by earlier rows. |
merged[-1][1]End of the current merged interval. |
| 4. New group? |
CASEIf start_date > previous_max_end → new group.
|
if start > merged[-1][1]→ append a new interval. |
| 5. Keep state |
SUM(flag) → group_idSQL stores state as derived columns. |
merged[-1]Python keeps mutable state directly. |
| 6. Merge |
MIN(start) + MAX(end)Merge after group_id is created. |
Update merged[-1][1]Merge while scanning. |
SQL: create group_id first, then merge.
Python: merge while scanning.
SQL previous_max_end ≈ Python merged[-1][1]
Both mean: the furthest end covered so far.
Two interview versions — SQL + Python / LeetCode-style
The core idea is the same: sort intervals, decide whether the current interval connects to existing coverage, then either merge it or start a new group.
Data Warehouse / Spark SQL
Use the running maximum end date because SQL must handle nested ranges, not only the immediately previous row.
WITHbaseAS(
SELECTDISTINCT
cmp_id,
start_date,
end_date
FROMcampaign
),
orderedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 1: previous_max_end = furthest end_date from EARLIER rows.
MAX(end_date)OVER(
PARTITIONBYcmp_id
ORDERBYstart_date,end_date
ROWSBETWEENUNBOUNDEDPRECEDINGAND1PRECEDING
)ASprevious_max_end
FROMbase
),
markedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 2: no overlap -> start a new group.
CASE
WHENprevious_max_endISNULL
ORstart_date>previous_max_end
THEN1
ELSE0
ENDASis_new_group
FROMordered
),
groupedAS(
SELECT
cmp_id,
start_date,
end_date,
-- Step 3: cumulative flags create group_id.
-- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW can be omitted here.
SUM(is_new_group)OVER(
PARTITIONBYcmp_id
ORDERBYstart_date,end_date
)ASgroup_id
FROMmarked
)
SELECT
cmp_id,
MIN(start_date)ASmerged_start,
MAX(end_date)ASmerged_end
FROMgrouped
GROUPBY
cmp_id,
group_id
ORDERBY
cmp_id,
merged_start;
A1 | 2026-01-01 | 2026-01-10
A1 | 2026-01-15 | 2026-01-17
A2 | 2026-02-01 | 2026-02-10
This SQL version matches LeetCode 56 semantics: merge overlapping ranges only. A non-overlapping range starts a new output row.
Grouped Merge Intervals — with cmp_id
LeetCode 56 core + cmp_id grouping.
First group by cmp_id, then run the standard merge logic inside each group.
records = [
["A1", 1, 5],
["A1", 4, 10],
["A1", 15, 17],
["A2", 1, 5],
["A2", 4, 8],
["A2", 7, 10],
]
Output
[
["A1", 1, 10],
["A1", 15, 17],
["A2", 1, 10],
]
fromcollectionsimport defaultdict
fromtypingimport List
classSolution:
defmergeByCampaign(self, records: List[List]) -> List[List]:
groups = defaultdict(list)
# Step 1: group intervals by cmp_id.
for cmp_id, start, end in records:
groups[cmp_id].append([start, end])
# groups now looks like:
# {
# "A1": [[1, 5], [4, 10], [15, 17]],
# "A2": [[1, 5], [4, 8], [7, 10]]
# }
result = []
# Step 2: merge intervals inside each cmp_id.
for cmp_id, intervals in groups.items():
intervals.sort(key=lambda x: x[0])
merged = []
for start, end in intervals:
if not merged or start > merged[-1][1]:
merged.append([start, end])
else:
merged[-1][1] = max(merged[-1][1], end)
# Step 3: add cmp_id back.
for start, end in merged:
result.append([cmp_id, start, end])
result.sort(key=lambda x: (x[0], x[1], x[2]))
return result
📊 Sales Ranking & Top-N
Daily city sales ranking
Three window functions in one query, each with a different PARTITION/ORDER combination.
Sample data| order_id | city | order_date | sales |
|---|---|---|---|
| 1 | Beijing | 2025-01-01 | 100 |
| 2 | Shanghai | 2025-01-01 | 150 |
| 3 | Guangzhou | 2025-01-01 | 120 |
| 4 | Beijing | 2025-01-02 | 300 |
| 5 | Shanghai | 2025-01-02 | 250 |
| 6 | Guangzhou | 2025-01-02 | 180 |
| 7 | Beijing | 2025-01-02 | 50 |
| order_date | city | daily_sales | rank | total | cumulative |
|---|---|---|---|---|---|
| 2025-01-01 | Shanghai | 150 | 1 | 370 | 150 |
| 2025-01-01 | Guangzhou | 120 | 2 | 370 | 270 |
| 2025-01-01 | Beijing | 100 | 3 | 370 | 370 |
| 2025-01-02 | Beijing | 350 | 1 | 780 | 350 |
| 2025-01-02 | Shanghai | 250 | 2 | 780 | 600 |
| 2025-01-02 | Guangzhou | 180 | 3 | 780 | 780 |
▶ Show execution process
Top 3 operators per city per day
Same ranking pattern as before, but partitioned by two columns instead of one.
Sample data| order_id | city | order_date | operator_id | sales |
|---|---|---|---|---|
| 1 | Beijing | 2025-01-01 | 101 | 200 |
| 2 | Beijing | 2025-01-01 | 101 | 300 |
| 3 | Beijing | 2025-01-01 | 102 | 300 |
| 4 | Beijing | 2025-01-01 | 103 | 250 |
| 5 | Beijing | 2025-01-01 | 104 | 100 |
| 6 | Shanghai | 2025-01-01 | 201 | 400 |
| 7 | Shanghai | 2025-01-01 | 202 | 300 |
| 8 | Shanghai | 2025-01-01 | 203 | 200 |
| 9 | Shanghai | 2025-01-01 | 204 | 100 |
| city | order_date | operator_id | total_sales | rn |
|---|---|---|---|---|
| Beijing | 2025-01-01 | 101 | 500 | 1 |
| Beijing | 2025-01-01 | 102 | 300 | 2 |
| Beijing | 2025-01-01 | 103 | 250 | 3 |
| Shanghai | 2025-01-01 | 201 | 400 | 1 |
| Shanghai | 2025-01-01 | 202 | 300 | 2 |
| Shanghai | 2025-01-01 | 203 | 200 | 3 |
▶ Show execution process
🔄 Retention
(标注不用看)
Rolling vs Cohort retention — the concept
The key conceptual difference before writing any SQL.
A) Rolling 30-day retention
Gap-based approach — measures the gap between EACH pair of consecutive transactions.
Sample data| seller_id | transaction_date | note |
|---|---|---|
| 201 | 2024-01-02 | |
| 201 | 2024-01-20 | ← return within 30 days ✅ |
| 201 | 2024-02-02 | ← return within 30 days ✅ |
| 202 | 2024-01-02 | |
| 202 | 2024-01-25 | ← return within 30 days ✅ |
| 203 | 2024-01-02 | ← no return ❌ |
B) Cohort 30-day retention
Four-step process: anchor date, candidate returns, per-seller flag, cohort-level rate.
Sample data| seller_id | transaction_date | note |
|---|---|---|
| 201 | 2024-01-02 | |
| 201 | 2024-01-20 | ← within 30 days ✅ |
| 201 | 2024-02-02 | ← 31 days, NOT within 30 ❌ |
| 202 | 2024-01-02 | |
| 202 | 2024-01-25 | ← within 30 days ✅ |
| 203 | 2024-01-02 | ← no return ❌ |
| cohort_date | cohort_size | retained_30d | retention_rate_30d |
|---|---|---|---|
| 2024-01-02 | 3 | 2 | 0.67 |
| 2024-03-01 | 1 | 1 | 1.00 |
🗓️ Effective Dates, Rolling Metrics & OKX Mock
Five related exercises. Spark SQL solutions; illustrative samples adapted for practice. Clarify date boundaries, output grain and the averaging denominator before coding.
| Question | Main skill | Connection to OKX |
|---|---|---|
| 1251 | ID + inclusive date-range join | Match facts to effective versions |
| 1164 | Latest version at a cutoff | Choose the correct historical value |
| 1321 | Daily grain + seven-day window | Historical totals and daily averages |
| DataLemur | Per-user three-row window | PARTITION BY + frame boundaries |
| OKX mock | Temporal join + conditional aggregation | Combine the skills; handle unmatched facts |
1. Average Selling Price · LeetCode 1251
Find each product's weighted average selling price. Join sales to the price effective on the purchase date. Round to two decimals; products without sales return zero.
Public problem reference · Sample data adapted for this handbook.
Rules & assumptionsPrice intervals for a product do not overlap. Both start_date and end_date are inclusive.
UnitsSold may contain duplicate rows; the problem treats them as sales records, so do not remove them without a business rule.
Input · Prices| product_id | start_date | end_date | price |
|---|---|---|---|
| 1 | 2026-10-01 | 2026-10-10 | 10 |
| 1 | 2026-10-11 | 2026-10-31 | 20 |
| 2 | 2026-10-01 | 2026-10-31 | 50 |
| product_id | purchase_date | units |
|---|---|---|
| 1 | 2026-10-05 | 2 |
| 1 | 2026-10-15 | 3 |
| product_id | average_price |
|---|---|
| 1 | 16.00 |
| 2 | 0.00 |
Product 1: October 5 matches price 10; October 15 matches price 20.
10 × 2 + 20 × 3 = 80; total units = 5.
80 / 5 = 16.00. Product 2 has no sale, so return 0.00.
| Case | How to handle it |
|---|---|
| No sales | Keep the product using LEFT JOIN; COALESCE returns 0. |
| Wrong average | AVG(price) ignores the number of units sold. |
| Boundary | This problem includes end_date; an SCD expiry may instead be exclusive. |
ID + effective interval → weighted revenue / units → default zero.
SQL · Spark SQL-- Match both product ID and the inclusive price interval.
SELECT
p.product_id,
COALESCE(
ROUND(
SUM(p.price * s.units) / NULLIF(SUM(s.units), 0),
2
),
0
) AS average_price
FROM Prices p
LEFT JOIN UnitsSold s
ON p.product_id = s.product_id
AND s.purchase_date >= p.start_date
AND s.purchase_date <= p.end_date
GROUP BY
p.product_id
ORDER BY
p.product_id;2. Product Price at a Given Date · LeetCode 1164
Find each product's price on 2019-08-16. Each price change stays effective until the next change. Initial price is 10.
Public problem reference · Sample data adapted for this handbook.
Rules & assumptionsChanges on the target date are already effective. Ignore changes after the target date.
The pair (product_id, change_date) is unique. Include products whose only changes occur in the future.
Input · Products| product_id | new_price | change_date |
|---|---|---|
| 1 | 20 | 2019-08-14 |
| 1 | 30 | 2019-08-16 |
| 1 | 40 | 2019-08-18 |
| 2 | 50 | 2019-08-15 |
| 3 | 80 | 2019-08-20 |
| product_id | price |
|---|---|
| 1 | 30 |
| 2 | 50 |
| 3 | 10 |
Keep change_date <= August 16, then take MAX(change_date) per product.
Product 1: August 16 → 30. Product 2: August 15 → 50.
Product 3 has no effective change yet, so use initial price 10.
| Case | How to handle it |
|---|---|
| Future-only product | It must remain in the output with price 10. |
| Price decrease | MAX(new_price) is not the latest price. |
| Same date | The original key prevents two versions on the same product/date. |
All IDs → latest date before cutoff → look up value → default 10.
SQL · Latest effective date, then retrieve priceWITH all_products AS (
SELECT DISTINCT
product_id
FROM Products
),
latest_date AS (
SELECT
product_id,
MAX(change_date) AS change_date
FROM Products
WHERE change_date <= '2019-08-16'
GROUP BY
product_id
)
SELECT
a.product_id,
COALESCE(p.new_price, 10) AS price
FROM all_products a
LEFT JOIN latest_date l
ON a.product_id = l.product_id
LEFT JOIN Products p
ON l.product_id = p.product_id
AND l.change_date = p.change_date
ORDER BY
a.product_id;3. Restaurant Growth · LeetCode 1321
For each complete seven-day window, return the total restaurant revenue and average daily revenue. Include the current date and previous six dates.
Public problem reference · Sample data adapted for this handbook.
Rules & assumptionsAt least one customer visits every calendar day. Multiple customers may visit on the same date.
Output begins on the seventh date. The denominator is seven calendar days, not the number of customer transactions.
Input · Customer| customer_id | name | visited_on | amount |
|---|---|---|---|
| 1 | Alice | 2026-10-01 | 100 |
| 2 | Bob | 2026-10-02 | 200 |
| 3 | Carol | 2026-10-03 | 300 |
| 4 | David | 2026-10-04 | 400 |
| 5 | Emma | 2026-10-05 | 500 |
| 6 | Frank | 2026-10-06 | 600 |
| 7 | Grace | 2026-10-07 | 700 |
| 8 | Henry | 2026-10-08 | 800 |
| visited_on | amount | average_amount |
|---|---|---|
| 2026-10-07 | 2800 | 400.00 |
| 2026-10-08 | 3500 | 500.00 |
GROUP BY visited_on before using either solution.
October 1–7: 100 + 200 + … + 700 = 2800; average = 400.
October 2–8: 2800 − 100 + 800 = 3500; average = 500.
| Case | How to handle it |
|---|---|
| Many customers per day | Aggregate first; seven raw rows do not mean seven days. |
| Missing dates in a variant | Create a calendar and fill zeros if required. ROWS counts rows. |
| Early dates | Do not emit incomplete seven-day windows in this problem. |
Daily totals → current date + six preceding dates → total / 7.
Solution A · Date-range self join, no window functionWITH daily AS (
-- Multiple customers on one date become one daily total.
SELECT
visited_on,
SUM(amount) AS daily_amount
FROM Customer
GROUP BY
visited_on
)
SELECT
d.visited_on,
SUM(h.daily_amount) AS amount,
ROUND(SUM(h.daily_amount) / 7.0, 2) AS average_amount
FROM daily d
JOIN daily h
ON h.visited_on >= DATE_SUB(d.visited_on, INTERVAL 6 DAY)
AND h.visited_on <= d.visited_on
GROUP BY
d.visited_on
HAVING COUNT(*) = 7
ORDER BY
d.visited_on;Solution B · Window function over daily totalsWITH daily AS (
SELECT
visited_on,
SUM(amount) AS daily_amount
FROM Customer
GROUP BY
visited_on
),
rolling AS (
SELECT
visited_on,
SUM(daily_amount) OVER (
ORDER BY visited_on
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS amount,
COUNT(*) OVER (
ORDER BY visited_on
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS days_in_window
FROM daily
)
SELECT
visited_on,
amount,
ROUND(amount / 7.0, 2) AS average_amount
FROM rolling
WHERE days_in_window = 7
ORDER BY
visited_on;4. Tweets’ Rolling Averages · DataLemur
Calculate a three-day rolling average of tweet counts for each user, rounded to two decimals.
Public problem reference · Sample data adapted for this handbook.
Rules & assumptionsThis practice sample has one row per user/date and consecutive dates for each user.
Include the current day and preceding two days. At the beginning, average only the available rows.
Input · tweets| user_id | tweet_date | tweet_count |
|---|---|---|
| 111 | 2026-10-01 | 2 |
| 111 | 2026-10-02 | 1 |
| 111 | 2026-10-03 | 3 |
| 111 | 2026-10-04 | 4 |
| 222 | 2026-10-01 | 6 |
| 222 | 2026-10-02 | 2 |
| user_id | tweet_date | rolling_avg_3d |
|---|---|---|
| 111 | 2026-10-01 | 2.00 |
| 111 | 2026-10-02 | 1.50 |
| 111 | 2026-10-03 | 2.00 |
| 111 | 2026-10-04 | 2.67 |
| 222 | 2026-10-01 | 6.00 |
| 222 | 2026-10-02 | 4.00 |
User 111 and user 222 are calculated independently.
For user 111, values are 2, 1, 3, 4 in date order.
October 4 uses [1, 3, 4]: 8 / 3 = 2.67; October 1 has left the window.
| Case | How to handle it |
|---|---|
| No PARTITION BY | Rows from different users can mix. |
| Missing calendar days | ROWS counts available rows. Clarify whether to fill missing days with zero. |
| Initial rows | This task allows partial windows, unlike Restaurant Growth. |
PARTITION BY user → ORDER BY date → current row + two previous rows.
SQL · Per-user moving averageSELECT
user_id,
tweet_date,
ROUND(
AVG(tweet_count) OVER (
PARTITION BY user_id
ORDER BY tweet_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS rolling_avg_3d
FROM tweets
ORDER BY
user_id,
tweet_date;5. OKX Mock · Daily Amount, Effective Risk & Previous 3-Day Average
Given transaction details and a historical user-risk dimension, return each user’s daily amount, the risk effective on that date, and the average daily amount over the previous three calendar days.
Focused reconstruction based on candidate recollection, not a verified original OKX question. SQL dialect: Spark SQL.
RulesA user’s risk validity intervals do not overlap. Each user/date matches at most one dimension row, so the temporal JOIN does not duplicate daily metrics.
For output date D, history is [D−3 days, D). Include all user transactions regardless of historical risk. Missing dates contribute zero; divide by 3. Assume complete history and non-NULL amounts in one currency.
Match effective_date <= D < expiry_date. NULL expiry means open-ended. Keep unmatched daily records as Unknown. Output only dates with transactions.
Input · dim_user_risk| version_id | user_id | risk_level | effective_date | expiry_date |
|---|---|---|---|---|
| 1 | U1 | Low | 2026-10-01 | 2026-10-18 |
| 2 | U1 | High | 2026-10-18 | NULL |
| 3 | U2 | Medium | 2026-10-01 | NULL |
| 4 | U3 | Low | 2026-10-01 | 2026-10-19 |
version_id identifies a dimension version. With non-overlapping validity intervals, this SQL is identical with or without the version_id column; date conditions already select the unique valid version.
Input · transactions| transaction_id | user_id | transaction_date | amount |
|---|---|---|---|
| T01 | U1 | 2026-10-17 | 100 |
| T02 | U1 | 2026-10-17 | 300 |
| T03 | U1 | 2026-10-18 | 200 |
| T04 | U1 | 2026-10-20 | 500 |
| T05 | U1 | 2026-10-20 | 100 |
| T06 | U2 | 2026-10-17 | 60 |
| T07 | U2 | 2026-10-19 | 120 |
| T08 | U2 | 2026-10-20 | 90 |
| T09 | U3 | 2026-10-20 | 70 |
| user_id | transaction_date | risk_level | daily_amount | previous_3_days_avg_amount |
|---|---|---|---|---|
| U1 | 2026-10-17 | Low | 400 | 0.0 |
| U1 | 2026-10-18 | High | 200 | 133.33 |
| U1 | 2026-10-20 | High | 600 | 200.0 |
| U2 | 2026-10-17 | Medium | 60 | 0.0 |
| U2 | 2026-10-19 | Medium | 120 | 20.0 |
| U2 | 2026-10-20 | Medium | 90 | 60.0 |
| U3 | 2026-10-20 | Unknown | 70 | 0.0 |
| User/date | Low: [Oct 1, Oct 18) | High: [Oct 18, open) | Result |
|---|---|---|---|
| U1 / October 17 | Matches | Does not match | One row: Low |
| U1 / October 18 | Does not match: expiry excluded | Matches: effective date included | One row: High |
| U1 / October 20 | Does not match | Matches | One row: High |
The old version ends exactly where the new version begins, but the old expiry boundary is exclusive. Therefore October 18 does not match both versions. No ROW_NUMBER, ranked CTE or rn = 1 filter is needed.
Approach + TraceU1: October 17 = 400, October 18 = 200, October 20 = 600.
For U1 on October 20: (400 + 200 + 0) / 3 = 200.00. The current daily amount is 600.
October 17 → Low; October 18 and 20 → High. U3 on October 20 → Unknown because its risk record has expired.
WITH daily AS (
SELECT
user_id,
transaction_date,
SUM(amount) AS daily_amount
FROM transactions
GROUP BY
user_id,
transaction_date
),
metrics AS (
SELECT
d.user_id,
d.transaction_date,
d.daily_amount,
ROUND(
COALESCE(SUM(h.daily_amount), 0) / 3.0,
2
) AS previous_3_days_avg_amount
FROM daily d
LEFT JOIN daily h
ON d.user_id = h.user_id
AND h.transaction_date >= DATE_SUB(
d.transaction_date,
3
)
AND h.transaction_date < d.transaction_date
GROUP BY
d.user_id,
d.transaction_date,
d.daily_amount
)
SELECT
m.user_id,
m.transaction_date,
COALESCE(r.risk_level, 'Unknown') AS risk_level,
m.daily_amount,
m.previous_3_days_avg_amount
FROM metrics m
LEFT JOIN dim_user_risk r
ON m.user_id = r.user_id
AND m.transaction_date >= r.effective_date
AND (
m.transaction_date < r.expiry_date
OR r.expiry_date IS NULL
)
ORDER BY
m.user_id,
m.transaction_date;Recall: daily totals → self join previous three dates → direct temporal LEFT JOIN. The historical self join intentionally finds multiple dates and aggregates them; the non-overlapping risk JOIN attaches at most one risk row.