pax_global_header00006660000000000000000000000064143255157010014515gustar00rootroot0000000000000052 comment=971941730312d0ac855afcce4c2be2b1c94fe744 periods-1.2.2/000077500000000000000000000000001432551570100131645ustar00rootroot00000000000000periods-1.2.2/.gitattributes000066400000000000000000000001011432551570100160470ustar00rootroot00000000000000sql/* linguist-language=SQL expected/* linguist-detectable=false periods-1.2.2/.github/000077500000000000000000000000001432551570100145245ustar00rootroot00000000000000periods-1.2.2/.github/workflows/000077500000000000000000000000001432551570100165615ustar00rootroot00000000000000periods-1.2.2/.github/workflows/regression.yml000066400000000000000000000010701432551570100214620ustar00rootroot00000000000000name: make installcheck on: [push, pull_request] jobs: test: strategy: matrix: pg: - 15 - 14 - 13 - 12 - 11 - 10 - 9.6 - 9.5 name: PostgreSQL ${{ matrix.pg }} runs-on: ubuntu-latest container: pgxn/pgxn-tools steps: - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} - name: Check out the repo uses: actions/checkout@v2 - name: Test on PostgreSQL ${{ matrix.pg }} run: pg-build-test periods-1.2.2/.gitignore000066400000000000000000000001071432551570100151520ustar00rootroot00000000000000.vscode/ periods.o periods.so regression.diffs regression.out results/ periods-1.2.2/CHANGELOG.md000066400000000000000000000043241432551570100150000ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added ### Fixed ## [1.2] – 2020-09-21 ### Added - Add Access Control to prevent users from modifying the history. Only the table owner and superusers can do this because we can't prevent it. - Compatibility with PostgreSQL 13 ### Fixed - Use SPI to insert into the history table. They previous way of doing it didn't update the indexes, leading to wrong results depending on the execution plan. Users must REINDEX all indexes on history tables. - Ensure all of our functions are `SECURITY DEFINER`. - Ensure ownership of history and for-portion objects follow the main table's owner. - Quote all identifiers when building queries. - Don't use `regprocedure` in our catalogs, they prevent `pg_upgrade` from working. This reduces functionality a little but, but not being able to upgrade is a showstopper. ## [1.1] – 2020-02-05 ### Added - Add support for excluded columns. These are columns for which updates do not cause `GENERATED ALWAYS AS ROW START` to change, and historical rows will not be generated. This is not in the standard, but was requested by several people. - Cache some query plans in the C code. - Describe the proper way to `ALTER` a table with `SYSTEM VERSIONING`. ### Fixed - Match columns in the main table and the history table by name. This was an issue if either of the tables had dropped columns. - Use the main table's tuple descriptor when there is no mapping necessary with the history table's tuple descriptor (see previous item). This works around PostgreSQL bug #16242 where missing attributes are not considered when detecting differences. ## [1.0] – 2019-08-25 ### Added - Initial release. Supports all features of the SQL Standard concerning periods and `SYSTEM VERSIONING`. [Unreleased]: https://github.com/xocolatl/periods/compare/v1.2...HEAD [1.2]: https://github.com/xocolatl/periods/compare/v1.1...v1.2 [1.1]: https://github.com/xocolatl/periods/compare/v1.0...v1.1 [1.0]: https://github.com/xocolatl/periods/releases/tag/v1.0 periods-1.2.2/CODE_OF_CONDUCT.md000066400000000000000000000003231432551570100157610ustar00rootroot00000000000000This extension adheres to the official [PostgreSQL Community Code of Conduct](https://www.postgresql.org/about/policies/coc/). It is not reproduced here so that future modifications may take effect immediately. periods-1.2.2/LICENSE000066400000000000000000000016661432551570100142020ustar00rootroot00000000000000PostgreSQL License Copyright (c) 2019, The PostgreSQL Global Development Group (PGDG) Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. IN NO EVENT SHALL PGDG BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF PGDG HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. PGDG SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND PGDG HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. periods-1.2.2/Makefile000066400000000000000000000010421432551570100146210ustar00rootroot00000000000000MODULES = periods EXTENSION = periods DOCS = README.periods DATA = periods--1.0.sql \ periods--1.0--1.1.sql \ periods--1.1.sql \ periods--1.1--1.2.sql \ periods--1.2.sql REGRESS = install \ periods \ system_time_periods \ system_versioning \ excluded_columns \ unique_foreign \ for_portion_of \ predicates \ drop_protection \ rename_following \ health_checks \ acl \ issues \ beeswax \ uninstall PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS) periods-1.2.2/README.md000066400000000000000000000273051432551570100144520ustar00rootroot00000000000000# Periods and `SYSTEM VERSIONING` for PostgreSQL [![License](https://img.shields.io/badge/license-PostgreSQL-blue)](https://www.postgresql.org/about/licence/) [![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-PostgreSQL-blueviolet)](https://www.postgresql.org/about/policies/coc/) *compatible 9.5–15* This extension recreates the behavior defined in [SQL:2016](https://www.iso.org/standard/63556.html) (originally in SQL:2011) around periods and tables with `SYSTEM VERSIONING`. The idea is to figure out all the rules that PostgreSQL would like to adopt (there are some details missing in the standard) and to allow earlier versions of PostgreSQL to simulate the behavior once the feature is finally integrated. # What is a period? A period is a definition on a table which specifies a name and two columns. The period’s name cannot be the same as any column name of the table. ``` sql -- Standard SQL CREATE TABLE example ( id bigint, start_date date, end_date date, PERIOD FOR validity (start_date, end_date) ); ``` Defining a period constrains the two columns such that the start column’s value must be strictly inferior to the end column’s value, and that both columns be non-null. The period’s value includes the start value but excludes the end value. A period is therefore very similar to PostgreSQL’s range types, but a bit more restricted. Since extensions cannot modify PostgreSQL’s grammar, we use functions, views, and triggers to get as close to the same thing as possible. ``` sql CREATE TABLE example ( id bigint, start_date date, end_date date ); SELECT periods.add_period('example', 'validity', 'start_date', 'end_date'); ``` ## Unique constraints Periods may be part of `PRIMARY KEY`s and `UNIQUE` constraints. ``` sql -- Standard SQL CREATE TABLE example ( id bigint, start_date date, end_date date, PERIOD FOR validity (start_date, end_date), UNIQUE (id, validity WITHOUT OVERLAPS) ); ``` ``` sql CREATE TABLE example ( id bigint, start_date date, end_date date ); SELECT periods.add_period('example', 'validity', 'start_date', 'end_date'); SELECT periods.add_unique_key('example', ARRAY['id'], 'validity'); ``` The extension will create a unique constraint over all of the columns specified and the two columns of the period given. It will also create an exclusion constraint using gist to implement the `WITHOUT OVERLAPS` part of the constraint. The function also takes optional parameters if you already have such a constraint that you would like to use. ``` sql -- Standard SQL CREATE TABLE example ( id bigint, start_date date, end_date date, PERIOD FOR validity (start_date, end_date), CONSTRAINT example_pkey PRIMARY KEY (id, validity WITHOUT OVERLAPS) ); ``` ``` sql CREATE TABLE example ( id bigint, start_date date, end_date date, CONSTRAINT example_pkey PRIMARY KEY (id, start_date, end_date) ); SELECT periods.add_period('example', 'validity', 'start_date', 'end_date'); SELECT periods.add_unique_key('example', ARRAY['id'], 'validity', unique_constraint => 'example_pkey'); ``` Unique constraints may only contain one period. ## Foreign keys If you can have unique keys with periods, you can also have foreign keys pointing at them. ``` sql SELECT periods.add_foreign_key('example2', 'ARRAY[ex_id]', 'validity', 'example_id_validity'); ``` In this example, we give the name of the unique key instead of listing out the referenced columns as you would in normal SQL. ## Portions The SQL standard allows syntax for updating or deleting just a portion of a period. Rows are inserted as needed for the portions not being updated or deleted. Yes, that means a simple `DELETE` statement can actually `INSERT` rows\! ``` sql -- Standard SQL UPDATE example FOR PORTION OF validity FROM '...' TO '...' SET ... WHERE ...; DELETE FROM example FOR PORTION OF validity FROM '...' TO '...' WHERE ...; ``` When updating a portion of a period, it is illegal to modify either of the two columns contained in the period. This extension uses a view with an `INSTEAD OF` trigger to figure out what portion of the period you would like to modify, and issue the correct DML on the underlying table to do the job. In order to use this feature, the table must have a primary key. ``` sql UPDATE example__for_portion_of_validity SET ..., start_date = ..., end_date = ... WHERE ...; ``` We see no way to simulate deleting portions of periods, alas. ## Predicates The SQL standard provides for several predicates on periods. We have implemented them as inlined functions for the sake of completeness but they require specifying the start and end column names instead of the period name. ``` sql -- Standard SQL and this extension's equivalent -- "t" and "u" are tables with respective periods "p" and "q". -- Both periods have underlying columns "s" and "e". WHERE t.p CONTAINS 42 WHERE periods.contains(t.s, t.e, 42) WHERE t.p CONTAINS u.q WHERE periods.contains(t.s, t.e, u.s, u.e) WHERE t.p EQUALS u.q WHERE periods.equals(t.s, t.e, u.s, u.e) WHERE t.p OVERLAPS u.q WHERE periods.overlaps(t.s, t.e, u.s, u.e) WHERE t.p PRECEDES u.q WHERE periods.precedes(t.s, t.e, u.s, u.e) WHERE t.p SUCCEEDS u.q WHERE periods.succeeds(t.s, t.e, u.s, u.e) WHERE t.p IMMEDIATELY PRECEDES u.q WHERE periods.immediately_precedes(t.s, t.e, u.s, u.e) WHERE t.p IMMEDIATELY SUCCEEDS u.q WHERE periods.immediately_succeeds(t.s, t.e, u.s, u.e) ``` # System-versioned tables ## `SYSTEM_TIME` If the period is named `SYSTEM_TIME`, then special rules apply. The type of the columns must be `date`, `timestamp without time zone`, or `timestamp with time zone`; and they are not modifiable by the user. In the SQL standard, the start column is `GENERATED ALWAYS AS ROW START` and the end column is `GENERATED ALWAYS AS ROW END`. This extension uses triggers to set the start column to `transaction_timestamp()` and the end column is always `'infinity'`. ***Note:*** It is generally unwise to use anything but `timestamp with time zone` because changes in the `TimeZone` configuration paramater or even just Daylight Savings Time changes can distort the history. Even when only using UTC, we recommend the `timestamp with time zone` type. ``` sql -- Standard SQL CREATE TABLE example ( id bigint PRIMARY KEY, value text, PERIOD FOR system_time (row_start, row_end) ); ``` ``` sql CREATE TABLE example ( id bigint PRIMARY KEY, value text ); SELECT periods.add_system_time_period('example', 'row_start', 'row_end'); ``` Note that the columns in this special case need not exist. They will be created both by the SQL standard and by this extension. A special function is provided as a convenience, but `add_period` can also be called. ### Excluding columns It might be desirable to prevent some columns from updating the `SYSTEM_TIME` values. For example, perhaps your `users` table has a column `last_login` which gets updated all the time and you don’t want to generate a new historical row (see below) for just that. Ideally such a column would be in its own table, but if not then it can be excluded with an optional parameter: ``` sql SELECT periods.add_system_time_period( 'example', excluded_column_names => ARRAY['foo', 'bar']); ``` Excluded columns can be define after the fact, as well. ``` sql SELECT periods.set_system_time_period_excluded_columns( 'example', ARRAY['foo', 'bar']); ``` This functionality is not present in the SQL standard. ## `WITH SYSTEM VERSIONING` This special `SYSTEM_TIME` period can be used to keep track of changes in the table. ``` sql -- Standard SQL CREATE TABLE example ( id bigint PRIMARY KEY, value text, PERIOD FOR system_time (row_start, row_end) ) WITH SYSTEM VERSIONING; ``` ``` sql CREATE TABLE example ( id bigint PRIMARY KEY, value text ); SELECT periods.add_system_time_period('example', 'row_start', 'row_end'); SELECT periods.add_system_versioning('example'); ``` This instructs the system to keep a record of all changes in the table. We use a separate history table for this. You can create the history table yourself and instruct the extension to use it if you want to do things like add partitioning. ## Temporal querying The SQL standard extends the `FROM` and `JOIN` clauses to allow specifying a point in time, or even a range of time (shall we say a *period* of time?) for which we want the data. This only applies to base tables and so this extension implements them through inlined functions. ``` sql -- Standard SQL and this extension's equivalent SELECT * FROM t FOR system_time AS OF '...'; SELECT * FROM t__as_of('...'); SELECT * FROM t FOR system_time FROM '...' TO '...'; SELECT * FROM t__from_to('...', '...'); SELECT * FROM t FOR system_time BETWEEN '...' AND '...'; SELECT * FROM t__between('...', '...'); SELECT * FROM t FOR system_time BETWEEN SYMMETRIC '...' AND '...'; SELECT * FROM t__between_symmetric('...', '...'); ``` ## Access control The history table as well as the helper functions all follow the ownership and access privileges of the base table. It is not possible to change the privileges independently. The history data is also read-only. In order to trim old data, `SYSTEM VERSIONING` must be suspended. ``` sql BEGIN; SELECT periods.drop_system_versioning('t'); GRANT DELETE ON TABLE t TO CURRENT_USER; DELETE FROM t_history WHERE system_time_end < now() - interval '1 year'; SELECT periods.add_system_versioning('t'); COMMIT; ``` The privileges are automatically fixed when system versioning is resumed. ## Altering a table with system versioning The SQL Standard does not say much about what should happen to a table with system versioning when the table is altered. This extension prevents you from dropping objects while system versioning is active, and other changes will be prevented in the future. The suggested way to make changes is: ``` sql BEGIN; SELECT periods.drop_system_versioning('t'); ALTER TABLE t ...; ALTER TABLE t_history ...; SELECT periods.add_system_versioning('t'); COMMIT; ``` It is up to you to make sure you alter the history table in a way that is compatible with the main table. Re-activating system versioning will verify this. # Future ## Completion This extension is pretty much feature complete, but there are still many aspects that need to be handled. ## Performance Performance for the temporal queries should be already very similar to what we can expect from a native implementation in PostgreSQL. Unique keys should also be as performant as a native implementation, except that two indexes are needed instead of just one. One of the goals of this extension is to fork btree to a new access method that handles the `WITHOUT OVERLAPS` and then patch that back into PostgreSQL when periods are added. Foreign key performance should mostly be reasonable, except perhaps when validating existing data. Some benchmarks would be helpful here. Performance for the DDL stuff isn’t all that important, but those functions will likely also be rewritten in C, if only to start being the patch to present to the PostgreSQL community. # Contributions ***Contributions are very much welcome\!*** If you would like to help implement the missing features, optimize them, rewrite them in C, and especially modify btree; please don’t hesitate to do so. This project adheres to the [PostgreSQL Community Code of Conduct](https://www.postgresql.org/about/policies/coc/). Released under the [PostgreSQL License](https://www.postgresql.org/about/licence/). # Acknowledgements The project would like extend special thanks to: - [Christoph Berg](https://github.com/df7cb/) for Debian packaging, - [Devrim Gündüz](https://github.com/devrimgunduz) for RPM packaging, and - [Mikhail Titov](https://github.com/mlt) for Appveyor and Windows support. periods-1.2.2/README.periods000077700000000000000000000000001432551570100167622README.mdustar00rootroot00000000000000periods-1.2.2/debian/000077500000000000000000000000001432551570100144065ustar00rootroot00000000000000periods-1.2.2/debian/changelog000066400000000000000000000035151432551570100162640ustar00rootroot00000000000000postgresql-periods (1.2.2-1) unstable; urgency=medium * Upload for PostgreSQL 15. -- Christoph Berg Mon, 24 Oct 2022 16:03:45 +0200 postgresql-periods (1.2.1-1) unstable; urgency=medium * New version with PG 15 support. -- Christoph Berg Wed, 28 Sep 2022 13:33:57 +0200 postgresql-periods (1.2-4) unstable; urgency=medium * Upload for PostgreSQL 14. -- Christoph Berg Thu, 21 Oct 2021 09:58:19 +0200 postgresql-periods (1.2-3) unstable; urgency=medium * Depend on postgresql-contrib-PGVERSION for btree_gist on 9.x. -- Christoph Berg Fri, 06 Nov 2020 13:27:51 +0100 postgresql-periods (1.2-2) unstable; urgency=medium * Upload for PostgreSQL 13. * R³: no. * debian/tests: Use 'make' instead of postgresql-server-dev-all. -- Christoph Berg Mon, 19 Oct 2020 15:39:38 +0200 postgresql-periods (1.2-1) unstable; urgency=medium * New upstream version. * DH 13. * Use dh --with pgxs. -- Christoph Berg Wed, 30 Sep 2020 16:43:33 +0200 postgresql-periods (1.1-1) unstable; urgency=medium * New upstream version. -- Christoph Berg Wed, 05 Feb 2020 13:07:16 +0100 postgresql-periods (1.0-2) unstable; urgency=medium * Upload for PostgreSQL 12. -- Christoph Berg Thu, 31 Oct 2019 12:59:23 +0100 postgresql-periods (1.0-1) unstable; urgency=medium * New upstream version. * Rename source package to postgresql-periods. -- Christoph Berg Mon, 26 Aug 2019 12:08:01 +0200 periods (0.04-1) unstable; urgency=medium * New upstream version. -- Christoph Berg Wed, 31 Jul 2019 10:34:51 +0200 periods (0.03-1) unstable; urgency=medium * Initial release. -- Christoph Berg Wed, 17 Jul 2019 12:54:33 +0200 periods-1.2.2/debian/control000066400000000000000000000017701432551570100160160ustar00rootroot00000000000000Source: postgresql-periods Section: database Priority: optional Maintainer: Debian PostgreSQL Maintainers Uploaders: Christoph Berg , Build-Depends: debhelper-compat (= 13), postgresql-all (>= 217~) Standards-Version: 4.6.1 Rules-Requires-Root: no Vcs-Browser: https://github.com/xocolatl/periods Vcs-Git: https://github.com/xocolatl/periods.git Homepage: https://github.com/xocolatl/periods Package: postgresql-15-periods Architecture: any Depends: postgresql-15, postgresql-contrib-15, ${misc:Depends}, ${shlibs:Depends} Description: PERIODs and SYSTEM VERSIONING for PostgreSQL This extension attempts to recreate the behavior defined in SQL:2016 (originally SQL:2011) around periods and tables with SYSTEM VERSIONING. The idea is to figure out all the rules that PostgreSQL would like to adopt (there are some details missing in the standard) and to allow earlier versions of PostgreSQL to simulate the behavior once the feature is finally integrated. periods-1.2.2/debian/control.in000066400000000000000000000020151432551570100164140ustar00rootroot00000000000000Source: postgresql-periods Section: database Priority: optional Maintainer: Debian PostgreSQL Maintainers Uploaders: Christoph Berg , Build-Depends: debhelper-compat (= 13), postgresql-all (>= 217~) Standards-Version: 4.6.1 Rules-Requires-Root: no Vcs-Browser: https://github.com/xocolatl/periods Vcs-Git: https://github.com/xocolatl/periods.git Homepage: https://github.com/xocolatl/periods Package: postgresql-PGVERSION-periods Architecture: any Depends: postgresql-PGVERSION, postgresql-contrib-PGVERSION, ${misc:Depends}, ${shlibs:Depends} Description: PERIODs and SYSTEM VERSIONING for PostgreSQL This extension attempts to recreate the behavior defined in SQL:2016 (originally SQL:2011) around periods and tables with SYSTEM VERSIONING. The idea is to figure out all the rules that PostgreSQL would like to adopt (there are some details missing in the standard) and to allow earlier versions of PostgreSQL to simulate the behavior once the feature is finally integrated. periods-1.2.2/debian/copyright000066400000000000000000000024421432551570100163430ustar00rootroot00000000000000Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: periods Source: https://github.com/xocolatl/periods Files: * Copyright: Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group Portions Copyright (c) 1994, The Regents of the University of California License: PostgreSQL Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. . IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. . THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. periods-1.2.2/debian/gitlab-ci.yml000066400000000000000000000001371432551570100167650ustar00rootroot00000000000000include: https://salsa.debian.org/postgresql/postgresql-common/raw/master/gitlab/gitlab-ci.yml periods-1.2.2/debian/pgversions000066400000000000000000000000051432551570100165230ustar00rootroot000000000000009.5+ periods-1.2.2/debian/rules000077500000000000000000000001431432551570100154640ustar00rootroot00000000000000#!/usr/bin/make -f override_dh_installdocs: dh_installdocs --all README.* %: dh $@ --with pgxs periods-1.2.2/debian/source/000077500000000000000000000000001432551570100157065ustar00rootroot00000000000000periods-1.2.2/debian/source/format000066400000000000000000000000141432551570100171140ustar00rootroot000000000000003.0 (quilt) periods-1.2.2/debian/tests/000077500000000000000000000000001432551570100155505ustar00rootroot00000000000000periods-1.2.2/debian/tests/control000066400000000000000000000001001432551570100171420ustar00rootroot00000000000000Depends: @, make Tests: installcheck Restrictions: allow-stderr periods-1.2.2/debian/tests/installcheck000077500000000000000000000000431432551570100201370ustar00rootroot00000000000000#!/bin/sh pg_buildext installcheck periods-1.2.2/debian/watch000066400000000000000000000001031432551570100154310ustar00rootroot00000000000000version=4 https://github.com/xocolatl/periods/tags .*/v(.*).tar.gz periods-1.2.2/expected/000077500000000000000000000000001432551570100147655ustar00rootroot00000000000000periods-1.2.2/expected/acl.out000066400000000000000000001211201432551570100162520ustar00rootroot00000000000000SELECT setting::integer < 110000 AS pre_11, setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_11 | pre_96 --------+-------- f | f (1 row) /* Tests for access control on the history tables */ CREATE ROLE periods_acl_1; CREATE ROLE periods_acl_2; CREATE ROLE periods_acl_3; /* OWNER */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_owners AS SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, c.relowner::regrole AS owner FROM pg_class AS c WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (ARRAY['owner_test', 'owner_test_history', 'owner_test_with_history', 'owner_test__for_portion_of_p']) UNION ALL SELECT p.pronamespace, p.proname, 'function', p.proowner FROM pg_proc AS p WHERE p.pronamespace = 'public'::regnamespace AND p.proname = ANY (ARRAY['owner_test__as_of', 'owner_test__between', 'owner_test__between_symmetric', 'owner_test__from_to']); CREATE TABLE owner_test (col text PRIMARY KEY, s integer, e integer); ALTER TABLE owner_test OWNER TO periods_acl_1; SELECT periods.add_period('owner_test', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('owner_test', 'p'); add_for_portion_view ---------------------- t (1 row) SELECT periods.add_system_time_period('owner_test'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('owner_test'); NOTICE: history table "owner_test_history" created for "owner_test", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_1 public | owner_test__as_of | function | periods_acl_1 public | owner_test__between | function | periods_acl_1 public | owner_test__between_symmetric | function | periods_acl_1 public | owner_test__for_portion_of_p | view | periods_acl_1 public | owner_test__from_to | function | periods_acl_1 public | owner_test_history | table | periods_acl_1 public | owner_test_with_history | view | periods_acl_1 (8 rows) -- This should change everything ALTER TABLE owner_test OWNER TO periods_acl_2; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- These should change nothing ALTER TABLE owner_test_history OWNER TO periods_acl_3; ALTER VIEW owner_test_with_history OWNER TO periods_acl_3; ALTER FUNCTION owner_test__as_of(timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between_symmetric(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__from_to(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- This should put the owner back to the base table's owner SELECT periods.drop_system_versioning('owner_test'); drop_system_versioning ------------------------ t (1 row) ALTER TABLE owner_test_history OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test_history | table | periods_acl_3 (3 rows) SELECT periods.add_system_versioning('owner_test'); add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) SELECT periods.drop_system_versioning('owner_test', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) SELECT periods.drop_for_portion_view('owner_test', NULL); drop_for_portion_view ----------------------- t (1 row) DROP TABLE owner_test CASCADE; DROP VIEW show_owners; /* FOR PORTION OF ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('fpacl', 'fpacl__for_portion_of_p') ) AS _; CREATE TABLE fpacl (col text PRIMARY KEY, s integer, e integer); ALTER TABLE fpacl OWNER TO periods_acl_1; SELECT periods.add_period('fpacl', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('fpacl', 'p'); add_for_portion_view ---------------------- t (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE (14 rows) GRANT SELECT, UPDATE ON TABLE fpacl__for_portion_of_p TO periods_acl_2; -- fail ERROR: cannot grant SELECT directly to "fpacl__for_portion_of_p"; grant SELECT to "fpacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT SELECT, UPDATE ON TABLE fpacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl | table | periods_acl_2 | UPDATE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 17 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT 18 | public | fpacl__for_portion_of_p | view | periods_acl_2 | UPDATE (18 rows) REVOKE UPDATE ON TABLE fpacl__for_portion_of_p FROM periods_acl_2; -- fail ERROR: cannot revoke UPDATE directly from "fpacl__for_portion_of_p", revoke UPDATE from "fpacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE REVOKE UPDATE ON TABLE fpacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT (16 rows) SELECT periods.drop_for_portion_view('fpacl', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE fpacl CASCADE; DROP VIEW show_acls; /* History ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('histacl', 'histacl_history', 'histacl_with_history') UNION ALL SELECT p.pronamespace::regnamespace, p.proname, 'function', acl.grantee::regrole::text, acl.privilege_type FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.proname IN ('histacl__as_of', 'histacl__between', 'histacl__between_symmetric', 'histacl__from_to') ) AS _; CREATE TABLE histacl (col text); ALTER TABLE histacl OWNER TO periods_acl_1; SELECT periods.add_system_time_period('histacl'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('histacl'); NOTICE: history table "histacl_history" created for "histacl", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- Disconnect, add some privs to the history table, and reconnect SELECT periods.drop_system_versioning('histacl'); drop_system_versioning ------------------------ t (1 row) GRANT ALL ON TABLE histacl_history TO periods_acl_3; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-----------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_history | table | periods_acl_3 | DELETE 10 | public | histacl_history | table | periods_acl_3 | INSERT 11 | public | histacl_history | table | periods_acl_3 | REFERENCES 12 | public | histacl_history | table | periods_acl_3 | SELECT 13 | public | histacl_history | table | periods_acl_3 | TRIGGER 14 | public | histacl_history | table | periods_acl_3 | TRUNCATE 15 | public | histacl_history | table | periods_acl_3 | UPDATE (15 rows) SELECT periods.add_system_versioning('histacl'); add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- These next 6 blocks should fail GRANT ALL ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_history"; history objects are read-only CONTEXT: PL/pgSQL function periods.health_checks() line 138 at RAISE GRANT SELECT ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_history"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON TABLE histacl_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_history", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_with_history"; history objects are read-only CONTEXT: PL/pgSQL function periods.health_checks() line 138 at RAISE GRANT SELECT ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_with_history"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON TABLE histacl_with_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_with_history", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__as_of(timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__as_of(timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__from_to(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- This one should work and propagate GRANT ALL ON TABLE histacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | SELECT 12 | public | histacl | table | periods_acl_2 | TRIGGER 13 | public | histacl | table | periods_acl_2 | TRUNCATE 14 | public | histacl | table | periods_acl_2 | UPDATE 15 | public | histacl_history | table | periods_acl_1 | SELECT 16 | public | histacl_history | table | periods_acl_2 | SELECT 17 | public | histacl_with_history | view | periods_acl_1 | SELECT 18 | public | histacl_with_history | view | periods_acl_2 | SELECT 19 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 20 | public | histacl__as_of | function | periods_acl_2 | EXECUTE 21 | public | histacl__between | function | periods_acl_1 | EXECUTE 22 | public | histacl__between | function | periods_acl_2 | EXECUTE 23 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 24 | public | histacl__between_symmetric | function | periods_acl_2 | EXECUTE 25 | public | histacl__from_to | function | periods_acl_1 | EXECUTE 26 | public | histacl__from_to | function | periods_acl_2 | EXECUTE (26 rows) REVOKE SELECT ON TABLE histacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | TRIGGER 12 | public | histacl | table | periods_acl_2 | TRUNCATE 13 | public | histacl | table | periods_acl_2 | UPDATE 14 | public | histacl_history | table | periods_acl_1 | SELECT 15 | public | histacl_with_history | view | periods_acl_1 | SELECT 16 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 17 | public | histacl__between | function | periods_acl_1 | EXECUTE 18 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 19 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (19 rows) SELECT periods.drop_system_versioning('histacl', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE histacl CASCADE; DROP VIEW show_acls; /* Who can modify the history table? */ CREATE TABLE retention (value integer); ALTER TABLE retention OWNER TO periods_acl_1; REVOKE ALL ON TABLE retention FROM PUBLIC; GRANT ALL ON TABLE retention TO periods_acl_2; GRANT SELECT ON TABLE retention TO periods_acl_3; SELECT periods.add_system_time_period('retention'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('retention'); NOTICE: history table "retention_history" created for "retention", be sure to index it properly add_system_versioning ----------------------- (1 row) INSERT INTO retention (value) VALUES (1); UPDATE retention SET value = 2; SET ROLE TO periods_acl_3; DELETE FROM retention_history; -- fail ERROR: permission denied for table retention_history SET ROLE TO periods_acl_2; DELETE FROM retention_history; -- fail ERROR: permission denied for table retention_history SET ROLE TO periods_acl_1; DELETE FROM retention_history; -- fail ERROR: permission denied for table retention_history -- test what the docs say to do BEGIN; SELECT periods.drop_system_versioning('retention'); drop_system_versioning ------------------------ t (1 row) GRANT DELETE ON TABLE retention_history TO CURRENT_USER; DELETE FROM retention_history; SELECT periods.add_system_versioning('retention'); add_system_versioning ----------------------- (1 row) COMMIT; -- superuser can do anything RESET ROLE; DELETE FROM retention_history; SELECT periods.drop_system_versioning('retention', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE retention CASCADE; /* Clean up */ DROP ROLE periods_acl_1; DROP ROLE periods_acl_2; DROP ROLE periods_acl_3; periods-1.2.2/expected/acl_1.out000066400000000000000000001211311432551570100164740ustar00rootroot00000000000000SELECT setting::integer < 110000 AS pre_11, setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_11 | pre_96 --------+-------- t | f (1 row) /* Tests for access control on the history tables */ CREATE ROLE periods_acl_1; CREATE ROLE periods_acl_2; CREATE ROLE periods_acl_3; /* OWNER */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_owners AS SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, c.relowner::regrole AS owner FROM pg_class AS c WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (ARRAY['owner_test', 'owner_test_history', 'owner_test_with_history', 'owner_test__for_portion_of_p']) UNION ALL SELECT p.pronamespace, p.proname, 'function', p.proowner FROM pg_proc AS p WHERE p.pronamespace = 'public'::regnamespace AND p.proname = ANY (ARRAY['owner_test__as_of', 'owner_test__between', 'owner_test__between_symmetric', 'owner_test__from_to']); CREATE TABLE owner_test (col text PRIMARY KEY, s integer, e integer); ALTER TABLE owner_test OWNER TO periods_acl_1; SELECT periods.add_period('owner_test', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('owner_test', 'p'); add_for_portion_view ---------------------- t (1 row) SELECT periods.add_system_time_period('owner_test'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('owner_test'); NOTICE: history table "owner_test_history" created for "owner_test", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_1 public | owner_test__as_of | function | periods_acl_1 public | owner_test__between | function | periods_acl_1 public | owner_test__between_symmetric | function | periods_acl_1 public | owner_test__for_portion_of_p | view | periods_acl_1 public | owner_test__from_to | function | periods_acl_1 public | owner_test_history | table | periods_acl_1 public | owner_test_with_history | view | periods_acl_1 (8 rows) -- This should change everything ALTER TABLE owner_test OWNER TO periods_acl_2; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- These should change nothing ALTER TABLE owner_test_history OWNER TO periods_acl_3; ALTER VIEW owner_test_with_history OWNER TO periods_acl_3; ALTER FUNCTION owner_test__as_of(timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between_symmetric(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__from_to(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- This should put the owner back to the base table's owner SELECT periods.drop_system_versioning('owner_test'); drop_system_versioning ------------------------ t (1 row) ALTER TABLE owner_test_history OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test_history | table | periods_acl_3 (3 rows) SELECT periods.add_system_versioning('owner_test'); add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) SELECT periods.drop_system_versioning('owner_test', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) SELECT periods.drop_for_portion_view('owner_test', NULL); drop_for_portion_view ----------------------- t (1 row) DROP TABLE owner_test CASCADE; DROP VIEW show_owners; /* FOR PORTION OF ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('fpacl', 'fpacl__for_portion_of_p') ) AS _; CREATE TABLE fpacl (col text PRIMARY KEY, s integer, e integer); ALTER TABLE fpacl OWNER TO periods_acl_1; SELECT periods.add_period('fpacl', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('fpacl', 'p'); add_for_portion_view ---------------------- t (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE (14 rows) GRANT SELECT, UPDATE ON TABLE fpacl__for_portion_of_p TO periods_acl_2; -- fail ERROR: cannot grant SELECT directly to "fpacl__for_portion_of_p"; grant SELECT to "fpacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT SELECT, UPDATE ON TABLE fpacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl | table | periods_acl_2 | UPDATE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 17 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT 18 | public | fpacl__for_portion_of_p | view | periods_acl_2 | UPDATE (18 rows) REVOKE UPDATE ON TABLE fpacl__for_portion_of_p FROM periods_acl_2; -- fail ERROR: cannot revoke UPDATE directly from "fpacl__for_portion_of_p", revoke UPDATE from "fpacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE REVOKE UPDATE ON TABLE fpacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT (16 rows) SELECT periods.drop_for_portion_view('fpacl', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE fpacl CASCADE; DROP VIEW show_acls; /* History ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('histacl', 'histacl_history', 'histacl_with_history') UNION ALL SELECT p.pronamespace::regnamespace, p.proname, 'function', acl.grantee::regrole::text, acl.privilege_type FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.proname IN ('histacl__as_of', 'histacl__between', 'histacl__between_symmetric', 'histacl__from_to') ) AS _; CREATE TABLE histacl (col text); ALTER TABLE histacl OWNER TO periods_acl_1; SELECT periods.add_system_time_period('histacl'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('histacl'); NOTICE: history table "histacl_history" created for "histacl", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- Disconnect, add some privs to the history table, and reconnect SELECT periods.drop_system_versioning('histacl'); drop_system_versioning ------------------------ t (1 row) GRANT ALL ON TABLE histacl_history TO periods_acl_3; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-----------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_history | table | periods_acl_3 | DELETE 10 | public | histacl_history | table | periods_acl_3 | INSERT 11 | public | histacl_history | table | periods_acl_3 | REFERENCES 12 | public | histacl_history | table | periods_acl_3 | SELECT 13 | public | histacl_history | table | periods_acl_3 | TRIGGER 14 | public | histacl_history | table | periods_acl_3 | TRUNCATE 15 | public | histacl_history | table | periods_acl_3 | UPDATE (15 rows) SELECT periods.add_system_versioning('histacl'); add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- These next 6 blocks should fail GRANT ALL ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_history"; history objects are read-only CONTEXT: PL/pgSQL function periods.health_checks() line 138 at RAISE GRANT SELECT ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_history"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON TABLE histacl_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_history", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_with_history"; history objects are read-only CONTEXT: PL/pgSQL function periods.health_checks() line 138 at RAISE GRANT SELECT ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_with_history"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON TABLE histacl_with_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_with_history", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__as_of(timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__as_of(timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE GRANT EXECUTE ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 143 at RAISE REVOKE ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__from_to(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead CONTEXT: PL/pgSQL function periods.health_checks() line 255 at RAISE TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- This one should work and propagate GRANT ALL ON TABLE histacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | SELECT 12 | public | histacl | table | periods_acl_2 | TRIGGER 13 | public | histacl | table | periods_acl_2 | TRUNCATE 14 | public | histacl | table | periods_acl_2 | UPDATE 15 | public | histacl_history | table | periods_acl_1 | SELECT 16 | public | histacl_history | table | periods_acl_2 | SELECT 17 | public | histacl_with_history | view | periods_acl_1 | SELECT 18 | public | histacl_with_history | view | periods_acl_2 | SELECT 19 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 20 | public | histacl__as_of | function | periods_acl_2 | EXECUTE 21 | public | histacl__between | function | periods_acl_1 | EXECUTE 22 | public | histacl__between | function | periods_acl_2 | EXECUTE 23 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 24 | public | histacl__between_symmetric | function | periods_acl_2 | EXECUTE 25 | public | histacl__from_to | function | periods_acl_1 | EXECUTE 26 | public | histacl__from_to | function | periods_acl_2 | EXECUTE (26 rows) REVOKE SELECT ON TABLE histacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | TRIGGER 12 | public | histacl | table | periods_acl_2 | TRUNCATE 13 | public | histacl | table | periods_acl_2 | UPDATE 14 | public | histacl_history | table | periods_acl_1 | SELECT 15 | public | histacl_with_history | view | periods_acl_1 | SELECT 16 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 17 | public | histacl__between | function | periods_acl_1 | EXECUTE 18 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 19 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (19 rows) SELECT periods.drop_system_versioning('histacl', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE histacl CASCADE; DROP VIEW show_acls; /* Who can modify the history table? */ CREATE TABLE retention (value integer); ALTER TABLE retention OWNER TO periods_acl_1; REVOKE ALL ON TABLE retention FROM PUBLIC; GRANT ALL ON TABLE retention TO periods_acl_2; GRANT SELECT ON TABLE retention TO periods_acl_3; SELECT periods.add_system_time_period('retention'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('retention'); NOTICE: history table "retention_history" created for "retention", be sure to index it properly add_system_versioning ----------------------- (1 row) INSERT INTO retention (value) VALUES (1); UPDATE retention SET value = 2; SET ROLE TO periods_acl_3; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history SET ROLE TO periods_acl_2; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history SET ROLE TO periods_acl_1; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history -- test what the docs say to do BEGIN; SELECT periods.drop_system_versioning('retention'); drop_system_versioning ------------------------ t (1 row) GRANT DELETE ON TABLE retention_history TO CURRENT_USER; DELETE FROM retention_history; SELECT periods.add_system_versioning('retention'); add_system_versioning ----------------------- (1 row) COMMIT; -- superuser can do anything RESET ROLE; DELETE FROM retention_history; SELECT periods.drop_system_versioning('retention', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE retention CASCADE; /* Clean up */ DROP ROLE periods_acl_1; DROP ROLE periods_acl_2; DROP ROLE periods_acl_3; periods-1.2.2/expected/acl_2.out000066400000000000000000001163411432551570100165040ustar00rootroot00000000000000SELECT setting::integer < 110000 AS pre_11, setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_11 | pre_96 --------+-------- t | t (1 row) /* Tests for access control on the history tables */ CREATE ROLE periods_acl_1; CREATE ROLE periods_acl_2; CREATE ROLE periods_acl_3; /* OWNER */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_owners AS SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, c.relowner::regrole AS owner FROM pg_class AS c WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (ARRAY['owner_test', 'owner_test_history', 'owner_test_with_history', 'owner_test__for_portion_of_p']) UNION ALL SELECT p.pronamespace, p.proname, 'function', p.proowner FROM pg_proc AS p WHERE p.pronamespace = 'public'::regnamespace AND p.proname = ANY (ARRAY['owner_test__as_of', 'owner_test__between', 'owner_test__between_symmetric', 'owner_test__from_to']); CREATE TABLE owner_test (col text PRIMARY KEY, s integer, e integer); ALTER TABLE owner_test OWNER TO periods_acl_1; SELECT periods.add_period('owner_test', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('owner_test', 'p'); add_for_portion_view ---------------------- t (1 row) SELECT periods.add_system_time_period('owner_test'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('owner_test'); NOTICE: history table "owner_test_history" created for "owner_test", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_1 public | owner_test__as_of | function | periods_acl_1 public | owner_test__between | function | periods_acl_1 public | owner_test__between_symmetric | function | periods_acl_1 public | owner_test__for_portion_of_p | view | periods_acl_1 public | owner_test__from_to | function | periods_acl_1 public | owner_test_history | table | periods_acl_1 public | owner_test_with_history | view | periods_acl_1 (8 rows) -- This should change everything ALTER TABLE owner_test OWNER TO periods_acl_2; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- These should change nothing ALTER TABLE owner_test_history OWNER TO periods_acl_3; ALTER VIEW owner_test_with_history OWNER TO periods_acl_3; ALTER FUNCTION owner_test__as_of(timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between_symmetric(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__from_to(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) -- This should put the owner back to the base table's owner SELECT periods.drop_system_versioning('owner_test'); drop_system_versioning ------------------------ t (1 row) ALTER TABLE owner_test_history OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test_history | table | periods_acl_3 (3 rows) SELECT periods.add_system_versioning('owner_test'); add_system_versioning ----------------------- (1 row) TABLE show_owners ORDER BY object_name; schema_name | object_name | object_type | owner -------------+-------------------------------+-------------+--------------- public | owner_test | table | periods_acl_2 public | owner_test__as_of | function | periods_acl_2 public | owner_test__between | function | periods_acl_2 public | owner_test__between_symmetric | function | periods_acl_2 public | owner_test__for_portion_of_p | view | periods_acl_2 public | owner_test__from_to | function | periods_acl_2 public | owner_test_history | table | periods_acl_2 public | owner_test_with_history | view | periods_acl_2 (8 rows) SELECT periods.drop_system_versioning('owner_test', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) SELECT periods.drop_for_portion_view('owner_test', NULL); drop_for_portion_view ----------------------- t (1 row) DROP TABLE owner_test CASCADE; DROP VIEW show_owners; /* FOR PORTION OF ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('fpacl', 'fpacl__for_portion_of_p') ) AS _; CREATE TABLE fpacl (col text PRIMARY KEY, s integer, e integer); ALTER TABLE fpacl OWNER TO periods_acl_1; SELECT periods.add_period('fpacl', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('fpacl', 'p'); add_for_portion_view ---------------------- t (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE (14 rows) GRANT SELECT, UPDATE ON TABLE fpacl__for_portion_of_p TO periods_acl_2; -- fail ERROR: cannot grant SELECT directly to "fpacl__for_portion_of_p"; grant SELECT to "fpacl" instead GRANT SELECT, UPDATE ON TABLE fpacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl | table | periods_acl_2 | UPDATE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 17 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT 18 | public | fpacl__for_portion_of_p | view | periods_acl_2 | UPDATE (18 rows) REVOKE UPDATE ON TABLE fpacl__for_portion_of_p FROM periods_acl_2; -- fail ERROR: cannot revoke UPDATE directly from "fpacl__for_portion_of_p", revoke UPDATE from "fpacl" instead REVOKE UPDATE ON TABLE fpacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-------------------------+-------------+---------------+---------------- 1 | public | fpacl | table | periods_acl_1 | DELETE 2 | public | fpacl | table | periods_acl_1 | INSERT 3 | public | fpacl | table | periods_acl_1 | REFERENCES 4 | public | fpacl | table | periods_acl_1 | SELECT 5 | public | fpacl | table | periods_acl_1 | TRIGGER 6 | public | fpacl | table | periods_acl_1 | TRUNCATE 7 | public | fpacl | table | periods_acl_1 | UPDATE 8 | public | fpacl | table | periods_acl_2 | SELECT 9 | public | fpacl__for_portion_of_p | view | periods_acl_1 | DELETE 10 | public | fpacl__for_portion_of_p | view | periods_acl_1 | INSERT 11 | public | fpacl__for_portion_of_p | view | periods_acl_1 | REFERENCES 12 | public | fpacl__for_portion_of_p | view | periods_acl_1 | SELECT 13 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRIGGER 14 | public | fpacl__for_portion_of_p | view | periods_acl_1 | TRUNCATE 15 | public | fpacl__for_portion_of_p | view | periods_acl_1 | UPDATE 16 | public | fpacl__for_portion_of_p | view | periods_acl_2 | SELECT (16 rows) SELECT periods.drop_for_portion_view('fpacl', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE fpacl CASCADE; DROP VIEW show_acls; /* History ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('histacl', 'histacl_history', 'histacl_with_history') UNION ALL SELECT p.pronamespace::regnamespace, p.proname, 'function', acl.grantee::regrole::text, acl.privilege_type FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.proname IN ('histacl__as_of', 'histacl__between', 'histacl__between_symmetric', 'histacl__from_to') ) AS _; CREATE TABLE histacl (col text); ALTER TABLE histacl OWNER TO periods_acl_1; SELECT periods.add_system_time_period('histacl'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('histacl'); NOTICE: history table "histacl_history" created for "histacl", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- Disconnect, add some privs to the history table, and reconnect SELECT periods.drop_system_versioning('histacl'); drop_system_versioning ------------------------ t (1 row) GRANT ALL ON TABLE histacl_history TO periods_acl_3; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+-----------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_history | table | periods_acl_3 | DELETE 10 | public | histacl_history | table | periods_acl_3 | INSERT 11 | public | histacl_history | table | periods_acl_3 | REFERENCES 12 | public | histacl_history | table | periods_acl_3 | SELECT 13 | public | histacl_history | table | periods_acl_3 | TRIGGER 14 | public | histacl_history | table | periods_acl_3 | TRUNCATE 15 | public | histacl_history | table | periods_acl_3 | UPDATE (15 rows) SELECT periods.add_system_versioning('histacl'); add_system_versioning ----------------------- (1 row) TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- These next 6 blocks should fail GRANT ALL ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_history"; history objects are read-only GRANT SELECT ON TABLE histacl_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_history"; grant SELECT to "histacl" instead REVOKE ALL ON TABLE histacl_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_history", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant DELETE to "histacl_with_history"; history objects are read-only GRANT SELECT ON TABLE histacl_with_history TO periods_acl_3; -- fail ERROR: cannot grant SELECT directly to "histacl_with_history"; grant SELECT to "histacl" instead REVOKE ALL ON TABLE histacl_with_history FROM periods_acl_1; -- fail ERROR: cannot revoke SELECT directly from "histacl_with_history", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead GRANT EXECUTE ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__as_of(timestamp with time zone)"; grant SELECT to "histacl" instead REVOKE ALL ON FUNCTION histacl__as_of(timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__as_of(timestamp with time zone)", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead GRANT EXECUTE ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead REVOKE ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead GRANT EXECUTE ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead REVOKE ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__between_symmetric(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) GRANT ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead GRANT EXECUTE ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail ERROR: cannot grant EXECUTE directly to "histacl__from_to(timestamp with time zone,timestamp with time zone)"; grant SELECT to "histacl" instead REVOKE ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail ERROR: cannot revoke EXECUTE directly from "histacl__from_to(timestamp with time zone,timestamp with time zone)", revoke SELECT from "histacl" instead TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl_history | table | periods_acl_1 | SELECT 9 | public | histacl_with_history | view | periods_acl_1 | SELECT 10 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 11 | public | histacl__between | function | periods_acl_1 | EXECUTE 12 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 13 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (13 rows) -- This one should work and propagate GRANT ALL ON TABLE histacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | SELECT 12 | public | histacl | table | periods_acl_2 | TRIGGER 13 | public | histacl | table | periods_acl_2 | TRUNCATE 14 | public | histacl | table | periods_acl_2 | UPDATE 15 | public | histacl_history | table | periods_acl_1 | SELECT 16 | public | histacl_history | table | periods_acl_2 | SELECT 17 | public | histacl_with_history | view | periods_acl_1 | SELECT 18 | public | histacl_with_history | view | periods_acl_2 | SELECT 19 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 20 | public | histacl__as_of | function | periods_acl_2 | EXECUTE 21 | public | histacl__between | function | periods_acl_1 | EXECUTE 22 | public | histacl__between | function | periods_acl_2 | EXECUTE 23 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 24 | public | histacl__between_symmetric | function | periods_acl_2 | EXECUTE 25 | public | histacl__from_to | function | periods_acl_1 | EXECUTE 26 | public | histacl__from_to | function | periods_acl_2 | EXECUTE (26 rows) REVOKE SELECT ON TABLE histacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; sort_order | schema_name | object_name | object_type | grantee | privilege_type ------------+-------------+----------------------------+-------------+---------------+---------------- 1 | public | histacl | table | periods_acl_1 | DELETE 2 | public | histacl | table | periods_acl_1 | INSERT 3 | public | histacl | table | periods_acl_1 | REFERENCES 4 | public | histacl | table | periods_acl_1 | SELECT 5 | public | histacl | table | periods_acl_1 | TRIGGER 6 | public | histacl | table | periods_acl_1 | TRUNCATE 7 | public | histacl | table | periods_acl_1 | UPDATE 8 | public | histacl | table | periods_acl_2 | DELETE 9 | public | histacl | table | periods_acl_2 | INSERT 10 | public | histacl | table | periods_acl_2 | REFERENCES 11 | public | histacl | table | periods_acl_2 | TRIGGER 12 | public | histacl | table | periods_acl_2 | TRUNCATE 13 | public | histacl | table | periods_acl_2 | UPDATE 14 | public | histacl_history | table | periods_acl_1 | SELECT 15 | public | histacl_with_history | view | periods_acl_1 | SELECT 16 | public | histacl__as_of | function | periods_acl_1 | EXECUTE 17 | public | histacl__between | function | periods_acl_1 | EXECUTE 18 | public | histacl__between_symmetric | function | periods_acl_1 | EXECUTE 19 | public | histacl__from_to | function | periods_acl_1 | EXECUTE (19 rows) SELECT periods.drop_system_versioning('histacl', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE histacl CASCADE; DROP VIEW show_acls; /* Who can modify the history table? */ CREATE TABLE retention (value integer); ALTER TABLE retention OWNER TO periods_acl_1; REVOKE ALL ON TABLE retention FROM PUBLIC; GRANT ALL ON TABLE retention TO periods_acl_2; GRANT SELECT ON TABLE retention TO periods_acl_3; SELECT periods.add_system_time_period('retention'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('retention'); NOTICE: history table "retention_history" created for "retention", be sure to index it properly add_system_versioning ----------------------- (1 row) INSERT INTO retention (value) VALUES (1); UPDATE retention SET value = 2; SET ROLE TO periods_acl_3; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history SET ROLE TO periods_acl_2; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history SET ROLE TO periods_acl_1; DELETE FROM retention_history; -- fail ERROR: permission denied for relation retention_history -- test what the docs say to do BEGIN; SELECT periods.drop_system_versioning('retention'); drop_system_versioning ------------------------ t (1 row) GRANT DELETE ON TABLE retention_history TO CURRENT_USER; DELETE FROM retention_history; SELECT periods.add_system_versioning('retention'); add_system_versioning ----------------------- (1 row) COMMIT; -- superuser can do anything RESET ROLE; DELETE FROM retention_history; SELECT periods.drop_system_versioning('retention', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE retention CASCADE; /* Clean up */ DROP ROLE periods_acl_1; DROP ROLE periods_acl_2; DROP ROLE periods_acl_3; periods-1.2.2/expected/beeswax.out000066400000000000000000000004331432551570100171540ustar00rootroot00000000000000/* * Test creating a table, dropping a column, and then dropping the whole thing; * without any periods. This is to make sure the health checks don't try to do * anything. */ CREATE TABLE beeswax (col1 text, col2 date); ALTER TABLE beeswax DROP COLUMN col1; DROP TABLE beeswax; periods-1.2.2/expected/drop_protection.out000066400000000000000000000175461432551570100207450ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Make sure nobody drops the objects we keep track of in our catalogs. */ CREATE TYPE integerrange AS RANGE (SUBTYPE = integer); CREATE TABLE dp ( id bigint, s integer, e integer, x boolean ); /* periods */ SELECT periods.add_period('dp', 'p', 's', 'e', 'integerrange'); add_period ------------ t (1 row) DROP TYPE integerrange; ERROR: cannot drop rangetype "public.integerrange" because it is used in period "p" on table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 56 at RAISE /* system_time_periods */ SELECT periods.add_system_time_period('dp', excluded_column_names => ARRAY['x']); add_system_time_period ------------------------ t (1 row) ALTER TABLE dp DROP COLUMN x; -- fails ERROR: cannot drop or rename column "x" on table "dp" because it is excluded from SYSTEM VERSIONING CONTEXT: PL/pgSQL function periods.drop_protection() line 124 at RAISE ALTER TABLE dp DROP CONSTRAINT dp_system_time_end_infinity_check; -- fails ERROR: cannot drop constraint "dp_system_time_end_infinity_check" on table "dp" because it is used in SYSTEM_TIME period CONTEXT: PL/pgSQL function periods.drop_protection() line 72 at RAISE DROP TRIGGER dp_system_time_generated_always ON dp; -- fails ERROR: cannot drop trigger "dp_system_time_generated_always" on table "dp" because it is used in SYSTEM_TIME period CONTEXT: PL/pgSQL function periods.drop_protection() line 84 at RAISE DROP TRIGGER dp_system_time_write_history ON dp; -- fails ERROR: cannot drop trigger "dp_system_time_write_history" on table "dp" because it is used in SYSTEM_TIME period CONTEXT: PL/pgSQL function periods.drop_protection() line 96 at RAISE DROP TRIGGER dp_truncate ON dp; -- fails ERROR: cannot drop trigger "dp_truncate" on table "dp" because it is used in SYSTEM_TIME period CONTEXT: PL/pgSQL function periods.drop_protection() line 108 at RAISE /* for_portion_views */ ALTER TABLE dp ADD CONSTRAINT dp_pkey PRIMARY KEY (id); SELECT periods.add_for_portion_view('dp', 'p'); add_for_portion_view ---------------------- t (1 row) DROP VIEW dp__for_portion_of_p; ERROR: cannot drop view "public.dp__for_portion_of_p", call "periods.drop_for_portion_view()" instead CONTEXT: PL/pgSQL function periods.drop_protection() line 141 at RAISE DROP TRIGGER for_portion_of_p ON dp__for_portion_of_p; ERROR: cannot drop trigger "for_portion_of_p" on view "dp__for_portion_of_p" because it is used in FOR PORTION OF view for period "p" on table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 153 at RAISE ALTER TABLE dp DROP CONSTRAINT dp_pkey; ERROR: cannot drop primary key on table "dp" because it has a FOR PORTION OF view for period "p" CONTEXT: PL/pgSQL function periods.drop_protection() line 165 at RAISE SELECT periods.drop_for_portion_view('dp', 'p'); drop_for_portion_view ----------------------- t (1 row) ALTER TABLE dp DROP CONSTRAINT dp_pkey; /* unique_keys */ ALTER TABLE dp ADD CONSTRAINT u UNIQUE (id, s, e), ADD CONSTRAINT x EXCLUDE USING gist (id WITH =, integerrange(s, e, '[)') WITH &&); SELECT periods.add_unique_key('dp', ARRAY['id'], 'p', 'k', 'u', 'x'); add_unique_key ---------------- k (1 row) ALTER TABLE dp DROP CONSTRAINT u; -- fails ERROR: cannot drop constraint "u" on table "dp" because it is used in period unique key "k" CONTEXT: PL/pgSQL function periods.drop_protection() line 186 at RAISE ALTER TABLE dp DROP CONSTRAINT x; -- fails ERROR: cannot drop constraint "x" on table "dp" because it is used in period unique key "k" CONTEXT: PL/pgSQL function periods.drop_protection() line 197 at RAISE ALTER TABLE dp DROP CONSTRAINT dp_p_check; -- fails /* foreign_keys */ CREATE TABLE dp_ref (LIKE dp); SELECT periods.add_period('dp_ref', 'p', 's', 'e', 'integerrange'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('dp_ref', ARRAY['id'], 'p', 'k', key_name => 'f'); add_foreign_key ----------------- f (1 row) DROP TRIGGER f_fk_insert ON dp_ref; -- fails ERROR: cannot drop trigger "f_fk_insert" on table "dp_ref" because it is used in period foreign key "f" CONTEXT: PL/pgSQL function periods.drop_protection() line 213 at RAISE DROP TRIGGER f_fk_update ON dp_ref; -- fails ERROR: cannot drop trigger "f_fk_update" on table "dp_ref" because it is used in period foreign key "f" CONTEXT: PL/pgSQL function periods.drop_protection() line 224 at RAISE DROP TRIGGER f_uk_update ON dp; -- fails ERROR: cannot drop trigger "f_uk_update" on table "dp" because it is used in period foreign key "f" CONTEXT: PL/pgSQL function periods.drop_protection() line 236 at RAISE DROP TRIGGER f_uk_delete ON dp; -- fails ERROR: cannot drop trigger "f_uk_delete" on table "dp" because it is used in period foreign key "f" CONTEXT: PL/pgSQL function periods.drop_protection() line 248 at RAISE SELECT periods.drop_foreign_key('dp_ref', 'f'); drop_foreign_key ------------------ t (1 row) DROP TABLE dp_ref; /* system_versioning */ SELECT periods.add_system_versioning('dp'); NOTICE: history table "dp_history" created for "dp", be sure to index it properly add_system_versioning ----------------------- (1 row) -- Note: The history table is protected by the history view and the history -- view is protected by the temporal functions. DROP TABLE dp_history CASCADE; NOTICE: drop cascades to 5 other objects DETAIL: drop cascades to view dp_with_history drop cascades to function dp__as_of(timestamp with time zone) drop cascades to function dp__between(timestamp with time zone,timestamp with time zone) drop cascades to function dp__between_symmetric(timestamp with time zone,timestamp with time zone) drop cascades to function dp__from_to(timestamp with time zone,timestamp with time zone) ERROR: cannot drop table "public.dp_history" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 264 at RAISE DROP VIEW dp_with_history CASCADE; NOTICE: drop cascades to 4 other objects DETAIL: drop cascades to function dp__as_of(timestamp with time zone) drop cascades to function dp__between(timestamp with time zone,timestamp with time zone) drop cascades to function dp__between_symmetric(timestamp with time zone,timestamp with time zone) drop cascades to function dp__from_to(timestamp with time zone,timestamp with time zone) ERROR: cannot drop view "public.dp_with_history" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 276 at RAISE DROP FUNCTION dp__as_of(timestamp with time zone); ERROR: cannot drop function "public.dp__as_of(timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 288 at RAISE DROP FUNCTION dp__between(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__between(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 288 at RAISE DROP FUNCTION dp__between_symmetric(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__between_symmetric(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 288 at RAISE DROP FUNCTION dp__from_to(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__from_to(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" CONTEXT: PL/pgSQL function periods.drop_protection() line 288 at RAISE SELECT periods.drop_system_versioning('dp', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE dp; DROP TYPE integerrange; periods-1.2.2/expected/drop_protection_1.out000066400000000000000000000146021432551570100211530ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Make sure nobody drops the objects we keep track of in our catalogs. */ CREATE TYPE integerrange AS RANGE (SUBTYPE = integer); CREATE TABLE dp ( id bigint, s integer, e integer, x boolean ); /* periods */ SELECT periods.add_period('dp', 'p', 's', 'e', 'integerrange'); add_period ------------ t (1 row) DROP TYPE integerrange; ERROR: cannot drop rangetype "public.integerrange" because it is used in period "p" on table "dp" /* system_time_periods */ SELECT periods.add_system_time_period('dp', excluded_column_names => ARRAY['x']); add_system_time_period ------------------------ t (1 row) ALTER TABLE dp DROP COLUMN x; -- fails ERROR: cannot drop or rename column "x" on table "dp" because it is excluded from SYSTEM VERSIONING ALTER TABLE dp DROP CONSTRAINT dp_system_time_end_infinity_check; -- fails ERROR: cannot drop constraint "dp_system_time_end_infinity_check" on table "dp" because it is used in SYSTEM_TIME period DROP TRIGGER dp_system_time_generated_always ON dp; -- fails ERROR: cannot drop trigger "dp_system_time_generated_always" on table "dp" because it is used in SYSTEM_TIME period DROP TRIGGER dp_system_time_write_history ON dp; -- fails ERROR: cannot drop trigger "dp_system_time_write_history" on table "dp" because it is used in SYSTEM_TIME period DROP TRIGGER dp_truncate ON dp; -- fails ERROR: cannot drop trigger "dp_truncate" on table "dp" because it is used in SYSTEM_TIME period /* for_portion_views */ ALTER TABLE dp ADD CONSTRAINT dp_pkey PRIMARY KEY (id); SELECT periods.add_for_portion_view('dp', 'p'); add_for_portion_view ---------------------- t (1 row) DROP VIEW dp__for_portion_of_p; ERROR: cannot drop view "public.dp__for_portion_of_p", call "periods.drop_for_portion_view()" instead DROP TRIGGER for_portion_of_p ON dp__for_portion_of_p; ERROR: cannot drop trigger "for_portion_of_p" on view "dp__for_portion_of_p" because it is used in FOR PORTION OF view for period "p" on table "dp" ALTER TABLE dp DROP CONSTRAINT dp_pkey; ERROR: cannot drop primary key on table "dp" because it has a FOR PORTION OF view for period "p" SELECT periods.drop_for_portion_view('dp', 'p'); drop_for_portion_view ----------------------- t (1 row) ALTER TABLE dp DROP CONSTRAINT dp_pkey; /* unique_keys */ ALTER TABLE dp ADD CONSTRAINT u UNIQUE (id, s, e), ADD CONSTRAINT x EXCLUDE USING gist (id WITH =, integerrange(s, e, '[)') WITH &&); SELECT periods.add_unique_key('dp', ARRAY['id'], 'p', 'k', 'u', 'x'); add_unique_key ---------------- k (1 row) ALTER TABLE dp DROP CONSTRAINT u; -- fails ERROR: cannot drop constraint "u" on table "dp" because it is used in period unique key "k" ALTER TABLE dp DROP CONSTRAINT x; -- fails ERROR: cannot drop constraint "x" on table "dp" because it is used in period unique key "k" ALTER TABLE dp DROP CONSTRAINT dp_p_check; -- fails /* foreign_keys */ CREATE TABLE dp_ref (LIKE dp); SELECT periods.add_period('dp_ref', 'p', 's', 'e', 'integerrange'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('dp_ref', ARRAY['id'], 'p', 'k', key_name => 'f'); add_foreign_key ----------------- f (1 row) DROP TRIGGER f_fk_insert ON dp_ref; -- fails ERROR: cannot drop trigger "f_fk_insert" on table "dp_ref" because it is used in period foreign key "f" DROP TRIGGER f_fk_update ON dp_ref; -- fails ERROR: cannot drop trigger "f_fk_update" on table "dp_ref" because it is used in period foreign key "f" DROP TRIGGER f_uk_update ON dp; -- fails ERROR: cannot drop trigger "f_uk_update" on table "dp" because it is used in period foreign key "f" DROP TRIGGER f_uk_delete ON dp; -- fails ERROR: cannot drop trigger "f_uk_delete" on table "dp" because it is used in period foreign key "f" SELECT periods.drop_foreign_key('dp_ref', 'f'); drop_foreign_key ------------------ t (1 row) DROP TABLE dp_ref; /* system_versioning */ SELECT periods.add_system_versioning('dp'); NOTICE: history table "dp_history" created for "dp", be sure to index it properly add_system_versioning ----------------------- (1 row) -- Note: The history table is protected by the history view and the history -- view is protected by the temporal functions. DROP TABLE dp_history CASCADE; NOTICE: drop cascades to 5 other objects DETAIL: drop cascades to view dp_with_history drop cascades to function dp__as_of(timestamp with time zone) drop cascades to function dp__between(timestamp with time zone,timestamp with time zone) drop cascades to function dp__between_symmetric(timestamp with time zone,timestamp with time zone) drop cascades to function dp__from_to(timestamp with time zone,timestamp with time zone) ERROR: cannot drop table "public.dp_history" because it is used in SYSTEM VERSIONING for table "dp" DROP VIEW dp_with_history CASCADE; NOTICE: drop cascades to 4 other objects DETAIL: drop cascades to function dp__as_of(timestamp with time zone) drop cascades to function dp__between(timestamp with time zone,timestamp with time zone) drop cascades to function dp__between_symmetric(timestamp with time zone,timestamp with time zone) drop cascades to function dp__from_to(timestamp with time zone,timestamp with time zone) ERROR: cannot drop view "public.dp_with_history" because it is used in SYSTEM VERSIONING for table "dp" DROP FUNCTION dp__as_of(timestamp with time zone); ERROR: cannot drop function "public.dp__as_of(timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" DROP FUNCTION dp__between(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__between(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" DROP FUNCTION dp__between_symmetric(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__between_symmetric(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" DROP FUNCTION dp__from_to(timestamp with time zone,timestamp with time zone); ERROR: cannot drop function "public.dp__from_to(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "dp" SELECT periods.drop_system_versioning('dp', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE dp; DROP TYPE integerrange; periods-1.2.2/expected/excluded_columns.out000066400000000000000000000137461432551570100210660ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; CREATE TABLE excl ( value text NOT NULL, null_value integer, flap text NOT NULL ); SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['xmin']); -- fails ERROR: cannot exclude system column "xmin" CONTEXT: PL/pgSQL function periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) line 316 at RAISE SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['none']); -- fails ERROR: column "none" does not exist CONTEXT: PL/pgSQL function periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) line 306 at RAISE SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['flap']); -- passes add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('excl'); NOTICE: history table "excl_history" created for "excl", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- excl | system_time | system_time_start | system_time_end | tstzrange | excl_system_time_check (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------------+-----------------------------------+--------------------------------+------------------+----------------------- excl | system_time | excl_system_time_end_infinity_check | excl_system_time_generated_always | excl_system_time_write_history | excl_truncate | {flap} (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-------------------+----------------------------------------------+-------------------------------------------------------------------------+-----------------------------------------------------------------------------------+------------------------------------------------------------------------- excl | system_time | excl_history | excl_with_history | public.excl__as_of(timestamp with time zone) | public.excl__between(timestamp with time zone,timestamp with time zone) | public.excl__between_symmetric(timestamp with time zone,timestamp with time zone) | public.excl__from_to(timestamp with time zone,timestamp with time zone) (1 row) BEGIN; SELECT CURRENT_TIMESTAMP AS now \gset INSERT INTO excl (value, flap) VALUES ('hello world', 'off'); COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed -------------+------------+------+--------- hello world | | off | f (1 row) UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed -------------+------------+------+--------- hello world | | on | f (1 row) BEGIN; SELECT CURRENT_TIMESTAMP AS now2 \gset UPDATE excl SET value = 'howdy folks!'; COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed --------------+------------+------+--------- howdy folks! | | on | t (1 row) UPDATE excl SET null_value = 0; SELECT value, null_value, flap, system_time_start <> :'now2' AS changed FROM excl; value | null_value | flap | changed --------------+------------+------+--------- howdy folks! | 0 | on | t (1 row) /* Test directly setting the excluded columns */ SELECT periods.drop_system_versioning('excl'); drop_system_versioning ------------------------ t (1 row) ALTER TABLE excl ADD COLUMN flop text; ALTER TABLE excl_history ADD COLUMN flop text; SELECT periods.add_system_versioning('excl'); add_system_versioning ----------------------- (1 row) SELECT periods.set_system_time_period_excluded_columns('excl', ARRAY['flap', 'flop']); set_system_time_period_excluded_columns ----------------------------------------- (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------------+-----------------------------------+--------------------------------+------------------+----------------------- excl | system_time | excl_system_time_end_infinity_check | excl_system_time_generated_always | excl_system_time_write_history | excl_truncate | {flap,flop} (1 row) UPDATE excl SET flop = 'flop'; SELECT value, null_value, flap, flop FROM excl; value | null_value | flap | flop --------------+------------+------+------ howdy folks! | 0 | on | flop (1 row) SELECT value, null_value, flap, flop FROM excl_history ORDER BY system_time_start; value | null_value | flap | flop --------------+------------+------+------ hello world | | on | howdy folks! | | on | (2 rows) SELECT periods.drop_system_versioning('excl', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE excl; periods-1.2.2/expected/excluded_columns_1.out000066400000000000000000000133441432551570100213000ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; CREATE TABLE excl ( value text NOT NULL, null_value integer, flap text NOT NULL ); SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['xmin']); -- fails ERROR: cannot exclude system column "xmin" SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['none']); -- fails ERROR: column "none" does not exist SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['flap']); -- passes add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('excl'); NOTICE: history table "excl_history" created for "excl", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- excl | system_time | system_time_start | system_time_end | tstzrange | excl_system_time_check (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------------+-----------------------------------+--------------------------------+------------------+----------------------- excl | system_time | excl_system_time_end_infinity_check | excl_system_time_generated_always | excl_system_time_write_history | excl_truncate | {flap} (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-------------------+----------------------------------------------+-------------------------------------------------------------------------+-----------------------------------------------------------------------------------+------------------------------------------------------------------------- excl | system_time | excl_history | excl_with_history | public.excl__as_of(timestamp with time zone) | public.excl__between(timestamp with time zone,timestamp with time zone) | public.excl__between_symmetric(timestamp with time zone,timestamp with time zone) | public.excl__from_to(timestamp with time zone,timestamp with time zone) (1 row) BEGIN; SELECT CURRENT_TIMESTAMP AS now \gset INSERT INTO excl (value, flap) VALUES ('hello world', 'off'); COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed -------------+------------+------+--------- hello world | | off | f (1 row) UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed -------------+------------+------+--------- hello world | | on | f (1 row) BEGIN; SELECT CURRENT_TIMESTAMP AS now2 \gset UPDATE excl SET value = 'howdy folks!'; COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; value | null_value | flap | changed --------------+------------+------+--------- howdy folks! | | on | t (1 row) UPDATE excl SET null_value = 0; SELECT value, null_value, flap, system_time_start <> :'now2' AS changed FROM excl; value | null_value | flap | changed --------------+------------+------+--------- howdy folks! | 0 | on | t (1 row) /* Test directly setting the excluded columns */ SELECT periods.drop_system_versioning('excl'); drop_system_versioning ------------------------ t (1 row) ALTER TABLE excl ADD COLUMN flop text; ALTER TABLE excl_history ADD COLUMN flop text; SELECT periods.add_system_versioning('excl'); add_system_versioning ----------------------- (1 row) SELECT periods.set_system_time_period_excluded_columns('excl', ARRAY['flap', 'flop']); set_system_time_period_excluded_columns ----------------------------------------- (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------------+-----------------------------------+--------------------------------+------------------+----------------------- excl | system_time | excl_system_time_end_infinity_check | excl_system_time_generated_always | excl_system_time_write_history | excl_truncate | {flap,flop} (1 row) UPDATE excl SET flop = 'flop'; SELECT value, null_value, flap, flop FROM excl; value | null_value | flap | flop --------------+------------+------+------ howdy folks! | 0 | on | flop (1 row) SELECT value, null_value, flap, flop FROM excl_history ORDER BY system_time_start; value | null_value | flap | flop --------------+------------+------+------ hello world | | on | howdy folks! | | on | (2 rows) SELECT periods.drop_system_versioning('excl', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE excl; periods-1.2.2/expected/for_portion_of.out000066400000000000000000000157001432551570100205450ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10, setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; pre_10 | pre_12 --------+-------- f | f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * Create a sequence to test non-serial primary keys. This actually tests * things like uuid primary keys, but makes for reproducible test cases. */ CREATE SEQUENCE pricing_seq; CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, id4 bigint GENERATED ALWAYS AS (id1 + id2) STORED, product text, min_quantity integer, max_quantity integer, price numeric); CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, product text, min_quantity integer, max_quantity integer, price numeric); ERROR: relation "pricing" already exists CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), product text, min_quantity integer, max_quantity integer, price numeric); ERROR: relation "pricing" already exists SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+------------------------------------+--------------------------- pricing | quantities | pricing__for_portion_of_quantities | for_portion_of_quantities (1 row) /* Test UPDATE FOR PORTION */ INSERT INTO pricing (product, min_quantity, max_quantity, price) VALUES ('Trinket', 1, 20, 200); TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | 2 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully preceding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 1, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | 2 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully succeeding UPDATE pricing__for_portion_of_quantities SET min_quantity = 30, max_quantity = 50, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | 2 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully surrounding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 100, price = 100; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | 2 | Trinket | 1 | 20 | 100 (1 row) -- UPDATE portion UPDATE pricing__for_portion_of_quantities SET min_quantity = 10, max_quantity = 20, price = 80; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 2 | 2 | 2 | 4 | Trinket | 1 | 10 | 100 1 | 1 | 1 | 2 | Trinket | 10 | 20 | 80 (2 rows) -- UPDATE portion of multiple rows UPDATE pricing__for_portion_of_quantities SET min_quantity = 5, max_quantity = 15, price = 90; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | id4 | product | min_quantity | max_quantity | price -----+-----+-----+-----+---------+--------------+--------------+------- 3 | 3 | 3 | 6 | Trinket | 1 | 5 | 100 2 | 2 | 2 | 4 | Trinket | 5 | 10 | 90 1 | 1 | 1 | 2 | Trinket | 10 | 15 | 90 4 | 4 | 4 | 8 | Trinket | 15 | 20 | 80 (4 rows) -- If we drop the period (without CASCADE) then the FOR PORTION views should be -- dropped, too. SELECT periods.drop_period('pricing', 'quantities'); drop_period ------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) -- Add it back to test the drop_for_portion_view function SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) -- We can't drop the the table without first dropping the FOR PORTION views -- because Postgres will complain about dependant objects (our views) before we -- get a chance to clean them up. DROP TABLE pricing; ERROR: cannot drop table pricing because other objects depend on it DETAIL: view pricing__for_portion_of_quantities depends on table pricing HINT: Use DROP ... CASCADE to drop the dependent objects too. SELECT periods.drop_for_portion_view('pricing', NULL); drop_for_portion_view ----------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) DROP TABLE pricing; DROP SEQUENCE pricing_seq; /* Types without btree must be excluded, too */ -- v10+ CREATE TABLE bt ( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); -- pre v10 CREATE TABLE bt ( id serial PRIMARY KEY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); ERROR: relation "bt" already exists SELECT periods.add_period('bt', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('bt', 'p'); add_for_portion_view ---------------------- t (1 row) INSERT INTO bt (pt, t, s, e) VALUES ('(0, 0)', 'sample', 10, 40); TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 1 | (0,0) | sample | 10 | 40 (1 row) UPDATE bt__for_portion_of_p SET t = 'simple', s = 20, e = 30; TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 2 | (0,0) | sample | 10 | 20 1 | (0,0) | simple | 20 | 30 3 | (0,0) | sample | 30 | 40 (3 rows) SELECT periods.drop_for_portion_view('bt', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE bt; periods-1.2.2/expected/for_portion_of_1.out000066400000000000000000000157001432551570100207650ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10, setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; pre_10 | pre_12 --------+-------- f | t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * Create a sequence to test non-serial primary keys. This actually tests * things like uuid primary keys, but makes for reproducible test cases. */ CREATE SEQUENCE pricing_seq; CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, id4 bigint GENERATED ALWAYS AS (id1 + id2) STORED, product text, min_quantity integer, max_quantity integer, price numeric); ERROR: syntax error at or near "(" LINE 4: ... id4 bigint GENERATED ALWAYS AS (id1 + id2... ^ CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, product text, min_quantity integer, max_quantity integer, price numeric); CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), product text, min_quantity integer, max_quantity integer, price numeric); ERROR: relation "pricing" already exists SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+------------------------------------+--------------------------- pricing | quantities | pricing__for_portion_of_quantities | for_portion_of_quantities (1 row) /* Test UPDATE FOR PORTION */ INSERT INTO pricing (product, min_quantity, max_quantity, price) VALUES ('Trinket', 1, 20, 200); TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully preceding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 1, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully succeeding UPDATE pricing__for_portion_of_quantities SET min_quantity = 30, max_quantity = 50, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully surrounding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 100, price = 100; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 1 | 1 | 1 | Trinket | 1 | 20 | 100 (1 row) -- UPDATE portion UPDATE pricing__for_portion_of_quantities SET min_quantity = 10, max_quantity = 20, price = 80; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 2 | 2 | 2 | Trinket | 1 | 10 | 100 1 | 1 | 1 | Trinket | 10 | 20 | 80 (2 rows) -- UPDATE portion of multiple rows UPDATE pricing__for_portion_of_quantities SET min_quantity = 5, max_quantity = 15, price = 90; TABLE pricing ORDER BY min_quantity; id1 | id2 | id3 | product | min_quantity | max_quantity | price -----+-----+-----+---------+--------------+--------------+------- 3 | 3 | 3 | Trinket | 1 | 5 | 100 2 | 2 | 2 | Trinket | 5 | 10 | 90 1 | 1 | 1 | Trinket | 10 | 15 | 90 4 | 4 | 4 | Trinket | 15 | 20 | 80 (4 rows) -- If we drop the period (without CASCADE) then the FOR PORTION views should be -- dropped, too. SELECT periods.drop_period('pricing', 'quantities'); drop_period ------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) -- Add it back to test the drop_for_portion_view function SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) -- We can't drop the the table without first dropping the FOR PORTION views -- because Postgres will complain about dependant objects (our views) before we -- get a chance to clean them up. DROP TABLE pricing; ERROR: cannot drop table pricing because other objects depend on it DETAIL: view pricing__for_portion_of_quantities depends on table pricing HINT: Use DROP ... CASCADE to drop the dependent objects too. SELECT periods.drop_for_portion_view('pricing', NULL); drop_for_portion_view ----------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) DROP TABLE pricing; DROP SEQUENCE pricing_seq; /* Types without btree must be excluded, too */ -- v10+ CREATE TABLE bt ( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); -- pre v10 CREATE TABLE bt ( id serial PRIMARY KEY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); ERROR: relation "bt" already exists SELECT periods.add_period('bt', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('bt', 'p'); add_for_portion_view ---------------------- t (1 row) INSERT INTO bt (pt, t, s, e) VALUES ('(0, 0)', 'sample', 10, 40); TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 1 | (0,0) | sample | 10 | 40 (1 row) UPDATE bt__for_portion_of_p SET t = 'simple', s = 20, e = 30; TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 2 | (0,0) | sample | 10 | 20 1 | (0,0) | simple | 20 | 30 3 | (0,0) | sample | 30 | 40 (3 rows) SELECT periods.drop_for_portion_view('bt', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE bt; periods-1.2.2/expected/for_portion_of_2.out000066400000000000000000000160171432551570100207700ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10, setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; pre_10 | pre_12 --------+-------- t | t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * Create a sequence to test non-serial primary keys. This actually tests * things like uuid primary keys, but makes for reproducible test cases. */ CREATE SEQUENCE pricing_seq; CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, id4 bigint GENERATED ALWAYS AS (id1 + id2) STORED, product text, min_quantity integer, max_quantity integer, price numeric); ERROR: syntax error at or near "GENERATED" LINE 3: id3 bigint GENERATED ALWAYS AS IDENTIT... ^ CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, product text, min_quantity integer, max_quantity integer, price numeric); ERROR: syntax error at or near "GENERATED" LINE 3: id3 bigint GENERATED ALWAYS AS IDENTIT... ^ CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), product text, min_quantity integer, max_quantity integer, price numeric); SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+------------------------------------+--------------------------- pricing | quantities | pricing__for_portion_of_quantities | for_portion_of_quantities (1 row) /* Test UPDATE FOR PORTION */ INSERT INTO pricing (product, min_quantity, max_quantity, price) VALUES ('Trinket', 1, 20, 200); TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully preceding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 1, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully succeeding UPDATE pricing__for_portion_of_quantities SET min_quantity = 30, max_quantity = 50, price = 0; TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 1 | 1 | Trinket | 1 | 20 | 200 (1 row) -- UPDATE fully surrounding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 100, price = 100; TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 1 | 1 | Trinket | 1 | 20 | 100 (1 row) -- UPDATE portion UPDATE pricing__for_portion_of_quantities SET min_quantity = 10, max_quantity = 20, price = 80; TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 2 | 2 | Trinket | 1 | 10 | 100 1 | 1 | Trinket | 10 | 20 | 80 (2 rows) -- UPDATE portion of multiple rows UPDATE pricing__for_portion_of_quantities SET min_quantity = 5, max_quantity = 15, price = 90; TABLE pricing ORDER BY min_quantity; id1 | id2 | product | min_quantity | max_quantity | price -----+-----+---------+--------------+--------------+------- 3 | 3 | Trinket | 1 | 5 | 100 2 | 2 | Trinket | 5 | 10 | 90 1 | 1 | Trinket | 10 | 15 | 90 4 | 4 | Trinket | 15 | 20 | 80 (4 rows) -- If we drop the period (without CASCADE) then the FOR PORTION views should be -- dropped, too. SELECT periods.drop_period('pricing', 'quantities'); drop_period ------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) -- Add it back to test the drop_for_portion_view function SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('pricing', 'quantities'); add_for_portion_view ---------------------- t (1 row) -- We can't drop the the table without first dropping the FOR PORTION views -- because Postgres will complain about dependant objects (our views) before we -- get a chance to clean them up. DROP TABLE pricing; ERROR: cannot drop table pricing because other objects depend on it DETAIL: view pricing__for_portion_of_quantities depends on table pricing HINT: Use DROP ... CASCADE to drop the dependent objects too. SELECT periods.drop_for_portion_view('pricing', NULL); drop_for_portion_view ----------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name ------------+-------------+-----------+-------------- (0 rows) DROP TABLE pricing; DROP SEQUENCE pricing_seq; /* Types without btree must be excluded, too */ -- v10+ CREATE TABLE bt ( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); ERROR: syntax error at or near "GENERATED" LINE 4: id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ^ -- pre v10 CREATE TABLE bt ( id serial PRIMARY KEY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); SELECT periods.add_period('bt', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_for_portion_view('bt', 'p'); add_for_portion_view ---------------------- t (1 row) INSERT INTO bt (pt, t, s, e) VALUES ('(0, 0)', 'sample', 10, 40); TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 1 | (0,0) | sample | 10 | 40 (1 row) UPDATE bt__for_portion_of_p SET t = 'simple', s = 20, e = 30; TABLE bt ORDER BY s, e; id | pt | t | s | e ----+-------+--------+----+---- 2 | (0,0) | sample | 10 | 20 1 | (0,0) | simple | 20 | 30 3 | (0,0) | sample | 30 | 40 (3 rows) SELECT periods.drop_for_portion_view('bt', 'p'); drop_for_portion_view ----------------------- t (1 row) DROP TABLE bt; periods-1.2.2/expected/health_checks.out000066400000000000000000000031731432551570100203070ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Ensure tables with periods are persistent */ CREATE UNLOGGED TABLE log (id bigint, s date, e date); SELECT periods.add_period('log', 'p', 's', 'e'); -- fails ERROR: table "log" must be persistent CONTEXT: PL/pgSQL function periods.add_period(regclass,name,name,name,regtype,name) line 72 at RAISE SELECT periods.add_system_time_period('log'); -- fails ERROR: table "log" must be persistent CONTEXT: PL/pgSQL function periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) line 74 at RAISE ALTER TABLE log SET LOGGED; SELECT periods.add_period('log', 'p', 's', 'e'); -- passes add_period ------------ t (1 row) SELECT periods.add_system_time_period('log'); -- passes add_system_time_period ------------------------ t (1 row) ALTER TABLE log SET UNLOGGED; -- fails ERROR: table "log" must remain persistent because it has periods CONTEXT: PL/pgSQL function periods.health_checks() line 15 at RAISE SELECT periods.add_system_versioning('log'); NOTICE: history table "log_history" created for "log", be sure to index it properly add_system_versioning ----------------------- (1 row) ALTER TABLE log_history SET UNLOGGED; -- fails ERROR: history table "log" must remain persistent because it has periods CONTEXT: PL/pgSQL function periods.health_checks() line 26 at RAISE SELECT periods.drop_system_versioning('log', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE log; periods-1.2.2/expected/health_checks_1.out000066400000000000000000000024131432551570100205230ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Ensure tables with periods are persistent */ CREATE UNLOGGED TABLE log (id bigint, s date, e date); SELECT periods.add_period('log', 'p', 's', 'e'); -- fails ERROR: table "log" must be persistent SELECT periods.add_system_time_period('log'); -- fails ERROR: table "log" must be persistent ALTER TABLE log SET LOGGED; SELECT periods.add_period('log', 'p', 's', 'e'); -- passes add_period ------------ t (1 row) SELECT periods.add_system_time_period('log'); -- passes add_system_time_period ------------------------ t (1 row) ALTER TABLE log SET UNLOGGED; -- fails ERROR: table "log" must remain persistent because it has periods SELECT periods.add_system_versioning('log'); NOTICE: history table "log_history" created for "log", be sure to index it properly add_system_versioning ----------------------- (1 row) ALTER TABLE log_history SET UNLOGGED; -- fails ERROR: history table "log" must remain persistent because it has periods SELECT periods.drop_system_versioning('log', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE log; periods-1.2.2/expected/install.out000066400000000000000000000007721432551570100171720ustar00rootroot00000000000000/* Once support for 9.5 has passed, use CASCADE */ CREATE EXTENSION IF NOT EXISTS btree_gist; /* Once support for 9.6 has passed, just create the extension */ CREATE EXTENSION periods VERSION '1.2'; SELECT extversion FROM pg_extension WHERE extname = 'periods'; extversion ------------ 1.2 (1 row) DROP ROLE periods_unprivileged_user; ERROR: role "periods_unprivileged_user" does not exist CREATE ROLE periods_unprivileged_user; /* Make tests work on PG 15 */ GRANT CREATE ON SCHEMA public TO PUBLIC; periods-1.2.2/expected/issues.out000066400000000000000000000100221432551570100170240ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10 FROM pg_settings WHERE name = 'server_version_num'; pre_10 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* https://github.com/xocolatl/periods/issues/5 */ CREATE TABLE issue5 ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value VARCHAR NOT NULL ); CREATE TABLE IF NOT EXISTS issue5 ( id serial PRIMARY KEY, value VARCHAR NOT NULL ); NOTICE: relation "issue5" already exists, skipping ALTER TABLE issue5 DROP COLUMN value; ALTER TABLE issue5 ADD COLUMN value2 varchar NOT NULL; INSERT INTO issue5 (value2) VALUES ('hello'), ('world'); SELECT periods.add_system_time_period ('issue5'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning ('issue5'); NOTICE: history table "issue5_history" created for "issue5", be sure to index it properly add_system_versioning ----------------------- (1 row) BEGIN; SELECT now() AS ts \gset UPDATE issue5 SET value2 = 'goodbye' WHERE id = 2; SELECT id, value2, system_time_start, system_time_end FROM issue5_with_history EXCEPT ALL VALUES (1::integer, 'hello'::varchar, '-infinity'::timestamptz, 'infinity'::timestamptz), (2, 'goodbye', :'ts', 'infinity'), (2, 'world', '-infinity', :'ts'); id | value2 | system_time_start | system_time_end ----+--------+-------------------+----------------- (0 rows) COMMIT; SELECT periods.drop_system_versioning('issue5', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE issue5; /* Check PostgreSQL Bug #16242 */ CREATE TABLE pg16242 (value text); INSERT INTO pg16242 (value) VALUES ('helloworld'); SELECT periods.add_system_time_period('pg16242'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('pg16242'); NOTICE: history table "pg16242_history" created for "pg16242", be sure to index it properly add_system_versioning ----------------------- (1 row) UPDATE pg16242 SET value = 'hello world'; SELECT system_time_start FROM pg16242_history; system_time_start ------------------- -infinity (1 row) SELECT periods.drop_system_versioning('pg16242', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE pg16242; /* https://github.com/xocolatl/periods/issues/11 */ CREATE TABLE "issue11" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "revision" INTEGER NOT NULL ); -- for versions pre-10: CREATE TABLE "issue11" ( "id" bigserial PRIMARY KEY, "revision" INTEGER NOT NULL ); ERROR: relation "issue11" already exists SELECT periods.add_system_time_period('issue11', 'row_start_time', 'row_end_time'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('issue11'); NOTICE: history table "issue11_history" created for "issue11", be sure to index it properly add_system_versioning ----------------------- (1 row) INSERT INTO "issue11" ("revision") VALUES (1); INSERT INTO "issue11" ("revision") VALUES (10); UPDATE "issue11" SET "revision" = 2 WHERE ("id" = 1); UPDATE "issue11" SET "revision" = 3 WHERE ("id" = 1); CREATE INDEX "yolo" ON "issue11_history" ("id", "revision"); UPDATE "issue11" SET "revision" = 11 WHERE ("id" = 2); -- returns 2 rows SELECT id, revision FROM "issue11_history" WHERE "id" = 1 ORDER BY row_start_time; id | revision ----+---------- 1 | 1 1 | 2 (2 rows) -- returns 0 rows if index is used / 1 row if seq scan is used SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; id | revision ----+---------- 2 | 10 (1 row) SET enable_seqscan = off; SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; id | revision ----+---------- 2 | 10 (1 row) RESET enable_seqscan; SELECT periods.drop_system_versioning('issue11', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE "issue11"; periods-1.2.2/expected/issues_1.out000066400000000000000000000103101432551570100172440ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10 FROM pg_settings WHERE name = 'server_version_num'; pre_10 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* https://github.com/xocolatl/periods/issues/5 */ CREATE TABLE issue5 ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value VARCHAR NOT NULL ); ERROR: syntax error at or near "GENERATED" LINE 3: id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ^ CREATE TABLE IF NOT EXISTS issue5 ( id serial PRIMARY KEY, value VARCHAR NOT NULL ); ALTER TABLE issue5 DROP COLUMN value; ALTER TABLE issue5 ADD COLUMN value2 varchar NOT NULL; INSERT INTO issue5 (value2) VALUES ('hello'), ('world'); SELECT periods.add_system_time_period ('issue5'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning ('issue5'); NOTICE: history table "issue5_history" created for "issue5", be sure to index it properly add_system_versioning ----------------------- (1 row) BEGIN; SELECT now() AS ts \gset UPDATE issue5 SET value2 = 'goodbye' WHERE id = 2; SELECT id, value2, system_time_start, system_time_end FROM issue5_with_history EXCEPT ALL VALUES (1::integer, 'hello'::varchar, '-infinity'::timestamptz, 'infinity'::timestamptz), (2, 'goodbye', :'ts', 'infinity'), (2, 'world', '-infinity', :'ts'); id | value2 | system_time_start | system_time_end ----+--------+-------------------+----------------- (0 rows) COMMIT; SELECT periods.drop_system_versioning('issue5', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE issue5; /* Check PostgreSQL Bug #16242 */ CREATE TABLE pg16242 (value text); INSERT INTO pg16242 (value) VALUES ('helloworld'); SELECT periods.add_system_time_period('pg16242'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('pg16242'); NOTICE: history table "pg16242_history" created for "pg16242", be sure to index it properly add_system_versioning ----------------------- (1 row) UPDATE pg16242 SET value = 'hello world'; SELECT system_time_start FROM pg16242_history; system_time_start ------------------- -infinity (1 row) SELECT periods.drop_system_versioning('pg16242', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE pg16242; /* https://github.com/xocolatl/periods/issues/11 */ CREATE TABLE "issue11" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "revision" INTEGER NOT NULL ); ERROR: syntax error at or near "GENERATED" LINE 3: "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY... ^ -- for versions pre-10: CREATE TABLE "issue11" ( "id" bigserial PRIMARY KEY, "revision" INTEGER NOT NULL ); SELECT periods.add_system_time_period('issue11', 'row_start_time', 'row_end_time'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_system_versioning('issue11'); NOTICE: history table "issue11_history" created for "issue11", be sure to index it properly add_system_versioning ----------------------- (1 row) INSERT INTO "issue11" ("revision") VALUES (1); INSERT INTO "issue11" ("revision") VALUES (10); UPDATE "issue11" SET "revision" = 2 WHERE ("id" = 1); UPDATE "issue11" SET "revision" = 3 WHERE ("id" = 1); CREATE INDEX "yolo" ON "issue11_history" ("id", "revision"); UPDATE "issue11" SET "revision" = 11 WHERE ("id" = 2); -- returns 2 rows SELECT id, revision FROM "issue11_history" WHERE "id" = 1 ORDER BY row_start_time; id | revision ----+---------- 1 | 1 1 | 2 (2 rows) -- returns 0 rows if index is used / 1 row if seq scan is used SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; id | revision ----+---------- 2 | 10 (1 row) SET enable_seqscan = off; SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; id | revision ----+---------- 2 | 10 (1 row) RESET enable_seqscan; SELECT periods.drop_system_versioning('issue11', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE "issue11"; periods-1.2.2/expected/periods.out000066400000000000000000000073601432551570100171710ustar00rootroot00000000000000SELECT setting::integer < 130000 AS pre_13 FROM pg_settings WHERE name = 'server_version_num'; pre_13 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic period definitions with dates */ CREATE TABLE basic (val text, s date, e date); TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | basic_bp_check (1 row) SELECT periods.drop_period('basic', 'bp'); drop_period ------------- t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e', bounds_check_constraint => 'c'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | c (1 row) SELECT periods.drop_period('basic', 'bp', purge => true); drop_period ------------- t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | basic_bp_check (1 row) /* Test constraints */ INSERT INTO basic (val, s, e) VALUES ('x', null, null); --fail ERROR: null value in column "s" of relation "basic" violates not-null constraint DETAIL: Failing row contains (x, null, null). INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', null); --fail ERROR: null value in column "e" of relation "basic" violates not-null constraint DETAIL: Failing row contains (x, 01-01-3000, null). INSERT INTO basic (val, s, e) VALUES ('x', null, '1000-01-01'); --fail ERROR: null value in column "s" of relation "basic" violates not-null constraint DETAIL: Failing row contains (x, null, 01-01-1000). INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', '1000-01-01'); --fail ERROR: new row for relation "basic" violates check constraint "basic_bp_check" DETAIL: Failing row contains (x, 01-01-3000, 01-01-1000). INSERT INTO basic (val, s, e) VALUES ('x', '1000-01-01', '3000-01-01'); --success TABLE basic; val | s | e -----+------------+------------ x | 01-01-1000 | 01-01-3000 (1 row) /* Test dropping the whole thing */ DROP TABLE basic; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) periods-1.2.2/expected/periods_1.out000066400000000000000000000072641432551570100174140ustar00rootroot00000000000000SELECT setting::integer < 130000 AS pre_13 FROM pg_settings WHERE name = 'server_version_num'; pre_13 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic period definitions with dates */ CREATE TABLE basic (val text, s date, e date); TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | basic_bp_check (1 row) SELECT periods.drop_period('basic', 'bp'); drop_period ------------- t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e', bounds_check_constraint => 'c'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | c (1 row) SELECT periods.drop_period('basic', 'bp', purge => true); drop_period ------------- t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) SELECT periods.add_period('basic', 'bp', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- basic | bp | s | e | daterange | basic_bp_check (1 row) /* Test constraints */ INSERT INTO basic (val, s, e) VALUES ('x', null, null); --fail ERROR: null value in column "s" violates not-null constraint DETAIL: Failing row contains (x, null, null). INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', null); --fail ERROR: null value in column "e" violates not-null constraint DETAIL: Failing row contains (x, 01-01-3000, null). INSERT INTO basic (val, s, e) VALUES ('x', null, '1000-01-01'); --fail ERROR: null value in column "s" violates not-null constraint DETAIL: Failing row contains (x, null, 01-01-1000). INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', '1000-01-01'); --fail ERROR: new row for relation "basic" violates check constraint "basic_bp_check" DETAIL: Failing row contains (x, 01-01-3000, 01-01-1000). INSERT INTO basic (val, s, e) VALUES ('x', '1000-01-01', '3000-01-01'); --success TABLE basic; val | s | e -----+------------+------------ x | 01-01-1000 | 01-01-3000 (1 row) /* Test dropping the whole thing */ DROP TABLE basic; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) periods-1.2.2/expected/predicates.out000066400000000000000000000155511432551570100176500ustar00rootroot00000000000000/* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; CREATE TABLE preds (s integer, e integer); SELECT periods.add_period('preds', 'p', 's', 'e'); add_period ------------ t (1 row) INSERT INTO preds (s, e) VALUES (100, 200); ANALYZE preds; /* Ensure the functions are inlined. */ EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.contains(s, e, 100); QUERY PLAN -------------------------------------- Seq Scan on preds Filter: ((s <= 100) AND (e > 100)) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.contains(s, e, 100, 200); QUERY PLAN --------------------------------------- Seq Scan on preds Filter: ((s <= 100) AND (e >= 200)) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.equals(s, e, 100, 200); QUERY PLAN ------------------------------------- Seq Scan on preds Filter: ((s = 100) AND (e = 200)) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.overlaps(s, e, 100, 200); QUERY PLAN ------------------------------------- Seq Scan on preds Filter: ((s < 200) AND (e > 100)) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.precedes(s, e, 100, 200); QUERY PLAN ---------------------- Seq Scan on preds Filter: (e <= 100) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.succeeds(s, e, 100, 200); QUERY PLAN ---------------------- Seq Scan on preds Filter: (s >= 200) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 100, 200); QUERY PLAN --------------------- Seq Scan on preds Filter: (e = 100) (2 rows) EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 100, 200); QUERY PLAN --------------------- Seq Scan on preds Filter: (s = 200) (2 rows) /* Now make sure they work! */ SELECT * FROM preds WHERE periods.contains(s, e, 0); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 150); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.contains(s, e, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 0, 50); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 50, 100); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 100, 150); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.contains(s, e, 150, 200); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.contains(s, e, 200, 250); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 250, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.contains(s, e, 125, 175); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.contains(s, e, 0, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.equals(s, e, 0, 100); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.equals(s, e, 100, 200); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.equals(s, e, 200, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.overlaps(s, e, 0, 50); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.overlaps(s, e, 50, 100); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.overlaps(s, e, 100, 150); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.overlaps(s, e, 150, 200); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.overlaps(s, e, 200, 250); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.overlaps(s, e, 250, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.overlaps(s, e, 125, 175); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.overlaps(s, e, 0, 300); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.precedes(s, e, 0, 50); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.precedes(s, e, 50, 100); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.precedes(s, e, 100, 150); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.precedes(s, e, 150, 200); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.precedes(s, e, 200, 250); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.precedes(s, e, 250, 300); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.precedes(s, e, 125, 175); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.precedes(s, e, 0, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 0, 50); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.succeeds(s, e, 50, 100); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.succeeds(s, e, 100, 150); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 150, 200); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 200, 250); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 250, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 125, 175); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.succeeds(s, e, 0, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 0, 50); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 50, 100); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 100, 150); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 150, 200); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 200, 250); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 250, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 125, 175); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 0, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 0, 50); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 50, 100); s | e -----+----- 100 | 200 (1 row) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 100, 150); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 150, 200); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 200, 250); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 250, 300); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 125, 175); s | e ---+--- (0 rows) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 0, 300); s | e ---+--- (0 rows) DROP TABLE preds; periods-1.2.2/expected/rename_following.out000066400000000000000000000314561432551570100210560ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * If anything we store as "name" is renamed, we need to update our catalogs or * throw an error. */ /* periods */ CREATE TABLE rename_test(col1 text, col2 bigint, col3 time, s integer, e integer); SELECT periods.add_period('rename_test', 'p', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | s | e | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME s TO start; ALTER TABLE rename_test RENAME e TO "end"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | start | end | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME start TO "s < e"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | s < e | end | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME "end" TO "embedded "" symbols"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+--------------------+------------+------------------------- rename_test | p | s < e | embedded " symbols | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME CONSTRAINT rename_test_p_check TO start_before_end; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+--------------------+------------+------------------------- rename_test | p | s < e | embedded " symbols | int4range | start_before_end (1 row) /* system_time_periods */ SELECT periods.add_system_time_period('rename_test', excluded_column_names => ARRAY['col3']); add_system_time_period ------------------------ t (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names -------------+-------------+--------------------------------------------+------------------------------------------+---------------------------------------+----------------------+----------------------- rename_test | system_time | rename_test_system_time_end_infinity_check | rename_test_system_time_generated_always | rename_test_system_time_write_history | rename_test_truncate | {col3} (1 row) ALTER TABLE rename_test RENAME col3 TO "COLUMN3"; ERROR: cannot drop or rename column "col3" on table "rename_test" because it is excluded from SYSTEM VERSIONING CONTEXT: PL/pgSQL function periods.rename_following() line 121 at RAISE ALTER TABLE rename_test RENAME CONSTRAINT rename_test_system_time_end_infinity_check TO inf_check; ALTER TRIGGER rename_test_system_time_generated_always ON rename_test RENAME TO generated_always; ALTER TRIGGER rename_test_system_time_write_history ON rename_test RENAME TO write_history; ALTER TRIGGER rename_test_truncate ON rename_test RENAME TO trunc; TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names -------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- rename_test | system_time | inf_check | generated_always | write_history | trunc | {col3} (1 row) /* for_portion_views */ ALTER TABLE rename_test ADD COLUMN id integer PRIMARY KEY; SELECT periods.add_for_portion_view('rename_test', 'p'); add_for_portion_view ---------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name -------------+-------------+-------------------------------+------------------ rename_test | p | rename_test__for_portion_of_p | for_portion_of_p (1 row) ALTER TRIGGER for_portion_of_p ON rename_test__for_portion_of_p RENAME TO portion_trigger; TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name -------------+-------------+-------------------------------+----------------- rename_test | p | rename_test__for_portion_of_p | portion_trigger (1 row) SELECT periods.drop_for_portion_view('rename_test', 'p'); drop_for_portion_view ----------------------- t (1 row) ALTER TABLE rename_test DROP COLUMN id; /* unique_keys */ SELECT periods.add_unique_key('rename_test', ARRAY['col2', 'col1', 'col3'], 'p'); add_unique_key ------------------------------ rename_test_col2_col1_col3_p (1 row) TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ------------------------------+-------------+------------------+-------------+---------------------------------------------------------+------------------------------------------- rename_test_col2_col1_col3_p | rename_test | {col2,col1,col3} | p | rename_test_col2_col1_col3_s < e_embedded " symbols_key | rename_test_col2_col1_col3_int4range_excl (1 row) ALTER TABLE rename_test RENAME COLUMN col1 TO "COLUMN1"; ALTER TABLE rename_test RENAME CONSTRAINT "rename_test_col2_col1_col3_s < e_embedded "" symbols_key" TO unconst; ALTER TABLE rename_test RENAME CONSTRAINT rename_test_col2_col1_col3_int4range_excl TO exconst; TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ------------------------------+-------------+---------------------+-------------+-------------------+-------------------- rename_test_col2_col1_col3_p | rename_test | {col2,COLUMN1,col3} | p | unconst | exconst (1 row) /* foreign_keys */ CREATE TABLE rename_test_ref (LIKE rename_test); SELECT periods.add_period('rename_test_ref', 'q', 's < e', 'embedded " symbols'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('rename_test_ref', ARRAY['col2', 'COLUMN1', 'col3'], 'q', 'rename_test_col2_col1_col3_p'); add_foreign_key ------------------------------------- rename_test_ref_col2_COLUMN1_col3_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger -------------------------------------+-----------------+---------------------+-------------+------------------------------+------------+---------------+---------------+-----------------------------------------------+-----------------------------------------------+-----------------------------------------------+----------------------------------------------- rename_test_ref_col2_COLUMN1_col3_q | rename_test_ref | {col2,COLUMN1,col3} | q | rename_test_col2_col1_col3_p | SIMPLE | NO ACTION | NO ACTION | rename_test_ref_col2_COLUMN1_col3_q_fk_insert | rename_test_ref_col2_COLUMN1_col3_q_fk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_delete (1 row) ALTER TABLE rename_test_ref RENAME COLUMN "COLUMN1" TO col1; -- fails ERROR: cannot drop or rename column "COLUMN1" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" CONTEXT: PL/pgSQL function periods.rename_following() line 210 at RAISE ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_insert" ON rename_test_ref RENAME TO fk_insert; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_fk_insert" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" CONTEXT: PL/pgSQL function periods.rename_following() line 245 at RAISE ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_update" ON rename_test_ref RENAME TO fk_update; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_fk_update" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" CONTEXT: PL/pgSQL function periods.rename_following() line 245 at RAISE ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_update" ON rename_test RENAME TO uk_update; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_uk_update" on table "rename_test" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" CONTEXT: PL/pgSQL function periods.rename_following() line 245 at RAISE ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_delete" ON rename_test RENAME TO uk_delete; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_uk_delete" on table "rename_test" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" CONTEXT: PL/pgSQL function periods.rename_following() line 245 at RAISE TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger -------------------------------------+-----------------+---------------------+-------------+------------------------------+------------+---------------+---------------+-----------------------------------------------+-----------------------------------------------+-----------------------------------------------+----------------------------------------------- rename_test_ref_col2_COLUMN1_col3_q | rename_test_ref | {col2,COLUMN1,col3} | q | rename_test_col2_col1_col3_p | SIMPLE | NO ACTION | NO ACTION | rename_test_ref_col2_COLUMN1_col3_q_fk_insert | rename_test_ref_col2_COLUMN1_col3_q_fk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_delete (1 row) DROP TABLE rename_test_ref; /* system_versioning */ SELECT periods.add_system_versioning('rename_test'); NOTICE: history table "rename_test_history" created for "rename_test", be sure to index it properly add_system_versioning ----------------------- (1 row) ALTER FUNCTION rename_test__as_of(timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__as_of(timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" CONTEXT: PL/pgSQL function periods.health_checks() line 42 at RAISE ALTER FUNCTION rename_test__between(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__between(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" CONTEXT: PL/pgSQL function periods.health_checks() line 42 at RAISE ALTER FUNCTION rename_test__between_symmetric(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__between_symmetric(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" CONTEXT: PL/pgSQL function periods.health_checks() line 42 at RAISE ALTER FUNCTION rename_test__from_to(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__from_to(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" CONTEXT: PL/pgSQL function periods.health_checks() line 42 at RAISE SELECT periods.drop_system_versioning('rename_test', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE rename_test; periods-1.2.2/expected/rename_following_1.out000066400000000000000000000301441432551570100212670ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * If anything we store as "name" is renamed, we need to update our catalogs or * throw an error. */ /* periods */ CREATE TABLE rename_test(col1 text, col2 bigint, col3 time, s integer, e integer); SELECT periods.add_period('rename_test', 'p', 's', 'e'); add_period ------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | s | e | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME s TO start; ALTER TABLE rename_test RENAME e TO "end"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | start | end | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME start TO "s < e"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------- rename_test | p | s < e | end | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME "end" TO "embedded "" symbols"; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+--------------------+------------+------------------------- rename_test | p | s < e | embedded " symbols | int4range | rename_test_p_check (1 row) ALTER TABLE rename_test RENAME CONSTRAINT rename_test_p_check TO start_before_end; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+--------------------+------------+------------------------- rename_test | p | s < e | embedded " symbols | int4range | start_before_end (1 row) /* system_time_periods */ SELECT periods.add_system_time_period('rename_test', excluded_column_names => ARRAY['col3']); add_system_time_period ------------------------ t (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names -------------+-------------+--------------------------------------------+------------------------------------------+---------------------------------------+----------------------+----------------------- rename_test | system_time | rename_test_system_time_end_infinity_check | rename_test_system_time_generated_always | rename_test_system_time_write_history | rename_test_truncate | {col3} (1 row) ALTER TABLE rename_test RENAME col3 TO "COLUMN3"; ERROR: cannot drop or rename column "col3" on table "rename_test" because it is excluded from SYSTEM VERSIONING ALTER TABLE rename_test RENAME CONSTRAINT rename_test_system_time_end_infinity_check TO inf_check; ALTER TRIGGER rename_test_system_time_generated_always ON rename_test RENAME TO generated_always; ALTER TRIGGER rename_test_system_time_write_history ON rename_test RENAME TO write_history; ALTER TRIGGER rename_test_truncate ON rename_test RENAME TO trunc; TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names -------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- rename_test | system_time | inf_check | generated_always | write_history | trunc | {col3} (1 row) /* for_portion_views */ ALTER TABLE rename_test ADD COLUMN id integer PRIMARY KEY; SELECT periods.add_for_portion_view('rename_test', 'p'); add_for_portion_view ---------------------- t (1 row) TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name -------------+-------------+-------------------------------+------------------ rename_test | p | rename_test__for_portion_of_p | for_portion_of_p (1 row) ALTER TRIGGER for_portion_of_p ON rename_test__for_portion_of_p RENAME TO portion_trigger; TABLE periods.for_portion_views; table_name | period_name | view_name | trigger_name -------------+-------------+-------------------------------+----------------- rename_test | p | rename_test__for_portion_of_p | portion_trigger (1 row) SELECT periods.drop_for_portion_view('rename_test', 'p'); drop_for_portion_view ----------------------- t (1 row) ALTER TABLE rename_test DROP COLUMN id; /* unique_keys */ SELECT periods.add_unique_key('rename_test', ARRAY['col2', 'col1', 'col3'], 'p'); add_unique_key ------------------------------ rename_test_col2_col1_col3_p (1 row) TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ------------------------------+-------------+------------------+-------------+---------------------------------------------------------+------------------------------------------- rename_test_col2_col1_col3_p | rename_test | {col2,col1,col3} | p | rename_test_col2_col1_col3_s < e_embedded " symbols_key | rename_test_col2_col1_col3_int4range_excl (1 row) ALTER TABLE rename_test RENAME COLUMN col1 TO "COLUMN1"; ALTER TABLE rename_test RENAME CONSTRAINT "rename_test_col2_col1_col3_s < e_embedded "" symbols_key" TO unconst; ALTER TABLE rename_test RENAME CONSTRAINT rename_test_col2_col1_col3_int4range_excl TO exconst; TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ------------------------------+-------------+---------------------+-------------+-------------------+-------------------- rename_test_col2_col1_col3_p | rename_test | {col2,COLUMN1,col3} | p | unconst | exconst (1 row) /* foreign_keys */ CREATE TABLE rename_test_ref (LIKE rename_test); SELECT periods.add_period('rename_test_ref', 'q', 's < e', 'embedded " symbols'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('rename_test_ref', ARRAY['col2', 'COLUMN1', 'col3'], 'q', 'rename_test_col2_col1_col3_p'); add_foreign_key ------------------------------------- rename_test_ref_col2_COLUMN1_col3_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger -------------------------------------+-----------------+---------------------+-------------+------------------------------+------------+---------------+---------------+-----------------------------------------------+-----------------------------------------------+-----------------------------------------------+----------------------------------------------- rename_test_ref_col2_COLUMN1_col3_q | rename_test_ref | {col2,COLUMN1,col3} | q | rename_test_col2_col1_col3_p | SIMPLE | NO ACTION | NO ACTION | rename_test_ref_col2_COLUMN1_col3_q_fk_insert | rename_test_ref_col2_COLUMN1_col3_q_fk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_delete (1 row) ALTER TABLE rename_test_ref RENAME COLUMN "COLUMN1" TO col1; -- fails ERROR: cannot drop or rename column "COLUMN1" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_insert" ON rename_test_ref RENAME TO fk_insert; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_fk_insert" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_update" ON rename_test_ref RENAME TO fk_update; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_fk_update" on table "rename_test_ref" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_update" ON rename_test RENAME TO uk_update; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_uk_update" on table "rename_test" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_delete" ON rename_test RENAME TO uk_delete; ERROR: cannot drop or rename trigger "rename_test_ref_col2_COLUMN1_col3_q_uk_delete" on table "rename_test" because it is used in period foreign key "rename_test_ref_col2_COLUMN1_col3_q" TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger -------------------------------------+-----------------+---------------------+-------------+------------------------------+------------+---------------+---------------+-----------------------------------------------+-----------------------------------------------+-----------------------------------------------+----------------------------------------------- rename_test_ref_col2_COLUMN1_col3_q | rename_test_ref | {col2,COLUMN1,col3} | q | rename_test_col2_col1_col3_p | SIMPLE | NO ACTION | NO ACTION | rename_test_ref_col2_COLUMN1_col3_q_fk_insert | rename_test_ref_col2_COLUMN1_col3_q_fk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_update | rename_test_ref_col2_COLUMN1_col3_q_uk_delete (1 row) DROP TABLE rename_test_ref; /* system_versioning */ SELECT periods.add_system_versioning('rename_test'); NOTICE: history table "rename_test_history" created for "rename_test", be sure to index it properly add_system_versioning ----------------------- (1 row) ALTER FUNCTION rename_test__as_of(timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__as_of(timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" ALTER FUNCTION rename_test__between(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__between(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" ALTER FUNCTION rename_test__between_symmetric(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__between_symmetric(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" ALTER FUNCTION rename_test__from_to(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ERROR: cannot drop or rename function "public.rename_test__from_to(timestamp with time zone,timestamp with time zone)" because it is used in SYSTEM VERSIONING for table "public.rename_test" SELECT periods.drop_system_versioning('rename_test', purge => true); drop_system_versioning ------------------------ t (1 row) DROP TABLE rename_test; periods-1.2.2/expected/system_time_periods.out000066400000000000000000000260501432551570100216100ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* SYSTEM_TIME with date */ BEGIN; SELECT transaction_timestamp()::date AS xd, transaction_timestamp()::timestamp AS xts, transaction_timestamp() AS xtstz \gset CREATE TABLE sysver_date (val text, start_date date, end_date date); SELECT periods.add_system_time_period('sysver_date', 'start_date', 'end_date'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------------- sysver_date | system_time | start_date | end_date | daterange | sysver_date_system_time_check (1 row) INSERT INTO sysver_date DEFAULT VALUES; SELECT val, start_date = :'xd' AS start_date_eq, end_date FROM sysver_date; val | start_date_eq | end_date -----+---------------+---------- | t | infinity (1 row) DROP TABLE sysver_date; /* SYSTEM_TIME with timestamp without time zone */ CREATE TABLE sysver_ts (val text, start_ts timestamp without time zone, end_ts timestamp without time zone); SELECT periods.add_system_time_period('sysver_ts', 'start_ts', 'end_ts'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+----------------------------- sysver_ts | system_time | start_ts | end_ts | tsrange | sysver_ts_system_time_check (1 row) INSERT INTO sysver_ts DEFAULT VALUES; SELECT val, start_ts = :'xts' AS start_ts_eq, end_ts FROM sysver_ts; val | start_ts_eq | end_ts -----+-------------+---------- | t | infinity (1 row) DROP TABLE sysver_ts; /* SYSTEM_TIME with timestamp with time zone */ CREATE TABLE sysver_tstz (val text, start_tstz timestamp with time zone, end_tstz timestamp with time zone); SELECT periods.add_system_time_period('sysver_tstz', 'start_tstz', 'end_tstz'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------------- sysver_tstz | system_time | start_tstz | end_tstz | tstzrange | sysver_tstz_system_time_check (1 row) INSERT INTO sysver_tstz DEFAULT VALUES; SELECT val, start_tstz = :'xtstz' AS start_tstz_eq, end_tstz FROM sysver_tstz; val | start_tstz_eq | end_tstz -----+---------------+---------- | t | infinity (1 row) DROP TABLE sysver_tstz; COMMIT; /* Basic SYSTEM_TIME periods with CASCADE/purge */ CREATE TABLE sysver (val text); SELECT periods.add_system_time_period('sysver', 'startname'); add_system_time_period ------------------------ t (1 row) SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); drop_period ------------- t (1 row) SELECT periods.add_system_time_period('sysver', end_column_name => 'endname'); add_system_time_period ------------------------ t (1 row) SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); drop_period ------------- t (1 row) SELECT periods.add_system_time_period('sysver', 'startname', 'endname'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+-------------------------- sysver | system_time | startname | endname | tstzrange | sysver_system_time_check (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------+-------------------------------------+----------------------------------+------------------+----------------------- sysver | system_time | sysver_endname_infinity_check | sysver_system_time_generated_always | sysver_system_time_write_history | sysver_truncate | {} (1 row) SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_system_time_period('sysver', 'endname', 'startname', bounds_check_constraint => 'b', infinity_check_constraint => 'i', generated_always_trigger => 'g', write_history_trigger => 'w', truncate_trigger => 't'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- sysver | system_time | endname | startname | tstzrange | b (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- sysver | system_time | i | g | w | t | {} (1 row) SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_system_time_period('sysver'); add_system_time_period ------------------------ t (1 row) DROP TABLE sysver; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- (0 rows) /* Forbid UNIQUE keys on system_time columns */ CREATE TABLE no_unique (col1 timestamp with time zone, s bigint, e bigint); SELECT periods.add_period('no_unique', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'p'); -- passes add_unique_key ------------------ no_unique_col1_p (1 row) SELECT periods.add_system_time_period('no_unique'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_unique_key(regclass,name[],name,name,name,name) line 78 at RAISE SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_unique_key(regclass,name[],name,name,name,name) line 78 at RAISE SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'system_time'); -- fails ERROR: periods for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_unique_key(regclass,name[],name,name,name,name) line 35 at RAISE SELECT periods.drop_system_time_period('no_unique'); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- passes add_unique_key ------------------------------- no_unique_system_time_start_p (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- passes add_unique_key ----------------------------- no_unique_system_time_end_p (1 row) SELECT periods.add_system_time_period('no_unique'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) line 48 at RAISE SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_start_p'); drop_unique_key ----------------- (1 row) SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_end_p'); drop_unique_key ----------------- (1 row) /* Forbid foreign keys on system_time columns */ CREATE TABLE no_unique_ref (LIKE no_unique); SELECT periods.add_period('no_unique_ref', 'q', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_system_time_period('no_unique_ref'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_foreign_key(regclass,name[],name,name,periods.fk_match_types,periods.fk_actions,periods.fk_actions,name,name,name,name,name) line 46 at RAISE SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys CONTEXT: PL/pgSQL function periods.add_foreign_key(regclass,name[],name,name,periods.fk_match_types,periods.fk_actions,periods.fk_actions,name,name,name,name,name) line 46 at RAISE SELECT periods.add_foreign_key('no_unique_ref', ARRAY['col1'], 'system_time', 'no_unique_col1_p'); -- fails ERROR: periods for SYSTEM_TIME are not allowed in foreign keys CONTEXT: PL/pgSQL function periods.add_foreign_key(regclass,name[],name,name,periods.fk_match_types,periods.fk_actions,periods.fk_actions,name,name,name,name,name) line 34 at RAISE SELECT periods.drop_system_time_period('no_unique_ref'); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- passes add_foreign_key ----------------------------------- no_unique_ref_system_time_start_q (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- passes add_foreign_key --------------------------------- no_unique_ref_system_time_end_q (1 row) SELECT periods.add_system_time_period('no_unique_ref'); -- fails ERROR: columns for SYSTEM_TIME must not be part of foreign keys CONTEXT: PL/pgSQL function periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) line 168 at RAISE DROP TABLE no_unique, no_unique_ref; periods-1.2.2/expected/system_time_periods_1.out000066400000000000000000000237121432551570100220320ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* SYSTEM_TIME with date */ BEGIN; SELECT transaction_timestamp()::date AS xd, transaction_timestamp()::timestamp AS xts, transaction_timestamp() AS xtstz \gset CREATE TABLE sysver_date (val text, start_date date, end_date date); SELECT periods.add_system_time_period('sysver_date', 'start_date', 'end_date'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------------- sysver_date | system_time | start_date | end_date | daterange | sysver_date_system_time_check (1 row) INSERT INTO sysver_date DEFAULT VALUES; SELECT val, start_date = :'xd' AS start_date_eq, end_date FROM sysver_date; val | start_date_eq | end_date -----+---------------+---------- | t | infinity (1 row) DROP TABLE sysver_date; /* SYSTEM_TIME with timestamp without time zone */ CREATE TABLE sysver_ts (val text, start_ts timestamp without time zone, end_ts timestamp without time zone); SELECT periods.add_system_time_period('sysver_ts', 'start_ts', 'end_ts'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+----------------------------- sysver_ts | system_time | start_ts | end_ts | tsrange | sysver_ts_system_time_check (1 row) INSERT INTO sysver_ts DEFAULT VALUES; SELECT val, start_ts = :'xts' AS start_ts_eq, end_ts FROM sysver_ts; val | start_ts_eq | end_ts -----+-------------+---------- | t | infinity (1 row) DROP TABLE sysver_ts; /* SYSTEM_TIME with timestamp with time zone */ CREATE TABLE sysver_tstz (val text, start_tstz timestamp with time zone, end_tstz timestamp with time zone); SELECT periods.add_system_time_period('sysver_tstz', 'start_tstz', 'end_tstz'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint -------------+-------------+-------------------+-----------------+------------+------------------------------- sysver_tstz | system_time | start_tstz | end_tstz | tstzrange | sysver_tstz_system_time_check (1 row) INSERT INTO sysver_tstz DEFAULT VALUES; SELECT val, start_tstz = :'xtstz' AS start_tstz_eq, end_tstz FROM sysver_tstz; val | start_tstz_eq | end_tstz -----+---------------+---------- | t | infinity (1 row) DROP TABLE sysver_tstz; COMMIT; /* Basic SYSTEM_TIME periods with CASCADE/purge */ CREATE TABLE sysver (val text); SELECT periods.add_system_time_period('sysver', 'startname'); add_system_time_period ------------------------ t (1 row) SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); drop_period ------------- t (1 row) SELECT periods.add_system_time_period('sysver', end_column_name => 'endname'); add_system_time_period ------------------------ t (1 row) SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); drop_period ------------- t (1 row) SELECT periods.add_system_time_period('sysver', 'startname', 'endname'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+-------------------------- sysver | system_time | startname | endname | tstzrange | sysver_system_time_check (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+-------------------------------+-------------------------------------+----------------------------------+------------------+----------------------- sysver | system_time | sysver_endname_infinity_check | sysver_system_time_generated_always | sysver_system_time_write_history | sysver_truncate | {} (1 row) SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_system_time_period('sysver', 'endname', 'startname', bounds_check_constraint => 'b', infinity_check_constraint => 'i', generated_always_trigger => 'g', write_history_trigger => 'w', truncate_trigger => 't'); add_system_time_period ------------------------ t (1 row) TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- sysver | system_time | endname | startname | tstzrange | b (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- sysver | system_time | i | g | w | t | {} (1 row) SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_system_time_period('sysver'); add_system_time_period ------------------------ t (1 row) DROP TABLE sysver; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- (0 rows) /* Forbid UNIQUE keys on system_time columns */ CREATE TABLE no_unique (col1 timestamp with time zone, s bigint, e bigint); SELECT periods.add_period('no_unique', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'p'); -- passes add_unique_key ------------------ no_unique_col1_p (1 row) SELECT periods.add_system_time_period('no_unique'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'system_time'); -- fails ERROR: periods for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.drop_system_time_period('no_unique'); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- passes add_unique_key ------------------------------- no_unique_system_time_start_p (1 row) SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- passes add_unique_key ----------------------------- no_unique_system_time_end_p (1 row) SELECT periods.add_system_time_period('no_unique'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_start_p'); drop_unique_key ----------------- (1 row) SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_end_p'); drop_unique_key ----------------- (1 row) /* Forbid foreign keys on system_time columns */ CREATE TABLE no_unique_ref (LIKE no_unique); SELECT periods.add_period('no_unique_ref', 'q', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_system_time_period('no_unique_ref'); add_system_time_period ------------------------ t (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- fails ERROR: columns in period for SYSTEM_TIME are not allowed in UNIQUE keys SELECT periods.add_foreign_key('no_unique_ref', ARRAY['col1'], 'system_time', 'no_unique_col1_p'); -- fails ERROR: periods for SYSTEM_TIME are not allowed in foreign keys SELECT periods.drop_system_time_period('no_unique_ref'); drop_system_time_period ------------------------- t (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- passes add_foreign_key ----------------------------------- no_unique_ref_system_time_start_q (1 row) SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- passes add_foreign_key --------------------------------- no_unique_ref_system_time_end_q (1 row) SELECT periods.add_system_time_period('no_unique_ref'); -- fails ERROR: columns for SYSTEM_TIME must not be part of foreign keys DROP TABLE no_unique, no_unique_ref; periods-1.2.2/expected/system_versioning.out000066400000000000000000000303401432551570100213050ustar00rootroot00000000000000/* * An alternative file for pre-v12 is necessary because LEAST() and GREATEST() * were not constant folded. It was actually while writing this extension that * the lack of optimization was noticed, and subsequently fixed. * * https://www.postgresql.org/message-id/flat/c6e8504c-4c43-35fa-6c8f-3c0b80a912cc%402ndquadrant.com */ SELECT setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; pre_12 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic SYSTEM VERSIONING */ CREATE TABLE sysver (val text, flap boolean); SELECT periods.add_system_time_period('sysver', excluded_column_names => ARRAY['flap']); add_system_time_period ------------------------ t (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------------------+-------------------------------------+----------------------------------+------------------+----------------------- sysver | system_time | sysver_system_time_end_infinity_check | sysver_system_time_generated_always | sysver_system_time_write_history | sysver_truncate | {flap} (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-----------+------------+--------------+------------------------+-------------- (0 rows) SELECT periods.add_system_versioning('sysver', history_table_name => 'custom_history_name', view_name => 'custom_view_name', function_as_of_name => 'custom_as_of', function_between_name => 'custom_between', function_between_symmetric_name => 'custom_between_symmetric', function_from_to_name => 'custom_from_to'); NOTICE: history table "custom_history_name" created for "sysver", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+---------------------+------------------+-----------------------------------------------+--------------------------------------------------------------------------+------------------------------------------------------------------------------------+-------------------------------------------------------------------------- sysver | system_time | custom_history_name | custom_view_name | public.custom_as_of(timestamp with time zone) | public.custom_between(timestamp with time zone,timestamp with time zone) | public.custom_between_symmetric(timestamp with time zone,timestamp with time zone) | public.custom_from_to(timestamp with time zone,timestamp with time zone) (1 row) SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE'); drop_system_versioning ------------------------ t (1 row) DROP TABLE custom_history_name; SELECT periods.add_system_versioning('sysver'); NOTICE: history table "sysver_history" created for "sysver", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+---------------------+------------------------------------------------+---------------------------------------------------------------------------+-------------------------------------------------------------------------------------+--------------------------------------------------------------------------- sysver | system_time | sysver_history | sysver_with_history | public.sysver__as_of(timestamp with time zone) | public.sysver__between(timestamp with time zone,timestamp with time zone) | public.sysver__between_symmetric(timestamp with time zone,timestamp with time zone) | public.sysver__from_to(timestamp with time zone,timestamp with time zone) (1 row) INSERT INTO sysver (val, flap) VALUES ('hello', false); SELECT val FROM sysver; val ------- hello (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ----- (0 rows) SELECT transaction_timestamp() AS ts1 \gset UPDATE sysver SET val = 'world'; SELECT val FROM sysver; val ------- world (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello (1 row) UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; SELECT val FROM sysver; val ------- world (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello (1 row) SELECT transaction_timestamp() AS ts2 \gset DELETE FROM sysver; SELECT val FROM sysver; val ----- (0 rows) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello world (2 rows) /* temporal queries */ SELECT val FROM sysver__as_of(:'ts1') ORDER BY system_time_start; val ------- hello (1 row) SELECT val FROM sysver__as_of(:'ts2') ORDER BY system_time_start; val ------- world (1 row) SELECT val FROM sysver__from_to(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__from_to(:'ts2', :'ts1') ORDER BY system_time_start; val ----- (0 rows) SELECT val FROM sysver__between(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__between(:'ts2', :'ts1') ORDER BY system_time_start; val ----- (0 rows) SELECT val FROM sysver__between_symmetric(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__between_symmetric(:'ts2', :'ts1') ORDER BY system_time_start; val ------- hello world (2 rows) /* Ensure functions are inlined */ SET TimeZone = 'UTC'; SET DateStyle = 'ISO'; EXPLAIN (COSTS OFF) SELECT * FROM sysver__as_of('2000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Append -> Seq Scan on sysver Filter: ((system_time_start <= '2000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_end > '2000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_start <= '2000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_end > '2000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__from_to('1000-01-01', '3000-01-01'); QUERY PLAN ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- Append -> Seq Scan on sysver Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start < '3000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start < '3000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__between('1000-01-01', '3000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Append -> Seq Scan on sysver Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__between_symmetric('3000-01-01', '1000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Append -> Seq Scan on sysver Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) /* TRUNCATE should delete the history, too */ SELECT val FROM sysver_with_history; val ------- hello world (2 rows) TRUNCATE sysver; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) /* Try modifying several times in a transaction */ BEGIN; INSERT INTO sysver (val) VALUES ('hello'); INSERT INTO sysver (val) VALUES ('world'); ROLLBACK; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) BEGIN; INSERT INTO sysver (val) VALUES ('hello'); UPDATE sysver SET val = 'world'; UPDATE sysver SET val = 'world2'; UPDATE sysver SET val = 'world3'; DELETE FROM sysver; COMMIT; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) -- We can't drop the the table without first dropping SYSTEM VERSIONING because -- Postgres will complain about dependant objects (our view functions) before -- we get a chance to clean them up. DROP TABLE sysver; ERROR: cannot drop table sysver because other objects depend on it DETAIL: view sysver_with_history depends on table sysver function sysver__as_of(timestamp with time zone) depends on type sysver_with_history function sysver__between(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history function sysver__between_symmetric(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history function sysver__from_to(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history HINT: Use DROP ... CASCADE to drop the dependent objects too. SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-----------+------------+--------------+------------------------+-------------- (0 rows) DROP TABLE sysver; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- (0 rows) periods-1.2.2/expected/system_versioning_1.out000066400000000000000000000313041432551570100215260ustar00rootroot00000000000000/* * An alternative file for pre-v12 is necessary because LEAST() and GREATEST() * were not constant folded. It was actually while writing this extension that * the lack of optimization was noticed, and subsequently fixed. * * https://www.postgresql.org/message-id/flat/c6e8504c-4c43-35fa-6c8f-3c0b80a912cc%402ndquadrant.com */ SELECT setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; pre_12 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic SYSTEM VERSIONING */ CREATE TABLE sysver (val text, flap boolean); SELECT periods.add_system_time_period('sysver', excluded_column_names => ARRAY['flap']); add_system_time_period ------------------------ t (1 row) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------------------+-------------------------------------+----------------------------------+------------------+----------------------- sysver | system_time | sysver_system_time_end_infinity_check | sysver_system_time_generated_always | sysver_system_time_write_history | sysver_truncate | {flap} (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-----------+------------+--------------+------------------------+-------------- (0 rows) SELECT periods.add_system_versioning('sysver', history_table_name => 'custom_history_name', view_name => 'custom_view_name', function_as_of_name => 'custom_as_of', function_between_name => 'custom_between', function_between_symmetric_name => 'custom_between_symmetric', function_from_to_name => 'custom_from_to'); NOTICE: history table "custom_history_name" created for "sysver", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+---------------------+------------------+-----------------------------------------------+--------------------------------------------------------------------------+------------------------------------------------------------------------------------+-------------------------------------------------------------------------- sysver | system_time | custom_history_name | custom_view_name | public.custom_as_of(timestamp with time zone) | public.custom_between(timestamp with time zone,timestamp with time zone) | public.custom_between_symmetric(timestamp with time zone,timestamp with time zone) | public.custom_from_to(timestamp with time zone,timestamp with time zone) (1 row) SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE'); drop_system_versioning ------------------------ t (1 row) DROP TABLE custom_history_name; SELECT periods.add_system_versioning('sysver'); NOTICE: history table "sysver_history" created for "sysver", be sure to index it properly add_system_versioning ----------------------- (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+---------------------+------------------------------------------------+---------------------------------------------------------------------------+-------------------------------------------------------------------------------------+--------------------------------------------------------------------------- sysver | system_time | sysver_history | sysver_with_history | public.sysver__as_of(timestamp with time zone) | public.sysver__between(timestamp with time zone,timestamp with time zone) | public.sysver__between_symmetric(timestamp with time zone,timestamp with time zone) | public.sysver__from_to(timestamp with time zone,timestamp with time zone) (1 row) INSERT INTO sysver (val, flap) VALUES ('hello', false); SELECT val FROM sysver; val ------- hello (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ----- (0 rows) SELECT transaction_timestamp() AS ts1 \gset UPDATE sysver SET val = 'world'; SELECT val FROM sysver; val ------- world (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello (1 row) UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; SELECT val FROM sysver; val ------- world (1 row) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello (1 row) SELECT transaction_timestamp() AS ts2 \gset DELETE FROM sysver; SELECT val FROM sysver; val ----- (0 rows) SELECT val FROM sysver_history ORDER BY system_time_start; val ------- hello world (2 rows) /* temporal queries */ SELECT val FROM sysver__as_of(:'ts1') ORDER BY system_time_start; val ------- hello (1 row) SELECT val FROM sysver__as_of(:'ts2') ORDER BY system_time_start; val ------- world (1 row) SELECT val FROM sysver__from_to(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__from_to(:'ts2', :'ts1') ORDER BY system_time_start; val ----- (0 rows) SELECT val FROM sysver__between(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__between(:'ts2', :'ts1') ORDER BY system_time_start; val ----- (0 rows) SELECT val FROM sysver__between_symmetric(:'ts1', :'ts2') ORDER BY system_time_start; val ------- hello world (2 rows) SELECT val FROM sysver__between_symmetric(:'ts2', :'ts1') ORDER BY system_time_start; val ------- hello world (2 rows) /* Ensure functions are inlined */ SET TimeZone = 'UTC'; SET DateStyle = 'ISO'; EXPLAIN (COSTS OFF) SELECT * FROM sysver__as_of('2000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Append -> Seq Scan on sysver Filter: ((system_time_start <= '2000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_end > '2000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_start <= '2000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_end > '2000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__from_to('1000-01-01', '3000-01-01'); QUERY PLAN ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- Append -> Seq Scan on sysver Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start < '3000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start < '3000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__between('1000-01-01', '3000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Append -> Seq Scan on sysver Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) -> Seq Scan on sysver_history Filter: ((system_time_end > '1000-01-01 00:00:00+00'::timestamp with time zone) AND (system_time_start <= '3000-01-01 00:00:00+00'::timestamp with time zone)) (5 rows) EXPLAIN (COSTS OFF) SELECT * FROM sysver__between_symmetric('3000-01-01', '1000-01-01'); QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Append -> Seq Scan on sysver Filter: ((system_time_end > LEAST('3000-01-01 00:00:00+00'::timestamp with time zone, '1000-01-01 00:00:00+00'::timestamp with time zone)) AND (system_time_start <= GREATEST('3000-01-01 00:00:00+00'::timestamp with time zone, '1000-01-01 00:00:00+00'::timestamp with time zone))) -> Seq Scan on sysver_history Filter: ((system_time_end > LEAST('3000-01-01 00:00:00+00'::timestamp with time zone, '1000-01-01 00:00:00+00'::timestamp with time zone)) AND (system_time_start <= GREATEST('3000-01-01 00:00:00+00'::timestamp with time zone, '1000-01-01 00:00:00+00'::timestamp with time zone))) (5 rows) /* TRUNCATE should delete the history, too */ SELECT val FROM sysver_with_history; val ------- hello world (2 rows) TRUNCATE sysver; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) /* Try modifying several times in a transaction */ BEGIN; INSERT INTO sysver (val) VALUES ('hello'); INSERT INTO sysver (val) VALUES ('world'); ROLLBACK; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) BEGIN; INSERT INTO sysver (val) VALUES ('hello'); UPDATE sysver SET val = 'world'; UPDATE sysver SET val = 'world2'; UPDATE sysver SET val = 'world3'; DELETE FROM sysver; COMMIT; SELECT val FROM sysver_with_history; --empty val ----- (0 rows) -- We can't drop the the table without first dropping SYSTEM VERSIONING because -- Postgres will complain about dependant objects (our view functions) before -- we get a chance to clean them up. DROP TABLE sysver; ERROR: cannot drop table sysver because other objects depend on it DETAIL: view sysver_with_history depends on table sysver function sysver__as_of(timestamp with time zone) depends on type sysver_with_history function sysver__between(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history function sysver__between_symmetric(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history function sysver__from_to(timestamp with time zone,timestamp with time zone) depends on type sysver_with_history HINT: Use DROP ... CASCADE to drop the dependent objects too. SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE', purge => true); drop_system_versioning ------------------------ t (1 row) TABLE periods.system_versioning; table_name | period_name | history_table_name | view_name | func_as_of | func_between | func_between_symmetric | func_from_to ------------+-------------+--------------------+-----------+------------+--------------+------------------------+-------------- (0 rows) DROP TABLE sysver; TABLE periods.periods; table_name | period_name | start_column_name | end_column_name | range_type | bounds_check_constraint ------------+-------------+-------------------+-----------------+------------+------------------------- (0 rows) TABLE periods.system_time_periods; table_name | period_name | infinity_check_constraint | generated_always_trigger | write_history_trigger | truncate_trigger | excluded_column_names ------------+-------------+---------------------------+--------------------------+-----------------------+------------------+----------------------- (0 rows) periods-1.2.2/expected/uninstall.out000066400000000000000000000000751432551570100175310ustar00rootroot00000000000000DROP EXTENSION periods; DROP ROLE periods_unprivileged_user; periods-1.2.2/expected/unique_foreign.out000066400000000000000000000136001432551570100205350ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- f (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; -- Unique keys are already pretty much guaranteed by the underlying features of -- PostgreSQL, but test them anyway. CREATE TABLE uk (id integer, s integer, e integer, CONSTRAINT uk_pkey PRIMARY KEY (id, s, e)); SELECT periods.add_period('uk', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_unique_key('uk', ARRAY['id'], 'p', key_name => 'uk_id_p', unique_constraint => 'uk_pkey'); add_unique_key ---------------- uk_id_p (1 row) TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ----------+------------+--------------+-------------+-------------------+---------------------- uk_id_p | uk | {id} | p | uk_pkey | uk_id_int4range_excl (1 row) INSERT INTO uk (id, s, e) VALUES (100, 1, 3), (100, 3, 4), (100, 4, 10); -- success INSERT INTO uk (id, s, e) VALUES (200, 1, 3), (200, 3, 4), (200, 5, 10); -- success INSERT INTO uk (id, s, e) VALUES (300, 1, 3), (300, 3, 5), (300, 4, 10); -- fail ERROR: conflicting key value violates exclusion constraint "uk_id_int4range_excl" DETAIL: Key (id, int4range(s, e, '[)'::text))=(300, [4,10)) conflicts with existing key (id, int4range(s, e, '[)'::text))=(300, [3,5)). CREATE TABLE fk (id integer, uk_id integer, s integer, e integer, PRIMARY KEY (id)); SELECT periods.add_period('fk', 'q', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q', fk_insert_trigger => 'fki', fk_update_trigger => 'fku', uk_update_trigger => 'uku', uk_delete_trigger => 'ukd'); add_foreign_key ----------------- fk_uk_id_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger ------------+------------+--------------+-------------+------------+------------+---------------+---------------+-------------------+-------------------+-------------------+------------------- fk_uk_id_q | fk | {uk_id} | q | uk_id_p | SIMPLE | NO ACTION | NO ACTION | fki | fku | uku | ukd (1 row) SELECT periods.drop_foreign_key('fk', 'fk_uk_id_q'); drop_foreign_key ------------------ t (1 row) SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q'); add_foreign_key ----------------- fk_uk_id_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger ------------+------------+--------------+-------------+------------+------------+---------------+---------------+----------------------+----------------------+----------------------+---------------------- fk_uk_id_q | fk | {uk_id} | q | uk_id_p | SIMPLE | NO ACTION | NO ACTION | fk_uk_id_q_fk_insert | fk_uk_id_q_fk_update | fk_uk_id_q_uk_update | fk_uk_id_q_uk_delete (1 row) -- INSERT INSERT INTO fk VALUES (0, 100, 0, 1); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: PL/pgSQL function periods.validate_foreign_key_new_row(name,jsonb) line 130 at RAISE SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (0, 100, 0, 10); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: PL/pgSQL function periods.validate_foreign_key_new_row(name,jsonb) line 130 at RAISE SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (0, 100, 1, 11); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: PL/pgSQL function periods.validate_foreign_key_new_row(name,jsonb) line 130 at RAISE SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (1, 100, 1, 3); -- success INSERT INTO fk VALUES (2, 100, 1, 10); -- success -- UPDATE UPDATE fk SET e = 20 WHERE id = 1; -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: PL/pgSQL function periods.validate_foreign_key_new_row(name,jsonb) line 130 at RAISE SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_update_check() line 19 at PERFORM UPDATE fk SET e = 6 WHERE id = 1; -- success UPDATE uk SET s = 2 WHERE (id, s, e) = (100, 1, 3); -- fail ERROR: update or delete on table "uk" violates foreign key constraint "fk_uk_id_q" on table "fk" CONTEXT: PL/pgSQL function periods.validate_foreign_key_old_row(name,jsonb,boolean) line 103 at RAISE SQL statement "SELECT periods.validate_foreign_key_old_row(TG_ARGV[0], jold, true)" PL/pgSQL function periods.uk_update_check() line 23 at PERFORM UPDATE uk SET s = 0 WHERE (id, s, e) = (100, 1, 3); -- success -- DELETE DELETE FROM uk WHERE (id, s, e) = (100, 3, 4); -- fail ERROR: update or delete on table "uk" violates foreign key constraint "fk_uk_id_q" on table "fk" CONTEXT: PL/pgSQL function periods.validate_foreign_key_old_row(name,jsonb,boolean) line 103 at RAISE SQL statement "SELECT periods.validate_foreign_key_old_row(TG_ARGV[0], jold, false)" PL/pgSQL function periods.uk_delete_check() line 22 at PERFORM DELETE FROM uk WHERE (id, s, e) = (200, 3, 5); -- success DROP TABLE fk; DROP TABLE uk; periods-1.2.2/expected/unique_foreign_1.out000066400000000000000000000125621432551570100207630ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; pre_96 -------- t (1 row) /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; -- Unique keys are already pretty much guaranteed by the underlying features of -- PostgreSQL, but test them anyway. CREATE TABLE uk (id integer, s integer, e integer, CONSTRAINT uk_pkey PRIMARY KEY (id, s, e)); SELECT periods.add_period('uk', 'p', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_unique_key('uk', ARRAY['id'], 'p', key_name => 'uk_id_p', unique_constraint => 'uk_pkey'); add_unique_key ---------------- uk_id_p (1 row) TABLE periods.unique_keys; key_name | table_name | column_names | period_name | unique_constraint | exclude_constraint ----------+------------+--------------+-------------+-------------------+---------------------- uk_id_p | uk | {id} | p | uk_pkey | uk_id_int4range_excl (1 row) INSERT INTO uk (id, s, e) VALUES (100, 1, 3), (100, 3, 4), (100, 4, 10); -- success INSERT INTO uk (id, s, e) VALUES (200, 1, 3), (200, 3, 4), (200, 5, 10); -- success INSERT INTO uk (id, s, e) VALUES (300, 1, 3), (300, 3, 5), (300, 4, 10); -- fail ERROR: conflicting key value violates exclusion constraint "uk_id_int4range_excl" DETAIL: Key (id, int4range(s, e, '[)'::text))=(300, [4,10)) conflicts with existing key (id, int4range(s, e, '[)'::text))=(300, [3,5)). CREATE TABLE fk (id integer, uk_id integer, s integer, e integer, PRIMARY KEY (id)); SELECT periods.add_period('fk', 'q', 's', 'e'); add_period ------------ t (1 row) SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q', fk_insert_trigger => 'fki', fk_update_trigger => 'fku', uk_update_trigger => 'uku', uk_delete_trigger => 'ukd'); add_foreign_key ----------------- fk_uk_id_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger ------------+------------+--------------+-------------+------------+------------+---------------+---------------+-------------------+-------------------+-------------------+------------------- fk_uk_id_q | fk | {uk_id} | q | uk_id_p | SIMPLE | NO ACTION | NO ACTION | fki | fku | uku | ukd (1 row) SELECT periods.drop_foreign_key('fk', 'fk_uk_id_q'); drop_foreign_key ------------------ t (1 row) SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q'); add_foreign_key ----------------- fk_uk_id_q (1 row) TABLE periods.foreign_keys; key_name | table_name | column_names | period_name | unique_key | match_type | delete_action | update_action | fk_insert_trigger | fk_update_trigger | uk_update_trigger | uk_delete_trigger ------------+------------+--------------+-------------+------------+------------+---------------+---------------+----------------------+----------------------+----------------------+---------------------- fk_uk_id_q | fk | {uk_id} | q | uk_id_p | SIMPLE | NO ACTION | NO ACTION | fk_uk_id_q_fk_insert | fk_uk_id_q_fk_update | fk_uk_id_q_uk_update | fk_uk_id_q_uk_delete (1 row) -- INSERT INSERT INTO fk VALUES (0, 100, 0, 1); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (0, 100, 0, 10); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (0, 100, 1, 11); -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_insert_check() line 20 at PERFORM INSERT INTO fk VALUES (1, 100, 1, 3); -- success INSERT INTO fk VALUES (2, 100, 1, 10); -- success -- UPDATE UPDATE fk SET e = 20 WHERE id = 1; -- fail ERROR: insert or update on table "fk" violates foreign key constraint "fk_uk_id_q" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_new_row(TG_ARGV[0], jnew)" PL/pgSQL function periods.fk_update_check() line 19 at PERFORM UPDATE fk SET e = 6 WHERE id = 1; -- success UPDATE uk SET s = 2 WHERE (id, s, e) = (100, 1, 3); -- fail ERROR: update or delete on table "uk" violates foreign key constraint "fk_uk_id_q" on table "fk" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_old_row(TG_ARGV[0], jold, true)" PL/pgSQL function periods.uk_update_check() line 23 at PERFORM UPDATE uk SET s = 0 WHERE (id, s, e) = (100, 1, 3); -- success -- DELETE DELETE FROM uk WHERE (id, s, e) = (100, 3, 4); -- fail ERROR: update or delete on table "uk" violates foreign key constraint "fk_uk_id_q" on table "fk" CONTEXT: SQL statement "SELECT periods.validate_foreign_key_old_row(TG_ARGV[0], jold, false)" PL/pgSQL function periods.uk_delete_check() line 22 at PERFORM DELETE FROM uk WHERE (id, s, e) = (200, 3, 5); -- success DROP TABLE fk; DROP TABLE uk; periods-1.2.2/periods--1.0--1.1.sql000066400000000000000000001356441432551570100163120ustar00rootroot00000000000000ALTER TABLE periods.system_time_periods ADD COLUMN excluded_column_names name[] NOT NULL DEFAULT '{}'; DROP FUNCTION periods.add_system_time_period(regclass, name, name, name, name, name, name, name); CREATE FUNCTION periods.add_system_time_period( table_class regclass, start_column_name name DEFAULT 'system_time_start', end_column_name name DEFAULT 'system_time_end', bounds_check_constraint name DEFAULT NULL, infinity_check_constraint name DEFAULT NULL, generated_always_trigger name DEFAULT NULL, write_history_trigger name DEFAULT NULL, truncate_trigger name DEFAULT NULL, excluded_column_names name[] DEFAULT '{}') RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_name CONSTANT name := 'system_time'; schema_name name; table_name name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; excluded_column_name name; DATE_OID CONSTANT integer := 1082; TIMESTAMP_OID CONSTANT integer := 1114; TIMESTAMPTZ_OID CONSTANT integer := 1184; range_type regtype; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.7 * SQL:2016 11.27 */ /* The columns must not be part of UNIQUE keys. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* * Check if period already exists. * * SQL:2016 11.27 SR 4.a */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, period_name)) THEN RAISE EXCEPTION 'period for SYSTEM_TIME already exists on table "%"', table_class; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 4.b */ IF EXISTS (SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, period_name)) THEN RAISE EXCEPTION 'a column named system_time already exists for table "%"', table_class; END IF; /* The standard says that the columns must not exist already, but we don't obey that rule for now. */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO start_attnum, start_type, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); IF NOT FOUND THEN /* * First add the column with DEFAULT of -infinity to fill the * current rows, then replace the DEFAULT with transaction_timestamp() for future * rows. * * The default value is just for self-documentation anyway because * the trigger will enforce the value. */ alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''-infinity''', start_column_name); start_attnum := 0; start_type := 'timestamp with time zone'::regtype; start_notnull := true; END IF; alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT transaction_timestamp()', start_column_name); IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO end_attnum, end_type, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); IF NOT FOUND THEN alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''infinity''', end_column_name); end_attnum := 0; end_type := 'timestamp with time zone'::regtype; end_notnull := true; ELSE alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT ''infinity''', end_column_name); END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Verify compatibility of start/end columns */ IF start_type::regtype NOT IN ('date', 'timestamp without time zone', 'timestamp with time zone') THEN RAISE EXCEPTION 'SYSTEM_TIME periods must be of type "date", "timestamp without time zone", or "timestamp with time zone"'; END IF; IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; /* Get appropriate range type */ CASE start_type WHEN DATE_OID THEN range_type := 'daterange'; WHEN TIMESTAMP_OID THEN range_type := 'tsrange'; WHEN TIMESTAMPTZ_OID THEN range_type := 'tstzrange'; ELSE RAISE EXCEPTION 'unexpected data type: "%"', start_type::regtype; END CASE; /* can't be part of a foreign key */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.table_name = table_class AND fk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* * Period columns must not be nullable. */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; bounds_check_constraint := periods._choose_name(ARRAY[table_name, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* * Find and appropriate a CHECK constraint to make sure that end = 'infinity'. * Create one if necessary. * * SQL:2016 4.15.2.2 */ DECLARE condef CONSTANT text := format('CHECK ((%I = ''infinity''::timestamp with time zone))', end_column_name); context text; BEGIN IF infinity_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, infinity_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', infinity_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO infinity_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; infinity_check_constraint := periods._choose_name(ARRAY[table_name, end_column_name], 'infinity_check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %I.%I %s', schema_name, table_name, array_to_string(alter_commands, ', ')); IF start_attnum = 0 THEN SELECT a.attnum INTO start_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); END IF; IF end_attnum = 0 THEN SELECT a.attnum INTO end_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); END IF; END IF; /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_class, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; generated_always_trigger := coalesce( generated_always_trigger, periods._choose_name(ARRAY[table_name], 'system_time_generated_always')); EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.generated_always_as_row_start_end()', generated_always_trigger, table_class); write_history_trigger := coalesce( write_history_trigger, periods._choose_name(ARRAY[table_name], 'system_time_write_history')); EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.write_history()', write_history_trigger, table_class); truncate_trigger := coalesce( truncate_trigger, periods._choose_name(ARRAY[table_name], 'truncate')); EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE PROCEDURE periods.truncate_system_versioning()', truncate_trigger, table_class); INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_class, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); INSERT INTO periods.system_time_periods ( table_name, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names) VALUES ( table_class, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names); RETURN true; END; $function$; CREATE OR REPLACE FUNCTION periods.drop_protection() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; table_name regclass; period_name name; BEGIN /* * This function is called after the fact, so we have to just look to see * if anything is missing in the catalogs if we just store the name and not * a reg* type. */ --- --- periods --- /* If one of our tables is being dropped, remove references to it */ FOR table_name, period_name IN SELECT p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP PERFORM periods.drop_period(table_name, period_name, 'CASCADE', true); END LOOP; /* * If a column belonging to one of our periods is dropped, we need to reject that. * SQL:2016 11.23 SR 6 */ FOR r IN SELECT dobj.object_identity, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_attribute AS sa ON (sa.attrelid, sa.attname) = (p.table_name, p.start_column_name) JOIN pg_catalog.pg_attribute AS ea ON (ea.attrelid, ea.attname) = (p.table_name, p.end_column_name) JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name AND dobj.objsubid IN (sa.attnum, ea.attnum) WHERE dobj.object_type = 'table column' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop column "%" because it is part of the period "%"', r.object_identity, r.period_name; END LOOP; /* Also reject dropping the rangetype */ FOR r IN SELECT dobj.object_identity, p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.range_type ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop rangetype "%" because it is used in period "%" on table "%"', r.object_identity, r.period_name, r.table_name; END LOOP; --- --- system_time_periods --- /* Complain if the infinity CHECK constraint is missing. */ FOR r IN SELECT p.table_name, p.infinity_check_constraint FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (p.table_name, p.infinity_check_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in SYSTEM_TIME period', r.infinity_check_constraint, r.table_name; END LOOP; /* Complain if the GENERATED ALWAYS AS ROW START/END trigger is missing. */ FOR r IN SELECT p.table_name, p.generated_always_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.generated_always_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.generated_always_trigger, r.table_name; END LOOP; /* Complain if the write_history trigger is missing. */ FOR r IN SELECT p.table_name, p.write_history_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.write_history_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.write_history_trigger, r.table_name; END LOOP; /* Complain if the TRUNCATE trigger is missing. */ FOR r IN SELECT p.table_name, p.truncate_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.truncate_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.truncate_trigger, r.table_name; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- /* Reject dropping the FOR PORTION OF view. */ FOR r IN SELECT dobj.object_identity FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = fpv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%", call "periods.drop_for_portion_view()" instead', r.object_identity; END LOOP; /* Complain if the FOR PORTION OF trigger is missing. */ FOR r IN SELECT fpv.table_name, fpv.period_name, fpv.view_name, fpv.trigger_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fpv.view_name, fpv.trigger_name)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on view "%" because it is used in FOR PORTION OF view for period "%" on table "%"', r.trigger_name, r.view_name, r.period_name, r.table_name; END LOOP; /* Complain if the table's primary key has been dropped. */ FOR r IN SELECT fpv.table_name, fpv.period_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (fpv.table_name, 'p')) LOOP RAISE EXCEPTION 'cannot drop primary key on table "%" because it has a FOR PORTION OF view for period "%"', r.table_name, r.period_name; END LOOP; --- --- unique_keys --- /* * We don't need to protect the individual columns as long as we protect * the indexes. PostgreSQL will make sure they stick around. */ /* Complain if the indexes implementing our unique indexes are missing. */ FOR r IN SELECT uk.key_name, uk.table_name, uk.unique_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.unique_constraint, r.table_name, r.key_name; END LOOP; FOR r IN SELECT uk.key_name, uk.table_name, uk.exclude_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.exclude_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.exclude_constraint, r.table_name, r.key_name; END LOOP; --- --- foreign_keys --- /* Complain if any of the triggers are missing */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_insert_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_update_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_update_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_delete_trigger, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.history_table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop table "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid IN (sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to) WHERE dobj.object_type = 'function' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop function "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; END; $function$; CREATE OR REPLACE FUNCTION periods.rename_following() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; sql text; BEGIN /* * Anything that is stored by reg* type will auto-adjust, but anything we * store by name will need to be updated after a rename. One way to do this * is to recreate the constraints we have and pull new names out that way. * If we are unable to do something like that, we must raise an exception. */ --- --- periods --- /* * Start and end columns of a period can be found by the bounds check * constraint. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET start_column_name = %L, end_column_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', sa.attname, ea.attname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (p.table_name, p.bounds_check_constraint) JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE (p.start_column_name, p.end_column_name) <> (sa.attname, ea.attname) AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) LOOP EXECUTE sql; END LOOP; /* * Inversely, the bounds check constraint can be retrieved via the start * and end columns. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET bounds_check_constraint = %L WHERE (table_name, period_name) = (%L::regclass, %L)', c.conname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE p.bounds_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) AND (p.start_column_name, p.end_column_name) = (sa.attname, ea.attname) AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (p.table_name, p.bounds_check_constraint)) LOOP EXECUTE sql; END LOOP; --- --- system_time_periods --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET infinity_check_constraint = %L WHERE table_name = %L::regclass', c.conname, p.table_name) FROM periods.periods AS p JOIN periods.system_time_periods AS stp ON (stp.table_name, stp.period_name) = (p.table_name, p.period_name) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE stp.infinity_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I = ''infinity''::%s))', ea.attname, format_type(ea.atttypid, ea.atttypmod)) AND p.end_column_name = ea.attname AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (stp.table_name, stp.infinity_check_constraint)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET generated_always_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.generated_always_trigger AND t.tgfoid = 'periods.generated_always_as_row_start_end()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.generated_always_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET write_history_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.write_history_trigger AND t.tgfoid = 'periods.write_history()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.write_history_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET truncate_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.truncate_trigger AND t.tgfoid = 'periods.truncate_system_versioning()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.truncate_trigger)) LOOP EXECUTE sql; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.for_portion_views SET trigger_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', t.tgname, fpv.table_name, fpv.period_name) FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = fpv.view_name WHERE t.tgname <> fpv.trigger_name AND t.tgfoid = 'periods.update_portion_of()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (fpv.table_name, fpv.trigger_name)) LOOP EXECUTE sql; END LOOP; --- --- unique_keys --- FOR sql IN SELECT format('UPDATE periods.unique_keys SET column_names = %L WHERE key_name = %L', a.column_names, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint) JOIN LATERAL ( SELECT array_agg(a.attname ORDER BY u.ordinality) AS column_names FROM unnest(c.conkey) WITH ORDINALITY AS u (attnum, ordinality) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attnum) = (uk.table_name, u.attnum) WHERE a.attname NOT IN (p.start_column_name, p.end_column_name) ) AS a ON true WHERE uk.column_names <> a.column_names LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET unique_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names || ARRAY[p.start_column_name, p.end_column_name]) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.unique_constraint)) GROUP BY uk.key_name, c.oid, c.conname HAVING format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET exclude_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.exclude_constraint)) GROUP BY uk.key_name, c.oid, c.conname, p.range_type, p.start_column_name, p.end_column_name HAVING format('EXCLUDE USING gist (%s, %I(%I, %I, ''[)''::text) WITH &&)', string_agg(quote_ident(u.column_name) || ' WITH =', ', ' ORDER BY u.ordinality), p.range_type, p.start_column_name, p.end_column_name) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; --- --- foreign_keys --- /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT fk.key_name, fk.table_name, u.column_name FROM periods.foreign_keys AS fk CROSS JOIN LATERAL unnest(fk.column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (fk.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is used in period foreign key "%"', r.column_name, r.table_name, r.key_name; END LOOP; /* * Since there can be multiple foreign keys, there is no reliable way to * know which trigger might belong to what, so just error out. */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) UNION ALL SELECT fk.key_name, fk.table_name, fk.fk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop or rename trigger "%" on table "%" because it is used in period foreign key "%"', r.trigger_name, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- /* Nothing to do here */ END; $function$; CREATE FUNCTION periods.set_system_time_period_excluded_columns( table_name regclass, excluded_column_names name[]) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE excluded_column_name name; BEGIN /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; /* Do it. */ UPDATE periods.system_time_periods AS stp SET excluded_column_names = excluded_column_names WHERE stp.table_name = table_name; END; $function$; CREATE OR REPLACE FUNCTION periods.add_system_versioning( table_class regclass, history_table_name name DEFAULT NULL, view_name name DEFAULT NULL, function_as_of_name name DEFAULT NULL, function_between_name name DEFAULT NULL, function_between_symmetric_name name DEFAULT NULL, function_from_to_name name DEFAULT NULL) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE schema_name name; table_name name; persistence "char"; kind "char"; period_row periods.periods; history_table_id oid; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.29 */ /* Already registered? SQL:2016 11.29 SR 5 */ IF EXISTS (SELECT FROM periods.system_versioning AS r WHERE r.table_name = table_class) THEN RAISE EXCEPTION 'table already has SYSTEM VERSIONING'; END IF; /* Must be a regular persistent base table. SQL:2016 11.29 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* * We could probably accept unlogged tables if the history table is * also unlogged, but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* We need a SYSTEM_TIME period. SQL:2016 11.29 SR 4 */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, 'system_time'); IF NOT FOUND THEN RAISE EXCEPTION 'no period for SYSTEM_TIME found for table %', table_class; END IF; /* Get all of our "fake" infrastructure ready */ history_table_name := coalesce(history_table_name, periods._choose_name(ARRAY[table_name], 'history')); view_name := coalesce(view_name, periods._choose_name(ARRAY[table_name], 'with_history')); function_as_of_name := coalesce(function_as_of_name, periods._choose_name(ARRAY[table_name], '_as_of')); function_between_name := coalesce(function_between_name, periods._choose_name(ARRAY[table_name], '_between')); function_between_symmetric_name := coalesce(function_between_symmetric_name, periods._choose_name(ARRAY[table_name], '_between_symmetric')); function_from_to_name := coalesce(function_from_to_name, periods._choose_name(ARRAY[table_name], '_from_to')); /* * Create the history table. If it already exists we check that all the * columns match but otherwise we trust the user. Perhaps the history * table was disconnected in order to change the schema (a case which is * not defined by the SQL standard). Or perhaps the user wanted to * partition the history table. * * There shouldn't be any concurrency issues here because our main catalog * is locked. */ SELECT c.oid INTO history_table_id FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (n.nspname, c.relname) = (schema_name, history_table_name); IF FOUND THEN /* Don't allow any periods on the system table (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.periods AS p WHERE p.table_name = history_table_id) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* * The query to the attributes is harder than one would think because * we need to account for dropped columns. Basically what we're * looking for is that all columns have the same name, type, and * collation. */ IF EXISTS ( WITH L (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = table_class AND NOT a.attisdropped ), R (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = history_table_id AND NOT a.attisdropped ) SELECT FROM L NATURAL FULL JOIN R WHERE L.attname IS NULL OR R.attname IS NULL) THEN RAISE EXCEPTION 'base table "%" and history table "%" are not compatible', table_class, history_table_id::regclass; END IF; ELSE EXECUTE format('CREATE TABLE %1$I.%2$I (LIKE %1$I.%3$I)', schema_name, history_table_name, table_name); history_table_id := format('%I.%I', schema_name, history_table_name)::regclass; RAISE NOTICE 'history table "%" created for "%", be sure to index it properly', history_table_id::regclass, table_class; END IF; /* Create the "with history" view. This one we do want to error out on if it exists. */ EXECUTE format( /* * The query we really here want is * * CREATE VIEW view_name AS * TABLE table_name * UNION ALL CORRESPONDING * TABLE history_table_name * * but PostgreSQL doesn't support that syntax (yet), so we have to do * it manually. */ 'CREATE VIEW %1$I.%2$I AS SELECT %5$s FROM %1$I.%3$I UNION ALL SELECT %5$s FROM %1$I.%4$I', schema_name, view_name, table_name, history_table_name, (SELECT string_agg(a.attname, ', ' ORDER BY a.attnum) FROM pg_attribute AS a WHERE a.attrelid = table_class AND a.attnum > 0 AND NOT a.attisdropped )); /* * Create functions to simulate the system versioned grammar. These must * be inlinable for any kind of performance. */ EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %4$I <= $1 AND %5$I > $1' $$, schema_name, function_as_of_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 <= $2 AND %5$I > $1 AND %4$I <= $2' $$, schema_name, function_between_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %5$I > least($1, $2) AND %4$I <= greatest($1, $2)' $$, schema_name, function_between_symmetric_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 < $2 AND %5$I > $1 AND %4$I < $2' $$, schema_name, function_from_to_name, view_name, period_row.start_column_name, period_row.end_column_name); /* Register it */ INSERT INTO periods.system_versioning (table_name, period_name, history_table_name, view_name, func_as_of, func_between, func_between_symmetric, func_from_to) VALUES ( table_class, 'system_time', format('%I.%I', schema_name, history_table_name), format('%I.%I', schema_name, view_name), format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure ); END; $function$; periods-1.2.2/periods--1.0.sql000066400000000000000000003521431432551570100157330ustar00rootroot00000000000000-- complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION periods" to load this file. \quit /* This extension is non-relocatable */ CREATE SCHEMA periods; CREATE TYPE periods.drop_behavior AS ENUM ('CASCADE', 'RESTRICT'); CREATE TYPE periods.fk_actions AS ENUM ('CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION'); CREATE TYPE periods.fk_match_types AS ENUM ('FULL', 'PARTIAL', 'SIMPLE'); /* * All referencing columns must be either name or regsomething in order for * pg_dump to work properly. Plain OIDs are not allowed but attribute numbers * are, so that we don't have to track renames. * * Anything declared as regsomething and created for the period (such as the * "__as_of" function), should be UNIQUE. If Postgres already verifies * uniqueness, such as constraint names on a table, then we don't need to do it * also. */ CREATE TABLE periods.periods ( table_name regclass NOT NULL, period_name name NOT NULL, start_column_name name NOT NULL, end_column_name name NOT NULL, range_type regtype NOT NULL, bounds_check_constraint name NOT NULL, PRIMARY KEY (table_name, period_name), CHECK (start_column_name <> end_column_name) ); SELECT pg_catalog.pg_extension_config_dump('periods.periods', ''); CREATE TABLE periods.system_time_periods ( table_name regclass NOT NULL, period_name name NOT NULL, infinity_check_constraint name NOT NULL, generated_always_trigger name NOT NULL, write_history_trigger name NOT NULL, truncate_trigger name NOT NULL, PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time') ); SELECT pg_catalog.pg_extension_config_dump('periods.system_time_periods', ''); COMMENT ON TABLE periods.periods IS 'The main catalog for periods. All "DDL" operations for periods must first take an exclusive lock on this table.'; CREATE VIEW periods.information_schema__periods AS SELECT current_catalog AS table_catalog, n.nspname AS table_schema, c.relname AS table_name, p.period_name, p.start_column_name, p.end_column_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace; CREATE TABLE periods.for_portion_views ( table_name regclass NOT NULL, period_name name NOT NULL, view_name regclass NOT NULL, trigger_name name NOT NULL, PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, UNIQUE (view_name) ); SELECT pg_catalog.pg_extension_config_dump('periods.for_portion_views', ''); CREATE TABLE periods.unique_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_constraint name NOT NULL, exclude_constraint name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods ); SELECT pg_catalog.pg_extension_config_dump('periods.unique_keys', ''); COMMENT ON TABLE periods.unique_keys IS 'A registry of UNIQUE/PRIMARY keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.foreign_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_key name NOT NULL, match_type periods.fk_match_types NOT NULL DEFAULT 'SIMPLE', delete_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', update_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', fk_insert_trigger name NOT NULL, fk_update_trigger name NOT NULL, uk_update_trigger name NOT NULL, uk_delete_trigger name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, FOREIGN KEY (unique_key) REFERENCES periods.unique_keys, CHECK (delete_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')), CHECK (update_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')) ); SELECT pg_catalog.pg_extension_config_dump('periods.foreign_keys', ''); COMMENT ON TABLE periods.foreign_keys IS 'A registry of foreign keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.system_versioning ( table_name regclass NOT NULL, period_name name NOT NULL, history_table_name regclass NOT NULL, view_name regclass NOT NULL, func_as_of regprocedure NOT NULL, func_between regprocedure NOT NULL, func_between_symmetric regprocedure NOT NULL, func_from_to regprocedure NOT NULL, PRIMARY KEY (table_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time'), UNIQUE (history_table_name), UNIQUE (view_name), UNIQUE (func_as_of), UNIQUE (func_between), UNIQUE (func_between_symmetric), UNIQUE (func_from_to) ); SELECT pg_catalog.pg_extension_config_dump('periods.system_versioning', ''); COMMENT ON TABLE periods.system_versioning IS 'A registry of tables with SYSTEM VERSIONING'; /* * These function starting with "_" are private to the periods extension and * should not be called by outsiders. When all the other functions have been * translated to C, they will be removed. */ CREATE FUNCTION periods._serialize(table_name regclass) RETURNS void LANGUAGE sql AS $function$ /* XXX: Is this the best way to do locking? */ SELECT pg_catalog.pg_advisory_xact_lock('periods.periods'::regclass::oid::integer, table_name::oid::integer); $function$; CREATE FUNCTION periods._choose_name(resizable text[], fixed text DEFAULT NULL, separator text DEFAULT '_', extra integer DEFAULT 2) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the resizable texts until they and the fixed text fit in * NAMEDATALEN. This probably isn't very efficient but it's not on a hot * code path so we don't care. */ SELECT max(length(t)) INTO max_length FROM unnest(resizable) AS u (t); LOOP result := format('%s%s', array_to_string(resizable, separator), separator || fixed); IF octet_length(result) <= NAMEDATALEN-extra-1 THEN RETURN result; END IF; max_length := max_length - 1; resizable := ARRAY ( SELECT left(t, max_length) FROM unnest(resizable) WITH ORDINALITY AS u (t, o) ORDER BY o ); END LOOP; END; $function$; CREATE FUNCTION periods._choose_portion_view_name(table_name name, period_name name) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the table and period names until they fit in NAMEDATALEN. This * probably isn't very efficient but it's not on a hot code path so we * don't care. */ max_length := greatest(length(table_name), length(period_name)); LOOP result := format('%s__for_portion_of_%s', table_name, period_name); IF octet_length(result) <= NAMEDATALEN-1 THEN RETURN result; END IF; max_length := max_length - 1; table_name := left(table_name, max_length); period_name := left(period_name, max_length); END LOOP; END; $function$; CREATE FUNCTION periods.add_period( table_name regclass, period_name name, start_column_name name, end_column_name name, range_type regtype DEFAULT NULL, bounds_check_constraint name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE table_name_only name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 11.27 */ /* Don't allow anything on system versioning history tables (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.system_versioning AS sv WHERE sv.history_table_name = table_name) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* Period names are limited to lowercase alphanumeric characters for now */ period_name := lower(period_name); IF period_name !~ '^[a-z_][0-9a-z_]*$' THEN RAISE EXCEPTION 'only alphanumeric characters are currently allowed'; END IF; IF period_name = 'system_time' THEN RETURN periods.add_system_time_period(table_name, start_column_name, end_column_name); END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT c.relpersistence, c.relkind INTO persistence, kind FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_name; END IF; /* * Check if period already exists. Actually no other application time * periods are allowed per spec, but we don't obey that. We can have as * many application time periods as we want. * * SQL:2016 11.27 SR 5.b */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period for "%" already exists on table "%"', period_name, table_name; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 5.c */ IF EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, period_name)) THEN RAISE EXCEPTION 'a column named "%" already exists for table "%"', period_name, table_name; END IF; /* * Contrary to SYSTEM_TIME periods, the columns must exist already for * application time periods. * * SQL:2016 11.27 SR 5.d */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO start_attnum, start_type, start_collation, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, start_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', start_column_name, table_name; END IF; IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO end_attnum, end_type, end_collation, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, end_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', end_column_name, table_name; END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* * Verify compatibility of start/end columns. The standard says these must * be either date or timestamp, but we allow anything with a corresponding * range type because why not. * * SQL:2016 11.27 SR 5.g */ IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; IF start_collation <> end_collation THEN RAISE EXCEPTION 'start and end columns must be of same collation'; END IF; /* Get the range type that goes with these columns */ IF range_type IS NOT NULL THEN IF NOT EXISTS ( SELECT FROM pg_catalog.pg_range AS r WHERE (r.rngtypid, r.rngsubtype, r.rngcollation) = (range_type, start_type, start_collation)) THEN RAISE EXCEPTION 'range "%" does not match data type "%"', range_type, start_type; END IF; ELSE SELECT r.rngtypid INTO range_type FROM pg_catalog.pg_range AS r JOIN pg_catalog.pg_opclass AS c ON c.oid = r.rngsubopc WHERE (r.rngsubtype, r.rngcollation) = (start_type, start_collation) AND c.opcdefault; IF NOT FOUND THEN RAISE EXCEPTION 'no default range type for %', start_type::regtype; END IF; END IF; /* * Period columns must not be nullable. * * SQL:2016 11.27 SR 5.h */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_name; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_name AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name_only FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; bounds_check_constraint := periods._choose_name(ARRAY[table_name_only, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %s %s', table_name, array_to_string(alter_commands, ', ')); END IF; INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); RETURN true; END; $function$; CREATE FUNCTION periods.drop_period(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; system_time_period_row periods.system_time_periods; system_versioning_row periods.system_versioning; portion_view regclass; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * Has the table been dropped already? This could happen if the period is * being dropped by the drop_protection event trigger or through a DROP * CASCADE. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE NOTICE 'period % not found on table %', period_name, table_name; RETURN false; END IF; /* Drop the "for portion" view if it hasn't been dropped already */ PERFORM periods.drop_for_portion_view(table_name, period_name, drop_behavior, purge); /* If this is a system_time period, get rid of the triggers */ DELETE FROM periods.system_time_periods AS stp WHERE stp.table_name = table_name RETURNING stp.* INTO system_time_period_row; IF FOUND AND NOT is_dropped THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, system_time_period_row.infinity_check_constraint); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.generated_always_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.write_history_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.truncate_trigger, table_name); END IF; IF drop_behavior = 'RESTRICT' THEN /* Check for UNIQUE or PRIMARY KEYs */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a UNIQUE or PRIMARY KEY', period_name; END IF; /* Check for FOREIGN KEYs */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a FOREIGN KEY', period_name; END IF; /* Check for SYSTEM VERSIONING */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'table % has SYSTEM VERSIONING', table_name; END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END IF; /* We must be in CASCADE mode now */ PERFORM periods.drop_foreign_key(table_name, fk.key_name) FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name); PERFORM periods.drop_unique_key(table_name, uk.key_name, drop_behavior, purge) FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name); /* * Save ourselves the NOTICE if this table doesn't have SYSTEM * VERSIONING. * * We don't do like above because the purge is different. We don't want * dropping SYSTEM VERSIONING to drop our infinity constraint; only * dropping the PERIOD should do that. */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN PERFORM periods.drop_system_versioning(table_name, drop_behavior, purge); END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END; $function$; CREATE FUNCTION periods.add_system_time_period( table_class regclass, start_column_name name DEFAULT 'system_time_start', end_column_name name DEFAULT 'system_time_end', bounds_check_constraint name DEFAULT NULL, infinity_check_constraint name DEFAULT NULL, generated_always_trigger name DEFAULT NULL, write_history_trigger name DEFAULT NULL, truncate_trigger name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_name CONSTANT name := 'system_time'; schema_name name; table_name name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; DATE_OID CONSTANT integer := 1082; TIMESTAMP_OID CONSTANT integer := 1114; TIMESTAMPTZ_OID CONSTANT integer := 1184; range_type regtype; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.7 * SQL:2016 11.27 */ /* The columns must not be part of UNIQUE keys. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* * Check if period already exists. * * SQL:2016 11.27 SR 4.a */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, period_name)) THEN RAISE EXCEPTION 'period for SYSTEM_TIME already exists on table "%"', table_class; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 4.b */ IF EXISTS (SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, period_name)) THEN RAISE EXCEPTION 'a column named system_time already exists for table "%"', table_class; END IF; /* The standard says that the columns must not exist already, but we don't obey that rule for now. */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO start_attnum, start_type, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); IF NOT FOUND THEN /* * First add the column with DEFAULT of -infinity to fill the * current rows, then replace the DEFAULT with transaction_timestamp() for future * rows. * * The default value is just for self-documentation anyway because * the trigger will enforce the value. */ alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''-infinity''', start_column_name); start_attnum := 0; start_type := 'timestamp with time zone'::regtype; start_notnull := true; END IF; alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT transaction_timestamp()', start_column_name); IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO end_attnum, end_type, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); IF NOT FOUND THEN alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''infinity''', end_column_name); end_attnum := 0; end_type := 'timestamp with time zone'::regtype; end_notnull := true; ELSE alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT ''infinity''', end_column_name); END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Verify compatibility of start/end columns */ IF start_type::regtype NOT IN ('date', 'timestamp without time zone', 'timestamp with time zone') THEN RAISE EXCEPTION 'SYSTEM_TIME periods must be of type "date", "timestamp without time zone", or "timestamp with time zone"'; END IF; IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; /* Get appropriate range type */ CASE start_type WHEN DATE_OID THEN range_type := 'daterange'; WHEN TIMESTAMP_OID THEN range_type := 'tsrange'; WHEN TIMESTAMPTZ_OID THEN range_type := 'tstzrange'; ELSE RAISE EXCEPTION 'unexpected data type: "%"', start_type::regtype; END CASE; /* can't be part of a foreign key */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.table_name = table_class AND fk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* * Period columns must not be nullable. */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; bounds_check_constraint := periods._choose_name(ARRAY[table_name, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* * Find and appropriate a CHECK constraint to make sure that end = 'infinity'. * Create one if necessary. * * SQL:2016 4.15.2.2 */ DECLARE condef CONSTANT text := format('CHECK ((%I = ''infinity''::timestamp with time zone))', end_column_name); context text; BEGIN IF infinity_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, infinity_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', infinity_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO infinity_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; infinity_check_constraint := periods._choose_name(ARRAY[table_name, end_column_name], 'infinity_check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %I.%I %s', schema_name, table_name, array_to_string(alter_commands, ', ')); IF start_attnum = 0 THEN SELECT a.attnum INTO start_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); END IF; IF end_attnum = 0 THEN SELECT a.attnum INTO end_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); END IF; END IF; generated_always_trigger := coalesce( generated_always_trigger, periods._choose_name(ARRAY[table_name], 'system_time_generated_always')); EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.generated_always_as_row_start_end()', generated_always_trigger, table_class); write_history_trigger := coalesce( write_history_trigger, periods._choose_name(ARRAY[table_name], 'system_time_write_history')); EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.write_history()', write_history_trigger, table_class); truncate_trigger := coalesce( truncate_trigger, periods._choose_name(ARRAY[table_name], 'truncate')); EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE PROCEDURE periods.truncate_system_versioning()', truncate_trigger, table_class); INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_class, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); INSERT INTO periods.system_time_periods (table_name, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger) VALUES (table_class, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger); RETURN true; END; $function$; CREATE FUNCTION periods.drop_system_time_period(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE sql AS $function$ SELECT periods.drop_period(table_name, 'system_time', drop_behavior, purge); $function$; CREATE FUNCTION periods.generated_always_as_row_start_end() RETURNS trigger LANGUAGE c STRICT AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.write_history() RETURNS trigger LANGUAGE c STRICT AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.truncate_system_versioning() RETURNS trigger LANGUAGE plpgsql STRICT AS $function$ #variable_conflict use_variable DECLARE history_table_name name; BEGIN SELECT sv.history_table_name INTO history_table_name FROM periods.system_versioning AS sv WHERE sv.table_name = TG_RELID; IF FOUND THEN EXECUTE format('TRUNCATE %s', history_table_name); END IF; RETURN NULL; END; $function$; CREATE FUNCTION periods.add_for_portion_view(table_name regclass DEFAULT NULL, period_name name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; view_name name; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just add the views for that. * * If no period is specified, add the views for all periods of the table. * * If no table is specified, add the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Can't use FOR PORTION OF on SYSTEM_TIME columns */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'cannot use FOR PORTION OF on SYSTEM_TIME periods'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * We require the table to have a primary key, so check to see if there is * one. This requires a lock on the table so no one removes it after we * check and before we commit. */ EXECUTE format('LOCK TABLE %s IN ACCESS SHARE MODE', table_name); /* Now check for the primary key */ IF NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'p')) THEN RAISE EXCEPTION 'table "%" must have a primary key', table_name; END IF; FOR r IN SELECT n.nspname AS schema_name, c.relname AS table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (table_name IS NULL OR p.table_name = table_name) AND (period_name IS NULL OR p.period_name = period_name) AND p.period_name <> 'system_time' AND NOT EXISTS ( SELECT FROM periods.for_portion_views AS _fpv WHERE (_fpv.table_name, _fpv.period_name) = (p.table_name, p.period_name)) LOOP view_name := periods._choose_portion_view_name(r.table_name, r.period_name); trigger_name := 'for_portion_of_' || r.period_name; EXECUTE format('CREATE VIEW %1$I.%2$I AS TABLE %1$I.%3$I', r.schema_name, view_name, r.table_name); EXECUTE format('CREATE TRIGGER %I INSTEAD OF UPDATE ON %I.%I FOR EACH ROW EXECUTE PROCEDURE periods.update_portion_of()', trigger_name, r.schema_name, view_name); INSERT INTO periods.for_portion_views (table_name, period_name, view_name, trigger_name) VALUES (format('%I.%I', r.schema_name, r.table_name), r.period_name, format('%I.%I', r.schema_name, view_name), trigger_name); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.drop_for_portion_view(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE view_name regclass; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just drop the views for that. * * If no period is specified, drop the views for all periods of the table. * * If no table is specified, drop the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR view_name, trigger_name IN DELETE FROM periods.for_portion_views AS fp WHERE (table_name IS NULL OR fp.table_name = table_name) AND (period_name IS NULL OR fp.period_name = period_name) RETURNING fp.view_name, fp.trigger_name LOOP EXECUTE format('DROP TRIGGER %I on %s', trigger_name, view_name); EXECUTE format('DROP VIEW %s %s', view_name, drop_behavior); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.update_portion_of() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE info record; test boolean; generated_columns_sql text; generated_columns text[]; jnew jsonb; fromval jsonb; toval jsonb; jold jsonb; bstartval jsonb; bendval jsonb; pre_row jsonb; new_row jsonb; post_row jsonb; pre_assigned boolean; post_assigned boolean; SERVER_VERSION CONSTANT integer := current_setting('server_version_num')::integer; TEST_SQL CONSTANT text := 'VALUES (CAST(%2$L AS %1$s) < CAST(%3$L AS %1$s) AND ' ' CAST(%3$L AS %1$s) < CAST(%4$L AS %1$s))'; GENERATED_COLUMNS_SQL_PRE_10 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_PRE_12 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_CURRENT CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR a.attgenerated <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; BEGIN /* * REFERENCES: * SQL:2016 15.13 GR 10 */ /* Get the table information from this view */ SELECT p.table_name, p.period_name, p.start_column_name, p.end_column_name, format_type(a.atttypid, a.atttypmod) AS datatype INTO info FROM periods.for_portion_views AS fpv JOIN periods.periods AS p ON (p.table_name, p.period_name) = (fpv.table_name, fpv.period_name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (p.table_name, p.start_column_name) WHERE fpv.view_name = TG_RELID; IF NOT FOUND THEN RAISE EXCEPTION 'table and period information not found for view "%"', TG_RELID::regclass; END IF; jnew := row_to_json(NEW); fromval := jnew->info.start_column_name; toval := jnew->info.end_column_name; jold := row_to_json(OLD); bstartval := jold->info.start_column_name; bendval := jold->info.end_column_name; pre_row := jold; new_row := jnew; post_row := jold; /* Reset the period columns */ new_row := jsonb_set(new_row, ARRAY[info.start_column_name], bstartval); new_row := jsonb_set(new_row, ARRAY[info.end_column_name], bendval); /* If the period is the only thing changed, do nothing */ IF new_row = jold THEN RETURN NULL; END IF; pre_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, fromval, bendval) INTO test; IF test THEN pre_assigned := true; pre_row := jsonb_set(pre_row, ARRAY[info.end_column_name], fromval); new_row := jsonb_set(new_row, ARRAY[info.start_column_name], fromval); END IF; post_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, toval, bendval) INTO test; IF test THEN post_assigned := true; new_row := jsonb_set(new_row, ARRAY[info.end_column_name], toval::jsonb); post_row := jsonb_set(post_row, ARRAY[info.start_column_name], toval::jsonb); END IF; IF pre_assigned OR post_assigned THEN /* Don't validate foreign keys until all this is done */ SET CONSTRAINTS ALL DEFERRED; /* * Find and remove all generated columns from pre_row and post_row. * SQL:2016 15.13 GR 10)b)i) * * We also remove columns that own a sequence as those are a form of * generated column. We do not, however, remove columns that default * to nextval() without owning the underlying sequence. * * Columns belonging to a SYSTEM_TIME period are also removed. * * In addition to what the standard calls for, we also remove any * columns belonging to primary keys. */ IF SERVER_VERSION < 100000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_10; ELSIF SERVER_VERSION < 120000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_12; ELSE generated_columns_sql := GENERATED_COLUMNS_SQL_CURRENT; END IF; EXECUTE generated_columns_sql INTO generated_columns USING info.table_name; /* There may not be any generated columns. */ IF generated_columns IS NOT NULL THEN IF SERVER_VERSION < 100000 THEN SELECT jsonb_object_agg(e.key, e.value) INTO pre_row FROM jsonb_each(pre_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); SELECT jsonb_object_agg(e.key, e.value) INTO post_row FROM jsonb_each(post_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); ELSE pre_row := pre_row - generated_columns; post_row := post_row - generated_columns; END IF; END IF; END IF; IF pre_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(pre_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(pre_row))); END IF; EXECUTE format('UPDATE %s SET %s WHERE %s AND %I > %L AND %I < %L', info.table_name, (SELECT string_agg(format('%I = %L', j.key, j.value), ', ') FROM (SELECT key, value FROM jsonb_each_text(new_row) EXCEPT ALL SELECT key, value FROM jsonb_each_text(jold) ) AS j ), (SELECT string_agg(format('%I = %L', key, value), ' AND ') FROM pg_catalog.jsonb_each_text(jold) AS j JOIN pg_catalog.pg_attribute AS a ON a.attname = j.key JOIN pg_catalog.pg_constraint AS c ON c.conkey @> ARRAY[a.attnum] WHERE a.attrelid = info.table_name AND c.conrelid = info.table_name ), info.end_column_name, fromval, info.start_column_name, toval ); IF post_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(post_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(post_row))); END IF; RETURN NEW; END; $function$; CREATE FUNCTION periods.add_unique_key( table_name regclass, column_names name[], period_name name, key_name name DEFAULT NULL, unique_constraint name DEFAULT NULL, exclude_constraint name DEFAULT NULL) RETURNS name LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; column_attnums smallint[]; period_attnums smallint[]; idx integer; constraint_record record; pass integer; sql text; alter_cmds text[]; unique_index regclass; exclude_index regclass; unique_sql text; exclude_sql text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in UNIQUE constraints. SQL:2016 11.7 SR 5)b) */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* For convenience, put the period's attnums in an array */ period_attnums := ARRAY[ (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.start_column_name)), (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.end_column_name)) ]; /* Get attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a UNIQUE * key. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* If we were given a unique constraint to use, look it up and make sure it matches */ SELECT format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) INTO unique_sql FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); IF unique_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, c.conkey INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, unique_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', unique_constraint; END IF; IF constraint_record.contype NOT IN ('p', 'u') THEN RAISE EXCEPTION 'constraint "%" is not a PRIMARY KEY or UNIQUE KEY', unique_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', unique_constraint; END IF; IF NOT constraint_record.conkey = column_attnums || period_attnums THEN RAISE EXCEPTION 'constraint "%" does not match', unique_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * If we were given an exclude constraint to use, look it up and make sure * it matches. We do that by generating the text that we expect * pg_get_constraintdef() to output and compare against that instead of * trying to deal with the internally stored components like we did for the * UNIQUE constraint. * * We will use this same text to create the constraint if it doesn't exist. */ DECLARE withs text[]; BEGIN SELECT array_agg(format('%I WITH =', column_name) ORDER BY n.ordinality) INTO withs FROM unnest(column_names) WITH ORDINALITY AS n (column_name, ordinality); withs := withs || format('%I(%I, %I, ''[)''::text) WITH &&', period_row.range_type, period_row.start_column_name, period_row.end_column_name); exclude_sql := format('EXCLUDE USING gist (%s)', array_to_string(withs, ', ')); END; IF exclude_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, pg_catalog.pg_get_constraintdef(c.oid) AS definition INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, exclude_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', exclude_constraint; END IF; IF constraint_record.contype <> 'x' THEN RAISE EXCEPTION 'constraint "%" is not an EXCLUDE constraint', exclude_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', exclude_constraint; END IF; IF constraint_record.definition <> exclude_sql THEN RAISE EXCEPTION 'constraint "%" does not match', exclude_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * Generate a name for the unique constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* Time to make the underlying constraints */ alter_cmds := '{}'; IF unique_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || unique_sql); END IF; IF exclude_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || exclude_sql); END IF; IF alter_cmds <> '{}' THEN SELECT format('ALTER TABLE %I.%I %s', n.nspname, c.relname, array_to_string(alter_cmds, ', ')) INTO sql FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_name; EXECUTE sql; END IF; /* If we don't already have a unique_constraint, it must be the one with the highest oid */ IF unique_constraint IS NULL THEN SELECT c.conname, c.conindid INTO unique_constraint, unique_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'u') ORDER BY oid DESC LIMIT 1; END IF; /* If we don't already have an exclude_constraint, it must be the one with the highest oid */ IF exclude_constraint IS NULL THEN SELECT c.conname, c.conindid INTO exclude_constraint, exclude_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'x') ORDER BY oid DESC LIMIT 1; END IF; INSERT INTO periods.unique_keys (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint) VALUES (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_unique_key(table_name regclass, key_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_key_row periods.unique_keys; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR unique_key_row IN SELECT uk.* FROM periods.unique_keys AS uk WHERE uk.table_name = table_name AND (uk.key_name = key_name OR key_name IS NULL) LOOP /* Cascade to foreign keys, if desired */ FOR foreign_key_row IN SELECT fk.key_name FROM periods.foreign_keys AS fk WHERE fk.unique_key = unique_key_row.key_name LOOP IF drop_behavior = 'RESTRICT' THEN RAISE EXCEPTION 'cannot drop unique key "%" because foreign key "%" on table "%" depends on it', unique_key_row.key_name, foreign_key_row.key_name, foreign_key_row.table_name; END IF; PERFORM periods.drop_foreign_key(NULL, foreign_key_row.key_name); END LOOP; DELETE FROM periods.unique_keys AS uk WHERE uk.key_name = unique_key_row.key_name; /* If purging, drop the underlying constraints unless the table has been dropped */ IF purge AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_key_row.table_name) THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, DROP CONSTRAINT %I', unique_key_row.table_name, unique_key_row.unique_constraint, unique_key_row.exclude_constraint); END IF; END LOOP; END; $function$; CREATE FUNCTION periods.uk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is updated. It checks to verify that the referenced table still * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * If this is a NO ACTION constraint, we need to check if there is a new * row that still satisfies the constraint, in which case there is no * error. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, true); RETURN NULL; END; $function$; CREATE FUNCTION periods.uk_delete_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is deleted from. It checks to verify that the referenced table * still contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * The only difference between NO ACTION and RESTRICT is when the check is * done, so this function is used for both. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, false); RETURN NULL; END; $function$; CREATE FUNCTION periods.add_foreign_key( table_name regclass, column_names name[], period_name name, ref_unique_name name, match_type periods.fk_match_types DEFAULT 'SIMPLE', update_action periods.fk_actions DEFAULT 'NO ACTION', delete_action periods.fk_actions DEFAULT 'NO ACTION', key_name name DEFAULT NULL, fk_insert_trigger name DEFAULT NULL, fk_update_trigger name DEFAULT NULL, uk_update_trigger name DEFAULT NULL, uk_delete_trigger name DEFAULT NULL) RETURNS name LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; ref_period_row periods.periods; unique_row periods.unique_keys; column_attnums smallint[]; idx integer; pass integer; upd_action text DEFAULT ''; del_action text DEFAULT ''; foreign_columns text; unique_columns text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Get the period involved */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in referential constraints. SQL:2016 11.8 SR 10 */ IF period_row.period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in foreign keys'; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a foreign * key. SQL:2016 11.8 SR 10 */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Get column attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* Columns can't be part of any SYSTEM_TIME period */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* Get the unique key we're linking to */ SELECT uk.* INTO unique_row FROM periods.unique_keys AS uk WHERE uk.key_name = ref_unique_name; IF NOT FOUND THEN RAISE EXCEPTION 'unique key "%" does not exist', ref_unique_name; END IF; /* Get the unique key's period */ SELECT p.* INTO ref_period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (unique_row.table_name, unique_row.period_name); IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types "%" and "%" are incompatible', period_row.period_name, ref_period_row.period_name; END IF; /* Check that all the columns match */ IF EXISTS ( SELECT FROM unnest(column_names, unique_row.column_names) AS u (fk_attname, uk_attname) JOIN pg_catalog.pg_attribute AS fa ON (fa.attrelid, fa.attname) = (table_name, u.fk_attname) JOIN pg_catalog.pg_attribute AS ua ON (ua.attrelid, ua.attname) = (unique_row.table_name, u.uk_attname) WHERE (fa.atttypid, fa.atttypmod, fa.attcollation) <> (ua.atttypid, ua.atttypmod, ua.attcollation)) THEN RAISE EXCEPTION 'column types do not match'; END IF; /* The range types must match, too */ IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types do not match'; END IF; /* * Generate a name for the foreign constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* See if we're deferring the constraints or not */ IF update_action = 'NO ACTION' THEN upd_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; IF delete_action = 'NO ACTION' THEN del_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; /* Get the columns that require checking the constraint */ SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO foreign_columns FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO unique_columns FROM unnest(unique_row.column_names || ref_period_row.start_column_name || ref_period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); /* Time to make the underlying triggers */ fk_insert_trigger := coalesce(fk_insert_trigger, periods._choose_name(ARRAY[key_name], 'fk_insert')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER INSERT ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_insert_check(%L)', fk_insert_trigger, table_name, unique_row.table_name, key_name); fk_update_trigger := coalesce(fk_update_trigger, periods._choose_name(ARRAY[key_name], 'fk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_update_check(%L)', fk_update_trigger, foreign_columns, table_name, unique_row.table_name, key_name); uk_update_trigger := coalesce(uk_update_trigger, periods._choose_name(ARRAY[key_name], 'uk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_update_check(%L)', uk_update_trigger, unique_columns, unique_row.table_name, table_name, upd_action, key_name); uk_delete_trigger := coalesce(uk_delete_trigger, periods._choose_name(ARRAY[key_name], 'uk_delete')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER DELETE ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_delete_check(%L)', uk_delete_trigger, unique_row.table_name, table_name, del_action, key_name); INSERT INTO periods.foreign_keys (key_name, table_name, column_names, period_name, unique_key, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger) VALUES (key_name, table_name, column_names, period_name, unique_row.key_name, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger); /* Validate the constraint on existing data */ PERFORM periods.validate_foreign_key_new_row(key_name, NULL); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_foreign_key(table_name regclass, key_name name) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_table_name regclass; BEGIN IF table_name IS NULL AND key_name IS NULL THEN RAISE EXCEPTION 'no table or key name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR foreign_key_row IN SELECT fk.* FROM periods.foreign_keys AS fk WHERE (fk.table_name = table_name OR table_name IS NULL) AND (fk.key_name = key_name OR key_name IS NULL) LOOP DELETE FROM periods.foreign_keys AS fk WHERE fk.key_name = foreign_key_row.key_name; /* * Make sure the table hasn't been dropped and that the triggers exist * before doing these. We could use the IF EXISTS clause but we don't * in order to avoid the NOTICE. */ IF EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = foreign_key_row.table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = foreign_key_row.table_name AND t.tgname IN (foreign_key_row.fk_insert_trigger, foreign_key_row.fk_update_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_insert_trigger, foreign_key_row.table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_update_trigger, foreign_key_row.table_name); END IF; SELECT uk.table_name INTO unique_table_name FROM periods.unique_keys AS uk WHERE uk.key_name = foreign_key_row.unique_key; /* Ditto for the UNIQUE side. */ IF FOUND AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = unique_table_name AND t.tgname IN (foreign_key_row.uk_update_trigger, foreign_key_row.uk_delete_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_update_trigger, unique_table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_delete_trigger, unique_table_name); END IF; END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.fk_insert_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a new row is inserted into a table * containing foreign keys with periods. It checks to verify that the * referenced table contains the proper data to satisfy the foreign key * constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; CREATE FUNCTION periods.fk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a table containing foreign keys with * periods is updated. It checks to verify that the referenced table * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_old_row(foreign_key_name name, row_data jsonb, is_update boolean) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; column_name name; has_nulls boolean; uk_column_names text[]; uk_column_values text[]; fk_column_names text; violation boolean; still_matches boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %1$I.%2$I AS t ' ' WHERE ROW(%3$s) = ROW(%6$s) ' ' AND t.%4$I <= %7$L ' ' AND t.%5$I >= %8$L ' '%9$s' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, uc.oid AS uk_table_oid, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; FOREACH column_name IN ARRAY foreign_key_info.uk_column_names LOOP IF row_data->>column_name IS NULL THEN /* * If the deleted row had nulls in the referenced columns then * there was no possible referencing row (until we implement * PARTIAL) so we can just stop here. */ RETURN true; END IF; uk_column_names := uk_column_names || ('t.' || quote_ident(column_name)); uk_column_values := uk_column_values || quote_literal(row_data->>column_name); END LOOP; IF is_update AND foreign_key_info.update_action = 'NO ACTION' THEN EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, array_to_string(uk_column_names, ', '), foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, 'FOR KEY SHARE') INTO still_matches; IF still_matches THEN RETURN true; END IF; END IF; SELECT string_agg('t.' || quote_ident(u.c), ', ' ORDER BY u.ordinality) INTO fk_column_names FROM unnest(foreign_key_info.fk_column_names) WITH ORDINALITY AS u (c, ordinality); EXECUTE format(QSQL, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, fk_column_names, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, '') INTO violation; IF violation THEN RAISE EXCEPTION 'update or delete on table "%" violates foreign key constraint "%" on table "%"', foreign_key_info.uk_table_oid::regclass, foreign_key_name, foreign_key_info.fk_table_oid::regclass; END IF; RETURN true; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_new_row(foreign_key_name name, row_data jsonb) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; row_clause text DEFAULT 'true'; violation boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %5$I.%6$I AS fk ' ' WHERE NOT EXISTS ( ' ' SELECT FROM (SELECT uk.uk_start_value, ' ' uk.uk_end_value, ' ' nullif(lag(uk.uk_end_value) OVER (ORDER BY uk.uk_start_value), uk.uk_start_value) AS x ' ' FROM (SELECT uk.%3$I AS uk_start_value, ' ' uk.%4$I AS uk_end_value ' ' FROM %1$I.%2$I AS uk ' ' WHERE %9$s ' ' AND uk.%3$I <= fk.%8$I ' ' AND uk.%4$I >= fk.%7$I ' ' FOR KEY SHARE ' ' ) AS uk ' ' ) AS uk ' ' WHERE uk.uk_start_value < fk.%8$I ' ' AND uk.uk_end_value >= fk.%7$I ' ' HAVING min(uk.uk_start_value) <= fk.%7$I ' ' AND max(uk.uk_end_value) >= fk.%8$I ' ' AND array_agg(uk.x) FILTER (WHERE uk.x IS NOT NULL) IS NULL ' ' ) AND %10$s ' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; /* * Now that we have all of our names, we can see if there are any nulls in * the row we were given (if we were given one). */ IF row_data IS NOT NULL THEN DECLARE column_name name; has_nulls boolean; all_nulls boolean; cols text[] DEFAULT '{}'; vals text[] DEFAULT '{}'; BEGIN FOREACH column_name IN ARRAY foreign_key_info.fk_column_names LOOP has_nulls := has_nulls OR row_data->>column_name IS NULL; all_nulls := all_nulls IS NOT false AND row_data->>column_name IS NULL; cols := cols || ('fk.' || quote_ident(column_name)); vals := vals || quote_literal(row_data->>column_name); END LOOP; IF all_nulls THEN /* * If there are no values at all, all three types pass. * * Period columns are by definition NOT NULL so the FULL MATCH * type is only concerned with the non-period columns of the * constraint. SQL:2016 4.23.3.3 */ RETURN true; END IF; IF has_nulls THEN CASE foreign_key_info.match_type WHEN 'SIMPLE' THEN RETURN true; WHEN 'PARTIAL' THEN RAISE EXCEPTION 'partial not implemented'; WHEN 'FULL' THEN RAISE EXCEPTION 'foreign key violated (nulls in FULL)'; END CASE; END IF; row_clause := format(' (%s) = (%s)', array_to_string(cols, ', '), array_to_string(vals, ', ')); END; END IF; EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, (SELECT string_agg(format('%I = %I', ukc, fkc), ' AND ') FROM unnest(foreign_key_info.uk_column_names, foreign_key_info.fk_column_names) AS u (ukc, fkc) ), row_clause) INTO violation; IF violation THEN IF row_data IS NULL THEN RAISE EXCEPTION 'foreign key violated by some row'; ELSE RAISE EXCEPTION 'insert or update on table "%" violates foreign key constraint "%"', foreign_key_info.fk_table_oid::regclass, foreign_key_name; END IF; END IF; RETURN true; END; $function$; CREATE FUNCTION periods.add_system_versioning( table_class regclass, history_table_name name DEFAULT NULL, view_name name DEFAULT NULL, function_as_of_name name DEFAULT NULL, function_between_name name DEFAULT NULL, function_between_symmetric_name name DEFAULT NULL, function_from_to_name name DEFAULT NULL) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE schema_name name; table_name name; persistence "char"; kind "char"; period_row periods.periods; history_table_id oid; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.29 */ /* Already registered? SQL:2016 11.29 SR 5 */ IF EXISTS (SELECT FROM periods.system_versioning AS r WHERE r.table_name = table_class) THEN RAISE EXCEPTION 'table already has SYSTEM VERSIONING'; END IF; /* Must be a regular persistent base table. SQL:2016 11.29 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* * We could probably accept unlogged tables if the history table is * also unlogged, but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* We need a SYSTEM_TIME period. SQL:2016 11.29 SR 4 */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, 'system_time'); IF NOT FOUND THEN RAISE EXCEPTION 'no period for SYSTEM_TIME found for table %', table_class; END IF; /* Get all of our "fake" infrastructure ready */ history_table_name := coalesce(history_table_name, periods._choose_name(ARRAY[table_name], 'history')); view_name := coalesce(view_name, periods._choose_name(ARRAY[table_name], 'with_history')); function_as_of_name := coalesce(function_as_of_name, periods._choose_name(ARRAY[table_name], '_as_of')); function_between_name := coalesce(function_between_name, periods._choose_name(ARRAY[table_name], '_between')); function_between_symmetric_name := coalesce(function_between_symmetric_name, periods._choose_name(ARRAY[table_name], '_between_symmetric')); function_from_to_name := coalesce(function_from_to_name, periods._choose_name(ARRAY[table_name], '_from_to')); /* * Create the history table. If it already exists we check that all the * columns match but otherwise we trust the user. Perhaps the history * table was disconnected in order to change the schema (a case which is * not defined by the SQL standard). Or perhaps the user wanted to * partition the history table. * * There shouldn't be any concurrency issues here because our main catalog * is locked. */ SELECT c.oid INTO history_table_id FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (n.nspname, c.relname) = (schema_name, history_table_name); IF FOUND THEN /* Don't allow any periods on the system table (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.periods AS p WHERE p.table_name = history_table_id) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* * The query to the attributes is harder than one would think because * we need to account for dropped columns. Basically what we're * looking for is that all columns have the same order, name, type, and * collation. */ IF EXISTS ( WITH L (attnum, attname, atttypid, atttypmod, attcollation) AS ( SELECT row_number() OVER (ORDER BY a.attnum), a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = table_class AND NOT a.attisdropped ), R (attnum, attname, atttypid, atttypmod, attcollation) AS ( SELECT row_number() OVER (ORDER BY a.attnum), a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = history_table_id AND NOT a.attisdropped ) SELECT FROM L NATURAL FULL JOIN R WHERE L.attnum IS NULL OR R.attnum IS NULL) THEN RAISE EXCEPTION 'base table "%" and history table "%" are not compatible', table_class, history_table_id::regclass; END IF; ELSE EXECUTE format('CREATE TABLE %1$I.%2$I (LIKE %1$I.%3$I)', schema_name, history_table_name, table_name); history_table_id := format('%I.%I', schema_name, history_table_name)::regclass; RAISE NOTICE 'history table "%" created for "%", be sure to index it properly', history_table_id::regclass, table_class; END IF; /* Create the "with history" view. This one we do want to error out on if it exists. */ EXECUTE format( 'CREATE VIEW %1$I.%2$I AS TABLE %1$I.%3$I UNION ALL TABLE %1$I.%4$I', schema_name, view_name, table_name, history_table_name); /* * Create functions to simulate the system versioned grammar. These must * be inlinable for any kind of performance. */ EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %4$I <= $1 AND %5$I > $1' $$, schema_name, function_as_of_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 <= $2 AND %5$I > $1 AND %4$I <= $2' $$, schema_name, function_between_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %5$I > least($1, $2) AND %4$I <= greatest($1, $2)' $$, schema_name, function_between_symmetric_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 < $2 AND %5$I > $1 AND %4$I < $2' $$, schema_name, function_from_to_name, view_name, period_row.start_column_name, period_row.end_column_name); /* Register it */ INSERT INTO periods.system_versioning (table_name, period_name, history_table_name, view_name, func_as_of, func_between, func_between_symmetric, func_from_to) VALUES ( table_class, 'system_time', format('%I.%I', schema_name, history_table_name), format('%I.%I', schema_name, view_name), format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure ); END; $function$; CREATE FUNCTION periods.drop_system_versioning(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE system_versioning_row periods.system_versioning; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.30 */ /* * We need to delete our row first so that the DROP protection doesn't * block us. */ DELETE FROM periods.system_versioning AS sv WHERE sv.table_name = table_name RETURNING * INTO system_versioning_row; IF NOT FOUND THEN RAISE NOTICE 'table % does not have SYSTEM VERSIONING', table_name; RETURN false; END IF; /* * Has the table been dropped? If so, everything else is also dropped * except for the history table. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); IF NOT is_dropped THEN /* Drop the functions. */ EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_as_of::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between_symmetric::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_from_to::regprocedure, drop_behavior); /* Drop the "with_history" view. */ EXECUTE format('DROP VIEW %s %s', system_versioning_row.view_name, drop_behavior); END IF; /* * SQL:2016 11.30 GR 2 says "Every row of T that corresponds to a * historical system row is effectively deleted at the end of the SQL- * statement." but we leave the history table intact in case the user * merely wants to make some DDL changes and hook things back up again. * * The purge parameter tells us that the user really wants to get rid of it * all. */ IF NOT is_dropped AND purge THEN PERFORM periods.drop_period(table_name, 'system_time', drop_behavior, purge); EXECUTE format('DROP TABLE %s %s', system_versioning_row.history_table_name, drop_behavior); END IF; RETURN true; END; $function$; CREATE FUNCTION periods.drop_protection() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; table_name regclass; period_name name; BEGIN /* * This function is called after the fact, so we have to just look to see * if anything is missing in the catalogs if we just store the name and not * a reg* type. */ --- --- periods --- /* If one of our tables is being dropped, remove references to it */ FOR table_name, period_name IN SELECT p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP PERFORM periods.drop_period(table_name, period_name, 'CASCADE', true); END LOOP; /* * If a column belonging to one of our periods is dropped, we need to reject that. * SQL:2016 11.23 SR 6 */ FOR r IN SELECT dobj.object_identity, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_attribute AS sa ON (sa.attrelid, sa.attname) = (p.table_name, p.start_column_name) JOIN pg_catalog.pg_attribute AS ea ON (ea.attrelid, ea.attname) = (p.table_name, p.end_column_name) JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name AND dobj.objsubid IN (sa.attnum, ea.attnum) WHERE dobj.object_type = 'table column' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop column "%" because it is part of the period "%"', r.object_identity, r.period_name; END LOOP; /* Also reject dropping the rangetype */ FOR r IN SELECT dobj.object_identity, p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.range_type ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop rangetype "%" because it is used in period "%" on table "%"', r.object_identity, r.period_name, r.table_name; END LOOP; --- --- system_time_periods --- /* Complain if the infinity CHECK constraint is missing. */ FOR r IN SELECT p.table_name, p.infinity_check_constraint FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (p.table_name, p.infinity_check_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in SYSTEM_TIME period', r.infinity_check_constraint, r.table_name; END LOOP; /* Complain if the GENERATED ALWAYS AS ROW START/END trigger is missing. */ FOR r IN SELECT p.table_name, p.generated_always_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.generated_always_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.generated_always_trigger, r.table_name; END LOOP; /* Complain if the write_history trigger is missing. */ FOR r IN SELECT p.table_name, p.write_history_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.write_history_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.write_history_trigger, r.table_name; END LOOP; /* Complain if the TRUNCATE trigger is missing. */ FOR r IN SELECT p.table_name, p.truncate_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.truncate_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.truncate_trigger, r.table_name; END LOOP; --- --- for_portion_views --- /* Reject dropping the FOR PORTION OF view. */ FOR r IN SELECT dobj.object_identity FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = fpv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%", call "periods.drop_for_portion_view()" instead', r.object_identity; END LOOP; /* Complain if the FOR PORTION OF trigger is missing. */ FOR r IN SELECT fpv.table_name, fpv.period_name, fpv.view_name, fpv.trigger_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fpv.view_name, fpv.trigger_name)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on view "%" because it is used in FOR PORTION OF view for period "%" on table "%"', r.trigger_name, r.view_name, r.period_name, r.table_name; END LOOP; /* Complain if the table's primary key has been dropped. */ FOR r IN SELECT fpv.table_name, fpv.period_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (fpv.table_name, 'p')) LOOP RAISE EXCEPTION 'cannot drop primary key on table "%" because it has a FOR PORTION OF view for period "%"', r.table_name, r.period_name; END LOOP; --- --- unique_keys --- /* * We don't need to protect the individual columns as long as we protect * the indexes. PostgreSQL will make sure they stick around. */ /* Complain if the indexes implementing our unique indexes are missing. */ FOR r IN SELECT uk.key_name, uk.table_name, uk.unique_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.unique_constraint, r.table_name, r.key_name; END LOOP; FOR r IN SELECT uk.key_name, uk.table_name, uk.exclude_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.exclude_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.exclude_constraint, r.table_name, r.key_name; END LOOP; --- --- foreign_keys --- /* Complain if any of the triggers are missing */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_insert_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_update_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_update_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_delete_trigger, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.history_table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop table "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid IN (sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to) WHERE dobj.object_type = 'function' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop function "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; END; $function$; CREATE EVENT TRIGGER periods_drop_protection ON sql_drop EXECUTE PROCEDURE periods.drop_protection(); CREATE FUNCTION periods.rename_following() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; sql text; BEGIN /* * Anything that is stored by reg* type will auto-adjust, but anything we * store by name will need to be updated after a rename. One way to do this * is to recreate the constraints we have and pull new names out that way. * If we are unable to do something like that, we must raise an exception. */ --- --- periods --- /* * Start and end columns of a period can be found by the bounds check * constraint. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET start_column_name = %L, end_column_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', sa.attname, ea.attname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (p.table_name, p.bounds_check_constraint) JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE (p.start_column_name, p.end_column_name) <> (sa.attname, ea.attname) AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) LOOP EXECUTE sql; END LOOP; /* * Inversely, the bounds check constraint can be retrieved via the start * and end columns. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET bounds_check_constraint = %L WHERE (table_name, period_name) = (%L::regclass, %L)', c.conname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE p.bounds_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) AND (p.start_column_name, p.end_column_name) = (sa.attname, ea.attname) AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (p.table_name, p.bounds_check_constraint)) LOOP EXECUTE sql; END LOOP; --- --- system_time_periods --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET infinity_check_constraint = %L WHERE table_name = %L::regclass', c.conname, p.table_name) FROM periods.periods AS p JOIN periods.system_time_periods AS stp ON (stp.table_name, stp.period_name) = (p.table_name, p.period_name) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE stp.infinity_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I = ''infinity''::%s))', ea.attname, format_type(ea.atttypid, ea.atttypmod)) AND p.end_column_name = ea.attname AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (stp.table_name, stp.infinity_check_constraint)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET generated_always_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.generated_always_trigger AND t.tgfoid = 'periods.generated_always_as_row_start_end()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.generated_always_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET write_history_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.write_history_trigger AND t.tgfoid = 'periods.write_history()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.write_history_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET truncate_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.truncate_trigger AND t.tgfoid = 'periods.truncate_system_versioning()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.truncate_trigger)) LOOP EXECUTE sql; END LOOP; --- --- for_portion_views --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.for_portion_views SET trigger_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', t.tgname, fpv.table_name, fpv.period_name) FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = fpv.view_name WHERE t.tgname <> fpv.trigger_name AND t.tgfoid = 'periods.update_portion_of()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (fpv.table_name, fpv.trigger_name)) LOOP EXECUTE sql; END LOOP; --- --- unique_keys --- FOR sql IN SELECT format('UPDATE periods.unique_keys SET column_names = %L WHERE key_name = %L', a.column_names, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint) JOIN LATERAL ( SELECT array_agg(a.attname ORDER BY u.ordinality) AS column_names FROM unnest(c.conkey) WITH ORDINALITY AS u (attnum, ordinality) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attnum) = (uk.table_name, u.attnum) WHERE a.attname NOT IN (p.start_column_name, p.end_column_name) ) AS a ON true WHERE uk.column_names <> a.column_names LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET unique_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names || ARRAY[p.start_column_name, p.end_column_name]) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.unique_constraint)) GROUP BY uk.key_name, c.oid, c.conname HAVING format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET exclude_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.exclude_constraint)) GROUP BY uk.key_name, c.oid, c.conname, p.range_type, p.start_column_name, p.end_column_name HAVING format('EXCLUDE USING gist (%s, %I(%I, %I, ''[)''::text) WITH &&)', string_agg(quote_ident(u.column_name) || ' WITH =', ', ' ORDER BY u.ordinality), p.range_type, p.start_column_name, p.end_column_name) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; --- --- foreign_keys --- /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT fk.key_name, fk.table_name, u.column_name FROM periods.foreign_keys AS fk CROSS JOIN LATERAL unnest(fk.column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (fk.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is used in period foreign key "%"', r.column_name, r.table_name, r.key_name; END LOOP; /* * Since there can be multiple foreign keys, there is no reliable way to * know which trigger might belong to what, so just error out. */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) UNION ALL SELECT fk.key_name, fk.table_name, fk.fk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop or rename trigger "%" on table "%" because it is used in period foreign key "%"', r.trigger_name, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- /* Nothing to do here */ END; $function$; CREATE EVENT TRIGGER periods_rename_following ON ddl_command_end EXECUTE PROCEDURE periods.rename_following(); CREATE FUNCTION periods.health_checks() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; BEGIN /* Make sure that all of our tables are still persistent */ FOR r IN SELECT p.table_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* And the history tables, too */ FOR r IN SELECT sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_class AS c ON c.oid = sv.history_table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'history table "%" must remain persistent because it has periods', r.table_name; END LOOP; END; $function$; CREATE EVENT TRIGGER periods_health_checks ON ddl_command_end EXECUTE PROCEDURE periods.health_checks(); /* Predicates */ CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, ve anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= ve AND ev1 > ve; $function$; CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= sv2 AND ev1 >= ev2; $function$; CREATE FUNCTION periods.equals(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = sv2 AND ev1 = ev2; $function$; CREATE FUNCTION periods.overlaps(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 < ev2 AND ev1 > sv2; $function$; CREATE FUNCTION periods.precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 <= sv2; $function$; CREATE FUNCTION periods.succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 >= ev2; $function$; CREATE FUNCTION periods.immediately_precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 = sv2; $function$; CREATE FUNCTION periods.immediately_succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = ev2; $function$; periods-1.2.2/periods--1.1--1.2.sql000066400000000000000000001271561432551570100163130ustar00rootroot00000000000000/* Fix up access controls */ GRANT USAGE ON SCHEMA periods TO PUBLIC; REVOKE ALL ON TABLE periods.periods, periods.system_time_periods, periods.for_portion_views, periods.unique_keys, periods.foreign_keys, periods.system_versioning FROM PUBLIC; GRANT SELECT ON TABLE periods.periods, periods.system_time_periods, periods.for_portion_views, periods.unique_keys, periods.foreign_keys, periods.system_versioning TO PUBLIC; ALTER TABLE periods.system_versioning ALTER COLUMN func_as_of SET DATA TYPE text, ALTER COLUMN func_between SET DATA TYPE text, ALTER COLUMN func_between_symmetric SET DATA TYPE text, ALTER COLUMN func_from_to SET DATA TYPE text ; ALTER FUNCTION periods.add_for_portion_view(regclass,name) SECURITY DEFINER; ALTER FUNCTION periods.add_foreign_key(regclass,name[],name,name,periods.fk_match_types,periods.fk_actions,periods.fk_actions,name,name,name,name,name) SECURITY DEFINER; ALTER FUNCTION periods.add_period(regclass,name,name,name,regtype,name) SECURITY DEFINER; ALTER FUNCTION periods.add_system_time_period(regclass,name,name,name,name,name,name,name,name[]) SECURITY DEFINER; ALTER FUNCTION periods.add_system_versioning(regclass,name,name,name,name,name,name) SECURITY DEFINER; ALTER FUNCTION periods.add_unique_key(regclass,name[],name,name,name,name) SECURITY DEFINER; ALTER FUNCTION periods.drop_for_portion_view(regclass,name,periods.drop_behavior,boolean) SECURITY DEFINER; ALTER FUNCTION periods.drop_foreign_key(regclass,name) SECURITY DEFINER; ALTER FUNCTION periods.drop_period(regclass,name,periods.drop_behavior,boolean) SECURITY DEFINER; ALTER FUNCTION periods.drop_protection() SECURITY DEFINER; ALTER FUNCTION periods.drop_system_versioning(regclass,periods.drop_behavior,boolean) SECURITY DEFINER; ALTER FUNCTION periods.drop_system_time_period(table_name regclass,drop_behavior periods.drop_behavior,purge boolean) SECURITY DEFINER; ALTER FUNCTION periods.drop_unique_key(regclass,name,periods.drop_behavior,boolean) SECURITY DEFINER; ALTER FUNCTION periods.generated_always_as_row_start_end() SECURITY DEFINER; ALTER FUNCTION periods.health_checks() SECURITY DEFINER; ALTER FUNCTION periods.rename_following() SECURITY DEFINER; ALTER FUNCTION periods.set_system_time_period_excluded_columns(regclass,name[]) SECURITY DEFINER; ALTER FUNCTION periods.truncate_system_versioning() SECURITY DEFINER; ALTER FUNCTION periods.write_history() SECURITY DEFINER; CREATE OR REPLACE FUNCTION periods.add_for_portion_view(table_name regclass DEFAULT NULL, period_name name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE r record; view_name name; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just add the views for that. * * If no period is specified, add the views for all periods of the table. * * If no table is specified, add the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Can't use FOR PORTION OF on SYSTEM_TIME columns */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'cannot use FOR PORTION OF on SYSTEM_TIME periods'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * We require the table to have a primary key, so check to see if there is * one. This requires a lock on the table so no one removes it after we * check and before we commit. */ EXECUTE format('LOCK TABLE %s IN ACCESS SHARE MODE', table_name); /* Now check for the primary key */ IF NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'p')) THEN RAISE EXCEPTION 'table "%" must have a primary key', table_name; END IF; FOR r IN SELECT n.nspname AS schema_name, c.relname AS table_name, c.relowner AS table_owner, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (table_name IS NULL OR p.table_name = table_name) AND (period_name IS NULL OR p.period_name = period_name) AND p.period_name <> 'system_time' AND NOT EXISTS ( SELECT FROM periods.for_portion_views AS _fpv WHERE (_fpv.table_name, _fpv.period_name) = (p.table_name, p.period_name)) LOOP view_name := periods._choose_portion_view_name(r.table_name, r.period_name); trigger_name := 'for_portion_of_' || r.period_name; EXECUTE format('CREATE VIEW %1$I.%2$I AS TABLE %1$I.%3$I', r.schema_name, view_name, r.table_name); EXECUTE format('ALTER VIEW %1$I.%2$I OWNER TO %s', r.schema_name, view_name, r.table_owner::regrole); EXECUTE format('CREATE TRIGGER %I INSTEAD OF UPDATE ON %I.%I FOR EACH ROW EXECUTE PROCEDURE periods.update_portion_of()', trigger_name, r.schema_name, view_name); INSERT INTO periods.for_portion_views (table_name, period_name, view_name, trigger_name) VALUES (format('%I.%I', r.schema_name, r.table_name), r.period_name, format('%I.%I', r.schema_name, view_name), trigger_name); END LOOP; RETURN true; END; $function$; CREATE OR REPLACE FUNCTION periods.add_system_versioning( table_class regclass, history_table_name name DEFAULT NULL, view_name name DEFAULT NULL, function_as_of_name name DEFAULT NULL, function_between_name name DEFAULT NULL, function_between_symmetric_name name DEFAULT NULL, function_from_to_name name DEFAULT NULL) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE schema_name name; table_name name; table_owner regrole; persistence "char"; kind "char"; period_row periods.periods; history_table_id oid; sql text; grantees text; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.29 */ /* Already registered? SQL:2016 11.29 SR 5 */ IF EXISTS (SELECT FROM periods.system_versioning AS r WHERE r.table_name = table_class) THEN RAISE EXCEPTION 'table already has SYSTEM VERSIONING'; END IF; /* Must be a regular persistent base table. SQL:2016 11.29 SR 2 */ SELECT n.nspname, c.relname, c.relowner, c.relpersistence, c.relkind INTO schema_name, table_name, table_owner, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * because I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* * We could probably accept unlogged tables if the history table is * also unlogged, but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* We need a SYSTEM_TIME period. SQL:2016 11.29 SR 4 */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, 'system_time'); IF NOT FOUND THEN RAISE EXCEPTION 'no period for SYSTEM_TIME found for table %', table_class; END IF; /* Get all of our "fake" infrastructure ready */ history_table_name := coalesce(history_table_name, periods._choose_name(ARRAY[table_name], 'history')); view_name := coalesce(view_name, periods._choose_name(ARRAY[table_name], 'with_history')); function_as_of_name := coalesce(function_as_of_name, periods._choose_name(ARRAY[table_name], '_as_of')); function_between_name := coalesce(function_between_name, periods._choose_name(ARRAY[table_name], '_between')); function_between_symmetric_name := coalesce(function_between_symmetric_name, periods._choose_name(ARRAY[table_name], '_between_symmetric')); function_from_to_name := coalesce(function_from_to_name, periods._choose_name(ARRAY[table_name], '_from_to')); /* * Create the history table. If it already exists we check that all the * columns match but otherwise we trust the user. Perhaps the history * table was disconnected in order to change the schema (a case which is * not defined by the SQL standard). Or perhaps the user wanted to * partition the history table. * * There shouldn't be any concurrency issues here because our main catalog * is locked. */ SELECT c.oid INTO history_table_id FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (n.nspname, c.relname) = (schema_name, history_table_name); IF FOUND THEN /* Don't allow any periods on the history table (this might be relaxed later) */ IF EXISTS (SELECT FROM periods.periods AS p WHERE p.table_name = history_table_id) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* * The query to the attributes is harder than one would think because * we need to account for dropped columns. Basically what we're * looking for is that all columns have the same name, type, and * collation. */ IF EXISTS ( WITH L (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = table_class AND NOT a.attisdropped ), R (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = history_table_id AND NOT a.attisdropped ) SELECT FROM L NATURAL FULL JOIN R WHERE L.attname IS NULL OR R.attname IS NULL) THEN RAISE EXCEPTION 'base table "%" and history table "%" are not compatible', table_class, history_table_id::regclass; END IF; /* Make sure the owner is correct */ EXECUTE format('ALTER TABLE %s OWNER TO %I', history_table_id::regclass, table_owner); /* * Remove all privileges other than SELECT from everyone on the history * table. We do this without error because some privileges may have * been added in order to do maintenance while we were disconnected. * * We start by doing the table owner because that will make sure we * don't have NULL in pg_class.relacl. */ --EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON TABLE %s FROM %I', --history_table_id::regclass, table_owner); ELSE EXECUTE format('CREATE TABLE %1$I.%2$I (LIKE %1$I.%3$I)', schema_name, history_table_name, table_name); history_table_id := format('%I.%I', schema_name, history_table_name)::regclass; EXECUTE format('ALTER TABLE %1$I.%2$I OWNER TO %3$I', schema_name, history_table_name, table_owner); RAISE NOTICE 'history table "%" created for "%", be sure to index it properly', history_table_id::regclass, table_class; END IF; /* Create the "with history" view. This one we do want to error out on if it exists. */ EXECUTE format( /* * The query we really want here is * * CREATE VIEW view_name AS * TABLE table_name * UNION ALL CORRESPONDING * TABLE history_table_name * * but PostgreSQL doesn't support that syntax (yet), so we have to do * it manually. */ 'CREATE VIEW %1$I.%2$I AS SELECT %5$s FROM %1$I.%3$I UNION ALL SELECT %5$s FROM %1$I.%4$I', schema_name, view_name, table_name, history_table_name, (SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FROM pg_attribute AS a WHERE a.attrelid = table_class AND a.attnum > 0 AND NOT a.attisdropped )); EXECUTE format('ALTER VIEW %1$I.%2$I OWNER TO %3$I', schema_name, view_name, table_owner); /* * Create functions to simulate the system versioned grammar. These must * be inlinable for any kind of performance. */ EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %4$I <= $1 AND %5$I > $1' $$, schema_name, function_as_of_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone) OWNER TO %3$I', schema_name, function_as_of_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 <= $2 AND %5$I > $1 AND %4$I <= $2' $$, schema_name, function_between_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_between_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %5$I > least($1, $2) AND %4$I <= greatest($1, $2)' $$, schema_name, function_between_symmetric_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_between_symmetric_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 < $2 AND %5$I > $1 AND %4$I < $2' $$, schema_name, function_from_to_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_from_to_name, table_owner); /* Set privileges on history objects */ FOR sql IN SELECT format('REVOKE ALL ON %s %s FROM %s', CASE object_type WHEN 'r' THEN 'TABLE' WHEN 'v' THEN 'TABLE' WHEN 'f' THEN 'FUNCTION' ELSE 'ERROR' END, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT quote_ident(COALESCE(a.rolname, 'public')), ', ')) FROM ( SELECT c.relkind AS object_type, c.oid::regclass::text AS object_name, acl.grantee AS grantee FROM pg_class AS c JOIN pg_namespace AS n ON n.oid = c.relnamespace CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE n.nspname = schema_name AND c.relname IN (history_table_name, view_name) UNION ALL SELECT 'f', p.oid::regprocedure::text, acl.grantee FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.oid = ANY (ARRAY[ format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure ]) ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY objects.object_type LOOP EXECUTE sql; END LOOP; FOR grantees IN SELECT string_agg(acl.grantee::regrole::text, ', ') FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.oid = table_class AND acl.privilege_type = 'SELECT' LOOP EXECUTE format('GRANT SELECT ON TABLE %1$I.%2$I, %1$I.%3$I TO %4$s', schema_name, history_table_name, view_name, grantees); EXECUTE format('GRANT EXECUTE ON FUNCTION %s, %s, %s, %s TO %s', format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure, grantees); END LOOP; /* Register it */ INSERT INTO periods.system_versioning (table_name, period_name, history_table_name, view_name, func_as_of, func_between, func_between_symmetric, func_from_to) VALUES ( table_class, 'system_time', format('%I.%I', schema_name, history_table_name), format('%I.%I', schema_name, view_name), format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name) ); END; $function$; CREATE OR REPLACE FUNCTION periods.drop_protection() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE r record; table_name regclass; period_name name; BEGIN /* * This function is called after the fact, so we have to just look to see * if anything is missing in the catalogs if we just store the name and not * a reg* type. */ --- --- periods --- /* If one of our tables is being dropped, remove references to it */ FOR table_name, period_name IN SELECT p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP PERFORM periods.drop_period(table_name, period_name, 'CASCADE', true); END LOOP; /* * If a column belonging to one of our periods is dropped, we need to reject that. * SQL:2016 11.23 SR 6 */ FOR r IN SELECT dobj.object_identity, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_attribute AS sa ON (sa.attrelid, sa.attname) = (p.table_name, p.start_column_name) JOIN pg_catalog.pg_attribute AS ea ON (ea.attrelid, ea.attname) = (p.table_name, p.end_column_name) JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name AND dobj.objsubid IN (sa.attnum, ea.attnum) WHERE dobj.object_type = 'table column' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop column "%" because it is part of the period "%"', r.object_identity, r.period_name; END LOOP; /* Also reject dropping the rangetype */ FOR r IN SELECT dobj.object_identity, p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.range_type ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop rangetype "%" because it is used in period "%" on table "%"', r.object_identity, r.period_name, r.table_name; END LOOP; --- --- system_time_periods --- /* Complain if the infinity CHECK constraint is missing. */ FOR r IN SELECT p.table_name, p.infinity_check_constraint FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (p.table_name, p.infinity_check_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in SYSTEM_TIME period', r.infinity_check_constraint, r.table_name; END LOOP; /* Complain if the GENERATED ALWAYS AS ROW START/END trigger is missing. */ FOR r IN SELECT p.table_name, p.generated_always_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.generated_always_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.generated_always_trigger, r.table_name; END LOOP; /* Complain if the write_history trigger is missing. */ FOR r IN SELECT p.table_name, p.write_history_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.write_history_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.write_history_trigger, r.table_name; END LOOP; /* Complain if the TRUNCATE trigger is missing. */ FOR r IN SELECT p.table_name, p.truncate_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.truncate_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.truncate_trigger, r.table_name; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- /* Reject dropping the FOR PORTION OF view. */ FOR r IN SELECT dobj.object_identity FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = fpv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%", call "periods.drop_for_portion_view()" instead', r.object_identity; END LOOP; /* Complain if the FOR PORTION OF trigger is missing. */ FOR r IN SELECT fpv.table_name, fpv.period_name, fpv.view_name, fpv.trigger_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fpv.view_name, fpv.trigger_name)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on view "%" because it is used in FOR PORTION OF view for period "%" on table "%"', r.trigger_name, r.view_name, r.period_name, r.table_name; END LOOP; /* Complain if the table's primary key has been dropped. */ FOR r IN SELECT fpv.table_name, fpv.period_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (fpv.table_name, 'p')) LOOP RAISE EXCEPTION 'cannot drop primary key on table "%" because it has a FOR PORTION OF view for period "%"', r.table_name, r.period_name; END LOOP; --- --- unique_keys --- /* * We don't need to protect the individual columns as long as we protect * the indexes. PostgreSQL will make sure they stick around. */ /* Complain if the indexes implementing our unique indexes are missing. */ FOR r IN SELECT uk.key_name, uk.table_name, uk.unique_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.unique_constraint, r.table_name, r.key_name; END LOOP; FOR r IN SELECT uk.key_name, uk.table_name, uk.exclude_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.exclude_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.exclude_constraint, r.table_name, r.key_name; END LOOP; --- --- foreign_keys --- /* Complain if any of the triggers are missing */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_insert_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_update_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_update_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_delete_trigger, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.history_table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop table "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.object_identity = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]) WHERE dobj.object_type = 'function' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop function "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; END; $function$; CREATE OR REPLACE FUNCTION periods.health_checks() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE cmd text; r record; save_search_path text; BEGIN /* Make sure that all of our tables are still persistent */ FOR r IN SELECT p.table_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* And the history tables, too */ FOR r IN SELECT sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_class AS c ON c.oid = sv.history_table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'history table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* Check that our system versioning functions are still here */ save_search_path := pg_catalog.current_setting('search_path'); PERFORM pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true); FOR r IN SELECT * FROM periods.system_versioning AS sv CROSS JOIN LATERAL UNNEST(ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]) AS u (fn) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_proc AS p WHERE p.oid::regprocedure::text = u.fn ) LOOP RAISE EXCEPTION 'cannot drop or rename function "%" because it is used in SYSTEM VERSIONING for table "%"', r.fn, r.table_name; END LOOP; PERFORM pg_catalog.set_config('search_path', save_search_path, true); /* Fix up history and for-portion objects ownership */ FOR cmd IN SELECT format('ALTER %s %s OWNER TO %I', CASE ht.relkind WHEN 'r' THEN 'TABLE' WHEN 'v' THEN 'VIEW' END, ht.oid::regclass, t.relowner::regrole) FROM periods.system_versioning AS sv JOIN pg_class AS t ON t.oid = sv.table_name JOIN pg_class AS ht ON ht.oid IN (sv.history_table_name, sv.view_name) WHERE t.relowner <> ht.relowner UNION ALL SELECT format('ALTER VIEW %s OWNER TO %I', fpt.oid::regclass, t.relowner::regrole) FROM periods.for_portion_views AS fpv JOIN pg_class AS t ON t.oid = fpv.table_name JOIN pg_class AS fpt ON fpt.oid = fpv.view_name WHERE t.relowner <> fpt.relowner UNION ALL SELECT format('ALTER FUNCTION %s OWNER TO %I', p.oid::regprocedure, t.relowner::regrole) FROM periods.system_versioning AS sv JOIN pg_class AS t ON t.oid = sv.table_name JOIN pg_proc AS p ON p.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE t.relowner <> p.proowner LOOP EXECUTE cmd; END LOOP; /* Check GRANTs */ IF EXISTS ( SELECT FROM pg_event_trigger_ddl_commands() AS ev_ddl WHERE ev_ddl.command_tag = 'GRANT') THEN FOR r IN SELECT *, EXISTS ( SELECT FROM pg_class AS _c CROSS JOIN LATERAL aclexplode(COALESCE(_c.relacl, acldefault('r', _c.relowner))) AS _acl WHERE _c.oid = objects.table_name AND _acl.grantee = objects.grantee AND _acl.privilege_type = 'SELECT' ) AS on_base_table FROM ( SELECT sv.table_name, c.oid::regclass::text AS object_name, c.relkind AS object_type, acl.privilege_type, acl.privilege_type AS base_privilege_type, acl.grantee, 'h' AS history_or_portion FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid IN (sv.history_table_name, sv.view_name) CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl UNION ALL SELECT fpv.table_name, c.oid::regclass::text, c.relkind, acl.privilege_type, acl.privilege_type, acl.grantee, 'p' AS history_or_portion FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.view_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl UNION ALL SELECT sv.table_name, p.oid::regprocedure::text, 'f', acl.privilege_type, 'SELECT', acl.grantee, 'h' FROM periods.system_versioning AS sv JOIN pg_proc AS p ON p.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl ) AS objects ORDER BY object_name, object_type, privilege_type LOOP IF r.history_or_portion = 'h' AND (r.object_type, r.privilege_type) NOT IN (('r', 'SELECT'), ('v', 'SELECT'), ('f', 'EXECUTE')) THEN RAISE EXCEPTION 'cannot grant % to "%"; history objects are read-only', r.privilege_type, r.object_name; END IF; IF NOT r.on_base_table THEN RAISE EXCEPTION 'cannot grant % directly to "%"; grant % to "%" instead', r.privilege_type, r.object_name, r.base_privilege_type, r.table_name; END IF; END LOOP; /* Propagate GRANTs */ FOR cmd IN SELECT format('GRANT %s ON %s %s TO %s', string_agg(DISTINCT privilege_type, ', '), object_type, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT COALESCE(a.rolname, 'public'), ', ')) FROM ( SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, 'SELECT' AS privilege_type, acl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) WHERE acl.privilege_type = 'SELECT' AND NOT has_table_privilege(acl.grantee, hc.oid, 'SELECT') UNION ALL SELECT 'TABLE', fpc.oid::regclass::text, acl.privilege_type, acl.grantee FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS fpc ON fpc.oid = fpv.view_name WHERE NOT has_table_privilege(acl.grantee, fpc.oid, acl.privilege_type) UNION ALL SELECT 'FUNCTION', hp.oid::regprocedure::text, 'EXECUTE', acl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE acl.privilege_type = 'SELECT' AND NOT has_function_privilege(acl.grantee, hp.oid, 'EXECUTE') ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY object_type LOOP EXECUTE cmd; END LOOP; END IF; /* Check REVOKEs */ IF EXISTS ( SELECT FROM pg_event_trigger_ddl_commands() AS ev_ddl WHERE ev_ddl.command_tag = 'REVOKE') THEN FOR r IN SELECT sv.table_name, hc.oid::regclass::text AS object_name, acl.privilege_type, acl.privilege_type AS base_privilege_type FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) WHERE acl.privilege_type = 'SELECT' AND NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS _acl WHERE _acl.privilege_type = 'SELECT' AND _acl.grantee = acl.grantee) UNION ALL SELECT fpv.table_name, hc.oid::regclass::text, acl.privilege_type, acl.privilege_type FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid = fpv.view_name WHERE NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS _acl WHERE _acl.privilege_type = acl.privilege_type AND _acl.grantee = acl.grantee) UNION ALL SELECT sv.table_name, hp.oid::regprocedure::text, 'EXECUTE', 'SELECT' FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE acl.privilege_type = 'SELECT' AND NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hp.proacl, acldefault('f', hp.proowner))) AS _acl WHERE _acl.privilege_type = 'EXECUTE' AND _acl.grantee = acl.grantee) ORDER BY table_name, object_name LOOP RAISE EXCEPTION 'cannot revoke % directly from "%", revoke % from "%" instead', r.privilege_type, r.object_name, r.base_privilege_type, r.table_name; END LOOP; /* Propagate REVOKEs */ FOR cmd IN SELECT format('REVOKE %s ON %s %s FROM %s', string_agg(DISTINCT privilege_type, ', '), object_type, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT COALESCE(a.rolname, 'public'), ', ')) FROM ( SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, 'SELECT' AS privilege_type, hacl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) CROSS JOIN LATERAL aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS hacl WHERE hacl.privilege_type = 'SELECT' AND NOT has_table_privilege(hacl.grantee, sv.table_name, 'SELECT') UNION ALL SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, hacl.privilege_type, hacl.grantee FROM periods.for_portion_views AS fpv JOIN pg_class AS hc ON hc.oid = fpv.view_name CROSS JOIN LATERAL aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS hacl WHERE NOT has_table_privilege(hacl.grantee, fpv.table_name, hacl.privilege_type) UNION ALL SELECT 'FUNCTION' AS object_type, hp.oid::regprocedure::text AS object_name, 'EXECUTE' AS privilege_type, hacl.grantee FROM periods.system_versioning AS sv JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) CROSS JOIN LATERAL aclexplode(COALESCE(hp.proacl, acldefault('f', hp.proowner))) AS hacl WHERE hacl.privilege_type = 'EXECUTE' AND NOT has_table_privilege(hacl.grantee, sv.table_name, 'SELECT') ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY object_type LOOP EXECUTE cmd; END LOOP; END IF; END; $function$; periods-1.2.2/periods--1.1.sql000066400000000000000000003621241432551570100157340ustar00rootroot00000000000000-- complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION periods" to load this file. \quit /* This extension is non-relocatable */ CREATE SCHEMA periods; CREATE TYPE periods.drop_behavior AS ENUM ('CASCADE', 'RESTRICT'); CREATE TYPE periods.fk_actions AS ENUM ('CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION'); CREATE TYPE periods.fk_match_types AS ENUM ('FULL', 'PARTIAL', 'SIMPLE'); /* * All referencing columns must be either name or regsomething in order for * pg_dump to work properly. Plain OIDs are not allowed but attribute numbers * are, so that we don't have to track renames. * * Anything declared as regsomething and created for the period (such as the * "__as_of" function), should be UNIQUE. If Postgres already verifies * uniqueness, such as constraint names on a table, then we don't need to do it * also. */ CREATE TABLE periods.periods ( table_name regclass NOT NULL, period_name name NOT NULL, start_column_name name NOT NULL, end_column_name name NOT NULL, range_type regtype NOT NULL, bounds_check_constraint name NOT NULL, PRIMARY KEY (table_name, period_name), CHECK (start_column_name <> end_column_name) ); SELECT pg_catalog.pg_extension_config_dump('periods.periods', ''); CREATE TABLE periods.system_time_periods ( table_name regclass NOT NULL, period_name name NOT NULL, infinity_check_constraint name NOT NULL, generated_always_trigger name NOT NULL, write_history_trigger name NOT NULL, truncate_trigger name NOT NULL, excluded_column_names name[] NOT NULL DEFAULT '{}', PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time') ); SELECT pg_catalog.pg_extension_config_dump('periods.system_time_periods', ''); COMMENT ON TABLE periods.periods IS 'The main catalog for periods. All "DDL" operations for periods must first take an exclusive lock on this table.'; CREATE VIEW periods.information_schema__periods AS SELECT current_catalog AS table_catalog, n.nspname AS table_schema, c.relname AS table_name, p.period_name, p.start_column_name, p.end_column_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace; CREATE TABLE periods.for_portion_views ( table_name regclass NOT NULL, period_name name NOT NULL, view_name regclass NOT NULL, trigger_name name NOT NULL, PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, UNIQUE (view_name) ); SELECT pg_catalog.pg_extension_config_dump('periods.for_portion_views', ''); CREATE TABLE periods.unique_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_constraint name NOT NULL, exclude_constraint name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods ); SELECT pg_catalog.pg_extension_config_dump('periods.unique_keys', ''); COMMENT ON TABLE periods.unique_keys IS 'A registry of UNIQUE/PRIMARY keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.foreign_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_key name NOT NULL, match_type periods.fk_match_types NOT NULL DEFAULT 'SIMPLE', delete_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', update_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', fk_insert_trigger name NOT NULL, fk_update_trigger name NOT NULL, uk_update_trigger name NOT NULL, uk_delete_trigger name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, FOREIGN KEY (unique_key) REFERENCES periods.unique_keys, CHECK (delete_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')), CHECK (update_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')) ); SELECT pg_catalog.pg_extension_config_dump('periods.foreign_keys', ''); COMMENT ON TABLE periods.foreign_keys IS 'A registry of foreign keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.system_versioning ( table_name regclass NOT NULL, period_name name NOT NULL, history_table_name regclass NOT NULL, view_name regclass NOT NULL, func_as_of regprocedure NOT NULL, func_between regprocedure NOT NULL, func_between_symmetric regprocedure NOT NULL, func_from_to regprocedure NOT NULL, PRIMARY KEY (table_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time'), UNIQUE (history_table_name), UNIQUE (view_name), UNIQUE (func_as_of), UNIQUE (func_between), UNIQUE (func_between_symmetric), UNIQUE (func_from_to) ); SELECT pg_catalog.pg_extension_config_dump('periods.system_versioning', ''); COMMENT ON TABLE periods.system_versioning IS 'A registry of tables with SYSTEM VERSIONING'; /* * These function starting with "_" are private to the periods extension and * should not be called by outsiders. When all the other functions have been * translated to C, they will be removed. */ CREATE FUNCTION periods._serialize(table_name regclass) RETURNS void LANGUAGE sql AS $function$ /* XXX: Is this the best way to do locking? */ SELECT pg_catalog.pg_advisory_xact_lock('periods.periods'::regclass::oid::integer, table_name::oid::integer); $function$; CREATE FUNCTION periods._choose_name(resizable text[], fixed text DEFAULT NULL, separator text DEFAULT '_', extra integer DEFAULT 2) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the resizable texts until they and the fixed text fit in * NAMEDATALEN. This probably isn't very efficient but it's not on a hot * code path so we don't care. */ SELECT max(length(t)) INTO max_length FROM unnest(resizable) AS u (t); LOOP result := format('%s%s', array_to_string(resizable, separator), separator || fixed); IF octet_length(result) <= NAMEDATALEN-extra-1 THEN RETURN result; END IF; max_length := max_length - 1; resizable := ARRAY ( SELECT left(t, max_length) FROM unnest(resizable) WITH ORDINALITY AS u (t, o) ORDER BY o ); END LOOP; END; $function$; CREATE FUNCTION periods._choose_portion_view_name(table_name name, period_name name) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the table and period names until they fit in NAMEDATALEN. This * probably isn't very efficient but it's not on a hot code path so we * don't care. */ max_length := greatest(length(table_name), length(period_name)); LOOP result := format('%s__for_portion_of_%s', table_name, period_name); IF octet_length(result) <= NAMEDATALEN-1 THEN RETURN result; END IF; max_length := max_length - 1; table_name := left(table_name, max_length); period_name := left(period_name, max_length); END LOOP; END; $function$; CREATE FUNCTION periods.add_period( table_name regclass, period_name name, start_column_name name, end_column_name name, range_type regtype DEFAULT NULL, bounds_check_constraint name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE table_name_only name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 11.27 */ /* Don't allow anything on system versioning history tables (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.system_versioning AS sv WHERE sv.history_table_name = table_name) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* Period names are limited to lowercase alphanumeric characters for now */ period_name := lower(period_name); IF period_name !~ '^[a-z_][0-9a-z_]*$' THEN RAISE EXCEPTION 'only alphanumeric characters are currently allowed'; END IF; IF period_name = 'system_time' THEN RETURN periods.add_system_time_period(table_name, start_column_name, end_column_name); END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT c.relpersistence, c.relkind INTO persistence, kind FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_name; END IF; /* * Check if period already exists. Actually no other application time * periods are allowed per spec, but we don't obey that. We can have as * many application time periods as we want. * * SQL:2016 11.27 SR 5.b */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period for "%" already exists on table "%"', period_name, table_name; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 5.c */ IF EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, period_name)) THEN RAISE EXCEPTION 'a column named "%" already exists for table "%"', period_name, table_name; END IF; /* * Contrary to SYSTEM_TIME periods, the columns must exist already for * application time periods. * * SQL:2016 11.27 SR 5.d */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO start_attnum, start_type, start_collation, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, start_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', start_column_name, table_name; END IF; IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO end_attnum, end_type, end_collation, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, end_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', end_column_name, table_name; END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* * Verify compatibility of start/end columns. The standard says these must * be either date or timestamp, but we allow anything with a corresponding * range type because why not. * * SQL:2016 11.27 SR 5.g */ IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; IF start_collation <> end_collation THEN RAISE EXCEPTION 'start and end columns must be of same collation'; END IF; /* Get the range type that goes with these columns */ IF range_type IS NOT NULL THEN IF NOT EXISTS ( SELECT FROM pg_catalog.pg_range AS r WHERE (r.rngtypid, r.rngsubtype, r.rngcollation) = (range_type, start_type, start_collation)) THEN RAISE EXCEPTION 'range "%" does not match data type "%"', range_type, start_type; END IF; ELSE SELECT r.rngtypid INTO range_type FROM pg_catalog.pg_range AS r JOIN pg_catalog.pg_opclass AS c ON c.oid = r.rngsubopc WHERE (r.rngsubtype, r.rngcollation) = (start_type, start_collation) AND c.opcdefault; IF NOT FOUND THEN RAISE EXCEPTION 'no default range type for %', start_type::regtype; END IF; END IF; /* * Period columns must not be nullable. * * SQL:2016 11.27 SR 5.h */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_name; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_name AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name_only FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; bounds_check_constraint := periods._choose_name(ARRAY[table_name_only, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %s %s', table_name, array_to_string(alter_commands, ', ')); END IF; INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); RETURN true; END; $function$; CREATE FUNCTION periods.drop_period(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; system_time_period_row periods.system_time_periods; system_versioning_row periods.system_versioning; portion_view regclass; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * Has the table been dropped already? This could happen if the period is * being dropped by the drop_protection event trigger or through a DROP * CASCADE. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE NOTICE 'period % not found on table %', period_name, table_name; RETURN false; END IF; /* Drop the "for portion" view if it hasn't been dropped already */ PERFORM periods.drop_for_portion_view(table_name, period_name, drop_behavior, purge); /* If this is a system_time period, get rid of the triggers */ DELETE FROM periods.system_time_periods AS stp WHERE stp.table_name = table_name RETURNING stp.* INTO system_time_period_row; IF FOUND AND NOT is_dropped THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, system_time_period_row.infinity_check_constraint); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.generated_always_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.write_history_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.truncate_trigger, table_name); END IF; IF drop_behavior = 'RESTRICT' THEN /* Check for UNIQUE or PRIMARY KEYs */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a UNIQUE or PRIMARY KEY', period_name; END IF; /* Check for FOREIGN KEYs */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a FOREIGN KEY', period_name; END IF; /* Check for SYSTEM VERSIONING */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'table % has SYSTEM VERSIONING', table_name; END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END IF; /* We must be in CASCADE mode now */ PERFORM periods.drop_foreign_key(table_name, fk.key_name) FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name); PERFORM periods.drop_unique_key(table_name, uk.key_name, drop_behavior, purge) FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name); /* * Save ourselves the NOTICE if this table doesn't have SYSTEM * VERSIONING. * * We don't do like above because the purge is different. We don't want * dropping SYSTEM VERSIONING to drop our infinity constraint; only * dropping the PERIOD should do that. */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN PERFORM periods.drop_system_versioning(table_name, drop_behavior, purge); END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END; $function$; CREATE FUNCTION periods.add_system_time_period( table_class regclass, start_column_name name DEFAULT 'system_time_start', end_column_name name DEFAULT 'system_time_end', bounds_check_constraint name DEFAULT NULL, infinity_check_constraint name DEFAULT NULL, generated_always_trigger name DEFAULT NULL, write_history_trigger name DEFAULT NULL, truncate_trigger name DEFAULT NULL, excluded_column_names name[] DEFAULT '{}') RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_name CONSTANT name := 'system_time'; schema_name name; table_name name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; excluded_column_name name; DATE_OID CONSTANT integer := 1082; TIMESTAMP_OID CONSTANT integer := 1114; TIMESTAMPTZ_OID CONSTANT integer := 1184; range_type regtype; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.7 * SQL:2016 11.27 */ /* The columns must not be part of UNIQUE keys. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* * Check if period already exists. * * SQL:2016 11.27 SR 4.a */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, period_name)) THEN RAISE EXCEPTION 'period for SYSTEM_TIME already exists on table "%"', table_class; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 4.b */ IF EXISTS (SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, period_name)) THEN RAISE EXCEPTION 'a column named system_time already exists for table "%"', table_class; END IF; /* The standard says that the columns must not exist already, but we don't obey that rule for now. */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO start_attnum, start_type, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); IF NOT FOUND THEN /* * First add the column with DEFAULT of -infinity to fill the * current rows, then replace the DEFAULT with transaction_timestamp() for future * rows. * * The default value is just for self-documentation anyway because * the trigger will enforce the value. */ alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''-infinity''', start_column_name); start_attnum := 0; start_type := 'timestamp with time zone'::regtype; start_notnull := true; END IF; alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT transaction_timestamp()', start_column_name); IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO end_attnum, end_type, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); IF NOT FOUND THEN alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''infinity''', end_column_name); end_attnum := 0; end_type := 'timestamp with time zone'::regtype; end_notnull := true; ELSE alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT ''infinity''', end_column_name); END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Verify compatibility of start/end columns */ IF start_type::regtype NOT IN ('date', 'timestamp without time zone', 'timestamp with time zone') THEN RAISE EXCEPTION 'SYSTEM_TIME periods must be of type "date", "timestamp without time zone", or "timestamp with time zone"'; END IF; IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; /* Get appropriate range type */ CASE start_type WHEN DATE_OID THEN range_type := 'daterange'; WHEN TIMESTAMP_OID THEN range_type := 'tsrange'; WHEN TIMESTAMPTZ_OID THEN range_type := 'tstzrange'; ELSE RAISE EXCEPTION 'unexpected data type: "%"', start_type::regtype; END CASE; /* can't be part of a foreign key */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.table_name = table_class AND fk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* * Period columns must not be nullable. */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; bounds_check_constraint := periods._choose_name(ARRAY[table_name, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* * Find and appropriate a CHECK constraint to make sure that end = 'infinity'. * Create one if necessary. * * SQL:2016 4.15.2.2 */ DECLARE condef CONSTANT text := format('CHECK ((%I = ''infinity''::timestamp with time zone))', end_column_name); context text; BEGIN IF infinity_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, infinity_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', infinity_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO infinity_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; infinity_check_constraint := periods._choose_name(ARRAY[table_name, end_column_name], 'infinity_check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %I.%I %s', schema_name, table_name, array_to_string(alter_commands, ', ')); IF start_attnum = 0 THEN SELECT a.attnum INTO start_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); END IF; IF end_attnum = 0 THEN SELECT a.attnum INTO end_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); END IF; END IF; /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_class, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; generated_always_trigger := coalesce( generated_always_trigger, periods._choose_name(ARRAY[table_name], 'system_time_generated_always')); EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.generated_always_as_row_start_end()', generated_always_trigger, table_class); write_history_trigger := coalesce( write_history_trigger, periods._choose_name(ARRAY[table_name], 'system_time_write_history')); EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.write_history()', write_history_trigger, table_class); truncate_trigger := coalesce( truncate_trigger, periods._choose_name(ARRAY[table_name], 'truncate')); EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE PROCEDURE periods.truncate_system_versioning()', truncate_trigger, table_class); INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_class, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); INSERT INTO periods.system_time_periods ( table_name, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names) VALUES ( table_class, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names); RETURN true; END; $function$; CREATE FUNCTION periods.set_system_time_period_excluded_columns( table_name regclass, excluded_column_names name[]) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE excluded_column_name name; BEGIN /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; /* Do it. */ UPDATE periods.system_time_periods AS stp SET excluded_column_names = excluded_column_names WHERE stp.table_name = table_name; END; $function$; CREATE FUNCTION periods.drop_system_time_period(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE sql AS $function$ SELECT periods.drop_period(table_name, 'system_time', drop_behavior, purge); $function$; CREATE FUNCTION periods.generated_always_as_row_start_end() RETURNS trigger LANGUAGE c STRICT AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.write_history() RETURNS trigger LANGUAGE c STRICT AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.truncate_system_versioning() RETURNS trigger LANGUAGE plpgsql STRICT AS $function$ #variable_conflict use_variable DECLARE history_table_name name; BEGIN SELECT sv.history_table_name INTO history_table_name FROM periods.system_versioning AS sv WHERE sv.table_name = TG_RELID; IF FOUND THEN EXECUTE format('TRUNCATE %s', history_table_name); END IF; RETURN NULL; END; $function$; CREATE FUNCTION periods.add_for_portion_view(table_name regclass DEFAULT NULL, period_name name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; view_name name; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just add the views for that. * * If no period is specified, add the views for all periods of the table. * * If no table is specified, add the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Can't use FOR PORTION OF on SYSTEM_TIME columns */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'cannot use FOR PORTION OF on SYSTEM_TIME periods'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * We require the table to have a primary key, so check to see if there is * one. This requires a lock on the table so no one removes it after we * check and before we commit. */ EXECUTE format('LOCK TABLE %s IN ACCESS SHARE MODE', table_name); /* Now check for the primary key */ IF NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'p')) THEN RAISE EXCEPTION 'table "%" must have a primary key', table_name; END IF; FOR r IN SELECT n.nspname AS schema_name, c.relname AS table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (table_name IS NULL OR p.table_name = table_name) AND (period_name IS NULL OR p.period_name = period_name) AND p.period_name <> 'system_time' AND NOT EXISTS ( SELECT FROM periods.for_portion_views AS _fpv WHERE (_fpv.table_name, _fpv.period_name) = (p.table_name, p.period_name)) LOOP view_name := periods._choose_portion_view_name(r.table_name, r.period_name); trigger_name := 'for_portion_of_' || r.period_name; EXECUTE format('CREATE VIEW %1$I.%2$I AS TABLE %1$I.%3$I', r.schema_name, view_name, r.table_name); EXECUTE format('CREATE TRIGGER %I INSTEAD OF UPDATE ON %I.%I FOR EACH ROW EXECUTE PROCEDURE periods.update_portion_of()', trigger_name, r.schema_name, view_name); INSERT INTO periods.for_portion_views (table_name, period_name, view_name, trigger_name) VALUES (format('%I.%I', r.schema_name, r.table_name), r.period_name, format('%I.%I', r.schema_name, view_name), trigger_name); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.drop_for_portion_view(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE view_name regclass; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just drop the views for that. * * If no period is specified, drop the views for all periods of the table. * * If no table is specified, drop the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR view_name, trigger_name IN DELETE FROM periods.for_portion_views AS fp WHERE (table_name IS NULL OR fp.table_name = table_name) AND (period_name IS NULL OR fp.period_name = period_name) RETURNING fp.view_name, fp.trigger_name LOOP EXECUTE format('DROP TRIGGER %I on %s', trigger_name, view_name); EXECUTE format('DROP VIEW %s %s', view_name, drop_behavior); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.update_portion_of() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE info record; test boolean; generated_columns_sql text; generated_columns text[]; jnew jsonb; fromval jsonb; toval jsonb; jold jsonb; bstartval jsonb; bendval jsonb; pre_row jsonb; new_row jsonb; post_row jsonb; pre_assigned boolean; post_assigned boolean; SERVER_VERSION CONSTANT integer := current_setting('server_version_num')::integer; TEST_SQL CONSTANT text := 'VALUES (CAST(%2$L AS %1$s) < CAST(%3$L AS %1$s) AND ' ' CAST(%3$L AS %1$s) < CAST(%4$L AS %1$s))'; GENERATED_COLUMNS_SQL_PRE_10 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_PRE_12 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_CURRENT CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR a.attgenerated <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; BEGIN /* * REFERENCES: * SQL:2016 15.13 GR 10 */ /* Get the table information from this view */ SELECT p.table_name, p.period_name, p.start_column_name, p.end_column_name, format_type(a.atttypid, a.atttypmod) AS datatype INTO info FROM periods.for_portion_views AS fpv JOIN periods.periods AS p ON (p.table_name, p.period_name) = (fpv.table_name, fpv.period_name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (p.table_name, p.start_column_name) WHERE fpv.view_name = TG_RELID; IF NOT FOUND THEN RAISE EXCEPTION 'table and period information not found for view "%"', TG_RELID::regclass; END IF; jnew := row_to_json(NEW); fromval := jnew->info.start_column_name; toval := jnew->info.end_column_name; jold := row_to_json(OLD); bstartval := jold->info.start_column_name; bendval := jold->info.end_column_name; pre_row := jold; new_row := jnew; post_row := jold; /* Reset the period columns */ new_row := jsonb_set(new_row, ARRAY[info.start_column_name], bstartval); new_row := jsonb_set(new_row, ARRAY[info.end_column_name], bendval); /* If the period is the only thing changed, do nothing */ IF new_row = jold THEN RETURN NULL; END IF; pre_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, fromval, bendval) INTO test; IF test THEN pre_assigned := true; pre_row := jsonb_set(pre_row, ARRAY[info.end_column_name], fromval); new_row := jsonb_set(new_row, ARRAY[info.start_column_name], fromval); END IF; post_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, toval, bendval) INTO test; IF test THEN post_assigned := true; new_row := jsonb_set(new_row, ARRAY[info.end_column_name], toval::jsonb); post_row := jsonb_set(post_row, ARRAY[info.start_column_name], toval::jsonb); END IF; IF pre_assigned OR post_assigned THEN /* Don't validate foreign keys until all this is done */ SET CONSTRAINTS ALL DEFERRED; /* * Find and remove all generated columns from pre_row and post_row. * SQL:2016 15.13 GR 10)b)i) * * We also remove columns that own a sequence as those are a form of * generated column. We do not, however, remove columns that default * to nextval() without owning the underlying sequence. * * Columns belonging to a SYSTEM_TIME period are also removed. * * In addition to what the standard calls for, we also remove any * columns belonging to primary keys. */ IF SERVER_VERSION < 100000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_10; ELSIF SERVER_VERSION < 120000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_12; ELSE generated_columns_sql := GENERATED_COLUMNS_SQL_CURRENT; END IF; EXECUTE generated_columns_sql INTO generated_columns USING info.table_name; /* There may not be any generated columns. */ IF generated_columns IS NOT NULL THEN IF SERVER_VERSION < 100000 THEN SELECT jsonb_object_agg(e.key, e.value) INTO pre_row FROM jsonb_each(pre_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); SELECT jsonb_object_agg(e.key, e.value) INTO post_row FROM jsonb_each(post_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); ELSE pre_row := pre_row - generated_columns; post_row := post_row - generated_columns; END IF; END IF; END IF; IF pre_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(pre_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(pre_row))); END IF; EXECUTE format('UPDATE %s SET %s WHERE %s AND %I > %L AND %I < %L', info.table_name, (SELECT string_agg(format('%I = %L', j.key, j.value), ', ') FROM (SELECT key, value FROM jsonb_each_text(new_row) EXCEPT ALL SELECT key, value FROM jsonb_each_text(jold) ) AS j ), (SELECT string_agg(format('%I = %L', key, value), ' AND ') FROM pg_catalog.jsonb_each_text(jold) AS j JOIN pg_catalog.pg_attribute AS a ON a.attname = j.key JOIN pg_catalog.pg_constraint AS c ON c.conkey @> ARRAY[a.attnum] WHERE a.attrelid = info.table_name AND c.conrelid = info.table_name ), info.end_column_name, fromval, info.start_column_name, toval ); IF post_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(post_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(post_row))); END IF; RETURN NEW; END; $function$; CREATE FUNCTION periods.add_unique_key( table_name regclass, column_names name[], period_name name, key_name name DEFAULT NULL, unique_constraint name DEFAULT NULL, exclude_constraint name DEFAULT NULL) RETURNS name LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; column_attnums smallint[]; period_attnums smallint[]; idx integer; constraint_record record; pass integer; sql text; alter_cmds text[]; unique_index regclass; exclude_index regclass; unique_sql text; exclude_sql text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in UNIQUE constraints. SQL:2016 11.7 SR 5)b) */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* For convenience, put the period's attnums in an array */ period_attnums := ARRAY[ (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.start_column_name)), (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.end_column_name)) ]; /* Get attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a UNIQUE * key. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* If we were given a unique constraint to use, look it up and make sure it matches */ SELECT format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) INTO unique_sql FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); IF unique_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, c.conkey INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, unique_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', unique_constraint; END IF; IF constraint_record.contype NOT IN ('p', 'u') THEN RAISE EXCEPTION 'constraint "%" is not a PRIMARY KEY or UNIQUE KEY', unique_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', unique_constraint; END IF; IF NOT constraint_record.conkey = column_attnums || period_attnums THEN RAISE EXCEPTION 'constraint "%" does not match', unique_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * If we were given an exclude constraint to use, look it up and make sure * it matches. We do that by generating the text that we expect * pg_get_constraintdef() to output and compare against that instead of * trying to deal with the internally stored components like we did for the * UNIQUE constraint. * * We will use this same text to create the constraint if it doesn't exist. */ DECLARE withs text[]; BEGIN SELECT array_agg(format('%I WITH =', column_name) ORDER BY n.ordinality) INTO withs FROM unnest(column_names) WITH ORDINALITY AS n (column_name, ordinality); withs := withs || format('%I(%I, %I, ''[)''::text) WITH &&', period_row.range_type, period_row.start_column_name, period_row.end_column_name); exclude_sql := format('EXCLUDE USING gist (%s)', array_to_string(withs, ', ')); END; IF exclude_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, pg_catalog.pg_get_constraintdef(c.oid) AS definition INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, exclude_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', exclude_constraint; END IF; IF constraint_record.contype <> 'x' THEN RAISE EXCEPTION 'constraint "%" is not an EXCLUDE constraint', exclude_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', exclude_constraint; END IF; IF constraint_record.definition <> exclude_sql THEN RAISE EXCEPTION 'constraint "%" does not match', exclude_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * Generate a name for the unique constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* Time to make the underlying constraints */ alter_cmds := '{}'; IF unique_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || unique_sql); END IF; IF exclude_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || exclude_sql); END IF; IF alter_cmds <> '{}' THEN SELECT format('ALTER TABLE %I.%I %s', n.nspname, c.relname, array_to_string(alter_cmds, ', ')) INTO sql FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_name; EXECUTE sql; END IF; /* If we don't already have a unique_constraint, it must be the one with the highest oid */ IF unique_constraint IS NULL THEN SELECT c.conname, c.conindid INTO unique_constraint, unique_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'u') ORDER BY oid DESC LIMIT 1; END IF; /* If we don't already have an exclude_constraint, it must be the one with the highest oid */ IF exclude_constraint IS NULL THEN SELECT c.conname, c.conindid INTO exclude_constraint, exclude_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'x') ORDER BY oid DESC LIMIT 1; END IF; INSERT INTO periods.unique_keys (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint) VALUES (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_unique_key(table_name regclass, key_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_key_row periods.unique_keys; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR unique_key_row IN SELECT uk.* FROM periods.unique_keys AS uk WHERE uk.table_name = table_name AND (uk.key_name = key_name OR key_name IS NULL) LOOP /* Cascade to foreign keys, if desired */ FOR foreign_key_row IN SELECT fk.key_name FROM periods.foreign_keys AS fk WHERE fk.unique_key = unique_key_row.key_name LOOP IF drop_behavior = 'RESTRICT' THEN RAISE EXCEPTION 'cannot drop unique key "%" because foreign key "%" on table "%" depends on it', unique_key_row.key_name, foreign_key_row.key_name, foreign_key_row.table_name; END IF; PERFORM periods.drop_foreign_key(NULL, foreign_key_row.key_name); END LOOP; DELETE FROM periods.unique_keys AS uk WHERE uk.key_name = unique_key_row.key_name; /* If purging, drop the underlying constraints unless the table has been dropped */ IF purge AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_key_row.table_name) THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, DROP CONSTRAINT %I', unique_key_row.table_name, unique_key_row.unique_constraint, unique_key_row.exclude_constraint); END IF; END LOOP; END; $function$; CREATE FUNCTION periods.uk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is updated. It checks to verify that the referenced table still * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * If this is a NO ACTION constraint, we need to check if there is a new * row that still satisfies the constraint, in which case there is no * error. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, true); RETURN NULL; END; $function$; CREATE FUNCTION periods.uk_delete_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is deleted from. It checks to verify that the referenced table * still contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * The only difference between NO ACTION and RESTRICT is when the check is * done, so this function is used for both. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, false); RETURN NULL; END; $function$; CREATE FUNCTION periods.add_foreign_key( table_name regclass, column_names name[], period_name name, ref_unique_name name, match_type periods.fk_match_types DEFAULT 'SIMPLE', update_action periods.fk_actions DEFAULT 'NO ACTION', delete_action periods.fk_actions DEFAULT 'NO ACTION', key_name name DEFAULT NULL, fk_insert_trigger name DEFAULT NULL, fk_update_trigger name DEFAULT NULL, uk_update_trigger name DEFAULT NULL, uk_delete_trigger name DEFAULT NULL) RETURNS name LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; ref_period_row periods.periods; unique_row periods.unique_keys; column_attnums smallint[]; idx integer; pass integer; upd_action text DEFAULT ''; del_action text DEFAULT ''; foreign_columns text; unique_columns text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Get the period involved */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in referential constraints. SQL:2016 11.8 SR 10 */ IF period_row.period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in foreign keys'; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a foreign * key. SQL:2016 11.8 SR 10 */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Get column attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* Columns can't be part of any SYSTEM_TIME period */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* Get the unique key we're linking to */ SELECT uk.* INTO unique_row FROM periods.unique_keys AS uk WHERE uk.key_name = ref_unique_name; IF NOT FOUND THEN RAISE EXCEPTION 'unique key "%" does not exist', ref_unique_name; END IF; /* Get the unique key's period */ SELECT p.* INTO ref_period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (unique_row.table_name, unique_row.period_name); IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types "%" and "%" are incompatible', period_row.period_name, ref_period_row.period_name; END IF; /* Check that all the columns match */ IF EXISTS ( SELECT FROM unnest(column_names, unique_row.column_names) AS u (fk_attname, uk_attname) JOIN pg_catalog.pg_attribute AS fa ON (fa.attrelid, fa.attname) = (table_name, u.fk_attname) JOIN pg_catalog.pg_attribute AS ua ON (ua.attrelid, ua.attname) = (unique_row.table_name, u.uk_attname) WHERE (fa.atttypid, fa.atttypmod, fa.attcollation) <> (ua.atttypid, ua.atttypmod, ua.attcollation)) THEN RAISE EXCEPTION 'column types do not match'; END IF; /* The range types must match, too */ IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types do not match'; END IF; /* * Generate a name for the foreign constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* See if we're deferring the constraints or not */ IF update_action = 'NO ACTION' THEN upd_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; IF delete_action = 'NO ACTION' THEN del_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; /* Get the columns that require checking the constraint */ SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO foreign_columns FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO unique_columns FROM unnest(unique_row.column_names || ref_period_row.start_column_name || ref_period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); /* Time to make the underlying triggers */ fk_insert_trigger := coalesce(fk_insert_trigger, periods._choose_name(ARRAY[key_name], 'fk_insert')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER INSERT ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_insert_check(%L)', fk_insert_trigger, table_name, unique_row.table_name, key_name); fk_update_trigger := coalesce(fk_update_trigger, periods._choose_name(ARRAY[key_name], 'fk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_update_check(%L)', fk_update_trigger, foreign_columns, table_name, unique_row.table_name, key_name); uk_update_trigger := coalesce(uk_update_trigger, periods._choose_name(ARRAY[key_name], 'uk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_update_check(%L)', uk_update_trigger, unique_columns, unique_row.table_name, table_name, upd_action, key_name); uk_delete_trigger := coalesce(uk_delete_trigger, periods._choose_name(ARRAY[key_name], 'uk_delete')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER DELETE ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_delete_check(%L)', uk_delete_trigger, unique_row.table_name, table_name, del_action, key_name); INSERT INTO periods.foreign_keys (key_name, table_name, column_names, period_name, unique_key, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger) VALUES (key_name, table_name, column_names, period_name, unique_row.key_name, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger); /* Validate the constraint on existing data */ PERFORM periods.validate_foreign_key_new_row(key_name, NULL); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_foreign_key(table_name regclass, key_name name) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_table_name regclass; BEGIN IF table_name IS NULL AND key_name IS NULL THEN RAISE EXCEPTION 'no table or key name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR foreign_key_row IN SELECT fk.* FROM periods.foreign_keys AS fk WHERE (fk.table_name = table_name OR table_name IS NULL) AND (fk.key_name = key_name OR key_name IS NULL) LOOP DELETE FROM periods.foreign_keys AS fk WHERE fk.key_name = foreign_key_row.key_name; /* * Make sure the table hasn't been dropped and that the triggers exist * before doing these. We could use the IF EXISTS clause but we don't * in order to avoid the NOTICE. */ IF EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = foreign_key_row.table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = foreign_key_row.table_name AND t.tgname IN (foreign_key_row.fk_insert_trigger, foreign_key_row.fk_update_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_insert_trigger, foreign_key_row.table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_update_trigger, foreign_key_row.table_name); END IF; SELECT uk.table_name INTO unique_table_name FROM periods.unique_keys AS uk WHERE uk.key_name = foreign_key_row.unique_key; /* Ditto for the UNIQUE side. */ IF FOUND AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = unique_table_name AND t.tgname IN (foreign_key_row.uk_update_trigger, foreign_key_row.uk_delete_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_update_trigger, unique_table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_delete_trigger, unique_table_name); END IF; END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.fk_insert_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a new row is inserted into a table * containing foreign keys with periods. It checks to verify that the * referenced table contains the proper data to satisfy the foreign key * constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; CREATE FUNCTION periods.fk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a table containing foreign keys with * periods is updated. It checks to verify that the referenced table * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_old_row(foreign_key_name name, row_data jsonb, is_update boolean) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; column_name name; has_nulls boolean; uk_column_names text[]; uk_column_values text[]; fk_column_names text; violation boolean; still_matches boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %1$I.%2$I AS t ' ' WHERE ROW(%3$s) = ROW(%6$s) ' ' AND t.%4$I <= %7$L ' ' AND t.%5$I >= %8$L ' '%9$s' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, uc.oid AS uk_table_oid, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; FOREACH column_name IN ARRAY foreign_key_info.uk_column_names LOOP IF row_data->>column_name IS NULL THEN /* * If the deleted row had nulls in the referenced columns then * there was no possible referencing row (until we implement * PARTIAL) so we can just stop here. */ RETURN true; END IF; uk_column_names := uk_column_names || ('t.' || quote_ident(column_name)); uk_column_values := uk_column_values || quote_literal(row_data->>column_name); END LOOP; IF is_update AND foreign_key_info.update_action = 'NO ACTION' THEN EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, array_to_string(uk_column_names, ', '), foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, 'FOR KEY SHARE') INTO still_matches; IF still_matches THEN RETURN true; END IF; END IF; SELECT string_agg('t.' || quote_ident(u.c), ', ' ORDER BY u.ordinality) INTO fk_column_names FROM unnest(foreign_key_info.fk_column_names) WITH ORDINALITY AS u (c, ordinality); EXECUTE format(QSQL, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, fk_column_names, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, '') INTO violation; IF violation THEN RAISE EXCEPTION 'update or delete on table "%" violates foreign key constraint "%" on table "%"', foreign_key_info.uk_table_oid::regclass, foreign_key_name, foreign_key_info.fk_table_oid::regclass; END IF; RETURN true; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_new_row(foreign_key_name name, row_data jsonb) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; row_clause text DEFAULT 'true'; violation boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %5$I.%6$I AS fk ' ' WHERE NOT EXISTS ( ' ' SELECT FROM (SELECT uk.uk_start_value, ' ' uk.uk_end_value, ' ' nullif(lag(uk.uk_end_value) OVER (ORDER BY uk.uk_start_value), uk.uk_start_value) AS x ' ' FROM (SELECT uk.%3$I AS uk_start_value, ' ' uk.%4$I AS uk_end_value ' ' FROM %1$I.%2$I AS uk ' ' WHERE %9$s ' ' AND uk.%3$I <= fk.%8$I ' ' AND uk.%4$I >= fk.%7$I ' ' FOR KEY SHARE ' ' ) AS uk ' ' ) AS uk ' ' WHERE uk.uk_start_value < fk.%8$I ' ' AND uk.uk_end_value >= fk.%7$I ' ' HAVING min(uk.uk_start_value) <= fk.%7$I ' ' AND max(uk.uk_end_value) >= fk.%8$I ' ' AND array_agg(uk.x) FILTER (WHERE uk.x IS NOT NULL) IS NULL ' ' ) AND %10$s ' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; /* * Now that we have all of our names, we can see if there are any nulls in * the row we were given (if we were given one). */ IF row_data IS NOT NULL THEN DECLARE column_name name; has_nulls boolean; all_nulls boolean; cols text[] DEFAULT '{}'; vals text[] DEFAULT '{}'; BEGIN FOREACH column_name IN ARRAY foreign_key_info.fk_column_names LOOP has_nulls := has_nulls OR row_data->>column_name IS NULL; all_nulls := all_nulls IS NOT false AND row_data->>column_name IS NULL; cols := cols || ('fk.' || quote_ident(column_name)); vals := vals || quote_literal(row_data->>column_name); END LOOP; IF all_nulls THEN /* * If there are no values at all, all three types pass. * * Period columns are by definition NOT NULL so the FULL MATCH * type is only concerned with the non-period columns of the * constraint. SQL:2016 4.23.3.3 */ RETURN true; END IF; IF has_nulls THEN CASE foreign_key_info.match_type WHEN 'SIMPLE' THEN RETURN true; WHEN 'PARTIAL' THEN RAISE EXCEPTION 'partial not implemented'; WHEN 'FULL' THEN RAISE EXCEPTION 'foreign key violated (nulls in FULL)'; END CASE; END IF; row_clause := format(' (%s) = (%s)', array_to_string(cols, ', '), array_to_string(vals, ', ')); END; END IF; EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, (SELECT string_agg(format('%I = %I', ukc, fkc), ' AND ') FROM unnest(foreign_key_info.uk_column_names, foreign_key_info.fk_column_names) AS u (ukc, fkc) ), row_clause) INTO violation; IF violation THEN IF row_data IS NULL THEN RAISE EXCEPTION 'foreign key violated by some row'; ELSE RAISE EXCEPTION 'insert or update on table "%" violates foreign key constraint "%"', foreign_key_info.fk_table_oid::regclass, foreign_key_name; END IF; END IF; RETURN true; END; $function$; CREATE FUNCTION periods.add_system_versioning( table_class regclass, history_table_name name DEFAULT NULL, view_name name DEFAULT NULL, function_as_of_name name DEFAULT NULL, function_between_name name DEFAULT NULL, function_between_symmetric_name name DEFAULT NULL, function_from_to_name name DEFAULT NULL) RETURNS void LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE schema_name name; table_name name; persistence "char"; kind "char"; period_row periods.periods; history_table_id oid; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.29 */ /* Already registered? SQL:2016 11.29 SR 5 */ IF EXISTS (SELECT FROM periods.system_versioning AS r WHERE r.table_name = table_class) THEN RAISE EXCEPTION 'table already has SYSTEM VERSIONING'; END IF; /* Must be a regular persistent base table. SQL:2016 11.29 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* * We could probably accept unlogged tables if the history table is * also unlogged, but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* We need a SYSTEM_TIME period. SQL:2016 11.29 SR 4 */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, 'system_time'); IF NOT FOUND THEN RAISE EXCEPTION 'no period for SYSTEM_TIME found for table %', table_class; END IF; /* Get all of our "fake" infrastructure ready */ history_table_name := coalesce(history_table_name, periods._choose_name(ARRAY[table_name], 'history')); view_name := coalesce(view_name, periods._choose_name(ARRAY[table_name], 'with_history')); function_as_of_name := coalesce(function_as_of_name, periods._choose_name(ARRAY[table_name], '_as_of')); function_between_name := coalesce(function_between_name, periods._choose_name(ARRAY[table_name], '_between')); function_between_symmetric_name := coalesce(function_between_symmetric_name, periods._choose_name(ARRAY[table_name], '_between_symmetric')); function_from_to_name := coalesce(function_from_to_name, periods._choose_name(ARRAY[table_name], '_from_to')); /* * Create the history table. If it already exists we check that all the * columns match but otherwise we trust the user. Perhaps the history * table was disconnected in order to change the schema (a case which is * not defined by the SQL standard). Or perhaps the user wanted to * partition the history table. * * There shouldn't be any concurrency issues here because our main catalog * is locked. */ SELECT c.oid INTO history_table_id FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (n.nspname, c.relname) = (schema_name, history_table_name); IF FOUND THEN /* Don't allow any periods on the system table (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.periods AS p WHERE p.table_name = history_table_id) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* * The query to the attributes is harder than one would think because * we need to account for dropped columns. Basically what we're * looking for is that all columns have the same name, type, and * collation. */ IF EXISTS ( WITH L (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = table_class AND NOT a.attisdropped ), R (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = history_table_id AND NOT a.attisdropped ) SELECT FROM L NATURAL FULL JOIN R WHERE L.attname IS NULL OR R.attname IS NULL) THEN RAISE EXCEPTION 'base table "%" and history table "%" are not compatible', table_class, history_table_id::regclass; END IF; ELSE EXECUTE format('CREATE TABLE %1$I.%2$I (LIKE %1$I.%3$I)', schema_name, history_table_name, table_name); history_table_id := format('%I.%I', schema_name, history_table_name)::regclass; RAISE NOTICE 'history table "%" created for "%", be sure to index it properly', history_table_id::regclass, table_class; END IF; /* Create the "with history" view. This one we do want to error out on if it exists. */ EXECUTE format( /* * The query we really here want is * * CREATE VIEW view_name AS * TABLE table_name * UNION ALL CORRESPONDING * TABLE history_table_name * * but PostgreSQL doesn't support that syntax (yet), so we have to do * it manually. */ 'CREATE VIEW %1$I.%2$I AS SELECT %5$s FROM %1$I.%3$I UNION ALL SELECT %5$s FROM %1$I.%4$I', schema_name, view_name, table_name, history_table_name, (SELECT string_agg(a.attname, ', ' ORDER BY a.attnum) FROM pg_attribute AS a WHERE a.attrelid = table_class AND a.attnum > 0 AND NOT a.attisdropped )); /* * Create functions to simulate the system versioned grammar. These must * be inlinable for any kind of performance. */ EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %4$I <= $1 AND %5$I > $1' $$, schema_name, function_as_of_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 <= $2 AND %5$I > $1 AND %4$I <= $2' $$, schema_name, function_between_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %5$I > least($1, $2) AND %4$I <= greatest($1, $2)' $$, schema_name, function_between_symmetric_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 < $2 AND %5$I > $1 AND %4$I < $2' $$, schema_name, function_from_to_name, view_name, period_row.start_column_name, period_row.end_column_name); /* Register it */ INSERT INTO periods.system_versioning (table_name, period_name, history_table_name, view_name, func_as_of, func_between, func_between_symmetric, func_from_to) VALUES ( table_class, 'system_time', format('%I.%I', schema_name, history_table_name), format('%I.%I', schema_name, view_name), format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure ); END; $function$; CREATE FUNCTION periods.drop_system_versioning(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE system_versioning_row periods.system_versioning; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.30 */ /* * We need to delete our row first so that the DROP protection doesn't * block us. */ DELETE FROM periods.system_versioning AS sv WHERE sv.table_name = table_name RETURNING * INTO system_versioning_row; IF NOT FOUND THEN RAISE NOTICE 'table % does not have SYSTEM VERSIONING', table_name; RETURN false; END IF; /* * Has the table been dropped? If so, everything else is also dropped * except for the history table. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); IF NOT is_dropped THEN /* Drop the functions. */ EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_as_of::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between_symmetric::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_from_to::regprocedure, drop_behavior); /* Drop the "with_history" view. */ EXECUTE format('DROP VIEW %s %s', system_versioning_row.view_name, drop_behavior); END IF; /* * SQL:2016 11.30 GR 2 says "Every row of T that corresponds to a * historical system row is effectively deleted at the end of the SQL- * statement." but we leave the history table intact in case the user * merely wants to make some DDL changes and hook things back up again. * * The purge parameter tells us that the user really wants to get rid of it * all. */ IF NOT is_dropped AND purge THEN PERFORM periods.drop_period(table_name, 'system_time', drop_behavior, purge); EXECUTE format('DROP TABLE %s %s', system_versioning_row.history_table_name, drop_behavior); END IF; RETURN true; END; $function$; CREATE FUNCTION periods.drop_protection() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; table_name regclass; period_name name; BEGIN /* * This function is called after the fact, so we have to just look to see * if anything is missing in the catalogs if we just store the name and not * a reg* type. */ --- --- periods --- /* If one of our tables is being dropped, remove references to it */ FOR table_name, period_name IN SELECT p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP PERFORM periods.drop_period(table_name, period_name, 'CASCADE', true); END LOOP; /* * If a column belonging to one of our periods is dropped, we need to reject that. * SQL:2016 11.23 SR 6 */ FOR r IN SELECT dobj.object_identity, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_attribute AS sa ON (sa.attrelid, sa.attname) = (p.table_name, p.start_column_name) JOIN pg_catalog.pg_attribute AS ea ON (ea.attrelid, ea.attname) = (p.table_name, p.end_column_name) JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name AND dobj.objsubid IN (sa.attnum, ea.attnum) WHERE dobj.object_type = 'table column' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop column "%" because it is part of the period "%"', r.object_identity, r.period_name; END LOOP; /* Also reject dropping the rangetype */ FOR r IN SELECT dobj.object_identity, p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.range_type ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop rangetype "%" because it is used in period "%" on table "%"', r.object_identity, r.period_name, r.table_name; END LOOP; --- --- system_time_periods --- /* Complain if the infinity CHECK constraint is missing. */ FOR r IN SELECT p.table_name, p.infinity_check_constraint FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (p.table_name, p.infinity_check_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in SYSTEM_TIME period', r.infinity_check_constraint, r.table_name; END LOOP; /* Complain if the GENERATED ALWAYS AS ROW START/END trigger is missing. */ FOR r IN SELECT p.table_name, p.generated_always_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.generated_always_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.generated_always_trigger, r.table_name; END LOOP; /* Complain if the write_history trigger is missing. */ FOR r IN SELECT p.table_name, p.write_history_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.write_history_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.write_history_trigger, r.table_name; END LOOP; /* Complain if the TRUNCATE trigger is missing. */ FOR r IN SELECT p.table_name, p.truncate_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.truncate_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.truncate_trigger, r.table_name; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- /* Reject dropping the FOR PORTION OF view. */ FOR r IN SELECT dobj.object_identity FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = fpv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%", call "periods.drop_for_portion_view()" instead', r.object_identity; END LOOP; /* Complain if the FOR PORTION OF trigger is missing. */ FOR r IN SELECT fpv.table_name, fpv.period_name, fpv.view_name, fpv.trigger_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fpv.view_name, fpv.trigger_name)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on view "%" because it is used in FOR PORTION OF view for period "%" on table "%"', r.trigger_name, r.view_name, r.period_name, r.table_name; END LOOP; /* Complain if the table's primary key has been dropped. */ FOR r IN SELECT fpv.table_name, fpv.period_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (fpv.table_name, 'p')) LOOP RAISE EXCEPTION 'cannot drop primary key on table "%" because it has a FOR PORTION OF view for period "%"', r.table_name, r.period_name; END LOOP; --- --- unique_keys --- /* * We don't need to protect the individual columns as long as we protect * the indexes. PostgreSQL will make sure they stick around. */ /* Complain if the indexes implementing our unique indexes are missing. */ FOR r IN SELECT uk.key_name, uk.table_name, uk.unique_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.unique_constraint, r.table_name, r.key_name; END LOOP; FOR r IN SELECT uk.key_name, uk.table_name, uk.exclude_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.exclude_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.exclude_constraint, r.table_name, r.key_name; END LOOP; --- --- foreign_keys --- /* Complain if any of the triggers are missing */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_insert_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_update_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_update_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_delete_trigger, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.history_table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop table "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid IN (sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to) WHERE dobj.object_type = 'function' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop function "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; END; $function$; CREATE EVENT TRIGGER periods_drop_protection ON sql_drop EXECUTE PROCEDURE periods.drop_protection(); CREATE FUNCTION periods.rename_following() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; sql text; BEGIN /* * Anything that is stored by reg* type will auto-adjust, but anything we * store by name will need to be updated after a rename. One way to do this * is to recreate the constraints we have and pull new names out that way. * If we are unable to do something like that, we must raise an exception. */ --- --- periods --- /* * Start and end columns of a period can be found by the bounds check * constraint. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET start_column_name = %L, end_column_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', sa.attname, ea.attname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (p.table_name, p.bounds_check_constraint) JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE (p.start_column_name, p.end_column_name) <> (sa.attname, ea.attname) AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) LOOP EXECUTE sql; END LOOP; /* * Inversely, the bounds check constraint can be retrieved via the start * and end columns. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET bounds_check_constraint = %L WHERE (table_name, period_name) = (%L::regclass, %L)', c.conname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE p.bounds_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) AND (p.start_column_name, p.end_column_name) = (sa.attname, ea.attname) AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (p.table_name, p.bounds_check_constraint)) LOOP EXECUTE sql; END LOOP; --- --- system_time_periods --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET infinity_check_constraint = %L WHERE table_name = %L::regclass', c.conname, p.table_name) FROM periods.periods AS p JOIN periods.system_time_periods AS stp ON (stp.table_name, stp.period_name) = (p.table_name, p.period_name) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE stp.infinity_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I = ''infinity''::%s))', ea.attname, format_type(ea.atttypid, ea.atttypmod)) AND p.end_column_name = ea.attname AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (stp.table_name, stp.infinity_check_constraint)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET generated_always_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.generated_always_trigger AND t.tgfoid = 'periods.generated_always_as_row_start_end()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.generated_always_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET write_history_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.write_history_trigger AND t.tgfoid = 'periods.write_history()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.write_history_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET truncate_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.truncate_trigger AND t.tgfoid = 'periods.truncate_system_versioning()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.truncate_trigger)) LOOP EXECUTE sql; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.for_portion_views SET trigger_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', t.tgname, fpv.table_name, fpv.period_name) FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = fpv.view_name WHERE t.tgname <> fpv.trigger_name AND t.tgfoid = 'periods.update_portion_of()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (fpv.table_name, fpv.trigger_name)) LOOP EXECUTE sql; END LOOP; --- --- unique_keys --- FOR sql IN SELECT format('UPDATE periods.unique_keys SET column_names = %L WHERE key_name = %L', a.column_names, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint) JOIN LATERAL ( SELECT array_agg(a.attname ORDER BY u.ordinality) AS column_names FROM unnest(c.conkey) WITH ORDINALITY AS u (attnum, ordinality) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attnum) = (uk.table_name, u.attnum) WHERE a.attname NOT IN (p.start_column_name, p.end_column_name) ) AS a ON true WHERE uk.column_names <> a.column_names LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET unique_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names || ARRAY[p.start_column_name, p.end_column_name]) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.unique_constraint)) GROUP BY uk.key_name, c.oid, c.conname HAVING format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET exclude_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.exclude_constraint)) GROUP BY uk.key_name, c.oid, c.conname, p.range_type, p.start_column_name, p.end_column_name HAVING format('EXCLUDE USING gist (%s, %I(%I, %I, ''[)''::text) WITH &&)', string_agg(quote_ident(u.column_name) || ' WITH =', ', ' ORDER BY u.ordinality), p.range_type, p.start_column_name, p.end_column_name) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; --- --- foreign_keys --- /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT fk.key_name, fk.table_name, u.column_name FROM periods.foreign_keys AS fk CROSS JOIN LATERAL unnest(fk.column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (fk.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is used in period foreign key "%"', r.column_name, r.table_name, r.key_name; END LOOP; /* * Since there can be multiple foreign keys, there is no reliable way to * know which trigger might belong to what, so just error out. */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) UNION ALL SELECT fk.key_name, fk.table_name, fk.fk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop or rename trigger "%" on table "%" because it is used in period foreign key "%"', r.trigger_name, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- /* Nothing to do here */ END; $function$; CREATE EVENT TRIGGER periods_rename_following ON ddl_command_end EXECUTE PROCEDURE periods.rename_following(); CREATE FUNCTION periods.health_checks() RETURNS event_trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE r record; BEGIN /* Make sure that all of our tables are still persistent */ FOR r IN SELECT p.table_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* And the history tables, too */ FOR r IN SELECT sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_class AS c ON c.oid = sv.history_table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'history table "%" must remain persistent because it has periods', r.table_name; END LOOP; END; $function$; CREATE EVENT TRIGGER periods_health_checks ON ddl_command_end EXECUTE PROCEDURE periods.health_checks(); /* Predicates */ CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, ve anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= ve AND ev1 > ve; $function$; CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= sv2 AND ev1 >= ev2; $function$; CREATE FUNCTION periods.equals(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = sv2 AND ev1 = ev2; $function$; CREATE FUNCTION periods.overlaps(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 < ev2 AND ev1 > sv2; $function$; CREATE FUNCTION periods.precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 <= sv2; $function$; CREATE FUNCTION periods.succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 >= ev2; $function$; CREATE FUNCTION periods.immediately_precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 = sv2; $function$; CREATE FUNCTION periods.immediately_succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = ev2; $function$; periods-1.2.2/periods--1.2.sql000066400000000000000000004257211432551570100157400ustar00rootroot00000000000000-- complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION periods" to load this file. \quit /* This extension is non-relocatable */ CREATE SCHEMA periods; GRANT USAGE ON SCHEMA periods TO PUBLIC; CREATE TYPE periods.drop_behavior AS ENUM ('CASCADE', 'RESTRICT'); CREATE TYPE periods.fk_actions AS ENUM ('CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION'); CREATE TYPE periods.fk_match_types AS ENUM ('FULL', 'PARTIAL', 'SIMPLE'); /* * All referencing columns must be either name or regsomething in order for * pg_dump to work properly. Plain OIDs are not allowed but attribute numbers * are, so that we don't have to track renames. * * Anything declared as regsomething and created for the period (such as the * "__as_of" function), should be UNIQUE. If Postgres already verifies * uniqueness, such as constraint names on a table, then we don't need to do it * also. */ CREATE TABLE periods.periods ( table_name regclass NOT NULL, period_name name NOT NULL, start_column_name name NOT NULL, end_column_name name NOT NULL, range_type regtype NOT NULL, bounds_check_constraint name NOT NULL, PRIMARY KEY (table_name, period_name), CHECK (start_column_name <> end_column_name) ); GRANT SELECT ON TABLE periods.periods TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.periods', ''); CREATE TABLE periods.system_time_periods ( table_name regclass NOT NULL, period_name name NOT NULL, infinity_check_constraint name NOT NULL, generated_always_trigger name NOT NULL, write_history_trigger name NOT NULL, truncate_trigger name NOT NULL, excluded_column_names name[] NOT NULL DEFAULT '{}', PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time') ); GRANT SELECT ON TABLE periods.system_time_periods TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.system_time_periods', ''); COMMENT ON TABLE periods.periods IS 'The main catalog for periods. All "DDL" operations for periods must first take an exclusive lock on this table.'; CREATE VIEW periods.information_schema__periods AS SELECT current_catalog AS table_catalog, n.nspname AS table_schema, c.relname AS table_name, p.period_name, p.start_column_name, p.end_column_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace; CREATE TABLE periods.for_portion_views ( table_name regclass NOT NULL, period_name name NOT NULL, view_name regclass NOT NULL, trigger_name name NOT NULL, PRIMARY KEY (table_name, period_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, UNIQUE (view_name) ); GRANT SELECT ON TABLE periods.for_portion_views TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.for_portion_views', ''); CREATE TABLE periods.unique_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_constraint name NOT NULL, exclude_constraint name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods ); GRANT SELECT ON TABLE periods.unique_keys TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.unique_keys', ''); COMMENT ON TABLE periods.unique_keys IS 'A registry of UNIQUE/PRIMARY keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.foreign_keys ( key_name name NOT NULL, table_name regclass NOT NULL, column_names name[] NOT NULL, period_name name NOT NULL, unique_key name NOT NULL, match_type periods.fk_match_types NOT NULL DEFAULT 'SIMPLE', delete_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', update_action periods.fk_actions NOT NULL DEFAULT 'NO ACTION', fk_insert_trigger name NOT NULL, fk_update_trigger name NOT NULL, uk_update_trigger name NOT NULL, uk_delete_trigger name NOT NULL, PRIMARY KEY (key_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, FOREIGN KEY (unique_key) REFERENCES periods.unique_keys, CHECK (delete_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')), CHECK (update_action NOT IN ('CASCADE', 'SET NULL', 'SET DEFAULT')) ); GRANT SELECT ON TABLE periods.foreign_keys TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.foreign_keys', ''); COMMENT ON TABLE periods.foreign_keys IS 'A registry of foreign keys using periods WITHOUT OVERLAPS'; CREATE TABLE periods.system_versioning ( table_name regclass NOT NULL, period_name name NOT NULL, history_table_name regclass NOT NULL, view_name regclass NOT NULL, -- These functions should be of type regprocedure, but that blocks pg_upgrade. func_as_of text NOT NULL, func_between text NOT NULL, func_between_symmetric text NOT NULL, func_from_to text NOT NULL, PRIMARY KEY (table_name), FOREIGN KEY (table_name, period_name) REFERENCES periods.periods, CHECK (period_name = 'system_time'), UNIQUE (history_table_name), UNIQUE (view_name), UNIQUE (func_as_of), UNIQUE (func_between), UNIQUE (func_between_symmetric), UNIQUE (func_from_to) ); GRANT SELECT ON TABLE periods.system_versioning TO PUBLIC; SELECT pg_catalog.pg_extension_config_dump('periods.system_versioning', ''); COMMENT ON TABLE periods.system_versioning IS 'A registry of tables with SYSTEM VERSIONING'; /* * These function starting with "_" are private to the periods extension and * should not be called by outsiders. When all the other functions have been * translated to C, they will be removed. */ CREATE FUNCTION periods._serialize(table_name regclass) RETURNS void LANGUAGE sql AS $function$ /* XXX: Is this the best way to do locking? */ SELECT pg_catalog.pg_advisory_xact_lock('periods.periods'::regclass::oid::integer, table_name::oid::integer); $function$; CREATE FUNCTION periods._choose_name(resizable text[], fixed text DEFAULT NULL, separator text DEFAULT '_', extra integer DEFAULT 2) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the resizable texts until they and the fixed text fit in * NAMEDATALEN. This probably isn't very efficient but it's not on a hot * code path so we don't care. */ SELECT max(length(t)) INTO max_length FROM unnest(resizable) AS u (t); LOOP result := format('%s%s', array_to_string(resizable, separator), separator || fixed); IF octet_length(result) <= NAMEDATALEN-extra-1 THEN RETURN result; END IF; max_length := max_length - 1; resizable := ARRAY ( SELECT left(t, max_length) FROM unnest(resizable) WITH ORDINALITY AS u (t, o) ORDER BY o ); END LOOP; END; $function$; CREATE FUNCTION periods._choose_portion_view_name(table_name name, period_name name) RETURNS name IMMUTABLE LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE max_length integer; result text; NAMEDATALEN CONSTANT integer := 64; BEGIN /* * Reduce the table and period names until they fit in NAMEDATALEN. This * probably isn't very efficient but it's not on a hot code path so we * don't care. */ max_length := greatest(length(table_name), length(period_name)); LOOP result := format('%s__for_portion_of_%s', table_name, period_name); IF octet_length(result) <= NAMEDATALEN-1 THEN RETURN result; END IF; max_length := max_length - 1; table_name := left(table_name, max_length); period_name := left(period_name, max_length); END LOOP; END; $function$; CREATE FUNCTION periods.add_period( table_name regclass, period_name name, start_column_name name, end_column_name name, range_type regtype DEFAULT NULL, bounds_check_constraint name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE table_name_only name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 11.27 */ /* Don't allow anything on system versioning history tables (this will be relaxed later) */ IF EXISTS (SELECT FROM periods.system_versioning AS sv WHERE sv.history_table_name = table_name) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* Period names are limited to lowercase alphanumeric characters for now */ period_name := lower(period_name); IF period_name !~ '^[a-z_][0-9a-z_]*$' THEN RAISE EXCEPTION 'only alphanumeric characters are currently allowed'; END IF; IF period_name = 'system_time' THEN RETURN periods.add_system_time_period(table_name, start_column_name, end_column_name); END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT c.relpersistence, c.relkind INTO persistence, kind FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * because I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_name; END IF; /* * Check if period already exists. Actually no other application time * periods are allowed per spec, but we don't obey that. We can have as * many application time periods as we want. * * SQL:2016 11.27 SR 5.b */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period for "%" already exists on table "%"', period_name, table_name; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 5.c */ IF EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, period_name)) THEN RAISE EXCEPTION 'a column named "%" already exists for table "%"', period_name, table_name; END IF; /* * Contrary to SYSTEM_TIME periods, the columns must exist already for * application time periods. * * SQL:2016 11.27 SR 5.d */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO start_attnum, start_type, start_collation, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, start_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', start_column_name, table_name; END IF; IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attcollation, a.attnotnull INTO end_attnum, end_type, end_collation, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, end_column_name); IF NOT FOUND THEN RAISE EXCEPTION 'column "%" not found in table "%"', end_column_name, table_name; END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* * Verify compatibility of start/end columns. The standard says these must * be either date or timestamp, but we allow anything with a corresponding * range type because why not. * * SQL:2016 11.27 SR 5.g */ IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; IF start_collation <> end_collation THEN RAISE EXCEPTION 'start and end columns must be of same collation'; END IF; /* Get the range type that goes with these columns */ IF range_type IS NOT NULL THEN IF NOT EXISTS ( SELECT FROM pg_catalog.pg_range AS r WHERE (r.rngtypid, r.rngsubtype, r.rngcollation) = (range_type, start_type, start_collation)) THEN RAISE EXCEPTION 'range "%" does not match data type "%"', range_type, start_type; END IF; ELSE SELECT r.rngtypid INTO range_type FROM pg_catalog.pg_range AS r JOIN pg_catalog.pg_opclass AS c ON c.oid = r.rngsubopc WHERE (r.rngsubtype, r.rngcollation) = (start_type, start_collation) AND c.opcdefault; IF NOT FOUND THEN RAISE EXCEPTION 'no default range type for %', start_type::regtype; END IF; END IF; /* * Period columns must not be nullable. * * SQL:2016 11.27 SR 5.h */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_name; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_name AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name_only FROM pg_catalog.pg_class AS c WHERE c.oid = table_name; bounds_check_constraint := periods._choose_name(ARRAY[table_name_only, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %s %s', table_name, array_to_string(alter_commands, ', ')); END IF; INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); RETURN true; END; $function$; CREATE FUNCTION periods.drop_period(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; system_time_period_row periods.system_time_periods; system_versioning_row periods.system_versioning; portion_view regclass; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; IF period_name IS NULL THEN RAISE EXCEPTION 'no period name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * Has the table been dropped already? This could happen if the period is * being dropped by the drop_protection event trigger or through a DROP * CASCADE. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE NOTICE 'period % not found on table %', period_name, table_name; RETURN false; END IF; /* Drop the "for portion" view if it hasn't been dropped already */ PERFORM periods.drop_for_portion_view(table_name, period_name, drop_behavior, purge); /* If this is a system_time period, get rid of the triggers */ DELETE FROM periods.system_time_periods AS stp WHERE stp.table_name = table_name RETURNING stp.* INTO system_time_period_row; IF FOUND AND NOT is_dropped THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, system_time_period_row.infinity_check_constraint); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.generated_always_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.write_history_trigger, table_name); EXECUTE format('DROP TRIGGER %I ON %s', system_time_period_row.truncate_trigger, table_name); END IF; IF drop_behavior = 'RESTRICT' THEN /* Check for UNIQUE or PRIMARY KEYs */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a UNIQUE or PRIMARY KEY', period_name; END IF; /* Check for FOREIGN KEYs */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'period % is part of a FOREIGN KEY', period_name; END IF; /* Check for SYSTEM VERSIONING */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN RAISE EXCEPTION 'table % has SYSTEM VERSIONING', table_name; END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END IF; /* We must be in CASCADE mode now */ PERFORM periods.drop_foreign_key(table_name, fk.key_name) FROM periods.foreign_keys AS fk WHERE (fk.table_name, fk.period_name) = (table_name, period_name); PERFORM periods.drop_unique_key(table_name, uk.key_name, drop_behavior, purge) FROM periods.unique_keys AS uk WHERE (uk.table_name, uk.period_name) = (table_name, period_name); /* * Save ourselves the NOTICE if this table doesn't have SYSTEM * VERSIONING. * * We don't do like above because the purge is different. We don't want * dropping SYSTEM VERSIONING to drop our infinity constraint; only * dropping the PERIOD should do that. */ IF EXISTS ( SELECT FROM periods.system_versioning AS sv WHERE (sv.table_name, sv.period_name) = (table_name, period_name)) THEN PERFORM periods.drop_system_versioning(table_name, drop_behavior, purge); END IF; /* Delete bounds check constraint if purging */ IF NOT is_dropped AND purge THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', table_name, period_row.bounds_check_constraint); END IF; /* Remove from catalog */ DELETE FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); RETURN true; END; $function$; CREATE FUNCTION periods.add_system_time_period( table_class regclass, start_column_name name DEFAULT 'system_time_start', end_column_name name DEFAULT 'system_time_end', bounds_check_constraint name DEFAULT NULL, infinity_check_constraint name DEFAULT NULL, generated_always_trigger name DEFAULT NULL, write_history_trigger name DEFAULT NULL, truncate_trigger name DEFAULT NULL, excluded_column_names name[] DEFAULT '{}') RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE period_name CONSTANT name := 'system_time'; schema_name name; table_name name; kind "char"; persistence "char"; alter_commands text[] DEFAULT '{}'; start_attnum smallint; start_type oid; start_collation oid; start_notnull boolean; end_attnum smallint; end_type oid; end_collation oid; end_notnull boolean; excluded_column_name name; DATE_OID CONSTANT integer := 1082; TIMESTAMP_OID CONSTANT integer := 1114; TIMESTAMPTZ_OID CONSTANT integer := 1184; range_type regtype; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.7 * SQL:2016 11.27 */ /* The columns must not be part of UNIQUE keys. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Must be a regular persistent base table. SQL:2016 11.27 SR 2 */ SELECT n.nspname, c.relname, c.relpersistence, c.relkind INTO schema_name, table_name, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * beceuase I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* We could probably accept unlogged tables but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* * Check if period already exists. * * SQL:2016 11.27 SR 4.a */ IF EXISTS (SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, period_name)) THEN RAISE EXCEPTION 'period for SYSTEM_TIME already exists on table "%"', table_class; END IF; /* * Although we are not creating a new object, the SQL standard says that * periods are in the same namespace as columns, so prevent that. * * SQL:2016 11.27 SR 4.b */ IF EXISTS (SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, period_name)) THEN RAISE EXCEPTION 'a column named system_time already exists for table "%"', table_class; END IF; /* The standard says that the columns must not exist already, but we don't obey that rule for now. */ /* Get start column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO start_attnum, start_type, start_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); IF NOT FOUND THEN /* * First add the column with DEFAULT of -infinity to fill the * current rows, then replace the DEFAULT with transaction_timestamp() for future * rows. * * The default value is just for self-documentation anyway because * the trigger will enforce the value. */ alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''-infinity''', start_column_name); start_attnum := 0; start_type := 'timestamp with time zone'::regtype; start_notnull := true; END IF; alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT transaction_timestamp()', start_column_name); IF start_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Get end column information */ SELECT a.attnum, a.atttypid, a.attnotnull INTO end_attnum, end_type, end_notnull FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); IF NOT FOUND THEN alter_commands := alter_commands || format('ADD COLUMN %I timestamp with time zone NOT NULL DEFAULT ''infinity''', end_column_name); end_attnum := 0; end_type := 'timestamp with time zone'::regtype; end_notnull := true; ELSE alter_commands := alter_commands || format('ALTER COLUMN %I SET DEFAULT ''infinity''', end_column_name); END IF; IF end_attnum < 0 THEN RAISE EXCEPTION 'system columns cannot be used in periods'; END IF; /* Verify compatibility of start/end columns */ IF start_type::regtype NOT IN ('date', 'timestamp without time zone', 'timestamp with time zone') THEN RAISE EXCEPTION 'SYSTEM_TIME periods must be of type "date", "timestamp without time zone", or "timestamp with time zone"'; END IF; IF start_type <> end_type THEN RAISE EXCEPTION 'start and end columns must be of same type'; END IF; /* Get appropriate range type */ CASE start_type WHEN DATE_OID THEN range_type := 'daterange'; WHEN TIMESTAMP_OID THEN range_type := 'tsrange'; WHEN TIMESTAMPTZ_OID THEN range_type := 'tstzrange'; ELSE RAISE EXCEPTION 'unexpected data type: "%"', start_type::regtype; END CASE; /* can't be part of a foreign key */ IF EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.table_name = table_class AND fk.column_names && ARRAY[start_column_name, end_column_name]) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* * Period columns must not be nullable. */ IF NOT start_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', start_column_name); END IF; IF NOT end_notnull THEN alter_commands := alter_commands || format('ALTER COLUMN %I SET NOT NULL', end_column_name); END IF; /* * Find and appropriate a CHECK constraint to make sure that start < end. * Create one if necessary. * * SQL:2016 11.27 GR 2.b */ DECLARE condef CONSTANT text := format('CHECK ((%I < %I))', start_column_name, end_column_name); context text; BEGIN IF bounds_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, bounds_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', bounds_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO bounds_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; bounds_check_constraint := periods._choose_name(ARRAY[table_name, period_name], 'check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', bounds_check_constraint, condef); END IF; END IF; END; /* * Find and appropriate a CHECK constraint to make sure that end = 'infinity'. * Create one if necessary. * * SQL:2016 4.15.2.2 */ DECLARE condef CONSTANT text := format('CHECK ((%I = ''infinity''::timestamp with time zone))', end_column_name); context text; BEGIN IF infinity_check_constraint IS NOT NULL THEN /* We were given a name, does it exist? */ SELECT pg_catalog.pg_get_constraintdef(c.oid) INTO context FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_class, infinity_check_constraint) AND c.contype = 'c'; IF FOUND THEN /* Does it match? */ IF context <> condef THEN RAISE EXCEPTION 'constraint "%" on table "%" does not match', infinity_check_constraint, table_class; END IF; ELSE /* If it doesn't exist, we'll use the name for the one we create. */ alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; ELSE /* No name given, can we appropriate one? */ SELECT c.conname INTO infinity_check_constraint FROM pg_catalog.pg_constraint AS c WHERE c.conrelid = table_class AND c.contype = 'c' AND pg_catalog.pg_get_constraintdef(c.oid) = condef; /* Make our own then */ IF NOT FOUND THEN SELECT c.relname INTO table_name FROM pg_catalog.pg_class AS c WHERE c.oid = table_class; infinity_check_constraint := periods._choose_name(ARRAY[table_name, end_column_name], 'infinity_check'); alter_commands := alter_commands || format('ADD CONSTRAINT %I %s', infinity_check_constraint, condef); END IF; END IF; END; /* If we've created any work for ourselves, do it now */ IF alter_commands <> '{}' THEN EXECUTE format('ALTER TABLE %I.%I %s', schema_name, table_name, array_to_string(alter_commands, ', ')); IF start_attnum = 0 THEN SELECT a.attnum INTO start_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, start_column_name); END IF; IF end_attnum = 0 THEN SELECT a.attnum INTO end_attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, end_column_name); END IF; END IF; /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_class, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_class, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; generated_always_trigger := coalesce( generated_always_trigger, periods._choose_name(ARRAY[table_name], 'system_time_generated_always')); EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.generated_always_as_row_start_end()', generated_always_trigger, table_class); write_history_trigger := coalesce( write_history_trigger, periods._choose_name(ARRAY[table_name], 'system_time_write_history')); EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE PROCEDURE periods.write_history()', write_history_trigger, table_class); truncate_trigger := coalesce( truncate_trigger, periods._choose_name(ARRAY[table_name], 'truncate')); EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE PROCEDURE periods.truncate_system_versioning()', truncate_trigger, table_class); INSERT INTO periods.periods (table_name, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint) VALUES (table_class, period_name, start_column_name, end_column_name, range_type, bounds_check_constraint); INSERT INTO periods.system_time_periods ( table_name, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names) VALUES ( table_class, period_name, infinity_check_constraint, generated_always_trigger, write_history_trigger, truncate_trigger, excluded_column_names); RETURN true; END; $function$; CREATE FUNCTION periods.set_system_time_period_excluded_columns( table_name regclass, excluded_column_names name[]) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE excluded_column_name name; BEGIN /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Make sure all the excluded columns exist */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (table_name, u.name)) LOOP RAISE EXCEPTION 'column "%" does not exist', excluded_column_name; END LOOP; /* Don't allow system columns to be excluded either */ FOR excluded_column_name IN SELECT u.name FROM unnest(excluded_column_names) AS u (name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, u.name) WHERE a.attnum < 0 LOOP RAISE EXCEPTION 'cannot exclude system column "%"', excluded_column_name; END LOOP; /* Do it. */ UPDATE periods.system_time_periods AS stp SET excluded_column_names = excluded_column_names WHERE stp.table_name = table_name; END; $function$; CREATE FUNCTION periods.drop_system_time_period(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE sql SECURITY DEFINER AS $function$ SELECT periods.drop_period(table_name, 'system_time', drop_behavior, purge); $function$; CREATE FUNCTION periods.generated_always_as_row_start_end() RETURNS trigger LANGUAGE c STRICT SECURITY DEFINER AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.write_history() RETURNS trigger LANGUAGE c STRICT SECURITY DEFINER AS 'MODULE_PATHNAME'; CREATE FUNCTION periods.truncate_system_versioning() RETURNS trigger LANGUAGE plpgsql STRICT SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE history_table_name name; BEGIN SELECT sv.history_table_name INTO history_table_name FROM periods.system_versioning AS sv WHERE sv.table_name = TG_RELID; IF FOUND THEN EXECUTE format('TRUNCATE %s', history_table_name); END IF; RETURN NULL; END; $function$; CREATE FUNCTION periods.add_for_portion_view(table_name regclass DEFAULT NULL, period_name name DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE r record; view_name name; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just add the views for that. * * If no period is specified, add the views for all periods of the table. * * If no table is specified, add the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Can't use FOR PORTION OF on SYSTEM_TIME columns */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'cannot use FOR PORTION OF on SYSTEM_TIME periods'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * We require the table to have a primary key, so check to see if there is * one. This requires a lock on the table so no one removes it after we * check and before we commit. */ EXECUTE format('LOCK TABLE %s IN ACCESS SHARE MODE', table_name); /* Now check for the primary key */ IF NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'p')) THEN RAISE EXCEPTION 'table "%" must have a primary key', table_name; END IF; FOR r IN SELECT n.nspname AS schema_name, c.relname AS table_name, c.relowner AS table_owner, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (table_name IS NULL OR p.table_name = table_name) AND (period_name IS NULL OR p.period_name = period_name) AND p.period_name <> 'system_time' AND NOT EXISTS ( SELECT FROM periods.for_portion_views AS _fpv WHERE (_fpv.table_name, _fpv.period_name) = (p.table_name, p.period_name)) LOOP view_name := periods._choose_portion_view_name(r.table_name, r.period_name); trigger_name := 'for_portion_of_' || r.period_name; EXECUTE format('CREATE VIEW %1$I.%2$I AS TABLE %1$I.%3$I', r.schema_name, view_name, r.table_name); EXECUTE format('ALTER VIEW %1$I.%2$I OWNER TO %s', r.schema_name, view_name, r.table_owner::regrole); EXECUTE format('CREATE TRIGGER %I INSTEAD OF UPDATE ON %I.%I FOR EACH ROW EXECUTE PROCEDURE periods.update_portion_of()', trigger_name, r.schema_name, view_name); INSERT INTO periods.for_portion_views (table_name, period_name, view_name, trigger_name) VALUES (format('%I.%I', r.schema_name, r.table_name), r.period_name, format('%I.%I', r.schema_name, view_name), trigger_name); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.drop_for_portion_view(table_name regclass, period_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE view_name regclass; trigger_name name; BEGIN /* * If table_name and period_name are specified, then just drop the views for that. * * If no period is specified, drop the views for all periods of the table. * * If no table is specified, drop the views everywhere. * * If no table is specified but a period is, that doesn't make any sense. */ IF table_name IS NULL AND period_name IS NOT NULL THEN RAISE EXCEPTION 'cannot specify period name without table name'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR view_name, trigger_name IN DELETE FROM periods.for_portion_views AS fp WHERE (table_name IS NULL OR fp.table_name = table_name) AND (period_name IS NULL OR fp.period_name = period_name) RETURNING fp.view_name, fp.trigger_name LOOP EXECUTE format('DROP TRIGGER %I on %s', trigger_name, view_name); EXECUTE format('DROP VIEW %s %s', view_name, drop_behavior); END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.update_portion_of() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE info record; test boolean; generated_columns_sql text; generated_columns text[]; jnew jsonb; fromval jsonb; toval jsonb; jold jsonb; bstartval jsonb; bendval jsonb; pre_row jsonb; new_row jsonb; post_row jsonb; pre_assigned boolean; post_assigned boolean; SERVER_VERSION CONSTANT integer := current_setting('server_version_num')::integer; TEST_SQL CONSTANT text := 'VALUES (CAST(%2$L AS %1$s) < CAST(%3$L AS %1$s) AND ' ' CAST(%3$L AS %1$s) < CAST(%4$L AS %1$s))'; GENERATED_COLUMNS_SQL_PRE_10 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_PRE_12 CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; GENERATED_COLUMNS_SQL_CURRENT CONSTANT text := 'SELECT array_agg(a.attname) ' 'FROM pg_catalog.pg_attribute AS a ' 'WHERE a.attrelid = $1 ' ' AND a.attnum > 0 ' ' AND NOT a.attisdropped ' ' AND (pg_catalog.pg_get_serial_sequence(a.attrelid::regclass::text, a.attname) IS NOT NULL ' ' OR a.attidentity <> '''' ' ' OR a.attgenerated <> '''' ' ' OR EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c ' ' WHERE _c.conrelid = a.attrelid ' ' AND _c.contype = ''p'' ' ' AND _c.conkey @> ARRAY[a.attnum]) ' ' OR EXISTS (SELECT FROM periods.periods AS _p ' ' WHERE (_p.table_name, _p.period_name) = (a.attrelid, ''system_time'') ' ' AND a.attname IN (_p.start_column_name, _p.end_column_name)))'; BEGIN /* * REFERENCES: * SQL:2016 15.13 GR 10 */ /* Get the table information from this view */ SELECT p.table_name, p.period_name, p.start_column_name, p.end_column_name, format_type(a.atttypid, a.atttypmod) AS datatype INTO info FROM periods.for_portion_views AS fpv JOIN periods.periods AS p ON (p.table_name, p.period_name) = (fpv.table_name, fpv.period_name) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (p.table_name, p.start_column_name) WHERE fpv.view_name = TG_RELID; IF NOT FOUND THEN RAISE EXCEPTION 'table and period information not found for view "%"', TG_RELID::regclass; END IF; jnew := row_to_json(NEW); fromval := jnew->info.start_column_name; toval := jnew->info.end_column_name; jold := row_to_json(OLD); bstartval := jold->info.start_column_name; bendval := jold->info.end_column_name; pre_row := jold; new_row := jnew; post_row := jold; /* Reset the period columns */ new_row := jsonb_set(new_row, ARRAY[info.start_column_name], bstartval); new_row := jsonb_set(new_row, ARRAY[info.end_column_name], bendval); /* If the period is the only thing changed, do nothing */ IF new_row = jold THEN RETURN NULL; END IF; pre_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, fromval, bendval) INTO test; IF test THEN pre_assigned := true; pre_row := jsonb_set(pre_row, ARRAY[info.end_column_name], fromval); new_row := jsonb_set(new_row, ARRAY[info.start_column_name], fromval); END IF; post_assigned := false; EXECUTE format(TEST_SQL, info.datatype, bstartval, toval, bendval) INTO test; IF test THEN post_assigned := true; new_row := jsonb_set(new_row, ARRAY[info.end_column_name], toval::jsonb); post_row := jsonb_set(post_row, ARRAY[info.start_column_name], toval::jsonb); END IF; IF pre_assigned OR post_assigned THEN /* Don't validate foreign keys until all this is done */ SET CONSTRAINTS ALL DEFERRED; /* * Find and remove all generated columns from pre_row and post_row. * SQL:2016 15.13 GR 10)b)i) * * We also remove columns that own a sequence as those are a form of * generated column. We do not, however, remove columns that default * to nextval() without owning the underlying sequence. * * Columns belonging to a SYSTEM_TIME period are also removed. * * In addition to what the standard calls for, we also remove any * columns belonging to primary keys. */ IF SERVER_VERSION < 100000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_10; ELSIF SERVER_VERSION < 120000 THEN generated_columns_sql := GENERATED_COLUMNS_SQL_PRE_12; ELSE generated_columns_sql := GENERATED_COLUMNS_SQL_CURRENT; END IF; EXECUTE generated_columns_sql INTO generated_columns USING info.table_name; /* There may not be any generated columns. */ IF generated_columns IS NOT NULL THEN IF SERVER_VERSION < 100000 THEN SELECT jsonb_object_agg(e.key, e.value) INTO pre_row FROM jsonb_each(pre_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); SELECT jsonb_object_agg(e.key, e.value) INTO post_row FROM jsonb_each(post_row) AS e (key, value) WHERE e.key <> ALL (generated_columns); ELSE pre_row := pre_row - generated_columns; post_row := post_row - generated_columns; END IF; END IF; END IF; IF pre_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(pre_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(pre_row))); END IF; EXECUTE format('UPDATE %s SET %s WHERE %s AND %I > %L AND %I < %L', info.table_name, (SELECT string_agg(format('%I = %L', j.key, j.value), ', ') FROM (SELECT key, value FROM jsonb_each_text(new_row) EXCEPT ALL SELECT key, value FROM jsonb_each_text(jold) ) AS j ), (SELECT string_agg(format('%I = %L', key, value), ' AND ') FROM pg_catalog.jsonb_each_text(jold) AS j JOIN pg_catalog.pg_attribute AS a ON a.attname = j.key JOIN pg_catalog.pg_constraint AS c ON c.conkey @> ARRAY[a.attnum] WHERE a.attrelid = info.table_name AND c.conrelid = info.table_name ), info.end_column_name, fromval, info.start_column_name, toval ); IF post_assigned THEN EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', info.table_name, (SELECT string_agg(quote_ident(key), ', ' ORDER BY key) FROM jsonb_each_text(post_row)), (SELECT string_agg(quote_nullable(value), ', ' ORDER BY key) FROM jsonb_each_text(post_row))); END IF; RETURN NEW; END; $function$; CREATE FUNCTION periods.add_unique_key( table_name regclass, column_names name[], period_name name, key_name name DEFAULT NULL, unique_constraint name DEFAULT NULL, exclude_constraint name DEFAULT NULL) RETURNS name LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; column_attnums smallint[]; period_attnums smallint[]; idx integer; constraint_record record; pass integer; sql text; alter_cmds text[]; unique_index regclass; exclude_index regclass; unique_sql text; exclude_sql text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in UNIQUE constraints. SQL:2016 11.7 SR 5)b) */ IF period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* For convenience, put the period's attnums in an array */ period_attnums := ARRAY[ (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.start_column_name)), (SELECT a.attnum FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (period_row.table_name, period_row.end_column_name)) ]; /* Get attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a UNIQUE * key. SQL:2016 11.7 SR 5)b) */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* If we were given a unique constraint to use, look it up and make sure it matches */ SELECT format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) INTO unique_sql FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); IF unique_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, c.conkey INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, unique_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', unique_constraint; END IF; IF constraint_record.contype NOT IN ('p', 'u') THEN RAISE EXCEPTION 'constraint "%" is not a PRIMARY KEY or UNIQUE KEY', unique_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', unique_constraint; END IF; IF NOT constraint_record.conkey = column_attnums || period_attnums THEN RAISE EXCEPTION 'constraint "%" does not match', unique_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * If we were given an exclude constraint to use, look it up and make sure * it matches. We do that by generating the text that we expect * pg_get_constraintdef() to output and compare against that instead of * trying to deal with the internally stored components like we did for the * UNIQUE constraint. * * We will use this same text to create the constraint if it doesn't exist. */ DECLARE withs text[]; BEGIN SELECT array_agg(format('%I WITH =', column_name) ORDER BY n.ordinality) INTO withs FROM unnest(column_names) WITH ORDINALITY AS n (column_name, ordinality); withs := withs || format('%I(%I, %I, ''[)''::text) WITH &&', period_row.range_type, period_row.start_column_name, period_row.end_column_name); exclude_sql := format('EXCLUDE USING gist (%s)', array_to_string(withs, ', ')); END; IF exclude_constraint IS NOT NULL THEN SELECT c.oid, c.contype, c.condeferrable, pg_catalog.pg_get_constraintdef(c.oid) AS definition INTO constraint_record FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (table_name, exclude_constraint); IF NOT FOUND THEN RAISE EXCEPTION 'constraint "%" does not exist', exclude_constraint; END IF; IF constraint_record.contype <> 'x' THEN RAISE EXCEPTION 'constraint "%" is not an EXCLUDE constraint', exclude_constraint; END IF; IF constraint_record.condeferrable THEN /* SQL:2016 11.8 SR 5 */ RAISE EXCEPTION 'constraint "%" must not be DEFERRABLE', exclude_constraint; END IF; IF constraint_record.definition <> exclude_sql THEN RAISE EXCEPTION 'constraint "%" does not match', exclude_constraint; END IF; /* Looks good, let's use it. */ END IF; /* * Generate a name for the unique constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.unique_keys AS uk WHERE uk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* Time to make the underlying constraints */ alter_cmds := '{}'; IF unique_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || unique_sql); END IF; IF exclude_constraint IS NULL THEN alter_cmds := alter_cmds || ('ADD ' || exclude_sql); END IF; IF alter_cmds <> '{}' THEN SELECT format('ALTER TABLE %I.%I %s', n.nspname, c.relname, array_to_string(alter_cmds, ', ')) INTO sql FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_name; EXECUTE sql; END IF; /* If we don't already have a unique_constraint, it must be the one with the highest oid */ IF unique_constraint IS NULL THEN SELECT c.conname, c.conindid INTO unique_constraint, unique_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'u') ORDER BY oid DESC LIMIT 1; END IF; /* If we don't already have an exclude_constraint, it must be the one with the highest oid */ IF exclude_constraint IS NULL THEN SELECT c.conname, c.conindid INTO exclude_constraint, exclude_index FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (table_name, 'x') ORDER BY oid DESC LIMIT 1; END IF; INSERT INTO periods.unique_keys (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint) VALUES (key_name, table_name, column_names, period_name, unique_constraint, exclude_constraint); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_unique_key(table_name regclass, key_name name, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_key_row periods.unique_keys; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR unique_key_row IN SELECT uk.* FROM periods.unique_keys AS uk WHERE uk.table_name = table_name AND (uk.key_name = key_name OR key_name IS NULL) LOOP /* Cascade to foreign keys, if desired */ FOR foreign_key_row IN SELECT fk.key_name FROM periods.foreign_keys AS fk WHERE fk.unique_key = unique_key_row.key_name LOOP IF drop_behavior = 'RESTRICT' THEN RAISE EXCEPTION 'cannot drop unique key "%" because foreign key "%" on table "%" depends on it', unique_key_row.key_name, foreign_key_row.key_name, foreign_key_row.table_name; END IF; PERFORM periods.drop_foreign_key(NULL, foreign_key_row.key_name); END LOOP; DELETE FROM periods.unique_keys AS uk WHERE uk.key_name = unique_key_row.key_name; /* If purging, drop the underlying constraints unless the table has been dropped */ IF purge AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_key_row.table_name) THEN EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, DROP CONSTRAINT %I', unique_key_row.table_name, unique_key_row.unique_constraint, unique_key_row.exclude_constraint); END IF; END LOOP; END; $function$; CREATE FUNCTION periods.uk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is updated. It checks to verify that the referenced table still * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * If this is a NO ACTION constraint, we need to check if there is a new * row that still satisfies the constraint, in which case there is no * error. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, true); RETURN NULL; END; $function$; CREATE FUNCTION periods.uk_delete_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jold jsonb; BEGIN /* * This function is called when a table referenced by foreign keys with * periods is deleted from. It checks to verify that the referenced table * still contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. * * The only difference between NO ACTION and RESTRICT is when the check is * done, so this function is used for both. */ /* Use jsonb to look up values by parameterized names */ jold := row_to_json(OLD); /* Check the constraint */ PERFORM periods.validate_foreign_key_old_row(TG_ARGV[0], jold, false); RETURN NULL; END; $function$; CREATE FUNCTION periods.add_foreign_key( table_name regclass, column_names name[], period_name name, ref_unique_name name, match_type periods.fk_match_types DEFAULT 'SIMPLE', update_action periods.fk_actions DEFAULT 'NO ACTION', delete_action periods.fk_actions DEFAULT 'NO ACTION', key_name name DEFAULT NULL, fk_insert_trigger name DEFAULT NULL, fk_update_trigger name DEFAULT NULL, uk_update_trigger name DEFAULT NULL, uk_delete_trigger name DEFAULT NULL) RETURNS name LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE period_row periods.periods; ref_period_row periods.periods; unique_row periods.unique_keys; column_attnums smallint[]; idx integer; pass integer; upd_action text DEFAULT ''; del_action text DEFAULT ''; foreign_columns text; unique_columns text; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* Get the period involved */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, period_name); IF NOT FOUND THEN RAISE EXCEPTION 'period "%" does not exist', period_name; END IF; /* SYSTEM_TIME is not allowed in referential constraints. SQL:2016 11.8 SR 10 */ IF period_row.period_name = 'system_time' THEN RAISE EXCEPTION 'periods for SYSTEM_TIME are not allowed in foreign keys'; END IF; /* * Columns belonging to a SYSTEM_TIME period are not allowed in a foreign * key. SQL:2016 11.8 SR 10 */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (period_row.table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns in period for SYSTEM_TIME are not allowed in UNIQUE keys'; END IF; /* Get column attnums from column names */ SELECT array_agg(a.attnum ORDER BY n.ordinality) INTO column_attnums FROM unnest(column_names) WITH ORDINALITY AS n (name, ordinality) LEFT JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attname) = (table_name, n.name); /* System columns are not allowed */ IF 0 > ANY (column_attnums) THEN RAISE EXCEPTION 'index creation on system columns is not supported'; END IF; /* Report if any columns weren't found */ idx := array_position(column_attnums, NULL); IF idx IS NOT NULL THEN RAISE EXCEPTION 'column "%" does not exist', column_names[idx]; END IF; /* Make sure the period columns aren't also in the normal columns */ IF period_row.start_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.start_column_name; END IF; IF period_row.end_column_name = ANY (column_names) THEN RAISE EXCEPTION 'column "%" specified twice', period_row.end_column_name; END IF; /* Columns can't be part of any SYSTEM_TIME period */ IF EXISTS ( SELECT FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_name, 'system_time') AND ARRAY[p.start_column_name, p.end_column_name] && column_names) THEN RAISE EXCEPTION 'columns for SYSTEM_TIME must not be part of foreign keys'; END IF; /* Get the unique key we're linking to */ SELECT uk.* INTO unique_row FROM periods.unique_keys AS uk WHERE uk.key_name = ref_unique_name; IF NOT FOUND THEN RAISE EXCEPTION 'unique key "%" does not exist', ref_unique_name; END IF; /* Get the unique key's period */ SELECT p.* INTO ref_period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (unique_row.table_name, unique_row.period_name); IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types "%" and "%" are incompatible', period_row.period_name, ref_period_row.period_name; END IF; /* Check that all the columns match */ IF EXISTS ( SELECT FROM unnest(column_names, unique_row.column_names) AS u (fk_attname, uk_attname) JOIN pg_catalog.pg_attribute AS fa ON (fa.attrelid, fa.attname) = (table_name, u.fk_attname) JOIN pg_catalog.pg_attribute AS ua ON (ua.attrelid, ua.attname) = (unique_row.table_name, u.uk_attname) WHERE (fa.atttypid, fa.atttypmod, fa.attcollation) <> (ua.atttypid, ua.atttypmod, ua.attcollation)) THEN RAISE EXCEPTION 'column types do not match'; END IF; /* The range types must match, too */ IF period_row.range_type <> ref_period_row.range_type THEN RAISE EXCEPTION 'period types do not match'; END IF; /* * Generate a name for the foreign constraint. We don't have to worry about * concurrency here because all period ddl commands lock the periods table. */ IF key_name IS NULL THEN key_name := periods._choose_name( ARRAY[(SELECT c.relname FROM pg_catalog.pg_class AS c WHERE c.oid = table_name)] || column_names || ARRAY[period_name]); END IF; pass := 0; WHILE EXISTS ( SELECT FROM periods.foreign_keys AS fk WHERE fk.key_name = key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END) LOOP pass := pass + 1; END LOOP; key_name := key_name || CASE WHEN pass > 0 THEN '_' || pass::text ELSE '' END; /* See if we're deferring the constraints or not */ IF update_action = 'NO ACTION' THEN upd_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; IF delete_action = 'NO ACTION' THEN del_action := ' DEFERRABLE INITIALLY DEFERRED'; END IF; /* Get the columns that require checking the constraint */ SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO foreign_columns FROM unnest(column_names || period_row.start_column_name || period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); SELECT string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality) INTO unique_columns FROM unnest(unique_row.column_names || ref_period_row.start_column_name || ref_period_row.end_column_name) WITH ORDINALITY AS u (column_name, ordinality); /* Time to make the underlying triggers */ fk_insert_trigger := coalesce(fk_insert_trigger, periods._choose_name(ARRAY[key_name], 'fk_insert')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER INSERT ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_insert_check(%L)', fk_insert_trigger, table_name, unique_row.table_name, key_name); fk_update_trigger := coalesce(fk_update_trigger, periods._choose_name(ARRAY[key_name], 'fk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE periods.fk_update_check(%L)', fk_update_trigger, foreign_columns, table_name, unique_row.table_name, key_name); uk_update_trigger := coalesce(uk_update_trigger, periods._choose_name(ARRAY[key_name], 'uk_update')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER UPDATE OF %s ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_update_check(%L)', uk_update_trigger, unique_columns, unique_row.table_name, table_name, upd_action, key_name); uk_delete_trigger := coalesce(uk_delete_trigger, periods._choose_name(ARRAY[key_name], 'uk_delete')); EXECUTE format('CREATE CONSTRAINT TRIGGER %I AFTER DELETE ON %s FROM %s%s FOR EACH ROW EXECUTE PROCEDURE periods.uk_delete_check(%L)', uk_delete_trigger, unique_row.table_name, table_name, del_action, key_name); INSERT INTO periods.foreign_keys (key_name, table_name, column_names, period_name, unique_key, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger) VALUES (key_name, table_name, column_names, period_name, unique_row.key_name, match_type, update_action, delete_action, fk_insert_trigger, fk_update_trigger, uk_update_trigger, uk_delete_trigger); /* Validate the constraint on existing data */ PERFORM periods.validate_foreign_key_new_row(key_name, NULL); RETURN key_name; END; $function$; CREATE FUNCTION periods.drop_foreign_key(table_name regclass, key_name name) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE foreign_key_row periods.foreign_keys; unique_table_name regclass; BEGIN IF table_name IS NULL AND key_name IS NULL THEN RAISE EXCEPTION 'no table or key name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); FOR foreign_key_row IN SELECT fk.* FROM periods.foreign_keys AS fk WHERE (fk.table_name = table_name OR table_name IS NULL) AND (fk.key_name = key_name OR key_name IS NULL) LOOP DELETE FROM periods.foreign_keys AS fk WHERE fk.key_name = foreign_key_row.key_name; /* * Make sure the table hasn't been dropped and that the triggers exist * before doing these. We could use the IF EXISTS clause but we don't * in order to avoid the NOTICE. */ IF EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = foreign_key_row.table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = foreign_key_row.table_name AND t.tgname IN (foreign_key_row.fk_insert_trigger, foreign_key_row.fk_update_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_insert_trigger, foreign_key_row.table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.fk_update_trigger, foreign_key_row.table_name); END IF; SELECT uk.table_name INTO unique_table_name FROM periods.unique_keys AS uk WHERE uk.key_name = foreign_key_row.unique_key; /* Ditto for the UNIQUE side. */ IF FOUND AND EXISTS ( SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = unique_table_name) AND EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE t.tgrelid = unique_table_name AND t.tgname IN (foreign_key_row.uk_update_trigger, foreign_key_row.uk_delete_trigger)) THEN EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_update_trigger, unique_table_name); EXECUTE format('DROP TRIGGER %I ON %s', foreign_key_row.uk_delete_trigger, unique_table_name); END IF; END LOOP; RETURN true; END; $function$; CREATE FUNCTION periods.fk_insert_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a new row is inserted into a table * containing foreign keys with periods. It checks to verify that the * referenced table contains the proper data to satisfy the foreign key * constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; CREATE FUNCTION periods.fk_update_check() RETURNS trigger LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE jnew jsonb; BEGIN /* * This function is called when a table containing foreign keys with * periods is updated. It checks to verify that the referenced table * contains the proper data to satisfy the foreign key constraint. * * The first argument is the name of the foreign key in our custom * catalogs. */ /* Use jsonb to look up values by parameterized names */ jnew := row_to_json(NEW); /* Check the constraint */ PERFORM periods.validate_foreign_key_new_row(TG_ARGV[0], jnew); RETURN NULL; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_old_row(foreign_key_name name, row_data jsonb, is_update boolean) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; column_name name; has_nulls boolean; uk_column_names text[]; uk_column_values text[]; fk_column_names text; violation boolean; still_matches boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %1$I.%2$I AS t ' ' WHERE ROW(%3$s) = ROW(%6$s) ' ' AND t.%4$I <= %7$L ' ' AND t.%5$I >= %8$L ' '%9$s' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, uc.oid AS uk_table_oid, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; FOREACH column_name IN ARRAY foreign_key_info.uk_column_names LOOP IF row_data->>column_name IS NULL THEN /* * If the deleted row had nulls in the referenced columns then * there was no possible referencing row (until we implement * PARTIAL) so we can just stop here. */ RETURN true; END IF; uk_column_names := uk_column_names || ('t.' || quote_ident(column_name)); uk_column_values := uk_column_values || quote_literal(row_data->>column_name); END LOOP; IF is_update AND foreign_key_info.update_action = 'NO ACTION' THEN EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, array_to_string(uk_column_names, ', '), foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, 'FOR KEY SHARE') INTO still_matches; IF still_matches THEN RETURN true; END IF; END IF; SELECT string_agg('t.' || quote_ident(u.c), ', ' ORDER BY u.ordinality) INTO fk_column_names FROM unnest(foreign_key_info.fk_column_names) WITH ORDINALITY AS u (c, ordinality); EXECUTE format(QSQL, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, fk_column_names, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, array_to_string(uk_column_values, ', '), row_data->>foreign_key_info.uk_start_column_name, row_data->>foreign_key_info.uk_end_column_name, '') INTO violation; IF violation THEN RAISE EXCEPTION 'update or delete on table "%" violates foreign key constraint "%" on table "%"', foreign_key_info.uk_table_oid::regclass, foreign_key_name, foreign_key_info.fk_table_oid::regclass; END IF; RETURN true; END; $function$; /* * This function either returns true or raises an exception. */ CREATE FUNCTION periods.validate_foreign_key_new_row(foreign_key_name name, row_data jsonb) RETURNS boolean LANGUAGE plpgsql AS $function$ #variable_conflict use_variable DECLARE foreign_key_info record; row_clause text DEFAULT 'true'; violation boolean; QSQL CONSTANT text := 'SELECT EXISTS ( ' ' SELECT FROM %5$I.%6$I AS fk ' ' WHERE NOT EXISTS ( ' ' SELECT FROM (SELECT uk.uk_start_value, ' ' uk.uk_end_value, ' ' nullif(lag(uk.uk_end_value) OVER (ORDER BY uk.uk_start_value), uk.uk_start_value) AS x ' ' FROM (SELECT uk.%3$I AS uk_start_value, ' ' uk.%4$I AS uk_end_value ' ' FROM %1$I.%2$I AS uk ' ' WHERE %9$s ' ' AND uk.%3$I <= fk.%8$I ' ' AND uk.%4$I >= fk.%7$I ' ' FOR KEY SHARE ' ' ) AS uk ' ' ) AS uk ' ' WHERE uk.uk_start_value < fk.%8$I ' ' AND uk.uk_end_value >= fk.%7$I ' ' HAVING min(uk.uk_start_value) <= fk.%7$I ' ' AND max(uk.uk_end_value) >= fk.%8$I ' ' AND array_agg(uk.x) FILTER (WHERE uk.x IS NOT NULL) IS NULL ' ' ) AND %10$s ' ')'; BEGIN SELECT fc.oid AS fk_table_oid, fn.nspname AS fk_schema_name, fc.relname AS fk_table_name, fk.column_names AS fk_column_names, fp.period_name AS fk_period_name, fp.start_column_name AS fk_start_column_name, fp.end_column_name AS fk_end_column_name, un.nspname AS uk_schema_name, uc.relname AS uk_table_name, uk.column_names AS uk_column_names, up.period_name AS uk_period_name, up.start_column_name AS uk_start_column_name, up.end_column_name AS uk_end_column_name, fk.match_type, fk.update_action, fk.delete_action INTO foreign_key_info FROM periods.foreign_keys AS fk JOIN periods.periods AS fp ON (fp.table_name, fp.period_name) = (fk.table_name, fk.period_name) JOIN pg_catalog.pg_class AS fc ON fc.oid = fk.table_name JOIN pg_catalog.pg_namespace AS fn ON fn.oid = fc.relnamespace JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key JOIN periods.periods AS up ON (up.table_name, up.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_class AS uc ON uc.oid = uk.table_name JOIN pg_catalog.pg_namespace AS un ON un.oid = uc.relnamespace WHERE fk.key_name = foreign_key_name; IF NOT FOUND THEN RAISE EXCEPTION 'foreign key "%" not found', foreign_key_name; END IF; /* * Now that we have all of our names, we can see if there are any nulls in * the row we were given (if we were given one). */ IF row_data IS NOT NULL THEN DECLARE column_name name; has_nulls boolean; all_nulls boolean; cols text[] DEFAULT '{}'; vals text[] DEFAULT '{}'; BEGIN FOREACH column_name IN ARRAY foreign_key_info.fk_column_names LOOP has_nulls := has_nulls OR row_data->>column_name IS NULL; all_nulls := all_nulls IS NOT false AND row_data->>column_name IS NULL; cols := cols || ('fk.' || quote_ident(column_name)); vals := vals || quote_literal(row_data->>column_name); END LOOP; IF all_nulls THEN /* * If there are no values at all, all three types pass. * * Period columns are by definition NOT NULL so the FULL MATCH * type is only concerned with the non-period columns of the * constraint. SQL:2016 4.23.3.3 */ RETURN true; END IF; IF has_nulls THEN CASE foreign_key_info.match_type WHEN 'SIMPLE' THEN RETURN true; WHEN 'PARTIAL' THEN RAISE EXCEPTION 'partial not implemented'; WHEN 'FULL' THEN RAISE EXCEPTION 'foreign key violated (nulls in FULL)'; END CASE; END IF; row_clause := format(' (%s) = (%s)', array_to_string(cols, ', '), array_to_string(vals, ', ')); END; END IF; EXECUTE format(QSQL, foreign_key_info.uk_schema_name, foreign_key_info.uk_table_name, foreign_key_info.uk_start_column_name, foreign_key_info.uk_end_column_name, foreign_key_info.fk_schema_name, foreign_key_info.fk_table_name, foreign_key_info.fk_start_column_name, foreign_key_info.fk_end_column_name, (SELECT string_agg(format('%I = %I', ukc, fkc), ' AND ') FROM unnest(foreign_key_info.uk_column_names, foreign_key_info.fk_column_names) AS u (ukc, fkc) ), row_clause) INTO violation; IF violation THEN IF row_data IS NULL THEN RAISE EXCEPTION 'foreign key violated by some row'; ELSE RAISE EXCEPTION 'insert or update on table "%" violates foreign key constraint "%"', foreign_key_info.fk_table_oid::regclass, foreign_key_name; END IF; END IF; RETURN true; END; $function$; CREATE FUNCTION periods.add_system_versioning( table_class regclass, history_table_name name DEFAULT NULL, view_name name DEFAULT NULL, function_as_of_name name DEFAULT NULL, function_between_name name DEFAULT NULL, function_between_symmetric_name name DEFAULT NULL, function_from_to_name name DEFAULT NULL) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE schema_name name; table_name name; table_owner regrole; persistence "char"; kind "char"; period_row periods.periods; history_table_id oid; sql text; grantees text; BEGIN IF table_class IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_class); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.29 */ /* Already registered? SQL:2016 11.29 SR 5 */ IF EXISTS (SELECT FROM periods.system_versioning AS r WHERE r.table_name = table_class) THEN RAISE EXCEPTION 'table already has SYSTEM VERSIONING'; END IF; /* Must be a regular persistent base table. SQL:2016 11.29 SR 2 */ SELECT n.nspname, c.relname, c.relowner, c.relpersistence, c.relkind INTO schema_name, table_name, table_owner, persistence, kind FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.oid = table_class; IF kind <> 'r' THEN /* * The main reason partitioned tables aren't supported yet is simply * because I haven't put any thought into it. * Maybe it's trivial, maybe not. */ IF kind = 'p' THEN RAISE EXCEPTION 'partitioned tables are not supported yet'; END IF; RAISE EXCEPTION 'relation % is not a table', $1; END IF; IF persistence <> 'p' THEN /* * We could probably accept unlogged tables if the history table is * also unlogged, but what's the point? */ RAISE EXCEPTION 'table "%" must be persistent', table_class; END IF; /* We need a SYSTEM_TIME period. SQL:2016 11.29 SR 4 */ SELECT p.* INTO period_row FROM periods.periods AS p WHERE (p.table_name, p.period_name) = (table_class, 'system_time'); IF NOT FOUND THEN RAISE EXCEPTION 'no period for SYSTEM_TIME found for table %', table_class; END IF; /* Get all of our "fake" infrastructure ready */ history_table_name := coalesce(history_table_name, periods._choose_name(ARRAY[table_name], 'history')); view_name := coalesce(view_name, periods._choose_name(ARRAY[table_name], 'with_history')); function_as_of_name := coalesce(function_as_of_name, periods._choose_name(ARRAY[table_name], '_as_of')); function_between_name := coalesce(function_between_name, periods._choose_name(ARRAY[table_name], '_between')); function_between_symmetric_name := coalesce(function_between_symmetric_name, periods._choose_name(ARRAY[table_name], '_between_symmetric')); function_from_to_name := coalesce(function_from_to_name, periods._choose_name(ARRAY[table_name], '_from_to')); /* * Create the history table. If it already exists we check that all the * columns match but otherwise we trust the user. Perhaps the history * table was disconnected in order to change the schema (a case which is * not defined by the SQL standard). Or perhaps the user wanted to * partition the history table. * * There shouldn't be any concurrency issues here because our main catalog * is locked. */ SELECT c.oid INTO history_table_id FROM pg_catalog.pg_class AS c JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE (n.nspname, c.relname) = (schema_name, history_table_name); IF FOUND THEN /* Don't allow any periods on the history table (this might be relaxed later) */ IF EXISTS (SELECT FROM periods.periods AS p WHERE p.table_name = history_table_id) THEN RAISE EXCEPTION 'history tables for SYSTEM VERSIONING cannot have periods'; END IF; /* * The query to the attributes is harder than one would think because * we need to account for dropped columns. Basically what we're * looking for is that all columns have the same name, type, and * collation. */ IF EXISTS ( WITH L (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = table_class AND NOT a.attisdropped ), R (attname, atttypid, atttypmod, attcollation) AS ( SELECT a.attname, a.atttypid, a.atttypmod, a.attcollation FROM pg_catalog.pg_attribute AS a WHERE a.attrelid = history_table_id AND NOT a.attisdropped ) SELECT FROM L NATURAL FULL JOIN R WHERE L.attname IS NULL OR R.attname IS NULL) THEN RAISE EXCEPTION 'base table "%" and history table "%" are not compatible', table_class, history_table_id::regclass; END IF; /* Make sure the owner is correct */ EXECUTE format('ALTER TABLE %s OWNER TO %I', history_table_id::regclass, table_owner); /* * Remove all privileges other than SELECT from everyone on the history * table. We do this without error because some privileges may have * been added in order to do maintenance while we were disconnected. * * We start by doing the table owner because that will make sure we * don't have NULL in pg_class.relacl. */ --EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON TABLE %s FROM %I', --history_table_id::regclass, table_owner); ELSE EXECUTE format('CREATE TABLE %1$I.%2$I (LIKE %1$I.%3$I)', schema_name, history_table_name, table_name); history_table_id := format('%I.%I', schema_name, history_table_name)::regclass; EXECUTE format('ALTER TABLE %1$I.%2$I OWNER TO %3$I', schema_name, history_table_name, table_owner); RAISE NOTICE 'history table "%" created for "%", be sure to index it properly', history_table_id::regclass, table_class; END IF; /* Create the "with history" view. This one we do want to error out on if it exists. */ EXECUTE format( /* * The query we really want here is * * CREATE VIEW view_name AS * TABLE table_name * UNION ALL CORRESPONDING * TABLE history_table_name * * but PostgreSQL doesn't support that syntax (yet), so we have to do * it manually. */ 'CREATE VIEW %1$I.%2$I AS SELECT %5$s FROM %1$I.%3$I UNION ALL SELECT %5$s FROM %1$I.%4$I', schema_name, view_name, table_name, history_table_name, (SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FROM pg_attribute AS a WHERE a.attrelid = table_class AND a.attnum > 0 AND NOT a.attisdropped )); EXECUTE format('ALTER VIEW %1$I.%2$I OWNER TO %3$I', schema_name, view_name, table_owner); /* * Create functions to simulate the system versioned grammar. These must * be inlinable for any kind of performance. */ EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %4$I <= $1 AND %5$I > $1' $$, schema_name, function_as_of_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone) OWNER TO %3$I', schema_name, function_as_of_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 <= $2 AND %5$I > $1 AND %4$I <= $2' $$, schema_name, function_between_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_between_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE %5$I > least($1, $2) AND %4$I <= greatest($1, $2)' $$, schema_name, function_between_symmetric_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_between_symmetric_name, table_owner); EXECUTE format( $$ CREATE FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) RETURNS SETOF %1$I.%3$I LANGUAGE sql STABLE AS 'SELECT * FROM %1$I.%3$I WHERE $1 < $2 AND %5$I > $1 AND %4$I < $2' $$, schema_name, function_from_to_name, view_name, period_row.start_column_name, period_row.end_column_name); EXECUTE format('ALTER FUNCTION %1$I.%2$I(timestamp with time zone, timestamp with time zone) OWNER TO %3$I', schema_name, function_from_to_name, table_owner); /* Set privileges on history objects */ FOR sql IN SELECT format('REVOKE ALL ON %s %s FROM %s', CASE object_type WHEN 'r' THEN 'TABLE' WHEN 'v' THEN 'TABLE' WHEN 'f' THEN 'FUNCTION' ELSE 'ERROR' END, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT quote_ident(COALESCE(a.rolname, 'public')), ', ')) FROM ( SELECT c.relkind AS object_type, c.oid::regclass::text AS object_name, acl.grantee AS grantee FROM pg_class AS c JOIN pg_namespace AS n ON n.oid = c.relnamespace CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE n.nspname = schema_name AND c.relname IN (history_table_name, view_name) UNION ALL SELECT 'f', p.oid::regprocedure::text, acl.grantee FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.oid = ANY (ARRAY[ format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure ]) ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY objects.object_type LOOP EXECUTE sql; END LOOP; FOR grantees IN SELECT string_agg(acl.grantee::regrole::text, ', ') FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.oid = table_class AND acl.privilege_type = 'SELECT' LOOP EXECUTE format('GRANT SELECT ON TABLE %1$I.%2$I, %1$I.%3$I TO %4$s', schema_name, history_table_name, view_name, grantees); EXECUTE format('GRANT EXECUTE ON FUNCTION %s, %s, %s, %s TO %s', format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name)::regprocedure, format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name)::regprocedure, grantees); END LOOP; /* Register it */ INSERT INTO periods.system_versioning (table_name, period_name, history_table_name, view_name, func_as_of, func_between, func_between_symmetric, func_from_to) VALUES ( table_class, 'system_time', format('%I.%I', schema_name, history_table_name), format('%I.%I', schema_name, view_name), format('%I.%I(timestamp with time zone)', schema_name, function_as_of_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_between_symmetric_name), format('%I.%I(timestamp with time zone,timestamp with time zone)', schema_name, function_from_to_name) ); END; $function$; CREATE FUNCTION periods.drop_system_versioning(table_name regclass, drop_behavior periods.drop_behavior DEFAULT 'RESTRICT', purge boolean DEFAULT false) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE system_versioning_row periods.system_versioning; is_dropped boolean; BEGIN IF table_name IS NULL THEN RAISE EXCEPTION 'no table name specified'; END IF; /* Always serialize operations on our catalogs */ PERFORM periods._serialize(table_name); /* * REFERENCES: * SQL:2016 4.15.2.2 * SQL:2016 11.3 SR 2.3 * SQL:2016 11.3 GR 1.c * SQL:2016 11.30 */ /* * We need to delete our row first so that the DROP protection doesn't * block us. */ DELETE FROM periods.system_versioning AS sv WHERE sv.table_name = table_name RETURNING * INTO system_versioning_row; IF NOT FOUND THEN RAISE NOTICE 'table % does not have SYSTEM VERSIONING', table_name; RETURN false; END IF; /* * Has the table been dropped? If so, everything else is also dropped * except for the history table. */ is_dropped := NOT EXISTS (SELECT FROM pg_catalog.pg_class AS c WHERE c.oid = table_name); IF NOT is_dropped THEN /* Drop the functions. */ EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_as_of::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_between_symmetric::regprocedure, drop_behavior); EXECUTE format('DROP FUNCTION %s %s', system_versioning_row.func_from_to::regprocedure, drop_behavior); /* Drop the "with_history" view. */ EXECUTE format('DROP VIEW %s %s', system_versioning_row.view_name, drop_behavior); END IF; /* * SQL:2016 11.30 GR 2 says "Every row of T that corresponds to a * historical system row is effectively deleted at the end of the SQL- * statement." but we leave the history table intact in case the user * merely wants to make some DDL changes and hook things back up again. * * The purge parameter tells us that the user really wants to get rid of it * all. */ IF NOT is_dropped AND purge THEN PERFORM periods.drop_period(table_name, 'system_time', drop_behavior, purge); EXECUTE format('DROP TABLE %s %s', system_versioning_row.history_table_name, drop_behavior); END IF; RETURN true; END; $function$; CREATE FUNCTION periods.drop_protection() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE r record; table_name regclass; period_name name; BEGIN /* * This function is called after the fact, so we have to just look to see * if anything is missing in the catalogs if we just store the name and not * a reg* type. */ --- --- periods --- /* If one of our tables is being dropped, remove references to it */ FOR table_name, period_name IN SELECT p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP PERFORM periods.drop_period(table_name, period_name, 'CASCADE', true); END LOOP; /* * If a column belonging to one of our periods is dropped, we need to reject that. * SQL:2016 11.23 SR 6 */ FOR r IN SELECT dobj.object_identity, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_attribute AS sa ON (sa.attrelid, sa.attname) = (p.table_name, p.start_column_name) JOIN pg_catalog.pg_attribute AS ea ON (ea.attrelid, ea.attname) = (p.table_name, p.end_column_name) JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.table_name AND dobj.objsubid IN (sa.attnum, ea.attnum) WHERE dobj.object_type = 'table column' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop column "%" because it is part of the period "%"', r.object_identity, r.period_name; END LOOP; /* Also reject dropping the rangetype */ FOR r IN SELECT dobj.object_identity, p.table_name, p.period_name FROM periods.periods AS p JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = p.range_type ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop rangetype "%" because it is used in period "%" on table "%"', r.object_identity, r.period_name, r.table_name; END LOOP; --- --- system_time_periods --- /* Complain if the infinity CHECK constraint is missing. */ FOR r IN SELECT p.table_name, p.infinity_check_constraint FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (p.table_name, p.infinity_check_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in SYSTEM_TIME period', r.infinity_check_constraint, r.table_name; END LOOP; /* Complain if the GENERATED ALWAYS AS ROW START/END trigger is missing. */ FOR r IN SELECT p.table_name, p.generated_always_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.generated_always_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.generated_always_trigger, r.table_name; END LOOP; /* Complain if the write_history trigger is missing. */ FOR r IN SELECT p.table_name, p.write_history_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.write_history_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.write_history_trigger, r.table_name; END LOOP; /* Complain if the TRUNCATE trigger is missing. */ FOR r IN SELECT p.table_name, p.truncate_trigger FROM periods.system_time_periods AS p WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (p.table_name, p.truncate_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in SYSTEM_TIME period', r.truncate_trigger, r.table_name; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- /* Reject dropping the FOR PORTION OF view. */ FOR r IN SELECT dobj.object_identity FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = fpv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%", call "periods.drop_for_portion_view()" instead', r.object_identity; END LOOP; /* Complain if the FOR PORTION OF trigger is missing. */ FOR r IN SELECT fpv.table_name, fpv.period_name, fpv.view_name, fpv.trigger_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fpv.view_name, fpv.trigger_name)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on view "%" because it is used in FOR PORTION OF view for period "%" on table "%"', r.trigger_name, r.view_name, r.period_name, r.table_name; END LOOP; /* Complain if the table's primary key has been dropped. */ FOR r IN SELECT fpv.table_name, fpv.period_name FROM periods.for_portion_views AS fpv WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.contype) = (fpv.table_name, 'p')) LOOP RAISE EXCEPTION 'cannot drop primary key on table "%" because it has a FOR PORTION OF view for period "%"', r.table_name, r.period_name; END LOOP; --- --- unique_keys --- /* * We don't need to protect the individual columns as long as we protect * the indexes. PostgreSQL will make sure they stick around. */ /* Complain if the indexes implementing our unique indexes are missing. */ FOR r IN SELECT uk.key_name, uk.table_name, uk.unique_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.unique_constraint, r.table_name, r.key_name; END LOOP; FOR r IN SELECT uk.key_name, uk.table_name, uk.exclude_constraint FROM periods.unique_keys AS uk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_constraint AS c WHERE (c.conrelid, c.conname) = (uk.table_name, uk.exclude_constraint)) LOOP RAISE EXCEPTION 'cannot drop constraint "%" on table "%" because it is used in period unique key "%"', r.exclude_constraint, r.table_name, r.key_name; END LOOP; --- --- foreign_keys --- /* Complain if any of the triggers are missing */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_insert_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_update_trigger FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.fk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_update_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_update_trigger, r.table_name, r.key_name; END LOOP; FOR r IN SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop trigger "%" on table "%" because it is used in period foreign key "%"', r.uk_delete_trigger, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.history_table_name WHERE dobj.object_type = 'table' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop table "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.objid = sv.view_name WHERE dobj.object_type = 'view' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop view "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; FOR r IN SELECT dobj.object_identity, sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_event_trigger_dropped_objects() WITH ORDINALITY AS dobj ON dobj.object_identity = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]) WHERE dobj.object_type = 'function' ORDER BY dobj.ordinality LOOP RAISE EXCEPTION 'cannot drop function "%" because it is used in SYSTEM VERSIONING for table "%"', r.object_identity, r.table_name; END LOOP; END; $function$; CREATE EVENT TRIGGER periods_drop_protection ON sql_drop EXECUTE PROCEDURE periods.drop_protection(); CREATE FUNCTION periods.rename_following() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE r record; sql text; BEGIN /* * Anything that is stored by reg* type will auto-adjust, but anything we * store by name will need to be updated after a rename. One way to do this * is to recreate the constraints we have and pull new names out that way. * If we are unable to do something like that, we must raise an exception. */ --- --- periods --- /* * Start and end columns of a period can be found by the bounds check * constraint. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET start_column_name = %L, end_column_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', sa.attname, ea.attname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (p.table_name, p.bounds_check_constraint) JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE (p.start_column_name, p.end_column_name) <> (sa.attname, ea.attname) AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) LOOP EXECUTE sql; END LOOP; /* * Inversely, the bounds check constraint can be retrieved via the start * and end columns. */ FOR sql IN SELECT pg_catalog.format('UPDATE periods.periods SET bounds_check_constraint = %L WHERE (table_name, period_name) = (%L::regclass, %L)', c.conname, p.table_name, p.period_name) FROM periods.periods AS p JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS sa ON sa.attrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE p.bounds_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I < %I))', sa.attname, ea.attname) AND (p.start_column_name, p.end_column_name) = (sa.attname, ea.attname) AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (p.table_name, p.bounds_check_constraint)) LOOP EXECUTE sql; END LOOP; --- --- system_time_periods --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET infinity_check_constraint = %L WHERE table_name = %L::regclass', c.conname, p.table_name) FROM periods.periods AS p JOIN periods.system_time_periods AS stp ON (stp.table_name, stp.period_name) = (p.table_name, p.period_name) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = p.table_name JOIN pg_catalog.pg_attribute AS ea ON ea.attrelid = p.table_name WHERE stp.infinity_check_constraint <> c.conname AND pg_catalog.pg_get_constraintdef(c.oid) = format('CHECK ((%I = ''infinity''::%s))', ea.attname, format_type(ea.atttypid, ea.atttypmod)) AND p.end_column_name = ea.attname AND NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (stp.table_name, stp.infinity_check_constraint)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET generated_always_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.generated_always_trigger AND t.tgfoid = 'periods.generated_always_as_row_start_end()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.generated_always_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET write_history_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.write_history_trigger AND t.tgfoid = 'periods.write_history()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.write_history_trigger)) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT pg_catalog.format('UPDATE periods.system_time_periods SET truncate_trigger = %L WHERE table_name = %L::regclass', t.tgname, stp.table_name) FROM periods.system_time_periods AS stp JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = stp.table_name WHERE t.tgname <> stp.truncate_trigger AND t.tgfoid = 'periods.truncate_system_versioning()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (stp.table_name, stp.truncate_trigger)) LOOP EXECUTE sql; END LOOP; /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT stp.table_name, u.column_name FROM periods.system_time_periods AS stp CROSS JOIN LATERAL unnest(stp.excluded_column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (stp.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is excluded from SYSTEM VERSIONING', r.column_name, r.table_name; END LOOP; --- --- for_portion_views --- FOR sql IN SELECT pg_catalog.format('UPDATE periods.for_portion_views SET trigger_name = %L WHERE (table_name, period_name) = (%L::regclass, %L)', t.tgname, fpv.table_name, fpv.period_name) FROM periods.for_portion_views AS fpv JOIN pg_catalog.pg_trigger AS t ON t.tgrelid = fpv.view_name WHERE t.tgname <> fpv.trigger_name AND t.tgfoid = 'periods.update_portion_of()'::regprocedure AND NOT EXISTS (SELECT FROM pg_catalog.pg_trigger AS _t WHERE (_t.tgrelid, _t.tgname) = (fpv.table_name, fpv.trigger_name)) LOOP EXECUTE sql; END LOOP; --- --- unique_keys --- FOR sql IN SELECT format('UPDATE periods.unique_keys SET column_names = %L WHERE key_name = %L', a.column_names, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) JOIN pg_catalog.pg_constraint AS c ON (c.conrelid, c.conname) = (uk.table_name, uk.unique_constraint) JOIN LATERAL ( SELECT array_agg(a.attname ORDER BY u.ordinality) AS column_names FROM unnest(c.conkey) WITH ORDINALITY AS u (attnum, ordinality) JOIN pg_catalog.pg_attribute AS a ON (a.attrelid, a.attnum) = (uk.table_name, u.attnum) WHERE a.attname NOT IN (p.start_column_name, p.end_column_name) ) AS a ON true WHERE uk.column_names <> a.column_names LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET unique_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names || ARRAY[p.start_column_name, p.end_column_name]) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.unique_constraint)) GROUP BY uk.key_name, c.oid, c.conname HAVING format('UNIQUE (%s)', string_agg(quote_ident(u.column_name), ', ' ORDER BY u.ordinality)) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; FOR sql IN SELECT format('UPDATE periods.unique_keys SET exclude_constraint = %L WHERE key_name = %L', c.conname, uk.key_name) FROM periods.unique_keys AS uk JOIN periods.periods AS p ON (p.table_name, p.period_name) = (uk.table_name, uk.period_name) CROSS JOIN LATERAL unnest(uk.column_names) WITH ORDINALITY AS u (column_name, ordinality) JOIN pg_catalog.pg_constraint AS c ON c.conrelid = uk.table_name WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_constraint AS _c WHERE (_c.conrelid, _c.conname) = (uk.table_name, uk.exclude_constraint)) GROUP BY uk.key_name, c.oid, c.conname, p.range_type, p.start_column_name, p.end_column_name HAVING format('EXCLUDE USING gist (%s, %I(%I, %I, ''[)''::text) WITH &&)', string_agg(quote_ident(u.column_name) || ' WITH =', ', ' ORDER BY u.ordinality), p.range_type, p.start_column_name, p.end_column_name) = pg_catalog.pg_get_constraintdef(c.oid) LOOP EXECUTE sql; END LOOP; --- --- foreign_keys --- /* * We can't reliably find out what a column was renamed to, so just error * out in this case. */ FOR r IN SELECT fk.key_name, fk.table_name, u.column_name FROM periods.foreign_keys AS fk CROSS JOIN LATERAL unnest(fk.column_names) AS u (column_name) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_attribute AS a WHERE (a.attrelid, a.attname) = (fk.table_name, u.column_name)) LOOP RAISE EXCEPTION 'cannot drop or rename column "%" on table "%" because it is used in period foreign key "%"', r.column_name, r.table_name, r.key_name; END LOOP; /* * Since there can be multiple foreign keys, there is no reliable way to * know which trigger might belong to what, so just error out. */ FOR r IN SELECT fk.key_name, fk.table_name, fk.fk_insert_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_insert_trigger)) UNION ALL SELECT fk.key_name, fk.table_name, fk.fk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (fk.table_name, fk.fk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_update_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_update_trigger)) UNION ALL SELECT fk.key_name, uk.table_name, fk.uk_delete_trigger AS trigger_name FROM periods.foreign_keys AS fk JOIN periods.unique_keys AS uk ON uk.key_name = fk.unique_key WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_trigger AS t WHERE (t.tgrelid, t.tgname) = (uk.table_name, fk.uk_delete_trigger)) LOOP RAISE EXCEPTION 'cannot drop or rename trigger "%" on table "%" because it is used in period foreign key "%"', r.trigger_name, r.table_name, r.key_name; END LOOP; --- --- system_versioning --- /* Nothing to do here */ END; $function$; CREATE EVENT TRIGGER periods_rename_following ON ddl_command_end EXECUTE PROCEDURE periods.rename_following(); CREATE OR REPLACE FUNCTION periods.health_checks() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$ #variable_conflict use_variable DECLARE cmd text; r record; save_search_path text; BEGIN /* Make sure that all of our tables are still persistent */ FOR r IN SELECT p.table_name FROM periods.periods AS p JOIN pg_catalog.pg_class AS c ON c.oid = p.table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* And the history tables, too */ FOR r IN SELECT sv.table_name FROM periods.system_versioning AS sv JOIN pg_catalog.pg_class AS c ON c.oid = sv.history_table_name WHERE c.relpersistence <> 'p' LOOP RAISE EXCEPTION 'history table "%" must remain persistent because it has periods', r.table_name; END LOOP; /* Check that our system versioning functions are still here */ save_search_path := pg_catalog.current_setting('search_path'); PERFORM pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true); FOR r IN SELECT * FROM periods.system_versioning AS sv CROSS JOIN LATERAL UNNEST(ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]) AS u (fn) WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_proc AS p WHERE p.oid::regprocedure::text = u.fn ) LOOP RAISE EXCEPTION 'cannot drop or rename function "%" because it is used in SYSTEM VERSIONING for table "%"', r.fn, r.table_name; END LOOP; PERFORM pg_catalog.set_config('search_path', save_search_path, true); /* Fix up history and for-portion objects ownership */ FOR cmd IN SELECT format('ALTER %s %s OWNER TO %I', CASE ht.relkind WHEN 'r' THEN 'TABLE' WHEN 'v' THEN 'VIEW' END, ht.oid::regclass, t.relowner::regrole) FROM periods.system_versioning AS sv JOIN pg_class AS t ON t.oid = sv.table_name JOIN pg_class AS ht ON ht.oid IN (sv.history_table_name, sv.view_name) WHERE t.relowner <> ht.relowner UNION ALL SELECT format('ALTER VIEW %s OWNER TO %I', fpt.oid::regclass, t.relowner::regrole) FROM periods.for_portion_views AS fpv JOIN pg_class AS t ON t.oid = fpv.table_name JOIN pg_class AS fpt ON fpt.oid = fpv.view_name WHERE t.relowner <> fpt.relowner UNION ALL SELECT format('ALTER FUNCTION %s OWNER TO %I', p.oid::regprocedure, t.relowner::regrole) FROM periods.system_versioning AS sv JOIN pg_class AS t ON t.oid = sv.table_name JOIN pg_proc AS p ON p.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE t.relowner <> p.proowner LOOP EXECUTE cmd; END LOOP; /* Check GRANTs */ IF EXISTS ( SELECT FROM pg_event_trigger_ddl_commands() AS ev_ddl WHERE ev_ddl.command_tag = 'GRANT') THEN FOR r IN SELECT *, EXISTS ( SELECT FROM pg_class AS _c CROSS JOIN LATERAL aclexplode(COALESCE(_c.relacl, acldefault('r', _c.relowner))) AS _acl WHERE _c.oid = objects.table_name AND _acl.grantee = objects.grantee AND _acl.privilege_type = 'SELECT' ) AS on_base_table FROM ( SELECT sv.table_name, c.oid::regclass::text AS object_name, c.relkind AS object_type, acl.privilege_type, acl.privilege_type AS base_privilege_type, acl.grantee, 'h' AS history_or_portion FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid IN (sv.history_table_name, sv.view_name) CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl UNION ALL SELECT fpv.table_name, c.oid::regclass::text, c.relkind, acl.privilege_type, acl.privilege_type, acl.grantee, 'p' AS history_or_portion FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.view_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl UNION ALL SELECT sv.table_name, p.oid::regprocedure::text, 'f', acl.privilege_type, 'SELECT', acl.grantee, 'h' FROM periods.system_versioning AS sv JOIN pg_proc AS p ON p.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl ) AS objects ORDER BY object_name, object_type, privilege_type LOOP IF r.history_or_portion = 'h' AND (r.object_type, r.privilege_type) NOT IN (('r', 'SELECT'), ('v', 'SELECT'), ('f', 'EXECUTE')) THEN RAISE EXCEPTION 'cannot grant % to "%"; history objects are read-only', r.privilege_type, r.object_name; END IF; IF NOT r.on_base_table THEN RAISE EXCEPTION 'cannot grant % directly to "%"; grant % to "%" instead', r.privilege_type, r.object_name, r.base_privilege_type, r.table_name; END IF; END LOOP; /* Propagate GRANTs */ FOR cmd IN SELECT format('GRANT %s ON %s %s TO %s', string_agg(DISTINCT privilege_type, ', '), object_type, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT COALESCE(a.rolname, 'public'), ', ')) FROM ( SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, 'SELECT' AS privilege_type, acl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) WHERE acl.privilege_type = 'SELECT' AND NOT has_table_privilege(acl.grantee, hc.oid, 'SELECT') UNION ALL SELECT 'TABLE', fpc.oid::regclass::text, acl.privilege_type, acl.grantee FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS fpc ON fpc.oid = fpv.view_name WHERE NOT has_table_privilege(acl.grantee, fpc.oid, acl.privilege_type) UNION ALL SELECT 'FUNCTION', hp.oid::regprocedure::text, 'EXECUTE', acl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE acl.privilege_type = 'SELECT' AND NOT has_function_privilege(acl.grantee, hp.oid, 'EXECUTE') ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY object_type LOOP EXECUTE cmd; END LOOP; END IF; /* Check REVOKEs */ IF EXISTS ( SELECT FROM pg_event_trigger_ddl_commands() AS ev_ddl WHERE ev_ddl.command_tag = 'REVOKE') THEN FOR r IN SELECT sv.table_name, hc.oid::regclass::text AS object_name, acl.privilege_type, acl.privilege_type AS base_privilege_type FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) WHERE acl.privilege_type = 'SELECT' AND NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS _acl WHERE _acl.privilege_type = 'SELECT' AND _acl.grantee = acl.grantee) UNION ALL SELECT fpv.table_name, hc.oid::regclass::text, acl.privilege_type, acl.privilege_type FROM periods.for_portion_views AS fpv JOIN pg_class AS c ON c.oid = fpv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_class AS hc ON hc.oid = fpv.view_name WHERE NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS _acl WHERE _acl.privilege_type = acl.privilege_type AND _acl.grantee = acl.grantee) UNION ALL SELECT sv.table_name, hp.oid::regprocedure::text, 'EXECUTE', 'SELECT' FROM periods.system_versioning AS sv JOIN pg_class AS c ON c.oid = sv.table_name CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) WHERE acl.privilege_type = 'SELECT' AND NOT EXISTS ( SELECT FROM aclexplode(COALESCE(hp.proacl, acldefault('f', hp.proowner))) AS _acl WHERE _acl.privilege_type = 'EXECUTE' AND _acl.grantee = acl.grantee) ORDER BY table_name, object_name LOOP RAISE EXCEPTION 'cannot revoke % directly from "%", revoke % from "%" instead', r.privilege_type, r.object_name, r.base_privilege_type, r.table_name; END LOOP; /* Propagate REVOKEs */ FOR cmd IN SELECT format('REVOKE %s ON %s %s FROM %s', string_agg(DISTINCT privilege_type, ', '), object_type, string_agg(DISTINCT object_name, ', '), string_agg(DISTINCT COALESCE(a.rolname, 'public'), ', ')) FROM ( SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, 'SELECT' AS privilege_type, hacl.grantee FROM periods.system_versioning AS sv JOIN pg_class AS hc ON hc.oid IN (sv.history_table_name, sv.view_name) CROSS JOIN LATERAL aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS hacl WHERE hacl.privilege_type = 'SELECT' AND NOT has_table_privilege(hacl.grantee, sv.table_name, 'SELECT') UNION ALL SELECT 'TABLE' AS object_type, hc.oid::regclass::text AS object_name, hacl.privilege_type, hacl.grantee FROM periods.for_portion_views AS fpv JOIN pg_class AS hc ON hc.oid = fpv.view_name CROSS JOIN LATERAL aclexplode(COALESCE(hc.relacl, acldefault('r', hc.relowner))) AS hacl WHERE NOT has_table_privilege(hacl.grantee, fpv.table_name, hacl.privilege_type) UNION ALL SELECT 'FUNCTION' AS object_type, hp.oid::regprocedure::text AS object_name, 'EXECUTE' AS privilege_type, hacl.grantee FROM periods.system_versioning AS sv JOIN pg_proc AS hp ON hp.oid = ANY (ARRAY[sv.func_as_of, sv.func_between, sv.func_between_symmetric, sv.func_from_to]::regprocedure[]) CROSS JOIN LATERAL aclexplode(COALESCE(hp.proacl, acldefault('f', hp.proowner))) AS hacl WHERE hacl.privilege_type = 'EXECUTE' AND NOT has_table_privilege(hacl.grantee, sv.table_name, 'SELECT') ) AS objects LEFT JOIN pg_authid AS a ON a.oid = objects.grantee GROUP BY object_type LOOP EXECUTE cmd; END LOOP; END IF; END; $function$; CREATE EVENT TRIGGER periods_health_checks ON ddl_command_end EXECUTE PROCEDURE periods.health_checks(); /* Predicates */ CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, ve anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= ve AND ev1 > ve; $function$; CREATE FUNCTION periods.contains(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 <= sv2 AND ev1 >= ev2; $function$; CREATE FUNCTION periods.equals(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = sv2 AND ev1 = ev2; $function$; CREATE FUNCTION periods.overlaps(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 < ev2 AND ev1 > sv2; $function$; CREATE FUNCTION periods.precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 <= sv2; $function$; CREATE FUNCTION periods.succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 >= ev2; $function$; CREATE FUNCTION periods.immediately_precedes(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT ev1 = sv2; $function$; CREATE FUNCTION periods.immediately_succeeds(sv1 anyelement, ev1 anyelement, sv2 anyelement, ev2 anyelement) RETURNS boolean LANGUAGE sql IMMUTABLE AS $function$ SELECT sv1 = ev2; $function$; periods-1.2.2/periods.c000066400000000000000000000542301432551570100150010ustar00rootroot00000000000000#include "postgres.h" #include "fmgr.h" #include "access/htup_details.h" #include "access/heapam.h" #if (PG_VERSION_NUM < 120000) #define table_open(r, l) heap_open(r, l) #define table_close(r, l) heap_close(r, l) #else #include "access/table.h" #endif #include "access/tupconvert.h" #include "access/xact.h" #include "catalog/pg_type.h" #include "commands/trigger.h" #include "datatype/timestamp.h" #include "executor/spi.h" #include "funcapi.h" #include "lib/stringinfo.h" #include "nodes/bitmapset.h" #include "utils/builtins.h" #include "utils/date.h" #include "utils/datum.h" #include "utils/elog.h" #if (PG_VERSION_NUM < 100000) #else #include "utils/fmgrprotos.h" #endif #include "utils/hsearch.h" #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/rel.h" #include "utils/timestamp.h" PG_MODULE_MAGIC; PGDLLEXPORT Datum generated_always_as_row_start_end(PG_FUNCTION_ARGS); PGDLLEXPORT Datum write_history(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(generated_always_as_row_start_end); PG_FUNCTION_INFO_V1(write_history); /* Define some SQLSTATEs that might not exist */ #if (PG_VERSION_NUM < 100000) #define ERRCODE_GENERATED_ALWAYS MAKE_SQLSTATE('4','2','8','C','9') #endif #define ERRCODE_INVALID_ROW_VERSION MAKE_SQLSTATE('2','2','0','1','H') /* We use these a lot, so make aliases for them */ #if (PG_VERSION_NUM < 100000) #define TRANSACTION_TSTZ TimestampTzGetDatum(GetCurrentTransactionStartTimestamp()) #define TRANSACTION_TS DirectFunctionCall1(timestamptz_timestamp, TRANSACTION_TSTZ) #define TRANSACTION_DATE DirectFunctionCall1(timestamptz_date, TRANSACTION_TSTZ) #else #define TRANSACTION_TSTZ TimestampTzGetDatum(GetCurrentTransactionStartTimestamp()) #define TRANSACTION_TS DirectFunctionCall1(timestamptz_timestamp, TRANSACTION_TSTZ) #define TRANSACTION_DATE DateADTGetDatum(GetSQLCurrentDate()) #endif #define INFINITE_TSTZ TimestampTzGetDatum(DT_NOEND) #define INFINITE_TS TimestampGetDatum(DT_NOEND) #define INFINITE_DATE DateADTGetDatum(DATEVAL_NOEND) /* Plan caches for inserting into history tables */ static HTAB *InsertHistoryPlanHash = NULL; typedef struct InsertHistoryPlanEntry { Oid history_relid; /* the hash key; must be first */ char schemaname[NAMEDATALEN]; char tablename[NAMEDATALEN]; SPIPlanPtr qplan; } InsertHistoryPlanEntry; static HTAB * CreateInsertHistoryPlanHash(void) { HASHCTL ctl; ctl.keysize = sizeof(Oid); ctl.entrysize = sizeof(InsertHistoryPlanEntry); return hash_create("Insert History Hash", 16, &ctl, HASH_ELEM | HASH_BLOBS); } static void GetPeriodColumnNames(Relation rel, char *period_name, char **start_name, char **end_name) { int ret; Datum values[2]; SPITupleTable *tuptable; bool is_null; Datum dat; MemoryContext mcxt = CurrentMemoryContext; /* The context outside of SPI */ const char *sql = "SELECT p.start_column_name, p.end_column_name " "FROM periods.periods AS p " "WHERE (p.table_name, p.period_name) = ($1, $2)"; static SPIPlanPtr qplan = NULL; if (SPI_connect() != SPI_OK_CONNECT) elog(ERROR, "SPI_connect failed"); /* * Query the periods table to get the start and end columns. * Cache the plan if we haven't already. */ if (qplan == NULL) { Oid types[2] = {OIDOID, NAMEOID}; qplan = SPI_prepare(sql, 2, types); if (qplan == NULL) elog(ERROR, "SPI_prepare returned %s for %s", SPI_result_code_string(SPI_result), sql); ret = SPI_keepplan(qplan); if (ret != 0) elog(ERROR, "SPI_keepplan returned %s", SPI_result_code_string(ret)); } values[0] = ObjectIdGetDatum(rel->rd_id); values[1] = CStringGetDatum(period_name); ret = SPI_execute_plan(qplan, values, NULL, true, 0); if (ret != SPI_OK_SELECT) elog(ERROR, "SPI_execute returned %s", SPI_result_code_string(ret)); /* Make sure we got one */ if (SPI_processed == 0) ereport(ERROR, (errmsg("period \"%s\" not found on table \"%s\"", period_name, RelationGetRelationName(rel)))); /* There is a unique constraint so there shouldn't be more than 1 row */ Assert(SPI_processed == 1); /* * Get the names from the result tuple. We copy them into the original * context so they don't get wiped out by SPI_finish(). */ tuptable = SPI_tuptable; dat = SPI_getbinval(tuptable->vals[0], tuptable->tupdesc, 1, &is_null); *start_name = MemoryContextStrdup(mcxt, NameStr(*(DatumGetName(dat)))); dat = SPI_getbinval(tuptable->vals[0], tuptable->tupdesc, 2, &is_null); *end_name = MemoryContextStrdup(mcxt, NameStr(*(DatumGetName(dat)))); /* All done with SPI */ if (SPI_finish() != SPI_OK_FINISH) elog(ERROR, "SPI_finish failed"); } /* * Check if the only columns changed in an UPDATE are columns that the user is * excluding from SYSTEM VERSIONING. One possible use case for this is a * "last_login timestamptz" column on a user table. Arguably, this column * should be in another table, but users have requested the feature so let's do * it. */ static bool OnlyExcludedColumnsChanged(Relation rel, HeapTuple old_row, HeapTuple new_row) { int ret; int i; Datum values[1]; TupleDesc tupdesc = RelationGetDescr(rel); Bitmapset *excluded_attnums = NULL; MemoryContext mcxt = CurrentMemoryContext; /* The context outside of SPI */ const char *sql = "SELECT u.name " "FROM periods.system_time_periods AS stp " "CROSS JOIN unnest(stp.excluded_column_names) AS u (name) " "WHERE stp.table_name = $1"; static SPIPlanPtr qplan = NULL; if (SPI_connect() != SPI_OK_CONNECT) elog(ERROR, "SPI_connect failed"); /* * Get the excluded column names. * Cache the plan if we haven't already. */ if (qplan == NULL) { Oid types[1] = {OIDOID}; qplan = SPI_prepare(sql, 1, types); if (qplan == NULL) elog(ERROR, "SPI_prepare returned %s for %s", SPI_result_code_string(SPI_result), sql); ret = SPI_keepplan(qplan); if (ret != 0) elog(ERROR, "SPI_keepplan returned %s", SPI_result_code_string(ret)); } values[0] = ObjectIdGetDatum(rel->rd_id); ret = SPI_execute_plan(qplan, values, NULL, true, 0); if (ret != SPI_OK_SELECT) elog(ERROR, "SPI_execute returned %s", SPI_result_code_string(ret)); /* Construct a bitmap of excluded attnums */ if (SPI_processed > 0 && SPI_tuptable != NULL) { TupleDesc spitupdesc = SPI_tuptable->tupdesc; bool isnull; int i; for (i = 0; i < SPI_processed; i++) { HeapTuple tuple = SPI_tuptable->vals[i]; Datum attdatum; char *attname; int16 attnum; /* Get the attnum from the column name */ attdatum = SPI_getbinval(tuple, spitupdesc, 1, &isnull); attname = NameStr(*(DatumGetName(attdatum))); attnum = SPI_fnumber(tupdesc, attname); /* Make sure it's valid (should always be) */ if (attnum == SPI_ERROR_NOATTRIBUTE) ereport(ERROR, (errcode(ERRCODE_UNDEFINED_COLUMN), errmsg("column \"%s\" does not exist", attname))); /* Just ignore system columns (should never happen) */ if (attnum < 0) continue; /* Add it to the bitmap set */ excluded_attnums = bms_add_member(excluded_attnums, attnum); } /* * If we have excluded columns, move the bitmapset out of the SPI * context. */ if (excluded_attnums != NULL) { MemoryContext spicontext = MemoryContextSwitchTo(mcxt); excluded_attnums = bms_copy(excluded_attnums); MemoryContextSwitchTo(spicontext); } } /* Don't need SPI anymore */ if (SPI_finish() != SPI_OK_FINISH) elog(ERROR, "SPI_finish failed"); /* If there are no excluded columns, then we're done */ if (excluded_attnums == NULL) return false; for (i = 1; i <= tupdesc->natts; i++) { Datum old_datum, new_datum; bool old_isnull, new_isnull; int16 typlen; bool typbyval; /* Ignore if deleted column */ if (TupleDescAttr(tupdesc, i-1)->attisdropped) continue; /* Ignore if excluded column */ if (bms_is_member(i, excluded_attnums)) continue; old_datum = SPI_getbinval(old_row, tupdesc, i, &old_isnull); new_datum = SPI_getbinval(new_row, tupdesc, i, &new_isnull); /* * If one value is NULL and other is not, then they are certainly not * equal. */ if (old_isnull != new_isnull) return false; /* If both are NULL, they can be considered equal. */ if (old_isnull) continue; /* Do a fairly strict binary comparison of the values */ typlen = TupleDescAttr(tupdesc, i-1)->attlen; typbyval = TupleDescAttr(tupdesc, i-1)->attbyval; if (!datumIsEqual(old_datum, new_datum, typbyval, typlen)) return false; } return true; } /* * Get the oid of the history table. If this table does not have a system_time * period an error is raised. If it doesn't have SYSTEM VERSIONING, then * InvalidOid is returned. */ static Oid GetHistoryTable(Relation rel) { int ret; Datum values[1]; Oid result; SPITupleTable *tuptable; bool is_null; const char *sql = "SELECT history_table_name::oid " "FROM periods.system_versioning AS sv " "WHERE sv.table_name = $1"; static SPIPlanPtr qplan = NULL; if (SPI_connect() != SPI_OK_CONNECT) elog(ERROR, "SPI_connect failed"); /* * Check existence in system_versioning table. * Cache the plan if we haven't already. */ if (qplan == NULL) { Oid types[1] = {OIDOID}; qplan = SPI_prepare(sql, 1, types); if (qplan == NULL) elog(ERROR, "SPI_prepare returned %s for %s", SPI_result_code_string(SPI_result), sql); ret = SPI_keepplan(qplan); if (ret != 0) elog(ERROR, "SPI_keepplan returned %s", SPI_result_code_string(ret)); } values[0] = ObjectIdGetDatum(rel->rd_id); ret = SPI_execute_plan(qplan, values, NULL, true, 0); if (ret != SPI_OK_SELECT) elog(ERROR, "SPI_execute returned %s", SPI_result_code_string(ret)); /* Did we get one? */ if (SPI_processed == 0) { if (SPI_finish() != SPI_OK_FINISH) elog(ERROR, "SPI_finish failed"); return InvalidOid; } /* There is a unique constraint so there shouldn't be more than 1 row */ Assert(SPI_processed == 1); /* Get oid from results */ tuptable = SPI_tuptable; result = DatumGetObjectId(SPI_getbinval(tuptable->vals[0], tuptable->tupdesc, 1, &is_null)); if (SPI_finish() != SPI_OK_FINISH) elog(ERROR, "SPI_finish failed"); return result; } static Datum GetRowStart(Oid typeid) { switch (typeid) { case TIMESTAMPTZOID: return TRANSACTION_TSTZ; case TIMESTAMPOID: return TRANSACTION_TS; case DATEOID: return TRANSACTION_DATE; default: elog(ERROR, "unexpected type: %d", typeid); return 0; /* keep compiler quiet */ } } static Datum GetRowEnd(Oid typeid) { switch (typeid) { case TIMESTAMPTZOID: return INFINITE_TSTZ; case TIMESTAMPOID: return INFINITE_TS; case DATEOID: return INFINITE_DATE; default: elog(ERROR, "unexpected type: %d", typeid); return 0; /* keep compiler quiet */ } } static int CompareWithCurrentDatum(Oid typeid, Datum value) { switch (typeid) { case TIMESTAMPTZOID: return DatumGetInt32(DirectFunctionCall2(timestamp_cmp, value, TRANSACTION_TSTZ)); case TIMESTAMPOID: return DatumGetInt32(DirectFunctionCall2(timestamp_cmp, value, TRANSACTION_TS)); case DATEOID: return DatumGetInt32(DirectFunctionCall2(date_cmp, value, TRANSACTION_DATE)); default: elog(ERROR, "unexpected type: %d", typeid); return 0; /* keep compiler quiet */ } } static int CompareWithInfiniteDatum(Oid typeid, Datum value) { switch (typeid) { case TIMESTAMPTZOID: return DatumGetInt32(DirectFunctionCall2(timestamp_cmp, value, INFINITE_TSTZ)); case TIMESTAMPOID: return DatumGetInt32(DirectFunctionCall2(timestamp_cmp, value, INFINITE_TS)); case DATEOID: return DatumGetInt32(DirectFunctionCall2(date_cmp, value, INFINITE_DATE)); default: elog(ERROR, "unexpected type: %d", typeid); return 0; /* keep compiler quiet */ } } Datum generated_always_as_row_start_end(PG_FUNCTION_ARGS) { TriggerData *trigdata = castNode(TriggerData, fcinfo->context); const char *funcname = "generated_always_as_row_start_end"; Relation rel; HeapTuple new_row; TupleDesc new_tupdesc; Datum values[2]; bool nulls[2]; int columns[2]; char *start_name, *end_name; int16 start_num, end_num; Oid typeid; /* * Make sure this is being called as an BEFORE ROW trigger. Note: * translatable error strings are shared with ri_triggers.c, so resist the * temptation to fold the function name into them. */ if (!CALLED_AS_TRIGGER(fcinfo)) ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" was not called by trigger manager", funcname))); if (!TRIGGER_FIRED_BEFORE(trigdata->tg_event) || !TRIGGER_FIRED_FOR_ROW(trigdata->tg_event)) ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" must be fired BEFORE ROW", funcname))); /* Get Relation information */ rel = trigdata->tg_relation; new_tupdesc = RelationGetDescr(rel); /* Get the new data that was inserted/updated */ if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event)) new_row = trigdata->tg_trigtuple; else if (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event)) { HeapTuple old_row; old_row = trigdata->tg_trigtuple; new_row = trigdata->tg_newtuple; /* Don't change anything if only excluded columns are being updated. */ if (OnlyExcludedColumnsChanged(rel, old_row, new_row)) return PointerGetDatum(new_row); } else { ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" must be fired for INSERT or UPDATE", funcname))); new_row = NULL; /* keep compiler quiet */ } GetPeriodColumnNames(rel, "system_time", &start_name, &end_name); /* Get the column numbers and type */ start_num = SPI_fnumber(new_tupdesc, start_name); end_num = SPI_fnumber(new_tupdesc, end_name); typeid = SPI_gettypeid(new_tupdesc, start_num); columns[0] = start_num; values[0] = GetRowStart(typeid); nulls[0] = false; columns[1] = end_num; values[1] = GetRowEnd(typeid); nulls[1] = false; #if (PG_VERSION_NUM < 100000) new_row = SPI_modifytuple(rel, new_row, 2, columns, values, nulls); #else new_row = heap_modify_tuple_by_cols(new_row, new_tupdesc, 2, columns, values, nulls); #endif return PointerGetDatum(new_row); } static void insert_into_history(Relation history_rel, HeapTuple history_tuple) { InsertHistoryPlanEntry *hentry; bool found; char *schemaname = SPI_getnspname(history_rel); char *tablename = SPI_getrelname(history_rel); Oid history_relid = history_rel->rd_id; Datum value; int ret; if (SPI_connect() != SPI_OK_CONNECT) elog(ERROR, "SPI_connect failed"); if (!InsertHistoryPlanHash) InsertHistoryPlanHash = CreateInsertHistoryPlanHash(); /* Fetch the cached plan */ hentry = (InsertHistoryPlanEntry *) hash_search( InsertHistoryPlanHash, &history_relid, HASH_ENTER, &found); /* If we didn't find it or the name changed, re-plan it */ if (!found || !strcmp(hentry->schemaname, schemaname) || !strcmp(hentry->tablename, tablename)) { StringInfo buf = makeStringInfo(); Oid type = HeapTupleHeaderGetTypeId(history_tuple->t_data); appendStringInfo(buf, "INSERT INTO %s VALUES (($1).*)", quote_qualified_identifier(schemaname, tablename)); hentry->history_relid = history_relid; strlcpy(hentry->schemaname, schemaname, sizeof(hentry->schemaname)); strlcpy(hentry->tablename, tablename, sizeof(hentry->tablename)); hentry->qplan = SPI_prepare(buf->data, 1, &type); if (hentry->qplan == NULL) elog(ERROR, "SPI_prepare returned %s for %s", SPI_result_code_string(SPI_result), buf->data); ret = SPI_keepplan(hentry->qplan); if (ret != 0) elog(ERROR, "SPI_keepplan returned %s", SPI_result_code_string(ret)); } /* Do the INSERT */ value = HeapTupleGetDatum(history_tuple); ret = SPI_execute_plan(hentry->qplan, &value, NULL, false, 0); if (ret != SPI_OK_INSERT) elog(ERROR, "SPI_execute returned %s", SPI_result_code_string(ret)); if (SPI_finish() != SPI_OK_FINISH) elog(ERROR, "SPI_finish failed"); } Datum write_history(PG_FUNCTION_ARGS) { TriggerData *trigdata = castNode(TriggerData, fcinfo->context); const char *funcname = "write_history"; Relation rel; HeapTuple old_row, new_row; TupleDesc tupledesc; char *start_name, *end_name; int16 start_num, end_num; Oid typeid; bool is_null; Oid history_id; int cmp; bool only_excluded_changed = false; /* * Make sure this is being called as an AFTER ROW trigger. Note: * translatable error strings are shared with ri_triggers.c, so resist the * temptation to fold the function name into them. */ if (!CALLED_AS_TRIGGER(fcinfo)) ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" was not called by trigger manager", funcname))); if (!TRIGGER_FIRED_AFTER(trigdata->tg_event) || !TRIGGER_FIRED_FOR_ROW(trigdata->tg_event)) ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" must be fired AFTER ROW", funcname))); /* Get Relation information */ rel = trigdata->tg_relation; tupledesc = RelationGetDescr(rel); /* Get the old data that was updated/deleted */ if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event)) { old_row = NULL; /* keep compiler quiet */ new_row = trigdata->tg_trigtuple; } else if (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event)) { old_row = trigdata->tg_trigtuple; new_row = trigdata->tg_newtuple; /* Did only excluded columns change? */ only_excluded_changed = OnlyExcludedColumnsChanged(rel, old_row, new_row); } else if (TRIGGER_FIRED_BY_DELETE(trigdata->tg_event)) { old_row = trigdata->tg_trigtuple; new_row = NULL; /* keep compiler quiet */ } else { ereport(ERROR, (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), errmsg("function \"%s\" must be fired for INSERT or UPDATE or DELETE", funcname))); old_row = NULL; /* keep compiler quiet */ new_row = NULL; /* keep compiler quiet */ } GetPeriodColumnNames(rel, "system_time", &start_name, &end_name); /* Get the column numbers and type */ start_num = SPI_fnumber(tupledesc, start_name); end_num = SPI_fnumber(tupledesc, end_name); typeid = SPI_gettypeid(tupledesc, start_num); /* * Validate that the period columns haven't been modified. This can happen * with a trigger executed after generated_always_as_row_start_end(). */ if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event) || (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event) && !only_excluded_changed)) { Datum start_datum = SPI_getbinval(new_row, tupledesc, start_num, &is_null); Datum end_datum = SPI_getbinval(new_row, tupledesc, end_num, &is_null); if (CompareWithCurrentDatum(typeid, start_datum) != 0) ereport(ERROR, (errcode(ERRCODE_GENERATED_ALWAYS), errmsg("cannot insert or update column \"%s\"", start_name), errdetail("Column \"%s\" is GENERATED ALWAYS AS ROW START", start_name))); if (CompareWithInfiniteDatum(typeid, end_datum) != 0) ereport(ERROR, (errcode(ERRCODE_GENERATED_ALWAYS), errmsg("cannot insert or update column \"%s\"", end_name), errdetail("Column \"%s\" is GENERATED ALWAYS AS ROW END", end_name))); /* * If this is an INSERT, then we're done because there is no history to * write. */ if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event)) return PointerGetDatum(NULL); } /* If only excluded columns have changed, don't write history. */ if (only_excluded_changed) return PointerGetDatum(NULL); /* Compare the OLD row's start with the transaction start */ cmp = CompareWithCurrentDatum(typeid, SPI_getbinval(old_row, tupledesc, start_num, &is_null)); /* * Don't do anything more if the start time is still the same. * * DELETE: SQL:2016 13.4 GR 15)a)iii)2) * UPDATE: SQL:2016 15.13 GR 9)a)iii)2) */ if (cmp == 0) return PointerGetDatum(NULL); /* * There is a weird case in READ UNCOMMITTED and READ COMMITTED where a * transaction can UPDATE/DELETE a row created by a transaction that * started later. In effect, system-versioned tables must be run at the * SERIALIZABLE level and so if we come across such an anomaly, we give an * invalid row version error, per spec. * * DELETE: SQL:2016 13.4 GR 15)a)iii)1) * UPDATE: SQL:2016 15.13 GR 9)a)iii)1) */ if (cmp > 0) ereport(ERROR, (errcode(ERRCODE_INVALID_ROW_VERSION), errmsg("invalid row version"), errdetail("The row being updated or deleted was created after this transaction started."), errhint("The transaction might succeed if retried."))); /* * If this table does not have SYSTEM VERSIONING, there is nothing else to * be done. */ history_id = GetHistoryTable(rel); if (OidIsValid(history_id)) { Relation history_rel; TupleDesc history_tupledesc; HeapTuple history_tuple; int16 history_end_num; TupleConversionMap *map; Datum *values; bool *nulls; /* Open the history table for inserting */ history_rel = table_open(history_id, RowExclusiveLock); history_tupledesc = RelationGetDescr(history_rel); history_end_num = SPI_fnumber(history_tupledesc, end_name); /* * We may have to convert the tuple structure between the table and the * history table. * * See https://github.com/xocolatl/periods/issues/5 */ #if (PG_VERSION_NUM < 130000) map = convert_tuples_by_name(tupledesc, history_tupledesc, gettext_noop("could not convert row type")); #else map = convert_tuples_by_name(tupledesc, history_tupledesc); #endif if (map != NULL) { #if (PG_VERSION_NUM < 120000) history_tuple = do_convert_tuple(old_row, map); #else history_tuple = execute_attr_map_tuple(old_row, map); #endif free_conversion_map(map); } else { history_tuple = old_row; /* * Use the main table's tupledesc if there is no map so that * missing attributes are filled in. This corrects for bug #16242 * which was found by this very problem. */ history_tupledesc = tupledesc; } /* Build the new tuple for the history table */ values = (Datum *) palloc(history_tupledesc->natts * sizeof(Datum)); nulls = (bool *) palloc(history_tupledesc->natts * sizeof(bool)); /* Modify the historical ROW END on the fly */ heap_deform_tuple(history_tuple, history_tupledesc, values, nulls); values[history_end_num-1] = GetRowStart(typeid); nulls[history_end_num-1] = false; history_tuple = heap_form_tuple(history_tupledesc, values, nulls); pfree(values); pfree(nulls); /* INSERT the row */ insert_into_history(history_rel, history_tuple); /* Keep the lock until end of transaction */ table_close(history_rel, NoLock); } return PointerGetDatum(NULL); } periods-1.2.2/periods.control000066400000000000000000000002671432551570100162400ustar00rootroot00000000000000comment = 'Provide Standard SQL functionality for PERIODs and SYSTEM VERSIONING' default_version = 1.2 module_pathname = '$libdir/periods' relocatable = false requires = 'btree_gist' periods-1.2.2/periods.vcxproj000066400000000000000000000100531432551570100162450ustar00rootroot00000000000000 Debug Win32 Release Win32 Debug x64 Release x64 Win32Proj periods v141 11 $(ProgramFiles) true DynamicLibrary $(ProgramW6432) $(pf)\PostgreSQL\$(pgversion) {9B904FEA-1564-4CF0-970A-826E43DE2980} true Unicode $(pgroot)\include\server\port\win32_msvc;$(pgroot)\include\server\port\win32;$(pgroot)\include\server;$(pgroot)\include;$(IncludePath) $(pgroot)\lib;$(LibraryPath) false true false DebugSymbolsProjectOutputGroup Level3 true CompileAsC false AdvancedVectorExtensions2 WIN32;%(PreprocessorDefinitions) USE_ASSERT_CHECKING;%(PreprocessorDefinitions) Debug postgres.lib;%(AdditionalDependencies) true true MaxSpeed Speed true true AnySuitable true true true periods-1.2.2/sql/000077500000000000000000000000001432551570100137635ustar00rootroot00000000000000periods-1.2.2/sql/acl.sql000066400000000000000000000230131432551570100152420ustar00rootroot00000000000000SELECT setting::integer < 110000 AS pre_11, setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Tests for access control on the history tables */ CREATE ROLE periods_acl_1; CREATE ROLE periods_acl_2; CREATE ROLE periods_acl_3; /* OWNER */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_owners AS SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, c.relowner::regrole AS owner FROM pg_class AS c WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (ARRAY['owner_test', 'owner_test_history', 'owner_test_with_history', 'owner_test__for_portion_of_p']) UNION ALL SELECT p.pronamespace, p.proname, 'function', p.proowner FROM pg_proc AS p WHERE p.pronamespace = 'public'::regnamespace AND p.proname = ANY (ARRAY['owner_test__as_of', 'owner_test__between', 'owner_test__between_symmetric', 'owner_test__from_to']); CREATE TABLE owner_test (col text PRIMARY KEY, s integer, e integer); ALTER TABLE owner_test OWNER TO periods_acl_1; SELECT periods.add_period('owner_test', 'p', 's', 'e'); SELECT periods.add_for_portion_view('owner_test', 'p'); SELECT periods.add_system_time_period('owner_test'); SELECT periods.add_system_versioning('owner_test'); TABLE show_owners ORDER BY object_name; -- This should change everything ALTER TABLE owner_test OWNER TO periods_acl_2; TABLE show_owners ORDER BY object_name; -- These should change nothing ALTER TABLE owner_test_history OWNER TO periods_acl_3; ALTER VIEW owner_test_with_history OWNER TO periods_acl_3; ALTER FUNCTION owner_test__as_of(timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__between_symmetric(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; ALTER FUNCTION owner_test__from_to(timestamp with time zone, timestamp with time zone) OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; -- This should put the owner back to the base table's owner SELECT periods.drop_system_versioning('owner_test'); ALTER TABLE owner_test_history OWNER TO periods_acl_3; TABLE show_owners ORDER BY object_name; SELECT periods.add_system_versioning('owner_test'); TABLE show_owners ORDER BY object_name; SELECT periods.drop_system_versioning('owner_test', drop_behavior => 'CASCADE', purge => true); SELECT periods.drop_for_portion_view('owner_test', NULL); DROP TABLE owner_test CASCADE; DROP VIEW show_owners; /* FOR PORTION OF ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('fpacl', 'fpacl__for_portion_of_p') ) AS _; CREATE TABLE fpacl (col text PRIMARY KEY, s integer, e integer); ALTER TABLE fpacl OWNER TO periods_acl_1; SELECT periods.add_period('fpacl', 'p', 's', 'e'); SELECT periods.add_for_portion_view('fpacl', 'p'); TABLE show_acls ORDER BY sort_order; GRANT SELECT, UPDATE ON TABLE fpacl__for_portion_of_p TO periods_acl_2; -- fail GRANT SELECT, UPDATE ON TABLE fpacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; REVOKE UPDATE ON TABLE fpacl__for_portion_of_p FROM periods_acl_2; -- fail REVOKE UPDATE ON TABLE fpacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; SELECT periods.drop_for_portion_view('fpacl', 'p'); DROP TABLE fpacl CASCADE; DROP VIEW show_acls; /* History ACL */ -- We call this query several times, so make it a view for eaiser maintenance CREATE VIEW show_acls AS SELECT row_number() OVER (ORDER BY array_position(ARRAY['table', 'view', 'function'], object_type), schema_name, object_name, grantee, privilege_type) AS sort_order, * FROM ( SELECT c.relnamespace::regnamespace AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' END AS object_type, acl.grantee::regrole::text AS grantee, acl.privilege_type FROM pg_class AS c CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) AS acl WHERE c.relname IN ('histacl', 'histacl_history', 'histacl_with_history') UNION ALL SELECT p.pronamespace::regnamespace, p.proname, 'function', acl.grantee::regrole::text, acl.privilege_type FROM pg_proc AS p CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) AS acl WHERE p.proname IN ('histacl__as_of', 'histacl__between', 'histacl__between_symmetric', 'histacl__from_to') ) AS _; CREATE TABLE histacl (col text); ALTER TABLE histacl OWNER TO periods_acl_1; SELECT periods.add_system_time_period('histacl'); SELECT periods.add_system_versioning('histacl'); TABLE show_acls ORDER BY sort_order; -- Disconnect, add some privs to the history table, and reconnect SELECT periods.drop_system_versioning('histacl'); GRANT ALL ON TABLE histacl_history TO periods_acl_3; TABLE show_acls ORDER BY sort_order; SELECT periods.add_system_versioning('histacl'); TABLE show_acls ORDER BY sort_order; -- These next 6 blocks should fail GRANT ALL ON TABLE histacl_history TO periods_acl_3; -- fail GRANT SELECT ON TABLE histacl_history TO periods_acl_3; -- fail REVOKE ALL ON TABLE histacl_history FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; GRANT ALL ON TABLE histacl_with_history TO periods_acl_3; -- fail GRANT SELECT ON TABLE histacl_with_history TO periods_acl_3; -- fail REVOKE ALL ON TABLE histacl_with_history FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; GRANT ALL ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail GRANT EXECUTE ON FUNCTION histacl__as_of(timestamp with time zone) TO periods_acl_3; -- fail REVOKE ALL ON FUNCTION histacl__as_of(timestamp with time zone) FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; GRANT ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail GRANT EXECUTE ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail REVOKE ALL ON FUNCTION histacl__between(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; GRANT ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail GRANT EXECUTE ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail REVOKE ALL ON FUNCTION histacl__between_symmetric(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; GRANT ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail GRANT EXECUTE ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) TO periods_acl_3; -- fail REVOKE ALL ON FUNCTION histacl__from_to(timestamp with time zone, timestamp with time zone) FROM periods_acl_1; -- fail TABLE show_acls ORDER BY sort_order; -- This one should work and propagate GRANT ALL ON TABLE histacl TO periods_acl_2; TABLE show_acls ORDER BY sort_order; REVOKE SELECT ON TABLE histacl FROM periods_acl_2; TABLE show_acls ORDER BY sort_order; SELECT periods.drop_system_versioning('histacl', drop_behavior => 'CASCADE', purge => true); DROP TABLE histacl CASCADE; DROP VIEW show_acls; /* Who can modify the history table? */ CREATE TABLE retention (value integer); ALTER TABLE retention OWNER TO periods_acl_1; REVOKE ALL ON TABLE retention FROM PUBLIC; GRANT ALL ON TABLE retention TO periods_acl_2; GRANT SELECT ON TABLE retention TO periods_acl_3; SELECT periods.add_system_time_period('retention'); SELECT periods.add_system_versioning('retention'); INSERT INTO retention (value) VALUES (1); UPDATE retention SET value = 2; SET ROLE TO periods_acl_3; DELETE FROM retention_history; -- fail SET ROLE TO periods_acl_2; DELETE FROM retention_history; -- fail SET ROLE TO periods_acl_1; DELETE FROM retention_history; -- fail -- test what the docs say to do BEGIN; SELECT periods.drop_system_versioning('retention'); GRANT DELETE ON TABLE retention_history TO CURRENT_USER; DELETE FROM retention_history; SELECT periods.add_system_versioning('retention'); COMMIT; -- superuser can do anything RESET ROLE; DELETE FROM retention_history; SELECT periods.drop_system_versioning('retention', drop_behavior => 'CASCADE', purge => true); DROP TABLE retention CASCADE; /* Clean up */ DROP ROLE periods_acl_1; DROP ROLE periods_acl_2; DROP ROLE periods_acl_3; periods-1.2.2/sql/beeswax.sql000066400000000000000000000004331432551570100161420ustar00rootroot00000000000000/* * Test creating a table, dropping a column, and then dropping the whole thing; * without any periods. This is to make sure the health checks don't try to do * anything. */ CREATE TABLE beeswax (col1 text, col2 date); ALTER TABLE beeswax DROP COLUMN col1; DROP TABLE beeswax; periods-1.2.2/sql/drop_protection.sql000066400000000000000000000051651432551570100177250ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Make sure nobody drops the objects we keep track of in our catalogs. */ CREATE TYPE integerrange AS RANGE (SUBTYPE = integer); CREATE TABLE dp ( id bigint, s integer, e integer, x boolean ); /* periods */ SELECT periods.add_period('dp', 'p', 's', 'e', 'integerrange'); DROP TYPE integerrange; /* system_time_periods */ SELECT periods.add_system_time_period('dp', excluded_column_names => ARRAY['x']); ALTER TABLE dp DROP COLUMN x; -- fails ALTER TABLE dp DROP CONSTRAINT dp_system_time_end_infinity_check; -- fails DROP TRIGGER dp_system_time_generated_always ON dp; -- fails DROP TRIGGER dp_system_time_write_history ON dp; -- fails DROP TRIGGER dp_truncate ON dp; -- fails /* for_portion_views */ ALTER TABLE dp ADD CONSTRAINT dp_pkey PRIMARY KEY (id); SELECT periods.add_for_portion_view('dp', 'p'); DROP VIEW dp__for_portion_of_p; DROP TRIGGER for_portion_of_p ON dp__for_portion_of_p; ALTER TABLE dp DROP CONSTRAINT dp_pkey; SELECT periods.drop_for_portion_view('dp', 'p'); ALTER TABLE dp DROP CONSTRAINT dp_pkey; /* unique_keys */ ALTER TABLE dp ADD CONSTRAINT u UNIQUE (id, s, e), ADD CONSTRAINT x EXCLUDE USING gist (id WITH =, integerrange(s, e, '[)') WITH &&); SELECT periods.add_unique_key('dp', ARRAY['id'], 'p', 'k', 'u', 'x'); ALTER TABLE dp DROP CONSTRAINT u; -- fails ALTER TABLE dp DROP CONSTRAINT x; -- fails ALTER TABLE dp DROP CONSTRAINT dp_p_check; -- fails /* foreign_keys */ CREATE TABLE dp_ref (LIKE dp); SELECT periods.add_period('dp_ref', 'p', 's', 'e', 'integerrange'); SELECT periods.add_foreign_key('dp_ref', ARRAY['id'], 'p', 'k', key_name => 'f'); DROP TRIGGER f_fk_insert ON dp_ref; -- fails DROP TRIGGER f_fk_update ON dp_ref; -- fails DROP TRIGGER f_uk_update ON dp; -- fails DROP TRIGGER f_uk_delete ON dp; -- fails SELECT periods.drop_foreign_key('dp_ref', 'f'); DROP TABLE dp_ref; /* system_versioning */ SELECT periods.add_system_versioning('dp'); -- Note: The history table is protected by the history view and the history -- view is protected by the temporal functions. DROP TABLE dp_history CASCADE; DROP VIEW dp_with_history CASCADE; DROP FUNCTION dp__as_of(timestamp with time zone); DROP FUNCTION dp__between(timestamp with time zone,timestamp with time zone); DROP FUNCTION dp__between_symmetric(timestamp with time zone,timestamp with time zone); DROP FUNCTION dp__from_to(timestamp with time zone,timestamp with time zone); SELECT periods.drop_system_versioning('dp', purge => true); DROP TABLE dp; DROP TYPE integerrange; periods-1.2.2/sql/excluded_columns.sql000066400000000000000000000037301432551570100200440ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; CREATE TABLE excl ( value text NOT NULL, null_value integer, flap text NOT NULL ); SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['xmin']); -- fails SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['none']); -- fails SELECT periods.add_system_time_period('excl', excluded_column_names => ARRAY['flap']); -- passes SELECT periods.add_system_versioning('excl'); TABLE periods.periods; TABLE periods.system_time_periods; TABLE periods.system_versioning; BEGIN; SELECT CURRENT_TIMESTAMP AS now \gset INSERT INTO excl (value, flap) VALUES ('hello world', 'off'); COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; UPDATE excl SET flap = 'off'; UPDATE excl SET flap = 'on'; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; BEGIN; SELECT CURRENT_TIMESTAMP AS now2 \gset UPDATE excl SET value = 'howdy folks!'; COMMIT; SELECT value, null_value, flap, system_time_start <> :'now' AS changed FROM excl; UPDATE excl SET null_value = 0; SELECT value, null_value, flap, system_time_start <> :'now2' AS changed FROM excl; /* Test directly setting the excluded columns */ SELECT periods.drop_system_versioning('excl'); ALTER TABLE excl ADD COLUMN flop text; ALTER TABLE excl_history ADD COLUMN flop text; SELECT periods.add_system_versioning('excl'); SELECT periods.set_system_time_period_excluded_columns('excl', ARRAY['flap', 'flop']); TABLE periods.system_time_periods; UPDATE excl SET flop = 'flop'; SELECT value, null_value, flap, flop FROM excl; SELECT value, null_value, flap, flop FROM excl_history ORDER BY system_time_start; SELECT periods.drop_system_versioning('excl', drop_behavior => 'CASCADE', purge => true); DROP TABLE excl; periods-1.2.2/sql/for_portion_of.sql000066400000000000000000000074211432551570100175340ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10, setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * Create a sequence to test non-serial primary keys. This actually tests * things like uuid primary keys, but makes for reproducible test cases. */ CREATE SEQUENCE pricing_seq; CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, id4 bigint GENERATED ALWAYS AS (id1 + id2) STORED, product text, min_quantity integer, max_quantity integer, price numeric); CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), id3 bigint GENERATED ALWAYS AS IDENTITY, product text, min_quantity integer, max_quantity integer, price numeric); CREATE TABLE pricing (id1 bigserial, id2 bigint PRIMARY KEY DEFAULT nextval('pricing_seq'), product text, min_quantity integer, max_quantity integer, price numeric); SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); SELECT periods.add_for_portion_view('pricing', 'quantities'); TABLE periods.for_portion_views; /* Test UPDATE FOR PORTION */ INSERT INTO pricing (product, min_quantity, max_quantity, price) VALUES ('Trinket', 1, 20, 200); TABLE pricing ORDER BY min_quantity; -- UPDATE fully preceding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 1, price = 0; TABLE pricing ORDER BY min_quantity; -- UPDATE fully succeeding UPDATE pricing__for_portion_of_quantities SET min_quantity = 30, max_quantity = 50, price = 0; TABLE pricing ORDER BY min_quantity; -- UPDATE fully surrounding UPDATE pricing__for_portion_of_quantities SET min_quantity = 0, max_quantity = 100, price = 100; TABLE pricing ORDER BY min_quantity; -- UPDATE portion UPDATE pricing__for_portion_of_quantities SET min_quantity = 10, max_quantity = 20, price = 80; TABLE pricing ORDER BY min_quantity; -- UPDATE portion of multiple rows UPDATE pricing__for_portion_of_quantities SET min_quantity = 5, max_quantity = 15, price = 90; TABLE pricing ORDER BY min_quantity; -- If we drop the period (without CASCADE) then the FOR PORTION views should be -- dropped, too. SELECT periods.drop_period('pricing', 'quantities'); TABLE periods.for_portion_views; -- Add it back to test the drop_for_portion_view function SELECT periods.add_period('pricing', 'quantities', 'min_quantity', 'max_quantity'); SELECT periods.add_for_portion_view('pricing', 'quantities'); -- We can't drop the the table without first dropping the FOR PORTION views -- because Postgres will complain about dependant objects (our views) before we -- get a chance to clean them up. DROP TABLE pricing; SELECT periods.drop_for_portion_view('pricing', NULL); TABLE periods.for_portion_views; DROP TABLE pricing; DROP SEQUENCE pricing_seq; /* Types without btree must be excluded, too */ -- v10+ CREATE TABLE bt ( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); -- pre v10 CREATE TABLE bt ( id serial PRIMARY KEY, pt point, -- something without btree t text, -- something with btree s integer, e integer ); SELECT periods.add_period('bt', 'p', 's', 'e'); SELECT periods.add_for_portion_view('bt', 'p'); INSERT INTO bt (pt, t, s, e) VALUES ('(0, 0)', 'sample', 10, 40); TABLE bt ORDER BY s, e; UPDATE bt__for_portion_of_p SET t = 'simple', s = 20, e = 30; TABLE bt ORDER BY s, e; SELECT periods.drop_for_portion_view('bt', 'p'); DROP TABLE bt; periods-1.2.2/sql/health_checks.sql000066400000000000000000000013431432551570100172720ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Ensure tables with periods are persistent */ CREATE UNLOGGED TABLE log (id bigint, s date, e date); SELECT periods.add_period('log', 'p', 's', 'e'); -- fails SELECT periods.add_system_time_period('log'); -- fails ALTER TABLE log SET LOGGED; SELECT periods.add_period('log', 'p', 's', 'e'); -- passes SELECT periods.add_system_time_period('log'); -- passes ALTER TABLE log SET UNLOGGED; -- fails SELECT periods.add_system_versioning('log'); ALTER TABLE log_history SET UNLOGGED; -- fails SELECT periods.drop_system_versioning('log', purge => true); DROP TABLE log; periods-1.2.2/sql/install.sql000066400000000000000000000006351432551570100161560ustar00rootroot00000000000000/* Once support for 9.5 has passed, use CASCADE */ CREATE EXTENSION IF NOT EXISTS btree_gist; /* Once support for 9.6 has passed, just create the extension */ CREATE EXTENSION periods VERSION '1.2'; SELECT extversion FROM pg_extension WHERE extname = 'periods'; DROP ROLE periods_unprivileged_user; CREATE ROLE periods_unprivileged_user; /* Make tests work on PG 15 */ GRANT CREATE ON SCHEMA public TO PUBLIC; periods-1.2.2/sql/issues.sql000066400000000000000000000054171432551570100160260ustar00rootroot00000000000000SELECT setting::integer < 100000 AS pre_10 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* https://github.com/xocolatl/periods/issues/5 */ CREATE TABLE issue5 ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value VARCHAR NOT NULL ); CREATE TABLE IF NOT EXISTS issue5 ( id serial PRIMARY KEY, value VARCHAR NOT NULL ); ALTER TABLE issue5 DROP COLUMN value; ALTER TABLE issue5 ADD COLUMN value2 varchar NOT NULL; INSERT INTO issue5 (value2) VALUES ('hello'), ('world'); SELECT periods.add_system_time_period ('issue5'); SELECT periods.add_system_versioning ('issue5'); BEGIN; SELECT now() AS ts \gset UPDATE issue5 SET value2 = 'goodbye' WHERE id = 2; SELECT id, value2, system_time_start, system_time_end FROM issue5_with_history EXCEPT ALL VALUES (1::integer, 'hello'::varchar, '-infinity'::timestamptz, 'infinity'::timestamptz), (2, 'goodbye', :'ts', 'infinity'), (2, 'world', '-infinity', :'ts'); COMMIT; SELECT periods.drop_system_versioning('issue5', drop_behavior => 'CASCADE', purge => true); DROP TABLE issue5; /* Check PostgreSQL Bug #16242 */ CREATE TABLE pg16242 (value text); INSERT INTO pg16242 (value) VALUES ('helloworld'); SELECT periods.add_system_time_period('pg16242'); SELECT periods.add_system_versioning('pg16242'); UPDATE pg16242 SET value = 'hello world'; SELECT system_time_start FROM pg16242_history; SELECT periods.drop_system_versioning('pg16242', drop_behavior => 'CASCADE', purge => true); DROP TABLE pg16242; /* https://github.com/xocolatl/periods/issues/11 */ CREATE TABLE "issue11" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "revision" INTEGER NOT NULL ); -- for versions pre-10: CREATE TABLE "issue11" ( "id" bigserial PRIMARY KEY, "revision" INTEGER NOT NULL ); SELECT periods.add_system_time_period('issue11', 'row_start_time', 'row_end_time'); SELECT periods.add_system_versioning('issue11'); INSERT INTO "issue11" ("revision") VALUES (1); INSERT INTO "issue11" ("revision") VALUES (10); UPDATE "issue11" SET "revision" = 2 WHERE ("id" = 1); UPDATE "issue11" SET "revision" = 3 WHERE ("id" = 1); CREATE INDEX "yolo" ON "issue11_history" ("id", "revision"); UPDATE "issue11" SET "revision" = 11 WHERE ("id" = 2); -- returns 2 rows SELECT id, revision FROM "issue11_history" WHERE "id" = 1 ORDER BY row_start_time; -- returns 0 rows if index is used / 1 row if seq scan is used SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; SET enable_seqscan = off; SELECT id, revision FROM "issue11_history" WHERE "id" = 2 ORDER BY row_start_time; RESET enable_seqscan; SELECT periods.drop_system_versioning('issue11', drop_behavior => 'CASCADE', purge => true); DROP TABLE "issue11"; periods-1.2.2/sql/periods.sql000066400000000000000000000022211432551570100161460ustar00rootroot00000000000000SELECT setting::integer < 130000 AS pre_13 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic period definitions with dates */ CREATE TABLE basic (val text, s date, e date); TABLE periods.periods; SELECT periods.add_period('basic', 'bp', 's', 'e'); TABLE periods.periods; SELECT periods.drop_period('basic', 'bp'); TABLE periods.periods; SELECT periods.add_period('basic', 'bp', 's', 'e', bounds_check_constraint => 'c'); TABLE periods.periods; SELECT periods.drop_period('basic', 'bp', purge => true); TABLE periods.periods; SELECT periods.add_period('basic', 'bp', 's', 'e'); TABLE periods.periods; /* Test constraints */ INSERT INTO basic (val, s, e) VALUES ('x', null, null); --fail INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', null); --fail INSERT INTO basic (val, s, e) VALUES ('x', null, '1000-01-01'); --fail INSERT INTO basic (val, s, e) VALUES ('x', '3000-01-01', '1000-01-01'); --fail INSERT INTO basic (val, s, e) VALUES ('x', '1000-01-01', '3000-01-01'); --success TABLE basic; /* Test dropping the whole thing */ DROP TABLE basic; TABLE periods.periods; periods-1.2.2/sql/predicates.sql000066400000000000000000000104101432551570100166230ustar00rootroot00000000000000/* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; CREATE TABLE preds (s integer, e integer); SELECT periods.add_period('preds', 'p', 's', 'e'); INSERT INTO preds (s, e) VALUES (100, 200); ANALYZE preds; /* Ensure the functions are inlined. */ EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.contains(s, e, 100); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.contains(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.equals(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.overlaps(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.precedes(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.succeeds(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 100, 200); EXPLAIN (COSTS OFF) SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 100, 200); /* Now make sure they work! */ SELECT * FROM preds WHERE periods.contains(s, e, 0); SELECT * FROM preds WHERE periods.contains(s, e, 150); SELECT * FROM preds WHERE periods.contains(s, e, 300); SELECT * FROM preds WHERE periods.contains(s, e, 0, 50); SELECT * FROM preds WHERE periods.contains(s, e, 50, 100); SELECT * FROM preds WHERE periods.contains(s, e, 100, 150); SELECT * FROM preds WHERE periods.contains(s, e, 150, 200); SELECT * FROM preds WHERE periods.contains(s, e, 200, 250); SELECT * FROM preds WHERE periods.contains(s, e, 250, 300); SELECT * FROM preds WHERE periods.contains(s, e, 125, 175); SELECT * FROM preds WHERE periods.contains(s, e, 0, 300); SELECT * FROM preds WHERE periods.equals(s, e, 0, 100); SELECT * FROM preds WHERE periods.equals(s, e, 100, 200); SELECT * FROM preds WHERE periods.equals(s, e, 200, 300); SELECT * FROM preds WHERE periods.overlaps(s, e, 0, 50); SELECT * FROM preds WHERE periods.overlaps(s, e, 50, 100); SELECT * FROM preds WHERE periods.overlaps(s, e, 100, 150); SELECT * FROM preds WHERE periods.overlaps(s, e, 150, 200); SELECT * FROM preds WHERE periods.overlaps(s, e, 200, 250); SELECT * FROM preds WHERE periods.overlaps(s, e, 250, 300); SELECT * FROM preds WHERE periods.overlaps(s, e, 125, 175); SELECT * FROM preds WHERE periods.overlaps(s, e, 0, 300); SELECT * FROM preds WHERE periods.precedes(s, e, 0, 50); SELECT * FROM preds WHERE periods.precedes(s, e, 50, 100); SELECT * FROM preds WHERE periods.precedes(s, e, 100, 150); SELECT * FROM preds WHERE periods.precedes(s, e, 150, 200); SELECT * FROM preds WHERE periods.precedes(s, e, 200, 250); SELECT * FROM preds WHERE periods.precedes(s, e, 250, 300); SELECT * FROM preds WHERE periods.precedes(s, e, 125, 175); SELECT * FROM preds WHERE periods.precedes(s, e, 0, 300); SELECT * FROM preds WHERE periods.succeeds(s, e, 0, 50); SELECT * FROM preds WHERE periods.succeeds(s, e, 50, 100); SELECT * FROM preds WHERE periods.succeeds(s, e, 100, 150); SELECT * FROM preds WHERE periods.succeeds(s, e, 150, 200); SELECT * FROM preds WHERE periods.succeeds(s, e, 200, 250); SELECT * FROM preds WHERE periods.succeeds(s, e, 250, 300); SELECT * FROM preds WHERE periods.succeeds(s, e, 125, 175); SELECT * FROM preds WHERE periods.succeeds(s, e, 0, 300); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 0, 50); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 50, 100); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 100, 150); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 150, 200); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 200, 250); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 250, 300); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 125, 175); SELECT * FROM preds WHERE periods.immediately_precedes(s, e, 0, 300); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 0, 50); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 50, 100); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 100, 150); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 150, 200); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 200, 250); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 250, 300); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 125, 175); SELECT * FROM preds WHERE periods.immediately_succeeds(s, e, 0, 300); DROP TABLE preds; periods-1.2.2/sql/rename_following.sql000066400000000000000000000071201432551570100200330ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* * If anything we store as "name" is renamed, we need to update our catalogs or * throw an error. */ /* periods */ CREATE TABLE rename_test(col1 text, col2 bigint, col3 time, s integer, e integer); SELECT periods.add_period('rename_test', 'p', 's', 'e'); TABLE periods.periods; ALTER TABLE rename_test RENAME s TO start; ALTER TABLE rename_test RENAME e TO "end"; TABLE periods.periods; ALTER TABLE rename_test RENAME start TO "s < e"; TABLE periods.periods; ALTER TABLE rename_test RENAME "end" TO "embedded "" symbols"; TABLE periods.periods; ALTER TABLE rename_test RENAME CONSTRAINT rename_test_p_check TO start_before_end; TABLE periods.periods; /* system_time_periods */ SELECT periods.add_system_time_period('rename_test', excluded_column_names => ARRAY['col3']); TABLE periods.system_time_periods; ALTER TABLE rename_test RENAME col3 TO "COLUMN3"; ALTER TABLE rename_test RENAME CONSTRAINT rename_test_system_time_end_infinity_check TO inf_check; ALTER TRIGGER rename_test_system_time_generated_always ON rename_test RENAME TO generated_always; ALTER TRIGGER rename_test_system_time_write_history ON rename_test RENAME TO write_history; ALTER TRIGGER rename_test_truncate ON rename_test RENAME TO trunc; TABLE periods.system_time_periods; /* for_portion_views */ ALTER TABLE rename_test ADD COLUMN id integer PRIMARY KEY; SELECT periods.add_for_portion_view('rename_test', 'p'); TABLE periods.for_portion_views; ALTER TRIGGER for_portion_of_p ON rename_test__for_portion_of_p RENAME TO portion_trigger; TABLE periods.for_portion_views; SELECT periods.drop_for_portion_view('rename_test', 'p'); ALTER TABLE rename_test DROP COLUMN id; /* unique_keys */ SELECT periods.add_unique_key('rename_test', ARRAY['col2', 'col1', 'col3'], 'p'); TABLE periods.unique_keys; ALTER TABLE rename_test RENAME COLUMN col1 TO "COLUMN1"; ALTER TABLE rename_test RENAME CONSTRAINT "rename_test_col2_col1_col3_s < e_embedded "" symbols_key" TO unconst; ALTER TABLE rename_test RENAME CONSTRAINT rename_test_col2_col1_col3_int4range_excl TO exconst; TABLE periods.unique_keys; /* foreign_keys */ CREATE TABLE rename_test_ref (LIKE rename_test); SELECT periods.add_period('rename_test_ref', 'q', 's < e', 'embedded " symbols'); SELECT periods.add_foreign_key('rename_test_ref', ARRAY['col2', 'COLUMN1', 'col3'], 'q', 'rename_test_col2_col1_col3_p'); TABLE periods.foreign_keys; ALTER TABLE rename_test_ref RENAME COLUMN "COLUMN1" TO col1; -- fails ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_insert" ON rename_test_ref RENAME TO fk_insert; ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_fk_update" ON rename_test_ref RENAME TO fk_update; ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_update" ON rename_test RENAME TO uk_update; ALTER TRIGGER "rename_test_ref_col2_COLUMN1_col3_q_uk_delete" ON rename_test RENAME TO uk_delete; TABLE periods.foreign_keys; DROP TABLE rename_test_ref; /* system_versioning */ SELECT periods.add_system_versioning('rename_test'); ALTER FUNCTION rename_test__as_of(timestamp with time zone) RENAME TO bumble_bee; ALTER FUNCTION rename_test__between(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ALTER FUNCTION rename_test__between_symmetric(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; ALTER FUNCTION rename_test__from_to(timestamp with time zone, timestamp with time zone) RENAME TO bumble_bee; SELECT periods.drop_system_versioning('rename_test', purge => true); DROP TABLE rename_test; periods-1.2.2/sql/system_time_periods.sql000066400000000000000000000107301432551570100205740ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* SYSTEM_TIME with date */ BEGIN; SELECT transaction_timestamp()::date AS xd, transaction_timestamp()::timestamp AS xts, transaction_timestamp() AS xtstz \gset CREATE TABLE sysver_date (val text, start_date date, end_date date); SELECT periods.add_system_time_period('sysver_date', 'start_date', 'end_date'); TABLE periods.periods; INSERT INTO sysver_date DEFAULT VALUES; SELECT val, start_date = :'xd' AS start_date_eq, end_date FROM sysver_date; DROP TABLE sysver_date; /* SYSTEM_TIME with timestamp without time zone */ CREATE TABLE sysver_ts (val text, start_ts timestamp without time zone, end_ts timestamp without time zone); SELECT periods.add_system_time_period('sysver_ts', 'start_ts', 'end_ts'); TABLE periods.periods; INSERT INTO sysver_ts DEFAULT VALUES; SELECT val, start_ts = :'xts' AS start_ts_eq, end_ts FROM sysver_ts; DROP TABLE sysver_ts; /* SYSTEM_TIME with timestamp with time zone */ CREATE TABLE sysver_tstz (val text, start_tstz timestamp with time zone, end_tstz timestamp with time zone); SELECT periods.add_system_time_period('sysver_tstz', 'start_tstz', 'end_tstz'); TABLE periods.periods; INSERT INTO sysver_tstz DEFAULT VALUES; SELECT val, start_tstz = :'xtstz' AS start_tstz_eq, end_tstz FROM sysver_tstz; DROP TABLE sysver_tstz; COMMIT; /* Basic SYSTEM_TIME periods with CASCADE/purge */ CREATE TABLE sysver (val text); SELECT periods.add_system_time_period('sysver', 'startname'); SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); SELECT periods.add_system_time_period('sysver', end_column_name => 'endname'); SELECT periods.drop_period('sysver', 'system_time', drop_behavior => 'CASCADE', purge => true); SELECT periods.add_system_time_period('sysver', 'startname', 'endname'); TABLE periods.periods; TABLE periods.system_time_periods; SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); SELECT periods.add_system_time_period('sysver', 'endname', 'startname', bounds_check_constraint => 'b', infinity_check_constraint => 'i', generated_always_trigger => 'g', write_history_trigger => 'w', truncate_trigger => 't'); TABLE periods.periods; TABLE periods.system_time_periods; SELECT periods.drop_system_time_period('sysver', drop_behavior => 'CASCADE', purge => true); SELECT periods.add_system_time_period('sysver'); DROP TABLE sysver; TABLE periods.periods; TABLE periods.system_time_periods; /* Forbid UNIQUE keys on system_time columns */ CREATE TABLE no_unique (col1 timestamp with time zone, s bigint, e bigint); SELECT periods.add_period('no_unique', 'p', 's', 'e'); SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'p'); -- passes SELECT periods.add_system_time_period('no_unique'); SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- fails SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- fails SELECT periods.add_unique_key('no_unique', ARRAY['col1'], 'system_time'); -- fails SELECT periods.drop_system_time_period('no_unique'); SELECT periods.add_unique_key('no_unique', ARRAY['system_time_start'], 'p'); -- passes SELECT periods.add_unique_key('no_unique', ARRAY['system_time_end'], 'p'); -- passes SELECT periods.add_system_time_period('no_unique'); -- fails SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_start_p'); SELECT periods.drop_unique_key('no_unique', 'no_unique_system_time_end_p'); /* Forbid foreign keys on system_time columns */ CREATE TABLE no_unique_ref (LIKE no_unique); SELECT periods.add_period('no_unique_ref', 'q', 's', 'e'); SELECT periods.add_system_time_period('no_unique_ref'); SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- fails SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- fails SELECT periods.add_foreign_key('no_unique_ref', ARRAY['col1'], 'system_time', 'no_unique_col1_p'); -- fails SELECT periods.drop_system_time_period('no_unique_ref'); SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_start'], 'q', 'no_unique_col1_p'); -- passes SELECT periods.add_foreign_key('no_unique_ref', ARRAY['system_time_end'], 'q', 'no_unique_col1_p'); -- passes SELECT periods.add_system_time_period('no_unique_ref'); -- fails DROP TABLE no_unique, no_unique_ref; periods-1.2.2/sql/system_versioning.sql000066400000000000000000000076761432551570100203130ustar00rootroot00000000000000/* * An alternative file for pre-v12 is necessary because LEAST() and GREATEST() * were not constant folded. It was actually while writing this extension that * the lack of optimization was noticed, and subsequently fixed. * * https://www.postgresql.org/message-id/flat/c6e8504c-4c43-35fa-6c8f-3c0b80a912cc%402ndquadrant.com */ SELECT setting::integer < 120000 AS pre_12 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; /* Basic SYSTEM VERSIONING */ CREATE TABLE sysver (val text, flap boolean); SELECT periods.add_system_time_period('sysver', excluded_column_names => ARRAY['flap']); TABLE periods.system_time_periods; TABLE periods.system_versioning; SELECT periods.add_system_versioning('sysver', history_table_name => 'custom_history_name', view_name => 'custom_view_name', function_as_of_name => 'custom_as_of', function_between_name => 'custom_between', function_between_symmetric_name => 'custom_between_symmetric', function_from_to_name => 'custom_from_to'); TABLE periods.system_versioning; SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE'); DROP TABLE custom_history_name; SELECT periods.add_system_versioning('sysver'); TABLE periods.system_versioning; INSERT INTO sysver (val, flap) VALUES ('hello', false); SELECT val FROM sysver; SELECT val FROM sysver_history ORDER BY system_time_start; SELECT transaction_timestamp() AS ts1 \gset UPDATE sysver SET val = 'world'; SELECT val FROM sysver; SELECT val FROM sysver_history ORDER BY system_time_start; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; UPDATE sysver SET flap = not flap; SELECT val FROM sysver; SELECT val FROM sysver_history ORDER BY system_time_start; SELECT transaction_timestamp() AS ts2 \gset DELETE FROM sysver; SELECT val FROM sysver; SELECT val FROM sysver_history ORDER BY system_time_start; /* temporal queries */ SELECT val FROM sysver__as_of(:'ts1') ORDER BY system_time_start; SELECT val FROM sysver__as_of(:'ts2') ORDER BY system_time_start; SELECT val FROM sysver__from_to(:'ts1', :'ts2') ORDER BY system_time_start; SELECT val FROM sysver__from_to(:'ts2', :'ts1') ORDER BY system_time_start; SELECT val FROM sysver__between(:'ts1', :'ts2') ORDER BY system_time_start; SELECT val FROM sysver__between(:'ts2', :'ts1') ORDER BY system_time_start; SELECT val FROM sysver__between_symmetric(:'ts1', :'ts2') ORDER BY system_time_start; SELECT val FROM sysver__between_symmetric(:'ts2', :'ts1') ORDER BY system_time_start; /* Ensure functions are inlined */ SET TimeZone = 'UTC'; SET DateStyle = 'ISO'; EXPLAIN (COSTS OFF) SELECT * FROM sysver__as_of('2000-01-01'); EXPLAIN (COSTS OFF) SELECT * FROM sysver__from_to('1000-01-01', '3000-01-01'); EXPLAIN (COSTS OFF) SELECT * FROM sysver__between('1000-01-01', '3000-01-01'); EXPLAIN (COSTS OFF) SELECT * FROM sysver__between_symmetric('3000-01-01', '1000-01-01'); /* TRUNCATE should delete the history, too */ SELECT val FROM sysver_with_history; TRUNCATE sysver; SELECT val FROM sysver_with_history; --empty /* Try modifying several times in a transaction */ BEGIN; INSERT INTO sysver (val) VALUES ('hello'); INSERT INTO sysver (val) VALUES ('world'); ROLLBACK; SELECT val FROM sysver_with_history; --empty BEGIN; INSERT INTO sysver (val) VALUES ('hello'); UPDATE sysver SET val = 'world'; UPDATE sysver SET val = 'world2'; UPDATE sysver SET val = 'world3'; DELETE FROM sysver; COMMIT; SELECT val FROM sysver_with_history; --empty -- We can't drop the the table without first dropping SYSTEM VERSIONING because -- Postgres will complain about dependant objects (our view functions) before -- we get a chance to clean them up. DROP TABLE sysver; SELECT periods.drop_system_versioning('sysver', drop_behavior => 'CASCADE', purge => true); TABLE periods.system_versioning; DROP TABLE sysver; TABLE periods.periods; TABLE periods.system_time_periods; periods-1.2.2/sql/uninstall.sql000066400000000000000000000000751432551570100165170ustar00rootroot00000000000000DROP EXTENSION periods; DROP ROLE periods_unprivileged_user; periods-1.2.2/sql/unique_foreign.sql000066400000000000000000000037321432551570100175300ustar00rootroot00000000000000SELECT setting::integer < 90600 AS pre_96 FROM pg_settings WHERE name = 'server_version_num'; /* Run tests as unprivileged user */ SET ROLE TO periods_unprivileged_user; -- Unique keys are already pretty much guaranteed by the underlying features of -- PostgreSQL, but test them anyway. CREATE TABLE uk (id integer, s integer, e integer, CONSTRAINT uk_pkey PRIMARY KEY (id, s, e)); SELECT periods.add_period('uk', 'p', 's', 'e'); SELECT periods.add_unique_key('uk', ARRAY['id'], 'p', key_name => 'uk_id_p', unique_constraint => 'uk_pkey'); TABLE periods.unique_keys; INSERT INTO uk (id, s, e) VALUES (100, 1, 3), (100, 3, 4), (100, 4, 10); -- success INSERT INTO uk (id, s, e) VALUES (200, 1, 3), (200, 3, 4), (200, 5, 10); -- success INSERT INTO uk (id, s, e) VALUES (300, 1, 3), (300, 3, 5), (300, 4, 10); -- fail CREATE TABLE fk (id integer, uk_id integer, s integer, e integer, PRIMARY KEY (id)); SELECT periods.add_period('fk', 'q', 's', 'e'); SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q', fk_insert_trigger => 'fki', fk_update_trigger => 'fku', uk_update_trigger => 'uku', uk_delete_trigger => 'ukd'); TABLE periods.foreign_keys; SELECT periods.drop_foreign_key('fk', 'fk_uk_id_q'); SELECT periods.add_foreign_key('fk', ARRAY['uk_id'], 'q', 'uk_id_p', key_name => 'fk_uk_id_q'); TABLE periods.foreign_keys; -- INSERT INSERT INTO fk VALUES (0, 100, 0, 1); -- fail INSERT INTO fk VALUES (0, 100, 0, 10); -- fail INSERT INTO fk VALUES (0, 100, 1, 11); -- fail INSERT INTO fk VALUES (1, 100, 1, 3); -- success INSERT INTO fk VALUES (2, 100, 1, 10); -- success -- UPDATE UPDATE fk SET e = 20 WHERE id = 1; -- fail UPDATE fk SET e = 6 WHERE id = 1; -- success UPDATE uk SET s = 2 WHERE (id, s, e) = (100, 1, 3); -- fail UPDATE uk SET s = 0 WHERE (id, s, e) = (100, 1, 3); -- success -- DELETE DELETE FROM uk WHERE (id, s, e) = (100, 3, 4); -- fail DELETE FROM uk WHERE (id, s, e) = (200, 3, 5); -- success DROP TABLE fk; DROP TABLE uk;