DECODE With Ranges

Tim Hall wrote this post yesterday (as part of Joel Kallman Day 2022) with some examples for “the old way” vs. “the new way” to do some stuff.
One of these examples is DECODE and CASE Expressions. As Tim wrote:

CASE expressions were first released in Oracle 8i […] Prior to that if we wanted something similar to a CASE expression in our SQL, we had to use the DECODE function.

This reminded me of a trick I came up with many years ago, probably before 8i 🙂

A table T has a column X, and we need a query that one of the items it returns is a rank – either “Low”, “Medium” or “High”. This rank is determined by the value of X and two thresholds – LOW_TH and HIGH_TH.

With a (Searched) CASE Expression it’s as simple as this:

select x,
       case
           when x < :low_th then
            'Low'
           when x < :high_th then
            'Medium'
           else
            'High'
       end
from   t;

But how could we do it without CASE?

One option is to write a PL/SQL function that gets X as the input and returns the rank as the output.

But could we have a pure SQL solution? DECODE doesn't look like a good fit, because it works with discrete values, not with ranges.

It is possible. We just need to convert each range to some unique discrete value. As we have 3 ranges, we need 3 values, and there is a SQL function that returns exactly 3 possible values (and was already supported back then) - the sign function.

In order to use the sign function, we first need to map the "Low" range to negative numbers, the "Medium" range to 0, and the "High" range to positive numbers.

This mapping can be done with this expression:

floor((x - :low_th) / (:high_th - :low_th))

And now it's easy to write the final query:

select x,
       decode(
         sign(floor((x - :low_th) / (:high_th - :low_th))),
           -1, 'Low',
            0, 'Medium',
            1, 'High')
from   t;

Obviously it's better having "modern" capabilities such as the CASE expression, but not having them had the advantage of forcing us to be creative 🙂

Eurovision Analysis #JoelKallmanDay

Here’s my contribution to Joel Kallman Day 2022.

The picture is from the last time I met Joel. It was taken at ilOUG Tech Days 2020, just before an invigorating keynote by Joel.

Four years ago, as part of ODC Appreciation Day 2018 (as the annual Oracle community appreciation day was called that year), I wrote about Pattern Matching in SQL. That post was not a technical one.

This time I decided to return to the same topic, with a technical post.
I recorded a video that explains Pattern Matching by analyzing the winners of the Eurovision Song Contest over the years.

Pre-19c Crossedition Triggers

As shown in the previous post, finding all the crossedition triggers in the database is easy using DBA_TRIGGERS_AE, but this can be done only as of Oracle 19c – the release in which this data dictionary view was added.

Here are two options that can be used before 19c.

A Proper Way

We can use DBA_OBJECTS_AE to find all the triggers across all editions, and then for each one to “jump” to its edition using dbms_sql and check whether it is a crosseedition trigger or not using DBA_TRIGGERS.

declare
    l_cur          number;
    l_rows         number;
    l_status       dba_triggers.status%type;
    l_crossedition dba_triggers.crossedition%type;
begin
    for o in (select owner,
                     edition_name,
                     object_name
              from   dba_objects_ae
              where  object_type = 'TRIGGER'
              and    edition_name is not null
              order  by owner,
                        edition_name,
                        object_name)
    loop
        l_cur := dbms_sql.open_cursor();
        dbms_sql.parse(
            c             => l_cur,
            statement     => 'select status,crossedition 
                              from   dba_triggers
                              where  owner = :owner
                              and    trigger_name = :trigger_name
                              and    crossedition != ''NO''',
            language_flag => dbms_sql.native,
            edition       => o.edition_name);
        dbms_sql.bind_variable(l_cur, ':owner', o.owner);
        dbms_sql.bind_variable(l_cur, ':trigger_name', o.object_name);
        dbms_sql.define_column(l_cur, 1, l_status, 8);
        dbms_sql.define_column(l_cur, 2, l_crossedition, 7);
        l_rows := dbms_sql.execute_and_fetch(l_cur);
        if l_rows > 0 then
            dbms_sql.column_value(l_cur, 1, l_status);
            dbms_sql.column_value(l_cur, 2, l_crossedition);
            dbms_sql.close_cursor(l_cur);
            dbms_output.put_line(
                utl_lms.format_message(
                    '%s.%s is a %s crossedition trigger defined in edition %s, and it is %s',
                    o.owner,
                    o.object_name,
                    l_crossedition,
                    o.edition_name,
                    l_status));
        end if;
    end loop;
end;
/

An Undocumented Way

Looking at the definition of DBA_TRIGGERS, it seems that when a trigger is a crossedition one, the 14th bit of the trigger$.property column is set.
In addition, if the 18th bit is set, the crossedition trigger is a Reverse one, and if not, it’s a Forward one.
If these assumptions are correct, we can write the following query:

select o.owner,
       o.object_name trigger_name,
       o.edition_name,
       decode(t.enabled, 1, 'ENABLED', 'DISABLED') status,
       decode(bitand(t.property, 131072), 131072, 'REVERSE', 'FORWARD') crossedition
from   sys.trigger$   t,
       dba_objects_ae o
where  bitand(t.property, 8192) = 8192
and    o.object_id = t.obj#
order  by owner,
          edition_name,
          trigger_name;

USER_TRIGGERS_AE

One of the fundamental building blocks of EBR (Edition Based Redefinition) is Crossedition Triggers.

This special type of triggers is used when we change the structure of tables in an online application upgrade, and the data of these tables may be changed while we do the upgrade and afterwards, until no session uses the pre-upgrade edition. These triggers keep the pre-upgrade representation of the data and post-upgrade representation of the data in sync.

Crossedition triggers are temporary in nature – they should be dropped when all the clients use the edition in which the triggers were created (or one of its descendent editions).

In theory, crossedition triggers live for a very short period.

In real life, when we have multiple active editions and different types of clients that may keep using older editions for a while before upgrading to the latest edition, it may take days and even weeks before we can drop the crossedition triggers. In my opinion, this is perfectly fine, and actually one of the benefits that I see in EBR.

Another significant difference between crossedition triggers and regular triggers is that crossedition triggers are not inherited by descendent editions. However, they may be triggered by DML operations that are executed in a descendent edition.

This means that we need a way to check which crossedition triggers exist in our schema/database.

Before Oracle 19c we did not have a simple way to do it.

We could find all the crossedition triggers in the current edition:

select *
from   user_triggers
where  crossedition != 'NO';

But there may be other crossedition triggers in ancestor editions that affect us, and they are not returned by this query.

And we could find all the triggers across all the editions:

select *
from   user_objects_ae
where  object_type = 'TRIGGER';

But we can’t tell from this query which of these triggers are crossedition ones.

In Oracle 19c a new data dictionary view – USER_TRIGGERS_AE – was added, to describe all the triggers, across all editions, that are owned by the current user.
The corresponding DBA_TRIGGERS_AE and ALL_TRIGGERS_AE were added as well, of course.

It seems that I’ve had some influence on the introduction of these views 🙂

So now, in order to find all the crossedition triggers in my schema, I can simply use this query:

select t.*
from   user_triggers_ae t
where  t.crossedition != 'NO';

In the next post I’ll show two ways to achieve the same results before 19c.

Frequency-Based Ranges – Generalized with SQL Macro

In the previous post I used MATCH_RECOGNIZE to convert a list of concrete event times to frequency-based ranges.
The same technique can be applied with different tables, so I wrapped the query in a Table SQL Macro.

The parameters are the table, the “type” column, the “time” column, and (optionally) the period used for filtering.
I assumed the frequencies are always in minutes. The macro can be further generalized to support different time resolutions.

Here is the code:

create or replace function freq_view
(
    i_table     in dbms_tf.table_t,
    i_columns   in dbms_tf.columns_t,
    i_from_time in date default date '1970-01-01',
    i_to_time   in date default date '9999-12-31'
) return varchar2 sql_macro as
begin
    return 'select event_type ' || i_columns(1) || ',
       to_char(from_time,''yyyy-mm-dd'') "DATE",
       to_char(from_time,''hh24:mi'') ||
         nvl2(to_time,
              '' - '' || to_char(to_time,''hh24:mi'')
                    || '' every '' || minutes || '' minutes (''
                    || number_of_events || '' events)'',
              null) event_times
from (
  select ' || i_columns(1) || ' as event_type, trunc(' || i_columns(2) || ',''mi'') as event_time
  from   i_table
  where  ' || i_columns(2) || ' between i_from_time and i_to_time
) match_recognize (
    partition by event_type
    order by event_time
    measures X.event_time from_time,
             last(Y.event_time) to_time,
             round((first(Y.event_time)-X.event_time)*24*60) minutes,
             count(*) number_of_events
    pattern (X Y{2,} | X)
    define
      Y as event_time-prev(event_time) = first(Y.event_time)-X.event_time
)
order by event_type, from_time';
end freq_view;
/

Let’s use it for the same data set used in the original post:

SQL> break on event_type skip 1 dup
SQL> select * from freq_view(events,columns(event_type,event_time),timestamp'2022-07-29 08:00:00');

EVENT_ DATE           EVENT_TIMES
------ -------------- ----------------------------------------------
A      2022-07-29     08:10 - 08:50 every 10 minutes (5 events)
A      2022-07-29     08:58 - 09:22 every 8 minutes (4 events)
A      2022-07-29     09:33
A      2022-07-29     09:42 - 10:52 every 10 minutes (8 events)
A      2022-07-29     10:59 - 11:11 every 4 minutes (4 events)
A      2022-07-29     11:21 - 12:01 every 10 minutes (5 events)

B      2022-07-29     08:15 - 09:55 every 20 minutes (6 events)
B      2022-07-29     10:10
B      2022-07-29     10:25 - 12:05 every 25 minutes (5 events)

C      2022-07-29     08:02 - 08:32 every 2 minutes (16 events)
C      2022-07-29     08:49 - 09:05 every 2 minutes (9 events)
C      2022-07-29     09:08 - 09:20 every 4 minutes (4 events)
C      2022-07-29     09:22 - 10:16 every 2 minutes (28 events)
C      2022-07-29     11:46 - 12:20 every 2 minutes (18 events)


14 rows selected.

And now let’s use it for something else – the DBMS_SCHEDULER executions from the last 3 hours:

SQL> select * from freq_view(user_scheduler_job_run_details,columns(job_name,log_date),sysdate-3/24);

JOB_NAME     DATE            EVENT_TIMES
------------ --------------- ------------------------------------------------
JOB_AAA      2022-07-30      08:45 - 10:45 every 60 minutes (3 events)
JOB_BBB      2022-07-30      08:10 - 10:10 every 60 minutes (3 events)
JOB_CCC      2022-07-30      08:07 - 11:02 every 5 minutes (36 events)
JOB_DDD      2022-07-30      10:00
JOB_EEE      2022-07-30      08:06 - 11:05 every 1 minutes (180 events)

Pattern Matching and Event Frequencies

I have a table that contains events of various types:

create table events (
  event_id   integer     not null,
  event_type varchar2(2) not null,
  event_time date        not null,
  -- some other columns with the event details
);

The events may arrive sporadically, but many times multiple consecutive events of the same type arrive in a fixed frequency (e.g., “events of type A happen every 10 minutes between 8:10am and 8:50am”).

I want to list all the event times in a specific period for every event type.

This is a simple query, but there are many results, so it’s not very easy to read them:

SQL> break on event_type skip 1 dup
SQL> select event_type,
  2         event_time
  3  from   events
  4  where  event_time > timestamp'2022-07-29 08:00:00'
  5  order  by event_type,
  6            event_time;

EVENT
TYPE  EVENT_TIME
----- -------------------
A     2022-07-29 08:10:00
A     2022-07-29 08:20:00
A     2022-07-29 08:30:00
A     2022-07-29 08:40:00
A     2022-07-29 08:50:00
A     2022-07-29 08:58:00
A     2022-07-29 09:06:00
A     2022-07-29 09:14:00
A     2022-07-29 09:22:00
A     2022-07-29 09:33:00
A     2022-07-29 09:42:00
A     2022-07-29 09:52:00
A     2022-07-29 10:02:00
A     2022-07-29 10:12:00
A     2022-07-29 10:22:00
A     2022-07-29 10:32:00
A     2022-07-29 10:42:00
A     2022-07-29 10:52:00
A     2022-07-29 10:59:00
A     2022-07-29 11:03:00
A     2022-07-29 11:07:00
A     2022-07-29 11:11:00
A     2022-07-29 11:21:00
A     2022-07-29 11:31:00
A     2022-07-29 11:41:00
A     2022-07-29 11:51:00
A     2022-07-29 12:01:00

B     2022-07-29 08:15:00
B     2022-07-29 08:35:00
B     2022-07-29 08:55:00
B     2022-07-29 09:15:00
B     2022-07-29 09:35:00
B     2022-07-29 09:55:00
B     2022-07-29 10:10:00
B     2022-07-29 10:25:00
B     2022-07-29 10:50:00
B     2022-07-29 11:15:00
B     2022-07-29 11:40:00
B     2022-07-29 12:05:00

C     2022-07-29 08:02:00
C     2022-07-29 08:04:00
C     2022-07-29 08:06:00
C     2022-07-29 08:08:00
C     2022-07-29 08:10:00
C     2022-07-29 08:12:00
C     2022-07-29 08:14:00
C     2022-07-29 08:16:00
C     2022-07-29 08:18:00
C     2022-07-29 08:20:00
C     2022-07-29 08:22:00
C     2022-07-29 08:24:00
C     2022-07-29 08:26:00
C     2022-07-29 08:28:00
C     2022-07-29 08:30:00
C     2022-07-29 08:32:00
C     2022-07-29 08:49:00
C     2022-07-29 08:51:00
...

114 rows selected.

Because this list contains batches of events with a fixed frequency, we can change the query to get a nicer output – with a single line for every batch.
Identifying these batches is easy with the MATCH_RECOGNIZE clause.

Let’s see first the output of the query:

EVENT
TYPE  DATE         EVENT_TIMES
----- ------------ -------------------------------------------
A     2022-07-29   08:10 - 08:50 every 10 minutes (5 events)
A     2022-07-29   08:58 - 09:22 every 8 minutes (4 events)
A     2022-07-29   09:33
A     2022-07-29   09:42 - 10:52 every 10 minutes (8 events)
A     2022-07-29   10:59 - 11:11 every 4 minutes (4 events)
A     2022-07-29   11:21 - 12:01 every 10 minutes (5 events)

B     2022-07-29   08:15 - 09:55 every 20 minutes (6 events)
B     2022-07-29   10:10
B     2022-07-29   10:25 - 12:05 every 25 minutes (5 events)

C     2022-07-29   08:02 - 08:32 every 2 minutes (16 events)
C     2022-07-29   08:49 - 09:05 every 2 minutes (9 events)
C     2022-07-29   09:08 - 09:20 every 4 minutes (4 events)
C     2022-07-29   09:22 - 10:16 every 2 minutes (28 events)
C     2022-07-29   11:46 - 12:20 every 2 minutes (18 events)


14 rows selected.

Much nicer, isn’t it? 🙂

Here is the query:

SQL> select event_type,
  2         to_char(from_time,'yyyy-mm-dd') "DATE",
  3         to_char(from_time,'hh24:mi') ||
  4           nvl2(to_time,
  5                ' - ' || to_char(to_time,'hh24:mi')
  6                      || ' every ' || minutes || ' minutes ('
  7                      || number_of_events || ' events)',
  8                null) event_times
  9  from (
 10    select event_type,event_time
 11    from   events
 12    where  event_time > timestamp'2022-07-29 08:00:00'
 13  ) match_recognize (
 14      partition by event_type
 15      order by event_time
 16      measures X.event_time from_time,
 17               last(Y.event_time) to_time,
 18               (first(Y.event_time)-X.event_time)*24*60 minutes,
 19               count(*) number_of_events
 20      pattern (X Y{2,} | X)
 21      define
 22        Y as event_time-prev(event_time) = first(Y.event_time)-X.event_time
 23  )
 24  order by event_type, from_time;

The original query [lines 10-12] is the input to the MATCH_RECOGNIZE clause [lines 13-23].

We partition the input record-set by EVENT_TYPE [line 14], as we want to get a separate list for each type.

MATCH_RECOGNIZE is used for finding consecutive rows that match some pattern. “Consecutive” means we have to define the order of the input record-set (in each partition). In this case, we order the events of each type by EVENT_TIME [line 15].

Now, let’s define the pattern [lines 20-22]. We’re looking for at least 3 consecutive events – an X record followed by two or more Y records [“X Y{2,}”].
Any event is potentially an X record, so I didn’t define it explicitly.
Y is explicitly defined [line 22] as a record that its time difference from the previous record [event_time-prev(event_time)] is the same as the time difference between the first event [X] and the second event [the first Y] in the current batch [first(Y.event_time)-X.event_time].
If we don’t find such a batch of events [X followed by two or more Ys], we have a single event to return – X. This is expressed by the pattern “X Y{2,} | X” [line 20].

For every match of this pattern, 4 expressions are calculated [lines 16-19]:
FROM_TIME: the time of the first event in the match [line 16]
TO_TIME: the time of the last event in the match [line 17]
MINUTES: the time difference between every two consecutive events [line 18]
NUMBER_OF_EVENTS: the total number of events in the match [line 19]

Finally, we display the results returned by the MATCH_RECOGNIZE operator, using the expressions calculated in the MEASURES clause [lines 1-8].

The same technique can be applied for different tables. See the next post for a generalized solution, using a Table SQL Macro.

FORALL – INDICES OF vs. Lower and Upper Bounds

When using the FORALL statement, I like to feed the index variable with the INDICES OF clause, rather than using the lower_bound .. upper_bound option.
INDICES OF is perfect when the corresponding collection may be sparse, but I prefer using it also when the collection is dense. In my opinion, this is a more elegant structure, self documented, and the use of one pattern throughout the system makes the code more consistent and easier to maintain.

But is this only a question of personal preference?
Is the difference between these two options only syntactic?
In other words, is this:

SQL> truncate table t;

Table truncated.

SQL> declare
  2      v_collection sys.odcinumberlist;
  3  begin
  4      insert into t (x,y)
  5        select rownum,rownum from dual connect by level <= 10;
  6
  7      v_collection := sys.odcinumberlist(1,7,43);
  8      forall i in 1 .. v_collection.count
  9          update t
 10          set    y = 0
 11          where  x = v_collection(i);
 12      dbms_output.put_line(sql%rowcount || ' rows updated');
 13  end;
 14  /
2 rows updated

PL/SQL procedure successfully completed.

completely identical to this:

SQL> truncate table t;

Table truncated.

SQL> declare
  2      v_collection sys.odcinumberlist;
  3  begin
  4      insert into t (x,y)
  5        select rownum,rownum from dual connect by level <= 10;
  6
  7      v_collection := sys.odcinumberlist(1,7,43);
  8      forall i in indices of v_collection
  9          update t
 10          set    y = 0
 11          where  x = v_collection(i);
 12      dbms_output.put_line(sql%rowcount || ' rows updated');
 13  end;
 14  /
2 rows updated

PL/SQL procedure successfully completed.

?

In most cases the answer is yes, but a few days ago I found a case where the behavior is different between the two options.

I was wondering what happens exactly when the collection is empty.
This is not a hypothetical question. Many times we populate a collection from a query, and then use this collection in a FORALL statement to perform some DML statement. It is legit that the query will return no records, and therefore the collection will be empty.

I did some tracing and found out that in the following example, when using INDICES OF, there is a context switch from the PL/SQL engine to the SQL engine – the UPDATE statement is executed, and since the collection is empty no binding happens and no rows are affeceted.

v_collection := sys.odcinumberlist();
forall i in indices of v_collection
    update t
    set    y = 0
    where  x = v_collection(i);
=====================
PARSING IN CURSOR #1808232998640 len=33 dep=1 uid=154 oct=6 lid=154 tim=153168435007 hv=2619660526 ad='7ffeeeaf44d0' sqlid='7n7dshqf29q7f'
UPDATE T SET Y = 0 WHERE X = :B1 
END OF STMT
PARSE #1808232998640:c=0,e=126,p=0,cr=0,cu=0,mis=1,r=0,dep=1,og=1,plh=0,tim=153168435007
=====================
...
EXEC #1808232998640:c=0,e=2893,p=0,cr=7,cu=0,mis=1,r=0,dep=1,og=1,plh=931696821,tim=153168437995
CLOSE #1808232998640:c=0,e=1,dep=1,type=3,tim=153168438053

While in the following example, when using the lower_bound..upper_bound syntax, there is no context switch. The PL/SQL engine does an optimization here – it understands that going from 1 to 0 yields no items, and it doesn’t bother calling the SQL engine.

v_collection := sys.odcinumberlist();
forall i in 1 .. v_collection.count
    update t
    set    y = 0
    where  x = v_collection(i);

The outcome is the same – nothing had to be updated anyway. From the performance perspective, the lower_bound..upper_bound option spared a context switch, so it’s probably better (I didn’t measure it). I assume it’s negligible in most cases (unless you make lots of calls with empty collections).

But there may be also consequences to this optimization.
Here is one such consequence I was thinking about.
Look at this example, and see if you find any problem with it:

truncate table t;
declare
    v_collection sys.odcinumberlist;
begin
    insert into t (x,y)
      select rownum,rownum from dual connect by level <= 10;

    v_collection := sys.odcinumberlist();
    forall i in indices of v_collection
        update t
        set    y = 0
        where  x = v_collection(i);
    dbms_output.put_line(sql%rowcount || ' rows updated');
end;
/

truncate table t;
declare
    v_collection sys.odcinumberlist;
begin
    insert into t (x,y)
      select rownum,rownum from dual connect by level <= 10;

    v_collection := sys.odcinumberlist();
    forall i in 1 .. v_collection.count
        update t
        set    y = 0
        where  x = v_collection(i);
    dbms_output.put_line(sql%rowcount || ' rows updated');
end;
/

What do you expect to see as the output of each case?

Here is the output when using INDICES OF:

SQL> declare
  2      v_collection sys.odcinumberlist;
  3  begin
  4      insert into t (x,y)
  5        select rownum,rownum from dual connect by level <= 10;
  6
  7      v_collection := sys.odcinumberlist();
  8      forall i in indices of v_collection
  9          update t
 10          set    y = 0
 11          where  x = v_collection(i);
 12      dbms_output.put_line(sql%rowcount || ' rows updated');
 13  end;
 14  /
0 rows updated

PL/SQL procedure successfully completed.

As expected, 0 rows updated.

Now, what happens with the second anonymous block?

SQL> declare
  2      v_collection sys.odcinumberlist;
  3  begin
  4      insert into t (x,y)
  5        select rownum,rownum from dual connect by level <= 10;
  6
  7      v_collection := sys.odcinumberlist();
  8      forall i in 1 .. v_collection.count
  9          update t
 10          set    y = 0
 11          where  x = v_collection(i);
 12      dbms_output.put_line(sql%rowcount || ' rows updated');
 13  end;
 14  /
10 rows updated

PL/SQL procedure successfully completed.

10 rows updated!
SQL%ROWCOUNT returns the number of affected rows from the latest executed SQL statement. The FORALL statement did not execute the UPDATE statement, so the latest SQL statement that was actually executed is the INSERT statement.
This can lead to confusing situations and potential bugs.

So now I have one more reason to prefer INDICES OF 🙂

Conditional Outer Joins

In the previous post we saw how to convert an outer join with no join condition from ANSI syntax to Oracle syntax.
In this post we’ll do the same for outer joins with conditional join conditions.

In the following query the join includes a filter condition on t2:

select *
from t1
left outer join t2
  on (t2.t1_id = t1.id and t2.x = :x);

Converting this query to the Oracle syntax is straightforward:

select *
from t1, t2
where t2.t1_id(+) = t1.id
and t2.x(+) = :x;

In the following query the join includes a filter condition on t1:

select *
from t1
left outer join t2
  on (t2.t1_id = t1.id and t1.a = :a);

Converting this query to the Oracle syntax is a bit more tricky. Here is one option how to do it:

select *
from t1, t2
where t2.t1_id(+) = decode(t1.a, :a, t1.id);

Outer Join with no Join Condition

I prefer the “Oracle syntax” for outer joins over the ANSI syntax.
I like the conciseness of the Oracle syntax, and I’ve been using it for the last 30 years or so.
I totally understand people that prefer the ANSI syntax.
I agree that the ANSI syntax is richer and more complete (although Oracle 12c reduced the gaps by adding more capabilities to the Oracle syntax).
I get it that for many people the ANSI syntax is easier to read and write.
I believe that both styles are legit. But I think it’s important to choose only one of them and make this choice part of your coding standards – whether it is for a specific project or company-wide or anything in between; in the level in which coding standards are defined in your organization.

By the way, even when using the ASNI syntax, Oracle converts it internally to its proprietary syntax

So for me the standard is using the Oracle syntax, and in 99% of the queries it’s trivial. But sometimes there are queries that are more challenging. This post is about one such case.

I want to get all the columns from a specific record in T1. In addition, I want to get the column X of a specific record from T2, if such a record exists.
This is the query using the ANSI syntax:

select t1.*, t2.x
from t1
left join t2 on t2.id = :b2
where t1.id = :b1;

How can we write it using the Oracle syntax?
One of the limitations of the Oracle syntax for left/right outer join is that there must be at least one join condition. If there are only filter conditions, the outer join operator (+) is silently ignored and the query becomes de facto an inner join. So writing the query like this

select t1.*, t2.x
from t1, t2
where t1.id = :b1
and t2.id(+) = :b2;

will not give us the required results. This is not an outer join. We can see from the execution plan that the join operation is NESTED LOOPS and not NESTED LOOPS OUTER.

--------------------------------------------------------------------------------------
| Id  | Operation                    | Name  | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |       |     1 |   113 |     2   (0)| 00:00:01 |
|   1 |  NESTED LOOPS                |       |     1 |   113 |     2   (0)| 00:00:01 |
|   2 |   TABLE ACCESS BY INDEX ROWID| T2    |     1 |    26 |     1   (0)| 00:00:01 |
|*  3 |    INDEX UNIQUE SCAN         | T2_PK |     1 |       |     1   (0)| 00:00:01 |
|   4 |   TABLE ACCESS BY INDEX ROWID| T1    |     1 |    87 |     1   (0)| 00:00:01 |
|*  5 |    INDEX UNIQUE SCAN         | T1_PK |     1 |       |     0   (0)| 00:00:01 |
--------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("T2"."ID"=TO_NUMBER(:B2))
   5 - access("T1"."ID"=TO_NUMBER(:B1))

Since we need only one column from T2, we can simply use a scalar subquery:

select t1.*,
       (select t2.x from t2 where t2.id = :b2) x
from   t1
where  t1.id = :b1;

But what if we want to get all the columns from T2? I wouldn’t use a scalar subquery for every column.
This is the query using the ANSI syntax:

select t1.*, t2.*
from t1
left join t2 on t2.id = :b2
where  t1.id = :b1;

We can get an idea how to write it using the Oracle syntax, by looking at the execution plan of the previous query:

ORA$BASE> @x
  2  select t1.*, t2.*
  3  from t1
  4  left join t2 on t2.id = :b2
  5  where  t1.id = :b1;

Explained.

ORA$BASE> @xplan

PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Plan hash value: 1875989973

-------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name            | Rows  | Bytes | Cost (%CPU)| Time     |
-------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                 |     1 |   139 |     2   (0)| 00:00:01 |
|   1 |  NESTED LOOPS OUTER           |                 |     1 |   139 |     2   (0)| 00:00:01 |
|   2 |   TABLE ACCESS BY INDEX ROWID | T1              |     1 |    87 |     1   (0)| 00:00:01 |
|*  3 |    INDEX UNIQUE SCAN          | T1_PK           |     1 |       |     1   (0)| 00:00:01 |
|   4 |   VIEW                        | VW_LAT_C83A7ED5 |     1 |    52 |     1   (0)| 00:00:01 |
|   5 |    TABLE ACCESS BY INDEX ROWID| T2              |     1 |    52 |     1   (0)| 00:00:01 |
|*  6 |     INDEX UNIQUE SCAN         | T2_PK           |     1 |       |     1   (0)| 00:00:01 |
-------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("T1"."ID"=TO_NUMBER(:B1))
   6 - access("T2"."ID"=TO_NUMBER(:B2))

19 rows selected.

The execution plan shows that Oracle does a NESTED LOOPS OUTER Join between T1 and a LATERAL Inline View (note the VIEW operation with the VW_LAT… name) that contains the subquery on T2.
We can do the same. We put the subquery on T2 inside an inline view, and then apply the outer join operator (+) on the inline view. In order to do it we have to declare the inline view as LATERAL (although we don’t refer to any column of T1 in the inline view), because (+) is not allowed on non-lateral inline views.

select t1.*, t2.*
from t1, 
     lateral (select t2.* from t2 where t2.id = :b2)(+) t2
where  t1.id = :b1;

Anther option is to trick the optimizer, and make it believe the filter condition on T2 is actually a join condition. We can do it by including some reference to T1 in the filtering expression that doesn’t change the expression value. Here are two examples:

select t1.*, t2.*
from t1, t2
where t1.id = :b1
and t2.id(+) = nvl2(t1.id,:b2,:b2) ;
select t1.*, t2.*
from t1, t2
where t1.id = :b1
and t2.id(+) = t1.id-t1.id + :b2 ;

ANY_VALUE and FIRST/LAST (KEEP)

The FIRST and LAST (a.k.a. “KEEP”) aggregate functions are very useful when you want to order a row set by one column and return the value of another column from the first or last row in the ordered set.
I wrote about these functions in the past; for example here and here.

To make sure the result of the FIRST (LAST) function is deterministic, we have to define a tie-breaker for the case that multiple rows have the same first (last) value. The tie-breaker is an aggregate function that is applied on the column we want to return.

For example, the following query returns for each department the FIRST_NAME of the employee with the lowest SALARY. If there are multiple employees with the lowest salary in the same department, the query returns the “minimum” first_name – so here the MIN function is the tie-breaker.
In this example the lowest salary in department 90 is 17000, and both Neena and Lex have this salary. We use MIN(FIRST_NAME), so the result is “Lex”.

Many times we don’t really need a tie-breaker, because we know that there is a single first/last row. For example, if we use a unique expression in the ORDER BY clause of the function. And sometimes we simply don’t care which record is returned in case of a tie. But since the syntax requires a tie-breaker, we have to use some “random” function, like MIN or MAX.

The ANY_VALUE function (that was added in Oracle 19c) is perfect, in my opinion, for this case. It may improve performance, but – more importantly – it makes the code clearer, by better reflecting our intention.
Assuming that in the previous example we don’t care which one of the employees with the lowest salary is returned, we can rewrite it like this:

select
  department_id,
  any_value(first_name) keep(dense_rank FIRST order by salary)
from employees
group by department_id;