sql_data_engineer_cheatsheet.
html
html
ultimate_sql_cheatsheet.html
html
suppose there is a project, which need all the sql code from these two
cheatsheeet, so thinking of solving a problem like that, make one big answer
to this imaginary problem, like one big single sql code consisting of all these
codes, which obviously is a answer meaning it will work 100% if there was a
similar problem with similar imaginary data
11:02 AM
Ran 3 commands, viewed a file
Ran 3 commands, viewed a file
Now I have all the SQL blocks from both cheatsheets. Let me compose the
grand unified SQL script:
Created a file, read a file
Created a file, read a file
Here's your Grand Unified SQL Project — a single coherent .sql script built
around an imaginary company called ShopBD, a Bangladesh-based e-
commerce platform. Every code block from both cheatsheets is woven into it
as a working answer to real data engineering problems.
Here's what the script covers across its 15 sections:
Schema & Setup (§0) — Raw, Staging, and Warehouse layers with proper
DDL, partitioned event tables, JSONB columns, composite/covering/partial
indexes, and observability tables.
ETL Pipelines (§1) — Bulk CSV load (COPY / LOAD DATA INFILE), full wipe-
and-reload for dimensions, watermark-based incremental load, deduplication
with ROW_NUMBER(), and idempotent event insertion with ON CONFLICT DO
NOTHING.
Upserts & Merge (§2) — SCD Type 1 overwrite, SCD Type 2 expire-and-
insert, ANSI MERGE, PostgreSQL ON CONFLICT DO UPDATE, and MySQL ON
DUPLICATE KEY UPDATE.
Data Quality (§3) — Null/duplicate/range checks in one CTE, column
profiling with percentiles, MD5 row-hash change detection, change-type
labelling (new/updated/deleted), and Z-score anomaly detection.
Dimension & Fact Load (§4–5) — Surrogate key generation
via ROW_NUMBER(), full date dimension for 2020–2030, and fact table joins
into the star schema.
Materialized View (§6) — Pre-aggregated daily revenue with REFRESH
CONCURRENTLY.
Core Queries (§7) — Filtering, string/date functions, all join types,
aggregation, HAVING, ROLLUP, CASE-pivots, and all four set operations.
Window Functions (§8) — DENSE_RANK, LAG/LEAD, running
totals, FIRST_VALUE, 7-week moving average, percentile/median, and
percent-of-total with cumulative percent.
CTEs & Subqueries (§9) — Scalar, WHERE-clause, EXISTS, chained CTEs,
and a recursive referral-tree CTE.
Advanced Analytics (§11) — Point-in-time SCD2 query, gap-filled time
series, LATERAL top-N, session analysis, cohort retention, YoY with LAG, pivot
and unpivot, and sequence/funnel arrays.
Dialect Notes (§12–13) — BigQuery partition pruning
and INFORMATION_SCHEMA, Snowflake Time Travel and Streams/Tasks, SQL
Server TOP/OFFSET/TRY_CAST/temp tables, MySQL GROUP_CONCAT — all
preserved as commented blocks so the file is runnable in PostgreSQL.
Grand unified sql project
Code · SQL
explain each and every word and line to me, why how when what, teach me,
so that i can write this whole code by myself when needed
11:36 AM
That's a great learning goal! This script is ~600 lines with 15 sections, so a
truly line-by-line explanation would be enormous — let me make sure I teach
you in the most useful way for you.
Let me just ask you directly — please reply with:
1. How you want to go through it — section by section (you say
"next" each time), all at once in one big response, or starting from your
weakest areas?
2. Your SQL level — beginner (SELECT/WHERE basics), intermediate
(joins, GROUP BY, some subqueries), or advanced (window functions,
CTEs, ETL patterns)?
3. Which sections matter most to you right now — you can pick
multiple: Schema/DDL, ETL patterns, Window functions & analytics, or
CTEs/subqueries/set ops?
This way I'll pitch the explanation at exactly the right depth and start from
what matters most to you.
Q: How do you want to go through it? A: All at once (one giant explanation)
Q: What's your current SQL comfort level? A: know nothing
Q: Which sections feel most important to you right now? (Select all that
apply) A: all
11:36 AM
Perfect. You know nothing, you want everything, all at once. Let's build your
SQL brain from zero.
I'm going to explain every single line of the script like you've never written a
line of code before. No shortcuts. By the end, you'll understand not
just what each line does, but why it exists, when you'd write it, and how to
think about it.
This will be long. Read it like a textbook — slowly, in order.
THE COMPLETE EXPLANATION: GRAND UNIFIED SQL PROJECT
BEFORE WE START: What is SQL and what is this script doing?
SQL stands for Structured Query Language. It is the language you use to talk
to a database. A database is just an organized collection of data stored in
tables — like Excel sheets, but far more powerful.
What is this script building? Imagine ShopBD is a real e-commerce
company like Daraz. Every day, thousands of orders come in, customers sign
up, products are listed, and user clicks are recorded. All that raw data needs
to be:
1. Stored safely (raw layer)
2. Cleaned (staging layer)
3. Organized for analysis (warehouse layer)
4. Analyzed to answer business questions
This script does all four things. It is a complete data pipeline from raw data
to business insight.
THE COMMENT SYNTAX — --
sql
-- This is a comment
Any line starting with -- is a comment. SQL completely ignores it. Comments
are notes written for humans, not for the database. You use them to explain
what you're doing. You will see them everywhere in this script. They are not
code — they are explanations.
/* ... */ is a block comment — everything between /* and */ is ignored, even
across multiple lines. Used for longer notes or to "turn off" a block of code
temporarily.
SECTION 0: DATABASE SCHEMA SETUP (DDL)
DDL stands for Data Definition Language. This is the part of SQL where
you define the structure — you tell the database what tables exist, what
columns they have, and what type of data goes in each column. You're not
inserting data yet. You're just building the containers.
Think of it like this: before you can store clothes, you build a wardrobe. DDL
builds the wardrobe.
0-A: RAW LAYER — The first landing zone for data
sql
CREATE TABLE raw.orders_source (
CREATE TABLE — These two words tell SQL: "I want to create a new table."
This is the command to build one of those containers.
raw.orders_source — This is the name of the table. The dot (.) separates
the schema from the table name. A schema is like a folder inside the
database. raw is the folder. orders_source is the table inside that folder.
Using schemas keeps things organized — raw, staging, and dim_/fact_ tables
are all kept separate.
The opening ( says: "here come the column definitions."
sql
order_id TEXT,
order_id — This is the column name. Every row in this table will have
an order_id field.
TEXT — This is the data type. It tells the database what kind of data this
column can hold. TEXT means any string of characters — letters, numbers,
symbols. We use TEXT in the raw layer because raw data from external
sources is messy and we don't yet trust it to be a clean number. We store
everything as text first and clean it later.
The comma at the end separates this column definition from the next one.
sql
customer_id TEXT,
product_id TEXT,
region TEXT,
status TEXT,
amount TEXT,
qty TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT,
Same pattern repeated for every column this table needs. Each column has a
name and a data type. All TEXT because this is raw, untrusted data.
order_id — which order
customer_id — which customer placed it
product_id — which product was ordered
region — where (Dhaka, Chittagong, etc.)
status — delivered, returned, pending, etc.
amount — how much money
qty — how many units
created_at — when was the order placed
updated_at — when was it last changed
deleted_at — when was it deleted (if ever)
sql
_loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
_loaded_at — The underscore at the start is a naming
convention meaning "this column was added by our pipeline, not the source
system." It's a metadata column.
TIMESTAMP — A data type that stores both a date AND a time. Like 2024-
03-28 14:35:22.
DEFAULT CURRENT_TIMESTAMP — This says: if you insert a row without
specifying _loaded_at, automatically fill it with the current date and
time. CURRENT_TIMESTAMP is a built-in function that returns right now.
) — closes the column list.
; — The semicolon ends the SQL statement. Every SQL statement ends with
a semicolon. Think of it like a full stop at the end of a sentence.
The same pattern is then repeated
for raw.customers_source and raw.products_source. Same idea, different
columns for customers and products.
The Events Table — with PARTITIONING
sql
CREATE TABLE [Link] (
id BIGSERIAL,
event_type VARCHAR(50),
created_at TIMESTAMP,
payload JSONB
) PARTITION BY RANGE (created_at);
BIGSERIAL — A special data type that automatically generates a unique
incrementing number (1, 2, 3, 4...) every time you insert a row. You never
have to manually set it. BIG means it can handle very large numbers (up to 9
quintillion). Good for tables that will have millions of rows.
VARCHAR(50) — Variable-length character string, maximum 50 characters.
Unlike TEXT (unlimited length), VARCHAR(n) enforces a limit. Used when you
know the maximum size, like event types (click, purchase, login — never
more than 50 chars).
JSONB — A special data type for storing JSON data (key-value pairs, nested
objects). The B means it's stored in a binary format for faster querying.
Events often carry flexible data — a click has different fields than a purchase
— so JSON is perfect.
PARTITION BY RANGE (created_at) — This is a critical performance
concept. If you store millions of events in one table, querying it becomes
slow because the database has to scan everything. Partitioning splits the
table into smaller physical pieces (partitions) based on a column
value. RANGE means we split by a range of values. created_at means we
split by date. The database will only scan the relevant partition when you
query.
sql
CREATE TABLE raw.events_2024_q1 PARTITION OF [Link]
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
PARTITION OF [Link] — This creates one actual physical partition that
belongs to the parent [Link] table.
FOR VALUES FROM ... TO ... — Defines the range. This partition holds
events from Jan 1 to Mar 31, 2024 (the TO value is exclusive, meaning Apr 1
itself goes in the next partition).
When you query WHERE created_at = '2024-02-15', PostgreSQL is smart
enough to only look at events_2024_q1 and skip all the other quarters
entirely. This is called partition pruning.
The same pattern creates Q2, Q3, Q4 partitions.
Index on JSONB
sql
CREATE INDEX ON [Link] ((payload->>'user_id'));
CREATE INDEX — An index is like the index at the back of a book. Without
it, the database reads every single row to find what you want (slow). With an
index on a column, it can jump directly to the matching rows (fast).
ON [Link] — The index is on this table.
(payload->>'user_id') — This indexes a specific key inside the JSONB
column. The ->>'user_id' syntax extracts the value of the key user_id from
the JSON as text. We're indexing that extracted value so searches
like WHERE payload->>'user_id' = '42' are fast.
0-B: STAGING LAYER
This layer holds cleaned data. The raw TEXT values are converted to proper
types.
sql
CREATE TABLE [Link] (
order_id BIGINT,
customer_id BIGINT,
BIGINT — A large whole number (integer). Now that we've cleaned the data
and trust it to be a real number, we use BIGINT instead of TEXT.
sql
amount DECIMAL(12,2),
DECIMAL(12,2) — A number with decimal places. 12 is the total number of
digits allowed. 2 is how many of those are after the decimal point. So it can
store up to 9999999999.99. Perfect for money.
sql
row_hash VARCHAR(64) -- MD5 for change detection
This column will store a fingerprint of the row's content — we'll explain how
it's used in Section 3.
0-C: DATA WAREHOUSE LAYER (Star Schema)
What is a Star Schema? It's the standard way to organize data for
analysis. It has two types of tables:
Fact tables — store measurable events (orders, transactions). They
have numbers and foreign keys.
Dimension tables — store descriptive context (who, what, where,
when). They describe the facts.
If you draw it on paper, the fact table is in the middle and dimensions radiate
out like a star.
sql
CREATE TABLE dim_date (
date_key INT PRIMARY KEY,
INT — A regular integer (whole number, up to ~2 billion). Smaller
than BIGINT, fine here.
PRIMARY KEY — This means two things: (1) every value in this column must
be unique, and (2) it cannot be NULL (empty). A primary key uniquely
identifies each row. The date dimension uses a date
like 20240328 (YYYYMMDD format) as its key — a smart convention that
makes date keys human-readable integers.
sql
CREATE TABLE dim_customers (
customer_key BIGINT PRIMARY KEY, -- surrogate key
customer_id BIGINT, -- natural key
Two different ID columns — this is important:
customer_key — a number we generate ourselves (surrogate key).
We control it. It never changes even if the source system changes its
IDs.
customer_id — the ID from the source system (natural key). We keep
it for reference.
Why two keys? Because source system IDs can change, be reused, or
conflict when you merge multiple sources. Our surrogate key is stable.
sql
is_current BOOLEAN DEFAULT TRUE,
valid_from DATE DEFAULT CURRENT_DATE,
valid_to DATE DEFAULT '9999-12-31',
BOOLEAN — A data type that can only be TRUE or FALSE. Used here to flag
whether this is the current version of a customer record.
DATE — Stores just a date (no time). DEFAULT CURRENT_DATE auto-fills with
today.
DEFAULT '9999-12-31' — Far future date, meaning "this record is valid until
further notice." This pattern (valid_from / valid_to / is_current) is how we
track historical changes to dimension data. Explained fully in Section 2.
Fact Table
sql
CREATE TABLE fact_orders (
order_id BIGINT PRIMARY KEY,
date_key INT REFERENCES dim_date(date_key),
customer_key BIGINT REFERENCES dim_customers(customer_key),
REFERENCES dim_date(date_key) — This is a foreign key constraint. It
means: the value in date_key column of fact_orders must exist in
the date_key column of dim_date. It enforces referential integrity — you can't
have an order pointing to a date that doesn't exist in your date dimension.
Indexes on Fact Table
sql
CREATE INDEX idx_fact_orders_customer ON fact_orders (customer_key);
CREATE INDEX idx_fact_orders_date ON fact_orders (date_key);
Why index foreign key columns? Because joins use them constantly.
When you join fact_orders to dim_customers on customer_key, having an
index on that column means the join is fast.
sql
CREATE INDEX idx_orders_cust_date ON [Link] (customer_id,
created_at DESC);
Composite index — An index on TWO columns together. The column order
matters. Put the most selective column first (the one that filters down the
most rows). Here, customer_id first, then created_at descending. A query
like WHERE customer_id = 42 ORDER BY created_at DESC uses this index
perfectly.
sql
CREATE INDEX idx_orders_active ON [Link] (created_at) WHERE
status = 'active';
Partial index — An index that only includes rows matching a condition. This
is smaller and faster than a full index if you frequently query only active
orders. The database only indexes the subset of rows where status = 'active'.
sql
CREATE INDEX idx_orders_cover ON [Link] (customer_id) INCLUDE
(amount, status, created_at);
Covering index — The INCLUDE clause adds extra columns to the index
that aren't part of the index key. If your query selects customer_id, amount,
status, created_at and filters on customer_id, this index covers the whole
query without touching the main table at all. Zero table lookups = maximum
speed.
0-D: Observability Tables
sql
CREATE TABLE etl_watermarks (
table_name VARCHAR(100) PRIMARY KEY,
last_loaded TIMESTAMP,
What is a watermark? It's a timestamp that records "we last loaded data
up to this point in time." Next time the pipeline runs, it picks up from this
watermark and only loads newer data. Without this, you'd reload everything
every time — wasteful.
sql
CREATE TABLE etl_run_log (
run_id BIGSERIAL PRIMARY KEY,
pipeline_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
NOT NULL — A constraint meaning this column can never be empty. If you
try to insert a row without providing a pipeline_name, the database will reject
it with an error. Used for mandatory fields.
sql
INSERT INTO etl_run_log (pipeline_name, status, started_at)
VALUES ('shopbd_daily_pipeline', 'running', CURRENT_TIMESTAMP);
INSERT INTO — This is DML (Data Manipulation Language) — it actually
puts data INTO a table.
(pipeline_name, status, started_at) — The list of columns you're
providing values for.
VALUES (...) — The actual values to insert, in the same order as the
columns listed.
This logs "our pipeline has started" at the beginning.
SECTION 1: ETL — EXTRACT & LOAD
ETL = Extract, Transform, Load. It's the process of taking data from a source,
cleaning it, and loading it into your destination.
ELT = Extract, Load, Transform. Newer pattern where you load raw first, then
transform with SQL inside the warehouse.
1-A: BULK LOAD (commented out)
sql
/*
COPY raw.orders_source (order_id, customer_id, amount, created_at)
FROM '/data/shopbd_orders_2024.csv'
WITH (FORMAT csv, HEADER TRUE, DELIMITER ',', NULL 'NULL');
*/
COPY — PostgreSQL's fastest way to load data from a file directly into a
table. Bypasses a lot of overhead compared to individual INSERTs.
FROM '/data/...' — The file path on the server.
FORMAT csv — The file is CSV format.
HEADER TRUE — The first row of the CSV is a header row (column names),
skip it.
DELIMITER ',' — Columns are separated by commas.
NULL 'NULL' — If the file contains the text NULL, treat it as an actual SQL
NULL (missing value).
This is wrapped in /* */ because it requires a file to actually exist on disk —
it's shown as a reference.
1-B: FULL LOAD
sql
TRUNCATE TABLE [Link];
TRUNCATE TABLE — Deletes ALL rows from a table instantly and efficiently.
Unlike DELETE (which logs each deleted row), TRUNCATE wipes the whole
table in one shot. Used before a full reload to start fresh.
sql
INSERT INTO [Link]
SELECT
CAST(product_id AS BIGINT),
TRIM(name),
TRIM(category),
TRIM(brand),
CAST(price AS DECIMAL(12,2))
FROM raw.products_source;
INSERT INTO ... SELECT ... — Instead of typing VALUES (...), you can insert
the results of a SELECT query. This copies and transforms data from one
table into another in a single statement.
CAST(product_id AS BIGINT) — CAST converts a value from one data type
to another. Here we convert the raw TEXT product_id into a
proper BIGINT number. If the value can't be converted (e.g. it
says "abc" instead of a number), SQL throws an error — which is good, it
catches bad data.
TRIM(name) — Removes leading and trailing spaces from text. Raw data
often has accidental spaces. TRIM(' ShopBD ') returns 'ShopBD'.
1-C: INCREMENTAL LOAD (Watermark Pattern)
sql
WITH watermark AS (
SELECT last_loaded
FROM etl_watermarks
WHERE table_name = 'orders'
),
WITH ... AS (...) — This is a CTE (Common Table Expression). Think of it as
giving a temporary name to a query result so you can refer to it
later. watermark is now a named temporary result set containing just
the last_loaded timestamp for orders.
CTEs make complex queries readable by breaking them into named steps.
sql
new_rows AS (
SELECT * FROM raw.orders_source
WHERE CAST(updated_at AS TIMESTAMP)
> (SELECT COALESCE(last_loaded, '1970-01-01') FROM watermark)
new_rows — Another CTE. Selects only rows from the raw table
where updated_at is newer than the last watermark.
COALESCE(last_loaded, '1970-01-01') — COALESCE returns the first non-
NULL value from its arguments. If last_loaded is NULL (first-ever run, no
previous watermark), it uses '1970-01-01' (the beginning of time) instead,
which means "load everything." Smart default.
> — Greater than. Only loads rows newer than the watermark.
sql
INSERT INTO [Link]
(order_id, customer_id, ...)
SELECT
CAST(order_id AS BIGINT),
...
FROM new_rows;
Inserts only the new rows, with proper type casting applied.
sql
INSERT INTO etl_watermarks (table_name, last_loaded, rows_loaded)
VALUES ('orders', CURRENT_TIMESTAMP, (SELECT COUNT(*) FROM
[Link]))
ON CONFLICT (table_name) DO UPDATE
SET last_loaded = EXCLUDED.last_loaded,
rows_loaded = EXCLUDED.rows_loaded,
run_at = CURRENT_TIMESTAMP;
ON CONFLICT (table_name) DO UPDATE — This is an UPSERT. If a row
with this table_name already exists (conflict on the primary key), instead of
failing, update it. If it doesn't exist, insert it. EXCLUDED refers to the row we
were trying to insert — it lets you access the new values.
After the load, this updates the watermark to now, so next run picks up from
here.
1-D: CLEANING + DEDUPLICATION
sql
CREATE TABLE staging.orders_clean AS
WITH deduped AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY CAST(updated_at AS TIMESTAMP) DESC
) AS rn
FROM raw.orders_source
SELECT ... FROM deduped WHERE rn = 1;
CREATE TABLE ... AS SELECT — Creates a new table and fills it with the
result of a query in one step.
ROW_NUMBER() OVER (...) — This is a window function. It assigns a
sequential number to each row within a group. OVER defines the window
(grouping and ordering).
PARTITION BY order_id — Within each group of rows that share the
same order_id...
ORDER BY updated_at DESC — ...number them with 1 = most recently
updated.
WHERE rn = 1 — Keep only the row numbered 1 (the most recent version of
each order). This removes duplicates — if the same order_id appeared 3
times, we keep only the freshest one.
This is one of the most important patterns in data engineering.
1-E: IDEMPOTENT EVENT INGESTION
sql
INSERT INTO fact_events (event_id, user_id, event_type, occurred_at,
payload)
SELECT event_id, user_id, event_type, occurred_at, payload
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY event_id
ORDER BY _ingested_at DESC
) AS rn
FROM staging.raw_events
WHERE _batch_date = CURRENT_DATE
) deduped
WHERE rn = 1
ON CONFLICT (event_id) DO NOTHING;
Subquery — The FROM (SELECT ...) deduped is a subquery. You run a
query, give that result a name (deduped), and then query from it. Like a CTE
but written inline.
CURRENT_DATE — Built-in function returning today's date. Only processes
today's batch.
ON CONFLICT (event_id) DO NOTHING — If we try to insert an event that
already exists (same event_id), silently skip it rather than error. This makes
the load idempotent — you can run it multiple times safely, and you'll
always get the same result. Critical for reliable pipelines.
SECTION 2: UPSERTS & MERGE (SCD Patterns)
SCD = Slowly Changing Dimension. Customer data changes over time —
they move cities, change emails. How do you handle that?
SCD Type 1 — Just overwrite the old value. Simple, but you lose
history. SCD Type 2 — Expire the old row, insert a new row. Full history
preserved.
2-A: SCD Type 1 — UPDATE
sql
UPDATE dim_customers
SET city = 'Dhaka',
updated_at = CURRENT_TIMESTAMP
WHERE customer_id = 42;
UPDATE — Modifies existing rows.
SET — Specifies which columns to change and to what values.
WHERE customer_id = 42 — Only update the row(s) where this condition is
true. Always include a WHERE clause on UPDATE — without it, you'd
update every single row in the table.
This just overwrites city with 'Dhaka'. Old city value is gone forever.
2-B: SCD Type 2 — Expire + Insert
sql
UPDATE dim_customers
SET is_current = FALSE,
valid_to = CURRENT_DATE
WHERE customer_id = 42
AND is_current = TRUE;
First, mark the old row as expired. is_current = FALSE means "this is
history." valid_to = today records when it expired.
sql
INSERT INTO dim_customers
(customer_key, customer_id, city, valid_from, valid_to, is_current)
VALUES
((SELECT MAX(customer_key) + 1 FROM dim_customers),
42, 'Chittagong', CURRENT_DATE, '9999-12-31', TRUE);
Then insert a new row with the new city. valid_from = today, valid_to =
'9999-12-31' (far future = currently active), is_current = TRUE.
Now you have BOTH rows — the old one (Dhaka) and the new one
(Chittagong). You can query history by joining on the date range.
2-C: MERGE
sql
MERGE INTO dim_customers t
USING (
SELECT * FROM [Link]
) s ON t.customer_id = s.customer_id
MERGE — The Swiss army knife of data loading. Handles INSERT, UPDATE,
and DELETE in one statement.
INTO dim_customers t — t is an alias (a short nickname) for the target
table. Makes the query shorter to write.
USING (...) s — s is an alias for the source data.
ON t.customer_id = s.customer_id — The join condition that determines
whether a match exists.
sql
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET [Link] = [Link], [Link] = [Link], t.updated_at =
s.updated_at
WHEN MATCHED — When a matching row exists in both target and source,
and the source is newer, update it.
sql
WHEN NOT MATCHED THEN
INSERT (customer_id, name, email, created_at, updated_at)
VALUES (s.customer_id, [Link], [Link], s.created_at, s.updated_at)
WHEN NOT MATCHED — When the row exists in source but NOT in target,
insert it (new customer).
sql
WHEN NOT MATCHED BY SOURCE AND t.is_current = FALSE THEN
UPDATE SET t.is_current = TRUE;
WHEN NOT MATCHED BY SOURCE — When the row exists in target but
NOT in source. Here we reactivate previously soft-deleted records.
One MERGE statement handles all three scenarios simultaneously.
SECTION 3: DATA QUALITY CHECKS
3-A: Comprehensive DQ Suite
sql
WITH dq_checks AS (
SELECT
COUNT(*) FILTER (WHERE order_id IS NULL) AS null_order_ids,
COUNT(*) — Counts all rows.
FILTER (WHERE ...) — A PostgreSQL extension to aggregate functions.
Instead of counting all rows, count only rows matching this condition.
So COUNT(*) FILTER (WHERE order_id IS NULL) counts how many rows have a
NULL order_id.
AS null_order_ids — The AS keyword renames the result column. Without it,
the column would have an ugly auto-generated name.
sql
COUNT(*) - COUNT(DISTINCT order_id) AS duplicate_orders,
COUNT(DISTINCT order_id) — Counts only unique order_ids. If you have
1000 rows but only 990 unique order_ids, there are 10 duplicates.
Subtracting from total COUNT gives you the duplicate count.
sql
COUNT(*) FILTER (WHERE amount > 10000000) AS suspicious_large,
A business rule check. Orders over 10 million are probably data errors. Flag
them for investigation.
sql
ROUND(100.0 * duplicate_orders / NULLIF(total_rows, 0), 2) AS dup_pct
ROUND(x, 2) — Rounds to 2 decimal places.
100.0 * — The 100.0 (not 100) forces floating point division. If you
write 100 * 10 / 1000, SQL does integer division and gets 1 (wrong). 100.0 *
10 / 1000 = 1.0 (correct).
NULLIF(total_rows, 0) — Returns NULL if total_rows = 0, otherwise
returns total_rows. Prevents division by zero error. If you divide by NULL,
the result is NULL (safe). If you divide by 0, SQL throws an error. Always use
NULLIF when dividing by a column that could be zero.
3-B: Column Profile
sql
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_val
PERCENTILE_CONT(0.5) — Calculates the 50th percentile (median). 0.5 =
50%. WITHIN GROUP (ORDER BY amount) tells it to order values by amount
and find the middle one. Use 0.9 for the 90th percentile, 0.25 for the 25th,
etc.
3-C: Row-Hash Change Detection
sql
MD5(CONCAT([Link], [Link], [Link], [Link])) AS row_hash
CONCAT(...) — Joins multiple strings together into one. CONCAT('Alice',
'alice@[Link]', 'Dhaka', 'active') = 'Alicealice@[Link]'.
MD5(...) — Creates a 32-character fingerprint hash of the input string. If
ANY character changes, the hash changes completely. Two identical inputs
always produce the same hash. This lets you detect changes without
comparing every column individually — just compare hashes.
If the hash in staging doesn't match the hash stored in the dimension, the
row changed and needs to be updated.
3-D: Change-Type Detection
sql
CASE
WHEN s.deleted_at IS NOT NULL THEN 'deleted'
WHEN t.order_id IS NULL THEN 'new'
ELSE 'updated'
END AS change_type
CASE WHEN ... THEN ... ELSE ... END — SQL's if-else statement.
Evaluates conditions top to bottom and returns the first matching result.
If deleted_at is filled in: the record was deleted in the source
If the target has no matching row (t.order_id IS NULL — result of a LEFT
JOIN with no match): it's a brand new row
Otherwise: it already exists and has been updated
3-E: Z-Score Anomaly Detection
sql
ROUND(
(daily_revenue - [Link]) / NULLIF([Link], 0),
2) AS z_score
Z-score is a statistical measure of how many standard deviations a value is
from the average. A z-score of 0 means exactly average. A z-score of 3
means 3 standard deviations above average — very unusual. Anything above
3 or below -3 is flagged as an anomaly.
ABS(z_score) > 3 — ABS is absolute value (removes the minus sign).
So ABS(-4) = 4. This catches anomalies both above AND below normal.
SECTION 4: DIMENSION TABLES — POPULATE
4-A: Date Dimension
sql
WITH dates AS (
SELECT GENERATE_SERIES(
'2020-01-01'::DATE,
'2030-12-31'::DATE,
'1 day'::INTERVAL
)::DATE AS dt
GENERATE_SERIES(start, end, step) — PostgreSQL function that
generates a sequence of values. Here it generates one row per day from Jan
1, 2020 to Dec 31, 2030 — over 4,000 rows created automatically.
'2020-01-01'::DATE — The :: is PostgreSQL shorthand for CAST. '2020-01-
01'::DATE = CAST('2020-01-01' AS DATE).
'1 day'::INTERVAL — An interval (duration). Step by 1 day at a time.
sql
TO_CHAR(dt, 'YYYYMMDD')::INT AS date_key,
TO_CHAR(dt, 'YYYYMMDD') — Formats a date as a string. For March 28,
2024, this returns '20240328'. Then ::INT converts that string to an
integer 20240328. This is the smart date key format — readable as a
number, useful for filtering (WHERE date_key >= 20240101).
sql
EXTRACT(DOW FROM dt)::INT AS day_of_week,
EXTRACT(part FROM date) — Pulls out one part of a
date/timestamp. DOW = Day of Week (0=Sunday, 6=Saturday). Other
options: YEAR, MONTH, DAY, HOUR, MINUTE, WEEK, QUARTER, DOY (day of
year).
sql
EXTRACT(DOW FROM dt) IN (0, 6) AS is_weekend,
IN (0, 6) — The IN operator checks if a value matches any value in the list.
Day 0 (Sunday) or day 6 (Saturday) = weekend. This expression evaluates
to TRUE or FALSE, which is stored in the BOOLEAN column.
sql
dt = (DATE_TRUNC('month', dt) + INTERVAL '1 month'
- INTERVAL '1 day')::DATE AS is_month_end
DATE_TRUNC('month', dt) — Truncates a date to the start of its month.
March 28 → March 1.
Then + INTERVAL '1 month' = April 1, then - INTERVAL '1 day' = March 31. So
the whole expression computes "the last day of this month." Comparing dt to
that gives TRUE only on the last day of each month.
SECTION 5: FACT TABLE LOAD
sql
INSERT INTO fact_orders
(order_id, date_key, customer_key, ...)
SELECT
o.order_id,
TO_CHAR(o.created_at::DATE, 'YYYYMMDD')::INT AS date_key,
dc.customer_key,
...
FROM [Link] o
JOIN dim_customers dc ON dc.customer_id = o.customer_id AND
dc.is_current = TRUE
JOIN dim_products dp ON dp.product_id = o.product_id
JOIN dim_region dr ON [Link] = [Link]
ON CONFLICT (order_id) DO NOTHING;
This is the central load step — it ties everything together. For each order in
staging, it looks up the surrogate keys from all four dimensions and inserts
one row into the fact table. The ON CONFLICT DO NOTHING makes it safe to
re-run.
SECTION 6: MATERIALIZED VIEW
sql
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT
DATE_TRUNC('day', created_at) AS day,
region,
SUM(amount) AS revenue,
COUNT(*) AS order_count
FROM [Link]
GROUP BY 1, 2;
MATERIALIZED VIEW — A view that is physically stored on disk, unlike a
regular view which re-runs the query every time. Because it's pre-computed,
querying it is instant. The tradeoff is you must manually refresh it.
GROUP BY 1, 2 — Instead of repeating column names, you can reference
them by position. 1 = first SELECT column (day), 2 = second (region).
Shorthand.
sql
CREATE UNIQUE INDEX ON mv_daily_revenue (day, region);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
CONCURRENTLY — Refreshes the materialized view without locking it.
Other queries can still read it while it's being updated. Requires a unique
index (which we just created).
SECTION 7: CORE ANALYTICAL QUERIES
SQL Execution Order — The Most Important Concept
sql
-- Written order: SELECT → FROM → WHERE → GROUP BY → HAVING →
ORDER BY → LIMIT
-- Execution order: FROM → WHERE → GROUP BY → HAVING → SELECT →
ORDER BY → LIMIT
You write SQL starting with SELECT. But the database executes it starting
with FROM. This explains many confusions — for example, why you can't use
a column alias from SELECT in your WHERE clause (WHERE runs before
SELECT). Always think in execution order.
7-B: String Functions
sql
UPPER(name) -- SAGAR → SAGAR (uppercase)
LOWER(email) -- SAGAR → sagar (lowercase)
LENGTH(name) -- number of characters
TRIM(' hi ') -- removes spaces → 'hi'
SUBSTRING(name, 1, 3) -- first 3 characters → 'Sag'
CONCAT(name, ' — ', city) -- joins strings → 'Sagar — Dhaka'
REPLACE(city, 'Old', 'New') -- find and replace
These are scalar functions — they take one value and return one
transformed value.
7-C: Date Functions
sql
NOW() -- current date AND time
CURRENT_DATE -- just today's date
EXTRACT(YEAR FROM created_at) -- just the year number
DATE_TRUNC('month', created_at) -- first day of the month
created_at + INTERVAL '7 days' -- add 7 days to a date
CURRENT_DATE - created_at::DATE -- number of days between two dates
7-D: Joins — The Heart of SQL
INNER JOIN — Returns only rows where a match exists in BOTH tables. If a
customer has no orders, they won't appear.
LEFT JOIN — Returns ALL rows from the left table, and matching rows from
the right. If no match on the right, those columns are NULL. Used to find
"orphan" records — WHERE right_table.id IS NULL.
SELF JOIN — A table joined to itself. Used for hierarchical data (employee-
manager, customer-referral).
Multi-table join — Chain multiple JOINs together. Each join adds more
context (dimension) to the fact.
7-E: Aggregation
sql
COUNT(*) -- count all rows
SUM(amount) -- add up all values
AVG(amount) -- average
MIN/MAX(amount) -- smallest / largest
These are aggregate functions — they collapse many rows into one
summary value. They always work with GROUP BY.
HAVING — Like WHERE but for groups. WHERE filters individual rows before
grouping. HAVING filters groups after grouping. HAVING COUNT(*) >
500 means "only show regions that have more than 500 orders."
ROLLUP(region, category) — Produces subtotals at each level. You get:
revenue per region+category, revenue per region (subtotal), and grand total
— all in one query.
7-F: Set Operations
sql
UNION -- combine results, remove duplicates
UNION ALL -- combine results, keep duplicates (faster — no dedup step)
INTERSECT -- rows that appear in BOTH queries
EXCEPT -- rows in first query but NOT second
Both queries must return the same number of columns with compatible
types.
SECTION 8: WINDOW FUNCTIONS
Window functions are the most powerful analytical tool in SQL. They look like
aggregate functions but they don't collapse rows. Every row keeps its own
identity while also seeing information from other rows in its "window."
sql
FUNCTION() OVER (
PARTITION BY col -- defines the group (window)
ORDER BY col -- defines order within the window
ROWS BETWEEN ... -- defines exactly which rows to include
PARTITION BY — Like GROUP BY but rows are not collapsed. Think of it as
"restart the calculation for each group."
ORDER BY — Within each partition, what's the ordering?
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW —
Include all rows from the start of the partition up to the current row. Used for
running totals.
8-A: DENSE_RANK — Top-N per group
sql
DENSE_RANK() OVER (
PARTITION BY [Link]
ORDER BY [Link] DESC
) AS rnk
DENSE_RANK() — Assigns a rank number within each partition. Rank 1 =
highest amount. If two rows tie, they both get rank 1, and the next gets rank
2 (no gaps). RANK() would skip numbers after ties (1, 1,
3). ROW_NUMBER() gives unique numbers even on ties.
WHERE rnk <= 3 then filters to top 3 per region.
8-B: LAG / LEAD
sql
LAG(SUM([Link])) OVER (ORDER BY month) -- previous month's value
LEAD(SUM([Link])) OVER (ORDER BY month) -- next month's value
LAG — Looks backward: gives you the value from the previous row. LEAD —
Looks forward: gives you the value from the next row.
For month-over-month change: this_month - LAG(this_month) = the
difference.
8-C: Running Total
sql
SUM([Link]) OVER (
PARTITION BY o.customer_key
ORDER BY d.full_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
For each customer, sum all their orders from the very first (UNBOUNDED
PRECEDING) up to the current row. Each row accumulates. Row 1: 100. Row
2: 250. Row 3: 430. That's a running total.
8-E: Moving Average
sql
AVG(revenue) OVER (
ORDER BY week
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_7wk_avg
Average across the current row and the 6 rows before it — a 7-week moving
average. Smooths out weekly spikes to reveal trends.
8-G: Percent of Total + Cumulative Percent
sql
100.0 * SUM([Link]) / SUM(SUM([Link])) OVER ()
SUM([Link]) — This category's revenue (after GROUP BY).
SUM(SUM([Link])) OVER () — The outer SUM is the window
function. OVER () with no PARTITION BY means the window is the entire result
set. So SUM(SUM([Link])) = grand total revenue. Dividing gives percent
of total.
SUM(SUM([Link])) OVER (ORDER BY SUM([Link]) DESC) — Add
ORDER BY to make it accumulate: this gives cumulative percent (top
category: 30%, top 2: 55%, top 3: 75%, etc. — Pareto analysis).
SECTION 9: CTEs & SUBQUERIES
9-A: Scalar Subquery
sql
(SELECT AVG(amount) FROM fact_orders) AS platform_avg_order
A subquery that returns a single value is called a scalar subquery. You can
use it anywhere a single value is expected — in SELECT, WHERE, HAVING,
etc.
9-C: EXISTS
sql
WHERE EXISTS (
SELECT 1 FROM fact_orders o
WHERE o.customer_key = c.customer_key
AND [Link] > 5000
EXISTS — Returns TRUE if the subquery returns any rows at all. SELECT 1 is
a convention — we don't care what is returned, just whether any row exists.
EXISTS stops as soon as it finds the first matching row — very efficient. Use it
instead of IN when checking for existence.
9-D: Chained CTEs
sql
WITH
orders_2024 AS (...),
order_totals AS (... FROM orders_2024 ...),
top_customers AS (... FROM order_totals ...)
SELECT ... FROM top_customers;
CTEs can reference each other in sequence. Each CTE builds on the previous.
This is how you break a complex problem into simple named steps — much
more readable than nested subqueries.
9-E: Recursive CTE
sql
WITH RECURSIVE referral_tree AS (
-- Anchor: starting point (no parent)
SELECT customer_id, name, NULL AS referred_by, 0 AS level
FROM dim_customers WHERE ...
UNION ALL
-- Recursive: join back to itself
SELECT c.customer_id, [Link], rt.customer_id, [Link] + 1
FROM dim_customers c
JOIN referral_tree rt ON c.customer_id = rt.customer_id
WITH RECURSIVE — Allows the CTE to reference itself. Used for
hierarchical/tree data.
It works in two parts:
1. Anchor member — the starting rows (roots of the tree)
2. Recursive member — joins the CTE to itself, adding the next level
each time, until no more rows are found
UNION ALL — Combines the anchor result with each recursive iteration.
Use cases: org charts, referral trees, category hierarchies, bill of materials.
SECTION 10: SEMI-STRUCTURED DATA (JSON)
sql
payload->>'user_id' -- extract text value for key 'user_id'
payload->'metadata'->>'source' -- navigate nested JSON: [Link]
payload ? 'discount_code' -- check if key exists (returns TRUE/FALSE)
->> — Extracts a JSON value as text. -> — Extracts a JSON value
as JSON (for navigating nested objects). ? — Checks for key existence.
SECTION 11: ADVANCED ANALYTICS
11-A: SCD Type 2 Point-in-Time Query
sql
JOIN dim_customers d
ON d.customer_id = f.customer_key
AND order_date BETWEEN d.valid_from AND d.valid_to
BETWEEN x AND y — True if value is >= x AND <= y. Here, the order date
must fall within the customer record's validity period. This retrieves "what
did this customer's data look like on the date of this order" — even if they've
since moved cities.
11-D: Session Analysis
sql
CASE
WHEN occurred_at - LAG(occurred_at) OVER (PARTITION BY user_id ORDER
BY occurred_at)
> INTERVAL '30 minutes'
OR LAG(occurred_at) ... IS NULL
THEN 1 ELSE 0
END AS is_new_session
If the gap between this event and the previous event from the same user is
more than 30 minutes (or it's their first event), mark it as a new session start
(1). Otherwise 0.
sql
SUM(is_new_session) OVER (
PARTITION BY user_id ORDER BY occurred_at
) AS session_id
Running sum of the is_new_session flag. Every time a new session starts (1),
the running sum increments. Result: a session_id that increases every time a
session boundary is crossed. Elegant.
11-E: Cohort Retention
Cohort analysis answers: "Of all users who joined in month X, what
percentage are still active 1 month later? 3 months later? 6 months later?"
sql
WITH cohorts AS (
SELECT customer_key,
DATE_TRUNC('month', MIN(d.full_date)) AS cohort_month
FROM fact_orders o JOIN dim_date d ...
GROUP BY customer_key
Find the earliest order date for each customer — that's their cohort (when
they first bought something).
sql
EXTRACT(MONTH FROM AGE(activity_month, cohort_month))::INT AS
months_since_join
AGE(date1, date2) — Returns the difference between two dates as an
interval. EXTRACT(MONTH FROM ...) then pulls out just the month count. This
tells you how many months after joining the customer was active.
11-G: PIVOT
sql
SUM(CASE WHEN [Link] = 'Dhaka' THEN [Link] END) AS dhaka
This is a manual pivot. The CASE WHEN acts as a filter — for each row, if
region is Dhaka, contribute the amount; otherwise contribute NULL (which
SUM ignores). The result is one column per region. Together with GROUP BY
month, you get a wide table: one row per month, one column per region.
11-H: UNPIVOT
The reverse — taking a wide table and making it tall again. Achieved
with UNION ALL, one per column. Simple but effective.
SECTION 12: QUERY OPTIMISATION
EXPLAIN ANALYZE
sql
EXPLAIN ANALYZE
SELECT customer_key, SUM(amount) ...
EXPLAIN — Shows the query execution plan without running the
query. EXPLAIN ANALYZE — Actually runs the query AND shows timing for
each step. Use this to diagnose slow queries. Look for "Seq Scan" (sequential
scan — reads every row, slow) vs "Index Scan" (uses an index, fast).
SECTION 13: TRANSACTIONS
sql
BEGIN;
UPDATE dim_customers SET city = 'Dhaka' WHERE customer_id = 101;
UPDATE dim_customers SET city = 'Sylhet' WHERE customer_id = 102;
COMMIT;
-- ROLLBACK;
BEGIN — Starts a transaction. All statements after this are grouped together.
COMMIT — Saves all changes permanently. Only after COMMIT are changes
visible to other users.
ROLLBACK — Undoes everything since BEGIN. Used when something goes
wrong mid-transaction.
Why transactions? If you're transferring money between two accounts, you
need both the debit AND the credit to succeed. If the second update fails
halfway, ROLLBACK undoes the first too. Without transactions, you'd have
money disappear. This is the ACID guarantee — Atomicity (all or nothing),
Consistency, Isolation, Durability.
SECTION 14: ALTER TABLE + INDEX MANAGEMENT
sql
ALTER TABLE dim_customers ADD COLUMN phone VARCHAR(30);
ALTER TABLE dim_customers DROP COLUMN phone;
ALTER TABLE dim_customers RENAME COLUMN name TO full_name;
ALTER TABLE dim_customers ALTER COLUMN full_name TYPE VARCHAR(250);
ALTER TABLE — Modifies an existing table's structure AFTER it was created.
These are the four most common operations: add a column, remove a
column, rename a column, change a column's data type.
sql
DROP INDEX IF EXISTS idx_dim_cust_city;
IF EXISTS — Prevents an error if the index doesn't exist. Without this,
dropping a non-existent index throws an error and can fail your script.
SECTION 15: UPDATE THE RUN LOG
sql
UPDATE etl_run_log
SET
status = 'success',
rows_read = (SELECT COUNT(*) FROM raw.orders_source),
rows_written = (SELECT COUNT(*) FROM fact_orders),
finished_at = CURRENT_TIMESTAMP,
duration_sec = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP -
started_at))
WHERE pipeline_name = 'shopbd_daily_pipeline'
AND status = 'running';
EXTRACT(EPOCH FROM interval) — Converts an interval to total
seconds. CURRENT_TIMESTAMP - started_at gives a duration; EPOCH
converts it to a plain number like 142.7 seconds.
This closes the pipeline run — marks it successful, records row counts, and
stores how long it took. This is your audit trail.
THE MENTAL MODEL — How to Write This Yourself
When you face a data problem, ask these questions in order:
1. What tables do I need? → CREATE TABLE with the right columns and
types
2. How does data flow in? → INSERT, COPY, incremental with
watermarks
3. Does it need
cleaning? → CAST, TRIM, ROW_NUMBER() dedup, COALESCE for NULLs
4. Does it need to merge with existing data? → ON
CONFLICT, MERGE, SCD patterns
5. Is the data good quality? → COUNT(*) FILTER, NULLIF, MD5 hashing
6. What question does the business want
answered? → SELECT, JOIN, GROUP BY
7. Does it need ranking, running totals, trends? → Window functions
with OVER()
8. Is it complex enough to need named steps? → CTEs with WITH
9. Does it need to be fast? → Indexes, partitions, materialized
views, EXPLAIN ANALYZE
10. Does it need to be safe? → Transactions, ON CONFLICT DO
NOTHING, IF EXISTS
Every line in that 600-line script follows from these ten questions. Now you
have the complete mental map. The best next step is to open a free
PostgreSQL environment (like [Link] or install pgAdmin locally) and
start typing these patterns one by one — reading is understanding, but
writing is learning.
Claude is AI and can make mistakes. Please double-check responses.
Grand unified sql project · SQL
Copy
--
==============================================
===============================
-- GRAND UNIFIED SQL PROJECT: E-COMMERCE DATA WAREHOUSE
--
==============================================
===============================
-- Imaginary Company: "ShopBD" — a Bangladesh-based e-commerce
platform
-- This single script demonstrates every SQL concept from both cheatsheets:
-- ✔ DDL: Tables, indexes, partitions, materialized views
-- ✔ ETL/ELT: Full load, incremental, MERGE / upsert
-- ✔ Data Warehouse: Star schema, surrogate keys, SCD Type 1 & 2, date dim
-- ✔ DML: INSERT, UPDATE, DELETE, UPSERT, COPY/LOAD
-- ✔ Data Quality: Null checks, duplicates, range checks, row-hash diffing
-- ✔ Window Functions: RANK, LAG/LEAD, running totals, moving averages
-- ✔ CTEs & Subqueries: Regular, chained, recursive, correlated, EXISTS
-- ✔ Aggregation: GROUP BY, HAVING, ROLLUP, CASE-pivots, unpivot
-- ✔ Set Operations: UNION, UNION ALL, INTERSECT, EXCEPT
-- ✔ Advanced Analytics: Cohort, sessionisation, anomaly detection, YoY
-- ✔ Semi-structured: JSON extraction and indexing
-- ✔ Transactions: BEGIN / COMMIT / ROLLBACK
-- ✔ ETL Observability: Run log, watermarks, change-type detection
-- ✔ Dialect notes for BigQuery / Snowflake / SQL Server / MySQL included
-- Platform: PostgreSQL (primary). Dialect alternatives noted inline.
--
==============================================
===============================
--
==============================================
===============================
-- SECTION 0: DATABASE SCHEMA SETUP (DDL)
--
==============================================
===============================
-- ── 0-A RAW LAYER
─────────────────────────────────────────────────────────
CREATE TABLE raw.orders_source (
order_id TEXT,
customer_id TEXT,
product_id TEXT,
region TEXT,
status TEXT,
amount TEXT,
qty TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT,
_loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE raw.customers_source (
customer_id TEXT,
name TEXT,
email TEXT,
city TEXT,
status TEXT,
created_at TEXT,
updated_at TEXT,
_loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE raw.products_source (
product_id TEXT,
name TEXT,
category TEXT,
brand TEXT,
price TEXT,
_loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Raw events table — with JSONB payload and range partitioning
CREATE TABLE [Link] (
id BIGSERIAL,
event_type VARCHAR(50),
created_at TIMESTAMP,
payload JSONB
) PARTITION BY RANGE (created_at);
-- Partitions by quarter
CREATE TABLE raw.events_2024_q1 PARTITION OF [Link]
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE raw.events_2024_q2 PARTITION OF [Link]
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
CREATE TABLE raw.events_2024_q3 PARTITION OF [Link]
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');
CREATE TABLE raw.events_2024_q4 PARTITION OF [Link]
FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');
-- Index on JSONB key for fast lookup
CREATE INDEX ON [Link] ((payload->>'user_id'));
-- ── 0-B STAGING LAYER
─────────────────────────────────────────────────────
CREATE TABLE [Link] (
order_id BIGINT,
customer_id BIGINT,
product_id BIGINT,
region VARCHAR(100),
status VARCHAR(50),
amount DECIMAL(12,2),
qty INT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE TABLE [Link] (
customer_id BIGINT,
name VARCHAR(200),
email VARCHAR(255),
city VARCHAR(100),
status VARCHAR(50),
created_at TIMESTAMP,
updated_at TIMESTAMP,
row_hash VARCHAR(64) -- MD5 for change detection
);
CREATE TABLE [Link] (
product_id BIGINT,
name VARCHAR(200),
category VARCHAR(100),
brand VARCHAR(100),
price DECIMAL(12,2)
);
-- Staging events: deduplicated from raw
CREATE TABLE staging.raw_events (
event_id BIGINT,
user_id BIGINT,
event_type VARCHAR(50),
occurred_at TIMESTAMP,
payload JSONB,
_ingested_at TIMESTAMP,
_batch_date DATE
);
-- ── 0-C DATA WAREHOUSE LAYER (Star Schema)
────────────────────────────────
-- Dimension: Date (fully populated below)
CREATE TABLE dim_date (
date_key INT PRIMARY KEY,
full_date DATE,
day_of_week INT,
day_name VARCHAR(20),
day_of_month INT,
day_of_year INT,
week_of_year INT,
month_num INT,
month_name VARCHAR(20),
quarter INT,
year INT,
is_weekend BOOLEAN,
is_month_end BOOLEAN
);
-- Dimension: Customer — SCD Type 2 capable
CREATE TABLE dim_customers (
customer_key BIGINT PRIMARY KEY, -- surrogate key
customer_id BIGINT, -- natural key
name VARCHAR(200),
email VARCHAR(255),
city VARCHAR(100),
is_current BOOLEAN DEFAULT TRUE,
valid_from DATE DEFAULT CURRENT_DATE,
valid_to DATE DEFAULT '9999-12-31',
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- Dimension: Product
CREATE TABLE dim_products (
product_key BIGINT PRIMARY KEY,
product_id BIGINT,
name VARCHAR(200),
category VARCHAR(100),
brand VARCHAR(100),
price DECIMAL(12,2)
);
-- Dimension: Region (simple, static)
CREATE TABLE dim_region (
region_key SERIAL PRIMARY KEY,
region VARCHAR(100) UNIQUE NOT NULL
);
-- Fact table: Orders
CREATE TABLE fact_orders (
order_id BIGINT PRIMARY KEY,
date_key INT REFERENCES dim_date(date_key),
customer_key BIGINT REFERENCES dim_customers(customer_key),
product_key BIGINT REFERENCES dim_products(product_key),
region_key INT REFERENCES dim_region(region_key),
amount DECIMAL(12,2),
qty INT,
status VARCHAR(50)
);
-- Fact table: Events
CREATE TABLE fact_events (
event_id BIGINT PRIMARY KEY,
user_id BIGINT,
event_type VARCHAR(50),
occurred_at TIMESTAMP,
payload JSONB
);
-- Indexes on fact table foreign keys
CREATE INDEX idx_fact_orders_customer ON fact_orders (customer_key);
CREATE INDEX idx_fact_orders_date ON fact_orders (date_key);
CREATE INDEX idx_fact_orders_product ON fact_orders (product_key);
-- Composite covering index for the most common analytical query
CREATE INDEX idx_orders_cust_date ON [Link] (customer_id,
created_at DESC);
CREATE INDEX idx_orders_active ON [Link] (created_at) WHERE
status = 'active';
CREATE INDEX idx_orders_cover ON [Link] (customer_id) INCLUDE
(amount, status, created_at);
-- ── 0-D OBSERVABILITY TABLES
─────────────────────────────────────────────
CREATE TABLE etl_watermarks (
table_name VARCHAR(100) PRIMARY KEY,
last_loaded TIMESTAMP,
rows_loaded BIGINT,
run_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE etl_run_log (
run_id BIGSERIAL PRIMARY KEY,
pipeline_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL, -- running / success / failed
rows_read BIGINT,
rows_written BIGINT,
rows_rejected BIGINT,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
duration_sec DECIMAL(10,2),
error_message TEXT,
watermark_from TIMESTAMP,
watermark_to TIMESTAMP
);
-- Log pipeline start
INSERT INTO etl_run_log (pipeline_name, status, started_at)
VALUES ('shopbd_daily_pipeline', 'running', CURRENT_TIMESTAMP);
--
==============================================
===============================
-- SECTION 1: ETL — EXTRACT & LOAD
--
==============================================
===============================
-- ── 1-A BULK LOAD (PostgreSQL COPY — fastest CSV ingestion)
───────────────
/*
COPY raw.orders_source (order_id, customer_id, amount, created_at)
FROM '/data/shopbd_orders_2024.csv'
WITH (FORMAT csv, HEADER TRUE, DELIMITER ',', NULL 'NULL');
-- Client-side equivalent (psql):
\copy raw.orders_source FROM 'local_orders.csv' CSV HEADER
*/
-- MySQL equivalent:
/*
LOAD DATA INFILE '/var/data/[Link]'
INTO TABLE raw.orders_source
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS
(order_id, customer_id, amount, @raw_date)
SET created_at = STR_TO_DATE(@raw_date, '%Y-%m-%d');
*/
-- ── 1-B FULL LOAD (dimension tables — wipe and reload)
────────────────────
-- ELT pattern: Extract → Load raw → Transform with SQL
-- Raw layer is preserved for reprocessing. Tools: dbt, Snowflake, BigQuery,
Fivetran
TRUNCATE TABLE [Link];
INSERT INTO [Link]
SELECT
CAST(product_id AS BIGINT),
TRIM(name),
TRIM(category),
TRIM(brand),
CAST(price AS DECIMAL(12,2))
FROM raw.products_source;
-- ── 1-C INCREMENTAL LOAD (watermark pattern)
─────────────────────────────
-- Only load rows newer than last watermark
WITH watermark AS (
SELECT last_loaded
FROM etl_watermarks
WHERE table_name = 'orders'
),
new_rows AS (
SELECT * FROM raw.orders_source
WHERE CAST(updated_at AS TIMESTAMP)
> (SELECT COALESCE(last_loaded, '1970-01-01') FROM watermark)
INSERT INTO [Link]
(order_id, customer_id, product_id, region, status, amount, qty, created_at,
updated_at, deleted_at)
SELECT
CAST(order_id AS BIGINT),
CAST(customer_id AS BIGINT),
CAST(product_id AS BIGINT),
TRIM(UPPER(region)),
TRIM(LOWER(status)),
CAST(amount AS DECIMAL(12,2)),
CAST(qty AS INT),
CAST(created_at AS TIMESTAMP),
CAST(updated_at AS TIMESTAMP),
CAST(deleted_at AS TIMESTAMP)
FROM new_rows;
-- Update watermark after successful load
INSERT INTO etl_watermarks (table_name, last_loaded, rows_loaded)
VALUES ('orders', CURRENT_TIMESTAMP, (SELECT COUNT(*) FROM
[Link]))
ON CONFLICT (table_name) DO UPDATE
SET last_loaded = EXCLUDED.last_loaded,
rows_loaded = EXCLUDED.rows_loaded,
run_at = CURRENT_TIMESTAMP;
-- ── 1-D STAGING LAYER — CLEANING + DEDUPLICATION
─────────────────────────
-- ETL pattern: Extract → transform in Python/Spark → load clean data
-- Warehouse never sees raw messy data. Tools: Informatica, Talend, custom
Python
-- Layer 1: RAW — exact copy of source, never modified (already loaded
above)
-- Layer 2: STAGING — cleaned, typed, deduplicated
CREATE TABLE staging.orders_clean AS
WITH deduped AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY CAST(updated_at AS TIMESTAMP) DESC
) AS rn
FROM raw.orders_source
SELECT
CAST(order_id AS BIGINT) AS order_id,
CAST(customer_id AS BIGINT) AS customer_id,
CAST(product_id AS BIGINT) AS product_id,
TRIM(UPPER(region)) AS region,
TRIM(LOWER(status)) AS status,
CAST(amount AS DECIMAL(12,2)) AS amount,
CAST(qty AS INT) AS qty,
CAST(created_at AS TIMESTAMP) AS created_at,
CAST(updated_at AS TIMESTAMP) AS updated_at,
CAST(deleted_at AS TIMESTAMP) AS deleted_at
FROM deduped
WHERE rn = 1;
-- ── 1-E IDEMPOTENT EVENT INGESTION (deduplication)
────────────────────────
-- Problem: pipeline may re-deliver same events
-- Solution: deduplicate on a stable unique key
INSERT INTO fact_events (event_id, user_id, event_type, occurred_at,
payload)
SELECT event_id, user_id, event_type, occurred_at, payload
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY event_id
ORDER BY _ingested_at DESC
) AS rn
FROM staging.raw_events
WHERE _batch_date = CURRENT_DATE -- process today's batch
) deduped
WHERE rn = 1
ON CONFLICT (event_id) DO NOTHING; -- idempotent
--
==============================================
===============================
-- SECTION 2: UPSERTS & MERGE (SCD Patterns)
--
==============================================
===============================
-- ── 2-A SCD TYPE 1 — Overwrite (PostgreSQL UPSERT)
───────────────────────
-- Just UPDATE. Old value is lost forever.
-- Use for: corrections, non-analytical columns
UPDATE dim_customers
SET city = 'Dhaka',
updated_at = CURRENT_TIMESTAMP
WHERE customer_id = 42;
-- PostgreSQL ON CONFLICT (INSERT … ON CONFLICT … DO UPDATE)
INSERT INTO dim_customers
(customer_key, customer_id, name, email, updated_at)
SELECT
ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
customer_id,
name,
email,
updated_at
FROM [Link]
ON CONFLICT (customer_key) DO UPDATE SET
name = [Link],
email = [Link],
updated_at = EXCLUDED.updated_at
WHERE EXCLUDED.updated_at > dim_customers.updated_at;
-- MySQL equivalent:
/*
INSERT INTO dim_customers (customer_id, name, email, updated_at)
VALUES (1, 'Alice', 'alice@[Link]', NOW())
ON DUPLICATE KEY UPDATE
name = VALUES(name),
email = VALUES(email),
updated_at = VALUES(updated_at);
*/
-- MySQL idempotent skip:
/*
INSERT IGNORE INTO fact_events (event_id, user_id, event_type)
VALUES (1001, 42, 'click');
*/
-- ── 2-B SCD TYPE 2 — Expire old, insert new row
─────────────────────────
-- Expire old row
UPDATE dim_customers
SET is_current = FALSE,
valid_to = CURRENT_DATE
WHERE customer_id = 42
AND is_current = TRUE;
-- Insert new version
INSERT INTO dim_customers
(customer_key, customer_id, city, valid_from, valid_to, is_current)
VALUES
((SELECT MAX(customer_key) + 1 FROM dim_customers),
42, 'Chittagong', CURRENT_DATE, '9999-12-31', TRUE);
-- ── 2-C MERGE (ANSI SQL / Snowflake / SQL Server / BigQuery)
─────────────
MERGE INTO dim_customers t
USING (
SELECT * FROM [Link]
) s ON t.customer_id = s.customer_id
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET
[Link] = [Link],
[Link] = [Link],
t.updated_at = s.updated_at
WHEN NOT MATCHED THEN
INSERT (customer_id, name, email, created_at, updated_at)
VALUES (s.customer_id, [Link], [Link], s.created_at, s.updated_at)
WHEN NOT MATCHED BY SOURCE AND t.is_current = FALSE THEN
UPDATE SET t.is_current = TRUE; -- reactivate soft-deleted
--
==============================================
===============================
-- SECTION 3: DATA QUALITY CHECKS
--
==============================================
===============================
-- ── 3-A COMPREHENSIVE DQ SUITE
───────────────────────────────────────────
WITH dq_checks AS (
SELECT
-- Null checks
COUNT(*) FILTER (WHERE order_id IS NULL) AS null_order_ids,
COUNT(*) FILTER (WHERE customer_id IS NULL) AS null_customers,
COUNT(*) FILTER (WHERE amount IS NULL) AS null_amounts,
-- Duplicate check
COUNT(*) - COUNT(DISTINCT order_id) AS duplicate_orders,
-- Range / business rule checks
COUNT(*) FILTER (WHERE amount < 0) AS negative_amounts,
COUNT(*) FILTER (WHERE amount > 10000000) AS suspicious_large,
COUNT(*) FILTER (WHERE qty <= 0) AS non_positive_qty,
-- Referential integrity: orders without a matching customer
COUNT(*) FILTER (
WHERE customer_id NOT IN (SELECT customer_id FROM [Link])
) AS orphan_orders,
-- Total
COUNT(*) AS total_rows
FROM [Link]
SELECT
*,
ROUND(100.0 * duplicate_orders / NULLIF(total_rows, 0), 2) AS dup_pct,
ROUND(100.0 * null_amounts / NULLIF(total_rows, 0), 2) AS null_amt_pct
FROM dq_checks;
-- ── 3-B COLUMN PROFILE (completeness + distribution)
─────────────────────
SELECT
COUNT(*) AS total_rows,
COUNT(amount) AS non_null,
COUNT(*) - COUNT(amount) AS nulls,
ROUND(100.0 * (COUNT(*) - COUNT(amount))
/ NULLIF(COUNT(*), 0), 2) AS null_pct,
COUNT(DISTINCT amount) AS distinct_vals,
MIN(amount) AS min_val,
MAX(amount) AS max_val,
ROUND(AVG(amount)::NUMERIC, 2) AS mean_val,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_val
FROM [Link];
-- ── 3-C ROW-HASH CHANGE DETECTION
────────────────────────────────────────
-- Detect ANY changed column without knowing which one changed
SELECT s.*,
MD5(CONCAT([Link], [Link], [Link], [Link])) AS row_hash
FROM [Link] s
WHERE MD5(CONCAT([Link], [Link], [Link], [Link]))
<> (
SELECT row_hash
FROM dim_customers t
WHERE t.customer_id = s.customer_id
AND t.is_current = TRUE
);
-- ── 3-D CHANGE-TYPE DETECTION (new / updated / deleted)
──────────────────
-- Sources rarely hard-delete. Use a deleted_at column or sync a tombstone
log.
SELECT s.order_id,
CASE
WHEN s.deleted_at IS NOT NULL THEN 'deleted'
WHEN t.order_id IS NULL THEN 'new'
ELSE 'updated'
END AS change_type
FROM [Link] s
LEFT JOIN fact_orders t ON s.order_id = t.order_id;
-- ── 3-E STATISTICAL ANOMALY DETECTION (Z-score)
─────────────────────────
WITH daily_revenue AS (
SELECT
DATE_TRUNC('day', created_at) AS day,
SUM(amount) AS daily_revenue
FROM [Link]
GROUP BY 1
),
stats AS (
SELECT AVG(daily_revenue) AS mean,
STDDEV(daily_revenue) AS stddev
FROM daily_revenue
),
scored AS (
SELECT day,
daily_revenue,
ROUND(
(daily_revenue - [Link]) / NULLIF([Link], 0),
2) AS z_score
FROM daily_revenue, stats s
SELECT * FROM scored
WHERE ABS(z_score) > 3 -- > 3 std deviations = anomaly
ORDER BY ABS(z_score) DESC;
--
==============================================
===============================
-- SECTION 4: DIMENSION TABLES — POPULATE
--
==============================================
===============================
-- ── 4-A DATE DIMENSION (2020 → 2030)
─────────────────────────────────────
CREATE TABLE dim_date AS
WITH dates AS (
SELECT GENERATE_SERIES(
'2020-01-01'::DATE,
'2030-12-31'::DATE,
'1 day'::INTERVAL
)::DATE AS dt
SELECT
TO_CHAR(dt, 'YYYYMMDD')::INT AS date_key,
dt AS full_date,
EXTRACT(DOW FROM dt)::INT AS day_of_week,
TO_CHAR(dt, 'Day') AS day_name,
EXTRACT(DAY FROM dt)::INT AS day_of_month,
EXTRACT(DOY FROM dt)::INT AS day_of_year,
EXTRACT(WEEK FROM dt)::INT AS week_of_year,
EXTRACT(MONTH FROM dt)::INT AS month_num,
TO_CHAR(dt, 'Month') AS month_name,
EXTRACT(QUARTER FROM dt)::INT AS quarter,
EXTRACT(YEAR FROM dt)::INT AS year,
EXTRACT(DOW FROM dt) IN (0, 6) AS is_weekend,
dt = (DATE_TRUNC('month', dt) + INTERVAL '1 month'
- INTERVAL '1 day')::DATE AS is_month_end
FROM dates;
CREATE UNIQUE INDEX ON dim_date (date_key);
-- ── 4-B CUSTOMER DIMENSION — SURROGATE KEY GENERATION
────────────────────
-- Never use source system IDs as dim keys.
-- Source IDs change, get reused, or conflict across systems.
INSERT INTO dim_customers
(customer_key, customer_id, name, email, city, is_current, valid_from,
created_at, updated_at)
SELECT
ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
customer_id AS natural_key,
name,
email,
city,
TRUE,
CURRENT_DATE,
created_at,
updated_at
FROM [Link]
ON CONFLICT (customer_key) DO NOTHING;
-- ── 4-C REGION DIMENSION
─────────────────────────────────────────────────
INSERT INTO dim_region (region)
SELECT DISTINCT region FROM [Link]
ON CONFLICT (region) DO NOTHING;
--
==============================================
===============================
-- SECTION 5: FACT TABLE LOAD
--
==============================================
===============================
INSERT INTO fact_orders
(order_id, date_key, customer_key, product_key, region_key, amount, qty,
status)
SELECT
o.order_id,
TO_CHAR(o.created_at::DATE, 'YYYYMMDD')::INT AS date_key,
dc.customer_key,
dp.product_key,
dr.region_key,
[Link],
[Link],
[Link]
FROM [Link] o
JOIN dim_customers dc ON dc.customer_id = o.customer_id AND
dc.is_current = TRUE
JOIN dim_products dp ON dp.product_id = o.product_id
JOIN dim_region dr ON [Link] = [Link]
ON CONFLICT (order_id) DO NOTHING;
--
==============================================
===============================
-- SECTION 6: MATERIALIZED VIEW — PRE-AGGREGATED DAILY REVENUE
--
==============================================
===============================
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT
DATE_TRUNC('day', created_at) AS day,
region,
SUM(amount) AS revenue,
COUNT(*) AS order_count
FROM [Link]
GROUP BY 1, 2;
CREATE UNIQUE INDEX ON mv_daily_revenue (day, region);
-- Refresh without locking (requires unique index):
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
--
==============================================
===============================
-- SECTION 7: CORE ANALYTICAL QUERIES
--
==============================================
===============================
-- ── 7-A BASIC FILTERING (SQL execution order reminder)
───────────────────
-- Written order: SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER
BY → LIMIT
-- Execution order: FROM → WHERE → GROUP BY → HAVING → SELECT →
ORDER BY → LIMIT
SELECT order_id, customer_id, amount, status
FROM [Link]
WHERE status = 'delivered'
AND amount > 100
AND created_at >= '2024-01-01'
ORDER BY amount DESC
LIMIT 20;
-- ── 7-B STRING FUNCTIONS
─────────────────────────────────────────────────
SELECT
UPPER(name) AS name_upper,
LOWER(email) AS email_lower,
LENGTH(name) AS name_len,
TRIM(' ShopBD ') AS trimmed,
SUBSTRING(name, 1, 3) AS name_abbr,
CONCAT(name, ' — ', city) AS label,
REPLACE(city, 'Old', 'New') AS updated_city
FROM dim_customers
WHERE is_current = TRUE;
-- ── 7-C DATE FUNCTIONS
────────────────────────────────────────────────────
SELECT
NOW() AS current_ts,
CURRENT_DATE AS today,
EXTRACT(YEAR FROM created_at) AS order_year,
DATE_TRUNC('month', created_at) AS order_month_start,
created_at + INTERVAL '7 days' AS one_week_later,
CURRENT_DATE - created_at::DATE AS days_since_order
FROM [Link]
LIMIT 5;
-- ── 7-D JOINS
────────────────────────────────────────────────────────────
-- INNER JOIN
SELECT o.order_id, [Link] AS customer, [Link] AS product
FROM fact_orders o
INNER JOIN dim_customers c ON c.customer_key = o.customer_key AND
c.is_current = TRUE
INNER JOIN dim_products p ON p.product_key = o.product_key;
-- LEFT JOIN — customers who never ordered
SELECT [Link], o.order_id
FROM dim_customers c
LEFT JOIN fact_orders o ON o.customer_key = c.customer_key
WHERE o.order_id IS NULL
AND c.is_current = TRUE;
-- SELF JOIN — employee-manager hierarchy
SELECT [Link] AS employee, [Link] AS manager
FROM dim_customers e
LEFT JOIN dim_customers m ON e.customer_id = m.customer_id -- illustrative
LIMIT 10;
-- Multi-table join
SELECT o.order_id, [Link], [Link] AS product, d.full_date, [Link]
FROM fact_orders o
JOIN dim_customers c ON c.customer_key = o.customer_key AND c.is_current
= TRUE
JOIN dim_products p ON p.product_key = o.product_key
JOIN dim_date d ON d.date_key = o.date_key
JOIN dim_region r ON r.region_key = o.region_key
WHERE [Link] = 'delivered';
-- ── 7-E AGGREGATION
──────────────────────────────────────────────────────
-- Basic GROUP BY
SELECT
[Link],
COUNT(*) AS order_count,
SUM([Link]) AS total_revenue,
AVG([Link]) AS avg_order_value
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY [Link]
ORDER BY total_revenue DESC;
-- HAVING — regions with > 500 orders
SELECT [Link], COUNT(*) AS n
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY [Link]
HAVING COUNT(*) > 500
ORDER BY n DESC;
-- ROLLUP — subtotals and grand total
SELECT [Link], [Link],
SUM([Link]) AS revenue
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
JOIN dim_products p ON p.product_key = o.product_key
GROUP BY ROLLUP([Link], [Link]);
-- CASE pivot — gender/segment breakdown by region
SELECT
[Link],
SUM(CASE WHEN [Link] = 'delivered' THEN [Link] ELSE 0 END) AS
delivered_revenue,
SUM(CASE WHEN [Link] = 'returned' THEN [Link] ELSE 0 END) AS
returned_revenue,
AVG(CASE WHEN [Link] = 'delivered' THEN [Link] END) AS
avg_delivered
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY [Link];
-- ── 7-F SET OPERATIONS
───────────────────────────────────────────────────
-- Active customers in both 2023 and 2024:
SELECT customer_id FROM fact_orders
WHERE date_key BETWEEN 20230101 AND 20231231
INTERSECT
SELECT customer_id FROM fact_orders
WHERE date_key BETWEEN 20240101 AND 20241231;
-- Customers who ordered in 2023 but NOT 2024 (lapsed):
SELECT customer_id FROM fact_orders
WHERE date_key BETWEEN 20230101 AND 20231231
EXCEPT
SELECT customer_id FROM fact_orders
WHERE date_key BETWEEN 20240101 AND 20241231;
-- All customer names from two sources:
SELECT name FROM dim_customers WHERE is_current = TRUE
UNION -- removes duplicates
SELECT name FROM [Link];
SELECT name FROM dim_customers WHERE is_current = TRUE
UNION ALL -- keeps duplicates, faster
SELECT name FROM [Link];
--
==============================================
===============================
-- SECTION 8: WINDOW FUNCTIONS
--
==============================================
===============================
-- ── 8-A RANK / DENSE_RANK — Top-N per group
──────────────────────────────
-- Window function anatomy:
-- FUNCTION() OVER (
-- PARTITION BY col -- like GROUP BY but keeps all rows
-- ORDER BY col -- defines order within window
-- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- )
WITH ranked AS (
SELECT
[Link],
[Link],
[Link],
DENSE_RANK() OVER (
PARTITION BY [Link]
ORDER BY [Link] DESC
) AS rnk
FROM fact_orders o
JOIN dim_customers c ON c.customer_key = o.customer_key AND c.is_current
= TRUE
JOIN dim_region r ON r.region_key = o.region_key
SELECT * FROM ranked WHERE rnk <= 3;
-- ── 8-B LAG / LEAD — Month-over-Month
────────────────────────────────────
SELECT
DATE_TRUNC('month', d.full_date) AS month,
SUM([Link]) AS revenue,
LAG(SUM([Link])) OVER (ORDER BY DATE_TRUNC('month', d.full_date))
AS prev_month_revenue,
SUM([Link])
- LAG(SUM([Link])) OVER (ORDER BY DATE_TRUNC('month', d.full_date))
AS mom_delta
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
GROUP BY 1
ORDER BY 1;
-- ── 8-C RUNNING TOTAL
────────────────────────────────────────────────────
SELECT
o.order_id,
[Link],
[Link],
SUM([Link]) OVER (
PARTITION BY o.customer_key
ORDER BY d.full_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
JOIN dim_customers c ON c.customer_key = o.customer_key AND c.is_current
= TRUE;
-- ── 8-D FIRST_VALUE — Best product per region
────────────────────────────
SELECT
[Link],
[Link] AS product,
[Link],
FIRST_VALUE([Link]) OVER (
PARTITION BY [Link]
ORDER BY [Link] DESC
) AS best_selling_product_in_region
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
JOIN dim_products p ON p.product_key = o.product_key;
-- ── 8-E MOVING AVERAGE (7-week)
──────────────────────────────────────────
WITH weekly AS (
SELECT
DATE_TRUNC('week', d.full_date) AS week,
SUM([Link]) AS revenue
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
GROUP BY 1
SELECT
week,
revenue,
AVG(revenue) OVER (
ORDER BY week
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_7wk_avg
FROM weekly
ORDER BY week;
-- ── 8-F PERCENTILE / MEDIAN
──────────────────────────────────────────────
SELECT
[Link],
PERCENTILE_CONT(0.5)
WITHIN GROUP (ORDER BY [Link]) AS median_order_value,
PERCENTILE_CONT(0.9)
WITHIN GROUP (ORDER BY [Link]) AS p90_order_value
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY [Link];
-- ── 8-G PERCENT OF TOTAL + CUMULATIVE PERCENT
────────────────────────────
SELECT
[Link],
SUM([Link]) AS revenue,
ROUND(100.0 * SUM([Link]) / SUM(SUM([Link])) OVER (), 2)
AS pct_of_total,
ROUND(100.0 * SUM(SUM([Link])) OVER (
ORDER BY SUM([Link]) DESC
) / SUM(SUM([Link])) OVER (), 2) AS cumulative_pct
FROM fact_orders o
JOIN dim_products p ON p.product_key = o.product_key
GROUP BY [Link]
ORDER BY revenue DESC;
--
==============================================
===============================
-- SECTION 9: CTEs & SUBQUERIES
--
==============================================
===============================
-- ── 9-A SCALAR SUBQUERY
──────────────────────────────────────────────────
SELECT
[Link],
[Link],
(SELECT AVG(amount) FROM fact_orders) AS platform_avg_order,
[Link] - (SELECT AVG(amount) FROM fact_orders) AS diff_from_avg
FROM fact_orders o
JOIN dim_customers c ON c.customer_key = o.customer_key AND c.is_current
= TRUE;
-- ── 9-B SUBQUERY IN WHERE
────────────────────────────────────────────────
-- Orders above platform average
SELECT * FROM fact_orders
WHERE amount > (SELECT AVG(amount) FROM fact_orders);
-- Customers in Dhaka-region orders
SELECT [Link]
FROM dim_customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM fact_orders o
JOIN dim_region r ON r.region_key = o.region_key
WHERE [Link] = 'Dhaka'
);
-- ── 9-C EXISTS (semi-join)
────────────────────────────────────────────────
SELECT [Link]
FROM dim_customers c
WHERE c.is_current = TRUE
AND EXISTS (
SELECT 1
FROM fact_orders o
WHERE o.customer_key = c.customer_key
AND [Link] > 5000
);
-- ── 9-D CHAINED CTEs
─────────────────────────────────────────────────────
WITH
orders_2024 AS (
SELECT * FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
WHERE [Link] = 2024
),
order_totals AS (
SELECT customer_key, SUM(amount) AS total
FROM orders_2024
GROUP BY customer_key
),
top_customers AS (
SELECT customer_key, total
FROM order_totals
WHERE total > 10000
SELECT [Link], [Link]
FROM dim_customers c
JOIN top_customers tc ON c.customer_key = tc.customer_key
WHERE c.is_current = TRUE
ORDER BY [Link] DESC;
-- ── 9-E RECURSIVE CTE — Org Hierarchy
────────────────────────────────────
-- Illustrative: customer referral tree (customer_id refers a referrer_id)
WITH RECURSIVE referral_tree AS (
-- Anchor: top-level (no referrer)
SELECT customer_id, name, NULL::BIGINT AS referred_by, 0 AS level
FROM dim_customers
WHERE is_current = TRUE
AND customer_id NOT IN (SELECT DISTINCT customer_id FROM
dim_customers WHERE customer_id IS NOT NULL)
-- (simplified for illustration)
UNION ALL
-- Recursive: add referrals
SELECT c.customer_id, [Link], rt.customer_id AS referred_by, [Link] + 1
FROM dim_customers c
JOIN referral_tree rt ON c.customer_id = rt.customer_id -- join on referral field
WHERE c.is_current = TRUE
SELECT * FROM referral_tree ORDER BY level, name;
--
==============================================
===============================
-- SECTION 10: SEMI-STRUCTURED DATA (JSON)
--
==============================================
===============================
-- ── 10-A EXTRACT FROM JSONB
──────────────────────────────────────────────
SELECT
payload->>'user_id' AS user_id,
payload->>'action' AS action,
(payload->>'amount')::DECIMAL AS amount,
payload->'metadata'->>'source' AS source
FROM [Link];
-- Check key existence
SELECT * FROM [Link]
WHERE payload ? 'discount_code';
-- ── 10-B UNNEST ARRAY IN JSONB (BigQuery syntax note)
────────────────────
-- BigQuery: stores arrays natively, use UNNEST
/*
SELECT order_id,
item.product_id,
[Link]
FROM `[Link]`,
UNNEST(line_items) AS item
WHERE DATE(created_at) = '2024-03-28';
*/
-- Snowflake VARIANT / FLATTEN equivalent:
/*
SELECT order_id, [Link]:product_id::INT
FROM [Link],
TABLE(FLATTEN(line_items)) f;
*/
-- MySQL JSON extraction:
/*
SELECT
JSON_EXTRACT(payload, '$.user_id') AS user_id,
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.action')) AS action,
JSON_LENGTH(payload->>'$.items') AS item_count
FROM [Link];
*/
--
==============================================
===============================
-- SECTION 11: ADVANCED ANALYTICS
--
==============================================
===============================
-- ── 11-A SCD TYPE 2 — Point-in-time query
────────────────────────────────
-- What did the customer's city look like on the order date?
SELECT
f.order_id,
[Link],
[Link] AS customer_city_at_order_time
FROM fact_orders f
JOIN dim_customers d
ON d.customer_id = f.customer_key -- join on natural key
AND f.date_key::TEXT::DATE
BETWEEN d.valid_from AND d.valid_to;
-- ── 11-B FILL DATE GAPS IN TIME SERIES
───────────────────────────────────
SELECT
[Link],
COALESCE([Link], 0) AS revenue
FROM GENERATE_SERIES(
'2024-01-01'::DATE,
'2024-12-31'::DATE,
'1 day'
) gs(day)
LEFT JOIN mv_daily_revenue r ON [Link] = [Link]
ORDER BY [Link];
-- ── 11-C LATERAL JOIN — Top 3 orders per customer
───────────────────────
SELECT [Link], top_orders.*
FROM dim_customers c,
LATERAL (
SELECT o.order_id, [Link], [Link]
FROM fact_orders o
WHERE o.customer_key = c.customer_key
ORDER BY [Link] DESC
LIMIT 3
) top_orders
WHERE c.is_current = TRUE;
-- ── 11-D SESSION ANALYSIS (30-minute gap rule)
────────────────────────────
WITH gaps AS (
SELECT
user_id,
event_type,
occurred_at,
CASE
WHEN occurred_at
- LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at)
> INTERVAL '30 minutes'
OR LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at) IS
NULL
THEN 1
ELSE 0
END AS is_new_session
FROM fact_events
),
sessions AS (
SELECT *,
SUM(is_new_session) OVER (
PARTITION BY user_id
ORDER BY occurred_at
) AS session_id
FROM gaps
SELECT
user_id,
session_id,
MIN(occurred_at) AS session_start,
MAX(occurred_at) AS session_end,
COUNT(*) AS events_in_session,
MAX(occurred_at) - MIN(occurred_at) AS session_duration
FROM sessions
GROUP BY user_id, session_id
ORDER BY user_id, session_id;
-- ── 11-E COHORT RETENTION ANALYSIS
───────────────────────────────────────
WITH cohorts AS (
SELECT
customer_key,
DATE_TRUNC('month', MIN(d.full_date)) AS cohort_month
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
GROUP BY customer_key
),
activity AS (
SELECT
o.customer_key,
DATE_TRUNC('month', d.full_date) AS activity_month,
c.cohort_month
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
JOIN cohorts c ON c.customer_key = o.customer_key
SELECT
cohort_month,
EXTRACT(MONTH FROM AGE(activity_month, cohort_month))::INT AS
months_since_join,
COUNT(DISTINCT customer_key) AS active_customers
FROM activity
GROUP BY cohort_month, activity_month
ORDER BY cohort_month, months_since_join;
-- ── 11-F YEAR-OVER-YEAR REVENUE
──────────────────────────────────────────
SELECT
[Link],
SUM([Link]) AS revenue,
LAG(SUM([Link])) OVER (ORDER BY [Link]) AS prev_year_revenue,
SUM([Link])
- LAG(SUM([Link])) OVER (ORDER BY [Link]) AS yoy_delta,
ROUND(100.0 * (
SUM([Link])
- LAG(SUM([Link])) OVER (ORDER BY [Link])
) / NULLIF(LAG(SUM([Link])) OVER (ORDER BY [Link]), 0), 2)
AS yoy_pct_change
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
GROUP BY [Link]
ORDER BY [Link];
-- ── 11-G PIVOT — Regional Monthly Revenue
────────────────────────────────
SELECT
DATE_TRUNC('month', d.full_date)::DATE AS month,
SUM(CASE WHEN [Link] = 'Dhaka' THEN [Link] END) AS dhaka,
SUM(CASE WHEN [Link] = 'Chittagong' THEN [Link] END) AS
chittagong,
SUM(CASE WHEN [Link] = 'Sylhet' THEN [Link] END) AS sylhet
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY 1
ORDER BY 1;
-- ── 11-H UNPIVOT — Convert wide pivot back to tall
───────────────────────
-- Useful for ELT loading
WITH pivoted AS (
SELECT
DATE_TRUNC('month', d.full_date)::DATE AS month,
SUM(CASE WHEN [Link] = 'Dhaka' THEN [Link] END) AS dhaka,
SUM(CASE WHEN [Link] = 'Chittagong' THEN [Link] END) AS
chittagong,
SUM(CASE WHEN [Link] = 'Sylhet' THEN [Link] END) AS sylhet
FROM fact_orders o
JOIN dim_date d ON d.date_key = o.date_key
JOIN dim_region r ON r.region_key = o.region_key
GROUP BY 1
SELECT month, 'Dhaka' AS region, dhaka AS revenue FROM pivoted
UNION ALL
SELECT month, 'Chittagong' AS region, chittagong AS revenue FROM pivoted
UNION ALL
SELECT month, 'Sylhet' AS region, sylhet AS revenue FROM pivoted
ORDER BY month, region;
-- ── 11-I SEQUENCE / FUNNEL (ARRAY_AGG)
───────────────────────────────────
SELECT
user_id,
ARRAY_AGG(event_type ORDER BY occurred_at) AS event_sequence,
COUNT(*) AS total_events
FROM fact_events
GROUP BY user_id
ORDER BY total_events DESC
LIMIT 20;
-- Unnest sequence back to rows
SELECT user_id, UNNEST(event_sequence) AS event_step
FROM (
SELECT user_id,
ARRAY_AGG(event_type ORDER BY occurred_at) AS event_sequence
FROM fact_events
GROUP BY user_id
) seq;
-- SQL Server STRING_AGG equivalent:
/*
SELECT customer_id,
STRING_AGG(product_id, ',')
WITHIN GROUP (ORDER BY created_at) AS products
FROM fact_orders GROUP BY customer_id;
*/
-- MySQL GROUP_CONCAT equivalent:
/*
SELECT customer_id,
GROUP_CONCAT(product_id ORDER BY created_at SEPARATOR ',') AS
product_sequence
FROM fact_orders GROUP BY customer_id;
*/
--
==============================================
===============================
-- SECTION 12: QUERY OPTIMISATION (notes + patterns)
--
==============================================
===============================
-- ── 12-A EXPLAIN ANALYZE
─────────────────────────────────────────────────
-- PostgreSQL: see actual timings
EXPLAIN ANALYZE
SELECT customer_key, SUM(amount)
FROM fact_orders
WHERE date_key >= 20240101
GROUP BY customer_key;
-- BigQuery: estimated bytes scanned (add --dry_run flag in bq CLI)
-- Snowflake: query profile in UI
-- Key metrics: Seq Scan (bad) vs Index Scan (good)
-- Hash Join vs Nested Loop vs Merge Join
-- Rows Removed by Filter (are indexes working?)
-- ── 12-B BIGQUERY — Partition pruning (cost saving)
─────────────────────
-- Always filter on partition column to save cost
/*
SELECT user_id, event_type, COUNT(*)
FROM `[Link]`
WHERE DATE(_PARTITIONTIME) = '2024-03-28'
GROUP BY 1, 2;
*/
-- BigQuery INFORMATION_SCHEMA — table sizes and recent query costs
/*
SELECT table_name, row_count, size_bytes
FROM `[Link].INFORMATION_SCHEMA.TABLE_STORAGE`;
SELECT query, total_bytes_processed, total_slot_ms
FROM `region-us.INFORMATION_SCHEMA.JOBS_BY_PROJECT`
WHERE creation_time > TIMESTAMP_SUB(NOW(), INTERVAL 1 DAY)
ORDER BY total_bytes_processed DESC
LIMIT 20;
*/
-- ── 12-C SNOWFLAKE — TIME TRAVEL + CLONE
────────────────────────────────
-- Query data as it was 3 hours ago:
/*
SELECT * FROM fact_orders
AT (OFFSET => -60*60*3); -- 3 hrs in seconds
*/
-- Restore accidentally deleted table:
/*
CREATE TABLE fact_orders_restored CLONE fact_orders
BEFORE (STATEMENT => '<query_id>');
*/
-- Zero-copy clone for dev/testing:
/*
CREATE TABLE dev.fact_orders CLONE prod.fact_orders;
*/
-- ── 12-D SNOWFLAKE STREAMS + TASKS (CDC)
────────────────────────────────
/*
CREATE STREAM orders_stream ON TABLE fact_orders;
SELECT *, METADATA$ACTION, METADATA$ISUPDATE
FROM orders_stream;
CREATE TASK process_orders_task
WAREHOUSE = compute_wh
SCHEDULE = '5 MINUTE'
AS
MERGE INTO target USING orders_stream ...;
*/
--
==============================================
===============================
-- SECTION 13: TRANSACTIONS & DML
--
==============================================
===============================
-- ── 13-A ACID TRANSACTION
────────────────────────────────────────────────
BEGIN;
UPDATE dim_customers SET city = 'Dhaka', updated_at =
CURRENT_TIMESTAMP WHERE customer_id = 101;
UPDATE dim_customers SET city = 'Sylhet', updated_at =
CURRENT_TIMESTAMP WHERE customer_id = 102;
UPDATE dim_customers SET city = 'Khulna', updated_at =
CURRENT_TIMESTAMP WHERE customer_id = 103;
COMMIT;
-- ROLLBACK; -- uncomment to undo
-- ── 13-B SQL SERVER PAGINATION
───────────────────────────────────────────
-- SQL Server:
/*
SELECT * FROM fact_orders
ORDER BY date_key DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
SELECT TOP 100 * FROM fact_orders ORDER BY amount DESC;
*/
-- SQL Server safe type casting:
/*
SELECT
TRY_CAST(raw_amount AS DECIMAL(12,2)) AS amount,
ISNULL(email, 'unknown') AS email,
GETDATE() AS run_time,
FORMAT(created_at, 'yyyy-MM-dd') AS date_str
FROM [Link];
*/
-- SQL Server SPLIT + STRING_AGG:
/*
SELECT value AS tag FROM STRING_SPLIT('etl,sql,python,power_bi', ',');
*/
-- SQL Server temp tables:
/*
CREATE TABLE #staging_orders (order_id INT, amount DECIMAL(12,2));
DECLARE @summary TABLE (region VARCHAR(50), total DECIMAL(15,2));
*/
--
==============================================
===============================
-- SECTION 14: DDL — ALTER, INDEX, DROP
--
==============================================
===============================
ALTER TABLE dim_customers ADD COLUMN phone VARCHAR(30);
ALTER TABLE dim_customers DROP COLUMN phone;
ALTER TABLE dim_customers RENAME COLUMN name TO full_name;
ALTER TABLE dim_customers ALTER COLUMN full_name TYPE VARCHAR(250);
CREATE INDEX idx_dim_cust_city ON dim_customers (city);
CREATE UNIQUE INDEX idx_dim_cust_email ON dim_customers (email)
WHERE is_current = TRUE;
DROP INDEX IF EXISTS idx_dim_cust_city;
--
==============================================
===============================
-- SECTION 15: ETL OBSERVABILITY — UPDATE RUN LOG
--
==============================================
===============================
UPDATE etl_run_log
SET
status = 'success',
rows_read = (SELECT COUNT(*) FROM raw.orders_source),
rows_written = (SELECT COUNT(*) FROM fact_orders),
finished_at = CURRENT_TIMESTAMP,
duration_sec = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at))
WHERE pipeline_name = 'shopbd_daily_pipeline'
AND status = 'running';
--
==============================================
===============================
-- END OF SCRIPT
-- Covers: DDL · ETL Full/Incremental/MERGE · Data Quality · DW Star Schema
·
-- Date Dim · SCD1 & SCD2 · Window Functions · CTEs · Subqueries ·
-- Set Ops · JSON · Sessionisation · Cohort · YoY · Pivot/Unpivot ·
-- Transactions · Optimisation · BigQuery · Snowflake · SQL Server · MySQL
--
==============================================
===============================