Skip to Main Content

Breadcrumb

Question and Answer

Chris Saxon

Thanks for the question, Evgeny.

Asked: April 28, 2025 - 5:15 pm UTC

Last updated: May 06, 2025 - 3:19 pm UTC

Version: 19.22

Viewed 1000+ times

You Asked

Hello!

We encountered strange (and logically inconsistent) CBO behavior. To provide some context: we are working with an EAV (entity-attribute-value) schema, all objects are stored in a single table, let's call it "my_objects", another table, "object_ancestry", stores all ancestor/descendant relations. Below is the script that creates all tables in question, populates them with some data, and builds indexes:


Environment
-----------
• Oracle 19.22.0.0 on AIX
• optimizer_mode = ALL_ROWS
• cursor_sharing = EXACT
• Full stats gathered on all objects (see script)

--- script start
declare 
  procedure drop_table(p_table_name in varchar2) 
  is
   e_no_table exception; 
   pragma exception_init(e_no_table, -942);  
  begin
    execute immediate 'drop table ' || p_table_name;
  exception when e_no_table then null; -- ignore
  end;
begin
  drop_table('object_ancestry');
  drop_table('my_objects');
end;
/

--- OEV (object-entity-value) schema:
create table my_objects(object_type_id number, object_id number, object_key varchar2(100));
create table object_ancestry(parent_type_id number, child_type_id number, parent_id number, child_id number);

--- adding some data
----- objects
insert /*+ append*/ into my_objects(object_type_id, object_id, object_key)
select 1 as object_type, level as object_id, 'location_' || level as object_key
  from dual connect by level <= 1e+06;
commit;

insert /*+ append*/ into my_objects(object_type_id, object_id, object_key)
select 2 as object_type, 1e+06 + level as object_id, 'subregion_' || level as object_key
  from dual connect by level <= 10000;
commit;

insert /*+ append*/ into my_objects(object_type_id, object_id, object_key)
select 3 as object_type, 2e+06 + level as object_id, 'region_' || level as object_key
  from dual connect by level <= 100;
commit;
----- EOF objects

----- object ancestry
insert into object_ancestry(parent_type_id, child_type_id, parent_id, child_id)
select subregion.object_type_id as parent_type_id, location.object_type_id as child_type_id, subregion.object_id as parent_id, location.object_id as child_id
  from my_objects location
    join my_objects subregion on mod(location.object_id, 10000) = (subregion.object_id - 1e+06 - 1)
 where location.object_type_id = 1
   and subregion.object_type_id = 2
;
commit;

insert into object_ancestry(parent_type_id, child_type_id, parent_id, child_id)
select region.object_type_id as parent_type_id, subregion.object_type_id as child_type_id, region.object_id as parent_id, subregion.object_id as child_id
  from my_objects subregion
    join my_objects region on mod(subregion.object_id - 1e+06, 100) = (region.object_id - 2e+06 - 1)
 where subregion.object_type_id = 2
   and region.object_type_id = 3
;
commit;

insert into object_ancestry(parent_type_id, child_type_id, parent_id, child_id)
select 3 /*region*/ as parent_type_id, 1 /*location*/ as child_type_id, region_id as parent_id, location_id as child_id
  from 
    (
      select parent_id as subregion_id, child_id as location_id 
        from object_ancestry subregion_location 
       where parent_type_id = 2 
        and child_type_id = 1
    ) sl
    join 
    (
      select parent_id as region_id, child_id as subregion_id 
        from object_ancestry region_subregion
       where parent_type_id = 3 
        and child_type_id = 2
    ) rs on sl.subregion_id = rs.subregion_id    
;
commit;

create unique index idx01_object_ancestry on object_ancestry (parent_type_id, child_type_id, child_id, parent_id);
create unique index idx02_object_ancestry on object_ancestry (parent_type_id, child_type_id, parent_id, child_id);

create unique index pk_my_objects on my_objects(object_id);
create index idx01_my_objects on my_objects(object_type_id);
create unique index idx02_my_objects on my_objects(object_type_id, upper(object_key));

alter table my_objects add constraint pk_my_objects primary key(object_id) using index pk_my_objects;

exec dbms_stats.gather_table_stats(ownname=>user, tabname => 'MY_OBJECTS', estimate_percent => 100, cascade => TRUE);
exec dbms_stats.gather_table_stats(ownname=>user, tabname => 'OBJECT_ANCESTRY', estimate_percent => 100, cascade => TRUE);

--- script end

We need to extract all "location" objects that belong to specific "region" objects, where "region" objects are given by their object_key (some middleware generates all the queries, and we can't modify them),

Short Problem description:
-------
With the query below, the optimiser picks a different, higher-cost plan
as soon as the IN-list on REGION.OBJECT_KEY grows from 1 to 2 bind
values, and reports the LEADING(region) hint as 'not used'.

select /*+ leading(region)*/
    location.object_id location_object_id,
    region_location.parent_id region_object_id,
    subregion_location.parent_id sub_region_object_id,
    location.object_key location_key,
    sub_region.object_key region_key,
    region.object_key region_key
  from my_objects location, 
    (
      select region_location_int.child_id, region_location_int.parent_id 
        from object_ancestry region_location_int
      where region_location_int.parent_type_id = :100 
        and region_location_int.child_type_id = :101
    ) region_location, 
    (
      select subregion_location_int.child_id, subregion_location_int.parent_id 
        from object_ancestry subregion_location_int
       where subregion_location_int.parent_type_id = :200 
         and subregion_location_int.child_type_id = :201
    ) subregion_location, 
    my_objects region,
    my_objects sub_region 
 where location.object_type_id = :1
   and subregion_location.child_id(+) = location.object_id
   and sub_region.object_type_id(+) = :2
   and sub_region.object_id(+) = subregion_location.parent_id
   and region_location.child_id(+) = location.object_id
   and region.object_type_id(+) = :3
   and region.object_id(+) = region_location.parent_id
   and (upper(region.object_key) in (upper(:4),upper(:5)))
;


For the above query, CBO refuses to use the hint, don't use index on my_objects(object_type_id, upper(object_key)), and generates the plan with the cost 5633 (for real application data, it is significantly higher)

If we remove one value from the list and modify the last predicate to make it look as follows:
and (upper(region.object_key) in (upper(:4)))


CBO honors the hint and switches to the plan that uses the index on my_objects(object_type_id, upper(object_key)) with the cost 267. There is an apparent logical inconsistency here, because in the worst case scenario in-list predicate with two values can be replaced by a union of two queries with one value in the list, and CBO knows that the cost of such plan is 267, therefore, in the worst case scenario it should be 267 * 2 = 534, which is 10 time lower than 5633 - the cost of the plan with two values in the list.

The things get even "curiouser" if we change the last predicate in the above query with the predicate on "sub_region":
and (upper(sub_region.object_key) in (upper(:4),upper(:5)))


In this case, CBO honors the hint, applies the index, and builds a plan with the cost of 181, even though we use bind variables in the query and from CBO perspective there must be absolutely no difference between the instance of "my_objects" table with alias "region" and another instance of "my_objects" table with alias "sub_region".

Can you please shed some light on what's going on here?

Thank you very much in advance!

and Chris said...

This is likely a reflection of the inconsistency in the query itself - mixing inner and outer joins!

   and region.object_type_id(+) = :3
   and region.object_id(+) = region_location.parent_id
   and (upper(region.object_key) in (upper(:4),upper(:5)))


This outer joins on object_type_id and object_id and inner joins on object_key. The inner join overrides the outer join.

Fix this so all the criteria are inner joins or outer joins as appropriate. Do this for region_location.child_id too as this in the join chain.

Doing this resolves the plan inconsistency. With everything outer joins and two variables in the list I get this plan on 19.26:

-------------------------------------------------------------------------------
| Id  | Operation                     | Name             | Rows  | Cost (%CPU)|
-------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                  |   438K| 10573   (1)|
|   1 |  HASH JOIN RIGHT OUTER        |                  |   438K| 10573   (1)|
|   2 |   INLIST ITERATOR             |                  |       |            |
|   3 |    TABLE ACCESS BY INDEX ROWID| MY_OBJECTS       |     1 |     5   (0)|
|   4 |     INDEX UNIQUE SCAN         | IDX02_MY_OBJECTS |     1 |     4   (0)|
|   5 |   HASH JOIN RIGHT OUTER       |                  |   438K| 10566   (1)|
|   6 |    TABLE ACCESS FULL          | OBJECT_ANCESTRY  |   502K|  1702   (2)|
|   7 |    HASH JOIN RIGHT OUTER      |                  |   384K|  6770   (1)|
|   8 |     TABLE ACCESS FULL         | MY_OBJECTS       |   336K|  1129   (2)|
|   9 |     HASH JOIN OUTER           |                  |   384K|  4099   (2)|
|  10 |      TABLE ACCESS FULL        | MY_OBJECTS       |   336K|  1129   (2)|
|  11 |      TABLE ACCESS FULL        | OBJECT_ANCESTRY  |   502K|  1702   (2)|
-------------------------------------------------------------------------------


I also see that it's ignoring the leading hint in this plan. But it's accessing region first, so it's a non-issue.

With one bind variable, the overall cost is 2 lower and the database uses a unique scan instead of inlist as shown below. But the rest of the plan is the same.

|   0 | SELECT STATEMENT             |                  |   438K| 10571   (1)|
|   1 |  HASH JOIN RIGHT OUTER       |                  |   438K| 10571   (1)|
|   2 |   TABLE ACCESS BY INDEX ROWID| MY_OBJECTS       |     1 |     3   (0)|
|   3 |    INDEX UNIQUE SCAN         | IDX02_MY_OBJECTS |     1 |     2   (0)|


Make everything inner joins and have two variables in the list and the plan is:

---------------------------------------------------------------------------------------
| Id  | Operation                        | Name                  | Rows  | Cost (%CPU)|
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                 |                       |    29 |   181   (0)|
|   1 |  NESTED LOOPS OUTER              |                       |    29 |   181   (0)|
|   2 |   NESTED LOOPS OUTER             |                       |    29 |   123   (0)|
|   3 |    NESTED LOOPS                  |                       |    25 |    73   (0)|
|   4 |     NESTED LOOPS                 |                       |    33 |     7   (0)|
|   5 |      INLIST ITERATOR             |                       |       |            |
|   6 |       TABLE ACCESS BY INDEX ROWID| MY_OBJECTS            |     1 |     5   (0)|
|   7 |        INDEX UNIQUE SCAN         | IDX02_MY_OBJECTS      |     1 |     4   (0)|
|   8 |      INDEX RANGE SCAN            | IDX02_OBJECT_ANCESTRY |    50 |     2   (0)|
|   9 |     TABLE ACCESS BY INDEX ROWID  | MY_OBJECTS            |     1 |     2   (0)|
|  10 |      INDEX UNIQUE SCAN           | PK_MY_OBJECTS         |     1 |     1   (0)|
|  11 |    INDEX RANGE SCAN              | IDX01_OBJECT_ANCESTRY |     1 |     2   (0)|
|  12 |   TABLE ACCESS BY INDEX ROWID    | MY_OBJECTS            |     1 |     2   (0)|
|  13 |    INDEX UNIQUE SCAN             | PK_MY_OBJECTS         |     1 |     1   (0)|
---------------------------------------------------------------------------------------


This is a slightly lower overall cost than the single variable plan (the rest of the plan is the same as before):

|   0 | SELECT STATEMENT                |                       |    43 |  5246 |   267   (0)| 00:00:01 |
|   1 |  NESTED LOOPS OUTER             |                       |    43 |  5246 |   267   (0)| 00:00:01 |
|   2 |   NESTED LOOPS OUTER            |                       |    43 |  4214 |   181   (0)| 00:00:01 |
|   3 |    NESTED LOOPS                 |                       |    38 |  3078 |   105   (0)| 00:00:01 |
|   4 |     NESTED LOOPS                |                       |    50 |  2850 |     5   (0)| 00:00:01 |
|   5 |      TABLE ACCESS BY INDEX ROWID| MY_OBJECTS            |     1 |    40 |     3   (0)| 00:00:01 |
|*  6 |       INDEX UNIQUE SCAN         | IDX02_MY_OBJECTS      |     1 |       |     2   (0)| 00:00:01 |


There may be a costing issue in the original query. But the first step is to make the join types consistent.

There is an apparent logical inconsistency here, because in the worst case scenario in-list predicate with two values can be replaced by a union of two queries with one value in the list, and CBO knows that the cost of such plan is 267, therefore, in the worst case scenario it should be 267 * 2 = 534, which is 10 time lower than 5633 - the cost of the plan with two values in the list.

It's not that simple. Adding another variable to the list will change the cost of accessing that table. This then has a cascading effect through the rest of the plan which can lead to wildly different final costs.

In general, there's no guarantee that adding another variable to an IN list will double the size of the results and thus the cost.

Further complicating this example is the huge skew in the values for object_type_id. An explain plan which doesn't peek at the bind values for this column can have vastly different row estimates and costs to the execution plans which peek at each of the values for this column.

The things get even "curiouser" if we change the last predicate in the above query with the predicate on "sub_region":

Again, this is mixing inner and outer joins.

Rating

  (2 ratings)

Comments

Re:Weird CBO Behavior

Evgeny, April 30, 2025 - 5:42 pm UTC

Hello Chris, thank you very much for your reply!

There is an inconsistency in the query, indeed, but:
1) As I mentioned in my original post, queries are generated by third-party middleware, and we can't rewrite them
2) this inconsistency may confuse a human, but it shouldn't confuse CBO, predicate
and (upper(region.object_key) in (upper(:4), upper(:5)))

makes it an inner join, plain and simple :)

But it seems that in case of one variable in the list, CBO correctly converts it to inner join and can honor the hint, but if there are two variables in the list, then it looks like CBO struggles with understanding that it is, in fact, an inner join, and because of that it refuses make "region" a driving table, and ignores the hint.

Another puzzling thing is that if the predicate applies to "sub_region" table
and (upper(sub_region.object_key) in (upper(:4), upper(:5)))

CBO correctly identifies it as an inner join and makes "sub_region" a driving table, but it stubbornly refuses to do it with "region" table, even though the query is absolutely symmetrical with regard to "region" and "sub_region" tables.

P.S. I've never said that adding a second variable should double the cost - I said that "in the worst case scenario", the query can be rewritten as a union of two queries with the cost of 267 each. If the alternative is a plan with a cost = 5633, then you can always fall back on the "union" option. CBO should be able to figure this out.
Chris Saxon
May 02, 2025 - 1:56 pm UTC

We've discussed this internally. There is an issue when functions are on the column for the IN condition. We've logged (unpublished) bug 37902581 for this. Work with support to check the progress on this and prioritise it.

You can see the problem with a simplified version of the problem query:

drop index idx02_my_objects;

select /*+ leading ( mo ) */count(*), count(mo.object_id)
from   object_ancestry oa, my_objects mo 
where  mo.object_id (+) = oa.parent_id
and    upper (mo.object_key) in ( 'REGION_1', 'REGION_2' );

select * from dbms_xplan.display_cursor ();

Plan hash value: 1516733417
 
------------------------------------------------------------------------------------------------
| Id  | Operation            | Name            | Rows  | Bytes |TempSpc| Cost (%CPU)| Time     |
------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT     |                 |       |       |       |  6120 (100)|          |
|   1 |  SORT AGGREGATE      |                 |     1 |    27 |       |            |          |
|*  2 |   FILTER             |                 |       |       |       |            |          |
|*  3 |    HASH JOIN OUTER   |                 |  2010K|    51M|    34M|  6120   (1)| 00:00:01 |
|   4 |     TABLE ACCESS FULL| OBJECT_ANCESTRY |  2010K|    11M|       |  1687   (1)| 00:00:01 |
|   5 |     TABLE ACCESS FULL| MY_OBJECTS      |  1010K|    20M|       |  1125   (1)| 00:00:01 |
------------------------------------------------------------------------------------------------


Notice the plan is a HASH JOIN OUTER and my_objects is second, despite the leading hint.

queries are generated by third-party middleware, and we can't rewrite them

Sorry, I missed that in your original post. There is a workaround: create a virtual column on the expression:

alter table my_objects add upper_object_key as ( upper ( object_key ) );

select /*+ leading ( mo ) */count(*), count(mo.object_id)
from   object_ancestry oa, my_objects mo 
where  mo.object_id (+) = oa.parent_id
and    upper (mo.object_key) in ( 'REGION_1', 'REGION_2' );

select * from dbms_xplan.display_cursor ();

Plan hash value: 2538604320
 
---------------------------------------------------------------------------------------
| Id  | Operation           | Name            | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |                 |       |       |  2824 (100)|          |
|   1 |  SORT AGGREGATE     |                 |     1 |    46 |            |          |
|*  2 |   HASH JOIN         |                 |  2010K|    88M|  2824   (2)| 00:00:01 |
|*  3 |    TABLE ACCESS FULL| MY_OBJECTS      | 10101 |   394K|  1130   (2)| 00:00:01 |
|   4 |    TABLE ACCESS FULL| OBJECT_ANCESTRY |  2010K|    11M|  1687   (1)| 00:00:01 |
---------------------------------------------------------------------------------------


You already have this virtual column from the unique index idx02_my_objects. This is why I dropped the index in before the first example.

So there may be another complication in your case. Which may explain why the filter on sub_region worked but not on region. Discuss this with support.

The virtual column supporting the unique index is invisible. It's possible that either making this visible or explicitly adding the virtual column yourself and making the unique index on the manual column will help. This is probably something you should discuss with the middleware provider. Take up them mixing inner & outer joins at the same time ;)

For another possible workaround, you could look at SQL Plan Management (baselines). Write the correct version of the query (with all inner joins) yourself and run it. Then copy the plan from your query to the app query using the process described in this post:

https://blogs.oracle.com/optimizer/post/using-sql-plan-management-to-control-sql-execution-plans

Big thanks to Nigel Bayliss (optimizer PM) for helping getting to the bottom of this problem.

Re:Weird CBO Behavior

Evgeny, May 05, 2025 - 4:46 pm UTC

Chris (and Nigel Bayliss), thank you so very much for the explanation! We created a ticket for Oracle tech support, and I will reference the internal bug you opened. We will also open a ticket with our middleware provider.

With the introduction of this internal bug, we can consider this question is 100% answered!

P.S. I'm afraid the "baseline" strategy won't help in this case for two reasons -
1) The queries are generated based on UI settings, and they can have different SQL text
2) Based on the fact that CBO ignores the hint and effectively "ignores" the plan with significantly lower cost, it seems that the plan is not considered acceptable - CBO doesn't see that the outer join can be converted to an inner one and therefore can't make the "outer joined" table a driving table. I believe that even if I managed to create a correct baseline, the baseline would be ignored in the same way it ignores the hint.


Chris Saxon
May 06, 2025 - 3:19 pm UTC

You're welcome.

On point 2 - a quick check to see if baselines may give the plan you want is:

- Write a query that gives the plan you want
- Get its plan using the +OUTLINE format - this gets all the steps to reproduce the plan
- Paste the outline into the original query with the unwanted plan

A quick test showed that (after removing the leading hint), the query does accept the outline:

select 
/*+   BEGIN_OUTLINE_DATA
      SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
      SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
      SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "REGION"@"SEL$1")
      USE_HASH(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
      USE_HASH(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
      USE_HASH(@"SEL$5428C7F1" "REGION"@"SEL$1")
      USE_HASH(@"SEL$5428C7F1" "REGION_LOCATION_INT"@"SEL$2")
      LEADING(@"SEL$5428C7F1" "LOCATION"@"SEL$1" "REGION_LOCATION_INT"@"SEL$2" "REGION"@"SEL$1" 
              "SUBREGION_LOCATION_INT"@"SEL$3" "SUB_REGION"@"SEL$1")
      FULL(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
      FULL(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
      FULL(@"SEL$5428C7F1" "REGION"@"SEL$1")
      FULL(@"SEL$5428C7F1" "REGION_LOCATION_INT"@"SEL$2")
      FULL(@"SEL$5428C7F1" "LOCATION"@"SEL$1")
      OUTLINE(@"SEL$3")
      OUTLINE(@"SEL$2")
      OUTLINE(@"SEL$1")
      MERGE(@"SEL$3" >"SEL$1")
      MERGE(@"SEL$2" >"SEL$1")
      OUTLINE_LEAF(@"SEL$5428C7F1")
      ALL_ROWS
      DB_VERSION('19.1.0')
      OPTIMIZER_FEATURES_ENABLE('19.1.0')
      IGNORE_OPTIM_EMBEDDED_HINTS
      END_OUTLINE_DATA */
    location.object_id location_object_id,
    region_location.parent_id region_object_id,
    subregion_location.parent_id sub_region_object_id,
    location.object_key location_key,
    sub_region.object_key region_key,
    region.object_key region_key
  from my_objects location, 
    (
      select region_location_int.child_id, region_location_int.parent_id 
        from object_ancestry region_location_int
      where region_location_int.parent_type_id = :100 
        and region_location_int.child_type_id = :101
    ) region_location, 
    (
      select subregion_location_int.child_id, subregion_location_int.parent_id 
        from object_ancestry subregion_location_int
       where subregion_location_int.parent_type_id = :200 
         and subregion_location_int.child_type_id = :201
    ) subregion_location, 
    my_objects region,
    my_objects sub_region 
 where location.object_type_id = :1
   and subregion_location.child_id(+) = location.object_id
   and sub_region.object_type_id(+) = :2
   and sub_region.object_id(+) = subregion_location.parent_id
   and region_location.child_id(+) = location.object_id
   and region.object_type_id(+) = :3
   and region.object_id(+) = region_location.parent_id
   and (upper(region.object_key) in (upper(:4),upper(:5)));

Plan hash value: 2570311735
 
----------------------------------------------------
| Id  | Operation                | Name            |
----------------------------------------------------
|   0 | SELECT STATEMENT         |                 |
|   1 |  HASH JOIN RIGHT OUTER   |                 |
|   2 |   TABLE ACCESS FULL      | MY_OBJECTS      |
|   3 |   HASH JOIN RIGHT OUTER  |                 |
|   4 |    TABLE ACCESS FULL     | OBJECT_ANCESTRY |
|   5 |    FILTER                |                 |
|   6 |     HASH JOIN RIGHT OUTER|                 |
|   7 |      TABLE ACCESS FULL   | MY_OBJECTS      |
|   8 |      HASH JOIN OUTER     |                 |
|   9 |       TABLE ACCESS FULL  | MY_OBJECTS      |
|  10 |       TABLE ACCESS FULL  | OBJECT_ANCESTRY |
----------------------------------------------------
 
Hint Report (identified by operation id / Query Block Name / Object Alias):
Total hints for statement: 19
---------------------------------------------------------------------------

   0 -  STATEMENT
           -  ALL_ROWS
           -  DB_VERSION('19.1.0')
           -  IGNORE_OPTIM_EMBEDDED_HINTS
           -  OPTIMIZER_FEATURES_ENABLE('19.1.0')
 
   0 -  SEL$2
           -  MERGE(@"SEL$2" >"SEL$1")
 
   0 -  SEL$3
           -  MERGE(@"SEL$3" >"SEL$1")
 
   1 -  SEL$5428C7F1
           -  LEADING(@"SEL$5428C7F1" "LOCATION"@"SEL$1" "REGION_LOCATION_INT"@"SEL$2" "REGION"@"SEL$1" 
              "SUBREGION_LOCATION_INT"@"SEL$3" "SUB_REGION"@"SEL$1")
 
   2 -  SEL$5428C7F1 / SUB_REGION@SEL$1
           -  FULL(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
           -  SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
           -  USE_HASH(@"SEL$5428C7F1" "SUB_REGION"@"SEL$1")
 
   4 -  SEL$5428C7F1 / SUBREGION_LOCATION_INT@SEL$3
           -  FULL(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
           -  SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
           -  USE_HASH(@"SEL$5428C7F1" "SUBREGION_LOCATION_INT"@"SEL$3")
 
   7 -  SEL$5428C7F1 / REGION@SEL$1
           -  FULL(@"SEL$5428C7F1" "REGION"@"SEL$1")
           -  SWAP_JOIN_INPUTS(@"SEL$5428C7F1" "REGION"@"SEL$1")
           -  USE_HASH(@"SEL$5428C7F1" "REGION"@"SEL$1")
 
   9 -  SEL$5428C7F1 / LOCATION@SEL$1
           -  FULL(@"SEL$5428C7F1" "LOCATION"@"SEL$1")
 
  10 -  SEL$5428C7F1 / REGION_LOCATION_INT@SEL$2
           -  FULL(@"SEL$5428C7F1" "REGION_LOCATION_INT"@"SEL$2")
           -  USE_HASH(@"SEL$5428C7F1" "REGION_LOCATION_INT"@"SEL$2")


So you may be able to use baselines. As you have (lots?) of generated queries it's probably impractical to create baselines for all the variations though. This may be worth pursuing for a few common variations are too slow though.

More to Explore

PL/SQL demos

Check out more PL/SQL tutorials on our LiveSQL tool.

PL/SQL docs

PL/SQL reference manual from the Oracle documentation library