SQL table macros 5: dynamic but dangerous!

Folks like me who research table macros would sometimes like them to be truly dynamic, such as when PIVOTing rows into columns. Here’s one way to achieve that goal, with multiple warnings about what this can do to your database!

Simple PIVOT example

select * from (
  select dname, job, ename 
  from dept join emp using(deptno)
)
pivot(
  listagg(ename ,',') within group(order by ename)
  for job in (
    'ANALYST' as ANALYST,
    'CLERK' as CLERK,
    'MANAGER' as MANAGER,
    'PRESIDENT' as PRESIDENT,
    'SALESMAN' as SALESMAN
  )
);
DNAME ANALYST CLERK MANAGER PRESIDENT SALESMAN
SALES   JAMES BLAKE   ALLEN,MARTIN,TURNER,WARD
RESEARCH FORD,SCOTT ADAMS,SMITH JONES    
ACCOUNTING   MILLER CLARK KING  
 

This looks fine to me, but only because I know beforehand what five JOBs are in the EMP table. They are listed in the lines that I have highlighted above.

What happens if I insert an employee with a new job title? Nothing: my query will not show that new data at all. If I want to show all the jobs all the time, I first need a query to get an up-to-date job list.

insert into emp
select 9999, 'SMITH', 'PM', 7698, sysdate, 99999, 9999, 30 from dual;

select listagg(distinct '''' || job || ''' as ' || job, ',
')within group(order by job)
from emp;

'ANALYST' as ANALYST,
'CLERK' as CLERK,
'MANAGER' as MANAGER,
'PM' as PM,
'PRESIDENT' as PRESIDENT,
'SALESMAN' as SALESMAN

If I plug the new list into my original query, I will see the new data.

Why not a SQL table macro?

This table macro will do exactly what I just did: get the list of jobs, then plug that list into the PIVOT query.

create or replace function emp_by_dname_job
return varchar2 sql_macro is
  L_sql varchar2(4000) := 
'select * from (
  select dname, job, ename 
  from dept join emp using(deptno)
)
pivot(listagg(ename ,'','') within group(order by ename) for job in(
#JOBLIST#
))';
  l_joblist varchar2(4000);
begin
  select listagg(distinct '''' || job || ''' as ' || job, ',
') within group(order by job)
  into l_joblist
  from emp;
  l_sql := replace(l_sql, '#JOBLIST#', l_joblist);
  dbms_output.put_line(l_sql);
  return l_sql;
end;
/
select * from emp_by_dname_job( );
-- output from dbms_output.put_line(l_sql);

select * from (
  select dname, job, ename 
  from dept join emp using(deptno)
)
pivot(listagg(ename ,',') within group(order by ename) for job in(
'ANALYST' as ANALYST,
'CLERK' as CLERK,
'MANAGER' as MANAGER,
'PM' as PM,
'PRESIDENT' as PRESIDENT,
'SALESMAN' as SALESMAN
))
DNAME ANALYST CLERK MANAGER PM PRESIDENT SALESMAN
SALES   JAMES BLAKE SMITH   ALLEN,MARTIN,TURNER,WARD
RESEARCH FORD,SCOTT ADAMS,SMITH JONES      
ACCOUNTING   MILLER CLARK   KING  
 

That looks like it works, but that is a mirage. In fact, the job list was generated at query parse time. If the data changes, the query will stay the same. Let’s promote Smith:

update emp set job = 'VP' where job = 'PM';

select * from emp_by_dname_job();
DNAME ANALYST CLERK MANAGER PM PRESIDENT SALESMAN
SALES   JAMES BLAKE     ALLEN,MARTIN,TURNER,WARD
RESEARCH FORD,SCOTT ADAMS,SMITH JONES      
ACCOUNTING   MILLER CLARK   KING  
 

The old PM job is there and the new VP job is not.

Dynamic yes, but at a price!

The table macro only executes when the query is “hard parsed”. If the optimizer has already parsed the query, it will find a cursor in the shared pool that provides an execution plan; if that cursor is “shareable”, the optimizer will execute that plan instead of parsing the query again.

To make the macro “dynamic”, we have no choice but to require a hard parse before every execution. One way to do this is by doing a flashback query using AS OF SCN. Such queries are always reparsed. The question remains, as of what SCN? Since we really just want current data, we can call dbms_flashback.get_system_change_number to stay current.

create or replace function emp_by_dname_job_dyn
return varchar2 sql_macro is
  L_sql varchar2(4000) := 
'with pivoted as (
  select * from (
    select dname, job, ename 
    from dept join emp using(deptno)
  )
  pivot(listagg(ename ,'','') within group(order by ename) for job in(
    #JOBLIST#
  ))
)
select * from pivoted as of scn dbms_flashback.get_system_change_number';
  l_joblist varchar2(4000);
begin
  select listagg(distinct '''' || job || ''' as ' || job, ',
 ') within group(order by job)
  into l_joblist
  from emp;
  l_sql := replace(l_sql, '#JOBLIST#', l_joblist);
  return l_sql;
end;
/

select * from emp_by_dname_job_dyn();
DNAME ANALYST CLERK MANAGER PRESIDENT SALESMAN VP
SALES   JAMES BLAKE   ALLEN,MARTIN,TURNER,WARD SMITH
RESEARCH FORD,SCOTT ADAMS,SMITH JONES      
ACCOUNTING   MILLER CLARK KING    
 
delete from emp where ename = 'SMITH';

select * from emp_by_dname_job_dyn();
DNAME ANALYST CLERK MANAGER PRESIDENT SALESMAN
SALES   JAMES BLAKE   ALLEN,MARTIN,TURNER,WARD
RESEARCH FORD,SCOTT ADAMS JONES    
ACCOUNTING   MILLER CLARK KING  
 

Danger!

Whenever the optimizer hard parses a query, it creates a cursor in the shared pool that provides the execution plan. In our case, the same SELECT statement is parsed more than once, so the optimizer creates one “parent cursor” and subsequent “child cursors”, all with the same SQL_ID. If users ran this query constantly, the shared pool would be flooded with child cursors, which would cause all sorts of performance problems! Be very, very careful about allowing this technique in production. I would never even consider it outside of data warehouses.

Fortunately, this technique is only available if the DBA allows it. It requires the following privileges:

  • EXECUTE on DBMS_FLASHBACK
  • FLASHBACK on all the tables accessed by the query.
Advertisement

My Presentations at #UKOUG #techfest19

I just finished my second presentation at TechFest19 in sunny Brighton, England. The conference this year is great from every point of view: venue, people, content, conversations… Thanks to those who came to my talks for your benevolent attention and excellent questions/remarks!

Both my presentations are now on SlideShare. I beg you to download them so the animations work; if you don’t, you will see a jumble.

Best wishes to all for the end of the conference and the trip home.

Regards, Stew

Make your Data Dance: UNPIVOT, PIVOT and GROUP BY extensions

JSON in Oracle database 18c and 19c

My #OUGN19 Presentations

As promised, here are the two presentations I gave on the OUGN 2019 Boat conference. I had a great time with old friends and new, and managed to have discussions about politics without anyone getting upset! Hope to see some of you again at OUG Ireland!

Please download the presentations to see the animations.

https://www.slideshare.net/stewashton/json-in-18c-and-19c

https://www.slideshare.net/stewashton/make-your-data-dance

Split a string again and again

I recently needed to gather multiple columns from multiple rows into one string, then split them out again. JSON_TABLE did both splits at the same time!

The situation

I want to find the shortest “foreign key” path between two tables in the OBE schema: OEHR_ORDER_ITEMS and OEHR_DEPARTMENTS. I also want the intermediate tables and the constraints used to connect the tables. As a starting point for today, I’ll create a little table with the data for the tables in the OBE schema that start with ‘OEHR’:

drop table t purge;
create table t as
with search_for (owner, table_name) as (
  select 'OBE', 'OEHR_DEPARTMENTS' from dual union all
  select 'OBE', 'OEHR_ORDER_ITEMS' from dual
)
, pur as (
  select OWNER, CONSTRAINT_NAME, TABLE_NAME,
    R_OWNER, R_CONSTRAINT_NAME
  from dba_constraints
  where constraint_type in('P','U','R')
  and owner = 'OBE'
  and table_name like 'OEHR%'
)
, relations as (
  select a.table_name, a.constraint_name,
    b.table_name r_table_name, a.r_constraint_name
  from pur a join pur b
  on (a.R_OWNER, a.R_CONSTRAINT_NAME) = ((b.OWNER, b.CONSTRAINT_NAME))
  and (a.OWNER, a.table_name) != ((b.OWNER, b.table_name))
)
select * from relations;

Now I’ll use a hierarchical query to find paths from one table to the other:

select rownum rn, level lvl, TABLE_NAME, CONSTRAINT_NAME, R_TABLE_NAME, R_CONSTRAINT_NAME
from t a
start with table_name = 'OEHR_ORDER_ITEMS'
connect by nocycle table_name = prior r_table_name
and table_name != 'OEHR_DEPARTMENTS';
RN LVL TABLE_NAME CONSTRAINT_NAME R_TABLE_NAME R_CONSTRAINT_NAME
1 1 OEHR_ORDER_ITEMS OEHR_ORDER_ITEMS_ORDER_ID_FK OEHR_ORDERS OEHR_ORDER_PK
2 2 OEHR_ORDERS OEHR_ORDERS_CUSTOMER_ID_FK OEHR_CUSTOMERS OEHR_CUSTOMERS_PK
3 3 OEHR_CUSTOMERS OEHR_CUST_ACCT_MGR_FK OEHR_EMPLOYEES OEHR_EMP_EMP_ID_PK
4 4 OEHR_EMPLOYEES OEHR_EMP_DEPT_FK OEHR_DEPARTMENTS OEHR_DEPT_ID_PK
5 4 OEHR_EMPLOYEES OEHR_EMP_JOB_FK OEHR_JOBS OEHR_JOB_ID_PK
6 2 OEHR_ORDERS OEHR_ORDERS_SALES_REP_FK OEHR_EMPLOYEES OEHR_EMP_EMP_ID_PK
7 3 OEHR_EMPLOYEES OEHR_EMP_DEPT_FK OEHR_DEPARTMENTS OEHR_DEPT_ID_PK
8 3 OEHR_EMPLOYEES OEHR_EMP_JOB_FK OEHR_JOBS OEHR_JOB_ID_PK
9 1 OEHR_ORDER_ITEMS OEHR_ORDER_ITEMS_PRODUCT_ID_FK OEHR_PRODUCT_INFORMATION OEHR_PRODUCT_INFO_PK

 

Just looking at the data myself, I can see that the shortest path will include rows 1, 6 and 7. Rows 1 through 4 will work too, but the path is longer.

Getting the data for the whole path

The problem is, how can I identify those three rows I want and just query them? The only way I see is to use SYS_CONNECT_BY_PATH (or the equivalent with a CTE) to string together all the data from all the rows I want – which means I have to split that data out again later.

To simplify what follows, I’m going to manually delete the rows that don’t matter:

delete from t where table_name not in ('OEHR_ORDER_ITEMS','OEHR_ORDERS','OEHR_EMPLOYEES');
delete from t where r_table_name not in ('OEHR_DEPARTMENTS','OEHR_ORDERS','OEHR_EMPLOYEES');
commit;

The rows that are left contain the data I want:

TABLE_NAME CONSTRAINT_NAME R_TABLE_NAME R_CONSTRAINT_NAME
OEHR_ORDER_ITEMS OEHR_ORDER_ITEMS_ORDER_ID_FK OEHR_ORDERS OEHR_ORDER_PK
OEHR_ORDERS OEHR_ORDERS_SALES_REP_FK OEHR_EMPLOYEES OEHR_EMP_EMP_ID_PK
OEHR_EMPLOYEES OEHR_EMP_DEPT_FK OEHR_DEPARTMENTS OEHR_DEPT_ID_PK

 

Now I’ll string that data together:

select sys_connect_by_path(
  table_name  ||'/'||  constraint_name||'/'||
  r_table_name||'/'||r_constraint_name
  ,'<'
) path
from t a
where r_table_name = 'OEHR_DEPARTMENTS'
start with table_name = 'OEHR_ORDER_ITEMS'
connect by table_name = prior r_table_name;

<OEHR_ORDER_ITEMS/OEHR_ORDER_ITEMS_ORDER_ID_FK/OEHR_ORDERS/OEHR_ORDER_PK
<OEHR_ORDERS/OEHR_ORDERS_SALES_REP_FK/OEHR_EMPLOYEES/OEHR_EMP_EMP_ID_PK
<OEHR_EMPLOYEES/OEHR_EMP_DEPT_FK/OEHR_DEPARTMENTS/OEHR_DEPT_ID_PK

I split the data into three lines for legibility. Note that I have to use different literals in the two parameters of SYS_CONNECT_BY_PATH, otherwise I get an exception.

Splitting out the data

Now we’re finally getting to the point. I can use JSON_ARRAY to string together the data for one row. Once I have all the data for all the rows, I can turn that into an array of arrays. Finally, JSON_TABLE will split everything out for me. Let’s start with JSON_ARRAY:

select sys_connect_by_path(
  json_array(table_name, constraint_name, r_table_name, r_constraint_name)
  , '
'
) path
from t a
where r_table_name = 'OEHR_DEPARTMENTS'
start with table_name = 'OEHR_ORDER_ITEMS'
connect by table_name = prior r_table_name;

["OEHR_ORDER_ITEMS","OEHR_ORDER_ITEMS_ORDER_ID_FK","OEHR_ORDERS","OEHR_ORDER_PK"]
["OEHR_ORDERS","OEHR_ORDERS_SALES_REP_FK","OEHR_EMPLOYEES","OEHR_EMP_EMP_ID_PK"]
["OEHR_EMPLOYEES","OEHR_EMP_DEPT_FK","OEHR_DEPARTMENTS","OEHR_DEPT_ID_PK"]

Now I’ll turn that data into an array of arrays:

with data as (
  select sys_connect_by_path(
    json_array(table_name, constraint_name, r_table_name, r_constraint_name)
    , '/'
  ) path
  from t a
  where r_table_name = 'OEHR_DEPARTMENTS'
  start with table_name = 'OEHR_ORDER_ITEMS'
  connect by table_name = prior r_table_name
)
select '['||substr(replace(path,'/',','),2) || ']' path
from data;
[
  [
    "OEHR_ORDER_ITEMS",
    "OEHR_ORDER_ITEMS_ORDER_ID_FK",
    "OEHR_ORDERS",
    "OEHR_ORDER_PK"
  ],
  [
    "OEHR_ORDERS",
    "OEHR_ORDERS_SALES_REP_FK",
    "OEHR_EMPLOYEES",
    "OEHR_EMP_EMP_ID_PK"
  ],
  [
    "OEHR_EMPLOYEES",
    "OEHR_EMP_DEPT_FK",
    "OEHR_DEPARTMENTS",
    "OEHR_DEPT_ID_PK"
  ]
]

Now JSON_TABLE will break down the top array into rows, just by putting ‘$[*]’ in the second parameter. The COLUMNS will do the rest, splitting the data in the inner arrays into different columns.

with data as (
  select sys_connect_by_path(
    json_array(table_name, constraint_name, r_table_name, r_constraint_name)
    , '/'
  ) path
  from t a
  where r_table_name = 'OEHR_DEPARTMENTS'
  start with table_name = 'OEHR_ORDER_ITEMS'
  connect by table_name = prior r_table_name
)
select a.* from data, json_table(
  '['||substr(replace(path,'/',','),2) || ']',
  '$[*]' columns(
    rn for ordinality,
    table_name varchar2(128) path '$[0]',
    constraint_name varchar2(128) path '$[1]',
    r_table_name varchar2(128) path '$[2]',
    r_constraint_name varchar2(128) path '$[3]'
  )
) a;
RN TABLE_NAME CONSTRAINT_NAME R_TABLE_NAME R_CONSTRAINT_NAME
1 OEHR_ORDER_ITEMS OEHR_ORDER_ITEMS_ORDER_ID_FK OEHR_ORDERS OEHR_ORDER_PK
2 OEHR_ORDERS OEHR_ORDERS_SALES_REP_FK OEHR_EMPLOYEES OEHR_EMP_EMP_ID_PK
3 OEHR_EMPLOYEES OEHR_EMP_DEPT_FK OEHR_DEPARTMENTS OEHR_DEPT_ID_PK

 

Whew!

P.S. If you think a CTE solution is cleaner:

with data( TABLE_NAME, R_TABLE_NAME, path ) as (
  select n.TABLE_NAME, n.R_TABLE_NAME,
    json_array(n.TABLE_NAME, n.CONSTRAINT_NAME, n.R_TABLE_NAME, n.R_CONSTRAINT_NAME)
  from t n
  where table_name = 'OEHR_ORDER_ITEMS'
  union all
  select n.TABLE_NAME, n.R_TABLE_NAME,
    o.path || ',' ||
    json_array(n.TABLE_NAME, n.CONSTRAINT_NAME, n.R_TABLE_NAME, n.R_CONSTRAINT_NAME)
  from t n
  join data o on (n.table_name = o.r_table_name)
    and n.table_name != 'OEHR_DEPARTMENTS'
)
select b.* from data a, json_table(
  '['|| path || ']',
  '$[*]' columns(
    rn for ordinality,
    table_name varchar2(128) path '$[0]',
    constraint_name varchar2(128) path '$[1]',
    r_table_name varchar2(128) path '$[2]',
    r_constraint_name varchar2(128) path '$[3]'
  )
) b
where a.r_table_name = 'OEHR_DEPARTMENTS';

PIVOT Function with Totals

I have updated the ADVANCED_PIVOT function to support row and/or column totals. It’s neat, but you need to know what you’re doing.

No totals

Let’s start from an example that has no totals. If we didn’t want to pivot, we could do a simple GROUP BY:

SQL> select dname, job, sum(sal)
  2  from emp join dept using(deptno)
  3  group by dname, job
  4  order by 1,2;

DNAME          JOB         SUM(SAL)
-------------- --------- ----------
ACCOUNTING     CLERK           1300
ACCOUNTING     MANAGER         2450
ACCOUNTING     PRESIDENT       5000
RESEARCH       ANALYST         3000
RESEARCH       CLERK           1900
RESEARCH       MANAGER         2975
SALES          CLERK            950
SALES          MANAGER         2850
SALES          SALESMAN        5600

If we do want to pivot, the PIVOT clause will do the aggregation for us:

SQL> var rc refcursor

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select dname, job, sal
  5  from emp join dept using(deptno)
  6  )@',
  7  p_aggfuncs => 'sum(sal)',
  8  p_pivotfor => 'job'
  9  );
 10  end;
 11  /

print :rc

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000           
RESEARCH             3000       1900       2975                      
SALES                            950       2850                  5600

Horizontal total

Now suppose we want an extra column that shows the total for each row. Is that a “row total” or a “column total”? To avoid confusion, I’m going to say “horizontal total” when we get an extra column, and “vertical total” when we get an extra row.

The first thing I realized is that, if we want any totals, we have to compute them beforehand: the PIVOT clause will only be used to create the pivot columns and move the data to the right places. Here is a query to do that:

SQL> select dname, 
  2    job,
  3    sum(sal) sal
  4  from emp join dept using(deptno)
  5  group by dname, rollup(job);

DNAME          JOB              SAL
-------------- --------- ----------
SALES          CLERK            950
SALES          MANAGER         2850
SALES          SALESMAN        5600
SALES                          9400
RESEARCH       CLERK           1900
RESEARCH       ANALYST         3000
RESEARCH       MANAGER         2975
RESEARCH                       7875
ACCOUNTING     CLERK           1300
ACCOUNTING     MANAGER         2450
ACCOUNTING     PRESIDENT       5000
ACCOUNTING                     8750

Notice the use of ROLLUP(JOB) to get totals for all the jobs, but always at the DNAME level.

Notice also that the total rows have a JOB value of NULL. PIVOT will not allow us to pivot data based on a NULL column value. So we use the GROUPING() function to find out when there is a total of all jobs, and replace the NULL with a literal.

SQL> select dname, 
  2    decode(grouping(job),1,'(Total)',job) job,
  3    sum(sal) sal
  4  from emp join dept using(deptno)
  5  group by dname, rollup(job);

DNAME          JOB              SAL
-------------- --------- ----------
SALES          CLERK            950
SALES          MANAGER         2850
SALES          SALESMAN        5600
SALES          (Total)         9400
RESEARCH       CLERK           1900
RESEARCH       ANALYST         3000
RESEARCH       MANAGER         2975
RESEARCH       (Total)         7875
ACCOUNTING     CLERK           1300
ACCOUNTING     MANAGER         2450
ACCOUNTING     PRESIDENT       5000
ACCOUNTING     (Total)         8750

Notice I always put the literal ‘(Total)’ before the value. If the value is not a string, Oracle will implicitly convert it. If we said decode(grouping(<column>),0,<column>,'(Total)') and <column> was a date or a number, we would get a conversion exception.

Now that we have the totals with a valid label, let’s plug that into my function:

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select dname, 
  5    decode(grouping(job),1,'(Total)',job) job,
  6    sum(sal) sal
  7  from emp join dept using(deptno)
  8  group by dname, rollup(job)
  9  )@',
 10  p_aggfuncs => 'sum(sal)',
 11  p_pivotfor => 'job'
 12  );
 13  end;
 14  /

DNAME             (Total)    ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING           8750                  1300       2450       5000           
RESEARCH             7875       3000       1900       2975                      
SALES                9400                   950       2850                  5600

Oops! The Total column is in the wrong place. To fix that, I added an optional parameter p_total_label. Whenever we order by a column, I will make sure that the content of that parameter goes last. Let’s try it:

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select dname, 
  5    decode(grouping(job),1,'(Total)',job) job,
  6    sum(sal) sal
  7  from emp join dept using(deptno)
  8  group by dname, rollup(job)
  9  )@',
 10  p_aggfuncs => 'sum(sal)',
 11  p_pivotfor => 'job',
 12  p_total_label => '(Total)'
 13  );
 14  end;
 15  /

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN    (Total)
-------------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000                  8750
RESEARCH             3000       1900       2975                             7875
SALES                            950       2850                  5600       9400

Warnings:

  1. Make sure you use the same literal value for p_total_label and within the p_source query! The function will never try to check for coherence, so it’s up to you.
  2. To use something like ‘(Total)’ instead of NULL, dates and numbers must be converted to strings. If that happens, the function can only order by the string value, not the original date or number value!

Vertical total

Let’s try to ROLLUP by DNAME rather than JOB, in order to get an extra row with the totals for each job.

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname, 
  5    job,
  6    sum(sal) sal
  7  from emp join dept using(deptno)
  8  group by rollup(dname), job
  9  )@',
 10  p_aggfuncs => 'sum(sal)',
 11  p_pivotfor => 'job',
 12  p_total_label => '(Total)'
 13  );
 14  end;
 15  /

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ----------
(Total)              3000       4150       8275       5000       5600
ACCOUNTING                      1300       2450       5000           
RESEARCH             3000       1900       2975                      
SALES                            950       2850                  5600

We still have a problem with sorting the rows. The function has no idea that the DNAME column has a ‘(Total)’ row that needs to go last. To fix that, I added another optional parameter p_ordercol. The function will order the final query by the column listed here, making sure that the total line goes last.

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname, 
  5    job,
  6    sum(sal) sal
  7  from emp join dept using(deptno)
  8  group by rollup(dname), job
  9  )@',
 10  p_aggfuncs => 'sum(sal)',
 11  p_pivotfor => 'job',
 12  p_total_label => '(Total)',
 13  p_ordercol => 'dname'
 14  );
 15  end;
 16  /

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000           
RESEARCH             3000       1900       2975                      
SALES                            950       2850                  5600
(Total)              3000       4150       8275       5000       5600

Horizontal and vertical totals

To get both totals, we just need to change the source query. We could combine two ROLLUPs, but in this case the CUBE function is more concise:

SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname, 
  5    decode(grouping(job),1,'(Total)',job) job,
  6    sum(sal) sal
  7  from emp join dept using(deptno)
  8  group by cube(dname,job)
  9  )@',
 10  p_aggfuncs => 'sum(sal)',
 11  p_pivotfor => 'job',
 12  p_total_label => '(Total)',
 13  p_ordercol => 'dname'
 14  );
 15  end;
 16  /

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN    (Total)
-------------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000                  8750
RESEARCH             3000       1900       2975                             7875
SALES                            950       2850                  5600       9400
(Total)              3000       4150       8275       5000       5600      26025

Two vertical totals

Now we’re getting fancy! Suppose we want totals by department, but also subtotals within each department by the year someone was hired?

  • row 10: get the hiring year
  • row 13: instead of ROLLUP on DNAME only, we ROLLUP on DNAME and HIREYEAR
  • row 5: HIREYEAR should say ‘(Total)’ only when we are on a subtotal line, not on the grand total.
  • row 18: the p_ordercol parameter can be a list of column names
SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname,
  5    decode(grouping_id(dname,hireyear),1,'(Total)',hireyear) hireyear,
  6    job,
  7    sum(sal) sal
  8  from (
  9    select dname, job, sal,
 10      extract(year from hiredate) hireyear
 11    from dept join emp using(deptno)
 12  )
 13  group by rollup(dname, hireyear), job
 14  )@',
 15  p_aggfuncs => 'sum(sal)',
 16  p_pivotfor => 'job',
 17  p_total_label => '(Total)',
 18  p_ordercol => 'dname,hireyear'
 19  );
 20  end;
 21  /

DNAME          HIREYEAR     ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- --------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING     1981                                  2450       5000           
ACCOUNTING     1982                       1300                                 
ACCOUNTING     (Total)                    1300       2450       5000           
RESEARCH       1980                        800                                 
RESEARCH       1981            3000                  2975                      
RESEARCH       1987                       1100                                 
RESEARCH       (Total)         3000       1900       2975                      
SALES          1981                        950       2850                  5600
SALES          (Total)                     950       2850                  5600
(Total)                        3000       4150       8275       5000       5600

Two vertical totals, one horizontal

  • row 13: we ROLLUP on the vertical axis (DNAME, HIREYEAR) and on the horizontal (JOB)
  • row 6: JOB must never have a NULL value, else the PIVOT will fail.
SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname,
  5    decode(grouping_id(dname,hireyear),1,'(Total)',hireyear) hireyear,
  6    decode(grouping(job),1,'(Total)',job) job,
  7    sum(sal) sal
  8  from (
  9    select dname, job, sal,
 10      extract(year from hiredate) hireyear
 11    from dept join emp using(deptno)
 12  )
 13  group by rollup(dname, hireyear), rollup(job)
 14  )@',
 15  p_aggfuncs => 'sum(sal)',
 16  p_pivotfor => 'job',
 17  p_total_label => '(Total)',
 18  p_ordercol => 'dname,hireyear'
 19  );
 20  end;
 21  /

DNAME          HIREYEAR     ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN    (Total)
-------------- --------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING     1981                                  2450       5000                  7450
ACCOUNTING     1982                       1300                                        1300
ACCOUNTING     (Total)                    1300       2450       5000                  8750
RESEARCH       1980                        800                                         800
RESEARCH       1981            3000                  2975                             5975
RESEARCH       1987                       1100                                        1100
RESEARCH       (Total)         3000       1900       2975                             7875
SALES          1981                        950       2850                  5600       9400
SALES          (Total)                     950       2850                  5600       9400
(Total)                        3000       4150       8275       5000       5600      26025

One vertical, two horizontal

  • row 5: HIREYEAR is now a source for a pivot column, so it must never be NULL.
  • row 16: the p_pivotfor parameter can be a list of column names
SQL> begin
  2  :rc := advanced_pivot(
  3  p_source => q'@(
  4  select decode(grouping(dname),1,'(Total)',dname) dname,
  5    decode(grouping(hireyear),1,'(Total)',hireyear) hireyear,
  6    decode(grouping(job),1,'(Total)',job) job,
  7    sum(sal) sal
  8  from (
  9    select dname, job, sal,
 10      extract(year from hiredate) hireyear
 11    from dept join emp using(deptno)
 12  )
 13  group by rollup(dname), rollup(hireyear, job)
 14  )@',
 15  p_aggfuncs => 'sum(sal)',
 16  p_pivotfor => 'hireyear,job',
 17  p_total_label => '(Total)',
 18  p_ordercol => 'dname'
 19  );
 20  end;
 21  /

DNAME          1980_CLERK 1980_(Total) 1981_ANALYST 1981_CLERK 1981_MANAGER 1981_PRESIDENT 1981_SALESMAN 1981_(Total) 1982_CLERK 1982_(Total) 1987_CLERK 1987_(Total) (Total)_(Total)
-------------- ---------- ------------ ------------ ---------- ------------ -------------- ------------- ------------ ---------- ------------ ---------- ------------ ---------------
ACCOUNTING                                                             2450           5000                       7450       1300         1300                                    8750
RESEARCH              800          800         3000                    2975                                      5975                               1100         1100            7875
SALES                                                      950         2850                         5600         9400                                                            9400
(Total)               800          800         3000        950         8275           5000          5600        22825       1300         1300       1100         1100           26025

Conclusion

Did you really read down to here? If so, congratulations! Here is what I wish you would take away from this post:

  • The ROLLUP and CUBE extensions to GROUP BY are very powerful means to get various levels of subtotals, totals and grand totals.
  • The PIVOT clause can manage vertical and / or horizontal totals very cleanly.
  • The source query should do one ROLLUP on the vertical columns and another on the horizontal columns, except when there is exactly one of each: then just use CUBE.
  • The ADVANCED_PIVOT function generates the appropriate PIVOT clause based on the data, and can handle multiple levels of vertical and / or horizontal totals.
      • The horizontal “p_pivotfor” columns must never be NULL.
      • The vertical “p_ordercol” columns can be NULL or labeled, your choice. If there are dates or numbers, NULL allows for sorting by the original values.

Improved PIVOT Function

My generic PIVOT function only allowed one column in the FOR clause. The modestly named “Advanced” function has no such limit.

Here are some test runs to put it through its paces. I’ll save the code for the end.

UPDATE 2018-05-31: I added support for horizontal and vertical totals. The most recent code is still at the bottom of this post, but the additional explanations are here: PIVOT Function with Totals

NOTE: I changed the order of the input parameters to match the order of the PIVOT statement itself: aggregation functions before the pivot_for_clause.

One aggregation function, one FOR column

SQL> set serveroutput off
SQL> var rc refcursor
SQL> begin
  2    :rc := advanced_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'AVG(SAL)', 
  5      'JOB'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000           
RESEARCH             3000        950       2975                      
SALES                            950       2850                  1400

Transposing the data and aliasing the aggregate

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'AVG(SAL) AVG_SAL', 
  5      'DNAME'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


JOB       ACCOUNTING_AVG_SAL RESEARCH_AVG_SAL SALES_AVG_SAL
--------- ------------------ ---------------- -------------
ANALYST                                  3000              
CLERK                   1300              950           950
MANAGER                 2450             2975          2850
PRESIDENT               5000                               
SALESMAN                                               1400

One aggregate, two FOR columns

The pivot columns are ordered by DNAME, JOB from left to right.

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'MAX(SAL) MAX_SAL', 
  5      'DNAME,JOB'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


ACCOUNTING_CLERK_MAX_SAL ACCOUNTING_MANAGER_MAX_SAL ACCOUNTING_PRESIDENT_MAX_SAL RESEARCH_ANALYST_MAX_SAL RESEARCH_CLERK_MAX_SAL RESEARCH_MANAGER_MAX_SAL SALES_CLERK_MAX_SAL SALES_MANAGER_MAX_SAL SALES_SALESMAN_MAX_SAL
------------------------ -------------------------- ---------------------------- ------------------------ ---------------------- ------------------------ ------------------- --------------------- ----------------------
                    1300                       2450                         5000                     3000                   1100                     2975                 950                  2850                   1600

Shorter pivot column names

The pivot column names can get long real fast. With this generic function, the way to shorten the names is to shorten the input data.

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select initcap(substr(dname,1,4)) dname, initcap(substr(job,1,5)) job, sal from DEPT join EMP using(DEPTNO))', 
  4      'MIN(SAL) "Min_Sal"', 
  5      'JOB,DNAME'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


Analy_Rese_Min_Sal Clerk_Acco_Min_Sal Clerk_Rese_Min_Sal Clerk_Sale_Min_Sal Manag_Acco_Min_Sal Manag_Rese_Min_Sal Manag_Sale_Min_Sal Presi_Acco_Min_Sal Sales_Sale_Min_Sal
------------------ ------------------ ------------------ ------------------ ------------------ ------------------ ------------------ ------------------ ------------------
              3000               1300                800                950               2450               2975               2850               5000               1250

Two aggregates, two FOR columns

I would rather not do this by hand…

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select substr(dname,1,2) dname, substr(job,1,2) job, sal from DEPT join EMP using(DEPTNO))', 
  4      'count(*),AVG(SAL) AVG', 
  5      'DNAME,JOB'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


     AC_CL  AC_CL_AVG      AC_MA  AC_MA_AVG      AC_PR  AC_PR_AVG      RE_AN  RE_AN_AVG      RE_CL  RE_CL_AVG      RE_MA  RE_MA_AVG      SA_CL  SA_CL_AVG      SA_MA  SA_MA_AVG      SA_SA  SA_SA_AVG
---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ----------
         1       1300          1       2450          1       5000          1       3000          2        950          1       2975          1        950          1       2850          4       1400

What if the FOR column is a date?

I want to break down the number of hires in each department by month, so why not truncate HIREDATE by month, then just display the month and the year?

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select dname, to_char(trunc(hiredate,''MM''),''MON-yyyy'') hiredate from DEPT join EMP using(DEPTNO))', 
  4      'count(*)', 
  5      'hiredate'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


DNAME            APR-1981   DEC-1980   DEC-1981   FEB-1981   JAN-1982   JUN-1981   MAY-1981   MAY-1987   NOV-1981   SEP-1981
-------------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING              0          0          0          0          1          1          0          0          1          0
RESEARCH                1          1          1          0          0          0          0          1          0          0
SALES                   0          0          1          2          0          0          1          0          0          2

Oops! The input HIREDATE is now a string, so the pivot column names are ordered by the string values.

Ordering dates in date order

Here is a situation where implicit conversion is your friend. Just keep the truncated date in your input query and set NLS_DATE_FORMAT as you wish. I’ll order the columns by date and Oracle will convert the dates to strings in the format you specified.

SQL> alter session set nls_date_format='mon-yyyy';

Session altered.

SQL> begin
  2    :rc := advanced_pivot(
  3      '(select dname, trunc(hiredate,''MM'') hiredate from DEPT join EMP using(DEPTNO))', 
  4      'count(*)', 
  5      'hiredate'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc


DNAME            dec-1980   feb-1981   apr-1981   may-1981   jun-1981   sep-1981   nov-1981   dec-1981   jan-1982   may-1987
-------------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING              0          0          0          0          1          0          1          0          1          0
RESEARCH                1          0          1          0          0          0          0          1          0          1
SALES                   0          2          0          1          0          2          0          1          0          0

The Code

create or replace function advanced_pivot(
  p_source in varchar2,       -- table, view or query in parentheses
  p_aggfuncs in varchar2,     -- one or more aggregation functions with or without aliases
  p_pivotfor in varchar2,     -- one or more columns from the input separated by commas
  p_total_label in varchar2 default null, -- label to use for pivot totals (if CUBE, ROLLUP, etc.)
  p_ordercol in varchar2 default null     -- column(s) to order by with p_total_label last
) return sys_refcursor authid current_user is
/*
Calculates pivot_in_list using SQL 1, updates SQL 2 text and opens ref cursor.
- Pivot_in_list concatenates values from all PIVOT_FOR columns
  for example, if (DNAME,JOB) then
    ('ACCOUNTING','CLERK') as "ACCOUNTING_CLERK",
    ('ACCOUNTING','MANAGER') as "ACCOUNTING_MANAGER",
  and so on
*/
  l_sql sys.odcivarchar2list := sys.odcivarchar2list(
  
q'@select listagg('(''' || expr || ''') as "' || al || '"', ',
')
within group(order by #ORDERFOR#)
from (select distinct
#EXPR# expr,
#ALIAS# al,
#PIVOTFOR#
from #SOURCE#)@',

'select * from #SOURCE#
pivot(#AGGFUNCS# for (#PIVOTFOR#) in (
#PIVOTINLIST#
)) order by #ORDERCOL#1,2,3'
  );
  l_pivotfor varchar2(255);
  l_orderfor varchar2(255);
  l_refcur sys_refcursor;
  l_pivotinlist varchar2(32767);
  l_expr varchar2(4000);
  l_alias varchar2(4000);
  l_ordercol varchar2(255);
begin
  l_pivotfor := trim( ',' from replace(p_pivotfor,' ') );
  l_orderfor := 
    case when p_total_label is null 
      then l_pivotfor
      else 'nullif(' 
        || replace(l_pivotfor, ',', ','''||p_total_label||'''),nullif(') 
        || ','''||p_total_label||''')'
    end;
  l_sql(1) := replace(l_sql(1), '#ORDERFOR#', l_orderfor);
  
  l_expr := replace(l_pivotfor,',',q'%||''','''||%');
  l_sql(1) := replace(l_sql(1), '#EXPR#', l_expr);
  
  l_alias := replace(l_pivotfor,',',q'%||'_'||%');
  l_sql(1) := replace(l_sql(1), '#ALIAS#', l_alias);
  
  for i in 1..l_sql.count loop
    l_sql(i) := replace(l_sql(i), '#SOURCE#', p_source);
    l_sql(i) := replace(l_sql(i), '#PIVOTFOR#', l_pivotfor);
  end loop;
  
  dbms_output.put_line(l_sql(1));
  dbms_output.put_line('/');
  open l_refcur for l_sql(1);
  fetch l_refcur into l_pivotinlist;
  close l_refcur;
  
  l_sql(2) := replace(l_sql(2), '#AGGFUNCS#', p_aggfuncs);
  l_sql(2) := replace(l_sql(2), '#PIVOTINLIST#', l_pivotinlist);
  
  
  l_ordercol := trim( ',' from replace(p_ordercol,' ') );
  l_ordercol :=
    case when p_total_label is null or l_ordercol is null
      then l_ordercol
      else 'nullif(' 
        || replace(l_ordercol, ',', ','''||p_total_label||'''),nullif(') 
        || ','''||p_total_label||'''),'
    end;
  l_sql(2) := replace(l_sql(2), '#ORDERCOL#', l_ordercol);
  
  dbms_output.put_line(l_sql(2));
  dbms_output.put_line('/');
  open l_refcur for l_sql(2);
  return l_refcur;
  
end advanced_pivot;
/

I really enjoyed the challenge of writing a truly generic function. I hope you enjoy using it!

Generic Pivot Function

Many folks ask how to do a “dynamic pivot”. It’s often best to use a preliminary query to generate the PIVOT clause. Here’s a generic function to do just that.

[UPDATE 2018-07-31: for advanced use cases, I wrote a more powerful function called ADVANCED_PIVOT, which I explain here: Improved PIVOT Function]

Anatomy of PIVOT

PIVOT clause from Oracle documentation

Let’s take a simple example, getting the average salary by department and by job:

select dname, job, avg(sal) avg_sal
from DEPT join EMP using(DEPTNO)
group by dname, job
order by 1,2;
DNAME JOB AVG_SAL
ACCOUNTING CLERK 1300
ACCOUNTING MANAGER 2450
ACCOUNTING PRESIDENT 5000
RESEARCH ANALYST 3000
RESEARCH CLERK 950
RESEARCH MANAGER 2975
SALES CLERK 950
SALES MANAGER 2850
SALES SALESMAN 1400

 

We want three rows, one per department, and five “pivot columns” for each of the five distinct jobs. The JOB column data become pivot column names and the SAL column data become pivot column data. The desired result is:

DNAME ANALYST CLERK MANAGER PRESIDENT SALESMAN
ACCOUNTING 1300 2450 5000
RESEARCH 3000 950 2975
SALES 950 2850 1400

 

A PIVOT clause needs:

  1. One row source for input: a table, view or query in parentheses. Each column in the input must have a unique name. The row source does not need to do a GROUP BY: the PIVOT clause will do the aggregation.
  2. One or more aggregate functions: they produce the data for the pivot columns.
  3. a PIVOT_FOR clause with one or more columns whose values will correspond to new column names.
  4. a PIVOT_IN clause that lists the pivot columns with their source values.
select * from (
  select dname, job, sal from DEPT join EMP using(DEPTNO)
)
pivot(
  AVG(SAL)
  for JOB
  in (
    'ANALYST' as "ANALYST",
    'CLERK' as "CLERK",
    'MANAGER' as "MANAGER",
    'PRESIDENT' as "PRESIDENT",
    'SALESMAN' as "SALESMAN"
  )
) order by 1
  • line 2 is the input.
  • line 5 is the aggregate function.
    There will be an implicit GROUP BY of DNAME (because not mentioned in the PIVOT) and JOB (because mentioned in the PIVOT_FOR clause).
  • line 6 is the PIVOT_FOR clause: JOB
  • lines 7 through 13 are the PIVOT_IN clause.
    In line 8, a pivot column called ANALYST is defined for when JOB = ‘ANALYST’. The result of the AVG(SAL) function for DNAME = ‘RESEARCH’ and JOB = ‘ANALYST’ will go into the ‘RESEARCH’ row and the ANALYST column.

A Generic PIVOT Template

I like to use what I call “SQL templates“. They are a way of describing a pattern: you distinguish what varies each time from what stays the same, and you put placeholders where the variable stuff should go. For PIVOT, the template has four placeholders.

select * from #SOURCE#
pivot(#AGGFUNCS# for #PIVOTFOR# in (
#PIVOTINLIST#
)) order by 1,2
  1. #SOURCE# is the row source: table, view, query in parentheses.
  2. #AGGFUNCS# is one or more aggregate functions separated by commas, with or without aliases.
  3. #PIVOTFOR# must contain exactly one column name from the source. This is a limitation of my function.
  4. #PIVOTINLIST# lists the pivot columns.

My function gets the first three placeholders from the input and generates the fourth one.

Generating the value for #PIVOTINLIST#

The problem with PIVOT is that the list of pivot columns depends on the data, so we need to know the data before querying it! In our example, we list five column names for the five JOBs in the EMP table, but how do we know what those jobs are?

My function uses a preliminary query to get that list. It has its own template using two of the same placeholders:

q'£select listagg('''' || #PIVOTFOR# || ''' as "' || #PIVOTFOR# || '"', ',')
within group(order by #PIVOTFOR#)
from (select distinct #PIVOTFOR# from #SOURCE#)£'

The Code

create or replace function generic_pivot(
  p_source in varchar2,   -- table, view or query in parentheses
  p_pivotfor in varchar2, -- one column from the input
  p_aggfuncs in varchar2  -- one or more aggregation functions
) return sys_refcursor is

-- Calculates pivot_in_list using SQL 1, updates SQL 2 text and opens ref cursor.
-- Pivot_for_clause can contain one column

  l_sql sys.odcivarchar2list := sys.odcivarchar2list(

q'£select listagg('''' || #PIVOTFOR# || ''' as "' || #PIVOTFOR# || '"', ',')
within group(order by #PIVOTFOR#)
from (select distinct #PIVOTFOR# from #SOURCE#)£',

'select * from #SOURCE#
pivot(#AGGFUNCS# for #PIVOTFOR# in (
#PIVOTINLIST#
)) order by 1,2'
  );
  l_refcur sys_refcursor;
  l_pivotinlist varchar2(32767);
begin
  for i in 1..l_sql.count loop
    l_sql(i) := replace(l_sql(i), '#SOURCE#', p_source);
    l_sql(i) := replace(l_sql(i), '#PIVOTFOR#', p_pivotfor);
  end loop;
  dbms_output.put_line(l_sql(1));
  dbms_output.put_line('/');
  open l_refcur for l_sql(1);
  fetch l_refcur into l_pivotinlist;
  close l_refcur;
  l_sql(2) := replace(l_sql(2), '#AGGFUNCS#', p_aggfuncs);
  l_sql(2) := replace(l_sql(2), '#PIVOTINLIST#', l_pivotinlist);
  dbms_output.put_line(l_sql(2));
  dbms_output.put_line('/');
  open l_refcur for l_sql(2);
  return l_refcur;
end generic_pivot;
/

Test results

SQL> set serveroutput off
SQL> var rc refcursor
SQL> begin
  2    :rc := generic_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'JOB', 
  5      'AVG(SAL)'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc

DNAME             ANALYST      CLERK    MANAGER  PRESIDENT   SALESMAN
-------------- ---------- ---------- ---------- ---------- ----------
ACCOUNTING                      1300       2450       5000           
RESEARCH             3000        950       2975                      
SALES                            950       2850                  1400


SQL> begin
  2    :rc := generic_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'DNAME', 
  5      'AVG(SAL)'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc

JOB       ACCOUNTING   RESEARCH      SALES
--------- ---------- ---------- ----------
ANALYST                    3000           
CLERK           1300        950        950
MANAGER         2450       2975       2850
PRESIDENT       5000                      
SALESMAN                              1400


SQL> begin
  2    :rc := generic_pivot(
  3      '(select dname, job, sal from DEPT join EMP using(DEPTNO))', 
  4      'JOB', 
  5      'MIN(SAL) min, MAX(SAL) max'
  6    );
  7  end;
  8  /

PL/SQL procedure successfully completed.

SQL> print :rc

DNAME          ANALYST_MIN ANALYST_MAX  CLERK_MIN  CLERK_MAX MANAGER_MIN MANAGER_MAX PRESIDENT_MIN PRESIDENT_MAX SALESMAN_MIN SALESMAN_MAX
-------------- ----------- ----------- ---------- ---------- ----------- ----------- ------------- ------------- ------------ ------------
ACCOUNTING                                   1300       1300        2450        2450          5000          5000                          
RESEARCH              3000        3000        800       1100        2975        2975                                                      
SALES                                         950        950        2850        2850                                     1250         1600

Hope this helps!