@akal/pg-conformance 0.0.0-stage → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/corpus/data.json +122 -0
- package/corpus/equivalences.json +44 -0
- package/corpus/hard-cases.json +452 -0
- package/corpus/migrations.json +515 -0
- package/corpus/objects.json +202 -0
- package/corpus/ordering.json +201 -0
- package/fingerprint.sql +245 -0
- package/index.d.ts +147 -0
- package/index.js +87 -0
- package/package.json +46 -4
- package/src/Conformance.php +97 -0
- package/state.sql +234 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "obj_create_table_pk",
|
|
4
|
+
"source_file": "objects",
|
|
5
|
+
"category": "create_table",
|
|
6
|
+
"table": "",
|
|
7
|
+
"before_sql": "",
|
|
8
|
+
"alter_sql": "CREATE TABLE o_books (id bigint PRIMARY KEY, title text NOT NULL);",
|
|
9
|
+
"description": "create a table with a primary key from nothing",
|
|
10
|
+
"skip_reason": null
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "obj_create_serial",
|
|
14
|
+
"source_file": "objects",
|
|
15
|
+
"category": "create_table",
|
|
16
|
+
"table": "",
|
|
17
|
+
"before_sql": "",
|
|
18
|
+
"alter_sql": "CREATE TABLE o_serial (id serial PRIMARY KEY, name text);",
|
|
19
|
+
"description": "serial column must round-trip with its owned sequence",
|
|
20
|
+
"skip_reason": null
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "obj_create_identity",
|
|
24
|
+
"source_file": "objects",
|
|
25
|
+
"category": "create_table",
|
|
26
|
+
"table": "",
|
|
27
|
+
"before_sql": "",
|
|
28
|
+
"alter_sql": "CREATE TABLE o_ident (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, n text);",
|
|
29
|
+
"description": "identity column from nothing",
|
|
30
|
+
"skip_reason": null
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "obj_create_identity_increment",
|
|
34
|
+
"source_file": "objects",
|
|
35
|
+
"category": "create_table",
|
|
36
|
+
"table": "",
|
|
37
|
+
"before_sql": "",
|
|
38
|
+
"alter_sql": "CREATE TABLE o_ident2 (id bigint GENERATED ALWAYS AS IDENTITY (INCREMENT 10 START 100), n text);",
|
|
39
|
+
"description": "identity carrying non-default sequence options",
|
|
40
|
+
"skip_reason": null
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "obj_create_enum_column",
|
|
44
|
+
"source_file": "objects",
|
|
45
|
+
"category": "create_type",
|
|
46
|
+
"table": "",
|
|
47
|
+
"before_sql": "",
|
|
48
|
+
"alter_sql": "CREATE TYPE o_mood AS ENUM ('ok','bad'); CREATE TABLE o_t (id int PRIMARY KEY, m o_mood);",
|
|
49
|
+
"description": "enum type plus a column using it",
|
|
50
|
+
"skip_reason": null
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "obj_create_composite",
|
|
54
|
+
"source_file": "objects",
|
|
55
|
+
"category": "create_type",
|
|
56
|
+
"table": "",
|
|
57
|
+
"before_sql": "",
|
|
58
|
+
"alter_sql": "CREATE TYPE o_addr AS (street text, city text);",
|
|
59
|
+
"description": "composite type",
|
|
60
|
+
"skip_reason": null
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "obj_create_domain",
|
|
64
|
+
"source_file": "objects",
|
|
65
|
+
"category": "create_type",
|
|
66
|
+
"table": "",
|
|
67
|
+
"before_sql": "",
|
|
68
|
+
"alter_sql": "CREATE DOMAIN o_pos AS integer CHECK (VALUE > 0);",
|
|
69
|
+
"description": "domain with a constraint",
|
|
70
|
+
"skip_reason": null
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "obj_partitioned_range",
|
|
74
|
+
"source_file": "objects",
|
|
75
|
+
"category": "partition",
|
|
76
|
+
"table": "",
|
|
77
|
+
"before_sql": "",
|
|
78
|
+
"alter_sql": "CREATE TABLE o_sales (id bigint, d date NOT NULL, PRIMARY KEY (d,id)) PARTITION BY RANGE (d); CREATE TABLE o_sales_26 PARTITION OF o_sales FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');",
|
|
79
|
+
"description": "partitioned parent plus an attached partition",
|
|
80
|
+
"skip_reason": null
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "obj_partitioned_list",
|
|
84
|
+
"source_file": "objects",
|
|
85
|
+
"category": "partition",
|
|
86
|
+
"table": "",
|
|
87
|
+
"before_sql": "",
|
|
88
|
+
"alter_sql": "CREATE TABLE o_reg (id bigint, r text NOT NULL, PRIMARY KEY (r,id)) PARTITION BY LIST (r); CREATE TABLE o_reg_emea PARTITION OF o_reg FOR VALUES IN ('emea');",
|
|
89
|
+
"description": "list partitioning",
|
|
90
|
+
"skip_reason": null
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "obj_partition_index",
|
|
94
|
+
"source_file": "objects",
|
|
95
|
+
"category": "partition",
|
|
96
|
+
"table": "",
|
|
97
|
+
"before_sql": "",
|
|
98
|
+
"alter_sql": "CREATE TABLE o_pidx (id bigint, d date NOT NULL, PRIMARY KEY (d,id)) PARTITION BY RANGE (d); CREATE TABLE o_pidx_26 PARTITION OF o_pidx FOR VALUES FROM ('2026-01-01') TO ('2027-01-01'); CREATE INDEX o_pidx_d ON o_pidx (d);",
|
|
99
|
+
"description": "index on a partitioned parent must reach its partitions",
|
|
100
|
+
"skip_reason": null
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "obj_create_view",
|
|
104
|
+
"source_file": "objects",
|
|
105
|
+
"category": "create_view",
|
|
106
|
+
"table": "",
|
|
107
|
+
"before_sql": "CREATE TABLE o_vt (id int PRIMARY KEY, name text);",
|
|
108
|
+
"alter_sql": "CREATE VIEW o_v AS SELECT id, name FROM o_vt;",
|
|
109
|
+
"description": "view over an existing table",
|
|
110
|
+
"skip_reason": null
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "obj_create_view_expr",
|
|
114
|
+
"source_file": "objects",
|
|
115
|
+
"category": "create_view",
|
|
116
|
+
"table": "",
|
|
117
|
+
"before_sql": "CREATE TABLE o_vt2 (id int PRIMARY KEY, a int, b int);",
|
|
118
|
+
"alter_sql": "CREATE VIEW o_v2 AS SELECT id, a + b AS total FROM o_vt2 WHERE a > 0;",
|
|
119
|
+
"description": "view with an expression and predicate",
|
|
120
|
+
"skip_reason": null
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "obj_create_function",
|
|
124
|
+
"source_file": "objects",
|
|
125
|
+
"category": "create_routine",
|
|
126
|
+
"table": "",
|
|
127
|
+
"before_sql": "",
|
|
128
|
+
"alter_sql": "CREATE FUNCTION o_add(a int, b int) RETURNS int LANGUAGE sql IMMUTABLE AS $$ SELECT a + b $$;",
|
|
129
|
+
"description": "plain function",
|
|
130
|
+
"skip_reason": null
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": "obj_create_trigger",
|
|
134
|
+
"source_file": "objects",
|
|
135
|
+
"category": "create_trigger",
|
|
136
|
+
"table": "",
|
|
137
|
+
"before_sql": "CREATE TABLE o_tt (id int PRIMARY KEY, v text);",
|
|
138
|
+
"alter_sql": "CREATE FUNCTION o_touch() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; CREATE TRIGGER o_tt_bu BEFORE UPDATE ON o_tt FOR EACH ROW EXECUTE FUNCTION o_touch();",
|
|
139
|
+
"description": "trigger and the function it calls, created together",
|
|
140
|
+
"skip_reason": null
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"id": "obj_trigger_on_partitioned",
|
|
144
|
+
"source_file": "objects",
|
|
145
|
+
"category": "create_trigger",
|
|
146
|
+
"table": "",
|
|
147
|
+
"before_sql": "",
|
|
148
|
+
"alter_sql": "CREATE TABLE o_pt (id bigint, d date NOT NULL, PRIMARY KEY (d,id)) PARTITION BY RANGE (d); CREATE TABLE o_pt_26 PARTITION OF o_pt FOR VALUES FROM ('2026-01-01') TO ('2027-01-01'); CREATE FUNCTION o_pt_f() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; CREATE TRIGGER o_pt_ai AFTER INSERT ON o_pt FOR EACH ROW EXECUTE FUNCTION o_pt_f();",
|
|
149
|
+
"description": "trigger on a partitioned parent propagates; the clone is not re-declared",
|
|
150
|
+
"skip_reason": null
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "obj_create_policy",
|
|
154
|
+
"source_file": "objects",
|
|
155
|
+
"category": "create_policy",
|
|
156
|
+
"table": "",
|
|
157
|
+
"before_sql": "CREATE TABLE o_sec (id int PRIMARY KEY, owner text);",
|
|
158
|
+
"alter_sql": "ALTER TABLE o_sec ENABLE ROW LEVEL SECURITY; CREATE POLICY o_sec_read ON o_sec FOR SELECT USING (true);",
|
|
159
|
+
"description": "row level security plus a policy",
|
|
160
|
+
"skip_reason": null
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"id": "obj_create_sequence",
|
|
164
|
+
"source_file": "objects",
|
|
165
|
+
"category": "create_sequence",
|
|
166
|
+
"table": "",
|
|
167
|
+
"before_sql": "",
|
|
168
|
+
"alter_sql": "CREATE SEQUENCE o_seq START 100 INCREMENT 5 MINVALUE 100 MAXVALUE 100000 CYCLE;",
|
|
169
|
+
"description": "standalone sequence with non-default options",
|
|
170
|
+
"skip_reason": null
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "obj_create_matview",
|
|
174
|
+
"source_file": "objects",
|
|
175
|
+
"category": "create_view",
|
|
176
|
+
"table": "",
|
|
177
|
+
"before_sql": "CREATE TABLE o_mt (id int PRIMARY KEY, n int);",
|
|
178
|
+
"alter_sql": "CREATE MATERIALIZED VIEW o_mv AS SELECT id, n FROM o_mt WHERE n > 0;",
|
|
179
|
+
"description": "materialized view",
|
|
180
|
+
"skip_reason": null
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"id": "obj_fk_between_tables",
|
|
184
|
+
"source_file": "objects",
|
|
185
|
+
"category": "create_table",
|
|
186
|
+
"table": "",
|
|
187
|
+
"before_sql": "",
|
|
188
|
+
"alter_sql": "CREATE TABLE o_parent (id bigint PRIMARY KEY); CREATE TABLE o_child (id bigint PRIMARY KEY, p bigint NOT NULL REFERENCES o_parent(id) ON DELETE CASCADE);",
|
|
189
|
+
"description": "two tables plus the foreign key between them, ordered correctly",
|
|
190
|
+
"skip_reason": null
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"id": "obj_generated_column",
|
|
194
|
+
"source_file": "objects",
|
|
195
|
+
"category": "create_table",
|
|
196
|
+
"table": "",
|
|
197
|
+
"before_sql": "",
|
|
198
|
+
"alter_sql": "CREATE TABLE o_gen (id int PRIMARY KEY, a int, b int GENERATED ALWAYS AS (a * 2) STORED);",
|
|
199
|
+
"description": "generated stored column",
|
|
200
|
+
"skip_reason": null
|
|
201
|
+
}
|
|
202
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "a_table_is_created_before_the_table_whose_foreign_key_points_at_it",
|
|
4
|
+
"description": "a table is created before the table whose foreign key points at it",
|
|
5
|
+
"statements": [
|
|
6
|
+
"CREATE TABLE user_profile (id int REFERENCES \"user\"(id), bio text);",
|
|
7
|
+
"CREATE TABLE \"user\" (id int PRIMARY KEY);"
|
|
8
|
+
],
|
|
9
|
+
"requires": [
|
|
10
|
+
[
|
|
11
|
+
"CREATE TABLE \"user\"",
|
|
12
|
+
"CREATE TABLE user_profile"
|
|
13
|
+
]
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "quoted_names_that_collide_with_keywords_are_still_ordered",
|
|
18
|
+
"description": "quoted names that collide with keywords are still ordered",
|
|
19
|
+
"statements": [
|
|
20
|
+
"CREATE TABLE \"order\" (id int REFERENCES \"table\"(id));",
|
|
21
|
+
"CREATE TABLE \"table\" (id int PRIMARY KEY);"
|
|
22
|
+
],
|
|
23
|
+
"requires": [
|
|
24
|
+
[
|
|
25
|
+
"CREATE TABLE \"table\"",
|
|
26
|
+
"CREATE TABLE \"order\""
|
|
27
|
+
]
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "a_domain_is_created_before_the_column_that_uses_it",
|
|
32
|
+
"description": "a domain is created before the column that uses it",
|
|
33
|
+
"statements": [
|
|
34
|
+
"CREATE TABLE d_t (id int, v money_amt);",
|
|
35
|
+
"CREATE DOMAIN money_amt AS numeric(10,2) CHECK (VALUE >= 0);"
|
|
36
|
+
],
|
|
37
|
+
"requires": [
|
|
38
|
+
[
|
|
39
|
+
"CREATE DOMAIN money_amt",
|
|
40
|
+
"CREATE TABLE d_t"
|
|
41
|
+
]
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "a_function_is_created_before_the_check_constraint_that_calls_it",
|
|
46
|
+
"description": "a function is created before the CHECK constraint that calls it",
|
|
47
|
+
"statements": [
|
|
48
|
+
"CREATE TABLE c_t (id int, code text, CONSTRAINT c_ck CHECK (is_valid(code)));",
|
|
49
|
+
"CREATE FUNCTION is_valid(t text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT length(t) > 2 $$;"
|
|
50
|
+
],
|
|
51
|
+
"requires": [
|
|
52
|
+
[
|
|
53
|
+
"CREATE FUNCTION is_valid",
|
|
54
|
+
"CREATE TABLE c_t"
|
|
55
|
+
]
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "a_function_is_created_before_the_generated_column_that_calls_it",
|
|
60
|
+
"description": "a function is created before the generated column that calls it",
|
|
61
|
+
"statements": [
|
|
62
|
+
"CREATE TABLE g_t (id int, raw text, norm text GENERATED ALWAYS AS (norm_it(raw)) STORED);",
|
|
63
|
+
"CREATE FUNCTION norm_it(t text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(t) $$;"
|
|
64
|
+
],
|
|
65
|
+
"requires": [
|
|
66
|
+
[
|
|
67
|
+
"CREATE FUNCTION norm_it",
|
|
68
|
+
"CREATE TABLE g_t"
|
|
69
|
+
]
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "a_composite_type_is_created_before_the_function_that_returns_it",
|
|
74
|
+
"description": "a composite type is created before the function that returns it",
|
|
75
|
+
"statements": [
|
|
76
|
+
"CREATE FUNCTION mk_addr() RETURNS addr_t LANGUAGE sql AS $$ SELECT ROW('a','b')::addr_t $$;",
|
|
77
|
+
"CREATE TYPE addr_t AS (street text, city text);"
|
|
78
|
+
],
|
|
79
|
+
"requires": [
|
|
80
|
+
[
|
|
81
|
+
"CREATE TYPE addr_t",
|
|
82
|
+
"CREATE FUNCTION mk_addr"
|
|
83
|
+
]
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "a_view_that_selects_from_a_function_waits_for_the_function_and_its_table",
|
|
88
|
+
"description": "a view that selects from a function waits for the function and its table",
|
|
89
|
+
"statements": [
|
|
90
|
+
"CREATE VIEW v_top AS SELECT * FROM top_ids();",
|
|
91
|
+
"CREATE FUNCTION top_ids() RETURNS TABLE(id int) LANGUAGE sql AS $$ SELECT id FROM base_t $$;",
|
|
92
|
+
"CREATE TABLE base_t (id int);"
|
|
93
|
+
],
|
|
94
|
+
"requires": [
|
|
95
|
+
[
|
|
96
|
+
"CREATE TABLE base_t",
|
|
97
|
+
"CREATE FUNCTION top_ids"
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
"CREATE FUNCTION top_ids",
|
|
101
|
+
"CREATE VIEW v_top"
|
|
102
|
+
]
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "a_chain_of_views_is_created_from_the_base_outwards",
|
|
107
|
+
"description": "a chain of views is created from the base outwards",
|
|
108
|
+
"statements": [
|
|
109
|
+
"CREATE VIEW v3 AS SELECT id FROM v2;",
|
|
110
|
+
"CREATE VIEW v1 AS SELECT id FROM vbase;",
|
|
111
|
+
"CREATE VIEW v2 AS SELECT id FROM v1;",
|
|
112
|
+
"CREATE TABLE vbase (id int);"
|
|
113
|
+
],
|
|
114
|
+
"requires": [
|
|
115
|
+
[
|
|
116
|
+
"CREATE TABLE vbase",
|
|
117
|
+
"CREATE VIEW v1"
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
"CREATE VIEW v1",
|
|
121
|
+
"CREATE VIEW v2"
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
"CREATE VIEW v2",
|
|
125
|
+
"CREATE VIEW v3"
|
|
126
|
+
]
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "a_partition_is_attached_before_the_index_on_its_parent",
|
|
131
|
+
"description": "a partition is attached before the index on its parent",
|
|
132
|
+
"statements": [
|
|
133
|
+
"CREATE INDEX p_i ON ONLY p_tbl (d);",
|
|
134
|
+
"CREATE TABLE p_26 PARTITION OF p_tbl FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');",
|
|
135
|
+
"CREATE TABLE p_tbl (id int, d date) PARTITION BY RANGE (d);"
|
|
136
|
+
],
|
|
137
|
+
"requires": [
|
|
138
|
+
[
|
|
139
|
+
"CREATE TABLE p_tbl",
|
|
140
|
+
"CREATE TABLE p_26"
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
"CREATE TABLE p_26",
|
|
144
|
+
"CREATE INDEX p_i"
|
|
145
|
+
]
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "an_enum_is_created_before_the_column_defaulting_to_one_of_its_labels",
|
|
150
|
+
"description": "an enum is created before the column defaulting to one of its labels",
|
|
151
|
+
"statements": [
|
|
152
|
+
"CREATE TABLE e_t (id int, m mood_x NOT NULL DEFAULT 'ok');",
|
|
153
|
+
"CREATE TYPE mood_x AS ENUM ('sad','ok');"
|
|
154
|
+
],
|
|
155
|
+
"requires": [
|
|
156
|
+
[
|
|
157
|
+
"CREATE TYPE mood_x",
|
|
158
|
+
"CREATE TABLE e_t"
|
|
159
|
+
]
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"id": "a_three_table_foreign_key_chain_is_created_root_first",
|
|
164
|
+
"description": "a three-table foreign key chain is created root first",
|
|
165
|
+
"statements": [
|
|
166
|
+
"CREATE TABLE l3 (id int REFERENCES l2(id));",
|
|
167
|
+
"CREATE TABLE l1 (id int PRIMARY KEY);",
|
|
168
|
+
"CREATE TABLE l2 (id int PRIMARY KEY REFERENCES l1(id));"
|
|
169
|
+
],
|
|
170
|
+
"requires": [
|
|
171
|
+
[
|
|
172
|
+
"CREATE TABLE l1",
|
|
173
|
+
"CREATE TABLE l2"
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
"CREATE TABLE l2",
|
|
177
|
+
"CREATE TABLE l3"
|
|
178
|
+
]
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "a_trigger_waits_for_its_function_and_the_table_it_fires_on",
|
|
183
|
+
"description": "a trigger waits for its function and the table it fires on",
|
|
184
|
+
"statements": [
|
|
185
|
+
"CREATE TRIGGER t_aud AFTER INSERT ON src_t FOR EACH ROW EXECUTE FUNCTION log_it();",
|
|
186
|
+
"CREATE FUNCTION log_it() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO audit_t VALUES (1); RETURN NEW; END $$;",
|
|
187
|
+
"CREATE TABLE src_t (id int);",
|
|
188
|
+
"CREATE TABLE audit_t (id int);"
|
|
189
|
+
],
|
|
190
|
+
"requires": [
|
|
191
|
+
[
|
|
192
|
+
"CREATE TABLE src_t",
|
|
193
|
+
"CREATE TRIGGER t_aud"
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
"CREATE FUNCTION log_it",
|
|
197
|
+
"CREATE TRIGGER t_aud"
|
|
198
|
+
]
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
]
|
package/fingerprint.sql
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
-- Structural fingerprint of a PostgreSQL schema.
|
|
2
|
+
--
|
|
3
|
+
-- One query that renders everything about a set of schemas which a migration
|
|
4
|
+
-- could get wrong, as sorted text. Two databases are structurally identical if
|
|
5
|
+
-- and only if this returns the same string for both.
|
|
6
|
+
--
|
|
7
|
+
-- Why the catalog and not information_schema
|
|
8
|
+
-- ------------------------------------------
|
|
9
|
+
-- information_schema is a standardised view over the catalog, and the standard
|
|
10
|
+
-- has no concept of most of what matters here. It cannot tell a partitioned
|
|
11
|
+
-- table from an ordinary one, has nowhere to put a partition bound, and does
|
|
12
|
+
-- not expose identity sequence options, generated-column storage, compression
|
|
13
|
+
-- or collation. Every one of those has hidden a real bug.
|
|
14
|
+
--
|
|
15
|
+
-- Why bodies, not names
|
|
16
|
+
-- --------------------
|
|
17
|
+
-- Objects that carry a body are compared by that body. Comparing names alone
|
|
18
|
+
-- reports a view whose predicate was inverted, a function whose implementation
|
|
19
|
+
-- was replaced, and a trigger moved from AFTER INSERT to BEFORE UPDATE as
|
|
20
|
+
-- identical — three schemas differing in the ways most likely to matter.
|
|
21
|
+
--
|
|
22
|
+
-- Where PostgreSQL can render an object canonically (pg_get_viewdef,
|
|
23
|
+
-- pg_get_constraintdef, pg_get_functiondef, pg_get_triggerdef, pg_get_expr)
|
|
24
|
+
-- that rendering is what gets compared, so both sides are produced by the same
|
|
25
|
+
-- server code and formatting can never manufacture a difference.
|
|
26
|
+
--
|
|
27
|
+
-- Definitions are flattened to one line. Entries are joined with newlines, so a
|
|
28
|
+
-- multi-line body would otherwise arrive as several unattributed entries —
|
|
29
|
+
-- losing the object name and making sort order depend on the body's layout.
|
|
30
|
+
--
|
|
31
|
+
-- Usage
|
|
32
|
+
-- -----
|
|
33
|
+
-- Replace the token __SCHEMAS__ with a quoted, comma-separated schema list,
|
|
34
|
+
-- e.g. 'public','app'. The language accessors do this for you and handle
|
|
35
|
+
-- quoting; do not interpolate user input by hand.
|
|
36
|
+
--
|
|
37
|
+
-- Requires PostgreSQL 14 or newer (pg_attribute.attcompression).
|
|
38
|
+
|
|
39
|
+
SELECT string_agg(line, E'\n' ORDER BY line) AS fingerprint FROM (
|
|
40
|
+
|
|
41
|
+
-- Relations. relkind distinguishes a partitioned table from an ordinary one;
|
|
42
|
+
-- relpartbound is the only place a partition's bound exists; relpersistence
|
|
43
|
+
-- carries UNLOGGED; reloptions carries fillfactor and friends.
|
|
44
|
+
SELECT format('rel %s.%s kind=%s persist=%s bound=%s opts=%s rls=%s force_rls=%s',
|
|
45
|
+
n.nspname, c.relname, c.relkind, c.relpersistence,
|
|
46
|
+
COALESCE(btrim(regexp_replace(pg_get_expr(c.relpartbound, c.oid), '\s+', ' ', 'g')), '-'),
|
|
47
|
+
COALESCE(array_to_string(c.reloptions, ','), '-'),
|
|
48
|
+
c.relrowsecurity, c.relforcerowsecurity) AS line
|
|
49
|
+
FROM pg_class c
|
|
50
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
51
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
52
|
+
AND c.relkind IN ('r','p','v','m','f','S')
|
|
53
|
+
|
|
54
|
+
UNION ALL
|
|
55
|
+
|
|
56
|
+
-- Columns, read from pg_attribute so identity, generated, storage,
|
|
57
|
+
-- compression and collation are visible at all.
|
|
58
|
+
SELECT format('col %s.%s.%s type=%s notnull=%s default=%s ident=%s gen=%s store=%s compress=%s coll=%s',
|
|
59
|
+
n.nspname, c.relname, a.attname,
|
|
60
|
+
format_type(a.atttypid, a.atttypmod),
|
|
61
|
+
a.attnotnull,
|
|
62
|
+
COALESCE(btrim(regexp_replace(pg_get_expr(d.adbin, d.adrelid), '\s+', ' ', 'g')), '-'),
|
|
63
|
+
a.attidentity, a.attgenerated, a.attstorage,
|
|
64
|
+
COALESCE(a.attcompression::text, '-'),
|
|
65
|
+
COALESCE(co.collname, '-'))
|
|
66
|
+
FROM pg_attribute a
|
|
67
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
68
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
69
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
70
|
+
LEFT JOIN pg_collation co ON co.oid = a.attcollation
|
|
71
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
72
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
73
|
+
AND c.relkind IN ('r','p','v','m','f')
|
|
74
|
+
|
|
75
|
+
UNION ALL
|
|
76
|
+
|
|
77
|
+
-- Sequence options. An identity column's sequence is part of the column: a
|
|
78
|
+
-- table rebuilt without INCREMENT and START silently restarts its counter.
|
|
79
|
+
SELECT format('seq %s.%s start=%s inc=%s min=%s max=%s cache=%s cycle=%s',
|
|
80
|
+
n.nspname, c.relname,
|
|
81
|
+
s.seqstart, s.seqincrement, s.seqmin, s.seqmax, s.seqcache, s.seqcycle)
|
|
82
|
+
FROM pg_sequence s
|
|
83
|
+
JOIN pg_class c ON c.oid = s.seqrelid
|
|
84
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
85
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
86
|
+
|
|
87
|
+
UNION ALL
|
|
88
|
+
|
|
89
|
+
-- Constraints, including whether they are validated and how they defer —
|
|
90
|
+
-- a NOT VALID check and a validated one are not the same constraint.
|
|
91
|
+
SELECT format('con %s.%s.%s %s valid=%s deferrable=%s deferred=%s',
|
|
92
|
+
n.nspname, c.relname, con.conname,
|
|
93
|
+
btrim(regexp_replace(pg_get_constraintdef(con.oid), '\s+', ' ', 'g')),
|
|
94
|
+
con.convalidated, con.condeferrable, con.condeferred)
|
|
95
|
+
FROM pg_constraint con
|
|
96
|
+
JOIN pg_class c ON c.oid = con.conrelid
|
|
97
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
98
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
99
|
+
|
|
100
|
+
UNION ALL
|
|
101
|
+
|
|
102
|
+
-- Indexes, per relation. An index created ON ONLY a partitioned parent never
|
|
103
|
+
-- reaches its partitions, and only a per-relation comparison shows that.
|
|
104
|
+
SELECT format('idx %s.%s %s', schemaname, indexname,
|
|
105
|
+
btrim(regexp_replace(indexdef, '\s+', ' ', 'g')))
|
|
106
|
+
FROM pg_indexes
|
|
107
|
+
WHERE schemaname IN (__SCHEMAS__)
|
|
108
|
+
|
|
109
|
+
UNION ALL
|
|
110
|
+
|
|
111
|
+
-- Views and materialised views, by body.
|
|
112
|
+
SELECT format('view %s.%s %s opts=%s',
|
|
113
|
+
n.nspname, c.relname,
|
|
114
|
+
btrim(regexp_replace(pg_get_viewdef(c.oid, true), '\s+', ' ', 'g')),
|
|
115
|
+
COALESCE(array_to_string(c.reloptions, ','), '-'))
|
|
116
|
+
FROM pg_class c
|
|
117
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
118
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
119
|
+
AND c.relkind IN ('v','m')
|
|
120
|
+
|
|
121
|
+
UNION ALL
|
|
122
|
+
|
|
123
|
+
-- Routines, by body. prokind is filtered because pg_get_functiondef raises
|
|
124
|
+
-- an error on aggregate and window functions.
|
|
125
|
+
SELECT format('fn %s.%s(%s) %s',
|
|
126
|
+
n.nspname, p.proname,
|
|
127
|
+
pg_get_function_identity_arguments(p.oid),
|
|
128
|
+
btrim(regexp_replace(pg_get_functiondef(p.oid), '\s+', ' ', 'g')))
|
|
129
|
+
FROM pg_proc p
|
|
130
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
131
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
132
|
+
AND p.prokind IN ('f','p')
|
|
133
|
+
|
|
134
|
+
UNION ALL
|
|
135
|
+
|
|
136
|
+
-- Triggers, by definition. tgparentid excludes the per-partition clones
|
|
137
|
+
-- PostgreSQL creates automatically, which are not independent objects.
|
|
138
|
+
SELECT format('trg %s.%s.%s %s',
|
|
139
|
+
n.nspname, c.relname, t.tgname,
|
|
140
|
+
btrim(regexp_replace(pg_get_triggerdef(t.oid), '\s+', ' ', 'g')))
|
|
141
|
+
FROM pg_trigger t
|
|
142
|
+
JOIN pg_class c ON c.oid = t.tgrelid
|
|
143
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
144
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
145
|
+
AND NOT t.tgisinternal
|
|
146
|
+
AND t.tgparentid = 0
|
|
147
|
+
|
|
148
|
+
UNION ALL
|
|
149
|
+
|
|
150
|
+
-- Policies. USING alone is not the policy: the command, the roles and the
|
|
151
|
+
-- WITH CHECK expression all decide who may do what.
|
|
152
|
+
SELECT format('pol %s.%s.%s cmd=%s permissive=%s roles=%s using=%s check=%s',
|
|
153
|
+
schemaname, tablename, policyname, cmd, permissive,
|
|
154
|
+
array_to_string(roles, ','),
|
|
155
|
+
COALESCE(btrim(regexp_replace(qual, '\s+', ' ', 'g')), '-'),
|
|
156
|
+
COALESCE(btrim(regexp_replace(with_check, '\s+', ' ', 'g')), '-'))
|
|
157
|
+
FROM pg_policies
|
|
158
|
+
WHERE schemaname IN (__SCHEMAS__)
|
|
159
|
+
|
|
160
|
+
UNION ALL
|
|
161
|
+
|
|
162
|
+
-- Enums, domains and composite types. Enum label order is significant:
|
|
163
|
+
-- it decides comparison and ORDER BY.
|
|
164
|
+
--
|
|
165
|
+
-- Every kind's content is carried inline, the way enum labels are, because no
|
|
166
|
+
-- other section can reach it. The col section reads relations (relkind
|
|
167
|
+
-- r/p/v/m/f) and so never sees a standalone composite's attributes, which are
|
|
168
|
+
-- on a 'c'; the con section joins conrelid, while a domain's CHECK is keyed by
|
|
169
|
+
-- contypid and has no conrelid at all. Until these were here, a composite of
|
|
170
|
+
-- (street text, city text) and one of (postcode int) fingerprinted the same,
|
|
171
|
+
-- as did domains with CHECK (VALUE > 0) and CHECK (VALUE < -999) — the same
|
|
172
|
+
-- class of silent agreement this file exists to prevent.
|
|
173
|
+
--
|
|
174
|
+
-- Only CHECK constraints are read. PostgreSQL 17 began recording a domain's
|
|
175
|
+
-- NOT NULL as a constraint row of its own, and notnull= already carries it;
|
|
176
|
+
-- taking every row would both state it twice and make one logical domain
|
|
177
|
+
-- fingerprint differently either side of that release.
|
|
178
|
+
SELECT format('typ %s.%s kind=%s labels=%s default=%s notnull=%s base=%s attrs=%s checks=%s',
|
|
179
|
+
n.nspname, t.typname, t.typtype,
|
|
180
|
+
COALESCE((SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
|
|
181
|
+
FROM pg_enum e WHERE e.enumtypid = t.oid), '-'),
|
|
182
|
+
COALESCE(btrim(regexp_replace(pg_get_expr(t.typdefaultbin, 0), '\s+', ' ', 'g')), '-'),
|
|
183
|
+
t.typnotnull,
|
|
184
|
+
COALESCE(CASE WHEN t.typtype = 'd'
|
|
185
|
+
THEN format_type(t.typbasetype, t.typtypmod) END, '-'),
|
|
186
|
+
COALESCE((SELECT string_agg(
|
|
187
|
+
format('%s %s', a.attname,
|
|
188
|
+
format_type(a.atttypid, a.atttypmod)),
|
|
189
|
+
',' ORDER BY a.attnum)
|
|
190
|
+
FROM pg_attribute a
|
|
191
|
+
WHERE a.attrelid = t.typrelid
|
|
192
|
+
AND a.attnum > 0 AND NOT a.attisdropped), '-'),
|
|
193
|
+
COALESCE((SELECT string_agg(
|
|
194
|
+
format('%s %s', con.conname,
|
|
195
|
+
btrim(regexp_replace(
|
|
196
|
+
pg_get_constraintdef(con.oid), '\s+', ' ', 'g'))),
|
|
197
|
+
',' ORDER BY con.conname)
|
|
198
|
+
FROM pg_constraint con
|
|
199
|
+
WHERE con.contypid = t.oid
|
|
200
|
+
AND con.contype = 'c'), '-'))
|
|
201
|
+
FROM pg_type t
|
|
202
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
203
|
+
WHERE n.nspname IN (__SCHEMAS__)
|
|
204
|
+
AND t.typtype IN ('e','d','c')
|
|
205
|
+
AND NOT EXISTS (
|
|
206
|
+
SELECT 1 FROM pg_class c
|
|
207
|
+
WHERE c.oid = t.typrelid AND c.relkind <> 'c'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
UNION ALL
|
|
211
|
+
|
|
212
|
+
-- Inheritance, which is not implied by anything above.
|
|
213
|
+
SELECT format('inh %s.%s <- %s.%s',
|
|
214
|
+
pn.nspname, parent.relname, cn.nspname, child.relname)
|
|
215
|
+
FROM pg_inherits i
|
|
216
|
+
JOIN pg_class child ON child.oid = i.inhrelid
|
|
217
|
+
JOIN pg_class parent ON parent.oid = i.inhparent
|
|
218
|
+
JOIN pg_namespace cn ON cn.oid = child.relnamespace
|
|
219
|
+
JOIN pg_namespace pn ON pn.oid = parent.relnamespace
|
|
220
|
+
WHERE cn.nspname IN (__SCHEMAS__)
|
|
221
|
+
|
|
222
|
+
UNION ALL
|
|
223
|
+
|
|
224
|
+
-- Comments, on anything in the schemas: tables and columns, and functions,
|
|
225
|
+
-- types, constraints, triggers, policies and the schemas themselves. Named
|
|
226
|
+
-- as PostgreSQL identifies the object, so a column by its name: keyed by
|
|
227
|
+
-- its position, the same comment on a column added last on one side read
|
|
228
|
+
-- as a difference. An extension's objects are its own.
|
|
229
|
+
SELECT format('cmt %s %s %s', i.type, i.identity,
|
|
230
|
+
btrim(regexp_replace(d.description, '\s+', ' ', 'g')))
|
|
231
|
+
FROM pg_description d
|
|
232
|
+
CROSS JOIN LATERAL pg_identify_object(d.classoid, d.objoid, d.objsubid) i
|
|
233
|
+
WHERE (i.schema IN (__SCHEMAS__)
|
|
234
|
+
-- A trigger or a policy has no schema of its own: its table's.
|
|
235
|
+
OR (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
236
|
+
WHERE c.oid = CASE d.classoid
|
|
237
|
+
WHEN 'pg_trigger'::regclass THEN (SELECT tgrelid FROM pg_trigger WHERE oid = d.objoid)
|
|
238
|
+
WHEN 'pg_policy'::regclass THEN (SELECT polrelid FROM pg_policy WHERE oid = d.objoid)
|
|
239
|
+
END) IN (__SCHEMAS__)
|
|
240
|
+
OR (d.classoid = 'pg_namespace'::regclass
|
|
241
|
+
AND d.objoid IN (SELECT oid FROM pg_namespace WHERE nspname IN (__SCHEMAS__))))
|
|
242
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend e
|
|
243
|
+
WHERE e.deptype = 'e' AND e.classid = d.classoid AND e.objid = d.objoid)
|
|
244
|
+
|
|
245
|
+
) entries;
|