Update multiple columns in a trigger function in plpgsql
Asked Answered
M

2

7

Given the following schema:

create table account_type_a (
  id SERIAL UNIQUE PRIMARY KEY,
  some_column VARCHAR
);

create table account_type_b (
  id SERIAL UNIQUE PRIMARY KEY,
  some_other_column VARCHAR
);

create view account_type_a view AS select * from account_type_a;
create view account_type_b view AS select * from account_type_b;

I try to create a generic trigger function in plpgsql, which enables updating the view:

create trigger trUpdate instead of UPDATE on account_view_type_a
for each row execute procedure updateAccount();    
create trigger trUpdate instead of UPDATE on account_view_type_a
for each row execute procedure updateAccount();

An unsuccessful effort of mine was:

create function updateAccount() returns trigger as $$
declare
  target_table varchar := substring(TG_TABLE_NAME from '(.+)_view');
  cols varchar;
begin
  execute 'select string_agg(column_name,$1) from information_schema.columns
           where table_name = $2' using ',', target_table into cols;
  execute 'update ' || target_table || ' set (' || cols || ') =  select ($1).*
           where id = ($1).id' using NEW;
  return NULL;
end;
$$ language plpgsql;

The problem is the update statement. I am unable to come up with a syntax that would work here. I have successfully implemented this in PL/Perl, but would be interested in a plpgsql-only solution.
Any ideas?

Update

As @Erwin Brandstetter suggested, here is the code for my PL/Perl solution. I incoporated some of his suggestions.

create function f_tr_up() returns trigger as $$
  use strict;
  use warnings;
  my $target_table = quote_ident($_TD->{'table_name'}) =~ s/^([\w]+)_view$/$1/r;
  my $NEW = $_TD->{'new'};
  my $cols = join(',', map { quote_ident($_) } keys $NEW);
  my $vals = join(',', map { quote_literal($_) } values $NEW);
  my $query = sprintf(
    "update %s set (%s) = (%s) where id = %d",
    $target_table,
    $cols,
    $vals,
    $NEW->{'id'});
  spi_exec_query($query);
return;
$$ language plperl;
Mumble answered 11/3, 2013 at 15:58 Comment(2)
Could you post your (complete) PL/Perl solution as another answer so we can compare. That would be great! +1 Interesting question, btw!Houdan
Thanks for posting the PL/Perl version. Nice opportunity to compare the two languages for the same job.Houdan
H
15

While @Gary's answer is technically correct, it fails to mention that PostgreSQL does support this form:

UPDATE tbl
SET (col1, col2, ...) = (expression1, expression2, ..)

Read the manual on UPDATE.

It's still tricky to get this done with dynamic SQL. I'll assume a simple case where views consist of the same columns as their underlying tables.

CREATE VIEW tbl_view AS SELECT * FROM tbl;

Problems

The special record NEW is not visible inside EXECUTE. I pass NEW as a single parameter with the USING clause of EXECUTE.

As discussed, UPDATE with list-form needs individual values. I use a subselect to split the record into individual columns:

UPDATE ...
FROM  (SELECT ($1).*) x

(Parenthesis around $1 are not optional.) This allows me to simply use two column lists built with string_agg() from the catalog table: one with and one without table qualification.

It's not possible to assign a row value as a whole to individual columns. The manual:

According to the standard, the source value for a parenthesized sub-list of target column names can be any row-valued expression yielding the correct number of columns. PostgreSQL only allows the source value to be a row constructor or a sub-SELECT.

INSERT is implemented simpler. If the structure of view and table are identical we can omit the column definition list. (Can be improved, see below.)

Solution

I made a couple of updates to your approach to make it shine.

Trigger function for UPDATE:

CREATE OR REPLACE FUNCTION f_trg_up()
  RETURNS TRIGGER
  LANGUAGE plpgsql AS
$func$
DECLARE
   _tbl  regclass := quote_ident(TG_TABLE_SCHEMA) || '.'
                  || quote_ident(substring(TG_TABLE_NAME from '(.+)_view$'));
   _cols text;
   _vals text;
BEGIN
   SELECT INTO _cols, _vals
          string_agg(quote_ident(attname), ', ')
        , string_agg('x.' || quote_ident(attname), ', ')
   FROM   pg_attribute
   WHERE  attrelid = _tbl
   AND    NOT attisdropped   -- no dropped (dead) columns
   AND    attnum > 0;        -- no system columns

   EXECUTE format('
   UPDATE %s
   SET   (%s) = (%s)
   FROM  (SELECT ($1).*) x', _tbl, _cols, _vals)
   USING NEW;

   RETURN NEW; -- Don't return NULL unless you knwo what you're doing
END
$func$;

Trigger function for INSERT:

CREATE OR REPLACE FUNCTION f_trg_ins()
  RETURNS TRIGGER
  LANGUAGE plpgsql AS
$func$
DECLARE
   _tbl regclass := quote_ident(TG_TABLE_SCHEMA) || '.'
                 || quote_ident(substring(TG_TABLE_NAME FROM '(.+)_view$'));
BEGIN
   EXECUTE format('INSERT INTO %s SELECT ($1).*', _tbl)
   USING NEW;

   RETURN NEW;  -- Don't return NULL unless you know what you're doing
END
$func$;

Triggers:

CREATE TRIGGER trg_instead_up
INSTEAD OF UPDATE ON a_view
FOR EACH ROW EXECUTE FUNCTION f_trg_up();

CREATE TRIGGER trg_instead_ins
INSTEAD OF INSERT ON a_view
FOR EACH ROW EXECUTE FUNCTION f_trg_ins();

Before Postgres 11 the syntax (oddly) was EXECUTE PROCEDURE instead of EXECUTE FUNCTION - which also still works.

db<>fiddle here - demonstrating INSERT and UPDATE
Old sqlfiddle

Major points

  • Include the schema name to make the table reference unambiguous. There can be multiple table of the same name in one database with multiple schemas!

  • Query pg_catalog.pg_attribute instead of information_schema.columns. Less portable, but much faster and allows to use the table-OID.

  • Table names are NOT safe against SQLi when concatenated as strings for dynamic SQL. Escape with quote_ident() or format() or with an object-identifer type. This includes the special trigger function variables TG_TABLE_SCHEMA and TG_TABLE_NAME!

  • Cast to the object identifier type regclass to assert the table name is valid and get the OID for the catalog look-up.

  • Optionally use format() to build the dynamic query string safely.

  • No need for dynamic SQL for the first query on the catalog tables. Faster, simpler.

  • Use RETURN NEW instead of RETURN NULL in these trigger functions unless you know what you are doing. (NULL would cancel the INSERT for the current row.)

  • This simple version assumes that every table (and view) has a unique column named id. A more sophisticated version might use the primary key dynamically.

  • The function for UPDATE allows the columns of view and table to be in any order, as long as the set is the same. The function for INSERT expects the columns of view and table to be in identical order. If you want to allow arbitrary order, add a column definition list to the INSERT command, just like with UPDATE.

  • Updated version also covers changes to the id column by using OLD additionally.

Houdan answered 12/3, 2013 at 0:32 Comment(8)
Thank you for this profound answer! I have some catching up todo incorporating your points into my code...Mumble
Nice solution. Although I don't really understand why you need the FROM (SELECT ($1).*) x in the update. Wouldn't a where id = .. be just as good?Ansley
@a_horse_with_no_name: Well, the FROM (SELECT ($1).*) x is needed, so I can refer to individual values in the list used in the SET clause easily. I guess I could write ($1).col instead of x.col, too. But I thought that was less elegant and probably slower than breaking up the record once. You also reminded me, that the WHERE clause was missing.Houdan
Hmm. But you are not using the values from the select. The values for the UPDATE statement are taken from the vals variable which is populated from the select on pg_attributeAnsley
@a_horse_with_no_name: vals is just another column list, table-qualified with x.. Results in SET (id, txt, day) = (x.id, x.txt, x.day). And x is the alias of the subquery.Houdan
@sschober: Added another improvement to cover updates on the id column as well.Houdan
if NEW is a record why would not smth like execute 'update t set (FULL_LIST) = $1 WHERE..' using NEW work?.. don't we have neater record to row "cast" then listing all columns and from alias?.. or smth like execute 'update t set (FULL_LIST) = (($1).*) WHERE..' using NEW...Idomeneus
@Vao: UPDATE demands a list of individual columns. I added a quote from the manual above.Houdan
S
1

Postgresql doesn't support updating multiple columns using the set (col1,col2) = select val1,val2 syntax.

To achieve the same in postgresql you'd use

update target_table
set col1 = d.val1,
    col2 = d.val2
from source_table d
where d.id = target_table.id

This is going to make the dynamic query a bit more complex to build as you'll need to iterate the column name list you're using into individual fields. I'd suggest you use array_agg instead of string_agg as an array is easier to process than splitting the string again.

Postgresql UPDATE syntax

documentation on array_agg function

Sanburn answered 11/3, 2013 at 16:9 Comment(2)
While you are technically correct, there is still a way. But that's only a lesser hurdle here. I added an answer.Houdan
@Erwin good catch. I've got to admit, I've never used that syntax.Sanburn

© 2022 - 2024 — McMap. All rights reserved.