Competitor Scorecard — Evaluate KPI Achievement with Multiple CTEs × Conditional Aggregation
Chaining multiple CTEs (Common Table Expressions) lets you split the transformation from raw data → evaluation → aggregation → ranking into clear stages. In particular, conditional aggregation (SUM(CASE WHEN ...)) is a common pattern for counting “achieved / not achieved” rows by summing a flag column.
WITH eval AS ( SELECT ..., CASE WHEN value >= target THEN 1 ELSE 0 END AS achieved FROM raw_table ), scored AS ( SELECT company_name, SUM(achieved) AS metrics_achieved, ROUND(SUM(achieved) * 100.0 / COUNT(*), 1) AS achievement_rate FROM eval GROUP BY company_name )
FROM. Splitting the logic into stages greatly improves readability and debuggability. A two-stage design—aggregate with GROUP BY inside a CTE, then apply a window function outside—is a standard practice.The competitor_metrics table stores KPI actuals and targets for each company. Calculate each company's KPI achievement count, achievement rate, and overall rank, remembering that a lower churn rate is better. Return company_name, metrics_achieved, total_metrics, achievement_rate, overall_rank, sorted by overall_rank ascending and then company_name ascending.
| company_name | metric_name | value | target |
|---|---|---|---|
| AlphaTech | Revenue growth (%) | 18.8 | 15.0 |
| AlphaTech | Market share (%) | 36.5 | 35.0 |
| AlphaTech | NPS | 42 | 50 |
| AlphaTech | Churn rate (%) | 3.2 | 4.0 |
| BetaSoft | Revenue growth (%) | 19.2 | 15.0 |
| BetaSoft | Market share (%) | 28.0 | 30.0 |
| BetaSoft | NPS | 55 | 50 |
| BetaSoft | Churn rate (%) | 4.8 | 4.0 |
| GammaSys | Revenue growth (%) | 22.2 | 15.0 |
| GammaSys | Market share (%) | 18.0 | 20.0 |
| GammaSys | NPS | 38 | 50 |
| GammaSys | Churn rate (%) | 2.5 | 4.0 |
| DeltaNet | Revenue growth (%) | 14.0 | 15.0 |
| DeltaNet | Market share (%) | 15.0 | 35.0 |
| DeltaNet | NPS | 35 | 50 |
| DeltaNet | Churn rate (%) | 5.5 | 4.0 |
| company_name | metrics_achieved | total_metrics | achievement_rate | overall_rank |
|---|---|---|---|---|
| AlphaTech | 3 | 4 | 75.0 | 1 |
| BetaSoft | 2 | 4 | 50.0 | 2 |
| GammaSys | 2 | 4 | 50.0 | 2 |
| DeltaNet | 0 | 4 | 0.0 | 3 |
WITH eval AS ( SELECT company_name, metric_name, value, target, CASE WHEN metric_name = 'Churn rate (%)' AND value <= target THEN 1 -- Reverse direction: achieved when value <= target WHEN metric_name != 'Churn rate (%)' AND value >= target THEN 1 -- Normal direction: achieved when value >= target ELSE 0 -- Not achieved END AS achieved FROM competitor_metrics ), scored AS ( SELECT company_name, SUM(achieved) AS metrics_achieved, -- Sum the achievement flags COUNT(*) AS total_metrics, ROUND(SUM(achieved) * 100.0 / COUNT(*), 1) AS achievement_rate -- Calculate achievement rate (%) FROM eval GROUP BY company_name ) SELECT company_name, metrics_achieved, total_metrics, achievement_rate, DENSE_RANK() OVER (ORDER BY achievement_rate DESC) AS overall_rank -- Ties share a rank; numbering remains consecutive FROM scored ORDER BY overall_rank, company_name; /* Execution order (logical SQL evaluation order): 1. Define CTE eval → Define the CTE and evaluate achievement flags 2. Define CTE scored → Group rows and evaluate aggregate functions 3. Outer query → Evaluate the DENSE_RANK window function (row count is preserved) 4. ORDER BY → Sort and return the result */
LEGEND
① FROM competitor_metrics
FROM competitor_metricsRead the 16 rows from competitor_metrics. The achieved flag is calculated from the metric_name, value, and target columns.| company_name | metric_name | value | target |
|---|---|---|---|
| AlphaTech | Revenue growth (%) | 18.8 | 15 |
| AlphaTech | Market share (%) | 36.5 | 35 |
| AlphaTech | NPS | 42 | 50 |
| AlphaTech | Churn rate (%) | 3.2 | 4 |
| BetaSoft | Revenue growth (%) | 19.2 | 15 |
| BetaSoft | Market share (%) | 28 | 30 |
| BetaSoft | NPS | 55 | 50 |
| BetaSoft | Churn rate (%) | 4.8 | 4 |
| GammaSys | Revenue growth (%) | 22.2 | 15 |
| GammaSys | Market share (%) | 18 | 20 |
| GammaSys | NPS | 38 | 50 |
| GammaSys | Churn rate (%) | 2.5 | 4 |
| DeltaNet | Revenue growth (%) | 14 | 15 |
| DeltaNet | Market share (%) | 15 | 35 |
| DeltaNet | NPS | 35 | 50 |
| DeltaNet | Churn rate (%) | 5.5 | 4 |
SELECT * FROM eval, which substantially reduces maintenance cost compared with putting everything into one long SELECT.SUM(CASE WHEN condition THEN 1 ELSE 0 END) is a general-purpose pattern for totaling rows that satisfy a condition. COUNT(CASE WHEN condition THEN 1 END) produces the same result, but the SUM pattern is easier to extend with weighting (such as THEN 2) and is common in practice.value >= target condition as other metrics, good performance is marked as not achieved. A standard practice is to store the metric direction (higher is better / lower is better) in the data during design.SELECT SUM(achieved), DENSE_RANK() OVER (...) FROM eval GROUP BY company_name does work, because the window function is applied to the aggregated data after GROUP BY. However, separating the aggregation into a CTE makes the intent clearer and reduces the chance of bugs.SUM(achieved * weight) / SUM(weight) uses the same syntax pattern.Moving Average × Cumulative Revenue — Read Quarterly Trends with ROWS BETWEEN Frame Clauses
Specifying a window-function frame clause (ROWS BETWEEN) gives precise control over how many preceding and following rows are included in an aggregation. A moving average and a cumulative total are representative applications.
| Frame clause | Meaning | Typical use |
|---|---|---|
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | Previous 2 rows through the current row (3 rows total) | 3-period moving average |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | First row through the current row (full cumulative range) | Cumulative revenue / count |
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING | Current row through the last row | Remaining total |
AVG(revenue) OVER ( PARTITION BY company_name ORDER BY quarter ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 3-quarter moving average )
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (cumulative). To target only the most recent N rows, as a moving average does, always specify ROWS BETWEEN.For each company's quarterly revenue in the quarterly_revenue table, calculate a 3-quarter moving average (moving_avg_3q) and cumulative revenue (cumulative_rev). Return company_name, quarter, revenue, moving_avg_3q, cumulative_rev, sorted by company_name ascending and then quarter ascending.
| company_name | quarter | revenue |
|---|---|---|
| AlphaTech | 2024Q1 | 95 |
| AlphaTech | 2024Q2 | 100 |
| AlphaTech | 2024Q3 | 108 |
| AlphaTech | 2024Q4 | 115 |
| BetaSoft | 2024Q1 | 68 |
| BetaSoft | 2024Q2 | 72 |
| BetaSoft | 2024Q3 | 75 |
| BetaSoft | 2024Q4 | 78 |
| GammaSys | 2024Q1 | 38 |
| GammaSys | 2024Q2 | 42 |
| GammaSys | 2024Q3 | 45 |
| GammaSys | 2024Q4 | 50 |
Revenue unit: JPY 100 million
| company_name | quarter | revenue | moving_avg_3q | cumulative_rev |
|---|---|---|---|---|
| AlphaTech | 2024Q1 | 95 | 95.0 | 95 |
| AlphaTech | 2024Q2 | 100 | 97.5 | 195 |
| AlphaTech | 2024Q3 | 108 | 101.0 | 303 |
| AlphaTech | 2024Q4 | 115 | 107.7 | 418 |
| BetaSoft | 2024Q1 | 68 | 68.0 | 68 |
| BetaSoft | 2024Q2 | 72 | 70.0 | 140 |
| BetaSoft | 2024Q3 | 75 | 71.7 | 215 |
| BetaSoft | 2024Q4 | 78 | 75.0 | 293 |
| GammaSys | 2024Q1 | 38 | 38.0 | 38 |
| GammaSys | 2024Q2 | 42 | 40.0 | 80 |
| GammaSys | 2024Q3 | 45 | 41.7 | 125 |
| GammaSys | 2024Q4 | 50 | 45.7 | 175 |
SELECT company_name, quarter, revenue, ROUND( AVG(revenue) OVER ( PARTITION BY company_name -- Independent window for each company ORDER BY quarter ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- Average the previous 2 rows plus the current row ), 1 ) AS moving_avg_3q, SUM(revenue) OVER ( PARTITION BY company_name -- Independent window for each company ORDER BY quarter ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- Cumulative total from the first row through the current row ) AS cumulative_rev FROM quarterly_revenue ORDER BY company_name, quarter; /* Execution order (logical SQL evaluation order): 1. FROM quarterly_revenue → Read the rows 2. AVG/SUM OVER (...) → Evaluate the window functions (row count is preserved) 3. SELECT → Evaluate the columns 4. ORDER BY company_name, quarter → Sort and return the result */
LEGEND
① FROM quarterly_revenue
FROM quarterly_revenueRead the 12 rows from quarterly_revenue. PARTITION BY company_name creates an independent window for each company.| company_name | quarter | revenue |
|---|---|---|
| AlphaTech | 2024Q1 | 95 |
| AlphaTech | 2024Q2 | 100 |
| AlphaTech | 2024Q3 | 108 |
| AlphaTech | 2024Q4 | 115 |
| BetaSoft | 2024Q1 | 68 |
| BetaSoft | 2024Q2 | 72 |
| BetaSoft | 2024Q3 | 75 |
| BetaSoft | 2024Q4 | 78 |
| GammaSys | 2024Q1 | 38 |
| GammaSys | 2024Q2 | 42 |
| GammaSys | 2024Q3 | 45 |
| GammaSys | 2024Q4 | 50 |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means “from the two rows immediately before the current row through the current row,” for at most three rows. Boundary keywords include UNBOUNDED PRECEDING (first row), N PRECEDING, CURRENT ROW, N FOLLOWING, and UNBOUNDED FOLLOWING (last row).ROWS BETWEEN would calculate a cumulative average instead.RANGE BETWEEN 2 PRECEDING AND CURRENT ROW is a value-based frame that targets rows whose values are at least the current value minus 2. It may not behave as intended with the string quarter column. Use ROWS BETWEEN for a row-count frame and RANGE BETWEEN INTERVAL '...' PRECEDING for a date range.Competitor Percentile Analysis — Quantify Market Position with PERCENT_RANK / CUME_DIST
PERCENT_RANK and CUME_DIST both express a row's relative position within a group as a percentage, but their formulas differ.
| Function | Formula | Minimum | Maximum | Interpretation |
|---|---|---|---|---|
PERCENT_RANK() | (rank − 1) / (n − 1) | 0.0 (minimum row) | 1.0 (maximum row) | Share of values smaller than this value |
CUME_DIST() | rank / n | 1/n (minimum row) | 1.0 (maximum row) | Share of values less than or equal to this value |
PERCENT_RANK() OVER ( PARTITION BY category ORDER BY annual_revenue -- Ascending: the maximum is 100% ) * 100 AS pct_rank
For each company's category revenue in the category_performance table, calculate the within-category percentile rank (pct_rank) and cumulative distribution (cume_dist_pct) on a 0–100 scale, rounded to one decimal place. Return category, company_name, annual_revenue, pct_rank, cume_dist_pct, sorted by category ascending and then annual_revenue descending.
| company_name | category | annual_revenue |
|---|---|---|
| AlphaTech | Cloud | 4200 |
| BetaSoft | Cloud | 2800 |
| GammaSys | Cloud | 1500 |
| DeltaNet | Cloud | 800 |
| EpsilonIO | Cloud | 500 |
| BetaSoft | Security | 2200 |
| AlphaTech | Security | 1200 |
| DeltaNet | Security | 1200 |
| GammaSys | Security | 900 |
| EpsilonIO | Security | 600 |
Annual revenue unit: JPY 100 million
| category | company_name | annual_revenue | pct_rank | cume_dist_pct |
|---|---|---|---|---|
| Cloud | AlphaTech | 4200 | 100.0 | 100.0 |
| Cloud | BetaSoft | 2800 | 75.0 | 80.0 |
| Cloud | GammaSys | 1500 | 50.0 | 60.0 |
| Cloud | DeltaNet | 800 | 25.0 | 40.0 |
| Cloud | EpsilonIO | 500 | 0.0 | 20.0 |
| Security | BetaSoft | 2200 | 100.0 | 100.0 |
| Security | AlphaTech | 1200 | 50.0 | 80.0 |
| Security | DeltaNet | 1200 | 50.0 | 80.0 |
| Security | GammaSys | 900 | 25.0 | 40.0 |
| Security | EpsilonIO | 600 | 0.0 | 20.0 |
SELECT category, company_name, annual_revenue, ROUND( (PERCENT_RANK() OVER ( PARTITION BY category -- Independent window for each category ORDER BY annual_revenue -- Ascending: minimum=0%, maximum=100% ) * 100)::numeric, 1 ) AS pct_rank, ROUND( (CUME_DIST() OVER ( PARTITION BY category -- Independent window for each category ORDER BY annual_revenue -- The share of values at or below the current value ) * 100)::numeric, 1 ) AS cume_dist_pct FROM category_performance ORDER BY category, annual_revenue DESC; /* Execution order (logical SQL evaluation order): 1. FROM category_performance → Read the rows 2. PERCENT_RANK/CUME_DIST OVER → Evaluate the window functions (row count is preserved) 3. ROUND(...) → Format the values 4. SELECT → Evaluate the columns 5. ORDER BY category, annual_revenue DESC → Sort and return the result */
LEGEND
① FROM category_performance
FROM category_performanceRead the 10 rows from category_performance, with five companies in each category. The window functions are evaluated with ORDER BY annual_revenue ascending, so the data is shown in ascending order at this stage.| category | company_name | annual_revenue |
|---|---|---|
| Cloud | EpsilonIO | 500 |
| Cloud | DeltaNet | 800 |
| Cloud | GammaSys | 1500 |
| Cloud | BetaSoft | 2800 |
| Cloud | AlphaTech | 4200 |
| Security | EpsilonIO | 600 |
| Security | GammaSys | 900 |
| Security | AlphaTech | 1200 |
| Security | DeltaNet | 1200 |
| Security | BetaSoft | 2200 |
(rank-1)/(n-1). With n=5, rank=1 (minimum) gives 0.0, rank=3 gives 0.5, and rank=5 (maximum) gives 1.0. The minimum percentile is always 0.0 (“the share of values smaller than this value” is 0%), so do not misread it as a “lowest score” in an absolute sense.rank/n. With n=5, even the minimum is 1/5=20%. EpsilonIO's cume_dist=20% means that 20% of companies in the Cloud market have revenue less than or equal to EpsilonIO's. Unlike PERCENT_RANK, it never becomes 0%.(3-1)/4 = 50.0% for both, while CUME_DIST is 4/5 = 80.0% because four rows are at or below them, including the tied rows.WHERE cume_dist_pct >= 0.8 in the outer query to filter to the top 20% of companies, enabling flexible percentile-threshold filtering that is difficult with NTILE.Gap Analysis to the Category Leader — Visualize the Leader Gap with FIRST_VALUE × CTE
FIRST_VALUE() attaches the value from the “first row” of the window defined in the OVER clause to every row. Combined with ORDER BY score DESC, it retrieves the maximum value—the leader's value—in each partition.
| Function | What it returns | Use |
|---|---|---|
FIRST_VALUE(col) | Value from the first row of the window | Category leader's value or name |
LAST_VALUE(col) | Value from the last row of the window* | Category's lowest value |
NTH_VALUE(col,n) | Value from the n-th row of the window | Second- or third-place value |
FIRST_VALUE(company_name) OVER ( PARTITION BY category ORDER BY composite_score DESC -- Descending: the first row has the highest score ) AS leader_name
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (cumulative through the current row), so it always returns “your own value”. To get the final row's value, you need ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.From the product_scores table, calculate the leader company name (leader_name), leader score (leader_score), and score gap to the leader (gap_to_leader) for each category. Use a CTE. Return category, company_name, composite_score, leader_name, leader_score, gap_to_leader, with categories in the specified Cloud, Security, Data Analytics order and composite_score descending within each category.
| company_name | category | composite_score |
|---|---|---|
| AlphaTech | Cloud | 88.5 |
| BetaSoft | Cloud | 79.2 |
| GammaSys | Cloud | 71.0 |
| DeltaNet | Cloud | 65.3 |
| BetaSoft | Security | 91.3 |
| AlphaTech | Security | 74.1 |
| GammaSys | Security | 68.7 |
| DeltaNet | Security | 55.2 |
| AlphaTech | Data Analytics | 83.4 |
| GammaSys | Data Analytics | 80.1 |
| BetaSoft | Data Analytics | 76.8 |
| DeltaNet | Data Analytics | 62.0 |
| category | company_name | composite_score | leader_name | leader_score | gap_to_leader |
|---|---|---|---|---|---|
| Cloud | AlphaTech | 88.5 | AlphaTech | 88.5 | 0.0 |
| Cloud | BetaSoft | 79.2 | AlphaTech | 88.5 | -9.3 |
| Cloud | GammaSys | 71.0 | AlphaTech | 88.5 | -17.5 |
| Cloud | DeltaNet | 65.3 | AlphaTech | 88.5 | -23.2 |
| Security | BetaSoft | 91.3 | BetaSoft | 91.3 | 0.0 |
| Security | AlphaTech | 74.1 | BetaSoft | 91.3 | -17.2 |
| Security | GammaSys | 68.7 | BetaSoft | 91.3 | -22.6 |
| Security | DeltaNet | 55.2 | BetaSoft | 91.3 | -36.1 |
| Data Analytics | AlphaTech | 83.4 | AlphaTech | 83.4 | 0.0 |
| Data Analytics | GammaSys | 80.1 | AlphaTech | 83.4 | -3.3 |
| Data Analytics | BetaSoft | 76.8 | AlphaTech | 83.4 | -6.6 |
| Data Analytics | DeltaNet | 62.0 | AlphaTech | 83.4 | -21.4 |
WITH ranked AS ( SELECT category, company_name, composite_score, FIRST_VALUE(company_name) OVER ( PARTITION BY category -- Independent window for each category ORDER BY composite_score DESC -- Descending: first row has the highest score ) AS leader_name, FIRST_VALUE(composite_score) OVER ( PARTITION BY category -- Get the leader score in the same window ORDER BY composite_score DESC ) AS leader_score FROM product_scores ) SELECT category, company_name, composite_score, leader_name, leader_score, ROUND(composite_score - leader_score, 1) AS gap_to_leader -- Gap from the leader (negative means behind) FROM ranked ORDER BY CASE category WHEN 'Cloud' THEN 1 WHEN 'Security' THEN 2 WHEN 'Data Analytics' THEN 3 END, composite_score DESC, company_name; /* Execution order (logical SQL evaluation order): 1. Define CTE ranked → Evaluate the FIRST_VALUE window functions (row count is preserved) 2. Outer query → Evaluate the gap and format it with ROUND 3. ORDER BY CASE category WHEN 'Cloud' THEN 1 WHEN 'Security' THEN 2 WHEN 'Data Analytics' THEN 3 END, composite_score DESC, company_name → Sort and return the result */
LEGEND
① FROM product_scores
FROM product_scoresRead the 12 rows from product_scores. There are four companies in each of Cloud, Security, and Data Analytics. FIRST_VALUE will identify the leader in each category in the next step.| category | company_name | composite_score |
|---|---|---|
| Cloud | AlphaTech | 88.5 |
| Cloud | BetaSoft | 79.2 |
| Cloud | GammaSys | 71 |
| Cloud | DeltaNet | 65.3 |
| Security | BetaSoft | 91.3 |
| Security | AlphaTech | 74.1 |
| Security | GammaSys | 68.7 |
| Security | DeltaNet | 55.2 |
| Data Analytics | AlphaTech | 83.4 |
| Data Analytics | GammaSys | 80.1 |
| Data Analytics | BetaSoft | 76.8 |
| Data Analytics | DeltaNet | 62 |
ranked CTE leaves the outer query with the concise expression composite_score - leader_score. In practice, extract repeated window expressions into a CTE or VIEW.WHERE gap_to_leader = 0 by adding the condition to the outer query.LAST_VALUE(composite_score) OVER (PARTITION BY category ORDER BY composite_score DESC) with the default frame, the frame is cumulative through the current row, so it always returns “your own score”. To get the lowest score in the category, explicitly specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.MAX(composite_score) OVER (PARTITION BY category) retrieves the maximum score, but not “the company that owns that score”. FIRST_VALUE is required to identify who leads the competition. If only the score is needed and the company name is not, MAX is simpler.Share-Change Trend Analysis — Classify Winners and Losers with an LAG × CASE × SUM OVER CTE Chain
A serial CTE chain organizes a complex data transformation into steps such as “calculate period-over-period change → classify → aggregate”. This question demonstrates how to connect three constructs: LAG, CASE WHEN, and SUM OVER.
WITH step1 AS ( SELECT ..., LAG(val) OVER (PARTITION BY grp ORDER BY t) AS prev_val FROM src ), step2 AS ( SELECT ..., val - prev_val AS delta, CASE WHEN val > prev_val THEN '▲ Growth' ELSE '▼ Decline' END AS trend FROM step1 WHERE prev_val IS NOT NULL -- Exclude the NULL at the first period ) SELECT ..., SUM(CASE WHEN trend = '▲ Growth' THEN 1 ELSE 0 END) OVER (PARTITION BY company_name ORDER BY period) AS expanding_periods FROM step2;
SELECT * FROM step1, layer step2 on top, and aggregate at the end—a gradual development process.The market_share_trend table stores semiannual market share for four companies. Chain two CTEs to calculate ① the period-over-period share change (share_delta), ② the growth/decline label (trend), and ③ the cumulative number of expanding periods (expanding_periods). Exclude the first period (the row where prev_share is NULL). Return company_name, period, market_share_pct, prev_share, share_delta, trend, expanding_periods, sorted by company_name ascending and then period ascending.
| company_name | period | market_share_pct |
|---|---|---|
| AlphaTech | 2023H1 | 38.5 |
| AlphaTech | 2023H2 | 40.2 |
| AlphaTech | 2024H1 | 42.0 |
| BetaSoft | 2023H1 | 30.1 |
| BetaSoft | 2023H2 | 28.5 |
| BetaSoft | 2024H1 | 26.8 |
| DeltaNet | 2023H1 | 13.0 |
| DeltaNet | 2023H2 | 12.3 |
| DeltaNet | 2024H1 | 11.7 |
| GammaSys | 2023H1 | 18.4 |
| GammaSys | 2023H2 | 19.0 |
| GammaSys | 2024H1 | 19.5 |
| company_name | period | market_share_pct | prev_share | share_delta | trend | expanding_periods |
|---|---|---|---|---|---|---|
| AlphaTech | 2023H2 | 40.2 | 38.5 | 1.7 | ▲ Growth | 1 |
| AlphaTech | 2024H1 | 42.0 | 40.2 | 1.8 | ▲ Growth | 2 |
| BetaSoft | 2023H2 | 28.5 | 30.1 | -1.6 | ▼ Decline | 0 |
| BetaSoft | 2024H1 | 26.8 | 28.5 | -1.7 | ▼ Decline | 0 |
| DeltaNet | 2023H2 | 12.3 | 13.0 | -0.7 | ▼ Decline | 0 |
| DeltaNet | 2024H1 | 11.7 | 12.3 | -0.6 | ▼ Decline | 0 |
| GammaSys | 2023H2 | 19.0 | 18.4 | 0.6 | ▲ Growth | 1 |
| GammaSys | 2024H1 | 19.5 | 19.0 | 0.5 | ▲ Growth | 2 |
WITH share_with_prev AS ( SELECT company_name, period, market_share_pct, LAG(market_share_pct) OVER ( PARTITION BY company_name -- Independent window for each company ORDER BY period -- Read the previous period's share in ascending period order ) AS prev_share -- The first period (2023H1) has no previous row, so it is NULL FROM market_share_trend ), deltas AS ( SELECT company_name, period, market_share_pct, prev_share, ROUND(market_share_pct - prev_share, 1) AS share_delta, -- Period-over-period change CASE WHEN market_share_pct > prev_share THEN '▲ Growth' WHEN market_share_pct < prev_share THEN '▼ Decline' ELSE '— Flat' END AS trend FROM share_with_prev WHERE prev_share IS NOT NULL -- Exclude the first-period NULL rows ) SELECT company_name, period, market_share_pct, prev_share, share_delta, trend, SUM(CASE WHEN trend = '▲ Growth' THEN 1 ELSE 0 END) OVER ( PARTITION BY company_name -- Accumulate separately for each company ORDER BY period -- Accumulate the growth flags in period order ) AS expanding_periods FROM deltas ORDER BY company_name, period; /* Execution order (logical SQL evaluation order): 1. Define CTE share_with_prev → Evaluate LAG as a window function (row count is preserved) 2. Define CTE deltas → Filter with WHERE and evaluate the change and label 3. Outer query → Evaluate the SUM OVER window function (row count is preserved) 4. ORDER BY company_name, period → Sort and return the result */
LEGEND
① CTE share_with_prev: FROM + LAG()
WITH share_with_prev AS ( ... LAG(...) OVER(...) AS prev_share )Read the 12 rows from market_share_trend and attach the previous-period share with LAG() in company-and-period order. Each company's first period (2023H1) has no previous row, so prev_share=NULL (four rows).| company_name | period | market_share_pct | ▸ prev_share |
|---|---|---|---|
| AlphaTech | 2023H1 | 38.5 | NULL |
| AlphaTech | 2023H2 | 40.2 | 38.5 |
| AlphaTech | 2024H1 | 42 | 40.2 |
| BetaSoft | 2023H1 | 30.1 | NULL |
| BetaSoft | 2023H2 | 28.5 | 30.1 |
| BetaSoft | 2024H1 | 26.8 | 28.5 |
| DeltaNet | 2023H1 | 13 | NULL |
| DeltaNet | 2023H2 | 12.3 | 13 |
| DeltaNet | 2024H1 | 11.7 | 12.3 |
| GammaSys | 2023H1 | 18.4 | NULL |
| GammaSys | 2023H2 | 19 | 18.4 |
| GammaSys | 2024H1 | 19.5 | 19 |
SUM(CASE WHEN trend='▲ Growth' THEN 1 ELSE 0 END) OVER (PARTITION BY company_name ORDER BY period) calculates the cumulative number of periods in which each company has expanded up to that period. A window SUM with ORDER BY uses a cumulative default frame, so the count builds as periods advance.WHERE prev_share IS NOT NULL in CTE deltas removes each company's first period, for which LAG returns NULL. Without this filter, share_delta would be NULL for the first-period row and trend could be incorrectly classified as '— Flat'.LAG(market_share_pct) OVER (ORDER BY period) makes BetaSoft 2023H1's “previous row” AlphaTech 2024H1, so the period-over-period change crosses company boundaries. Meaningless differences such as 26.8→38.5 result. PARTITION BY is always required for time-series LAG.SUM(CASE WHEN trend='▲ Growth' THEN 1 ELSE 0 END) evaluates to ELSE 0 and the row is included in the count. Excluding the first period with WHERE sets the correct starting point for expanding_periods.