Update multiple columns in a trigger function in plpgsql

dynamic-sql, plpgsql, postgresql, sql, triggers

Solution

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.

- How to check if a table exists in a given schema

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.

Problem

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; ```

Original source

Related problems