Is this possible? I'm interested in finding out which columns were specified in the UPDATE request regardless of the fact that the new value that is being sent may or may not be what is stored in the database already.
The reason I want to do this is because we have a table that can receive updates from multiple sources. Previously, we weren't recording which source the update originated from. Now the table stores which source has performed the most recent update. We can change some of the sources to send an identifier, but that isn't an option for everything. So I'd like to be able to recognize when an UPDATE request doesn't have an identifier so I can substitute in a default value.
If a "source" doesn't "send an identifier", the column will be unchanged. Then you cannot detect whether the current UPDATE was done by the same source as the last one or by a source that did not change the column at all. In other words: this does not work properly.
If the "source" is identifiable by any session information function, you can work with that. Like:
NEW.column = session_user;
Unconditionally for every update.
General Solution
I found a way how to solve the original problem.
Set the column to a default value if it's not targeted in an UPDATE (not in the SET list). Key element is a per-column trigger introduced with PostgreSQL 9.0 - a column-specific trigger using the UPDATE OFcolumn_name clause. The manual:
The trigger will only fire if at least one of the listed columns is
mentioned as a target of the UPDATE command.
That's the only simple way I found to distinguish whether a column was updated with a new value identical to the old, versus not updated at all.
One could also parse the text returned by current_query(). But that seems cumbersome, tricky and unreliable.
Trigger functions
I assume a column source defined NOT NULL.
Step 1: Set source to NULL if unchanged:
CREATE OR REPLACE FUNCTION trg_tbl_upbef_step1()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
BEGIN
IF NEW.source = OLD.source THEN
NEW.source := NULL; -- "impossible" value (source is NOT NULL)
END IF;
RETURN NEW;
END
$func$;
Step 2: Revert to old value. Trigger will only be fired, if the value was actually updated (see below):
CREATE OR REPLACE FUNCTION trg_tbl_upbef_step2()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
BEGIN
IF NEW.source IS NULL THEN
NEW.source := OLD.source;
END IF;
RETURN NEW;
END
$func$;
Step 3: Now we can identify the lacking update and set a default value instead:
CREATE OR REPLACE FUNCTION trg_tbl_upbef_step3()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
BEGIN
IF NEW.source IS NULL THEN
NEW.source := 'UPDATE default source'; -- optionally same as column default
END IF;
RETURN NEW;
END
$func$;
Triggers
The trigger for Step 2 is fired per column!
CREATE TRIGGER upbef_step1
BEFORE UPDATE ON tbl
FOR EACH ROW
EXECUTE PROCEDURE trg_tbl_upbef_step1();
CREATE TRIGGER upbef_step2
BEFORE UPDATE OF source ON tbl -- key element!
FOR EACH ROW
EXECUTE PROCEDURE trg_tbl_upbef_step2();
CREATE TRIGGER upbef_step3
BEFORE UPDATE ON tbl
FOR EACH ROW
EXECUTE PROCEDURE trg_tbl_upbef_step3();
db<>fiddle here
Trigger names are relevant, because they are fired in alphabetical order (all being BEFORE UPDATE)!
The procedure could be simplified with something like "per-not-column triggers" or any other way to check the target-list of an UPDATE in a trigger. But I see no handle for this, currently (unchanged as of Postgres 14).
If source can be NULL, use any other "impossible" intermediate value and check for NULL additionally in trigger function 1:
IF OLD.source IS NOT DISTINCT FROM NEW.source THEN
NEW.source := '#impossible_value#';
END IF;
Adapt the rest accordingly.
Another way is to exploit JSON/JSONB functions that come in recent versions of PostgreSQL. It has the advantage of working both with anything that can be converted to a JSON object (rows or any other structured data), and you don't even need to know the record type.
To find the differences between any two rows/records, you can use this little hack:
SELECT pre.key AS columname, pre.value AS prevalue, post.value AS postvalue
FROM jsonb_each(to_jsonb(OLD)) AS pre
CROSS JOIN jsonb_each(to_jsonb(NEW)) AS post
WHERE pre.key = post.key AND pre.value IS DISTINCT FROM post.value
Where OLD and NEW are the built-in records found in trigger functions representing the pre and after state respectively of the changed record. Note that I have used the table aliases pre and post instead of old and new to avoid collision with the OLD and NEW built-in objects. Note also the use of IS DISTINCT FROM instead of a simple != or <> to handle NULL values appropriately.
Of course, this will also work with any ROW constructor such as ROW(1,2,3,...) or its short-hand (1,2,3,...). It will also work with any two JSONB objects that have the same keys.
For example, consider an example with two rows (already converted to JSONB for the purposes of the example):
SELECT pre.key AS columname, pre.value AS prevalue, post.value AS postvalue
FROM jsonb_each('{"col1": "same", "col2": "prediff", "col3": 1, "col4": false}') AS pre
CROSS JOIN jsonb_each('{"col1": "same", "col2": "postdiff", "col3": 1, "col4": true}') AS post
WHERE pre.key = post.key AND pre.value IS DISTINCT FROM post.value
The query will show the columns that have changed values:
columname | prevalue | postvalue
-----------+-----------+------------
col2 | "prediff" | "postdiff"
col4 | false | true
The cool thing about this approach is that it is trivial to filter by column. For example, imagine you ONLY want to detect changes in columns col1 and col2:
SELECT pre.key AS columname, pre.value AS prevalue, post.value AS postvalue
FROM jsonb_each('{"col1": "same", "col2": "prediff", "col3": 1, "col4": false}') AS pre
CROSS JOIN jsonb_each('{"col1": "same", "col2": "postdiff", "col3": 1, "col4": true}') AS post
WHERE pre.key = post.key AND pre.value IS DISTINCT FROM post.value
AND pre.key IN ('col1', 'col2')
The new results will exclude col3 from the results even if it's value has changed:
columname | prevalue | postvalue
-----------+-----------+------------
col2 | "prediff" | "postdiff"
It is easy to see how this approach can be extended in many ways. For example, say you want to throw an exception if certain columns are updated. You can achieve this with a universal trigger function, that is, one that can be applied to any/all tables, without having to know the table type:
CREATE OR REPLACE FUNCTION yourschema.yourtriggerfunction()
RETURNS TRIGGER AS
$$
DECLARE
immutable_cols TEXT[] := ARRAY['createdon', 'createdby'];
BEGIN
IF TG_OP = 'UPDATE' AND EXISTS(
SELECT 1
FROM jsonb_each(to_jsonb(OLD)) AS pre, jsonb_each(to_jsonb(NEW)) AS post
WHERE pre.key = post.key AND pre.value IS DISTINCT FROM post.value
AND pre.key = ANY(immutable_cols)
) THEN
RAISE EXCEPTION 'Error 12345 updating table %.%. Cannot alter these immutable cols: %.',
TG_TABLE_SCHEMA, TG_TABLE_NAME, immutable_cols;
END IF;
END
$$
LANGUAGE plpgsql VOLATILE
You would then register the above trigger function to any and all tables you want to control via:
CREATE TRIGGER yourtiggername
BEFORE UPDATE ON yourschema.yourtable
FOR EACH ROW EXECUTE PROCEDURE yourschema.yourtriggerfunction();
In plpgsql you could do something like this in your trigger function:
IF NEW.column IS NULL THEN
NEW.column = 'default value';
END IF;
I have obtained another solution to similar problem almost naturally, because my table contained a column with semantics of 'last update timestamp' (lets call it UPDT).
So, I decided to include new values of source and UPDT in any update only at once (or none of them). Since UPDT is intended to change on every update, with such a policy one can use condition new.UPDT = old.UPDT to deduce that no source was specified with current update and substitute the default one.
If one already has 'last update timestamp' column in his table, this solution will be simpler, than creating three triggers. Not sure if it is better idea to create UPDT, when it is not needed already. If updates are so frequent that there is risk of timestamp similarity, a sequencer can be used instead of timestamp.
Related
I want to create a deleted logs and insert data from the OLD row column. The problem is the column is not same for each table, some tables only has transaction_date and other table only has created_at. So I want to check if transaction_date just use it, otherwise use created_at column. I tried using coalesce function but still return:
ERROR: record "old" has no field "transaction_date" CONTEXT: SQL
statement "INSERT INTO "public"."delete_logs" ("table", "date") VALUES
(TG_TABLE_NAME, coalesce(OLD.transaction_date,
coalesce(OLD.created_at, now())))" PL/pgSQL function delete_table()
line 2 at SQL statement
here is my function:
CREATE OR REPLACE FUNCTION delete_table() RETURNS trigger AS
$$BEGIN
INSERT INTO "public"."deleted_logs" ("table", "created_at") VALUES (TG_TABLE_NAME, coalesce(OLD.transaction_date, coalesce(OLD.created_at, now())));
RETURN OLD;
END;$$ LANGUAGE plpgsql;
CREATE TRIGGER "testDelete" AFTER DELETE ON "exampletable" FOR EACH ROW EXECUTE PROCEDURE "delete_table"();
Actually, I wanted to create a function for each table, but I think it will be difficult to update the function in the future, so I need to create a single function for all tables.
So I want to check if transaction_date just use it, otherwise use created_at column.
You can avoid the exception you saw by converting the row to json:
CREATE OR REPLACE FUNCTION log_ts_after_delete()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
BEGIN
INSERT INTO public.deleted_logs
(table_name , created_at) -- "table" is a reserved word
VALUES (TG_TABLE_NAME, COALESCE(to_json(OLD)->>'transaction_date', to_json(OLD)->>'created_at')::timestamptz);
RETURN NULL; -- not used in AFTER trugger
END
$func$;
My answer assumes that transaction_date is defined NOT NULL. Else, the expression defaults to created_at. Probably not what you want.
JSON is not as strict as SQL. A reference to a non-existing JSON key results in NULL instead of the exception for the reference to a non-existing table column. So COALESCE just works.
Related:
How to set value of composite variable field using dynamic SQL
If the row is wide, it might be cheaper to convert to JSON only once and save it to a variable, or do it in a subquery or CTE.
Related:
To convert from Python arrays to PostgreSQL quickly?
If tables never switch the columns in question, passing a parameter in the trigger definition would be much cheaper.
You find out (at trigger creation time) once with:
SELECT attname
FROM pg_attribute
WHERE attrelid = 'public.exampletable'::regclass
AND attname IN ('transaction_date', 'created_at')
AND NOT attisdropped
ORDER BY attname DESC
This returns 'transaction_date' if such a column exists in the table, else 'created_at', else NULL (no row). Related:
PostgreSQL rename a column only if it exists
It's still cheapest to have a separate trigger function for each type of trigger. Just two functions instead of one. If the trigger is fired often I would do that.
Avoid exception handling if you can. The manual:
Tip
A block containing an EXCEPTION clause is significantly more
expensive to enter and exit than a block without one. Therefore, don't
use EXCEPTION without need.
I am trying to make an upsert trigger on ORACLE via PL/SQL by checking some examples, i am doing fine, i think it is the last step i should only configure. My requirement is that :
A system that will insert to that field will remain one column always null, so i will read column value from another table, then upsert it with inclusion of that value.
d2c_region_locale_config holds d2c_is_active value, so i firstly read that value regarding to locale condition then trigger inserts or updates to table with addition of this value on active_for_d2c column.(for update i am using locale and country columns as it is shown on where clause, they are not PK but has not null condition)
So i've created this trigger:
CREATE OR REPLACE TRIGGER BL_PIM_LOCALE_COUNTRY
BEFORE INSERT OR UPDATE ON PIM_LOCALE_COUNTRY REFERENCING NEW AS NEW OLD AS OLD
FOR EACH ROW
DECLARE
l_active_for_d2c INTEGER;
BEGIN
if :NEW.active_for_d2c is null then
DELETE from pim_locale_country where active_for_d2c is null;
select distinct(d2c_isactive) into l_active_for_d2c from d2c_region_locale_config where d2c_locale= :NEW.locale;
UPDATE pim_locale_country
SET locale = :NEW.locale, locale_name = :NEW.locale_name,
country = :NEW.country, country_name = :NEW.country_name, isdummy = :NEW.isdummy,
active_for_d2c = l_active_for_d2c, itextpos = :NEW.itextpos, locale_charset = :NEW.locale_charset,
fallback_locale = :NEW.fallback_locale, default_for_lang = :NEW.default_for_lang, opeclang = :NEW.opeclang
where locale = :NEW.locale and country = :NEW.country;
IF ( sql%notfound ) THEN
INSERT INTO PIM_LOCALE_COUNTRY (locale,locale_name,country,country_name,isdummy,active_for_d2c,itextpos,locale_charset,fallback_locale,default_for_lang,opeclang)
VALUES (:NEW.locale, :NEW.locale_name,:NEW.country,:NEW.country_name,:NEW.isdummy,l_active_for_d2c,:NEW.itextpos,:NEW.locale_charset,:NEW.fallback_locale,:NEW.default_for_lang,:NEW.opeclang);
END IF;
end if;
END;
It currently does the job, reads value and inserts or updates the existing locale-country couple for other values. But critical thing is that, table always has one "null" value(Please check screenshot), even that i run delete statement at the beginning on my trigger. So my question would be how to delete, or how to make this approach on trigger side ?
Many thanks for answers!
Trigger before insert doesn't block insert itself, so you insert that record twice. That is, once your trigger done its work (inserted or updated record), oracle will proceed with insert (or update) using values that stand in NEW record of your trigger. If trigger modifies NEW., it will be stored as you changed it, but if trigger inserts something itself, you can get more records.
You can use instead of insert or update triggers, and then oracle will not run its own inserts/updates after trigger finishes.
But more common way for 1-record triggers is to modify fields in NEW, for this case field NEW.d2c_is_active.
It looks like this (possible typos, please check)
CREATE OR REPLACE TRIGGER BL_PIM_LOCALE_COUNTRY
BEFORE INSERT OR UPDATE ON PIM_LOCALE_COUNTRY REFERENCING NEW AS NEW OLD AS OLD
FOR EACH ROW
BEGIN
if :NEW.active_for_d2c is null then
select d2c_isactive
into :NEW.active_for_d2c
from d2c_region_locale_config
where d2c_locale= :NEW.locale and rownum<=1;
end if;
END;
I have a table Content like this:
id | text | date | idUser → User | contentType
And another table Answer:
idAnswer → Content | idQuestion → Content | isAccepted
I want to ensure that the Answer's date is bigger than the Question's date. A question is a Content with contentType = 'QUESTION'.
I tried to solve this with the following trigger, but when I try to insert an Answer there's an error:
ERROR: record "new" has no field "idanswer"
CONTEXT: SQL statement "SELECT (SELECT "Content".date FROM "Content" WHERE "Content".id = NEW.idAnswer) < (SELECT "Content".date FROM "Content" WHERE "Content".id = NEW.idQuestion)"
PL/pgSQL function "check_valid_date_answer" line 2 at IF
Trigger:
CREATE TRIGGER check_valid_answer
AFTER INSERT ON "Answer"
FOR EACH ROW EXECUTE PROCEDURE check_valid_date_answer();
Trigger function:
CREATE FUNCTION check_valid_date_answer() RETURNS trigger
LANGUAGE plpgsql
AS $$BEGIN
IF (SELECT "Content".date FROM "Content"
WHERE "Content".id = NEW.idAnswer)
< (SELECT "Content".date FROM "Content"
WHERE "Content".id = NEW.idQuestion)
THEN
RAISE NOTICE 'This Answer is an invalid date';
END IF;
RETURN NEW;
END;$$;
So, my question is: do I really need to create a trigger for this? I saw that I can't use a CHECK in Answer because I need to compare with an attribute of another table. Is there any other (easier/better) way to do this? If not, why the error and how can I solve it?
Your basic approach is sound. The trigger is a valid solution. It should work except for 3 problems:
1) Your naming convention:
We would need to see your exact table definition to be sure, but the evidence is there. The error message says: has no field "idanswer" - lower case. Doesn't say "idAnswer" - CaMeL case. If you create CaMeL case identifiers in Postgres, you are bound to double-quote them everywhere for the rest of their life.
Are PostgreSQL column names case-sensitive?
2) Abort violating insert
Either raise an EXCEPTION instead of a friendly NOTICE to actually abort the whole transaction.
Or RETURN NULL instead of RETURN NEW to just abort the inserted row silently without raising an exception and without rolling anything back.
I would do the first. This will probably fix the error at hand and work:
CREATE FUNCTION trg_answer_insbef_check()
RETURNS trigger AS
$func$
BEGIN
IF (SELECT c.date FROM "Content" c WHERE c.id = NEW."idAnswer")
< (SELECT c.date FROM "Content" c WHERE c.id = NEW."idQuestion") THEN
RAISE EXCEPTION 'This Answer is an invalid date';
END IF;
RETURN NEW;
END
$func$ LANGUAGE plpgsql;
The proper solution is to use legal, lower case names exclusively and avoid such problems altogether. That includes your unfortunate table names as well as the column name date, which is a reserved word in standard SQL and should not be used as identifier - even if Postgres allows it.
3) Should be a BEFORE trigger
CREATE TRIGGER insbef_check
BEFORE INSERT ON "Answer"
FOR EACH ROW EXECUTE PROCEDURE trg_answer_insbef_check();
You want to abort invalid inserts before you do anything else.
Of course you will have to make sure that the timestamps table Content cannot be changed or you need more triggers to make sure your conditions are met.
The same goes for the fk columns in Answer.
I would approach this in a different way.
Recommendation:
use a BEFORE INSERT trigger if you want to change data before
inserting it
use a AFTER INSERT trigger if you have to do additional
work
use a CHECK clause if you have additional data consistency requirements.
So write a sql function that checks the condition that one date be earlier than the other, and add the check constraint. Yes, you can select from other tables in your function.
I wrote something similar (complex check) in answer to this question on SO.
I am writing an INSTEAD OF UPDATE trigger and I want to identify what columns has been given to the WHERE clause of the UPDATE statement that triggers the trigger.
For example,
Let's say that we have the table below
table_name
--COL1
--COL2
--COL3
--COL4
I want, when an update is performed
e.g.UPDATE table_name SET COL1=VAL1,COL2=VAL2 WHERE COL3=VAL3
to be able to say in my trigger
CREATE or replace TRIGGER DEVICES_VIEW_TR
INSTEAD OF UPDATE ON DEVICES_VW
BEGIN
IF (COL3 has been given in the where clause) THEN
variable=getValueOf(COL3);
ELSEIF (COL4 has been given in the where clause) THEN
variable=getValueOf(COL4);
END IF;
END;
/
Can this be done?
Thanks
You can use the UPDATING('column name') in your trigger:
-- in INSTEAD OF trigger body:
IF updating('COL1') THEN
-- some operation
END IF;
Check this for an example: Example of using UPDATING
You could use the NEW and OLD pseudorecords and run a comparison of the values
if :NEW.COL3 <> :OLD.COL3 THEN ...
Triggers don't know anything about the statement that invoked them, so you'll have to use some kind of out-of-band signalling, e.g. change your application to set some globals in a database package, or use an application context.
I'm changing a database (oracle) with a script containing a few updates looking like:
UPDATE customer
SET status = REPLACE(status, 'X_Y', 'xy')
WHERE status LIKE '%X_Y%'
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
UPDATE customer
SET status = REPLACE(status, 'X_Z', 'xz')
WHERE status LIKE '%X_Z%'
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
-- More updates looking the same...
In this case, how would you enforce DRY (Don't Repeat Yourself)?
I'd particularly interested in solving the two following recurring problems:
Define a function, available from this script only, to extract the subquery SELECT id FROM category WHERE code = 'ABC'
Create a set of replace rules (that could look like {"X_Y": "yx", "X_Z": "xz", ...} in a popular programming language) and then iterate a single update query on it.
Thanks!
I would reduce it to a single query:
UPDATE customer
SET status = REPLACE(REPLACE(status, 'X_Y', 'xy'), 'X_Z', 'xz')
WHERE status REGEXP_LIKE 'X_[YZ]'
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
First of all, remember that scripting is not the same thing as programming, and you don't have to adhere to DRY principles. Scripts like this one are usually one-offs, not a program to be maintained over a long time.
But you could use PL/SQL to do this:
declare
type str_tab is table of varchar2(30) index by binary_integer;
from_tab str_tab;
to_tab str_tab;
begin
from_tab(1) := 'X_Y';
from_tab(2) := 'X_Z';
to_tab(1) := 'xy';
to_tab(2) := 'xz';
for i in 1..from_tab.count loop
UPDATE customer
SET status = REPLACE(status, from_tab(i), to_tab(i))
WHERE status LIKE '%' || from_tab(i) || '%'
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
end loop;
end;
Pretty straightforward, unless I'm missing something.
UPDATE customer
SET status = REPLACE(REPLACE(status,'X_Y','xy'),'X_Z','xz')
WHERE ( status LIKE '%X_Y%' Or status LIKE '%X_Z%')
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
Write a script that takes parameters and call it multiple times. (I'm assuming you're using SQLPlus to run the script.)
replace_in_status.sql:
UPDATE customer
SET status = REPLACE(status, UPPER('&1'), '&2')
WHERE status LIKE '%' ||UPPER('&1')|| '%'
AND category_id IN
(SELECT id
FROM category
WHERE code = 'ABC');
Calling script:
#replace_in_status X_Y xy
#replace_in_status X_Z xz
Okay, a shot from the hip here, take it easy on my syntax :-)
Would an approach like this help:
DECLARE
v_sql1 VARCHAR2(1000);
v_sql2 VARCHAR2(2000);
TYPE T_Rules IS RECORD (srch VARCHAR2(100), repl(VARCHAR2(100));
TYPE T_RuleTab IS TABLE OF T_Rules INDEX BY BINARY_INTEGER;
v_rules T_RuleTab;
FUNCTION get_subquery RETURN VARCHAR2 IS
BEGIN
RETURN '(SELECT id FROM category WHERE code = ''ABC'')';
END;
BEGIN
v_sql1 := 'UPDATE customer SET status = REPLACE('':1'','':2'') WHERE status LIKE ''%:1%'' AND category_id IN ';
v_rules(1).srch := ('X_Y'); v_rules(1).repl := 'yx';
v_rules(2).srch := ('X_Z'); v_rules(2).repl := 'xz';
FOR i IN 1..v_rules.COUNT LOOP
v_sql2 := v_sql1||get_subquery();
EXECUTE IMMEDIATE v_sql2 USING v_rules(i).srch, v_rules(i).repl;
END LOOP;
END;
You could replace the PL/SQL table with a real table and run a cursor over it, but this addresses your second requirement.
Obviously some work is left on get_subquery, your first requirement ;-)
EDIT
Dang! forgot to mention you need to be careful with that replace string in your WHERE clause - underscores are a single character matching wild card in Oracle...
Depending on how important the script is, I would:
Just copy and paste and modify, or
Write a script in another programming language that has better ways to resolve the duplication.
For the replace rules you could create a temporary table and fill it with these replace rules, and then join with this table.
If the subquery is always the same, you have solved the first problem also by using a join.
I've seen a few approaches to this:
Use string buffers to assemble the sql dynamically using PL/SQL or in your programming language.
Use a framework such as IBATIS which let's you reuse and extend fragments of SQL that are stored in XML files.
Using an ORM framework circumvents this issue by working with objects rather than directly with the SQL.
Depending on your language and problem at hand using a framework may be the best approach and then extending it to do what you want it to do.
The solution suggested by soulmerge is the simplest, and therefore best one - you just need to nest the calls to "replace". I just want to add that the condition
status like '%tagada%'
is useless. replace() will change nothing to the status if the searched string is not found, therefore you can safely apply it to all rows. And since a condition where you search a string lost in the middle of another string cannot make any use of whatever index you have, it's useless as a filtering condition.
Your only filtering condition is the one on category_id ...
Which brings one point that justifies why soulmerge's solution is best: iterating on all the changes is a bad idea. Suppose that the filter on category_id is moderately selective, odds are that Oracle will choose to scan the table. Do you really want to scan the table each time when you can do all the changes in a single pass?