pg_trunk 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (196) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +87 -0
  3. data/.gitignore +9 -0
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +92 -0
  6. data/.yardopts +4 -0
  7. data/CHANGELOG.md +31 -0
  8. data/CONTRIBUTING.md +17 -0
  9. data/Gemfile +22 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +141 -0
  12. data/Rakefile +16 -0
  13. data/bin/console +8 -0
  14. data/bin/rake +19 -0
  15. data/bin/rspec +19 -0
  16. data/bin/setup +8 -0
  17. data/bin/yard +19 -0
  18. data/lib/pg_trunk/core/adapters/postgres.rb +80 -0
  19. data/lib/pg_trunk/core/dependencies_resolver.rb +101 -0
  20. data/lib/pg_trunk/core/generators.rb +140 -0
  21. data/lib/pg_trunk/core/operation/attributes.rb +78 -0
  22. data/lib/pg_trunk/core/operation/callbacks.rb +40 -0
  23. data/lib/pg_trunk/core/operation/generators.rb +51 -0
  24. data/lib/pg_trunk/core/operation/inversion.rb +70 -0
  25. data/lib/pg_trunk/core/operation/registration.rb +55 -0
  26. data/lib/pg_trunk/core/operation/ruby_builder.rb +112 -0
  27. data/lib/pg_trunk/core/operation/ruby_helpers.rb +99 -0
  28. data/lib/pg_trunk/core/operation/sql_helpers.rb +44 -0
  29. data/lib/pg_trunk/core/operation/validations.rb +21 -0
  30. data/lib/pg_trunk/core/operation.rb +78 -0
  31. data/lib/pg_trunk/core/qualified_name.rb +165 -0
  32. data/lib/pg_trunk/core/railtie/command_recorder.rb +30 -0
  33. data/lib/pg_trunk/core/railtie/custom_types.rb +37 -0
  34. data/lib/pg_trunk/core/railtie/migration.rb +50 -0
  35. data/lib/pg_trunk/core/railtie/migrator.rb +22 -0
  36. data/lib/pg_trunk/core/railtie/schema_dumper.rb +75 -0
  37. data/lib/pg_trunk/core/railtie/schema_migration.rb +22 -0
  38. data/lib/pg_trunk/core/railtie/statements.rb +21 -0
  39. data/lib/pg_trunk/core/railtie.rb +35 -0
  40. data/lib/pg_trunk/core/registry.rb +159 -0
  41. data/lib/pg_trunk/core/serializers/array_of_hashes_serializer.rb +28 -0
  42. data/lib/pg_trunk/core/serializers/array_of_strings_serializer.rb +29 -0
  43. data/lib/pg_trunk/core/serializers/array_of_symbols_serializer.rb +28 -0
  44. data/lib/pg_trunk/core/serializers/array_serializer.rb +22 -0
  45. data/lib/pg_trunk/core/serializers/lowercase_string_serializer.rb +21 -0
  46. data/lib/pg_trunk/core/serializers/multiline_text_serializer.rb +21 -0
  47. data/lib/pg_trunk/core/serializers/qualified_name_serializer.rb +27 -0
  48. data/lib/pg_trunk/core/serializers/symbol_serializer.rb +22 -0
  49. data/lib/pg_trunk/core/serializers.rb +16 -0
  50. data/lib/pg_trunk/core/validators/all_items_valid_validator.rb +15 -0
  51. data/lib/pg_trunk/core/validators/difference_validator.rb +19 -0
  52. data/lib/pg_trunk/core/validators.rb +10 -0
  53. data/lib/pg_trunk/core.rb +21 -0
  54. data/lib/pg_trunk/generators.rb +7 -0
  55. data/lib/pg_trunk/operations/check_constraints/add_check_constraint.rb +109 -0
  56. data/lib/pg_trunk/operations/check_constraints/base.rb +69 -0
  57. data/lib/pg_trunk/operations/check_constraints/drop_check_constraint.rb +60 -0
  58. data/lib/pg_trunk/operations/check_constraints/rename_check_constraint.rb +54 -0
  59. data/lib/pg_trunk/operations/check_constraints/validate_check_constraint.rb +39 -0
  60. data/lib/pg_trunk/operations/check_constraints.rb +14 -0
  61. data/lib/pg_trunk/operations/composite_types/base.rb +61 -0
  62. data/lib/pg_trunk/operations/composite_types/change_composite_type.rb +136 -0
  63. data/lib/pg_trunk/operations/composite_types/column.rb +118 -0
  64. data/lib/pg_trunk/operations/composite_types/create_composite_type.rb +99 -0
  65. data/lib/pg_trunk/operations/composite_types/drop_composite_type.rb +67 -0
  66. data/lib/pg_trunk/operations/composite_types/rename_composite_type.rb +44 -0
  67. data/lib/pg_trunk/operations/composite_types.rb +15 -0
  68. data/lib/pg_trunk/operations/domains/base.rb +46 -0
  69. data/lib/pg_trunk/operations/domains/change_domain.rb +140 -0
  70. data/lib/pg_trunk/operations/domains/constraint.rb +93 -0
  71. data/lib/pg_trunk/operations/domains/create_domain.rb +124 -0
  72. data/lib/pg_trunk/operations/domains/drop_domain.rb +65 -0
  73. data/lib/pg_trunk/operations/domains/rename_domain.rb +44 -0
  74. data/lib/pg_trunk/operations/domains.rb +15 -0
  75. data/lib/pg_trunk/operations/enums/base.rb +47 -0
  76. data/lib/pg_trunk/operations/enums/change.rb +55 -0
  77. data/lib/pg_trunk/operations/enums/change_enum.rb +119 -0
  78. data/lib/pg_trunk/operations/enums/create_enum.rb +83 -0
  79. data/lib/pg_trunk/operations/enums/drop_enum.rb +63 -0
  80. data/lib/pg_trunk/operations/enums/rename_enum.rb +44 -0
  81. data/lib/pg_trunk/operations/enums.rb +15 -0
  82. data/lib/pg_trunk/operations/foreign_keys/add_foreign_key.rb +174 -0
  83. data/lib/pg_trunk/operations/foreign_keys/base.rb +155 -0
  84. data/lib/pg_trunk/operations/foreign_keys/drop_foreign_key.rb +76 -0
  85. data/lib/pg_trunk/operations/foreign_keys/rename_foreign_key.rb +63 -0
  86. data/lib/pg_trunk/operations/foreign_keys.rb +16 -0
  87. data/lib/pg_trunk/operations/functions/base.rb +54 -0
  88. data/lib/pg_trunk/operations/functions/change_function.rb +108 -0
  89. data/lib/pg_trunk/operations/functions/create_function.rb +198 -0
  90. data/lib/pg_trunk/operations/functions/drop_function.rb +88 -0
  91. data/lib/pg_trunk/operations/functions/rename_function.rb +57 -0
  92. data/lib/pg_trunk/operations/functions.rb +14 -0
  93. data/lib/pg_trunk/operations/indexes/add_index.rb +68 -0
  94. data/lib/pg_trunk/operations/indexes.rb +10 -0
  95. data/lib/pg_trunk/operations/materialized_views/base.rb +79 -0
  96. data/lib/pg_trunk/operations/materialized_views/change_materialized_view.rb +139 -0
  97. data/lib/pg_trunk/operations/materialized_views/column.rb +94 -0
  98. data/lib/pg_trunk/operations/materialized_views/create_materialized_view.rb +170 -0
  99. data/lib/pg_trunk/operations/materialized_views/drop_materialized_view.rb +70 -0
  100. data/lib/pg_trunk/operations/materialized_views/refresh_materialized_view.rb +48 -0
  101. data/lib/pg_trunk/operations/materialized_views/rename_materialized_view.rb +61 -0
  102. data/lib/pg_trunk/operations/materialized_views.rb +17 -0
  103. data/lib/pg_trunk/operations/procedures/base.rb +42 -0
  104. data/lib/pg_trunk/operations/procedures/change_procedure.rb +107 -0
  105. data/lib/pg_trunk/operations/procedures/create_procedure.rb +146 -0
  106. data/lib/pg_trunk/operations/procedures/drop_procedure.rb +66 -0
  107. data/lib/pg_trunk/operations/procedures/rename_procedure.rb +57 -0
  108. data/lib/pg_trunk/operations/procedures.rb +14 -0
  109. data/lib/pg_trunk/operations/statistics/base.rb +94 -0
  110. data/lib/pg_trunk/operations/statistics/create_statistics.rb +181 -0
  111. data/lib/pg_trunk/operations/statistics/drop_statistics.rb +75 -0
  112. data/lib/pg_trunk/operations/statistics/rename_statistics.rb +48 -0
  113. data/lib/pg_trunk/operations/statistics.rb +13 -0
  114. data/lib/pg_trunk/operations/tables/create_table.rb +75 -0
  115. data/lib/pg_trunk/operations/tables.rb +10 -0
  116. data/lib/pg_trunk/operations/triggers/base.rb +119 -0
  117. data/lib/pg_trunk/operations/triggers/change_trigger.rb +82 -0
  118. data/lib/pg_trunk/operations/triggers/create_trigger.rb +208 -0
  119. data/lib/pg_trunk/operations/triggers/drop_trigger.rb +66 -0
  120. data/lib/pg_trunk/operations/triggers/rename_trigger.rb +71 -0
  121. data/lib/pg_trunk/operations/triggers.rb +14 -0
  122. data/lib/pg_trunk/operations/views/base.rb +38 -0
  123. data/lib/pg_trunk/operations/views/change_view.rb +90 -0
  124. data/lib/pg_trunk/operations/views/create_view.rb +115 -0
  125. data/lib/pg_trunk/operations/views/drop_view.rb +69 -0
  126. data/lib/pg_trunk/operations/views/rename_view.rb +58 -0
  127. data/lib/pg_trunk/operations/views.rb +14 -0
  128. data/lib/pg_trunk/operations.rb +23 -0
  129. data/lib/pg_trunk/version.rb +6 -0
  130. data/lib/pg_trunk.rb +27 -0
  131. data/pg_trunk.gemspec +34 -0
  132. data/spec/dummy/.gitignore +16 -0
  133. data/spec/dummy/Rakefile +15 -0
  134. data/spec/dummy/bin/bundle +6 -0
  135. data/spec/dummy/bin/rails +6 -0
  136. data/spec/dummy/bin/rake +6 -0
  137. data/spec/dummy/config/application.rb +18 -0
  138. data/spec/dummy/config/boot.rb +7 -0
  139. data/spec/dummy/config/database.yml +14 -0
  140. data/spec/dummy/config/environment.rb +7 -0
  141. data/spec/dummy/config.ru +6 -0
  142. data/spec/dummy/db/materialized_views/admin_users_v01.sql +1 -0
  143. data/spec/dummy/db/migrate/.keep +0 -0
  144. data/spec/dummy/db/schema.rb +18 -0
  145. data/spec/dummy/db/views/admin_users_v01.sql +1 -0
  146. data/spec/dummy/db/views/admin_users_v02.sql +1 -0
  147. data/spec/operations/check_constraints/add_check_constraint_spec.rb +85 -0
  148. data/spec/operations/check_constraints/drop_check_constraint_spec.rb +111 -0
  149. data/spec/operations/check_constraints/rename_check_constraint_spec.rb +90 -0
  150. data/spec/operations/composite_types/change_composite_type_spec.rb +257 -0
  151. data/spec/operations/composite_types/create_composite_type_spec.rb +55 -0
  152. data/spec/operations/composite_types/drop_composite_type_spec.rb +109 -0
  153. data/spec/operations/composite_types/rename_composite_type_spec.rb +74 -0
  154. data/spec/operations/dependency_resolver_spec.rb +177 -0
  155. data/spec/operations/domains/change_domain_spec.rb +287 -0
  156. data/spec/operations/domains/create_domain_spec.rb +69 -0
  157. data/spec/operations/domains/drop_domain_spec.rb +119 -0
  158. data/spec/operations/domains/rename_domain_spec.rb +70 -0
  159. data/spec/operations/enums/change_enum_spec.rb +157 -0
  160. data/spec/operations/enums/create_enum_spec.rb +40 -0
  161. data/spec/operations/enums/drop_enum_spec.rb +120 -0
  162. data/spec/operations/enums/rename_enum_spec.rb +72 -0
  163. data/spec/operations/foreign_keys/add_foreign_key_spec.rb +208 -0
  164. data/spec/operations/foreign_keys/drop_foreign_key_spec.rb +167 -0
  165. data/spec/operations/foreign_keys/rename_foreign_key_spec.rb +101 -0
  166. data/spec/operations/functions/change_function_spec.rb +166 -0
  167. data/spec/operations/functions/create_function_spec.rb +192 -0
  168. data/spec/operations/functions/drop_function_spec.rb +182 -0
  169. data/spec/operations/functions/rename_function_spec.rb +101 -0
  170. data/spec/operations/indexes/add_index_spec.rb +94 -0
  171. data/spec/operations/materialized_views/change_materialized_view_spec.rb +190 -0
  172. data/spec/operations/materialized_views/create_materialized_view_spec.rb +144 -0
  173. data/spec/operations/materialized_views/drop_materialized_view_spec.rb +145 -0
  174. data/spec/operations/materialized_views/refresh_materialized_view_spec.rb +79 -0
  175. data/spec/operations/materialized_views/rename_materialized_view_spec.rb +88 -0
  176. data/spec/operations/procedures/change_procedure_spec.rb +175 -0
  177. data/spec/operations/procedures/create_procedure_spec.rb +151 -0
  178. data/spec/operations/procedures/drop_procedure_spec.rb +159 -0
  179. data/spec/operations/procedures/rename_procedure_spec.rb +107 -0
  180. data/spec/operations/statistics/create_statistics_spec.rb +230 -0
  181. data/spec/operations/statistics/drop_statistics_spec.rb +106 -0
  182. data/spec/operations/statistics/rename_statistics_spec.rb +129 -0
  183. data/spec/operations/tables/create_table_spec.rb +53 -0
  184. data/spec/operations/tables/rename_table_spec.rb +37 -0
  185. data/spec/operations/triggers/change_trigger_spec.rb +195 -0
  186. data/spec/operations/triggers/create_trigger_spec.rb +104 -0
  187. data/spec/operations/triggers/drop_trigger_spec.rb +124 -0
  188. data/spec/operations/triggers/rename_trigger_spec.rb +160 -0
  189. data/spec/operations/views/change_view_spec.rb +144 -0
  190. data/spec/operations/views/create_view_spec.rb +134 -0
  191. data/spec/operations/views/drop_view_spec.rb +146 -0
  192. data/spec/operations/views/rename_view_spec.rb +85 -0
  193. data/spec/pg_trunk/dependencies_resolver_spec.rb +43 -0
  194. data/spec/spec_helper.rb +28 -0
  195. data/spec/support/migrations_helper.rb +376 -0
  196. metadata +348 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#create_statistics(name, **options, &block)
4
+ # Create a custom statistics
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the statistics
7
+ # @option [Boolean] :if_not_exists (false)
8
+ # Suppress the error when the statistics is already exist
9
+ # @option [#to_s] table (nil)
10
+ # The qualified name of the table whose statistics will be collected
11
+ # @option [Array<Symbol>] kinds ([:dependencies, :mcv, :ndistinct])
12
+ # The kinds of statistics to be collected (all by default).
13
+ # Supported values in the array: :dependencies, :mcv, :ndistinct
14
+ # @option [#to_s] :comment The description of the statistics
15
+ # @yield [Proc] the block with the statistics' definition
16
+ # @yieldparam The receiver of methods specifying the statistics
17
+ #
18
+ # The statistics can be created with explicit name:
19
+ #
20
+ # create_statistics "users_stats" do |s|
21
+ # s.table "users"
22
+ # s.columns "family", "name"
23
+ # s.kinds :dependencies, :mcv, :ndistinct
24
+ # s.comment "Statistics for users' names and families"
25
+ # SQL
26
+ #
27
+ # The name can be generated as well:
28
+ #
29
+ # create_statistics do |s|
30
+ # s.table "users"
31
+ # s.columns "family", "name"
32
+ # s.kinds :dependencies, :mcv, :ndistinct
33
+ # s.comment "Statistics for users' names and families"
34
+ # SQL
35
+ #
36
+ # Since v14 PostgreSQL have supported expressions in addition to columns:
37
+ #
38
+ # create_statistics "users_stats" do |s|
39
+ # s.table "users"
40
+ # s.columns "family"
41
+ # s.expression "length(name)"
42
+ # s.kinds :dependencies, :mcv, :ndistinct
43
+ # s.comment "Statistics for users' name lengths and families"
44
+ # SQL
45
+ #
46
+ # as well as statistics for the sole expression (kinds must be blank)
47
+ # by columns of some table.
48
+ #
49
+ # create_statistics "users_stats" do |s|
50
+ # s.table "users"
51
+ # s.expression "length(name || ' ' || family)"
52
+ # s.comment "Statistics for full name lengths"
53
+ # SQL
54
+ #
55
+ # Use `if_not_exists: true` to suppress error in case the statistics
56
+ # has already been created. This option, though, makes the migration
57
+ # irreversible due to uncertainty of the previous state of the database.
58
+
59
+ module PGTrunk::Operations::Statistics
60
+ # SQL snippet to fetch statistics in v10-13
61
+ SQL_V10 = <<~SQL.freeze
62
+ WITH
63
+ list (key, name) AS (
64
+ VALUES ('m', 'mcv'), ('f', 'dependencies'), ('d', 'ndistinct')
65
+ )
66
+ SELECT
67
+ s.oid,
68
+ (s.stxnamespace::regnamespace || '.' || s.stxname) AS name,
69
+ (t.relnamespace::regnamespace || '.' || t.relname) AS "table",
70
+ (
71
+ SELECT array_agg(l.name)
72
+ FROM list l
73
+ WHERE ARRAY[l.key]::char[] <@ s.stxkind::char[]
74
+ ) AS kinds,
75
+ (
76
+ SELECT array_agg(DISTINCT a.attname)
77
+ FROM pg_attribute a
78
+ WHERE a.attrelid = s.stxrelid
79
+ AND ARRAY[a.attnum]::int[] <@ s.stxkeys::int[]
80
+ ) AS columns,
81
+ d.description AS comment
82
+ FROM pg_statistic_ext s
83
+ JOIN pg_trunk e ON e.oid = s.oid AND e.classid = 'pg_statistic_ext'::regclass
84
+ JOIN pg_class t ON t.oid = s.stxrelid
85
+ LEFT JOIN pg_description d ON d.objoid = s.oid;
86
+ SQL
87
+
88
+ # In version 14 statistics can be collected for expressions.
89
+ SQL_V14 = <<~SQL.freeze
90
+ WITH
91
+ list (key, name) AS (
92
+ VALUES ('m', 'mcv'), ('f', 'dependencies'), ('d', 'ndistinct')
93
+ )
94
+ SELECT
95
+ s.oid,
96
+ (s.stxnamespace::regnamespace || '.' || s.stxname) AS name,
97
+ (t.relnamespace::regnamespace || '.' || t.relname) AS "table",
98
+ (
99
+ SELECT array_agg(l.name)
100
+ FROM list l
101
+ WHERE ARRAY[l.key]::char[] <@ s.stxkind::char[]
102
+ ) AS kinds,
103
+ (
104
+ SELECT array_agg(DISTINCT a.attname)
105
+ FROM pg_attribute a
106
+ WHERE a.attrelid = s.stxrelid
107
+ AND ARRAY[a.attnum]::int[] <@ s.stxkeys::int[]
108
+ ) AS columns,
109
+ pg_get_expr(s.stxexprs, stxrelid, true) AS expressions,
110
+ d.description AS comment
111
+ FROM pg_statistic_ext s
112
+ JOIN pg_trunk e ON e.oid = s.oid AND e.classid = 'pg_statistic_ext'::regclass
113
+ JOIN pg_class t ON t.oid = s.stxrelid
114
+ LEFT JOIN pg_description d ON d.objoid = s.oid;
115
+ SQL
116
+
117
+ # @private
118
+ class CreateStatistics < Base
119
+ validates :if_exists, :force, :new_name, absence: true
120
+ validates :table, presence: true
121
+ validate do
122
+ errors.add :base, "Columns and expressions can't be blank" if parts.blank?
123
+ end
124
+
125
+ from_sql do |version|
126
+ version >= "14" ? SQL_V14 : SQL_V10
127
+ end
128
+
129
+ def to_sql(version)
130
+ check_version!(version)
131
+
132
+ [create_statistics, *create_comment, register_object].join(" ")
133
+ end
134
+
135
+ def invert
136
+ irreversible!("if_not_exists: true") if if_not_exists
137
+ DropStatistics.new(**to_h)
138
+ end
139
+
140
+ private
141
+
142
+ def check_version!(version)
143
+ raise <<~ERROR.squish if version < "14" && expressions.present?
144
+ Statistics for expressions are supported in PostgreSQL v14+"
145
+ ERROR
146
+
147
+ raise <<~ERROR.squish if version < "12" && kinds.include?(:mcv)
148
+ The `mcv` kind is supported in PostgreSQL v12+
149
+ ERROR
150
+ end
151
+
152
+ def create_statistics
153
+ sql = "CREATE STATISTICS"
154
+ sql << " IF NOT EXISTS" if if_not_exists
155
+ sql << " #{name.to_sql}"
156
+ sql << " (#{kinds.join(',')})" if kinds.present?
157
+ sql << " ON #{parts.join(', ')}"
158
+ sql << "FROM #{table.to_sql};"
159
+ end
160
+
161
+ def create_comment
162
+ return if comment.blank?
163
+
164
+ "COMMENT ON STATISTICS #{name.to_sql} IS $comment$#{comment}$comment$;"
165
+ end
166
+
167
+ def register_object
168
+ <<~SQL
169
+ INSERT INTO pg_trunk(oid, classid)
170
+ SELECT s.oid, 'pg_statistic_ext'::regclass
171
+ FROM pg_statistic_ext s
172
+ JOIN pg_class t ON t.oid = s.stxrelid
173
+ WHERE s.stxname = #{name.quoted}
174
+ AND s.stxnamespace = #{name.namespace}
175
+ AND t.relname = #{table.quoted}
176
+ AND t.relnamespace = #{table.namespace}
177
+ ON CONFLICT DO NOTHING;
178
+ SQL
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#drop_statistics(name, **options, &block)
4
+ # Drop a custom statistics
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the statistics
7
+ # @option [Boolean] :if_exists (false) Suppress the error when the statistics is absent
8
+ # @option [Symbol] :force (:restrict) How to process dependent objects (`:cascade` or `:restrict`)
9
+ # @option [#to_s] table (nil)
10
+ # The qualified name of the table whose statistics will be collected
11
+ # @option [Array<Symbol>] kinds ([:dependencies, :mcv, :ndistinct])
12
+ # The kinds of statistics to be collected (all by default).
13
+ # Supported values in the array: :dependencies, :mcv, :ndistinct
14
+ # @option [#to_s] :comment The description of the statistics
15
+ # @yield [Proc] the block with the statistics' definition
16
+ # @yieldparam The receiver of methods specifying the statistics
17
+ #
18
+ # A statistics can be dropped by its name only:
19
+ #
20
+ # drop_statistics "my_stats"
21
+ #
22
+ # Such operation is irreversible. To make it inverted
23
+ # you have to provide a full definition:
24
+ #
25
+ # drop_statistics "users_stat" do |s|
26
+ # s.table "users"
27
+ # s.columns "firstname", "name"
28
+ # s.expression <<~SQL
29
+ # round(age, 10)
30
+ # SQL
31
+ # s.kinds :dependency, :mcv, :ndistinct
32
+ # p.comment "Statistics for name, firstname, and rough age"
33
+ # SQL
34
+ #
35
+ # If the statistics was anonymous (used the generated name),
36
+ # it can be dropped without defining the name as well:
37
+ #
38
+ # drop_statistics do |s|
39
+ # s.table "users"
40
+ # s.columns "firstname", "name"
41
+ # s.expression <<~SQL
42
+ # round(age, 10)
43
+ # SQL
44
+ # s.kinds :dependency, :mcv, :ndistinct
45
+ # p.comment "Statistics for name, firstname, and rough age"
46
+ # SQL
47
+ #
48
+ # The operation can be called with `if_exists` option. In this case
49
+ # it would do nothing when no statistics existed.
50
+ #
51
+ # drop_procedure "unknown_statistics", if_exists: true
52
+ #
53
+ # Notice, that this option make the operation irreversible because of
54
+ # uncertainty about the previous state of the database.
55
+
56
+ module PGTrunk::Operations::Statistics
57
+ # @private
58
+ class DropStatistics < Base
59
+ validates :if_not_exists, :new_name, absence: true
60
+
61
+ def to_sql(_version)
62
+ sql = "DROP STATISTICS"
63
+ sql << " IF EXISTS" if if_exists
64
+ sql << " #{name.to_sql}"
65
+ sql << " CASCADE" if force == :cascade
66
+ sql << ";"
67
+ end
68
+
69
+ def invert
70
+ irreversible!("if_exists: true") if if_exists
71
+ irreversible!("force: :cascade") if force == :cascade
72
+ CreateStatistics.new(**to_h.except(:force))
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#rename_statistics(name, to:)
4
+ # Change the name and/or schema of a statistics
5
+ #
6
+ # @param [#to_s] :name (nil) The qualified name of the statistics
7
+ # @option [#to_s] :to (nil) The new qualified name for the statistics
8
+ #
9
+ # A custom statistics can be renamed by changing both the name
10
+ # and the schema (namespace) it belongs to.
11
+ #
12
+ # rename_statistics "math.my_stat", to: "public.my_stats"
13
+ #
14
+ # The operation is always reversible.
15
+
16
+ module PGTrunk::Operations::Statistics
17
+ # @private
18
+ class RenameStatistics < Base
19
+ after_initialize { self.new_name ||= generated_name }
20
+
21
+ validates :new_name, presence: true
22
+ validates :if_exists, :if_not_exists, :force, absence: true
23
+
24
+ def to_sql(_version)
25
+ [*change_schema, *change_name].join("; ")
26
+ end
27
+
28
+ def invert
29
+ q_new_name = "#{new_name.schema}.#{new_name.routine}(#{name.args}) #{name.returns}"
30
+ self.class.new(**to_h, name: q_new_name.strip, to: name)
31
+ end
32
+
33
+ private
34
+
35
+ def change_schema
36
+ return if name.schema == new_name.schema
37
+
38
+ "ALTER STATISTICS #{name.to_sql} SET SCHEMA #{new_name.schema.inspect};"
39
+ end
40
+
41
+ def change_name
42
+ return if new_name.routine == name.routine
43
+
44
+ changed_name = name.merge(schema: new_name.schema).to_sql
45
+ "ALTER STATISTICS #{changed_name} RENAME TO #{new_name.routine.inspect};"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # nodoc
4
+ module PGTrunk::Operations
5
+ # @private
6
+ # Namespace for operations with functions
7
+ module Statistics
8
+ require_relative "statistics/base"
9
+ require_relative "statistics/create_statistics"
10
+ require_relative "statistics/drop_statistics"
11
+ require_relative "statistics/rename_statistics"
12
+ end
13
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGTrunk::Operations::Tables
4
+ # @private
5
+ #
6
+ # When dealing with tables we're only interested in
7
+ # dumping tables one-by-one to enable other operations
8
+ # in between tables.
9
+ #
10
+ # We doesn't overload the method `create_table`, but
11
+ # keep the original implementation unchanged. That's why
12
+ # neither `to_sql`, `invert` or `generates_object` are necessary.
13
+ #
14
+ # While we rely on the original implementation,
15
+ # there are some differences in a way we fetching
16
+ # tables and dumping them to the schema:
17
+ #
18
+ # - we extracting both qualified +name+ and +oid+ for every table,
19
+ # and checking them against the content of `pg_trunk`;
20
+ # - we wrap every table new_name this class for dependencies resolving;
21
+ # - we don't keep indexes and check constraints
22
+ # inside the table definitions because they can depend
23
+ # on functions which, in turn, can depend on tables.
24
+ #
25
+ class CreateTable < PGTrunk::Operation
26
+ # No other attributes except for the mandatory `name` and `oid` are needed.
27
+ # We also use default ordering by qualified names.
28
+ validates :oid, presence: true
29
+
30
+ # SQL to fetch table names and oids from the database.
31
+ # We rely on the fact all tables of interest are registered in `pg_trunk`.
32
+ from_sql do
33
+ <<~SQL
34
+ SELECT
35
+ c.oid,
36
+ (c.relnamespace::regnamespace || '.' || c.relname) AS name
37
+ FROM pg_class c JOIN pg_trunk p ON p.oid = c.oid
38
+ -- 'r' for tables and 'p' for partitions
39
+ WHERE c.relkind IN ('r', 'p')
40
+ SQL
41
+ end
42
+
43
+ # Instead of defining +ruby_snippet+, we overload
44
+ # the +to_ruby+ to rely on the original implementation.
45
+ #
46
+ # We overloaded the +ActiveRecord::SchemaDumper+
47
+ # methods +indexes_in_create+ and +check_constraints_in_create+
48
+ # so that they do nothing to exclude indexes and constraints
49
+ # from a table definition.
50
+ #
51
+ # @see +PGTrunk::SchemaDumper+ module (in `core/railtie`).
52
+ def to_ruby
53
+ stream = StringIO.new
54
+ PGTrunk.dumper.send(:table, name.lean, stream)
55
+ unindent(stream.string)
56
+ end
57
+
58
+ private
59
+
60
+ # ActiveRecord builds the dump indented by 2 space chars.
61
+ # Because the +to_ruby+ method is used in error messages,
62
+ # we do indentation separately in the +PGTrunk::SchemaDumper+.
63
+ #
64
+ # That's why we have to unindent the original snippet
65
+ # provided by the +ActiveRecord::Dumper##table+ method call
66
+ # back by 2 space characters.
67
+ #
68
+ # The `.strip << "\n"` is added for the compatibility
69
+ # with the +RubyBuilder+ which returns snippets
70
+ # having one trailing newline only.
71
+ def unindent(snippet)
72
+ snippet.lines.map { |line| line.sub(/^ {1,2}/, "") }.join.strip << "\n"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # nodoc
4
+ module PGTrunk::Operations
5
+ # @private
6
+ # Namespace for operations with tables
7
+ module Tables
8
+ require_relative "tables/create_table"
9
+ end
10
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: false
2
+
3
+ module PGTrunk::Operations::Triggers
4
+ # @abstract
5
+ # @private
6
+ # Base class for operations with triggers
7
+ class Base < PGTrunk::Operation
8
+ # All attributes that can be used by trigger-related commands
9
+ attribute :columns, :pg_trunk_array_of_strings, default: []
10
+ attribute :constraint, :boolean
11
+ attribute :events, :pg_trunk_array_of_symbols, default: []
12
+ attribute :for_each, :pg_trunk_symbol
13
+ attribute :function, :pg_trunk_qualified_name
14
+ attribute :initially, :pg_trunk_symbol
15
+ attribute :replace_existing, :boolean
16
+ attribute :table, :pg_trunk_qualified_name
17
+ attribute :type, :pg_trunk_symbol
18
+ attribute :when, :string
19
+
20
+ # Generate missed name of the trigger
21
+ after_initialize { self.name = generated_name if name.blank? }
22
+ after_initialize { self.type ||= :after if constraint }
23
+ after_initialize { self.for_each ||= :row if constraint }
24
+
25
+ # Ensure correctness of present values
26
+ validates :table, presence: true
27
+ validates :for_each, inclusion: { in: %i[row statement] }, allow_nil: true
28
+ validates :initially, inclusion: { in: %i[immediate deferred] }, allow_nil: true
29
+ validates :type, inclusion: { in: %i[after before instead_of] }, allow_nil: true
30
+ validates :events,
31
+ inclusion: { in: %i[insert update delete truncate] },
32
+ allow_blank: true
33
+ validate do
34
+ next if name.blank?
35
+
36
+ errors.add :name, "can't have a schema" unless name.current_schema?
37
+ end
38
+ validate do
39
+ next unless initially && !constraint
40
+
41
+ errors.add :initially, "can be used for constraints only"
42
+ end
43
+ validate do
44
+ next unless columns.present? && type == :instead_of
45
+
46
+ errors.add :columns,
47
+ "can be defined for before/after update triggers only"
48
+ end
49
+ validate do
50
+ next unless constraint && type != :after && for_each != :row
51
+
52
+ errors.add :base, "Only AFTER EACH ROW triggers can be constraints"
53
+ end
54
+ validate do
55
+ next unless self.when && type == :instead_of
56
+
57
+ errors.add :when, "is not supported for INSTEAD OF triggers"
58
+ end
59
+ validate do
60
+ next if new_name.blank? || new_name.current_schema?
61
+
62
+ errors.add :base, "New name can't specify the schema"
63
+ end
64
+
65
+ # triggers are ordered by table and name
66
+ def <=>(other)
67
+ return unless other.is_a?(self.class)
68
+
69
+ result = table <=> other.table
70
+ result.zero? ? super : result
71
+ end
72
+
73
+ # Support `table` and `name` in positional arguments.
74
+ # @example
75
+ # add_trigger :users, :my_trigger, **opts
76
+ ruby_params :table, :name
77
+
78
+ ruby_snippet do |s|
79
+ s.ruby_param(table.lean) if table.present?
80
+ s.ruby_param(name.lean) if custom_name?
81
+ s.ruby_param(to: new_name.lean) if custom_name?(new_name)
82
+ s.ruby_param(if_exists: true) if if_exists
83
+ s.ruby_param(:replace_existing, true) if replace_existing
84
+
85
+ s.ruby_line(:function, function.lean) if function.present?
86
+ s.ruby_line(:when, self.when)
87
+ s.ruby_line(:constraint, true) if constraint
88
+ s.ruby_line(:for_each, for_each) if for_each&.== :row
89
+ s.ruby_line(:type, type) if type.present?
90
+ s.ruby_line(:events, events) if events.present?
91
+ s.ruby_line(:columns, columns) if columns.present?
92
+ s.ruby_line(:initially, initially)
93
+ s.ruby_line(:comment, comment, from: from_comment)
94
+ end
95
+
96
+ private
97
+
98
+ # Generate the name of the trigger using the essential options
99
+ # @return [PGTrunk::QualifiedName]
100
+ def generated_name
101
+ return @generated_name if instance_variable_defined?(:@generated_name)
102
+
103
+ @generated_name = begin
104
+ return if [table, function, type, events].any?(&:blank?)
105
+
106
+ key_options = to_h.reject { |_, v| v.blank? }.slice(
107
+ :table, :function, :for_each, :type, :events,
108
+ )
109
+ identifier = "#{table.lean}_#{key_options}_tg"
110
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
111
+ PGTrunk::QualifiedName.wrap("tg_rails_#{hashed_identifier}")
112
+ end
113
+ end
114
+
115
+ def custom_name?(qname = name)
116
+ qname&.differs_from?(/^tg_rails_\w{10}$/)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#create_trigger(table, name = nil, **options, &block)
4
+ # Create a trigger for a table
5
+ #
6
+ # @param [#to_s] table (nil) The qualified name of the table
7
+ # @param [#to_s] name (nil) The name of the trigger
8
+ # @option [Boolean] :if_exists (false) Suppress the error when the trigger is absent
9
+ # @yield [Proc] the block with the trigger's definition
10
+ # @yieldparam The receiver of methods specifying the trigger
11
+ #
12
+ # The trigger can be changed using `CREATE OR REPLACE TRIGGER` command:
13
+ #
14
+ # change_trigger "users", "do_something" do |t|
15
+ # t.function "do_something()", from: "do_something_different()"
16
+ # t.for_each :row # from: :statement
17
+ # t.type :after, from: :before
18
+ # t.events %i[insert update], from: %i[insert]
19
+ # t.comment "Does something useful", from: ""
20
+ # end
21
+
22
+ module PGTrunk::Operations::Triggers
23
+ # @private
24
+ class ChangeTrigger < Base
25
+ validates :replace_existing, :new_name, :version, absence: true
26
+ validate { errors.add :base, "Changes can't be blank" if changes.blank? }
27
+ validate do
28
+ next if if_exists
29
+
30
+ errors.add :base, "The trigger cannot be found" unless create_trigger
31
+ end
32
+
33
+ def to_sql(server_version)
34
+ return create_trigger&.to_sql(server_version) if server_version >= "14"
35
+
36
+ raise "The operation is supported by PostgreSQL server v14+"
37
+ end
38
+
39
+ def invert
40
+ irreversible!("if_exists: true") if if_exists
41
+ undefined = inversion.select { |_, v| v.nil? }.keys.join(", ").presence
42
+ raise IrreversibleMigration.new(self, nil, <<~MSG.squish) if undefined
43
+ Undefined values to revert #{undefined}.
44
+ MSG
45
+
46
+ self.class.new(**inversion, table: table, name: name)
47
+ end
48
+
49
+ private
50
+
51
+ def changes
52
+ @changes ||= {
53
+ type: type.presence,
54
+ events: events.presence,
55
+ columns: columns.presence,
56
+ constraint: constraint,
57
+ for_each: for_each,
58
+ function: function.presence,
59
+ initially: initially,
60
+ when: self.when.presence,
61
+ comment: comment,
62
+ }.compact
63
+ end
64
+
65
+ def inversion
66
+ changes
67
+ .each_with_object({}) { |(k, _), obj| obj[k] = send(:"from_#{k}") }
68
+ .tap do |i|
69
+ i[:for_each] ||= (%i[statement row] - [for_each]).first if for_each
70
+ end
71
+ end
72
+
73
+ def create_trigger
74
+ return if name.blank? || table.blank?
75
+
76
+ @create_trigger ||=
77
+ CreateTrigger
78
+ .find { |o| o.name == name && o.table == table }
79
+ &.tap { |o| o.attributes = { **changes, replace_existing: true } }
80
+ end
81
+ end
82
+ end