Commit 7cbe57c3 authored by Noah Misch's avatar Noah Misch

Offer triggers on foreign tables.

This covers all the SQL-standard trigger types supported for regular
tables; it does not cover constraint triggers.  The approach for
acquiring the old row mirrors that for view INSTEAD OF triggers.  For
AFTER ROW triggers, we spool the foreign tuples to a tuplestore.

This changes the FDW API contract; when deciding which columns to
populate in the slot returned from data modification callbacks, writable
FDWs will need to check for AFTER ROW triggers in addition to checking
for a RETURNING clause.

In support of the feature addition, refactor the TriggerFlags bits and
the assembly of old tuples in ModifyTable.

Ronan Dunklau, reviewed by KaiGai Kohei; some additional hacking by me.
parent 6115480c
...@@ -110,6 +110,7 @@ static void deparseTargetList(StringInfo buf, ...@@ -110,6 +110,7 @@ static void deparseTargetList(StringInfo buf,
List **retrieved_attrs); List **retrieved_attrs);
static void deparseReturningList(StringInfo buf, PlannerInfo *root, static void deparseReturningList(StringInfo buf, PlannerInfo *root,
Index rtindex, Relation rel, Index rtindex, Relation rel,
bool trig_after_row,
List *returningList, List *returningList,
List **retrieved_attrs); List **retrieved_attrs);
static void deparseColumnRef(StringInfo buf, int varno, int varattno, static void deparseColumnRef(StringInfo buf, int varno, int varattno,
...@@ -875,11 +876,9 @@ deparseInsertSql(StringInfo buf, PlannerInfo *root, ...@@ -875,11 +876,9 @@ deparseInsertSql(StringInfo buf, PlannerInfo *root,
else else
appendStringInfoString(buf, " DEFAULT VALUES"); appendStringInfoString(buf, " DEFAULT VALUES");
if (returningList) deparseReturningList(buf, root, rtindex, rel,
deparseReturningList(buf, root, rtindex, rel, returningList, rel->trigdesc && rel->trigdesc->trig_insert_after_row,
retrieved_attrs); returningList, retrieved_attrs);
else
*retrieved_attrs = NIL;
} }
/* /*
...@@ -919,11 +918,9 @@ deparseUpdateSql(StringInfo buf, PlannerInfo *root, ...@@ -919,11 +918,9 @@ deparseUpdateSql(StringInfo buf, PlannerInfo *root,
} }
appendStringInfoString(buf, " WHERE ctid = $1"); appendStringInfoString(buf, " WHERE ctid = $1");
if (returningList) deparseReturningList(buf, root, rtindex, rel,
deparseReturningList(buf, root, rtindex, rel, returningList, rel->trigdesc && rel->trigdesc->trig_update_after_row,
retrieved_attrs); returningList, retrieved_attrs);
else
*retrieved_attrs = NIL;
} }
/* /*
...@@ -943,34 +940,48 @@ deparseDeleteSql(StringInfo buf, PlannerInfo *root, ...@@ -943,34 +940,48 @@ deparseDeleteSql(StringInfo buf, PlannerInfo *root,
deparseRelation(buf, rel); deparseRelation(buf, rel);
appendStringInfoString(buf, " WHERE ctid = $1"); appendStringInfoString(buf, " WHERE ctid = $1");
if (returningList) deparseReturningList(buf, root, rtindex, rel,
deparseReturningList(buf, root, rtindex, rel, returningList, rel->trigdesc && rel->trigdesc->trig_delete_after_row,
retrieved_attrs); returningList, retrieved_attrs);
else
*retrieved_attrs = NIL;
} }
/* /*
* deparse RETURNING clause of INSERT/UPDATE/DELETE * Add a RETURNING clause, if needed, to an INSERT/UPDATE/DELETE.
*/ */
static void static void
deparseReturningList(StringInfo buf, PlannerInfo *root, deparseReturningList(StringInfo buf, PlannerInfo *root,
Index rtindex, Relation rel, Index rtindex, Relation rel,
bool trig_after_row,
List *returningList, List *returningList,
List **retrieved_attrs) List **retrieved_attrs)
{ {
Bitmapset *attrs_used; Bitmapset *attrs_used = NULL;
if (trig_after_row)
{
/* whole-row reference acquires all non-system columns */
attrs_used =
bms_make_singleton(0 - FirstLowInvalidHeapAttributeNumber);
}
if (returningList != NIL)
{
/* /*
* We need the attrs mentioned in the query's RETURNING list. * We need the attrs, non-system and system, mentioned in the local
* query's RETURNING list.
*/ */
attrs_used = NULL;
pull_varattnos((Node *) returningList, rtindex, pull_varattnos((Node *) returningList, rtindex,
&attrs_used); &attrs_used);
}
if (attrs_used != NULL)
{
appendStringInfoString(buf, " RETURNING "); appendStringInfoString(buf, " RETURNING ");
deparseTargetList(buf, root, rtindex, rel, attrs_used, deparseTargetList(buf, root, rtindex, rel, attrs_used,
retrieved_attrs); retrieved_attrs);
}
else
*retrieved_attrs = NIL;
} }
/* /*
......
...@@ -108,7 +108,7 @@ enum FdwScanPrivateIndex ...@@ -108,7 +108,7 @@ enum FdwScanPrivateIndex
* 1) INSERT/UPDATE/DELETE statement text to be sent to the remote server * 1) INSERT/UPDATE/DELETE statement text to be sent to the remote server
* 2) Integer list of target attribute numbers for INSERT/UPDATE * 2) Integer list of target attribute numbers for INSERT/UPDATE
* (NIL for a DELETE) * (NIL for a DELETE)
* 3) Boolean flag showing if there's a RETURNING clause * 3) Boolean flag showing if the remote query has a RETURNING clause
* 4) Integer list of attribute numbers retrieved by RETURNING, if any * 4) Integer list of attribute numbers retrieved by RETURNING, if any
*/ */
enum FdwModifyPrivateIndex enum FdwModifyPrivateIndex
...@@ -1246,7 +1246,7 @@ postgresPlanForeignModify(PlannerInfo *root, ...@@ -1246,7 +1246,7 @@ postgresPlanForeignModify(PlannerInfo *root,
*/ */
return list_make4(makeString(sql.data), return list_make4(makeString(sql.data),
targetAttrs, targetAttrs,
makeInteger((returningList != NIL)), makeInteger((retrieved_attrs != NIL)),
retrieved_attrs); retrieved_attrs);
} }
......
...@@ -390,3 +390,219 @@ insert into loc1(f2) values('bye'); ...@@ -390,3 +390,219 @@ insert into loc1(f2) values('bye');
insert into rem1(f2) values('bye remote'); insert into rem1(f2) values('bye remote');
select * from loc1; select * from loc1;
select * from rem1; select * from rem1;
-- ===================================================================
-- test local triggers
-- ===================================================================
-- Trigger functions "borrowed" from triggers regress test.
CREATE FUNCTION trigger_func() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE 'trigger_func(%) called: action = %, when = %, level = %',
TG_ARGV[0], TG_OP, TG_WHEN, TG_LEVEL;
RETURN NULL;
END;$$;
CREATE TRIGGER trig_stmt_before BEFORE DELETE OR INSERT OR UPDATE ON rem1
FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
CREATE TRIGGER trig_stmt_after AFTER DELETE OR INSERT OR UPDATE ON rem1
FOR EACH STATEMENT EXECUTE PROCEDURE trigger_func();
CREATE OR REPLACE FUNCTION trigger_data() RETURNS trigger
LANGUAGE plpgsql AS $$
declare
oldnew text[];
relid text;
argstr text;
begin
relid := TG_relid::regclass;
argstr := '';
for i in 0 .. TG_nargs - 1 loop
if i > 0 then
argstr := argstr || ', ';
end if;
argstr := argstr || TG_argv[i];
end loop;
RAISE NOTICE '%(%) % % % ON %',
tg_name, argstr, TG_when, TG_level, TG_OP, relid;
oldnew := '{}'::text[];
if TG_OP != 'INSERT' then
oldnew := array_append(oldnew, format('OLD: %s', OLD));
end if;
if TG_OP != 'DELETE' then
oldnew := array_append(oldnew, format('NEW: %s', NEW));
end if;
RAISE NOTICE '%', array_to_string(oldnew, ',');
if TG_OP = 'DELETE' then
return OLD;
else
return NEW;
end if;
end;
$$;
-- Test basic functionality
CREATE TRIGGER trig_row_before
BEFORE INSERT OR UPDATE OR DELETE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
CREATE TRIGGER trig_row_after
AFTER INSERT OR UPDATE OR DELETE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
delete from rem1;
insert into rem1 values(1,'insert');
update rem1 set f2 = 'update' where f1 = 1;
update rem1 set f2 = f2 || f2;
-- cleanup
DROP TRIGGER trig_row_before ON rem1;
DROP TRIGGER trig_row_after ON rem1;
DROP TRIGGER trig_stmt_before ON rem1;
DROP TRIGGER trig_stmt_after ON rem1;
DELETE from rem1;
-- Test WHEN conditions
CREATE TRIGGER trig_row_before_insupd
BEFORE INSERT OR UPDATE ON rem1
FOR EACH ROW
WHEN (NEW.f2 like '%update%')
EXECUTE PROCEDURE trigger_data(23,'skidoo');
CREATE TRIGGER trig_row_after_insupd
AFTER INSERT OR UPDATE ON rem1
FOR EACH ROW
WHEN (NEW.f2 like '%update%')
EXECUTE PROCEDURE trigger_data(23,'skidoo');
-- Insert or update not matching: nothing happens
INSERT INTO rem1 values(1, 'insert');
UPDATE rem1 set f2 = 'test';
-- Insert or update matching: triggers are fired
INSERT INTO rem1 values(2, 'update');
UPDATE rem1 set f2 = 'update update' where f1 = '2';
CREATE TRIGGER trig_row_before_delete
BEFORE DELETE ON rem1
FOR EACH ROW
WHEN (OLD.f2 like '%update%')
EXECUTE PROCEDURE trigger_data(23,'skidoo');
CREATE TRIGGER trig_row_after_delete
AFTER DELETE ON rem1
FOR EACH ROW
WHEN (OLD.f2 like '%update%')
EXECUTE PROCEDURE trigger_data(23,'skidoo');
-- Trigger is fired for f1=2, not for f1=1
DELETE FROM rem1;
-- cleanup
DROP TRIGGER trig_row_before_insupd ON rem1;
DROP TRIGGER trig_row_after_insupd ON rem1;
DROP TRIGGER trig_row_before_delete ON rem1;
DROP TRIGGER trig_row_after_delete ON rem1;
-- Test various RETURN statements in BEFORE triggers.
CREATE FUNCTION trig_row_before_insupdate() RETURNS TRIGGER AS $$
BEGIN
NEW.f2 := NEW.f2 || ' triggered !';
RETURN NEW;
END
$$ language plpgsql;
CREATE TRIGGER trig_row_before_insupd
BEFORE INSERT OR UPDATE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trig_row_before_insupdate();
-- The new values should have 'triggered' appended
INSERT INTO rem1 values(1, 'insert');
SELECT * from loc1;
INSERT INTO rem1 values(2, 'insert') RETURNING f2;
SELECT * from loc1;
UPDATE rem1 set f2 = '';
SELECT * from loc1;
UPDATE rem1 set f2 = 'skidoo' RETURNING f2;
SELECT * from loc1;
DELETE FROM rem1;
-- Add a second trigger, to check that the changes are propagated correctly
-- from trigger to trigger
CREATE TRIGGER trig_row_before_insupd2
BEFORE INSERT OR UPDATE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trig_row_before_insupdate();
INSERT INTO rem1 values(1, 'insert');
SELECT * from loc1;
INSERT INTO rem1 values(2, 'insert') RETURNING f2;
SELECT * from loc1;
UPDATE rem1 set f2 = '';
SELECT * from loc1;
UPDATE rem1 set f2 = 'skidoo' RETURNING f2;
SELECT * from loc1;
DROP TRIGGER trig_row_before_insupd ON rem1;
DROP TRIGGER trig_row_before_insupd2 ON rem1;
DELETE from rem1;
INSERT INTO rem1 VALUES (1, 'test');
-- Test with a trigger returning NULL
CREATE FUNCTION trig_null() RETURNS TRIGGER AS $$
BEGIN
RETURN NULL;
END
$$ language plpgsql;
CREATE TRIGGER trig_null
BEFORE INSERT OR UPDATE OR DELETE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trig_null();
-- Nothing should have changed.
INSERT INTO rem1 VALUES (2, 'test2');
SELECT * from loc1;
UPDATE rem1 SET f2 = 'test2';
SELECT * from loc1;
DELETE from rem1;
SELECT * from loc1;
DROP TRIGGER trig_null ON rem1;
DELETE from rem1;
-- Test a combination of local and remote triggers
CREATE TRIGGER trig_row_before
BEFORE INSERT OR UPDATE OR DELETE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
CREATE TRIGGER trig_row_after
AFTER INSERT OR UPDATE OR DELETE ON rem1
FOR EACH ROW EXECUTE PROCEDURE trigger_data(23,'skidoo');
CREATE TRIGGER trig_local_before BEFORE INSERT OR UPDATE ON loc1
FOR EACH ROW EXECUTE PROCEDURE trig_row_before_insupdate();
INSERT INTO rem1(f2) VALUES ('test');
UPDATE rem1 SET f2 = 'testo';
-- Test returning system attributes
INSERT INTO rem1(f2) VALUES ('test') RETURNING ctid, xmin, xmax;
...@@ -308,7 +308,8 @@ AddForeignUpdateTargets (Query *parsetree, ...@@ -308,7 +308,8 @@ AddForeignUpdateTargets (Query *parsetree,
extra values to be fetched. Each such entry must be marked extra values to be fetched. Each such entry must be marked
<structfield>resjunk</> = <literal>true</>, and must have a distinct <structfield>resjunk</> = <literal>true</>, and must have a distinct
<structfield>resname</> that will identify it at execution time. <structfield>resname</> that will identify it at execution time.
Avoid using names matching <literal>ctid<replaceable>N</></literal> or Avoid using names matching <literal>ctid<replaceable>N</></literal>,
<literal>wholerow</literal>, or
<literal>wholerow<replaceable>N</></literal>, as the core system can <literal>wholerow<replaceable>N</></literal>, as the core system can
generate junk columns of these names. generate junk columns of these names.
</para> </para>
...@@ -447,11 +448,12 @@ ExecForeignInsert (EState *estate, ...@@ -447,11 +448,12 @@ ExecForeignInsert (EState *estate,
<para> <para>
The data in the returned slot is used only if the <command>INSERT</> The data in the returned slot is used only if the <command>INSERT</>
query has a <literal>RETURNING</> clause. Hence, the FDW could choose query has a <literal>RETURNING</> clause or the foreign table has
to optimize away returning some or all columns depending on the contents an <literal>AFTER ROW</> trigger. Triggers require all columns, but the
of the <literal>RETURNING</> clause. However, some slot must be FDW could choose to optimize away returning some or all columns depending
returned to indicate success, or the query's reported row count will be on the contents of the <literal>RETURNING</> clause. Regardless, some
wrong. slot must be returned to indicate success, or the query's reported row
count will be wrong.
</para> </para>
<para> <para>
...@@ -492,11 +494,12 @@ ExecForeignUpdate (EState *estate, ...@@ -492,11 +494,12 @@ ExecForeignUpdate (EState *estate,
<para> <para>
The data in the returned slot is used only if the <command>UPDATE</> The data in the returned slot is used only if the <command>UPDATE</>
query has a <literal>RETURNING</> clause. Hence, the FDW could choose query has a <literal>RETURNING</> clause or the foreign table has
to optimize away returning some or all columns depending on the contents an <literal>AFTER ROW</> trigger. Triggers require all columns, but the
of the <literal>RETURNING</> clause. However, some slot must be FDW could choose to optimize away returning some or all columns depending
returned to indicate success, or the query's reported row count will be on the contents of the <literal>RETURNING</> clause. Regardless, some
wrong. slot must be returned to indicate success, or the query's reported row
count will be wrong.
</para> </para>
<para> <para>
...@@ -535,11 +538,12 @@ ExecForeignDelete (EState *estate, ...@@ -535,11 +538,12 @@ ExecForeignDelete (EState *estate,
<para> <para>
The data in the returned slot is used only if the <command>DELETE</> The data in the returned slot is used only if the <command>DELETE</>
query has a <literal>RETURNING</> clause. Hence, the FDW could choose query has a <literal>RETURNING</> clause or the foreign table has
to optimize away returning some or all columns depending on the contents an <literal>AFTER ROW</> trigger. Triggers require all columns, but the
of the <literal>RETURNING</> clause. However, some slot must be FDW could choose to optimize away returning some or all columns depending
returned to indicate success, or the query's reported row count will be on the contents of the <literal>RETURNING</> clause. Regardless, some
wrong. slot must be returned to indicate success, or the query's reported row
count will be wrong.
</para> </para>
<para> <para>
......
...@@ -43,9 +43,10 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable> ...@@ -43,9 +43,10 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable>
<para> <para>
<command>CREATE TRIGGER</command> creates a new trigger. The <command>CREATE TRIGGER</command> creates a new trigger. The
trigger will be associated with the specified table or view and will trigger will be associated with the specified table, view, or foreign table
execute the specified function <replaceable and will execute the specified
class="parameter">function_name</replaceable> when certain events occur. function <replaceable class="parameter">function_name</replaceable> when
certain events occur.
</para> </para>
<para> <para>
...@@ -93,7 +94,7 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable> ...@@ -93,7 +94,7 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable>
<para> <para>
The following table summarizes which types of triggers may be used on The following table summarizes which types of triggers may be used on
tables and views: tables, views, and foreign tables:
</para> </para>
<informaltable id="supported-trigger-types"> <informaltable id="supported-trigger-types">
...@@ -110,8 +111,8 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable> ...@@ -110,8 +111,8 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable>
<row> <row>
<entry align="center" morerows="1"><literal>BEFORE</></entry> <entry align="center" morerows="1"><literal>BEFORE</></entry>
<entry align="center"><command>INSERT</>/<command>UPDATE</>/<command>DELETE</></entry> <entry align="center"><command>INSERT</>/<command>UPDATE</>/<command>DELETE</></entry>
<entry align="center">Tables</entry> <entry align="center">Tables and foreign tables</entry>
<entry align="center">Tables and views</entry> <entry align="center">Tables, views, and foreign tables</entry>
</row> </row>
<row> <row>
<entry align="center"><command>TRUNCATE</></entry> <entry align="center"><command>TRUNCATE</></entry>
...@@ -121,8 +122,8 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable> ...@@ -121,8 +122,8 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable>
<row> <row>
<entry align="center" morerows="1"><literal>AFTER</></entry> <entry align="center" morerows="1"><literal>AFTER</></entry>
<entry align="center"><command>INSERT</>/<command>UPDATE</>/<command>DELETE</></entry> <entry align="center"><command>INSERT</>/<command>UPDATE</>/<command>DELETE</></entry>
<entry align="center">Tables</entry> <entry align="center">Tables and foreign tables</entry>
<entry align="center">Tables and views</entry> <entry align="center">Tables, views, and foreign tables</entry>
</row> </row>
<row> <row>
<entry align="center"><command>TRUNCATE</></entry> <entry align="center"><command>TRUNCATE</></entry>
...@@ -164,13 +165,13 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable> ...@@ -164,13 +165,13 @@ CREATE [ CONSTRAINT ] TRIGGER <replaceable class="PARAMETER">name</replaceable>
<firstterm>constraint trigger</>. This is the same as a regular trigger <firstterm>constraint trigger</>. This is the same as a regular trigger
except that the timing of the trigger firing can be adjusted using except that the timing of the trigger firing can be adjusted using
<xref linkend="SQL-SET-CONSTRAINTS">. <xref linkend="SQL-SET-CONSTRAINTS">.
Constraint triggers must be <literal>AFTER ROW</> triggers. They can Constraint triggers must be <literal>AFTER ROW</> triggers on tables. They
be fired either at the end of the statement causing the triggering event, can be fired either at the end of the statement causing the triggering
or at the end of the containing transaction; in the latter case they are event, or at the end of the containing transaction; in the latter case they
said to be <firstterm>deferred</>. A pending deferred-trigger firing can are said to be <firstterm>deferred</>. A pending deferred-trigger firing
also be forced to happen immediately by using <command>SET CONSTRAINTS</>. can also be forced to happen immediately by using <command>SET
Constraint triggers are expected to raise an exception when the constraints CONSTRAINTS</>. Constraint triggers are expected to raise an exception
they implement are violated. when the constraints they implement are violated.
</para> </para>
<para> <para>
...@@ -244,8 +245,8 @@ UPDATE OF <replaceable>column_name1</replaceable> [, <replaceable>column_name2</ ...@@ -244,8 +245,8 @@ UPDATE OF <replaceable>column_name1</replaceable> [, <replaceable>column_name2</
<term><replaceable class="parameter">table_name</replaceable></term> <term><replaceable class="parameter">table_name</replaceable></term>
<listitem> <listitem>
<para> <para>
The name (optionally schema-qualified) of the table or view the trigger The name (optionally schema-qualified) of the table, view, or foreign
is for. table the trigger is for.
</para> </para>
</listitem> </listitem>
</varlistentry> </varlistentry>
...@@ -481,6 +482,14 @@ CREATE TRIGGER view_insert ...@@ -481,6 +482,14 @@ CREATE TRIGGER view_insert
<refsect1 id="SQL-CREATETRIGGER-compatibility"> <refsect1 id="SQL-CREATETRIGGER-compatibility">
<title>Compatibility</title> <title>Compatibility</title>
<!--
It's not clear whether SQL/MED contemplates triggers on foreign tables.
Its <drop basic column definition> General Rules do mention the possibility
of a reference from a trigger column list. On the other hand, nothing
overrides the fact that CREATE TRIGGER only targets base tables. For now,
do not document the compatibility status of triggers on foreign tables.
-->
<para> <para>
The <command>CREATE TRIGGER</command> statement in The <command>CREATE TRIGGER</command> statement in
<productname>PostgreSQL</productname> implements a subset of the <productname>PostgreSQL</productname> implements a subset of the
......
...@@ -33,20 +33,21 @@ ...@@ -33,20 +33,21 @@
<para> <para>
A trigger is a specification that the database should automatically A trigger is a specification that the database should automatically
execute a particular function whenever a certain type of operation is execute a particular function whenever a certain type of operation is
performed. Triggers can be attached to both tables and views. performed. Triggers can be attached to tables, views, and foreign tables.
</para> </para>
<para> <para>
On tables, triggers can be defined to execute either before or after any On tables and foreign tables, triggers can be defined to execute either
<command>INSERT</command>, <command>UPDATE</command>, or before or after any <command>INSERT</command>, <command>UPDATE</command>,
<command>DELETE</command> operation, either once per modified row, or <command>DELETE</command> operation, either once per modified row,
or once per <acronym>SQL</acronym> statement. or once per <acronym>SQL</acronym> statement.
<command>UPDATE</command> triggers can moreover be set to fire only if <command>UPDATE</command> triggers can moreover be set to fire only if
certain columns are mentioned in the <literal>SET</literal> clause of the certain columns are mentioned in the <literal>SET</literal> clause of the
<command>UPDATE</command> statement. <command>UPDATE</command> statement.
Triggers can also fire for <command>TRUNCATE</command> statements. Triggers can also fire for <command>TRUNCATE</command> statements.
If a trigger event occurs, the trigger's function is called at the If a trigger event occurs, the trigger's function is called at the
appropriate time to handle the event. appropriate time to handle the event. Foreign tables do not support the
TRUNCATE statement at all.
</para> </para>
<para> <para>
...@@ -111,10 +112,10 @@ ...@@ -111,10 +112,10 @@
triggers fire immediately before a particular row is operated on, triggers fire immediately before a particular row is operated on,
while row-level <literal>AFTER</> triggers fire at the end of the while row-level <literal>AFTER</> triggers fire at the end of the
statement (but before any statement-level <literal>AFTER</> triggers). statement (but before any statement-level <literal>AFTER</> triggers).
These types of triggers may only be defined on tables. Row-level These types of triggers may only be defined on tables and foreign tables.
<literal>INSTEAD OF</> triggers may only be defined on views, and fire Row-level <literal>INSTEAD OF</> triggers may only be defined on views,
immediately as each row in the view is identified as needing to be and fire immediately as each row in the view is identified as needing to
operated on. be operated on.
</para> </para>
<para> <para>
...@@ -548,7 +549,8 @@ typedef struct TriggerData ...@@ -548,7 +549,8 @@ typedef struct TriggerData
<command>DELETE</command> then this is what you should return <command>DELETE</command> then this is what you should return
from the function if you don't want to replace the row with from the function if you don't want to replace the row with
a different one (in the case of <command>INSERT</command>) or a different one (in the case of <command>INSERT</command>) or
skip the operation. skip the operation. For triggers on foreign tables, values of system
columns herein are unspecified.
</para> </para>
</listitem> </listitem>
</varlistentry> </varlistentry>
...@@ -563,7 +565,8 @@ typedef struct TriggerData ...@@ -563,7 +565,8 @@ typedef struct TriggerData
<command>DELETE</command>. This is what you have to return <command>DELETE</command>. This is what you have to return
from the function if the event is an <command>UPDATE</command> from the function if the event is an <command>UPDATE</command>
and you don't want to replace this row by a different one or and you don't want to replace this row by a different one or
skip the operation. skip the operation. For triggers on foreign tables, values of system
columns herein are unspecified.
</para> </para>
</listitem> </listitem>
</varlistentry> </varlistentry>
......
...@@ -3180,6 +3180,9 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd, ...@@ -3180,6 +3180,9 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
case AT_DisableTrig: /* DISABLE TRIGGER variants */ case AT_DisableTrig: /* DISABLE TRIGGER variants */
case AT_DisableTrigAll: case AT_DisableTrigAll:
case AT_DisableTrigUser: case AT_DisableTrigUser:
ATSimplePermissions(rel, ATT_TABLE | ATT_FOREIGN_TABLE);
pass = AT_PASS_MISC;
break;
case AT_EnableRule: /* ENABLE/DISABLE RULE variants */ case AT_EnableRule: /* ENABLE/DISABLE RULE variants */
case AT_EnableAlwaysRule: case AT_EnableAlwaysRule:
case AT_EnableReplicaRule: case AT_EnableReplicaRule:
......
This diff is collapsed.
...@@ -309,15 +309,17 @@ ExecInsert(TupleTableSlot *slot, ...@@ -309,15 +309,17 @@ ExecInsert(TupleTableSlot *slot,
* delete and oldtuple is NULL. When deleting from a view, * delete and oldtuple is NULL. When deleting from a view,
* oldtuple is passed to the INSTEAD OF triggers and identifies * oldtuple is passed to the INSTEAD OF triggers and identifies
* what to delete, and tupleid is invalid. When deleting from a * what to delete, and tupleid is invalid. When deleting from a
* foreign table, both tupleid and oldtuple are NULL; the FDW has * foreign table, tupleid is invalid; the FDW has to figure out
* to figure out which row to delete using data from the planSlot. * which row to delete using data from the planSlot. oldtuple is
* passed to foreign table triggers; it is NULL when the foreign
* table has no relevant triggers.
* *
* Returns RETURNING result if any, otherwise NULL. * Returns RETURNING result if any, otherwise NULL.
* ---------------------------------------------------------------- * ----------------------------------------------------------------
*/ */
static TupleTableSlot * static TupleTableSlot *
ExecDelete(ItemPointer tupleid, ExecDelete(ItemPointer tupleid,
HeapTupleHeader oldtuple, HeapTuple oldtuple,
TupleTableSlot *planSlot, TupleTableSlot *planSlot,
EPQState *epqstate, EPQState *epqstate,
EState *estate, EState *estate,
...@@ -342,7 +344,7 @@ ExecDelete(ItemPointer tupleid, ...@@ -342,7 +344,7 @@ ExecDelete(ItemPointer tupleid,
bool dodelete; bool dodelete;
dodelete = ExecBRDeleteTriggers(estate, epqstate, resultRelInfo, dodelete = ExecBRDeleteTriggers(estate, epqstate, resultRelInfo,
tupleid); tupleid, oldtuple);
if (!dodelete) /* "do nothing" */ if (!dodelete) /* "do nothing" */
return NULL; return NULL;
...@@ -352,16 +354,10 @@ ExecDelete(ItemPointer tupleid, ...@@ -352,16 +354,10 @@ ExecDelete(ItemPointer tupleid,
if (resultRelInfo->ri_TrigDesc && if (resultRelInfo->ri_TrigDesc &&
resultRelInfo->ri_TrigDesc->trig_delete_instead_row) resultRelInfo->ri_TrigDesc->trig_delete_instead_row)
{ {
HeapTupleData tuple;
bool dodelete; bool dodelete;
Assert(oldtuple != NULL); Assert(oldtuple != NULL);
tuple.t_data = oldtuple; dodelete = ExecIRDeleteTriggers(estate, resultRelInfo, oldtuple);
tuple.t_len = HeapTupleHeaderGetDatumLength(oldtuple);
ItemPointerSetInvalid(&(tuple.t_self));
tuple.t_tableOid = InvalidOid;
dodelete = ExecIRDeleteTriggers(estate, resultRelInfo, &tuple);
if (!dodelete) /* "do nothing" */ if (!dodelete) /* "do nothing" */
return NULL; return NULL;
...@@ -488,7 +484,7 @@ ldelete:; ...@@ -488,7 +484,7 @@ ldelete:;
(estate->es_processed)++; (estate->es_processed)++;
/* AFTER ROW DELETE Triggers */ /* AFTER ROW DELETE Triggers */
ExecARDeleteTriggers(estate, resultRelInfo, tupleid); ExecARDeleteTriggers(estate, resultRelInfo, tupleid, oldtuple);
/* Process RETURNING if present */ /* Process RETURNING if present */
if (resultRelInfo->ri_projectReturning) if (resultRelInfo->ri_projectReturning)
...@@ -512,10 +508,7 @@ ldelete:; ...@@ -512,10 +508,7 @@ ldelete:;
slot = estate->es_trig_tuple_slot; slot = estate->es_trig_tuple_slot;
if (oldtuple != NULL) if (oldtuple != NULL)
{ {
deltuple.t_data = oldtuple; deltuple = *oldtuple;
deltuple.t_len = HeapTupleHeaderGetDatumLength(oldtuple);
ItemPointerSetInvalid(&(deltuple.t_self));
deltuple.t_tableOid = InvalidOid;
delbuffer = InvalidBuffer; delbuffer = InvalidBuffer;
} }
else else
...@@ -564,15 +557,17 @@ ldelete:; ...@@ -564,15 +557,17 @@ ldelete:;
* update and oldtuple is NULL. When updating a view, oldtuple * update and oldtuple is NULL. When updating a view, oldtuple
* is passed to the INSTEAD OF triggers and identifies what to * is passed to the INSTEAD OF triggers and identifies what to
* update, and tupleid is invalid. When updating a foreign table, * update, and tupleid is invalid. When updating a foreign table,
* both tupleid and oldtuple are NULL; the FDW has to figure out * tupleid is invalid; the FDW has to figure out which row to
* which row to update using data from the planSlot. * update using data from the planSlot. oldtuple is passed to
* foreign table triggers; it is NULL when the foreign table has
* no relevant triggers.
* *
* Returns RETURNING result if any, otherwise NULL. * Returns RETURNING result if any, otherwise NULL.
* ---------------------------------------------------------------- * ----------------------------------------------------------------
*/ */
static TupleTableSlot * static TupleTableSlot *
ExecUpdate(ItemPointer tupleid, ExecUpdate(ItemPointer tupleid,
HeapTupleHeader oldtuple, HeapTuple oldtuple,
TupleTableSlot *slot, TupleTableSlot *slot,
TupleTableSlot *planSlot, TupleTableSlot *planSlot,
EPQState *epqstate, EPQState *epqstate,
...@@ -609,7 +604,7 @@ ExecUpdate(ItemPointer tupleid, ...@@ -609,7 +604,7 @@ ExecUpdate(ItemPointer tupleid,
resultRelInfo->ri_TrigDesc->trig_update_before_row) resultRelInfo->ri_TrigDesc->trig_update_before_row)
{ {
slot = ExecBRUpdateTriggers(estate, epqstate, resultRelInfo, slot = ExecBRUpdateTriggers(estate, epqstate, resultRelInfo,
tupleid, slot); tupleid, oldtuple, slot);
if (slot == NULL) /* "do nothing" */ if (slot == NULL) /* "do nothing" */
return NULL; return NULL;
...@@ -622,16 +617,8 @@ ExecUpdate(ItemPointer tupleid, ...@@ -622,16 +617,8 @@ ExecUpdate(ItemPointer tupleid,
if (resultRelInfo->ri_TrigDesc && if (resultRelInfo->ri_TrigDesc &&
resultRelInfo->ri_TrigDesc->trig_update_instead_row) resultRelInfo->ri_TrigDesc->trig_update_instead_row)
{ {
HeapTupleData oldtup;
Assert(oldtuple != NULL);
oldtup.t_data = oldtuple;
oldtup.t_len = HeapTupleHeaderGetDatumLength(oldtuple);
ItemPointerSetInvalid(&(oldtup.t_self));
oldtup.t_tableOid = InvalidOid;
slot = ExecIRUpdateTriggers(estate, resultRelInfo, slot = ExecIRUpdateTriggers(estate, resultRelInfo,
&oldtup, slot); oldtuple, slot);
if (slot == NULL) /* "do nothing" */ if (slot == NULL) /* "do nothing" */
return NULL; return NULL;
...@@ -788,7 +775,7 @@ lreplace:; ...@@ -788,7 +775,7 @@ lreplace:;
(estate->es_processed)++; (estate->es_processed)++;
/* AFTER ROW UPDATE Triggers */ /* AFTER ROW UPDATE Triggers */
ExecARUpdateTriggers(estate, resultRelInfo, tupleid, tuple, ExecARUpdateTriggers(estate, resultRelInfo, tupleid, oldtuple, tuple,
recheckIndexes); recheckIndexes);
list_free(recheckIndexes); list_free(recheckIndexes);
...@@ -873,7 +860,8 @@ ExecModifyTable(ModifyTableState *node) ...@@ -873,7 +860,8 @@ ExecModifyTable(ModifyTableState *node)
TupleTableSlot *planSlot; TupleTableSlot *planSlot;
ItemPointer tupleid = NULL; ItemPointer tupleid = NULL;
ItemPointerData tuple_ctid; ItemPointerData tuple_ctid;
HeapTupleHeader oldtuple = NULL; HeapTupleData oldtupdata;
HeapTuple oldtuple;
/* /*
* This should NOT get called during EvalPlanQual; we should have passed a * This should NOT get called during EvalPlanQual; we should have passed a
...@@ -958,6 +946,7 @@ ExecModifyTable(ModifyTableState *node) ...@@ -958,6 +946,7 @@ ExecModifyTable(ModifyTableState *node)
EvalPlanQualSetSlot(&node->mt_epqstate, planSlot); EvalPlanQualSetSlot(&node->mt_epqstate, planSlot);
slot = planSlot; slot = planSlot;
oldtuple = NULL;
if (junkfilter != NULL) if (junkfilter != NULL)
{ {
/* /*
...@@ -984,11 +973,21 @@ ExecModifyTable(ModifyTableState *node) ...@@ -984,11 +973,21 @@ ExecModifyTable(ModifyTableState *node)
* ctid!! */ * ctid!! */
tupleid = &tuple_ctid; tupleid = &tuple_ctid;
} }
else if (relkind == RELKIND_FOREIGN_TABLE) /*
{ * Use the wholerow attribute, when available, to reconstruct
/* do nothing; FDW must fetch any junk attrs it wants */ * the old relation tuple.
} *
else * Foreign table updates have a wholerow attribute when the
* relation has an AFTER ROW trigger. Note that the wholerow
* attribute does not carry system columns. Foreign table
* triggers miss seeing those, except that we know enough here
* to set t_tableOid. Quite separately from this, the FDW may
* fetch its own junk attrs to identify the row.
*
* Other relevant relkinds, currently limited to views, always
* have a wholerow attribute.
*/
else if (AttributeNumberIsValid(junkfilter->jf_junkAttNo))
{ {
datum = ExecGetJunkAttribute(slot, datum = ExecGetJunkAttribute(slot,
junkfilter->jf_junkAttNo, junkfilter->jf_junkAttNo,
...@@ -997,8 +996,19 @@ ExecModifyTable(ModifyTableState *node) ...@@ -997,8 +996,19 @@ ExecModifyTable(ModifyTableState *node)
if (isNull) if (isNull)
elog(ERROR, "wholerow is NULL"); elog(ERROR, "wholerow is NULL");
oldtuple = DatumGetHeapTupleHeader(datum); oldtupdata.t_data = DatumGetHeapTupleHeader(datum);
oldtupdata.t_len =
HeapTupleHeaderGetDatumLength(oldtupdata.t_data);
ItemPointerSetInvalid(&(oldtupdata.t_self));
/* Historically, view triggers see invalid t_tableOid. */
oldtupdata.t_tableOid =
(relkind == RELKIND_VIEW) ? InvalidOid :
RelationGetRelid(resultRelInfo->ri_RelationDesc);
oldtuple = &oldtupdata;
} }
else
Assert(relkind == RELKIND_FOREIGN_TABLE);
} }
/* /*
...@@ -1334,7 +1344,11 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags) ...@@ -1334,7 +1344,11 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
} }
else if (relkind == RELKIND_FOREIGN_TABLE) else if (relkind == RELKIND_FOREIGN_TABLE)
{ {
/* FDW must fetch any junk attrs it wants */ /*
* When there is an AFTER trigger, there should be a
* wholerow attribute.
*/
j->jf_junkAttNo = ExecFindJunkAttribute(j, "wholerow");
} }
else else
{ {
......
...@@ -1199,7 +1199,7 @@ static void ...@@ -1199,7 +1199,7 @@ static void
rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte, rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte,
Relation target_relation) Relation target_relation)
{ {
Var *var; Var *var = NULL;
const char *attrname; const char *attrname;
TargetEntry *tle; TargetEntry *tle;
...@@ -1231,7 +1231,26 @@ rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte, ...@@ -1231,7 +1231,26 @@ rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte,
fdwroutine->AddForeignUpdateTargets(parsetree, target_rte, fdwroutine->AddForeignUpdateTargets(parsetree, target_rte,
target_relation); target_relation);
return; /*
* If we have a row-level trigger corresponding to the operation, emit
* a whole-row Var so that executor will have the "old" row to pass to
* the trigger. Alas, this misses system columns.
*/
if (target_relation->trigdesc &&
((parsetree->commandType == CMD_UPDATE &&
(target_relation->trigdesc->trig_update_after_row ||
target_relation->trigdesc->trig_update_before_row)) ||
(parsetree->commandType == CMD_DELETE &&
(target_relation->trigdesc->trig_delete_after_row ||
target_relation->trigdesc->trig_delete_before_row))))
{
var = makeWholeRowVar(target_rte,
parsetree->resultRelation,
0,
false);
attrname = "wholerow";
}
} }
else else
{ {
...@@ -1247,12 +1266,15 @@ rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte, ...@@ -1247,12 +1266,15 @@ rewriteTargetListUD(Query *parsetree, RangeTblEntry *target_rte,
attrname = "wholerow"; attrname = "wholerow";
} }
if (var != NULL)
{
tle = makeTargetEntry((Expr *) var, tle = makeTargetEntry((Expr *) var,
list_length(parsetree->targetList) + 1, list_length(parsetree->targetList) + 1,
pstrdup(attrname), pstrdup(attrname),
true); true);
parsetree->targetList = lappend(parsetree->targetList, tle); parsetree->targetList = lappend(parsetree->targetList, tle);
}
} }
......
...@@ -147,10 +147,12 @@ extern void ExecASDeleteTriggers(EState *estate, ...@@ -147,10 +147,12 @@ extern void ExecASDeleteTriggers(EState *estate,
extern bool ExecBRDeleteTriggers(EState *estate, extern bool ExecBRDeleteTriggers(EState *estate,
EPQState *epqstate, EPQState *epqstate,
ResultRelInfo *relinfo, ResultRelInfo *relinfo,
ItemPointer tupleid); ItemPointer tupleid,
HeapTuple fdw_trigtuple);
extern void ExecARDeleteTriggers(EState *estate, extern void ExecARDeleteTriggers(EState *estate,
ResultRelInfo *relinfo, ResultRelInfo *relinfo,
ItemPointer tupleid); ItemPointer tupleid,
HeapTuple fdw_trigtuple);
extern bool ExecIRDeleteTriggers(EState *estate, extern bool ExecIRDeleteTriggers(EState *estate,
ResultRelInfo *relinfo, ResultRelInfo *relinfo,
HeapTuple trigtuple); HeapTuple trigtuple);
...@@ -162,10 +164,12 @@ extern TupleTableSlot *ExecBRUpdateTriggers(EState *estate, ...@@ -162,10 +164,12 @@ extern TupleTableSlot *ExecBRUpdateTriggers(EState *estate,
EPQState *epqstate, EPQState *epqstate,
ResultRelInfo *relinfo, ResultRelInfo *relinfo,
ItemPointer tupleid, ItemPointer tupleid,
HeapTuple fdw_trigtuple,
TupleTableSlot *slot); TupleTableSlot *slot);
extern void ExecARUpdateTriggers(EState *estate, extern void ExecARUpdateTriggers(EState *estate,
ResultRelInfo *relinfo, ResultRelInfo *relinfo,
ItemPointer tupleid, ItemPointer tupleid,
HeapTuple fdw_trigtuple,
HeapTuple newtuple, HeapTuple newtuple,
List *recheckIndexes); List *recheckIndexes);
extern TupleTableSlot *ExecIRUpdateTriggers(EState *estate, extern TupleTableSlot *ExecIRUpdateTriggers(EState *estate,
......
...@@ -1158,6 +1158,43 @@ CREATE USER MAPPING FOR current_user SERVER s9; ...@@ -1158,6 +1158,43 @@ CREATE USER MAPPING FOR current_user SERVER s9;
DROP SERVER s9 CASCADE; -- ERROR DROP SERVER s9 CASCADE; -- ERROR
ERROR: must be owner of foreign server s9 ERROR: must be owner of foreign server s9
RESET ROLE; RESET ROLE;
-- Triggers
CREATE FUNCTION dummy_trigger() RETURNS TRIGGER AS $$
BEGIN
RETURN NULL;
END
$$ language plpgsql;
CREATE TRIGGER trigtest_before_stmt BEFORE INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH STATEMENT
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_after_stmt AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH STATEMENT
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_before_row BEFORE INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_after_row AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
CREATE CONSTRAINT TRIGGER trigtest_constraint AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
ERROR: "foreign_table_1" is a foreign table
DETAIL: Foreign tables cannot have constraint triggers.
ALTER FOREIGN TABLE foreign_schema.foreign_table_1
DISABLE TRIGGER trigtest_before_stmt;
ALTER FOREIGN TABLE foreign_schema.foreign_table_1
ENABLE TRIGGER trigtest_before_stmt;
DROP TRIGGER trigtest_before_stmt ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_before_row ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_after_stmt ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_after_row ON foreign_schema.foreign_table_1;
DROP FUNCTION dummy_trigger();
-- DROP FOREIGN TABLE -- DROP FOREIGN TABLE
DROP FOREIGN TABLE no_table; -- ERROR DROP FOREIGN TABLE no_table; -- ERROR
ERROR: foreign table "no_table" does not exist ERROR: foreign table "no_table" does not exist
......
...@@ -470,6 +470,50 @@ CREATE USER MAPPING FOR current_user SERVER s9; ...@@ -470,6 +470,50 @@ CREATE USER MAPPING FOR current_user SERVER s9;
DROP SERVER s9 CASCADE; -- ERROR DROP SERVER s9 CASCADE; -- ERROR
RESET ROLE; RESET ROLE;
-- Triggers
CREATE FUNCTION dummy_trigger() RETURNS TRIGGER AS $$
BEGIN
RETURN NULL;
END
$$ language plpgsql;
CREATE TRIGGER trigtest_before_stmt BEFORE INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH STATEMENT
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_after_stmt AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH STATEMENT
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_before_row BEFORE INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
CREATE TRIGGER trigtest_after_row AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
CREATE CONSTRAINT TRIGGER trigtest_constraint AFTER INSERT OR UPDATE OR DELETE
ON foreign_schema.foreign_table_1
FOR EACH ROW
EXECUTE PROCEDURE dummy_trigger();
ALTER FOREIGN TABLE foreign_schema.foreign_table_1
DISABLE TRIGGER trigtest_before_stmt;
ALTER FOREIGN TABLE foreign_schema.foreign_table_1
ENABLE TRIGGER trigtest_before_stmt;
DROP TRIGGER trigtest_before_stmt ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_before_row ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_after_stmt ON foreign_schema.foreign_table_1;
DROP TRIGGER trigtest_after_row ON foreign_schema.foreign_table_1;
DROP FUNCTION dummy_trigger();
-- DROP FOREIGN TABLE -- DROP FOREIGN TABLE
DROP FOREIGN TABLE no_table; -- ERROR DROP FOREIGN TABLE no_table; -- ERROR
DROP FOREIGN TABLE IF EXISTS no_table; DROP FOREIGN TABLE IF EXISTS no_table;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment