How to Update Records in Bulk with a Preview Before Saving in Oracle APEX

Filters, a preview report of old and new values, and an Apply button that runs one UPDATE only if the data still matches the preview.

A bulk update changes many rows with one action, such as raising all prices in a category by 10 percent. It saves a lot of clicks, but a wrong filter or a wrong number can change hundreds of rows at once.

Users need to see the result before anything is saved, and the change must be easy to trace later.

This guide builds a bulk update page in Oracle APEX with filters, a preview of the old and new values, and an Apply button that runs one UPDATE. A preview key makes sure the saved change is exactly what the user saw, and a history table keeps the old and new value of every row.

The guide uses product prices as the example; the same steps work for any table and any column. The post How to Process Selected Report Rows with Checkboxes in Oracle APEX lets users pick single rows by hand.

Here the rows come from filters instead, and nothing changes until the user has checked a preview.

Step 1: Create the Tables

The example needs four tables:

  • BLK_CATEGORIES holds the categories used as a filter.
  • BLK_PRODUCTS holds the rows to update, with UNIT_PRICE, an IS_ACTIVE flag and the columns PRICE_CHANGED_ON and PRICE_CHANGED_BY.
  • BLK_PRICE_CHANGES keeps one row for every bulk change: the filter, the percent, the rounding, the number of rows and the user.
  • BLK_PRICE_CHANGE_LINES keeps the old and the new price of every product in a change.

Create the tables and the sample data:

Example:

create table blk_categories (
  category_id    number generated by default on null as identity,
  category_name  varchar2(50)  not null,
  constraint blk_categories_pk primary key (category_id),
  constraint blk_categories_name_uk unique (category_name)
);

create table blk_products (
  product_id        number generated by default on null as identity,
  sku               varchar2(20)   not null,
  product_name      varchar2(100)  not null,
  category_id       number         not null,
  unit_price        number(10,2)   not null,
  is_active         varchar2(1)    default on null 'Y' not null,
  price_changed_on  date,
  price_changed_by  varchar2(128),
  constraint blk_products_pk primary key (product_id),
  constraint blk_products_sku_uk unique (sku),
  constraint blk_products_category_fk foreign key (category_id)
    references blk_categories (category_id),
  constraint blk_products_price_ck check (unit_price > 0),
  constraint blk_products_active_ck check (is_active in ('Y', 'N'))
);

create index blk_products_category_ix on blk_products (category_id, is_active);

create table blk_price_changes (
  change_id       number generated by default on null as identity,
  category_id     number,
  percent_change  number(5,2)    not null,
  rounding        varchar2(10)   not null,
  rows_updated    number         not null,
  changed_by      varchar2(128)  not null,
  changed_on      date           default on null sysdate not null,
  constraint blk_price_changes_pk primary key (change_id),
  constraint blk_price_changes_category_fk foreign key (category_id)
    references blk_categories (category_id),
  constraint blk_price_changes_percent_ck
    check (percent_change between -50 and 100 and percent_change <> 0),
  constraint blk_price_changes_rounding_ck
    check (rounding in ('NONE', 'WHOLE', 'END99'))
);

create table blk_price_change_lines (
  change_id   number        not null,
  product_id  number        not null,
  old_price   number(10,2)  not null,
  new_price   number(10,2)  not null,
  constraint blk_price_change_lines_pk primary key (change_id, product_id),
  constraint blk_price_change_lines_change_fk foreign key (change_id)
    references blk_price_changes (change_id) on delete cascade,
  constraint blk_price_change_lines_product_fk foreign key (product_id)
    references blk_products (product_id)
);

create index blk_price_change_lines_prod_ix on blk_price_change_lines (product_id);

insert into blk_categories (category_name) values ('Office Supplies');
insert into blk_categories (category_name) values ('Electronics');
insert into blk_categories (category_name) values ('Furniture');

insert into blk_products (sku, product_name, category_id, unit_price) values ('OS-100', 'Stapler', 1, 8.50);
insert into blk_products (sku, product_name, category_id, unit_price) values ('OS-110', 'Paper A4, 500 Sheets', 1, 5.20);
insert into blk_products (sku, product_name, category_id, unit_price) values ('OS-120', 'Gel Pens, Pack of 10', 1, 12.75);
insert into blk_products (sku, product_name, category_id, unit_price) values ('OS-130', 'Desk Organizer', 1, 19.90);
insert into blk_products (sku, product_name, category_id, unit_price, is_active) values ('OS-140', 'Fax Paper Roll', 1, 4.10, 'N');
insert into blk_products (sku, product_name, category_id, unit_price) values ('EL-200', 'Wireless Mouse', 2, 24.99);
insert into blk_products (sku, product_name, category_id, unit_price) values ('EL-210', 'USB-C Hub', 2, 39.00);
insert into blk_products (sku, product_name, category_id, unit_price) values ('EL-220', '27-inch Monitor', 2, 219.00);
insert into blk_products (sku, product_name, category_id, unit_price) values ('FU-300', 'Office Chair', 3, 149.00);
insert into blk_products (sku, product_name, category_id, unit_price) values ('FU-310', 'Standing Desk', 3, 389.50);
commit;

select p.sku, p.product_name, c.category_name, p.unit_price, p.is_active
from blk_products p
join blk_categories c on c.category_id = p.category_id
order by p.sku;

Output:

Table BLK_CATEGORIES created.

Table BLK_PRODUCTS created.

Index BLK_PRODUCTS_CATEGORY_IX created.

Table BLK_PRICE_CHANGES created.

Table BLK_PRICE_CHANGE_LINES created.

Index BLK_PRICE_CHANGE_LINES_PROD_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

SKU       PRODUCT_NAME            CATEGORY_NAME         UNIT_PRICE IS_ACTIVE
_________ _______________________ __________________ _____________ ____________
EL-200    Wireless Mouse          Electronics                24.99 Y
EL-210    USB-C Hub               Electronics                   39 Y
EL-220    27-inch Monitor         Electronics                  219 Y
FU-300    Office Chair            Furniture                    149 Y
FU-310    Standing Desk           Furniture                  389.5 Y
OS-100    Stapler                 Office Supplies              8.5 Y
OS-110    Paper A4, 500 Sheets    Office Supplies              5.2 Y
OS-120    Gel Pens, Pack of 10    Office Supplies            12.75 Y
OS-130    Desk Organizer          Office Supplies             19.9 Y
OS-140    Fax Paper Roll          Office Supplies              4.1 N

10 rows selected.
  • BLK_PRODUCTS_PRICE_CK rejects a price of zero or less, whatever code runs the update.
  • BLK_PRICE_CHANGES_PERCENT_CK allows a percent between -50 and 100, other than 0.
  • BLK_PRICE_CHANGES_ROUNDING_CK allows only the three rounding options of the page.
  • BLK_PRICE_CHANGES.CATEGORY_ID is NULL when a change applies to all categories.
  • IS_ACTIVE and CHANGED_ON use DEFAULT ON NULL, so no trigger is needed to fill them.

OS-140 Fax Paper Roll is inactive. A bulk change must never touch it.

Step 2: Create the Price Package

The rules live in one package, so the page cannot skip them. It has three parts:

  • NEW_PRICE returns the new price for a percent and a rounding option. The preview and the update both call it, so they always agree.
  • PREVIEW_KEY returns a short text that describes a preview: the filter, the percent, the rounding, the number of rows, and a hash of every row ID with its current price.
  • APPLY_CHANGE locks the matching rows, checks the key again, runs the UPDATE and writes the history.

Create the package:

Example:

create or replace package blk_price_pkg as
  function new_price (
    p_price     in number,
    p_percent   in number,
    p_rounding  in varchar2
  ) return number deterministic;

  function preview_key (
    p_category_id  in number,
    p_percent      in number,
    p_rounding     in varchar2
  ) return varchar2;

  procedure apply_change (
    p_category_id  in  number,
    p_percent      in  number,
    p_rounding     in  varchar2,
    p_preview_key  in  varchar2,
    p_changed_by   in  varchar2,
    p_change_id    out number,
    p_count        out number
  );
end blk_price_pkg;
/

create or replace package body blk_price_pkg as
  procedure check_params (
    p_percent   in number,
    p_rounding  in varchar2
  ) is
  begin
    if p_percent is null or p_percent = 0 or p_percent not between -50 and 100 then
      raise_application_error(-20001,
        'Enter a percent change between -50 and 100, other than 0.');
    end if;
    if p_rounding is null or p_rounding not in ('NONE', 'WHOLE', 'END99') then
      raise_application_error(-20001, 'Select a valid rounding option.');
    end if;
  end check_params;

  function new_price (
    p_price     in number,
    p_percent   in number,
    p_rounding  in varchar2
  ) return number deterministic is
    l_price number := p_price * (1 + p_percent / 100);
  begin
    return case p_rounding
             when 'WHOLE' then greatest(round(l_price), 1)
             when 'END99' then trunc(l_price) + 0.99
             else round(l_price, 2)
           end;
  end new_price;

  function preview_key (
    p_category_id  in number,
    p_percent      in number,
    p_rounding     in varchar2
  ) return varchar2 is
    l_count number;
    l_hash  number;
  begin
    check_params(p_percent, p_rounding);
    select count(*),
           sum(ora_hash(product_id || ':' || unit_price))
    into   l_count, l_hash
    from   blk_products
    where  is_active = 'Y'
    and    (p_category_id is null or category_id = p_category_id);

    return nvl(to_char(p_category_id), 'ALL') || '|' || p_percent || '|'
        || p_rounding || '|' || l_count || '|' || nvl(l_hash, 0);
  end preview_key;

  procedure apply_change (
    p_category_id  in  number,
    p_percent      in  number,
    p_rounding     in  varchar2,
    p_preview_key  in  varchar2,
    p_changed_by   in  varchar2,
    p_change_id    out number,
    p_count        out number
  ) is
    type t_numbers is table of number;
    l_ids        t_numbers;
    l_old_prices t_numbers;
    l_new_prices t_numbers;
    l_count      pls_integer;
  begin
    check_params(p_percent, p_rounding);

    select product_id
    bulk collect into l_ids
    from   blk_products
    where  is_active = 'Y'
    and    (p_category_id is null or category_id = p_category_id)
    for update;

    l_count := l_ids.count;
    if l_count = 0 then
      raise_application_error(-20002, 'No products match the filters.');
    end if;

    if p_preview_key is null
       or p_preview_key <> preview_key(p_category_id, p_percent, p_rounding) then
      raise_application_error(-20003,
        'The filters or the prices changed after the preview. Click Preview again.');
    end if;

    insert into blk_price_changes
      (category_id, percent_change, rounding, rows_updated, changed_by)
    values
      (p_category_id, p_percent, p_rounding, l_count, p_changed_by)
    returning change_id into p_change_id;

    update blk_products
    set    unit_price       = new_price(unit_price, p_percent, p_rounding),
           price_changed_on = sysdate,
           price_changed_by = p_changed_by
    where  is_active = 'Y'
    and    (p_category_id is null or category_id = p_category_id)
    returning product_id, old unit_price, new unit_price
    bulk collect into l_ids, l_old_prices, l_new_prices;

    forall i in 1 .. l_ids.count
      insert into blk_price_change_lines (change_id, product_id, old_price, new_price)
      values (p_change_id, l_ids(i), l_old_prices(i), l_new_prices(i));

    p_count := l_ids.count;
  end apply_change;
end blk_price_pkg;
/

select object_name, object_type, status
from user_objects
where object_name = 'BLK_PRICE_PKG'
order by object_type;

Output:

Package BLK_PRICE_PKG compiled

Package Body BLK_PRICE_PKG compiled

OBJECT_NAME      OBJECT_TYPE     STATUS
________________ _______________ _________
BLK_PRICE_PKG    PACKAGE         VALID
BLK_PRICE_PKG    PACKAGE BODY    VALID

The three rounding options work like this. NONE rounds to cents. WHOLE rounds to a whole number, but never below 1. END99 keeps the whole part and sets the cents to .99, so 9.35 becomes 9.99.

APPLY_CHANGE first selects the matching rows with FOR UPDATE. While the rows are locked, no other session can change their prices.

It then builds the key again from the locked rows. If the key differs from the key of the preview, the filters or the prices have changed since the user looked at them, and the procedure stops with ORA-20003.

The UPDATE uses RETURNING with OLD and NEW values to collect the old and the new price of every row in one statement. FORALL then writes them into BLK_PRICE_CHANGE_LINES. RETURNING OLD and NEW is new in Oracle Database 23ai; on older releases, copy the old prices into the lines table before the UPDATE.

In a first version, the INSERT into BLK_PRICE_CHANGES used l_ids.count directly in the VALUES list. The package body did not compile and showed ORA-00984: column not allowed here. A collection method cannot be used inside a SQL statement, so the count is copied into the variable l_count first.

A trigger could not do this work. The check needs the preview key from the page and the name of the APEX user, and neither is a column of the table. The procedure does not commit; APEX commits at the end of the request.

Step 3: Test the Package in SQL

First compare the three rounding options for a 10 percent increase, and print a few preview keys:

Example:

set serveroutput on

select p.sku, p.product_name, to_char(p.unit_price, 'FM9990.00') as old_price,
       to_char(blk_price_pkg.new_price(p.unit_price, 10, 'NONE'), 'FM9990.00') as plus_10,
       to_char(blk_price_pkg.new_price(p.unit_price, 10, 'WHOLE'), 'FM9990.00') as whole,
       to_char(blk_price_pkg.new_price(p.unit_price, 10, 'END99'), 'FM9990.00') as end_99
from blk_products p
where p.category_id = 1
and p.is_active = 'Y'
order by p.sku;

begin
  dbms_output.put_line('Office +10% END99: ' || blk_price_pkg.preview_key(1, 10, 'END99'));
  dbms_output.put_line('Office +10% WHOLE: ' || blk_price_pkg.preview_key(1, 10, 'WHOLE'));
  dbms_output.put_line('All -5% NONE:      ' || blk_price_pkg.preview_key(null, -5, 'NONE'));
end;
/

Output:

SKU       PRODUCT_NAME            OLD_PRICE    PLUS_10    WHOLE    END_99
_________ _______________________ ____________ __________ ________ _________
OS-100    Stapler                 8.50         9.35       9.00     9.99
OS-110    Paper A4, 500 Sheets    5.20         5.72       6.00     5.99
OS-120    Gel Pens, Pack of 10    12.75        14.03      14.00    14.99
OS-130    Desk Organizer          19.90        21.89      22.00    21.99

Office +10% END99: 1|10|END99|4|9073310215
Office +10% WHOLE: 1|10|WHOLE|4|9073310215
All -5% NONE:      ALL|-5|NONE|9|24420305703

PL/SQL procedure successfully completed.

The two Office Supplies keys differ only in the rounding part. The count 4 leaves out the inactive OS-140, and the hash part changes when any matching price changes.

Now call APPLY_CHANGE with good and bad input. The test is rolled back at the end:

Example:

set serveroutput on
declare
  l_key       varchar2(200);
  l_change_id number;
  l_count     number;

  procedure try (p_label in varchar2, p_category_id in number, p_percent in number,
                 p_rounding in varchar2, p_key in varchar2) is
  begin
    blk_price_pkg.apply_change(p_category_id, p_percent, p_rounding, p_key,
                               'DEMO_DEV', l_change_id, l_count);
    dbms_output.put_line(rpad(p_label, 28) || 'OK, change ' || l_change_id
                         || ', ' || l_count || ' products');
  exception
    when others then
      dbms_output.put_line(rpad(p_label, 28) || sqlerrm);
  end try;
begin
  l_key := blk_price_pkg.preview_key(2, 5, 'NONE');
  try('Electronics +5%', 2, 5, 'NONE', l_key);
  try('Same key again', 2, 5, 'NONE', l_key);
  try('Key of +5%, apply +8%', 2, 8, 'NONE', l_key);
  try('No preview key', 3, 5, 'NONE', null);

  l_key := blk_price_pkg.preview_key(3, -10, 'WHOLE');
  update blk_products set unit_price = 155 where sku = 'FU-300';
  try('Price changed after preview', 3, -10, 'WHOLE', l_key);

  try('Percent 0', 3, 0, 'NONE', 'x');
  try('Percent 250', 3, 250, 'NONE', 'x');
  try('Rounding UP', 3, 5, 'UP', 'x');
  try('Category without products', 99, 5, 'NONE', 'x');
end;
/

select p.sku, to_char(p.unit_price, 'FM9990.00') as unit_price, p.is_active,
       p.price_changed_by
from blk_products p
order by p.sku;

select l.change_id, p.sku, to_char(l.old_price, 'FM9990.00') as old_price,
       to_char(l.new_price, 'FM9990.00') as new_price
from blk_price_change_lines l
join blk_products p on p.product_id = l.product_id
order by p.sku;

rollback;

Output:

Electronics +5%             OK, change 2, 3 products
Same key again              ORA-20003: The filters or the prices changed after the preview. Click Preview again.
Key of +5%, apply +8%       ORA-20003: The filters or the prices changed after the preview. Click Preview again.
No preview key              ORA-20003: The filters or the prices changed after the preview. Click Preview again.
Price changed after preview ORA-20003: The filters or the prices changed after the preview. Click Preview again.
Percent 0                   ORA-20001: Enter a percent change between -50 and 100, other than 0.
Percent 250                 ORA-20001: Enter a percent change between -50 and 100, other than 0.
Rounding UP                 ORA-20001: Select a valid rounding option.
Category without products   ORA-20002: No products match the filters.

PL/SQL procedure successfully completed.

SKU       UNIT_PRICE    IS_ACTIVE    PRICE_CHANGED_BY
_________ _____________ ____________ ___________________
EL-200    26.24         Y            DEMO_DEV
EL-210    40.95         Y            DEMO_DEV
EL-220    229.95        Y            DEMO_DEV
FU-300    155.00        Y
FU-310    389.50        Y
OS-100    8.50          Y
OS-110    5.20          Y
OS-120    12.75         Y
OS-130    19.90         Y
OS-140    4.10          N

10 rows selected.

   CHANGE_ID SKU       OLD_PRICE    NEW_PRICE
____________ _________ ____________ ____________
           2 EL-200    24.99        26.24
           2 EL-210    39.00        40.95
           2 EL-220    219.00       229.95

Rollback complete.

The first call updated the three Electronics prices and wrote three lines. Calling it again with the same key failed, because the prices were no longer the ones in the key. This also protects against a double click or a resubmitted page.

A key made for +5% did not work for +8%, an empty key did not work, and a price that changed after the preview stopped the update.

A percent of 0 or 250, an unknown rounding option and a category without products were all refused. Identity values are not reused after a rollback, so the change IDs in this example have gaps.

Step 4: Test Two Users Applying the Same Change

Two administrators may preview the same change and click Apply at nearly the same time. Run two sessions. Session A applies +10% to Furniture and commits six seconds later:

Example:

set serveroutput on
declare
  l_change_id number;
  l_count     number;
begin
  blk_price_pkg.apply_change(3, 10, 'NONE', blk_price_pkg.preview_key(3, 10, 'NONE'),
                             'DEMO_DEV', l_change_id, l_count);
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' A updated '
                       || l_count || ' products, change ' || l_change_id);
  dbms_session.sleep(6);
  commit;
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' A committed');
end;
/

Output:

22:58:13 A updated 2 products, change 3
22:58:19 A committed

PL/SQL procedure successfully completed.

Session B builds its key two seconds after A started, before A committed, and applies the same change:

Example:

set serveroutput on
declare
  l_key       varchar2(200) := blk_price_pkg.preview_key(3, 10, 'NONE');
  l_change_id number;
  l_count     number;
begin
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' B key ' || l_key);
  blk_price_pkg.apply_change(3, 10, 'NONE', l_key, 'SALES_MANAGER', l_change_id, l_count);
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' B updated ' || l_count);
  commit;
exception
  when others then
    dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' B ' || sqlerrm);
    rollback;
end;
/

select sku, to_char(unit_price, 'FM9990.00') as unit_price, price_changed_by
from blk_products
where category_id = 3
order by sku;

Output:

22:58:15 B key 3|10|NONE|2|4820198098
22:58:19 B ORA-20003: The filters or the prices changed after the preview. Click Preview again.

PL/SQL procedure successfully completed.

SKU       UNIT_PRICE    PRICE_CHANGED_BY
_________ _____________ ___________________
FU-300    163.90        DEMO_DEV
FU-310    428.45        DEMO_DEV

Session B still saw the old prices, so its key matched the old data. Its FOR UPDATE waited four seconds until A committed. B then built the key again from the new prices, saw the difference and stopped with ORA-20003.

The Furniture prices went up by 10 percent once, not twice. After this test, the Furniture prices were set back and the test change was removed.

Step 5: Create the Bulk Price Update Page

In App Builder, open the application, click Create Page and select Blank Page:

Create a Page wizard with the Blank Page tile marked with a red box
Select the Blank Page type.

Enter page number 720 and the name Bulk Price Update. Turn Use Breadcrumb off, keep Use Navigation on and enter the icon fa-percent. Click Create Page:

Create Blank Page screen with Page Number 720, Name Bulk Price Update, Use Breadcrumb off, Use Navigation on and Icon fa-percent marked with red boxes
The page number, name and navigation entry.

Only administrators may change prices in bulk. In Page Designer, select the page node and set Security, Authorization Scheme to Is Administrator:

Page 720 properties with Authorization Scheme Is Administrator marked with a red box
The page requires the Is Administrator scheme.

The wizard also created a Bulk Price Update entry in the navigation menu. In Shared Components, open Navigation Menu, edit the entry, set Authorization Scheme to Is Administrator and click Apply Changes:

List Entry Bulk Price Update with Authorization Scheme Is Administrator marked with a red box
Other users do not see the menu entry.

Step 6: Add the Filters Region

Right-click Body and select Create Region. Set Name to Filters and Type to Static Content:

Region Filters with Name and Type Static Content marked with red boxes
The region holds the three filter items and the Preview button.

Right-click Filters and select Create Page Item. Set Name to P720_CATEGORY_ID, Type to Select List and Label to Category. Set List of Values, Type to SQL Query and enter this query:

Example:

select category_name as d,
       category_id   as r
from blk_categories
order by category_name

Keep Display Null Value on and set Null Display Value to "- All Categories -":

Page item P720_CATEGORY_ID with Name, Type Select List, Label Category, LOV Type SQL Query, the query, Display Null Value and Null Display Value marked with red boxes
An empty category means all categories.

Create P720_PERCENT with Type Number Field and Label Percent Change. Under Settings, set Minimum Value to -50 and Maximum Value to 100:

Page item P720_PERCENT with Name, Type Number Field, Label Percent Change, Minimum Value -50 and Maximum Value 100 marked with red boxes
The same range as the check constraint.

Turn Validation, Value Required on:

Page item P720_PERCENT with Value Required on marked with a red box
A percent is always needed.

Under Help, set Inline Help Text to "Use a negative number to lower prices, for example -10.":

Page item P720_PERCENT with the Inline Help Text marked with a red box
The help text shows below the field.

Create P720_ROUNDING with Type Radio Group and Label Rounding. Set Number of Columns to 3. Set List of Values, Type to Static Values with these values:

  • Display No rounding, Return NONE
  • Display Whole number, Return WHOLE
  • Display Ends in .99, Return END99
Page item P720_ROUNDING with Name, Type Radio Group, Label Rounding, Number of Columns 3, LOV Type Static Values and the static values marked with red boxes
The three rounding options.

Turn Value Required on and set Default, Type to Static with Static Value NONE:

Page item P720_ROUNDING with Value Required on, Default Type Static and Static Value NONE marked with red boxes
No rounding is selected by default.

Right-click Filters and select Create Button. Set Button Name to PREVIEW, Label to Preview, Slot to Region Body and Hot on. Give it a sequence after the three items, so it shows below them:

Button PREVIEW with Button Name, Label Preview, Region Filters, Slot Region Body and Hot marked with red boxes
The Preview button submits the page.

All three filter items keep the default storage Per Session (Persistent). Their values stay in the session after the page reloads, so the preview report can read them.

Step 7: Add the Hidden Items

Right-click Body and create the page item P720_PREVIEW_KEY with Type Hidden. Keep Value Protected on and Storage Per Session (Persistent):

Page item P720_PREVIEW_KEY with Name, Type Hidden, Value Protected on and Storage Per Session marked with red boxes
The key of the last preview.

Only the server sets the key. Value Protected adds a checksum, so a key changed in the browser is rejected. Create P720_PREVIEW_COUNT the same way. It holds the number of rows in the preview.

Create P720_CHANGE_ID and P720_COUNT with Type Hidden and Storage Per Request (Memory Only). They receive the change ID and the row count for the success message:

Page item P720_COUNT with Name, Type Hidden and Storage Per Request Memory Only marked with red boxes
The values for the success message.

Step 8: Add the Preview Region

Create a region below Filters. Set Name to Preview, Type to Classic Report and enter this SQL Query:

Example:

select p.sku,
       p.product_name,
       c.category_name,
       p.unit_price as old_price,
       blk_price_pkg.new_price(p.unit_price, to_number(:P720_PERCENT), :P720_ROUNDING) as new_price,
       blk_price_pkg.new_price(p.unit_price, to_number(:P720_PERCENT), :P720_ROUNDING)
         - p.unit_price as difference
from blk_products p
join blk_categories c on c.category_id = p.category_id
where p.is_active = 'Y'
and (:P720_CATEGORY_ID is null or p.category_id = :P720_CATEGORY_ID)
order by c.category_name, p.sku
Region Preview with Name, Type Classic Report and the SQL Query marked with red boxes
Old price, new price and difference for each matching row.

The query calls BLK_PRICE_PKG.NEW_PRICE, the same function the UPDATE uses. The WHERE clause is the same filter as in the package: active rows, and one category or all of them.

Set Server-side Condition, Type to Item is NOT NULL and Item to P720_PREVIEW_KEY. The region shows only after a preview:

Preview region with Server-side Condition Type Item is NOT NULL and Item P720_PREVIEW_KEY marked with a red box
No preview key, no Preview region.

Expand Columns. Set the headings to SKU, Product, Category, Old Price, New Price and Difference. Set Format Mask to 999G999G990D00 for OLD_PRICE, NEW_PRICE and DIFFERENCE:

Column NEW_PRICE with Heading New Price and Format Mask 999G999G990D00 marked with red boxes
The price columns show two decimals.

Under Messages, set When No Data Found to "No active products match the filters.".

Right-click Preview and select Create Button. Set Button Name to APPLY, Label to Apply Price Change, Slot to Next and Hot on:

Button APPLY with Button Name, Label Apply Price Change, Region Preview, Slot Next and Hot marked with red boxes
The Apply button submits the page.

Under Behavior, turn Requires Confirmation on. Set Confirmation, Message to "Update the prices of &P720_PREVIEW_COUNT. products?" and Style to Warning. Set Server-side Condition, Type to Item is NOT NULL and NOT zero with Item P720_PREVIEW_COUNT:

Button APPLY with Action Submit Page, Requires Confirmation on, the confirmation Message, Style Warning and Server-side Condition on P720_PREVIEW_COUNT marked with red boxes
APEX asks before it submits, and hides the button when no row matches.

Create a second button. Set Button Name to CANCEL, Label to Cancel, Slot to Previous, and Behavior, Action to Redirect to Page in this Application:

Button CANCEL with Button Name, Label, Region Preview, Slot Previous, Action Redirect to Page and Target marked with red boxes
Cancel reloads the page without submitting.

Click Target. Set Page to 720 and Clear Cache to 720, and click OK:

Link Builder Target dialog with Page 720 and Clear Cache 720 marked with red boxes
Clear Cache empties all items of page 720, including the preview key.

Step 9: Add the Price Change History Region

Create a third region. Set Name to Price Change History, Type to Classic Report and enter this SQL Query:

Example:

select h.change_id,
       nvl(c.category_name, 'All Categories') as category_name,
       h.percent_change,
       case h.rounding
         when 'NONE'  then 'No rounding'
         when 'WHOLE' then 'Whole number'
         when 'END99' then 'Ends in .99'
       end as rounding_label,
       h.rows_updated,
       h.changed_by,
       h.changed_on
from blk_price_changes h
left join blk_categories c on c.category_id = h.category_id
order by h.change_id desc
Region Price Change History with Name, Type Classic Report and the SQL Query marked with red boxes
One row for each bulk change, newest first.

Set the headings to Change, Category, Percent, Rounding, Products, Changed By and Changed On. Set Format Mask to DD-MON-YYYY HH24:MI for CHANGED_ON, and When No Data Found to "No price changes yet.".

Step 10: Add the Processes and the Branch

Open the Processing tab, right-click Processing and select Create Process. Set Name to Preview Price Change, Type to Execute Code and enter this PL/SQL Code:

Example:

:P720_PREVIEW_KEY := null;
:P720_PREVIEW_KEY := blk_price_pkg.preview_key(
  p_category_id => :P720_CATEGORY_ID,
  p_percent     => :P720_PERCENT,
  p_rounding    => :P720_ROUNDING);

select count(*)
into   :P720_PREVIEW_COUNT
from   blk_products
where  is_active = 'Y'
and    (:P720_CATEGORY_ID is null or category_id = :P720_CATEGORY_ID);
Process Preview Price Change with Name, Type Execute Code and the PL/SQL Code marked with red boxes
The process stores the key and the row count.

The process first clears the old key. PREVIEW_KEY then checks the percent and the rounding and raises an error for bad input, so a failed preview never leaves a usable key behind.

Set Success Message to "&P720_PREVIEW_COUNT. product(s) match. Check the new prices, then click Apply Price Change.", Error Message to #SQLERRM_TEXT#, and When Button Pressed to PREVIEW:

Process Preview Price Change with Success Message, Error Message SQLERRM_TEXT and When Button Pressed PREVIEW marked with red boxes
The messages and the button condition.

Create a second process. Set Name to Apply Price Change, Type to Execute Code and enter this code:

Example:

blk_price_pkg.apply_change(
  p_category_id => :P720_CATEGORY_ID,
  p_percent     => :P720_PERCENT,
  p_rounding    => :P720_ROUNDING,
  p_preview_key => :P720_PREVIEW_KEY,
  p_changed_by  => :APP_USER,
  p_change_id   => :P720_CHANGE_ID,
  p_count       => :P720_COUNT);

:P720_PREVIEW_KEY   := null;
:P720_PREVIEW_COUNT := null;
Process Apply Price Change with Name, Type Execute Code and the PL/SQL Code marked with red boxes
The process passes the filters, the key and the user to APPLY_CHANGE.

After a successful update, the process clears the key and the count, so the Preview region disappears. Set Success Message to "&P720_COUNT. price(s) updated in change &P720_CHANGE_ID..", Error Message to #SQLERRM_TEXT#, and When Button Pressed to APPLY:

Process Apply Price Change with Success Message, Error Message SQLERRM_TEXT and When Button Pressed APPLY marked with red boxes
#SQLERRM_TEXT# shows the package message without the ORA- number.

The first dot after &P720_CHANGE_ID ends the substitution, and the second one ends the sentence. Right-click After Processing and select Create Branch. Set Name to Back to Bulk Price Update, Type to Page or URL (Redirect) and Target to page 720:

Branch Back to Bulk Price Update with Name, Type Page or URL Redirect and Target Page 720 marked with red boxes
The page reloads after Preview and after Apply.

Save the page.

Step 11: Test the Preview and Apply

Run page 720 as DEMO_DEV, an administrator. Select Office Supplies, enter 10 and select Ends in .99:

Filters region with Category Office Supplies, Percent Change 10 and Rounding Ends in .99 marked with a red box
Office Supplies plus 10 percent, ending in .99.

Click Preview. The message says 4 product(s) match, and the Preview region lists the four active Office Supplies products with the old and the new price. The inactive OS-140 is not in the list:

Bulk Price Update page with the success message and the Preview region with four products marked with a red box
The preview shows every change before it is saved.

Nothing has been saved yet. The preview shows that Ends in .99 raised the Stapler from 8.50 to 9.99, which is more than 10 percent. This is exactly the kind of result a user should see before saving.

Click Apply Price Change. APEX shows the confirmation message with the number of rows:

Confirmation dialog Update the prices of 4 products with the buttons Cancel and Apply Price Change
The confirmation dialog.

Clicking Cancel in the dialog closed it, and the history stayed empty. Clicking Apply Price Change in the dialog updated the prices:

Success message 4 prices updated in change 21 and the new row in Price Change History marked with red boxes
One change with four products.

Check the result in SQL:

Example:

select p.sku, c.category_name, to_char(p.unit_price, 'FM9990.00') as unit_price,
       p.is_active, p.price_changed_by
from blk_products p
join blk_categories c on c.category_id = p.category_id
order by p.sku;

select l.change_id, p.sku, to_char(l.old_price, 'FM9990.00') as old_price,
       to_char(l.new_price, 'FM9990.00') as new_price
from blk_price_change_lines l
join blk_products p on p.product_id = l.product_id
order by l.change_id, p.sku;

Output:

SKU       CATEGORY_NAME      UNIT_PRICE    IS_ACTIVE    PRICE_CHANGED_BY
_________ __________________ _____________ ____________ ___________________
EL-200    Electronics        24.99         Y
EL-210    Electronics        39.00         Y
EL-220    Electronics        219.00        Y
FU-300    Furniture          149.00        Y
FU-310    Furniture          389.50        Y
OS-100    Office Supplies    9.99          Y            DEMO_DEV
OS-110    Office Supplies    5.99          Y            DEMO_DEV
OS-120    Office Supplies    14.99         Y            DEMO_DEV
OS-130    Office Supplies    21.99         Y            DEMO_DEV
OS-140    Office Supplies    4.10          N

10 rows selected.

   CHANGE_ID SKU       OLD_PRICE    NEW_PRICE
____________ _________ ____________ ____________
          21 OS-100    8.50         9.99
          21 OS-110    5.20         5.99
          21 OS-120    12.75        14.99
          21 OS-130    19.90        21.99

Only the four active Office Supplies rows changed. Electronics, Furniture and the inactive OS-140 kept their prices, and the lines table holds the old and the new price of each changed row.

Step 12: Test Cancel

Select Electronics, enter -10, select Whole number and click Preview. The Preview region shows the lower prices. Then click Cancel:

Preview of Electronics minus 10 percent rounded to whole numbers with the Cancel button marked with a red box
A preview of a 10 percent price cut.

The page reloads with empty filters, the default rounding and no Preview region. The history still has one change, and the Electronics prices did not change:

Bulk Price Update page after Cancel with empty filters and one row in Price Change History
Cancel changed nothing.

Step 13: Test the Error Cases

First, a price changes after the preview. Preview Electronics with +5% and No rounding. Before clicking Apply, another session changes the price of EL-200:

Example:

update blk_products
set    unit_price = 25.49
where  sku = 'EL-200';
commit;

Now click Apply Price Change and confirm. The package finds a different key and stops:

Error The filters or the prices changed after the preview. Click Preview again, with the old preview still on the page
The preview on the screen is out of date, so nothing is saved.

The error did not reload the page, so the old preview with 24.99 is still on the screen. After clicking Preview again, the report showed EL-200 at 25.49 with a new price of 26.76, and Apply saved change 22:

Price Change History with change 22 Electronics 5 percent and change 21 Office Supplies 10 percent marked with a red box
The change was saved after a fresh preview.

Second, the filters change after the preview. Furniture was previewed with +5%, the percent was then changed to 50 without a new preview, and Apply was clicked. APEX submitted the new value, the key did not match, and the same "changed after the preview" error appeared. No Furniture price changed.

Third, invalid input. A percent of 0 passed the item range check, and the package refused it in the Preview process:

Error Enter a percent change between -50 and 100, other than 0, with Percent Change 0 marked with a red box
A change of 0 percent is refused.

A percent of 150 was stopped by the Maximum Value of the Number Field:

Error Percent Change must be a number between -50 and 100, with the field marked with a red box
The Number Field range check.

Fourth, forced requests from the browser console. Without any preview, this call was run with Furniture and 5 percent in the filters:

Example:

apex.page.submit('APPLY');

The Apply process ran, because the hidden button does not stop a request, but the empty key failed the check. The page showed "The filters or the prices changed after the preview. Click Preview again." and nothing was saved.

After a real Furniture preview, the console changed the hidden key before Apply:

Example:

apex.item('P720_PREVIEW_KEY').setValue('3|5|NONE|2|1');

APEX stopped the request with "Session state protection violation: This may be caused by manual alteration of protected page item P720_PREVIEW_KEY.", because Value Protected is on.

Last, a user without the administrator role. SALES_USER does not see Bulk Price Update in the menu, and opening the page URL is blocked:

Access denied page with the message Only administrators can use this page
Only administrators can open the page.

The final state in SQL:

Example:

select p.sku, c.category_name, to_char(p.unit_price, 'FM9990.00') as unit_price,
       p.price_changed_by
from blk_products p
join blk_categories c on c.category_id = p.category_id
where p.is_active = 'Y'
order by p.sku;

select change_id, category_id, percent_change, rounding, rows_updated, changed_by
from blk_price_changes
order by change_id;

Output:

SKU       CATEGORY_NAME      UNIT_PRICE    PRICE_CHANGED_BY
_________ __________________ _____________ ___________________
EL-200    Electronics        26.76         DEMO_DEV
EL-210    Electronics        40.95         DEMO_DEV
EL-220    Electronics        229.95        DEMO_DEV
FU-300    Furniture          149.00
FU-310    Furniture          389.50
OS-100    Office Supplies    9.99          DEMO_DEV
OS-110    Office Supplies    5.99          DEMO_DEV
OS-120    Office Supplies    14.99         DEMO_DEV
OS-130    Office Supplies    21.99         DEMO_DEV

9 rows selected.

   CHANGE_ID    CATEGORY_ID    PERCENT_CHANGE ROUNDING       ROWS_UPDATED CHANGED_BY
____________ ______________ _________________ ___________ _______________ _____________
          21              1                10 END99                     4 DEMO_DEV
          22              2                 5 NONE                      3 DEMO_DEV

Two bulk changes were saved, Furniture never changed, and every changed row has PRICE_CHANGED_BY set.

Things to Know

  • Use the same function for the preview and the update, so the user saves exactly the numbers they saw.
  • Store a key of the preview and check it under a row lock. It catches changed filters, changed data, double clicks and two users applying the same change.
  • Lock the rows with FOR UPDATE before the check. A second session then waits, sees the committed data and stops.
  • A hidden button does not stop a forced request. Check the rules in the code that changes the data.
  • Keep Value Protected on for items that only the server sets.
  • RETURNING OLD and NEW values in an UPDATE fill a history table in one pass in Oracle Database 23ai and later.
  • A collection method such as l_ids.count cannot be used inside a SQL statement (ORA-00984); copy it into a variable first.

Related Guides

Conclusion

A safe bulk update in Oracle APEX needs a preview of the old and new values and an Apply step that saves exactly that preview. A PL/SQL package computes the new values, builds a key of the preview, locks the rows, checks the key again and runs one UPDATE that also fills a history table.

On the page, filters, a classic report and two buttons with a confirmation dialog are enough. Cancel, stale previews, changed filters, invalid percents, forced requests, a changed key and a user without the role were all tested, and none of them changed a price.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted