Wednesday, November 24 2010
plpgsql, YeSQL

Dynamic Triggers in PLpgSQL

You certainly know that implementing dynamic triggers in PLpgSQL is impossible. But I had a very bad night, being up from as soon as 3:30 am today, so that when a developer asked me about reusing the same trigger function code from more than one table and for a dynamic column name, I didn't remember about it being impossible.

Here's what happens in such cases, after a long time on the problem (yes, overall, that's a slow day). Note that I'm abusing the (record_literal).* notation a lot in there, and even the (record_literal).column_name too.

CREATE OR REPLACE FUNCTION public.update_timestamp()
 RETURNS TRIGGER
 LANGUAGE plpgsql
AS $f$
DECLARE
    ts_column varchar;
    old_timestamp timestamptz;
    attname name;
    n text;
    v text;
BEGIN
    IF TG_NARGS != 1
    THEN
        RAISE EXCEPTION 'Trigger public.update_timestamp() called with % args',
                         TG_NARGS;
    END IF;

    ts_column := TG_ARGV[0];

    EXECUTE 'SELECT n.' || ts_column
         || ' FROM (SELECT (' 
         || quote_literal(OLD) || '::' || TG_RELID::regclass
         || ').*) as n'
       INTO old_timestamp;

    -- build NEW record text
    n := '(';
    FOR attname IN
      EXECUTE 'SELECT attname '
           || '  FROM pg_class c left join pg_attribute a on a.attrelid = c.oid'
           || ' WHERE c.oid = $1 and attnum > 0 order by attnum'
       USING TG_RELID
    LOOP
        EXECUTE 'SELECT (' || quote_literal(NEW) || '::' || TG_RELID::regclass || ').' || attname INTO v;

        IF n != '(' THEN n := n || ','; END IF;

        IF attname = ts_column 
           AND v::timestamptz IS NOT DISTINCT FROM old_timestamp
        THEN
                n := n || now();
        ELSE
                n := n || COALESCE(v, '');              
        END IF;
    END LOOP;
    n := n || ')';

    EXECUTE 'SELECT ($1::' || TG_RELID::regclass || ').*'
      INTO NEW
     USING n;

    RETURN NEW;
END;
$f$;

It's not pretty, and not fast. It's about 2 ms per call on a table with 15 columns, in some preliminary tests. But it sure was a nice challenge!