How to call a PL SQL function within a CHECK statement? - sql

I would like to add a CHECK statement that calls a function when inserting new entries into a table.
I have used the following sample code to implement such functionality:
CREATE TABLE customers(
id NUMBER NOT NULL,
PRIMARY KEY(id));
CREATE OR REPLACE FUNCTION totalCustomers
RETURN NUMBER IS
total NUMBER := 0;
BEGIN
SELECT count(*) into total
FROM customers;
RETURN total;
END;
/
ALTER TABLE customers
ADD CHECK (totalCustomers() < 10);
When I run this query in livesql.oracle.com, I get the following error:
ORA-00904: "TOTALCUSTOMERS": invalid identifier.
What is the right way of calling this function in the check statement?
P.S. Please ignore the contents of the function. I will replace it with the desired contents later.

There isn't one.
Straight from the Oracle documentation:
https://docs.oracle.com/en/database/oracle/oracle-database/12.2/sqlrf/constraint.html#GUID-1055EA97-BA6F-4764-A15F-1024FD5B6DFE
Conditions of check constraints cannot contain the following
constructs:
.............
Calls to user-defined functions
..............
Now: you said "disregard the actual content of the function". That is not a healthy attitude; the content matters too. For one thing, the function would have to be deterministic anyway (yours is not) - this is a problem quite apart from it being a user-defined function. Moreover, conditions in constraints can only refer to values in a single row - they can't be "table" constraints, like yours is.
You may wonder, then - how would one implement a "constraint" like yours? One somewhat common method is to create a materialized view based on "select count(*)....." and put a constraint on the MV. The MV should refresh full on commit. Whenever you modify the base table and you commit. the MV is refreshed - and if the count increases above 10, the changes are rolled back.

In your comment on mathguy's answer, you say "I am also trying to make sure the time period of new entries don't overlap with existing ones." I have done this with "refresh fast on commit" materialized views. Warning: "fast" refreshes can be slow if you are not careful, please refer to this blog http://www.adellera.it/ , especially concerning statistics on the materialized view log.
I am assuming exclusive end dates. If an end date is null, that means the datetime range goes on indefinitely. Many overlaps will be caught immediately by the primary key and unique constraints. The others will be caught at commit time by the constraint on the materialized view. Note that at the end of the transaction the MV will never have any rows.
SQL> create table date_ranges (
2 key1, start_date,
3 primary key(key1, start_date),
4 end_date,
5 unique(key1, end_date),
6 check(start_date < end_date)
7 )
8 as
9 with a as (select date '2000-01-01' dte from dual)
10 select 1, dte, dte+1 from a
11 union all
12 select 1, dte+1, dte+2 from a
13 union all
14 select 1, dte-1, dte from a
15 union all
16 select 2, dte+10, dte+11 from a
17 union all
18 select 2, dte+12, dte+13 from a
19 union all
20 select 2, dte+8, dte+9 from a
21 /
Table DATE_RANGES created.
SQL> create materialized view log on date_ranges
2 with sequence, rowid, primary key, commit scn (end_date) including new values
3 /
Materialized view log DATE_RANGES created.
SQL> create materialized view overlapping_ranges refresh fast on commit
2 as
3 select a.rowid arid, b.rowid brid
4 from date_ranges a, date_ranges b
5 where a.key1 = b.key1
6 and a.rowid != b.rowid
7 and a.start_date < b.end_date
8 and a.end_date > b.start_date;
Materialized view OVERLAPPING_RANGES created.
SQL>
SQL> alter materialized view overlapping_ranges
2 add constraint overlaps_not_allowed check (1=0) deferrable initially deferred
3 /
Materialized view OVERLAPPING_RANGES altered.
SQL> insert into date_ranges select 1, date '1999-12-30', date '2000-01-4' from dual;
1 row inserted.
SQL> commit;
Error starting at line : 42 in command -
commit
Error report -
ORA-02091: transaction rolled back
ORA-02290: check constraint (STEW.OVERLAPS_NOT_ALLOWED) violated

I would suggest a trigger for such requirement.
CREATE OR REPLACE TRIGGER AI_customers
AFTER INSERT ON customers
DECLARE
total NUMBER;
BEGIN
SELECT count(*) into total
FROM customers;
IF total > 10 THEN
RAISE_APPLICATION_ERROR(-20001, 'Total number of customers must not exceed 10');
END IF;
END;
/
Note, this is a STATEMENT LEVEL trigger (no FOR EACH ROW clause), thus you cannot get the famous "ORA-04091: table is mutating, trigger/function may not see it" error.
However, this trigger has some limitations in a multi-user environment. If user_1 inserts records into customers table and user_2 also inserts some records (before user_1 has done a COMMIT) then you may get more than 10 records in your customers table.

Related

Validate data inserted based on other data in the same table (Oracle)

I am doing a project in SQL Oracle and have found a problem, as far as I am quite new to SQL.
I have a system that administrates booked rooms, in a table called bookings. Some of the attributes are:
room_id
arrival_date
nights_nr
booking_id (which is the PK).
I have a UNIQUE(room_id, arrival_date) constraint.
To have a coherent DB I would need to check, for every inserted or updated row, whether any of the intended-to-book days for that room are already booked. This being a constraint based on the table's values, I would need a select so I cannot perform this in a CHECK (or at least I haven't figured out another possibility).
I am trying to do this with a trigger, but this is my first contact with triggers.
What I thought was to have a select in a when and to verify if, for another booking_id and the same room_id I am currently intending to update, the range of days I am intending to book intersects with the range booked.
Due to lack of knowledge I have written something like this:
CREATE OR REPLACE TRIGGER validate_free_room
BEFORE INSERT OR UPDATE OF arrival_date, night_nr ON bookings
FOR each row
DECLARE a char(8)
DECLARE b char(3)
SET a=booking_id
SET b=room_id
WHEN EXISTS (
SELECT booking_id
FROM bookings r
WHERE
(r.booking_id!=a
AND
((arrival_date BETWEEN r.arrival_date AND r.arrival_date + nights_nr)
OR
(arrival_date + nights_nr BETWEEN r.arrival_date AND r.arrival_date + r.nights_nr)
)
AND
b=r.room_id
)
)
BEGIN
RAISE_APPLICATION_ERROR (-20107,'Room already booked')
end;
I have just found out that I cannot perform selects in a table that is being modified with a "for each row".
Do you have any ideas how I could do this in a correct way? (I know the precedent lines are a complete disaster).
I am using Oracle Application Express, which gave me the following error suggestions:
ORA-24344: success with compilation error
ORA-06512: at "SYS.WWV_DBMS_SQL_APEX_190200", line 592
ORA-06512: at "SYS.DBMS_SYS_SQL", line 1658
ORA-06512: at "SYS.WWV_DBMS_SQL_APEX_190200", line 578
ORA-06512: at "APEX_190200.WWV_FLOW_DYNAMIC_EXEC", line 2057
3. for each row
4. declare a char(8)
5. declare b char(3)
6. set a=id_rezerva
7. set b=id_camera
and
Error computing plan for statement.
ORA-00900: invalid SQL statement
There is another way to do this that does not involve triggers:
create a materialized view that joins the table on itself and contains rows only when there is an overlap.
This materialized view should refresh on commit.
It has a constraint 1=0 that will fail every time there is a row.
So at every commit, if there is an overlap, the commit will fail and the materialized view will always be empty.
There are DBA-type things to be done so this will perform OK, such as gathering statistics on the materialized view log when empty, then locking thoses statistics.
SQL> create table bookings(
2 booking_id integer primary key,
3 room_id integer not null,
4 arrival_date date not null check (arrival_date = trunc(arrival_date)),
5 nights_nr integer not null,
6 UNIQUE(room_id, arrival_date)
7 );
Table BOOKINGS created.
SQL> create materialized view log on bookings with rowid including new values;
Materialized view log BOOKINGS created.
SQL> create materialized view bookings_conflicts
2 refresh fast on commit as
3 select a.rowid arid, b.rowid brid
4 from bookings a, bookings b
5 where a.room_id = b.room_id
6 and a.arrival_date < b.arrival_date
7 and a.arrival_date + a.nights_nr > b.arrival_date;
Materialized view BOOKINGS_CONFLICTS created.
SQL> alter materialized view bookings_conflicts add constraint no_overlaps check(1=0) deferrable;
Materialized view BOOKINGS_CONFLICTS altered.
SQL> insert into bookings
2 select 1, 1, date '2020-01-01', 5 from dual union all
3 select 2, 1, date '2020-01-05', 1 from dual union all
4 select 3, 1, date '2020-01-06', 1 from dual;
3 rows inserted.
SQL> commit;
Error starting at line : 24 in command -
commit
Error report -
ORA-12008: error in materialized view or zonemap refresh path
ORA-02290: check constraint (STEW.NO_OVERLAPS) violated
....

table mutating in oracle

I have a table, participated, which has a trigger that returns the total damage amount for a driver id when a new record is inserted into the table.
create or replace trigger display
after insert on participated
for each row
declare
t_id integer;
total integer;
begin
select driver_id, sum(damage_amount)
into t_id, total
from participated
where :new.driver_id = driver_id
group by driver_id;
dbms_output.put_line(' total amount ' || total' driver id' || t_id);
end;
/
The trigger is created, but it returns this error:
ORA-04091: table SQL_HSATRWHKNJHKDFMGWCUISUEEE.PARTICIPATED is mutating,
trigger/function may not see it ORA-06512: at
"SQL_HSATRWHKNJHKDFMGWCUISUEEE.DISPLAY", line 5
ORA-06512: at "SYS.DBMS_SQL", line 1721
Please help with this trigger.
As commented above, this feels like a code-smell. A row level trigger cannot change the table being changed, since that would fire another trigger, which would end up in an endless loop of calling triggers.
Changing this to a statement level trigger is not doing the same thing.
Preferred solution:
1) put this to the application logic, and calculate after row has been inserted - this is trivial as #kfinity mentioned.
2) earmark newly inserted rows and use a statement level trigger. For example, have an extra column, say is_new default 1 - therefore all new inserted rows will have this flag. Then use a statement level trigger suggested by #hbourchi to calculate and update all drivers that is_new is 1, and then set this flag back to zero
3) the logic in 2) can be implemented using pl/sql and in-memory pl/sql tables. The pl/sql table collects affected driver ids using a row level trigger, and then updates the totals of the selected drivers. Tom Kyte has lots of examples on this, this is not a rocket science, however if you lack of PL/SQL knowledge, then this is probably not your way. (For the note: PL/SQL is super important when using Oracle - without that Oracle is just an expensive Excel sheet like any other db. Worth of using it.)
4) probably, you shall revise your data model - and the problem solves itself. The participated table shows multiple rows per driver id. You want to calculate one total row per driver id - why would you put that summary to the same table? Simply add a new table, participated_total which has driver_id and damaged_amount fields. Then feel free to insert or update that from your trigger as you planned originally!
5) in fact, you can calculate these totals on the fly (depending on the number of rows and your performance expectations), by simply crafting the right SQL when querying - this way no need to store the pre-calculated totals.
6) but if you wish Oracle to store these totals for you, you can do 5) and use materialized views. These are in-fact tables, which are updated and maintained automatically by Oracle, so your actual query at 5) does not need to calculate anything on the fly but can get the automatically pre-calculated data from the materialized view.
How about just no triggers at all ?
SQL> create table participated (
2 incident_id int primary key,
3 driver_id int not null,
4 damage_amount int not null
5 );
Table created.
SQL>
SQL> insert into participated
2 select rownum, mod(rownum,10), dbms_random.value(1000,2000)
3 from dual
4 connect by level <= 200;
200 rows created.
SQL> create materialized view log on participated with rowid, (driver_id,damage_amount), sequence including new values;
Materialized view log created.
SQL> create materialized view driver_tot
2 refresh fast on commit
3 with primary key
4 as
5 select driver_id,
6 sum(damage_amount) tot,
7 count(*) cnt
8 from participated
9 group by driver_id;
Materialized view created.
SQL> select driver_id, tot
2 from driver_tot
3 order by 1;
DRIVER_ID TOT
---------- ----------
0 32808
1 29847
2 28585
3 29714
4 32148
5 30491 <====
6 29258
7 32103
8 30131
9 26834
10 rows selected.
SQL>
SQL> insert into participated values (9999,5,1234);
1 row created.
SQL> commit;
Commit complete.
SQL>
SQL> select driver_id, tot
2 from driver_tot
3 order by 1;
DRIVER_ID TOT
---------- ----------
0 32808
1 29847
2 28585
3 29714
4 32148
5 31725 <====
6 29258
7 32103
8 30131
9 26834
10 rows selected.
SQL>
SQL>
You didn't post your trigger definition but normally you can not query a table, inside trigger when updating records in the same table.
Try using after update trigger. it might work in your case. Something like this:
CREATE OR REPLACE TRIGGER my_trigger AFTER UPDATE ON my_table FOR EACH ROW
DECLARE
...
BEGIN
...
END;
another option would be to make your trigger AUTONOMOUS_TRANSACTION:
CREATE OR REPLACE TRIGGER my_trigger BEFORE INSERT ON my_table FOR EACH ROW
DECLARE
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
...
END;
However read this before choosing for this option:
https://docs.oracle.com/cd/B14117_01/appdev.101/b10807/13_elems002.htm

Restrict Insert based on previous insertion date

I want to restrict insertion in my table based on some condition.
My table is like
col1 col2 Date Create
A 1 04/05/2016
B 2 04/06/2016
A 3 04/08/2016 -- Do not allow insert
A 4 04/10/2016 -- Allow insert
So I want to restrict insert based on the number of days the same record was inserted earlier.
As shown in able example, A can be inserted again in table only after 4 days of previous insertion not before that.
Any pointers how I can do this in SQL/Oracle.
You only want to insert when there not exists a record with the same col1 and a too recent date:
insert into mytable (col1, col2, date_create)
select 'B' as col1, 4 as col2, trunc(sysdate) as date_create from dual ins
where not exists
(
select *
from mytable other
where other.col1 = ins.col1
and other.date_create > ins.date_create - 4
);
An undesired record would not be inserted thus. However, no exception would be raised. If you want that, I'd suggest a PL/SQL block or a before insert trigger.
If several processes write to your table simultaneously with possibly conflicting data then oracle database should do the job.
This can be solved by defining a constraint to check if there already exists an entry with the same col1 value younger than four days.
As far as I know, it is not possible to define such a constraint directly. Instead, define a materialized view and add a constraint on this view.
create materialized view mytable_mv refresh on commit as
select f2.col1, f2.date_create, f1.date_create as date_create_conflict
from mytable f2, mytable f1
where f2.col1 = f1.col1
and f2.date_create > f1.date_create
and f2.date_create - f1.date_create < 4;
This materialized view will contain an entry, if and only if a conflict exists.
Now define a constraint on this view:
alter table mytable_mv add constraint check_date_create
check(date_create=
date_create_conflict) deferrable;
It is executed when the current transaction is commited (because the materialized view is refreshed - as declared above refresh on commit).
This works fine if you insert into your table mytable in an autonomous transaction, e.g. for a logging table.
In other cases, you can force the refresh on the materialized view by dbms_mview.refresh('mytable_mv') or use another option than refresh on commit.

How can I prevent multiple records of a type are not allowed in a many-to-many relationship?

I have four tables.
PERSON DELIVERY_MAPPING GENERATION_SYSTEM DELIVERY_METHOD
------ ---------------- ----------------- ---------------
ID PERSON_ID ID ID
NAME GENERATION_SYSTEM_ID NAME NAME
DELIVERY_METHOD_ID IS_SPECIAL
Example data:
PERSON DELIVERY_MAPPING GENERATION_SYSTEM DELIVERY_METHOD
------ ---------------- ----------------- ---------------
1. TOM 1 1 1. COLOR PRINTER 1 1. EMAIL N
2. DICK 1 2 2. BW PRINTER 1 2. POST N
3. HARRY 2 3 3. HANDWRITTEN 3 3. PIGEONS Y
A DELIVERY_METHOD contains ways to deliver new letters — EMAIL, POST, PIGEON. The IS_SPECIAL column marks a record as a means of a special delivery. It is indicated by a simple value of Y or N. Only PIGEON is a special delivery method i.e. Y, the others are not i.e. N.
The GENERATION_SYSTEM has the information that will finally print the letter. Example values are COLOR PRINTER and DOT MATRIX PRINTER. Each GENERATION_SYSTEM will always be delivered using one of the DELIVERY_METHODs. There's a foreign key-between GENERATION_SYSTEM and DELIVERY_METHOD.
Now, each PERSON can have his letters generated by different GENERATION_SYSTEMs and since, it is a many-to-many relation, we have the DELIVERY_MAPPING table and that's that's why we have foreign key's on both ends.
So far, so good.
I need to ensure that it if a person has his letters generated by a system that uses a special delivery method then he cannot be allowed to have multiple generation systems in the mappings list. For example, Dick can't have his letters generated using the colour printer because he already gets all his handwritten letters delivery by a pigeon (which is a marked a special delivery method).
How would I accomplish such a constraint? I tried doing it with a before-insert-or-update trigger on the DELIVERY_MAPPING table but that causes the mutating trigger problem when updating.
Can is normalise this scenario even more? Maybe it is just that i haven't normalised my table properly.
Either way, I'd love to hear your take on this issue. I hope I've been verbose enough (...and if you can propose a better title for this post, that would be great)
For a complicated constraint like this, I think you need to use triggers. I don't think the mutating table problem is an issue, because you are either going to do an update or do nothing.
The only table you need to worry about is Delivery_Mapping. Before allowing a change to this table, you need to run a query on the existing table to get the number of specials and gs's:
select SUM(case when dme.is_special = 'Y' then 1 else 0 end) as NumSpecial,
count(distinct gs.id) as NumGS,
MIN(gs.id) as GSID
from delivery_mapping dm join
generation_system gs
on dm.generation_system_id = gs.id join
delivery_method dme
on gs.delivery_method_id = dme.id
where dm.person_id = PERSONID
With this information, you can check if the insert/update can proceed. I think you need to
check the conditions:
If NumSpecial = 0 and the new delivery method is not special, then proceed.
If NumSpecial = 0 and NumGS = 0, then proceed.
Otherwise fail.
The logic is a bit more complicated for updates.
By the way, I prefer to wrap updates/inserts/deletes in stored procedures, so logic like this doesn't get hidden in triggers. I find that debugging and maintaining procedures is much easier than dealing with triggers, which may be possibly cascading.
I'd avoid triggers on the base tables for this unless you can guarantee serialization.
you could use an API (best way) as Gordon says (again, be sure to serialize) or if that isn't suitable, use a materialized view (we don't need to serialize here, as the check is done on commit):
SQL> create materialized view log on person with rowid, primary key including new values;
Materialized view log created.
SQL> create materialized view log on delivery_mapping with rowid, primary key including new values;
Materialized view log created.
SQL> create materialized view log on generation_system with rowid, primary key (delivery_method_id) including new values;
Materialized view log created.
SQL> create materialized view log on delivery_method with rowid, primary key (is_special) including new values;
Materialized view log created.
we create a materialized view to show the counts of special + non special links for each user:
SQL> create materialized view check_del_method
2 refresh fast on commit
3 with primary key
4 as
5 select pers.id, count(case del_meth.is_special when 'Y' then 1 end) special_count,
6 count(case del_meth.is_special when 'N' then 1 end) non_special_count
7 from person pers
8 inner join delivery_mapping del_map
9 on pers.id = del_map.person_id
10 inner join generation_system gen
11 on gen.id = del_map.generation_system_id
12 inner join delivery_method del_meth
13 on del_meth.id = gen.delivery_method_id
14 group by pers.id;
Materialized view created.
the MView is defined as fast refresh on commit, so the modified rows are rebuilt on commit. now the rule is that if special+non special counts are non-zero, that's an error condition.
SQL> create trigger check_del_method_aiu
2 after insert or update on check_del_method
3 for each row
4 declare
5 begin
6 if (:new.special_count > 0 and :new.non_special_count > 0)
7 then
8 raise_application_error(-20000, 'Cannot have a mix of special and non special delivery methods for user ' || :new.id);
9 end if;
11 end;
12 /
Trigger created.
SQL> set serverout on
SQL> insert into delivery_mapping values (1, 3);
1 row created.
SQL> commit;
commit
*
ERROR at line 1:
ORA-12008: error in materialized view refresh path
ORA-20000: Cannot have a mix of special and non special delivery methods for
user 1
ORA-06512: at "TEST.CHECK_DEL_METHOD_AIU", line 6
ORA-04088: error during execution of trigger 'TEST.CHECK_DEL_METHOD_AIU'
CREATE MATERIALIZED VIEW special_queues_mv
NOLOGGING
CACHE
BUILD IMMEDIATE
REFRESH ON COMMIT
ENABLE QUERY REWRITE
AS SELECT dmap.person_id
, SUM(DECODE(dmet.is_special, 'Y', 1, 0)) AS special_queues
, SUM(DECODE(dmet.is_special, 'N', 1, 0)) AS regular_queues
FROM delivery_mapping dmap
, generation_system gsys
, delivery_method dmet
WHERE dmap.generation_system_id = gsys.id
AND gsys.delevery_method_id = dmet.id
GROUP
BY dmap.person_id
/
ALTER MATERIALIZED VIEW special_queues_mv
ADD ( CONSTRAINT special_queues_mv_chk1 CHECK ((special_queues = 1 AND regular_queues = 0) OR ( regular_queues > 0 AND special_queues = 0 ) ) ENABLE VALIDATE)
/
That's how I did it. DazzaL's answer gave me a hint on how to do it.

A trigger to find the sum of one field in a different table and error if it's over a certain value in oracle

I have two tables
moduleprogress which contains fields:
studentid
modulecode
moduleyear
modules which contains fields:
modulecode
credits
I need a trigger to run when the user is attempting to insert or update data in the moduleprogress table.
The trigger needs to:
look at the studentid that the user has input and look at all modules that they have taken in moduleyear "1".
take the modulecode the user input and look at the modules table and find the sum of the credits field for all these modules (each module is worth 10 or 20 credits).
if the value is above 120 (yearly credit limit) then it needs to error; if not, input is ok.
Does this make sense? Is this possible?
#a_horse_with_no_name
This looks like it will work but I will only be using the database to input data manually so it needs to error on input. I'm trying to get a trigger similar to this to solve the problem(trigger doesn't work) and forget that "UOS_" is before everything. Just helps me with my database and other functions.
CREATE OR REPLACE TRIGGER "UOS_TESTINGS"
BEFORE UPDATE OR INSERT ON UOS_MODULE_PROGRESS
REFERENCING NEW AS NEW OLD AS OLD
DECLARE
MODULECREDITS INTEGER;
BEGIN
SELECT
m.UOS_CREDITS,
mp.UOS_MODULE_YEAR,
SUM(m.UOS_CREDITS)
INTO MODULECREDITS
FROM UOS_MODULE_PROGRESS mp JOIN UOS_MODULES m
ON m.UOS_MODULE_CODE = mp.UOS_MODULE_CODE
WHERE mp.UOS_MODULE_YEAR = 1;
IF MODULECREDITS >= 120 THEN
RAISE_APPLICATION_ERROR(-20000, 'Students are only allowed to take upto 120 credits per year');
END IF;
END;
I get the error message :
8 23 PL/SQL: ORA-00947: not enough values
4 1 PL/SQL: SQL Statement ignored
I'm not sure I understand your description, but the way I understand it, this can be solved using a materialized view, which might give better transactional behaviour than the trigger:
CREATE MATERIALIZED VIEW LOG
ON moduleprogress WITH ROWID (modulecode, studentid, moduleyear)
INCLUDING NEW VALUES;
CREATE MATERIALIZED VIEW LOG
ON modules with rowid (modulecode, credits)
INCLUDING NEW VALUES;
CREATE MATERIALIZED VIEW mv_module_credits
REFRESH FAST ON COMMIT WITH ROWID
AS
SELECT pr.studentid,
SUM(m.credits) AS total_credits
FROM moduleprogress pr
JOIN modules m ON pr.modulecode = m.modulecode
WHERE pr.moduleyear = 1
GROUP BY pr.studentid;
ALTER TABLE mv_module_credits
ADD CONSTRAINT check_total_credits CHECK (total_credits <= 120)
But: depending on the size of the table this might however be slower than a pure trigger based solution.
The only drawback of this solution is, that the error will be thrown at commit time, not when the insert happens (because the MV is only refreshed on commit, and the check constraint is evaluated then)