diff --git a/doc/src/sgml/ref/create_trigger.sgml b/doc/src/sgml/ref/create_trigger.sgml index 6514ffc6ae..936476a558 100644 --- a/doc/src/sgml/ref/create_trigger.sgml +++ b/doc/src/sgml/ref/create_trigger.sgml @@ -49,8 +49,8 @@ CREATE [ CONSTRAINT ] TRIGGER name CREATE TRIGGER creates a new trigger. The - trigger will be associated with the specified table, view, or foreign table - and will execute the specified + trigger will be associated with the specified table, view, materialized view, + or foreign table and will execute the specified function function_name when certain operations are performed on that table. @@ -92,6 +92,12 @@ CREATE [ CONSTRAINT ] TRIGGER name must be marked as FOR EACH STATEMENT. + + AFTER triggers can be specified on materialized views and + are fired after every REFRESH MATERIALIZED VIEW CONCURRENTLY + on the materialized view. + + In addition, triggers may be defined to fire for TRUNCATE, though only @@ -100,7 +106,7 @@ CREATE [ CONSTRAINT ] TRIGGER name The following table summarizes which types of triggers may be used on - tables, views, and foreign tables: + tables, views, materialized views, and foreign tables: @@ -128,8 +134,8 @@ CREATE [ CONSTRAINT ] TRIGGER name AFTER INSERT/UPDATE/DELETE - Tables and foreign tables - Tables, views, and foreign tables + Tables, materialized views, and foreign tables + Tables, views, materialized views, and foreign tables TRUNCATE @@ -276,8 +282,8 @@ UPDATE OF column_name1 [, column_name2table_name - The name (optionally schema-qualified) of the table, view, or foreign - table the trigger is for. + The name (optionally schema-qualified) of the table, view, materialized view, + or foreign table the trigger is for. @@ -520,6 +526,17 @@ UPDATE OF column_name1 [, column_name2 + + Triggers on materialized views are fired only when + REFRESH MATERIALIZED VIEW is used with the + CONCURRENTLY option. Such refresh computes + a diff between old and new data and executes INSERT, + UPDATE, and DELETE queries to + update the materialized view to new data. + Refresh without this option refreshes all data at once and does not + fire any triggers. + + Creating a row-level trigger on a partitioned table will cause identical triggers to be created in all its existing partitions; and any partitions @@ -730,6 +747,11 @@ CREATE TRIGGER paired_items_update standard. + + The ability to define triggers for materialized views is a PostgreSQL + extension of the SQL standard. + + diff --git a/doc/src/sgml/ref/refresh_materialized_view.sgml b/doc/src/sgml/ref/refresh_materialized_view.sgml index fd06f1fda1..b0c85cb3b9 100644 --- a/doc/src/sgml/ref/refresh_materialized_view.sgml +++ b/doc/src/sgml/ref/refresh_materialized_view.sgml @@ -66,10 +66,6 @@ REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] nameWHERE clause. - - This option may not be used when the materialized view is not already - populated. - Even with this option only one REFRESH at a time may run against any one materialized view. @@ -100,6 +96,14 @@ REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] nameORDER BY clause in the backing query. + + + Refresh with the CONCURRENTLY option fires + any INSERT, UPDATE, and + DELETE triggers defined on the materialized view. + Refresh without this option refreshes all data at once and does not + fire any triggers. + diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c index a171ebabf8..feaff32cc0 100644 --- a/src/backend/commands/matview.c +++ b/src/backend/commands/matview.c @@ -172,12 +172,6 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString, errmsg("\"%s\" is not a materialized view", RelationGetRelationName(matviewRel)))); - /* Check that CONCURRENTLY is not specified if not populated. */ - if (concurrent && !RelationIsPopulated(matviewRel)) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("CONCURRENTLY cannot be used when the materialized view is not populated"))); - /* Check that conflicting options have not been specified. */ if (concurrent && stmt->skipData) ereport(ERROR, @@ -565,9 +559,11 @@ make_temptable_name_n(char *tempname, int n) * the old record (if matched) and the ROW from the new table as a single * column of complex record type (if matched). * - * Once we have the diff table, we perform set-based DELETE and INSERT + * Once we have the diff table, we perform set-based DELETE, UPDATE, and INSERT * operations against the materialized view, and discard both temporary - * tables. + * tables. We do all of those operations so that any triggers called because + * of these operations represent reasonable calls one would expect to see when + * syncing the materialized view to new data. * * Everything from the generation of the new data to applying the differences * takes place under cover of an ExclusiveLock, since it seems as though we @@ -590,6 +586,7 @@ refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner, bool foundUniqueIndex; List *indexoidlist; ListCell *indexoidscan; + AttrNumber relattno; int16 relnatts; Oid *opUsedForQual; @@ -779,8 +776,9 @@ refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner, Assert(foundUniqueIndex); appendStringInfoString(&querybuf, - " AND newdata OPERATOR(pg_catalog.*=) mv) " + ") " "WHERE newdata IS NULL OR mv IS NULL " + "OR newdata OPERATOR(pg_catalog.*<>) mv " "ORDER BY tid"); /* Create the temporary "diff" table. */ @@ -803,7 +801,7 @@ refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner, OpenMatViewIncrementalMaintenance(); - /* Deletes must come before inserts; do them first. */ + /* We do deletes first. */ resetStringInfo(&querybuf); appendStringInfo(&querybuf, "DELETE FROM %s mv WHERE ctid OPERATOR(pg_catalog.=) ANY " @@ -814,7 +812,38 @@ refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner, if (SPI_exec(querybuf.data, 0) != SPI_OK_DELETE) elog(ERROR, "SPI_exec failed: %s", querybuf.data); - /* Inserts go last. */ + /* Then we do updates. */ + resetStringInfo(&querybuf); + appendStringInfo(&querybuf, "UPDATE %s mv SET (", matviewname); + + for (relattno = 1; relattno <= relnatts; relattno++) + { + Form_pg_attribute attribute = TupleDescAttr(tupdesc, relattno - 1); + char *attributeName = NameStr(attribute->attname); + + /* Ignore dropped */ + if (attribute->attisdropped) + continue; + + if (relattno == 1) + { + appendStringInfo(&querybuf, "%s", quote_identifier(attributeName)); + } + else + { + appendStringInfo(&querybuf, ", %s", quote_identifier(attributeName)); + } + } + + appendStringInfo(&querybuf, + ") = ROW((diff.newdata).*) FROM %s diff " + "WHERE diff.tid IS NOT NULL AND diff.newdata IS NOT NULL " + "AND mv.ctid OPERATOR(pg_catalog.=) diff.tid", + diffname); + if (SPI_exec(querybuf.data, 0) != SPI_OK_UPDATE) + elog(ERROR, "SPI_exec failed: %s", querybuf.data); + + /* Inserts and updates go last. */ resetStringInfo(&querybuf); appendStringInfo(&querybuf, "INSERT INTO %s SELECT (diff.newdata).* " diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c index fb0de60a45..8597def50a 100644 --- a/src/backend/commands/trigger.c +++ b/src/backend/commands/trigger.c @@ -208,6 +208,16 @@ CreateTrigger(CreateTrigStmt *stmt, const char *queryString, RelationGetRelationName(rel)), errdetail("Tables cannot have INSTEAD OF triggers."))); } + else if (rel->rd_rel->relkind == RELKIND_MATVIEW) + { + /* Materialized views can have only AFTER triggers */ + if (stmt->timing != TRIGGER_TYPE_AFTER) + ereport(ERROR, + (errcode(ERRCODE_WRONG_OBJECT_TYPE), + errmsg("\"%s\" is a materialized view", + RelationGetRelationName(rel)), + errdetail("Materialized views can have only AFTER triggers."))); + } else if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE) { /* Partitioned tables can't have INSTEAD OF triggers */ @@ -307,7 +317,7 @@ CreateTrigger(CreateTrigStmt *stmt, const char *queryString, else ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE), - errmsg("\"%s\" is not a table or view", + errmsg("\"%s\" is not a table, view, or materialized view", RelationGetRelationName(rel)))); if (!allowSystemTableMods && IsSystemRelation(rel)) @@ -1513,11 +1523,12 @@ RemoveTriggerById(Oid trigOid) if (rel->rd_rel->relkind != RELKIND_RELATION && rel->rd_rel->relkind != RELKIND_VIEW && + rel->rd_rel->relkind != RELKIND_MATVIEW && rel->rd_rel->relkind != RELKIND_FOREIGN_TABLE && rel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE) ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE), - errmsg("\"%s\" is not a table, view, or foreign table", + errmsg("\"%s\" is not a table, view, materialized view, or foreign table", RelationGetRelationName(rel)))); if (!allowSystemTableMods && IsSystemRelation(rel)) @@ -1619,11 +1630,12 @@ RangeVarCallbackForRenameTrigger(const RangeVar *rv, Oid relid, Oid oldrelid, /* only tables and views can have triggers */ if (form->relkind != RELKIND_RELATION && form->relkind != RELKIND_VIEW && + form->relkind != RELKIND_MATVIEW && form->relkind != RELKIND_FOREIGN_TABLE && form->relkind != RELKIND_PARTITIONED_TABLE) ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE), - errmsg("\"%s\" is not a table, view, or foreign table", + errmsg("\"%s\" is not a table, view, materialized view, or foreign table", rv->relname))); /* you must own the table to rename one of its triggers */ diff --git a/src/test/regress/expected/matview.out b/src/test/regress/expected/matview.out index 08cd4bea48..944d608251 100644 --- a/src/test/regress/expected/matview.out +++ b/src/test/regress/expected/matview.out @@ -564,6 +564,104 @@ REFRESH MATERIALIZED VIEW mvtest_mv_foo; REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_mv_foo; DROP OWNED BY regress_user_mvtest CASCADE; DROP ROLE regress_user_mvtest; +-- create a new test table +CREATE TABLE mvtest_t2 (id int NOT NULL PRIMARY KEY, type text NOT NULL, amt numeric NOT NULL); +INSERT INTO mvtest_t2 VALUES + (1, 'x', 2), + (2, 'x', 3), + (3, 'y', 5), + (4, 'y', 7), + (5, 'z', 11); +-- define trigger functions +CREATE FUNCTION notify_changes() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE +message TEXT := TG_ARGV[0]; +BEGIN + IF (TG_OP = 'DELETE') THEN + RAISE NOTICE 'DELETE %', concat_ws(' ', message, (SELECT array_to_json(array_agg(old_table)) FROM old_table)); + ELSIF (TG_OP = 'TRUNCATE') THEN + RAISE NOTICE 'TRUNCATE %', message; + ELSIF (TG_OP = 'UPDATE') THEN + RAISE NOTICE 'UPDATE %', concat_ws(' ', message, (SELECT array_to_json(array_agg(new_table)) FROM new_table), (SELECT array_to_json(array_agg(old_table)) FROM old_table)); + ELSIF (TG_OP = 'INSERT') THEN + RAISE NOTICE 'INSERT %', concat_ws(' ', message, (SELECT array_to_json(array_agg(new_table)) FROM new_table)); + END IF; +RETURN NULL; +END +$$; +CREATE FUNCTION notify_changes_row() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE +message TEXT := TG_ARGV[0]; +BEGIN + IF (TG_OP = 'DELETE') THEN + RAISE NOTICE 'DELETE %', concat_ws(' ', message, row_to_json(OLD)); + ELSIF (TG_OP = 'UPDATE') THEN + RAISE NOTICE 'UPDATE %', concat_ws(' ', message, row_to_json(NEW), row_to_json(OLD)); + ELSIF (TG_OP = 'INSERT') THEN + RAISE NOTICE 'INSERT %', concat_ws(' ', message, row_to_json(NEW)); + END IF; +RETURN NULL; +END +$$; +-- create materialized view WITH NO DATA +CREATE MATERIALIZED VIEW mvtest_t2_no_data_view AS SELECT * FROM mvtest_t2 ORDER BY id WITH NO DATA; +CREATE UNIQUE INDEX mvtest_t2_no_data_view_id ON mvtest_t2_no_data_view (id); +-- register triggers +CREATE TRIGGER mvtest_t2_no_data_view_insert AFTER INSERT ON mvtest_t2_no_data_view REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_insert'); +CREATE TRIGGER mvtest_t2_no_data_view_update AFTER UPDATE ON mvtest_t2_no_data_view REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_update'); +CREATE TRIGGER mvtest_t2_no_data_view_delete AFTER DELETE ON mvtest_t2_no_data_view REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_delete'); +CREATE TRIGGER mvtest_t2_no_data_view_truncate AFTER TRUNCATE ON mvtest_t2_no_data_view FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_truncate'); +CREATE TRIGGER mvtest_t2_no_data_view_insert_row AFTER INSERT ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_insert_row'); +CREATE TRIGGER mvtest_t2_no_data_view_update_row AFTER UPDATE ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_update_row'); +CREATE TRIGGER mvtest_t2_no_data_view_delete_row AFTER DELETE ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_delete_row'); +-- try to select from view without data, it should error +SELECT * FROM mvtest_t2_no_data_view; +ERROR: materialized view "mvtest_t2_no_data_view" has not been populated +HINT: Use the REFRESH MATERIALIZED VIEW command. +-- we should be able to do initial REFRESH MATERIALIZED VIEW CONCURRENTLY and triggers should be called +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_t2_no_data_view; +NOTICE: DELETE mvtest_t2_no_data_view_delete +NOTICE: UPDATE mvtest_t2_no_data_view_update +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":2,"type":"x","amt":3} +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":5,"type":"z","amt":11} +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":4,"type":"y","amt":7} +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":1,"type":"x","amt":2} +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":3,"type":"y","amt":5} +NOTICE: INSERT mvtest_t2_no_data_view_insert [{"id":2,"type":"x","amt":3},{"id":5,"type":"z","amt":11},{"id":4,"type":"y","amt":7},{"id":1,"type":"x","amt":2},{"id":3,"type":"y","amt":5}] +-- now materialized view should have data +SELECT * FROM mvtest_t2_no_data_view ORDER BY id; + id | type | amt +----+------+----- + 1 | x | 2 + 2 | x | 3 + 3 | y | 5 + 4 | y | 7 + 5 | z | 11 +(5 rows) + +-- update the original table +INSERT INTO mvtest_t2 VALUES (7, 'k', 10); +DELETE FROM mvtest_t2 WHERE id=2; +UPDATE mvtest_t2 SET amt=5 WHERE id=4; +-- refresh +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_t2_no_data_view; +NOTICE: DELETE mvtest_t2_no_data_view_delete_row {"id":2,"type":"x","amt":3} +NOTICE: DELETE mvtest_t2_no_data_view_delete [{"id":2,"type":"x","amt":3}] +NOTICE: UPDATE mvtest_t2_no_data_view_update_row {"id":4,"type":"y","amt":5} {"id":4,"type":"y","amt":7} +NOTICE: UPDATE mvtest_t2_no_data_view_update [{"id":4,"type":"y","amt":5}] [{"id":4,"type":"y","amt":7}] +NOTICE: INSERT mvtest_t2_no_data_view_insert_row {"id":7,"type":"k","amt":10} +NOTICE: INSERT mvtest_t2_no_data_view_insert [{"id":7,"type":"k","amt":10}] +-- materialized view should have updated data +SELECT * FROM mvtest_t2_no_data_view ORDER BY id; + id | type | amt +----+------+----- + 1 | x | 2 + 3 | y | 5 + 4 | y | 5 + 5 | z | 11 + 7 | k | 10 +(5 rows) + -- make sure that create WITH NO DATA works via SPI BEGIN; CREATE FUNCTION mvtest_func() diff --git a/src/test/regress/sql/matview.sql b/src/test/regress/sql/matview.sql index d96175aa26..d150ce3824 100644 --- a/src/test/regress/sql/matview.sql +++ b/src/test/regress/sql/matview.sql @@ -223,6 +223,80 @@ REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_mv_foo; DROP OWNED BY regress_user_mvtest CASCADE; DROP ROLE regress_user_mvtest; +-- create a new test table +CREATE TABLE mvtest_t2 (id int NOT NULL PRIMARY KEY, type text NOT NULL, amt numeric NOT NULL); +INSERT INTO mvtest_t2 VALUES + (1, 'x', 2), + (2, 'x', 3), + (3, 'y', 5), + (4, 'y', 7), + (5, 'z', 11); + +-- define trigger functions +CREATE FUNCTION notify_changes() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE +message TEXT := TG_ARGV[0]; +BEGIN + IF (TG_OP = 'DELETE') THEN + RAISE NOTICE 'DELETE %', concat_ws(' ', message, (SELECT array_to_json(array_agg(old_table)) FROM old_table)); + ELSIF (TG_OP = 'TRUNCATE') THEN + RAISE NOTICE 'TRUNCATE %', message; + ELSIF (TG_OP = 'UPDATE') THEN + RAISE NOTICE 'UPDATE %', concat_ws(' ', message, (SELECT array_to_json(array_agg(new_table)) FROM new_table), (SELECT array_to_json(array_agg(old_table)) FROM old_table)); + ELSIF (TG_OP = 'INSERT') THEN + RAISE NOTICE 'INSERT %', concat_ws(' ', message, (SELECT array_to_json(array_agg(new_table)) FROM new_table)); + END IF; +RETURN NULL; +END +$$; +CREATE FUNCTION notify_changes_row() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE +message TEXT := TG_ARGV[0]; +BEGIN + IF (TG_OP = 'DELETE') THEN + RAISE NOTICE 'DELETE %', concat_ws(' ', message, row_to_json(OLD)); + ELSIF (TG_OP = 'UPDATE') THEN + RAISE NOTICE 'UPDATE %', concat_ws(' ', message, row_to_json(NEW), row_to_json(OLD)); + ELSIF (TG_OP = 'INSERT') THEN + RAISE NOTICE 'INSERT %', concat_ws(' ', message, row_to_json(NEW)); + END IF; +RETURN NULL; +END +$$; + +-- create materialized view WITH NO DATA +CREATE MATERIALIZED VIEW mvtest_t2_no_data_view AS SELECT * FROM mvtest_t2 ORDER BY id WITH NO DATA; +CREATE UNIQUE INDEX mvtest_t2_no_data_view_id ON mvtest_t2_no_data_view (id); + +-- register triggers +CREATE TRIGGER mvtest_t2_no_data_view_insert AFTER INSERT ON mvtest_t2_no_data_view REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_insert'); +CREATE TRIGGER mvtest_t2_no_data_view_update AFTER UPDATE ON mvtest_t2_no_data_view REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_update'); +CREATE TRIGGER mvtest_t2_no_data_view_delete AFTER DELETE ON mvtest_t2_no_data_view REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_delete'); +CREATE TRIGGER mvtest_t2_no_data_view_truncate AFTER TRUNCATE ON mvtest_t2_no_data_view FOR EACH STATEMENT EXECUTE FUNCTION notify_changes('mvtest_t2_no_data_view_truncate'); +CREATE TRIGGER mvtest_t2_no_data_view_insert_row AFTER INSERT ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_insert_row'); +CREATE TRIGGER mvtest_t2_no_data_view_update_row AFTER UPDATE ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_update_row'); +CREATE TRIGGER mvtest_t2_no_data_view_delete_row AFTER DELETE ON mvtest_t2_no_data_view FOR EACH ROW EXECUTE FUNCTION notify_changes_row('mvtest_t2_no_data_view_delete_row'); + +-- try to select from view without data, it should error +SELECT * FROM mvtest_t2_no_data_view; + +-- we should be able to do initial REFRESH MATERIALIZED VIEW CONCURRENTLY and triggers should be called +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_t2_no_data_view; + +-- now materialized view should have data +SELECT * FROM mvtest_t2_no_data_view ORDER BY id; + +-- update the original table +INSERT INTO mvtest_t2 VALUES (7, 'k', 10); +DELETE FROM mvtest_t2 WHERE id=2; +UPDATE mvtest_t2 SET amt=5 WHERE id=4; + +-- refresh +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_t2_no_data_view; + +-- materialized view should have updated data +SELECT * FROM mvtest_t2_no_data_view ORDER BY id; + -- make sure that create WITH NO DATA works via SPI BEGIN; CREATE FUNCTION mvtest_func()