How to Warn About Possible Duplicate Records While Typing in Oracle APEX

Build a possible-duplicates warning with a virtual name key, Jaro-Winkler scores, a classic report in an Alert region and a debounced Key Release dynamic action.

Duplicate records creep into every table that people type into. One user saves "Northwind Traders", and a month later someone else adds "North Wind Trading" because they did not find the first one. Reports then split the history of one company over two rows.

A unique constraint cannot stop this, because the two names are not equal. A better fix is a friendly warning while the user types: a list of existing records with similar names, shown before the record is saved. The user decides whether it is really new.

This guide builds that warning in Oracle APEX with UTL_MATCH, a classic report region and a debounced dynamic action.

The guide uses business partners as the example; the same steps work for any table with a name column. The post Oracle Apex Validation Without Submit Using JavaScript and Ajax Callback checks a value with an Ajax callback and JavaScript. This one needs no JavaScript: it only uses a region refresh, and it warns instead of blocking the save.

Step 1: Create the Table

The example needs one table, PARTNERS. Next to the name, city and email, it has a virtual column NAME_KEY. It holds the name in upper case without spaces and punctuation, so "Fabrikam, Inc." and "fabrikam inc" get the same key.

Create the table and the sample data:

Example:

create table partners (
  partner_id    number generated by default on null as identity,
  partner_name  varchar2(100) not null,
  city          varchar2(60)  not null,
  email         varchar2(254),
  created_on    date          default on null sysdate not null,
  name_key      varchar2(100 char) generated always as
                  (upper(translate(partner_name, 'x .,-&''/', 'x'))) virtual,
  constraint partners_pk primary key (partner_id)
);

insert into partners (partner_name, city, email) values ('Northwind Traders', 'Seattle', 'info@northwind.example.com');
insert into partners (partner_name, city, email) values ('Blue Harbor Supplies', 'Boston', 'sales@blueharbor.example.com');
insert into partners (partner_name, city, email) values ('Alpine Components', 'Denver', 'hello@alpine.example.com');
insert into partners (partner_name, city, email) values ('Contoso Pharmaceuticals', 'Chicago', 'contact@contoso.example.com');
insert into partners (partner_name, city, email) values ('Fabrikam Inc.', 'Austin', 'office@fabrikam.example.com');
insert into partners (partner_name, city, email) values ('Adventure Works Cycles', 'Portland', 'bikes@adventureworks.example.com');
insert into partners (partner_name, city, email) values ('Tailspin Toys', 'Phoenix', 'play@tailspin.example.com');
insert into partners (partner_name, city, email) values ('Wide World Importers', 'Miami', 'trade@wideworld.example.com');
insert into partners (partner_name, city, email) values ('Fourth Coffee', 'San Diego', 'beans@fourthcoffee.example.com');
insert into partners (partner_name, city, email) values ('Litware Systems', 'Atlanta', 'support@litware.example.com');
commit;

select partner_id, partner_name, city, name_key
from   partners
order  by partner_id;

Output:

Table PARTNERS 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.

Commit complete.

   PARTNER_ID PARTNER_NAME               CITY         NAME_KEY
_____________ __________________________ ____________ _________________________
            1 Northwind Traders          Seattle      NORTHWINDTRADERS
            2 Blue Harbor Supplies       Boston       BLUEHARBORSUPPLIES
            3 Alpine Components          Denver       ALPINECOMPONENTS
            4 Contoso Pharmaceuticals    Chicago      CONTOSOPHARMACEUTICALS
            5 Fabrikam Inc.              Austin       FABRIKAMINC
            6 Adventure Works Cycles     Portland     ADVENTUREWORKSCYCLES
            7 Tailspin Toys              Phoenix      TAILSPINTOYS
            8 Wide World Importers       Miami        WIDEWORLDIMPORTERS
            9 Fourth Coffee              San Diego    FOURTHCOFFEE
           10 Litware Systems            Atlanta      LITWARESYSTEMS

10 rows selected.
  • TRANSLATE removes space, dot, comma, dash, ampersand, apostrophe and slash. The first character "x" maps to itself; it is only there because TRANSLATE returns NULL when its last argument is empty.
  • NAME_KEY is a virtual column, so no trigger or process has to fill it. It is always in step with PARTNER_NAME.
  • Declare it as VARCHAR2(100 CHAR). In a first test, VARCHAR2(100) failed with ORA-12899, because Oracle sizes the expression at 400 bytes in this AL32UTF8 database.
  • A first version used REGEXP_REPLACE(partner_name, '[^[:alnum:]]', '') for the key. Wrapped in a CAST, it failed with ORA-54002: only pure functions can be specified in a virtual column expression. TRANSLATE has no such problem.
  • CREATED_ON gets SYSDATE from DEFAULT ON NULL.

Step 2: Find Similar Names in SQL

UTL_MATCH.JARO_WINKLER_SIMILARITY compares two strings and returns a score from 0 to 100. It forgives typos and gives extra weight to a matching start. Run it on a few names a user might type, against the keys of the table:

Example:

with typed as (
  select column_value as typed_name
  from   sys.odcivarchar2list('North Wind Trading', 'Fabrikam', 'Contoso Pharma',
                              'Tailspin Toy', 'Blue Harbour Supply', 'Fourth Cafe',
                              'Northern Lights', 'Proseware Inc')
)
select t.typed_name,
       p.partner_name,
       utl_match.jaro_winkler_similarity(
         p.name_key, upper(translate(t.typed_name, 'x .,-&''/', 'x'))) as match_pct
from   typed t
       cross join partners p
where  utl_match.jaro_winkler_similarity(
         p.name_key, upper(translate(t.typed_name, 'x .,-&''/', 'x'))) >= 75
order  by match_pct desc;

Output:

TYPED_NAME             PARTNER_NAME                  MATCH_PCT
______________________ __________________________ ____________
Tailspin Toy           Tailspin Toys                        98
Fabrikam               Fabrikam Inc.                        94
Fourth Cafe            Fourth Coffee                        93
North Wind Trading     Northwind Traders                    92
Blue Harbour Supply    Blue Harbor Supplies                 92
Contoso Pharma         Contoso Pharmaceuticals              91
Northern Lights        Northwind Traders                    82

7 rows selected.

Real variants of existing partners score 91 or more. "Northern Lights" is a different company and scores 82 against Northwind Traders. Proseware Inc scores below 75 everywhere and is not listed. So this guide uses 85 as the limit: a score of 85 or more means "possibly the same".

Jaro-Winkler gives low scores to a short part of a long name, such as "Contoso" for Contoso Pharmaceuticals. So the check also lists rows whose key contains the typed key. Use INSTR for that, not LIKE. If a user types % or _, LIKE treats them as wildcards:

Example:

select count(*) as like_matches
from   partners
where  name_key like '%' || '%%%' || '%';

select count(*) as instr_matches
from   partners
where  instr(name_key, '%%%') > 0;

Output:

   LIKE_MATCHES
_______________
             10

   INSTR_MATCHES
________________
               0

With LIKE, typing "%%%" matched all ten partners. INSTR looks for the literal text and found none.

This is the full query for the warning. It reads two page items, so the test sets them as SQLcl bind variables first:

Example:

variable P401_PARTNER_NAME varchar2(100)
variable P401_PARTNER_ID number
exec :P401_PARTNER_NAME := 'Northwind Trading Co.'

select p.partner_name,
       p.city,
       utl_match.jaro_winkler_similarity(p.name_key, t.name_key) as match_pct
from   partners p
       cross join (select upper(translate(:P401_PARTNER_NAME, 'x .,-&''/', 'x')) as name_key
                   from   dual) t
where  length(t.name_key) >= 3
and    p.partner_id <> nvl(:P401_PARTNER_ID, 0)
and   (instr(p.name_key, t.name_key) > 0
       or utl_match.jaro_winkler_similarity(p.name_key, t.name_key) >= 85)
order  by match_pct desc
fetch  first 5 rows only;

Output:

PL/SQL procedure successfully completed.

PARTNER_NAME         CITY          MATCH_PCT
____________________ __________ ____________
Northwind Traders    Seattle              90
  • The inline view T builds the key of the typed name once, with the same TRANSLATE as the virtual column.
  • length(t.name_key) >= 3 skips the check until the user has typed three letters or digits.
  • p.partner_id <> nvl(:P401_PARTNER_ID, 0) leaves out the record being edited, so a partner is never reported as a duplicate of itself.
  • FETCH FIRST 5 ROWS ONLY keeps the warning short.

The query reads every row of the table, because neither INSTR inside the text nor Jaro-Winkler can use an index. To see what that costs, the same check ran on a scratch table with 100,000 random names, which was dropped afterwards:

Example:

create table zz_partners_perf as
select level as partner_id,
       initcap(dbms_random.string('l', 8)) || ' ' || initcap(dbms_random.string('l', 7)) as partner_name,
       cast(null as varchar2(100)) as name_key
from   dual
connect by level <= 100000;

update zz_partners_perf set name_key = upper(translate(partner_name, 'x .,-&''/', 'x'));
commit;

set timing on
select count(*) as matches
from   zz_partners_perf p
where  instr(p.name_key, 'NORTHWINDTRADING') > 0
   or  utl_match.jaro_winkler_similarity(p.name_key, 'NORTHWINDTRADING') >= 85;
set timing off

drop table zz_partners_perf purge;

Output:

Table ZZ_PARTNERS_PERF created.

100,000 rows updated.

Commit complete.

   MATCHES
__________
         0

Elapsed: 00:00:00.176

Table ZZ_PARTNERS_PERF dropped.

The check took 0.18 seconds for 100,000 rows in this example. That is fast enough to run while a user types, for tables of this size.

Step 3: Create the Report and Form Pages

In App Builder, open the application and click Create Page. On the Component tab, click Interactive Report:

Create a Page dialog with the Interactive Report page type marked with a red box
The Interactive Report page type.

Enter Page Number 400 and Name Partners. Turn on Include Form Page, enter Form Page Number 401 and Form Page Name Partner, and set Form Page Mode to Modal Dialog:

Create Interactive Report wizard with Page Number 400, Name Partners and the form page settings marked with red boxes
A report page and a modal form page.

Scroll down to Data Source. Keep Source Type Table and enter PARTNERS as the Table / View Name. Keep the Navigation settings, so the report gets a menu entry, and click Next:

Data Source section of the wizard with Table / View Name PARTNERS marked with a red box
The partners table as the source.

The wizard reads the primary key of the table and fills Primary Key Column 1 with PARTNER_ID. Click Create Page:

Primary Key screen of the wizard with Primary Key Column 1 PARTNER_ID marked with a red box
PARTNER_ID is the primary key.

The wizard creates page 400 with the report and page 401 with the form.

Step 4: Remove the Name Key and Created On Items

The wizard made a form item for every column, including the virtual column. Before you remove it, see what happens: in a test, a new partner saved with P401_NAME_KEY on the form failed with "ORA-54013: INSERT operation disallowed on virtual columns". Nothing was saved.

Open page 401 in Page Designer. Right-click P401_NAME_KEY and choose Delete:

Page Designer with the context menu of P401_NAME_KEY and the Delete entry marked with a red box
The database computes NAME_KEY itself.

Delete P401_CREATED_ON the same way. The wizard made it a required date picker, but DEFAULT ON NULL fills the column when a partner is saved. The form now has P401_PARTNER_ID (hidden), P401_PARTNER_NAME, P401_CITY and P401_EMAIL.

Step 5: Add the Possible Duplicates Region

In the Rendering tree, right-click Content Body and choose Create Region. Set Name to Possible Duplicates and Type to Classic Report. Under Source, keep Type SQL Query and enter the query from Step 2:

Example:

select p.partner_name,
       p.city,
       p.email,
       utl_match.jaro_winkler_similarity(p.name_key, t.name_key) as match_pct
from   partners p
       cross join (select upper(translate(:P401_PARTNER_NAME, 'x .,-&''/', 'x')) as name_key
                   from   dual) t
where  length(t.name_key) >= 3
and    p.partner_id <> nvl(:P401_PARTNER_ID, 0)
and   (instr(p.name_key, t.name_key) > 0
       or utl_match.jaro_winkler_similarity(p.name_key, t.name_key) >= 85)
order  by match_pct desc
fetch  first 5 rows only

Set Page Items to Submit to P401_PARTNER_NAME:

Page Designer with the Possible Duplicates region, Name, Type Classic Report, Source Type SQL Query, the SQL Query and Page Items to Submit marked with red boxes
The region runs the similarity query.

Page Items to Submit is the key setting. When the region refreshes, APEX first sends the current value of P401_PARTNER_NAME to the server, so the query sees what the user has typed so far, not the value from page load.

P401_PARTNER_ID does not need to be submitted. The form process puts its value in session state when the dialog opens, and the user cannot change it.

Under Appearance, set Template to Alert. Its default template options show a warning icon and color. Under Advanced, set HTML DOM ID to possible_dups:

Appearance and Advanced groups with Template Alert and HTML DOM ID possible_dups marked with red boxes
A warning-style region with a fixed ID.

The region comes after the Partner form region, so the warning appears below the fields. When it appears, the field the user is typing in does not move.

Step 6: Name the Match Column

Expand the Possible Duplicates region and its Columns. Select MATCH_PCT and set Heading to Match %:

Page Designer with column MATCH_PCT selected and Heading Match % marked with a red box
A short heading for the score.

Step 7: Hide the Region When Nothing Is Found

When the query returns no rows, the classic report renders an empty element with the class nodatafound. The warning box with its title would still show. One CSS rule hides it in that case.

Select the page (Page 401: Partner) at the top of the Rendering tree. Under CSS, enter this in Inline:

Example:

#possible_dups:has(.nodatafound) {
  display: none;
}
Page properties with the CSS Inline rule for possible_dups marked with a red box
The rule uses the HTML DOM ID from Step 5.

The :has() selector works on page load and after every refresh, so no extra dynamic action is needed to show or hide the region.

Step 8: Refresh the Region While the User Types

Right-click P401_PARTNER_NAME and choose Create Dynamic Action. Set Name to Check for duplicates. Under When, set Event to Key Release. Selection Type Item(s) and Item(s) P401_PARTNER_NAME are filled in already:

Dynamic Action properties with Name Check for duplicates and the When group with Event Key Release and Item P401_PARTNER_NAME marked with red boxes
The dynamic action fires on each key.

Firing a refresh on every key would send one request per letter. Under Execution, set Type to Debounce and Time to 500 milliseconds, and leave Immediate off:

Execution group with Type Debounce and Time 500 milliseconds marked with a red box
Wait for a 500 ms pause in typing.

With Debounce, APEX waits until no key was pressed for 500 milliseconds and then runs the actions once.

Under True, select the Show action that APEX created. Set Action to Refresh, Selection Type to Region and Region to Possible Duplicates:

Action properties with Action Refresh, Selection Type Region and Region Possible Duplicates marked with red boxes
Refresh the warning region.

Save the page. The page needs no JavaScript code and no Ajax callback process.

Step 9: Test the Warning

Run page 400. The report lists the ten partners. The Name Key column shows how each name is normalized:

Partners interactive report with ten partners and the Name Key column
The sample partners.

Click Create. The dialog opens without a warning, because the empty name returns no rows and the CSS rule hides the region:

Partner dialog with empty Partner Name, City and Email fields and no warning region
No warning for an empty name.

Type North Wind Trading at normal speed. About half a second after the last key, the warning appears with Northwind Traders and a score of 92:

Partner dialog with Partner Name North Wind Trading and the Possible Duplicates warning listing Northwind Traders, Seattle, score 92, marked with a red box
A similar partner exists.

The test counted the requests while typing the 18 characters: the browser sent exactly one refresh request. Without Debounce, every key would have sent one.

Replace the name with Proseware Inc. No existing name is similar, and the warning disappears:

Partner dialog with Partner Name Proseware Inc and no warning region
A new name gets no warning.

Two more inputs were tested. "%%%" showed no warning, as INSTR does not treat % as a wildcard. "Fab" listed Fabrikam Inc. with a score of 83: the score is below 85, but the key contains FAB.

The warning never blocks the save. Type Northwind Trading Co., a different company in Vancouver. The warning lists Northwind Traders with 90. Enter the city and email and click Create:

Partner dialog with Northwind Trading Co., Vancouver, an email and the warning listing Northwind Traders with 90
The user decides that this is a new partner.

The dialog closes and the new partner is in the report:

Partners report with the new row Northwind Trading Co., Vancouver, created Now, name key NORTHWINDTRADINGCO
The record is saved despite the warning.

Both Northwind rows are in the table:

Example:

select partner_id, partner_name, city, name_key
from   partners
where  name_key like 'NORTHWIND%'
order  by partner_id;

Output:

   PARTNER_ID PARTNER_NAME             CITY         NAME_KEY
_____________ ________________________ ____________ _____________________
            1 Northwind Traders        Seattle      NORTHWINDTRADERS
           21 Northwind Trading Co.    Vancouver    NORTHWINDTRADINGCO

The new ID is 21, not 11, because the identity column hands out cached values. That is normal.

Finally, click the edit icon of Northwind Traders. The region runs at page load with the saved name. It lists Northwind Trading Co. with 90, but not Northwind Traders itself:

Partner dialog editing Northwind Traders with the warning listing only Northwind Trading Co., Vancouver, score 90, marked with a red box
The record being edited is left out.

Things to Know

  • This is a warning, not a rule. To stop exact duplicates, add a unique constraint or index as well, for example on NAME_KEY.
  • Pick the score limit from your own data. In this example, true variants scored 90 to 98 and an unrelated name 82, so 85 separated them.
  • The query reads all rows. 100,000 rows took 0.18 seconds here; for much larger tables, narrow the rows first, for example by country.
  • Oracle 26ai also has the SQL operator FUZZY_MATCH. In this example, FUZZY_MATCH(JARO_WINKLER, ...) returned the same 92 as UTL_MATCH for the same two keys.

Related Guides

Conclusion

A duplicate warning in Oracle APEX takes one table column, one region and one dynamic action. The virtual NAME_KEY column normalizes names, UTL_MATCH scores them, and a classic report with the Alert template shows the close matches below the form.

A Key Release dynamic action with Execution Type Debounce refreshes the region once the user pauses, and a single CSS rule hides it when nothing is found. The user still decides, so real new records are never blocked, but most accidental duplicates are caught before they are saved.

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