Oracle 26ai: QUALIFY Under the Hood #JoelKallmanDay

Analytic functions have been among my favorite Oracle SQL features ever since their introduction in Oracle 8i. I’ve used them extensively over the years, and I still remember how much easier they made many SQL problems that previously required rather complicated solutions.

Tom Kyte once described analytic functions as “the coolest thing to happen to the SQL language since the introduction of the keyword SELECT.” I couldn’t agree more.

But there was one thing I kept missing: the ability to filter directly on the results of analytic functions. With the new QUALIFY clause in Oracle 26ai, this is finally possible. It’s a relatively small addition to Oracle SQL, but a long-awaited and very welcome one.

Let’s see how it works – and then take a look at something rather surprising under the hood.

The Problem: Filtering on Analytic Functions

Suppose we want to find the five best-selling products by revenue for each calendar year, using Oracle’s SH sample schema.

We can calculate the revenue per product and year and assign a row number within each year:

SELECT t.calendar_year,
       p.prod_name,
       SUM(s.amount_sold) AS revenue,
       ROW_NUMBER() OVER (
         PARTITION BY t.calendar_year
         ORDER BY SUM(s.amount_sold) DESC
       ) AS rn
FROM sh.sales s
JOIN sh.times t    ON t.time_id = s.time_id
JOIN sh.products p ON p.prod_id = s.prod_id
GROUP BY t.calendar_year, p.prod_name;

Listing 1: Calculating revenue and row numbers per year

So far, so good. But how do we restrict the result to the top five products per year?

We cannot simply add the filter to the WHERE or HAVING clause:

WHERE   ROW_NUMBER() OVER (PARTITION BY t.calendar_year
        *
ERROR at line 9:
ORA-30483: window  functions are not allowed here

The WHERE clause cannot reference the result of an analytic function. It is because analytic functions are evaluated after the WHERE clause. The same applies to HAVING, which is evaluated after grouping and aggregation, but before analytic functions. Unlike aggregate functions, analytic functions do not participate in grouping.

Traditionally, we have to wrap the query in an inline view or a common table expression:

WITH ranked_products AS (
  SELECT t.calendar_year,
         p.prod_name,
         SUM(s.amount_sold) AS revenue,
         ROW_NUMBER() OVER (
           PARTITION BY t.calendar_year
           ORDER BY SUM(s.amount_sold) DESC
         ) AS rn
  FROM sh.sales s
  JOIN sh.times t    ON t.time_id = s.time_id
  JOIN sh.products p ON p.prod_id = s.prod_id
  GROUP BY t.calendar_year, p.prod_name
)
SELECT calendar_year, prod_name, revenue, rn
FROM ranked_products
WHERE rn <= 5
ORDER BY calendar_year, rn;

---------------------------------------------------------------------------
| Id  | Operation                         | Name     | Rows  | Cost (%CPU)|
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |          |    25 |  3902   (2)|
|   1 |  SORT ORDER BY                    |          |    25 |  3902   (2)|
|*  2 |   VIEW                            |          |    25 |  3901   (2)|
|*  3 |    WINDOW SORT PUSHED RANK        |          |   237 |  3901   (2)|
|   4 |     HASH GROUP BY                 |          |   237 |  3901   (2)|
|*  5 |      HASH JOIN                    |          |   918K|  3847   (1)|
|   6 |       PART JOIN FILTER CREATE     | :BF0000  |  1826 |    17   (0)|
|   7 |        TABLE ACCESS FULL          | TIMES    |  1826 |    17   (0)|
|*  8 |       HASH JOIN                   |          |   918K|  3828   (1)|
|   9 |        TABLE ACCESS FULL          | PRODUCTS |    72 |     3   (0)|
|  10 |        PARTITION RANGE JOIN-FILTER|          |   918K|  3823   (1)|
|  11 |         TABLE ACCESS FULL         | SALES    |   918K|  3823   (1)|
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("RN"<=5)
   3 - filter(ROW_NUMBER() OVER ( PARTITION BY "T"."CALENDAR_YEAR" ORDER BY SUM("S"."AMOUNT_SOLD") 
              DESC )<=5)
   5 - access("T"."TIME_ID"="S"."TIME_ID")
   8 - access("P"."PROD_ID"="S"."PROD_ID")

Listing 2: Top-5 query using a CTE and its execution plan

There is nothing fundamentally wrong with this solution. It works, and Oracle has been able to optimize such queries efficiently for a long time. Looking at the execution plan in Listing 2 (slightly shortened for readability), the most interesting operation is WINDOW SORT PUSHED RANK. In simple terms, Oracle applies the Top-N filter while processing the analytic function, rather than calculating the row numbers for all rows first and filtering them afterwards.

But the additional query block exists only because we need to filter on the result of an analytic function. It does not add anything to the business logic.

The Solution: QUALIFY

Oracle 26ai introduces the QUALIFY clause, which allows us to apply filtering conditions to the results of analytic functions directly within the same query block.

Our example becomes:

SELECT t.calendar_year,
       p.prod_name,
       SUM(s.amount_sold) AS revenue,
       ROW_NUMBER() OVER (
         PARTITION BY t.calendar_year
         ORDER BY SUM(s.amount_sold) DESC
       ) AS rn
FROM sh.sales s
JOIN sh.times t    ON t.time_id = s.time_id
JOIN sh.products p ON p.prod_id = s.prod_id
GROUP BY t.calendar_year, p.prod_name
QUALIFY rn <= 5
ORDER BY t.calendar_year, rn;
---------------------------------------------------------------------------
| Id  | Operation                         | Name     | Rows  | Cost (%CPU)|
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |          |    25 |  3884   (2)|
|   1 |  SORT ORDER BY                    |          |    25 |  3884   (2)|
|*  2 |   VIEW                            |          |    25 |  3883   (2)|
|*  3 |    WINDOW SORT PUSHED RANK        |          |   237 |  3883   (2)|
|   4 |     HASH GROUP BY                 |          |   237 |  3883   (2)|
|*  5 |      HASH JOIN                    |          |   918K|  3847   (1)|
|   6 |       PART JOIN FILTER CREATE     | :BF0000  |  1826 |    17   (0)|
|   7 |        TABLE ACCESS FULL          | TIMES    |  1826 |    17   (0)|
|*  8 |       HASH JOIN                   |          |   918K|  3828   (1)|
|   9 |        TABLE ACCESS FULL          | PRODUCTS |    72 |     3   (0)|
|  10 |        PARTITION RANGE JOIN-FILTER|          |   918K|  3823   (1)|
|  11 |         TABLE ACCESS FULL         | SALES    |   918K|  3823   (1)|
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("from$_subquery$_006"."sys_qualify$_h$_predex$_0"=TRUE)
   3 - filter(ROW_NUMBER() OVER ( PARTITION BY "T"."CALENDAR_YEAR" ORDER BY SUM("S"."AMOUNT_SOLD") 
              DESC )<=5)
   5 - access("T"."TIME_ID"="S"."TIME_ID")
   8 - access("P"."PROD_ID"="S"."PROD_ID")

Listing 3: Top-5 query using QUALIFY and its execution plan

No inline view or additional CTE needed in our SQL.

The QUALIFY clause is evaluated after the analytic functions, so we can reference their results directly. We can also use the alias rn, which makes the condition particularly readable.

This makes QUALIFY somewhat similar to HAVING: while HAVING filters grouped results, QUALIFY filters results based on analytic functions.

And just as with HAVING, we do not necessarily have to expose the expression used for filtering in the select list.

For example, if we are not interested in displaying the row number:

SELECT t.calendar_year,
       p.prod_name,
       SUM(s.amount_sold) AS revenue
FROM sh.sales s
JOIN sh.times t    ON t.time_id = s.time_id
JOIN sh.products p ON p.prod_id = s.prod_id
GROUP BY t.calendar_year, p.prod_name
QUALIFY ROW_NUMBER() OVER (
          PARTITION BY t.calendar_year
          ORDER BY SUM(s.amount_sold) DESC
        ) <= 5
ORDER BY t.calendar_year, revenue DESC;

Listing 4: QUALIFY without displaying the row number

This is particularly convenient for common analytical patterns such as Top-N per group, deduplication, or selecting the latest record per business key.

In my opinion, QUALIFY is one of those features that should have been available in Oracle SQL a long time ago. It does not introduce fundamentally new functionality, but it eliminates unnecessary query blocks and makes the intention much clearer.

What About the Execution Plan?

Comparing the execution plans in Listings 2 and 3, we can see that they are almost identical. There are some minor differences in the estimated costs, but nothing significant. Most importantly, both plans contain the WINDOW SORT PUSHED RANK operation, so the Top-N optimization is still there.

However, one detail in the predicate information of Listing 3 (line 34) caught my attention:

2 - filter("from$_subquery$_006"."sys_qualify$_h$_predex$_0"=TRUE)

What exactly is sys_qualify$_h$_predex$_0? Where does this Boolean expression come from? By the way, we can also see Oracle’s long-awaited SQL Boolean data type in action here.

Whenever I investigate a new SQL feature, I usually try to find out what Oracle does with it internally. I look at query transformations, use DBMS_UTILITY.EXPAND_SQL_TEXT, and sometimes dig into the CBO trace.

Earlier this year, while preparing a presentation about different techniques for Top-N queries, I came across this very detail. And, as I already discussed in my earlier blog post about JOIN TO ONE, looking at Oracle’s transformed SQL can sometimes reveal interesting things – although we have to be careful when interpreting the results.

Let’s see what Oracle makes of our QUALIFY query and first pass it to DBMS_UTILITY.EXPAND_SQL_TEXT:

WITH FUNCTION get_sql(p_sql IN CLOB)
  RETURN CLOB
IS
  p_raw CLOB;
BEGIN
  DBMS_UTILITY.EXPAND_SQL_TEXT(p_sql, p_raw);
  RETURN p_raw;
END;
SELECT get_sql(q'[
  SELECT t.calendar_year,
         p.prod_name,
         SUM(s.amount_sold) AS revenue,
         ROW_NUMBER() OVER (
           PARTITION BY t.calendar_year
           ORDER BY SUM(s.amount_sold) DESC
         ) AS rn
  FROM sh.sales s
  JOIN sh.times t    ON t.time_id = s.time_id
  JOIN sh.products p ON p.prod_id = s.prod_id
  GROUP BY t.calendar_year, p.prod_name
  QUALIFY rn <= 5
  ORDER BY t.calendar_year, rn
]') AS expanded_sql;

Listing 5: Expanding the QUALIFY query with DBMS_UTILITY.EXPAND_SQL_TEXT

The result is surprisingly verbose. Click to see the full SQL text:


SELECT &amp;quot;A1&amp;quot;.&amp;quot;CALENDAR_YEAR&amp;quot;     &amp;quot;CALENDAR_YEAR&amp;quot;,
         &amp;quot;A1&amp;quot;.&amp;quot;PROD_NAME&amp;quot;         &amp;quot;PROD_NAME&amp;quot;,
         &amp;quot;A1&amp;quot;.&amp;quot;REVENUE&amp;quot;           &amp;quot;REVENUE&amp;quot;,
         &amp;quot;A1&amp;quot;.&amp;quot;RN&amp;quot;                &amp;quot;RN&amp;quot;
    FROM (  SELECT &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot; &amp;quot;CALENDAR_YEAR&amp;quot;,
                   &amp;quot;A2&amp;quot;.&amp;quot;PROD_NAME_46&amp;quot; &amp;quot;PROD_NAME&amp;quot;,
                   SUM (&amp;quot;A2&amp;quot;.&amp;quot;AMOUNT_SOLD_6&amp;quot;) &amp;quot;REVENUE&amp;quot;,
                   ROW_NUMBER ()
                       OVER (PARTITION BY &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot;
                             ORDER BY SUM (&amp;quot;A2&amp;quot;.&amp;quot;AMOUNT_SOLD_6&amp;quot;) DESC) &amp;quot;RN&amp;quot;,
                   ROW_NUMBER ()
                       OVER (PARTITION BY &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot;
                             ORDER BY SUM (&amp;quot;A2&amp;quot;.&amp;quot;AMOUNT_SOLD_6&amp;quot;) DESC) &amp;lt;=  5  &amp;quot;sys_qualify$_h$_predex$_0&amp;quot;,
                   &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot; &amp;quot;sys_qualify$_h$_obyex$_00000&amp;quot;,
                   ROW_NUMBER ()
                       OVER (PARTITION BY &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot;
                             ORDER BY SUM (&amp;quot;A2&amp;quot;.&amp;quot;AMOUNT_SOLD_6&amp;quot;) DESC) &amp;quot;sys_qualify$_h$_obyex$_00001&amp;quot;
              FROM (SELECT &amp;quot;A4&amp;quot;.&amp;quot;PROD_ID_0&amp;quot; &amp;quot;QCSJ_C000000000500000&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CUST_ID_1&amp;quot; &amp;quot;CUST_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;QCSJ_C000000000300000_2&amp;quot; &amp;quot;QCSJ_C000000000300000&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CHANNEL_ID_3&amp;quot; &amp;quot;CHANNEL_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;PROMO_ID_4&amp;quot; &amp;quot;PROMO_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;QUANTITY_SOLD_5&amp;quot; &amp;quot;QUANTITY_SOLD&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;AMOUNT_SOLD_6&amp;quot; &amp;quot;AMOUNT_SOLD_6&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;QCSJ_C000000000300001_7&amp;quot; &amp;quot;QCSJ_C000000000300001&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAY_NAME_8&amp;quot; &amp;quot;DAY_NAME&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAY_NUMBER_IN_WEEK_9&amp;quot; &amp;quot;DAY_NUMBER_IN_WEEK&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAY_NUMBER_IN_MONTH_10&amp;quot; &amp;quot;DAY_NUMBER_IN_MONTH&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_WEEK_NUMBER_11&amp;quot; &amp;quot;CALENDAR_WEEK_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_WEEK_NUMBER_12&amp;quot; &amp;quot;FISCAL_WEEK_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;WEEK_ENDING_DAY_13&amp;quot; &amp;quot;WEEK_ENDING_DAY&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;WEEK_ENDING_DAY_ID_14&amp;quot; &amp;quot;WEEK_ENDING_DAY_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_MONTH_NUMBER_15&amp;quot; &amp;quot;CALENDAR_MONTH_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_MONTH_NUMBER_16&amp;quot; &amp;quot;FISCAL_MONTH_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_MONTH_DESC_17&amp;quot; &amp;quot;CALENDAR_MONTH_DESC&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_MONTH_ID_18&amp;quot; &amp;quot;CALENDAR_MONTH_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_MONTH_DESC_19&amp;quot; &amp;quot;FISCAL_MONTH_DESC&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_MONTH_ID_20&amp;quot; &amp;quot;FISCAL_MONTH_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_CAL_MONTH_21&amp;quot; &amp;quot;DAYS_IN_CAL_MONTH&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_FIS_MONTH_22&amp;quot; &amp;quot;DAYS_IN_FIS_MONTH&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_CAL_MONTH_23&amp;quot; &amp;quot;END_OF_CAL_MONTH&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_FIS_MONTH_24&amp;quot; &amp;quot;END_OF_FIS_MONTH&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_MONTH_NAME_25&amp;quot; &amp;quot;CALENDAR_MONTH_NAME&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_MONTH_NAME_26&amp;quot; &amp;quot;FISCAL_MONTH_NAME&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_QUARTER_DESC_27&amp;quot; &amp;quot;CALENDAR_QUARTER_DESC&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_QUARTER_ID_28&amp;quot; &amp;quot;CALENDAR_QUARTER_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_QUARTER_DESC_29&amp;quot; &amp;quot;FISCAL_QUARTER_DESC&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_QUARTER_ID_30&amp;quot; &amp;quot;FISCAL_QUARTER_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_CAL_QUARTER_31&amp;quot; &amp;quot;DAYS_IN_CAL_QUARTER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_FIS_QUARTER_32&amp;quot; &amp;quot;DAYS_IN_FIS_QUARTER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_CAL_QUARTER_33&amp;quot; &amp;quot;END_OF_CAL_QUARTER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_FIS_QUARTER_34&amp;quot; &amp;quot;END_OF_FIS_QUARTER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_QUARTER_NUMBER_35&amp;quot; &amp;quot;CALENDAR_QUARTER_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_QUARTER_NUMBER_36&amp;quot; &amp;quot;FISCAL_QUARTER_NUMBER&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot; &amp;quot;CALENDAR_YEAR_37&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;CALENDAR_YEAR_ID_38&amp;quot; &amp;quot;CALENDAR_YEAR_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_YEAR_39&amp;quot; &amp;quot;FISCAL_YEAR&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;FISCAL_YEAR_ID_40&amp;quot; &amp;quot;FISCAL_YEAR_ID&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_CAL_YEAR_41&amp;quot; &amp;quot;DAYS_IN_CAL_YEAR&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;DAYS_IN_FIS_YEAR_42&amp;quot; &amp;quot;DAYS_IN_FIS_YEAR&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_CAL_YEAR_43&amp;quot; &amp;quot;END_OF_CAL_YEAR&amp;quot;,
                           &amp;quot;A4&amp;quot;.&amp;quot;END_OF_FIS_YEAR_44&amp;quot; &amp;quot;END_OF_FIS_YEAR&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_ID&amp;quot; &amp;quot;QCSJ_C000000000500001&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_NAME&amp;quot; &amp;quot;PROD_NAME_46&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_DESC&amp;quot; &amp;quot;PROD_DESC&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_SUBCATEGORY&amp;quot; &amp;quot;PROD_SUBCATEGORY&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_SUBCATEGORY_ID&amp;quot; &amp;quot;PROD_SUBCATEGORY_ID&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_SUBCATEGORY_DESC&amp;quot; &amp;quot;PROD_SUBCATEGORY_DESC&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_CATEGORY&amp;quot; &amp;quot;PROD_CATEGORY&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_CATEGORY_ID&amp;quot; &amp;quot;PROD_CATEGORY_ID&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_CATEGORY_DESC&amp;quot; &amp;quot;PROD_CATEGORY_DESC&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_WEIGHT_CLASS&amp;quot; &amp;quot;PROD_WEIGHT_CLASS&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_UNIT_OF_MEASURE&amp;quot; &amp;quot;PROD_UNIT_OF_MEASURE&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_PACK_SIZE&amp;quot; &amp;quot;PROD_PACK_SIZE&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;SUPPLIER_ID&amp;quot; &amp;quot;SUPPLIER_ID&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_STATUS&amp;quot; &amp;quot;PROD_STATUS&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_LIST_PRICE&amp;quot; &amp;quot;PROD_LIST_PRICE&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_MIN_PRICE&amp;quot; &amp;quot;PROD_MIN_PRICE&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_TOTAL&amp;quot; &amp;quot;PROD_TOTAL&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_TOTAL_ID&amp;quot; &amp;quot;PROD_TOTAL_ID&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_SRC_ID&amp;quot; &amp;quot;PROD_SRC_ID&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_EFF_FROM&amp;quot; &amp;quot;PROD_EFF_FROM&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_EFF_TO&amp;quot; &amp;quot;PROD_EFF_TO&amp;quot;,
                           &amp;quot;A3&amp;quot;.&amp;quot;PROD_VALID&amp;quot; &amp;quot;PROD_VALID&amp;quot;
                      FROM (SELECT &amp;quot;A6&amp;quot;.&amp;quot;PROD_ID&amp;quot; &amp;quot;PROD_ID_0&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;CUST_ID&amp;quot; &amp;quot;CUST_ID_1&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;TIME_ID&amp;quot; &amp;quot;QCSJ_C000000000300000_2&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;CHANNEL_ID&amp;quot; &amp;quot;CHANNEL_ID_3&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;PROMO_ID&amp;quot; &amp;quot;PROMO_ID_4&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;QUANTITY_SOLD&amp;quot; &amp;quot;QUANTITY_SOLD_5&amp;quot;,
                                   &amp;quot;A6&amp;quot;.&amp;quot;AMOUNT_SOLD&amp;quot; &amp;quot;AMOUNT_SOLD_6&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;TIME_ID&amp;quot; &amp;quot;QCSJ_C000000000300001_7&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAY_NAME&amp;quot; &amp;quot;DAY_NAME_8&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAY_NUMBER_IN_WEEK&amp;quot; &amp;quot;DAY_NUMBER_IN_WEEK_9&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAY_NUMBER_IN_MONTH&amp;quot; &amp;quot;DAY_NUMBER_IN_MONTH_10&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_WEEK_NUMBER&amp;quot; &amp;quot;CALENDAR_WEEK_NUMBER_11&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_WEEK_NUMBER&amp;quot; &amp;quot;FISCAL_WEEK_NUMBER_12&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;WEEK_ENDING_DAY&amp;quot; &amp;quot;WEEK_ENDING_DAY_13&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;WEEK_ENDING_DAY_ID&amp;quot; &amp;quot;WEEK_ENDING_DAY_ID_14&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_MONTH_NUMBER&amp;quot; &amp;quot;CALENDAR_MONTH_NUMBER_15&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_MONTH_NUMBER&amp;quot; &amp;quot;FISCAL_MONTH_NUMBER_16&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_MONTH_DESC&amp;quot; &amp;quot;CALENDAR_MONTH_DESC_17&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_MONTH_ID&amp;quot; &amp;quot;CALENDAR_MONTH_ID_18&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_MONTH_DESC&amp;quot; &amp;quot;FISCAL_MONTH_DESC_19&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_MONTH_ID&amp;quot; &amp;quot;FISCAL_MONTH_ID_20&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_CAL_MONTH&amp;quot; &amp;quot;DAYS_IN_CAL_MONTH_21&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_FIS_MONTH&amp;quot; &amp;quot;DAYS_IN_FIS_MONTH_22&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_CAL_MONTH&amp;quot; &amp;quot;END_OF_CAL_MONTH_23&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_FIS_MONTH&amp;quot; &amp;quot;END_OF_FIS_MONTH_24&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_MONTH_NAME&amp;quot; &amp;quot;CALENDAR_MONTH_NAME_25&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_MONTH_NAME&amp;quot; &amp;quot;FISCAL_MONTH_NAME_26&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_QUARTER_DESC&amp;quot; &amp;quot;CALENDAR_QUARTER_DESC_27&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_QUARTER_ID&amp;quot; &amp;quot;CALENDAR_QUARTER_ID_28&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_QUARTER_DESC&amp;quot; &amp;quot;FISCAL_QUARTER_DESC_29&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_QUARTER_ID&amp;quot; &amp;quot;FISCAL_QUARTER_ID_30&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_CAL_QUARTER&amp;quot; &amp;quot;DAYS_IN_CAL_QUARTER_31&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_FIS_QUARTER&amp;quot; &amp;quot;DAYS_IN_FIS_QUARTER_32&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_CAL_QUARTER&amp;quot; &amp;quot;END_OF_CAL_QUARTER_33&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_FIS_QUARTER&amp;quot; &amp;quot;END_OF_FIS_QUARTER_34&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_QUARTER_NUMBER&amp;quot; &amp;quot;CALENDAR_QUARTER_NUMBER_35&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_QUARTER_NUMBER&amp;quot; &amp;quot;FISCAL_QUARTER_NUMBER_36&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_YEAR&amp;quot; &amp;quot;CALENDAR_YEAR_37&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;CALENDAR_YEAR_ID&amp;quot; &amp;quot;CALENDAR_YEAR_ID_38&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_YEAR&amp;quot; &amp;quot;FISCAL_YEAR_39&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;FISCAL_YEAR_ID&amp;quot; &amp;quot;FISCAL_YEAR_ID_40&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_CAL_YEAR&amp;quot; &amp;quot;DAYS_IN_CAL_YEAR_41&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;DAYS_IN_FIS_YEAR&amp;quot; &amp;quot;DAYS_IN_FIS_YEAR_42&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_CAL_YEAR&amp;quot; &amp;quot;END_OF_CAL_YEAR_43&amp;quot;,
                                   &amp;quot;A5&amp;quot;.&amp;quot;END_OF_FIS_YEAR&amp;quot; &amp;quot;END_OF_FIS_YEAR_44&amp;quot;
                              FROM &amp;quot;SH&amp;quot;.&amp;quot;SALES&amp;quot; &amp;quot;A6&amp;quot;, &amp;quot;SH&amp;quot;.&amp;quot;TIMES&amp;quot; &amp;quot;A5&amp;quot;
                             WHERE &amp;quot;A5&amp;quot;.&amp;quot;TIME_ID&amp;quot; = &amp;quot;A6&amp;quot;.&amp;quot;TIME_ID&amp;quot;) &amp;quot;A4&amp;quot;,
                           &amp;quot;SH&amp;quot;.&amp;quot;PRODUCTS&amp;quot; &amp;quot;A3&amp;quot;
                     WHERE &amp;quot;A3&amp;quot;.&amp;quot;PROD_ID&amp;quot; = &amp;quot;A4&amp;quot;.&amp;quot;PROD_ID_0&amp;quot;) &amp;quot;A2&amp;quot;
          GROUP BY &amp;quot;A2&amp;quot;.&amp;quot;CALENDAR_YEAR_37&amp;quot;, &amp;quot;A2&amp;quot;.&amp;quot;PROD_NAME_46&amp;quot;) &amp;quot;A1&amp;quot;
   WHERE &amp;quot;A1&amp;quot;.&amp;quot;sys_qualify$_h$_predex$_0&amp;quot; = TRUE
ORDER BY &amp;quot;A1&amp;quot;.&amp;quot;sys_qualify$_h$_obyex$_00000&amp;quot;,
         &amp;quot;A1&amp;quot;.&amp;quot;sys_qualify$_h$_obyex$_00001&amp;quot;

Listing 5a: Expanded SQL text (full output)

Oracle generates multiple nested inline views, additional column aliases, and a number of internal expressions.

Among them are:

ROW_NUMBER() OVER (
  PARTITION BY ...
  ORDER BY ...
) AS rn,

ROW_NUMBER() OVER (
  PARTITION BY ...
  ORDER BY ...
) <= 5 AS "sys_qualify$_h$_predex$_0"

The outer query then applies:

WHERE "A1"."sys_qualify$_h$_predex$_0" = TRUE

In other words, Oracle represents the QUALIFY condition as an additional Boolean expression in an inner query block and filters on that expression in the outer query.

The CBO’s Final Query

Let’s compare this with the Final Query after Transformations from the CBO trace.

The Final query after transformations contains essentially the same QUALIFY transformation, but without the extensive expansion of the joins.

Here is the SQL query extracted from the CBO trace:

SELECT "from$_subquery$_006"."CALENDAR_YEAR"     "CALENDAR_YEAR",
         "from$_subquery$_006"."PROD_NAME"         "PROD_NAME",
         "from$_subquery$_006"."REVENUE"           "REVENUE",
         "from$_subquery$_006"."RN"                "RN"
    FROM (  SELECT "T"."CALENDAR_YEAR"
                       "CALENDAR_YEAR",
                   "P"."PROD_NAME"
                       "PROD_NAME",
                   SUM ("S"."AMOUNT_SOLD")
                       "REVENUE",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC)
                       "RN",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC) <=  5  "sys_qualify$_h$_predex$_0",
                   "T"."CALENDAR_YEAR"
                       "sys_qualify$_h$_obyex$_00000",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC)
                       "sys_qualify$_h$_obyex$_00001"
              FROM "SH"."SALES" "S", "SH"."TIMES" "T", "SH"."PRODUCTS" "P"
             WHERE     "P"."PROD_ID" = "S"."PROD_ID"
                   AND "T"."TIME_ID" = "S"."TIME_ID"
          GROUP BY "T"."CALENDAR_YEAR", "P"."PROD_NAME") "from$_subquery$_006"
   WHERE "from$_subquery$_006"."sys_qualify$_h$_predex$_0" = TRUE
ORDER BY "from$_subquery$_006"."sys_qualify$_h$_obyex$_00000",
         "from$_subquery$_006"."sys_qualify$_h$_obyex$_00001"
;

Listing 6: Final Query after Transformations from the CBO trace

Compared to the output of EXPAND_SQL_TEXT, the CBO’s final query is much easier to read. The transformation of the QUALIFY condition, however, is essentially the same.

Same Logic, Different Execution Plan

But what happens if we execute these statements directly?

Interestingly, both produce almost identical execution plans:

---------------------------------------------------------------------------
| Id  | Operation                         | Name     | Rows  | Cost (%CPU)|
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |          |   237 |  3884   (2)|
|   1 |  SORT ORDER BY                    |          |   237 |  3884   (2)|
|*  2 |   VIEW                            |          |   237 |  3883   (2)|
|   3 |    WINDOW SORT                    |          |   237 |  3883   (2)|
|   4 |     HASH GROUP BY                 |          |   237 |  3883   (2)|
|*  5 |      HASH JOIN                    |          |   918K|  3847   (1)|
|   6 |       PART JOIN FILTER CREATE     | :BF0000  |  1826 |    17   (0)|
|   7 |        TABLE ACCESS FULL          | TIMES    |  1826 |    17   (0)|
|*  8 |       HASH JOIN                   |          |   918K|  3828   (1)|
|   9 |        TABLE ACCESS FULL          | PRODUCTS |    72 |     3   (0)|
|  10 |        PARTITION RANGE JOIN-FILTER|          |   918K|  3823   (1)|
|  11 |         TABLE ACCESS FULL         | SALES    |   918K|  3823   (1)|
---------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("from$_subquery$_006"."sys_qualify$_h$_predex$_0"=TRUE)
   5 - access("T"."TIME_ID"="S"."TIME_ID")
   8 - access("P"."PROD_ID"="S"."PROD_ID")

Listing 7: Execution plan of the generated SQL

Notice something missing? Instead of WINDOW SORT PUSHED RANK, which we saw in the original execution plans in Listings 2 and 3, we now have a plain WINDOW SORT.

So, although the generated SQL produces the same results, it no longer benefits from the pushed-rank optimization. In my tests, this also resulted in measurably worse performance.

But why?

Let’s make a small change to the transformed SQL. Instead of filtering on the generated Boolean expression:

WHERE "from$_subquery$_006"."sys_qualify$_h$_predex$_0" = TRUE

we filter directly on the row number:

WHERE rn <= 5

Everything else remains unchanged.

  SELECT "from$_subquery$_006"."CALENDAR_YEAR"     "CALENDAR_YEAR",
         "from$_subquery$_006"."PROD_NAME"         "PROD_NAME",
         "from$_subquery$_006"."REVENUE"           "REVENUE",
         "from$_subquery$_006"."RN"                "RN"
    FROM (  SELECT "T"."CALENDAR_YEAR"
                       "CALENDAR_YEAR",
                   "P"."PROD_NAME"
                       "PROD_NAME",
                   SUM ("S"."AMOUNT_SOLD")
                       "REVENUE",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC)
                       "RN",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC) <=  5  "sys_qualify$_h$_predex$_0",
                   "T"."CALENDAR_YEAR"
                       "sys_qualify$_h$_obyex$_00000",
                   ROW_NUMBER ()
                       OVER (PARTITION BY "T"."CALENDAR_YEAR"
                             ORDER BY SUM ("S"."AMOUNT_SOLD") DESC)
                       "sys_qualify$_h$_obyex$_00001"
              FROM "SH"."SALES" "S", "SH"."TIMES" "T", "SH"."PRODUCTS" "P"
             WHERE     "P"."PROD_ID" = "S"."PROD_ID"
                   AND "T"."TIME_ID" = "S"."TIME_ID"
          GROUP BY "T"."CALENDAR_YEAR", "P"."PROD_NAME") "from$_subquery$_006"
   WHERE rn <= 5
ORDER BY "from$_subquery$_006"."sys_qualify$_h$_obyex$_00000",
         "from$_subquery$_006"."sys_qualify$_h$_obyex$_00001"
;
---------------------------------------------------------------------------
| Id  | Operation                         | Name     | Rows  | Cost (%CPU)|
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |          |    25 |  3902   (2)|
|   1 |  SORT ORDER BY                    |          |    25 |  3902   (2)|
|*  2 |   VIEW                            |          |    25 |  3901   (2)|
|*  3 |    WINDOW SORT PUSHED RANK        |          |   237 |  3901   (2)|
|   4 |     HASH GROUP BY                 |          |   237 |  3901   (2)|
|*  5 |      HASH JOIN                    |          |   918K|  3847   (1)|
|   6 |       PART JOIN FILTER CREATE     | :BF0000  |  1826 |    17   (0)|
|   7 |        TABLE ACCESS FULL          | TIMES    |  1826 |    17   (0)|
|*  8 |       HASH JOIN                   |          |   918K|  3828   (1)|
|   9 |        TABLE ACCESS FULL          | PRODUCTS |    72 |     3   (0)|
|  10 |        PARTITION RANGE JOIN-FILTER|          |   918K|  3823   (1)|
|  11 |         TABLE ACCESS FULL         | SALES    |   918K|  3823   (1)|
---------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("RN"<=5)
   3 - filter(ROW_NUMBER() OVER ( PARTITION BY "T"."CALENDAR_YEAR" ORDER BY SUM("S"."AMOUNT_SOLD") 
              DESC )<=5)
   5 - access("T"."TIME_ID"="S"."TIME_ID")
   8 - access("P"."PROD_ID"="S"."PROD_ID")
 

Listing 8: Restoring WINDOW SORT PUSHED RANK by filtering directly on RN

And suddenly, the optimizer produces:

WINDOW SORT PUSHED RANK

So the difference is not caused by the additional inline view or the repeated ROW_NUMBER() expressions. The crucial difference appears to be the way the filtering condition is represented.

In this example, the optimizer recognizes the Top-N pattern when the filter references the row number directly. But when the same condition is expressed through a Boolean flag, the pushed-rank optimization is lost.

This is another example of why we should be careful when interpreting SQL produced by DBMS_UTILITY.EXPAND_SQL_TEXT or the CBO trace. Even the Final Query after Transformations is not necessarily optimizer-equivalent to the original statement when executed independently.

Apparently, there is more to the optimizer’s internal representation than what we can see in the generated SQL text.

Conclusion

I really like the new QUALIFY clause. It makes SQL more concise, avoids unnecessary query blocks, and expresses a very common analytical requirement directly where it belongs.

The experiment also revealed two interesting things. First, expressing a Top-N condition through a Boolean flag can prevent Oracle from applying the WINDOW SORT PUSHED RANK optimization. This is something to watch out for when manually filtering on Boolean flags derived from analytic functions. But then again, why would you want to do that anyway? You now have QUALIFY!

Second, SQL produced by DBMS_UTILITY.EXPAND_SQL_TEXT or extracted from the CBO trace should not necessarily be treated as optimizer-equivalent to the original statement. Even the Final Query after Transformations may produce a different execution plan when executed independently.

What looks like a straightforward query rewrite can apparently hide quite a bit of optimizer magic.

Leave a Reply

Your email address will not be published. Required fields are marked *