পাঠ ০৬ · ৩০-এর মধ্যে · মডিউল ১

Window function ও CTE

Window functions & Common Table Expressions
৮ মিনিট পড়া মাঝারি · Intermediate Advanced SQL

এই পাঠে যা শিখবেন

  • Window function-এর OVER, PARTITION BY, ORDER BY syntax
  • ৬টি common window function — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER
  • Running total, moving average, period-over-period growth
  • CTE দিয়ে complex query-কে layered, readable করা

১ · Window function — কী ও কেন

Window functionWindow FunctionSQL-এর শক্তিশালী feature যা একটি row-এর "window" বা group-এর উপর হিসাব করে — কিন্তু row collapse করে না। SQL:2003 standard-এ যুক্ত। PostgreSQL, SQL Server, Oracle, MySQL ৮+ — সবার সাপোর্ট। aggregate function-এর extension — কিন্তু একটি critical পার্থক্য:

Aggregate vs Window

Aggregate (GROUP BY): n row → m row (m < n)। collapse।
Window (OVER): n row → n row। প্রতি row পাশে aggregate column যোগ।
একই query-তে individual row + group-level metric দু'টোই।

SQL
-- প্রতিটি অর্ডার + ক্যাটেগরির total revenue (একই query)
SELECT
    o.order_id,
    p.category,
    o.total_amount,
    SUM(o.total_amount) OVER (PARTITION BY p.category) AS category_total,
    o.total_amount * 100.0
        / SUM(o.total_amount) OVER (PARTITION BY p.category) AS pct_of_category
FROM orders o
JOIN products p ON o.product_id = p.product_id
ORDER BY p.category, o.total_amount DESC;

    
প্রতিটি order detail visible — পাশে ক্যাটেগরির total + এই order ক্যাটেগরির শতকরা কত। GROUP BY দিয়ে এটি একই query-তে impossible — দু'টি query + JOIN লাগত।

২ · OVER clause-এর তিন অংশ

OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) — তিনটি optional অংশ:

  • PARTITION BY: "কী দিয়ে group করব" (GROUP BY-এর মতো, কিন্তু collapse নয়)।
  • ORDER BY: "window-এ row-গুলো কী order-এ সাজাব" (running total, rank-এর জন্য essential)।
  • ROWS/RANGE BETWEEN: "কোন row-গুলো current window-এ" (frame definition)।

৩ · Ranking functions — ROW_NUMBER, RANK, DENSE_RANK

SQL
-- প্রতিটি ক্যাটেগরির top-৩ পণ্য (sales-অনুযায়ী)
WITH ranked AS (
    SELECT
        category,
        product_name,
        sales,
        ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn,
        RANK()       OVER (PARTITION BY category ORDER BY sales DESC) AS rnk,
        DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dnk
    FROM products
)
SELECT *
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;

    
Tie behavior — ৩ ranking

Sales: 100, 90, 90, 80
ROW_NUMBER: 1, 2, 3, 4 (arbitrary tie-break)
RANK: 1, 2, 2, 4 (gap after tie)
DENSE_RANK: 1, 2, 2, 3 (no gap)

৪ · LAG ও LEAD — আগের/পরের row দেখা

Time series-এ — "গত মাসের তুলনায় এ মাসে কত বেড়েছে" — এই pattern-এর backbone।

SQL
-- মাসিক revenue + আগের মাসের সাথে তুলনা
WITH monthly AS (
    SELECT
        DATE_TRUNC('month', order_date) AS month,
        SUM(total_amount) AS revenue
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
    month,
    revenue,
    LAG(revenue, 1)  OVER (ORDER BY month) AS prev_month_revenue,
    revenue - LAG(revenue, 1) OVER (ORDER BY month) AS month_over_month_diff,
    ROUND(
        100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY month))
        / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
        2
    ) AS mom_growth_pct
FROM monthly
ORDER BY month;

    
NULLIF: divide-by-zero প্রতিরোধ। প্রথম মাসে LAG NULL — division valid হয়।

৫ · Running total ও moving average

SQL
-- প্রতিটি দিনের সংখ্যা + cumulative running total + ৭-day moving average
WITH daily AS (
    SELECT
        order_date::date AS day,
        SUM(total_amount) AS revenue
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY order_date::date
)
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_revenue,
    AVG(revenue) OVER (
        ORDER BY day
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS rolling_7day_avg
FROM daily
ORDER BY day;

    

Frame keywords: UNBOUNDED PRECEDING (start), CURRENT ROW, n PRECEDING, n FOLLOWING, UNBOUNDED FOLLOWING। Default frame ORDER BY থাকলে RANGE UNBOUNDED PRECEDING TO CURRENT ROW — সাবধান।

৬ · CTE — Common Table Expression

WITH clause দিয়ে query-কে নামকৃত block-এ ভাঙা। Subquery-র চেয়ে readable।

SQL
-- ৩ স্তরের CTE — clean, readable analytics query
WITH
recent_orders AS (
    SELECT *
    FROM orders
    WHERE order_date >= '2024-01-01'
      AND status = 'completed'
),
customer_metrics AS (
    SELECT
        customer_id,
        COUNT(*)            AS order_count,
        SUM(total_amount)   AS total_spent,
        MAX(order_date)     AS last_order
    FROM recent_orders
    GROUP BY customer_id
),
customer_segments AS (
    SELECT
        customer_id,
        order_count,
        total_spent,
        last_order,
        CASE
            WHEN total_spent >= 50000 THEN 'VIP'
            WHEN total_spent >= 10000 THEN 'Regular'
            ELSE 'Casual'
        END AS segment
    FROM customer_metrics
)
SELECT
    segment,
    COUNT(*) AS customers,
    ROUND(AVG(total_spent), 2) AS avg_spend,
    ROUND(AVG(order_count), 2) AS avg_orders
FROM customer_segments
GROUP BY segment
ORDER BY avg_spend DESC;

    
CTE-কে ভাবুন রান্নার "mise en place" — সব ingredient আগে আলাদা bowl-এ ready। Final dish বানানোর সময় প্রতিটি step clear। Subquery দিয়ে একই query "recipe-এ চাল কাঁচা মাছের সাথে মিশিয়ে fry" — সম্ভব কিন্তু painful।

৭ · Recursive CTE — hierarchy traversal

Org chart, category tree, graph traversal — সব recursive CTE-তে।

SQL
-- Employee hierarchy — CEO থেকে সব subordinate
WITH RECURSIVE org_tree AS (
    -- base case: top-level (CEO)
    SELECT id, name, manager_id, 0 AS level, name AS path
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- recursive case: যাদের manager আগের level-এ
    SELECT e.id, e.name, e.manager_id, ot.level + 1,
           ot.path || ' > ' || e.name
    FROM employees e
    JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name, path
FROM org_tree
ORDER BY path;

    
Aggregate vs Window — চাবি পার্থক্য Both compute group metrics; only one collapses rows 📊 Aggregate (GROUP BY) order 1, cat A, 1000 order 2, cat A, 500 order 3, cat B, 800 collapse cat A → 1500 cat B → 800 3 rows → 2 rows 🪟 Window (OVER) order 1, cat A, 1000 order 2, cat A, 500 order 3, cat B, 800 enrich order 1, cat A, 1000, total=1500 order 2, cat A, 500, total=1500 order 3, cat B, 800, total=800 3 rows → 3 rows + new column Aggregate summarize, window enrich। দু'টি ভিন্ন tool — ভিন্ন কাজে।
Window function row সংখ্যা একই রাখে — পাশে নতুন column add করে। Aggregate row collapse করে।

৮ · Common patterns — quick reference

  • Top-N per group: ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) + WHERE rn ≤ N।
  • Running total: SUM(x) OVER (ORDER BY date)।
  • Moving average: AVG(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT)।
  • Period growth: x - LAG(x) OVER (ORDER BY period)।
  • Rolling rank: RANK() OVER (PARTITION BY day ORDER BY revenue DESC)।
  • Running max: MAX(x) OVER (ORDER BY t) — peak-to-date।
  • First/last value: FIRST_VALUE(x), LAST_VALUE(x) OVER (...)।
  • Percentile: NTILE(4) OVER (ORDER BY x) — quartile।

৯ · Performance ও best practice

  • Window function সাধারণত একটি extra sort/scan add করে — large dataset-এ partition column-এ index।
  • একই PARTITION BY/ORDER BY-এর multiple window — DB optimizer একসাথে চালায়; ভিন্ন হলে multiple sort।
  • CTE PostgreSQL ১২+ — inline (optimizer fold করে) by default; পুরোনো version "optimization fence"।
  • Recursive CTE — termination condition থাকতেই হবে; না হলে infinite loop।
  • Massive frame (UNBOUNDED PRECEDING) — memory expensive; necessary না হলে limited frame।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ Window function ২০০৩ থেকে SQL standard-এ। তবু অনেক analyst এড়িয়ে যান, "subquery দিয়ে কাজ চলে"। Window function কেন paradigm shift — এর কোন কাজ subquery-তে impossible বা impractical?

"Window function লাগে না" — junior থেকে mid analyst-দের সবচেয়ে costly belief।

Pure SQL (pre-window) approach:

  • Top-N per group → correlated subquery (slow)।
  • Running total → self-join (quadratic complexity)।
  • Moving average → multiple self-join + GROUP BY (painful)।
  • LAG/LEAD → subquery with row_number trick।

Concrete comparison — running total:

Without window:

SELECT a.day, a.revenue,
       (SELECT SUM(b.revenue)
        FROM daily b
        WHERE b.day <= a.day) AS cumulative
FROM daily a;
-- O(n²) — 10000 rows = 100M operations

With window:

SELECT day, revenue,
       SUM(revenue) OVER (ORDER BY day) AS cumulative
FROM daily;
-- O(n log n) — 10000 rows = 130k operations

৭৭০x speedup on this scale alone।

Window-only operations:

  • Frame definition: "last 7 days" — single SQL clause; subquery-তে date arithmetic + grouping।
  • Multiple windows: 7-day MA + 30-day MA + cumulative — single query; subquery-তে ৩x effort।
  • Gaps and islands: consecutive run detection — window অপরিহার্য।
  • Sessionization: user activity gap-based session — without window, ugly procedural।

Why analyst এড়িয়ে যান:

  1. "Looks complex" — OVER, PARTITION BY syntax intimidating প্রথমে।
  2. Tutorials beginners-এ কম। Hidden gem।
  3. Old DB version — MySQL ৮.০-এর আগে support নেই।
  4. Habit — "subquery দিয়েই করি" mentality।

Real-world impact:

  • Daraz analytics dashboard — pre-window 30-second load, post-window 2-second।
  • bKash daily report — pre-window 4 separate jobs, post-window single CTE।
  • Analyst productivity — same problem, half the LOC, double the readability।

মূল উপলব্ধি: Window function না জানা — "still using horse-and-buggy" সমান। ২০২৪-এর data analyst-এর জন্য non-negotiable। ১ সপ্তাহ-এর deep dive — careerlong reward।

প্র ০২ "OVER clause-এ ORDER BY default frame কী?" — interview question। এই default-এ কী trap আছে এবং কীভাবে এটি bug-এর উৎস?

"Default frame" — SQL-এর সবচেয়ে gotcha-laden topic-গুলোর একটি।

SQL standard default:

  • OVER-এ ORDER BY থাকলে default frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW।
  • OVER-এ ORDER BY না থাকলে default frame: RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (পুরো partition)।

Trap 1 — RANGE vs ROWS:

-- ROWS — শুধু row position-এ
SUM(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- RANGE — value-based, equal value একসাথে
SUM(x) OVER (ORDER BY date RANGE BETWEEN '6 days' PRECEDING AND CURRENT ROW)

Same date-এর multiple row-এ behavior ভিন্ন।

Trap 2 — ORDER BY-এ ties:

SELECT day, revenue,
       SUM(revenue) OVER (ORDER BY day) AS cumul
FROM daily;
-- যদি একই day-এ multiple row → RANGE default-এ একই day-এর সব row একসাথে aggregate
-- ROWS দিয়ে individually:
SUM(revenue) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

Trap 3 — ORDER BY-হীন aggregate:

SELECT id, x, SUM(x) OVER () FROM data;
-- পুরো result-এ same value (grand total)
-- vs
SELECT id, x, SUM(x) OVER (ORDER BY id) FROM data;
-- running total — radically different

Trap 4 — LAST_VALUE puzzle:

-- "শেষ value" পেতে চাই
LAST_VALUE(x) OVER (ORDER BY date)
-- result: প্রতি row-এ "current" — কারণ default frame current row-এ থামে
-- সঠিক:
LAST_VALUE(x) OVER (
    ORDER BY date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

Trap 5 — performance:

  • RANGE default — extra value comparison overhead।
  • ROWS frame সাধারণত দ্রুত।
  • Same partition, different ORDER BY — multiple sort।

Best practices:

  1. Frame সবসময় explicit লিখুন — assume করবেন না।
  2. ROWS use করুন যদি না RANGE specifically দরকার।
  3. LAST_VALUE/FIRST_VALUE use করলে frame অবশ্যই full partition।
  4. Documentation comment — "windowed by 7-day rolling"।

Database differences:

  • PostgreSQL — strict standard।
  • SQL Server ২০১২+ — ROWS support।
  • MySQL ৮+ — ROWS/RANGE support।
  • Older versions — frame ignored (silent bug)।

মূল উপলব্ধি: Default frame bug-এর invisible source। Senior practitioner-রা frame সবসময় explicit লেখেন। "সংক্ষিপ্ত SQL" valid concern নয় — প্রতিটি bug debug-এ ৫ গুণ বেশি সময় খরচ।

প্র ০৩ CTE vs subquery vs temp table vs materialized view — চারটি ভিন্ন approach। কোনটি কখন use করব? Performance ও maintainability-এর tradeoff কী?

SQL architecture-এর চিরন্তন প্রশ্ন।

(১) Subquery (inline):

SELECT *
FROM (
    SELECT customer_id, SUM(amount) AS total
    FROM orders
    GROUP BY customer_id
) sub
WHERE sub.total > 10000;
  • Pro: simple, no extra setup।
  • Con: nested = unreadable, একই subquery use multi-times duplicate।
  • Use: small one-time, single use।

(২) CTE (WITH):

WITH customer_total AS (
    SELECT customer_id, SUM(amount) AS total
    FROM orders
    GROUP BY customer_id
)
SELECT * FROM customer_total WHERE total > 10000;
  • Pro: readable, reusable in same query, recursive support।
  • Con: some DB-তে materialized (slow for huge data); session-end লাগে।
  • Use: complex multi-step query, recursive logic।

(৩) Temp table:

CREATE TEMP TABLE customer_total AS
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id;

CREATE INDEX ON customer_total(customer_id);

SELECT * FROM customer_total WHERE total > 10000;
  • Pro: indexed, multi-query reuse, statistics for optimizer।
  • Con: session-bound, cleanup burden, transaction issue।
  • Use: repeated heavy compute in same session, ETL pipeline।

(৪) Materialized view:

CREATE MATERIALIZED VIEW customer_total AS
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id;

REFRESH MATERIALIZED VIEW customer_total;  -- periodic
SELECT * FROM customer_total WHERE total > 10000;
  • Pro: persistent, indexed, instant query।
  • Con: stale data, refresh strategy, storage cost।
  • Use: dashboard backend, expensive aggregation reused often।

Decision matrix:

  • Single query, simple → subquery।
  • Single query, complex/recursive → CTE।
  • Multiple queries same session → temp table।
  • Reused across sessions → materialized view।
  • Real-time freshness needed → CTE/view।
  • Performance critical, stale OK → materialized view।

Performance pitfall — CTE optimization fence:

  • PostgreSQL ১১ এবং আগে — CTE materialized always; predicate push down হয় না।
  • PostgreSQL ১২+ — inline by default; MATERIALIZED hint দিয়ে force।
  • SQL Server, Oracle — সাধারণত inline।

Modern alternative — dbt models:

  • dbt-এ CTE-based modeling layered।
  • Incremental model — "today's diff only" refresh।
  • Test, doc, lineage — analytics engineering practice।

Bangladesh production patterns:

  • Daraz analytics — CTE-heavy ad-hoc + materialized view dashboard।
  • bKash fraud — temp table in batch ETL।
  • Pathao — dbt + Snowflake stack (recent)।

মূল উপলব্ধি: "One size fits all" নয়। Query lifecycle (one-shot vs reused) + freshness + scale — তিনটি বিচার করে choice। Senior data engineer-এর kit-এ চারটিই থাকে।

প্র ০৪ আপনাকে দিতে হবে — "প্রতিটি গ্রাহকের প্রথম অর্ডার থেকে latest অর্ডার পর্যন্ত journey" — first product, last product, total spent, days as customer। SQL window function দিয়ে কীভাবে?

একটি classic "customer journey" query — RFM analysis-এর precursor।

Approach 1 — Multi-CTE:

WITH first_order AS (
    SELECT
        customer_id,
        product_id    AS first_product,
        order_date    AS first_date,
        total_amount  AS first_amount
    FROM (
        SELECT *,
               ROW_NUMBER() OVER (
                   PARTITION BY customer_id
                   ORDER BY order_date ASC
               ) AS rn
        FROM orders
    ) t
    WHERE rn = 1
),
last_order AS (
    SELECT
        customer_id,
        product_id    AS last_product,
        order_date    AS last_date
    FROM (
        SELECT *,
               ROW_NUMBER() OVER (
                   PARTITION BY customer_id
                   ORDER BY order_date DESC
               ) AS rn
        FROM orders
    ) t
    WHERE rn = 1
),
totals AS (
    SELECT
        customer_id,
        SUM(total_amount) AS total_spent,
        COUNT(*)          AS order_count
    FROM orders
    GROUP BY customer_id
)
SELECT
    c.customer_id,
    c.name,
    fp.name  AS first_product_name,
    lp.name  AS last_product_name,
    fo.first_date,
    lo.last_date,
    (lo.last_date - fo.first_date) AS days_as_customer,
    t.total_spent,
    t.order_count
FROM customers c
JOIN first_order fo  ON c.customer_id = fo.customer_id
JOIN last_order  lo  ON c.customer_id = lo.customer_id
JOIN totals      t   ON c.customer_id = t.customer_id
JOIN products   fp   ON fo.first_product = fp.product_id
JOIN products   lp   ON lo.last_product  = lp.product_id;

Approach 2 — FIRST_VALUE/LAST_VALUE:

WITH journey AS (
    SELECT DISTINCT
        customer_id,
        FIRST_VALUE(product_id) OVER (
            PARTITION BY customer_id ORDER BY order_date ASC
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
        ) AS first_product,
        LAST_VALUE(product_id) OVER (
            PARTITION BY customer_id ORDER BY order_date ASC
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
        ) AS last_product,
        MIN(order_date) OVER (PARTITION BY customer_id) AS first_date,
        MAX(order_date) OVER (PARTITION BY customer_id) AS last_date,
        SUM(total_amount) OVER (PARTITION BY customer_id) AS total_spent,
        COUNT(*) OVER (PARTITION BY customer_id) AS order_count
    FROM orders
)
SELECT *,
       (last_date - first_date) AS days_as_customer
FROM journey;

Note: LAST_VALUE-এর full frame critical।

Approach 3 — JSON aggregation:

SELECT
    customer_id,
    JSONB_AGG(
        JSONB_BUILD_OBJECT('date', order_date, 'product', product_id)
        ORDER BY order_date
    ) AS journey,
    SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id;
-- Full timeline as JSON array

Tradeoffs:

  • Multi-CTE: readable, multi-JOIN cost।
  • Window: single pass, অবলম্বন।
  • JSON: flexible, application-side processing।

Adding richness — RFM:

  • Recency: CURRENT_DATE - last_date।
  • Frequency: order_count।
  • Monetary: total_spent।
  • NTILE(4)-এ R, F, M — segment 1-4।
  • RFM combined → customer segments (champion, at-risk, lost, etc.)।

Real-world consideration:

  • Customer "journey" শুধু order নয় — view, cart, search-ও।
  • Multi-touch attribution — multi-table window।
  • Sessionization — gap detection।
  • Cohort overlay — first_date-এর month-এ partition।

মূল উপলব্ধি: Window function customer analytics-এর primary tool। FIRST_VALUE, LAST_VALUE, MIN/MAX OVER, COUNT OVER — single query-তে customer-360 view। SQL ২০০৩ standard ছাড়া এই kind of analysis painful ছিল।

অনুশীলন

  1. Top-৫ per category: Window function দিয়ে প্রতিটি ক্যাটেগরির top-৫ best-selling পণ্য (revenue অনুযায়ী)।
    WITH product_revenue AS (
        SELECT
            p.category,
            p.product_id,
            p.name,
            SUM(o.total_amount) AS revenue
        FROM orders o
        JOIN products p ON o.product_id = p.product_id
        GROUP BY p.category, p.product_id, p.name
    ),
    ranked AS (
        SELECT *,
               ROW_NUMBER() OVER (
                   PARTITION BY category
                   ORDER BY revenue DESC
               ) AS rn
        FROM product_revenue
    )
    SELECT category, name, revenue
    FROM ranked
    WHERE rn <= 5
    ORDER BY category, rn;
  2. Month-over-month growth: মাসিক revenue, আগের মাসের revenue, এবং MoM growth %।
    WITH monthly AS (
        SELECT
            DATE_TRUNC('month', order_date) AS month,
            SUM(total_amount) AS revenue
        FROM orders
        WHERE order_date >= '2024-01-01'
        GROUP BY DATE_TRUNC('month', order_date)
    )
    SELECT
        month,
        revenue,
        LAG(revenue, 1) OVER (ORDER BY month) AS prev_revenue,
        revenue - LAG(revenue, 1) OVER (ORDER BY month) AS diff,
        ROUND(
            100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY month))
            / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
            2
        ) AS mom_pct
    FROM monthly
    ORDER BY month;

    NULLIF divide-by-zero এড়ায়। প্রথম মাসে LAG NULL — diff/pct-ও NULL, যা সঠিক।

  3. Customer segmentation (RFM-lite): CTE দিয়ে প্রতিটি গ্রাহকের recency (days since last order), frequency (order count), monetary (total spent) — এবং তিন NTILE quartile।
    WITH customer_rfm AS (
        SELECT
            customer_id,
            CURRENT_DATE - MAX(order_date) AS recency_days,
            COUNT(*)                       AS frequency,
            SUM(total_amount)              AS monetary
        FROM orders
        GROUP BY customer_id
    ),
    rfm_quartiles AS (
        SELECT *,
               NTILE(4) OVER (ORDER BY recency_days ASC)  AS r_score,
               NTILE(4) OVER (ORDER BY frequency DESC)    AS f_score,
               NTILE(4) OVER (ORDER BY monetary DESC)     AS m_score
        FROM customer_rfm
    )
    SELECT
        customer_id,
        recency_days,
        frequency,
        monetary,
        r_score, f_score, m_score,
        CASE
            WHEN r_score = 1 AND f_score = 1 AND m_score = 1 THEN 'Champion'
            WHEN r_score <= 2 AND f_score <= 2 THEN 'Loyal'
            WHEN r_score = 4 AND f_score = 4 THEN 'Lost'
            ELSE 'Other'
        END AS segment
    FROM rfm_quartiles
    ORDER BY monetary DESC;

    NTILE(4): ৪টি equal-size bucket-এ split। Score 1 = best, 4 = worst (recency-এ ASC, frequency/monetary DESC)।

আরও পড়ুন

Window function visual guide: LearnSQL cheat sheet প্রিন্ট করে ডেস্কে রাখুন।
পূর্ববর্তী পাঠ
পাঠ ০৫ · GROUP BY