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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGTrunk::Operations::Indexes
4
+ # @private
5
+ #
6
+ # PGTrunk excludes indexes from table definitions provided by Rails.
7
+ # That's why we have to fetch and dump indexes separately.
8
+ #
9
+ # We fetch indexes from the database by their names and oids,
10
+ # and then rely on the original method +ActiveRecord::SchemaDumper#add_index+
11
+ #
12
+ # We doesn't overload the method `create_table`, but
13
+ # keep the original implementation unchanged. That's why
14
+ # neither `to_sql`, `invert` or `generates_object` are necessary.
15
+ #
16
+ class AddIndex < PGTrunk::Operation
17
+ attribute :table, :pg_trunk_qualified_name
18
+
19
+ validates :oid, :table, presence: true
20
+
21
+ # Indexes are ordered by table and name
22
+ def <=>(other)
23
+ return unless other.is_a?(self.class)
24
+
25
+ result = table <=> other.table
26
+ result&.zero? ? super : result
27
+ end
28
+
29
+ # SQL to fetch table names and oids from the database.
30
+ # We only extract (oid, table, name) for indexes that
31
+ # are not used as primary key constraints.
32
+ #
33
+ # Primary keys are added inside tables because
34
+ # they cannot depend on anything else.
35
+ from_sql do
36
+ <<~SQL
37
+ SELECT
38
+ c.oid,
39
+ (c.relnamespace::regnamespace || '.' || c.relname) AS name,
40
+ (t.relnamespace::regnamespace || '.' || t.relname) AS "table"
41
+ FROM pg_class c
42
+ -- ensure the table was created by a migration
43
+ JOIN pg_trunk p ON p.oid = c.oid
44
+ JOIN pg_index i ON i.indexrelid = c.oid
45
+ JOIN pg_class t ON t.oid = i.indrelid
46
+ -- ignore primary keys
47
+ WHERE NOT i.indisprimary
48
+ SQL
49
+ end
50
+
51
+ # Instead of defining +ruby_snippet+, we overload
52
+ # the +to_ruby+ to rely on the original implementation.
53
+ #
54
+ # We overloaded the +ActiveRecord::SchemaDumper+
55
+ # method +indexes_in_create+ so that it does nothing
56
+ # to exclude indexes from a table definition.
57
+ #
58
+ # @see +PGTrunk::SchemaDumper+ module (in `core/railtie`).
59
+ def to_ruby
60
+ indexes = PGTrunk.database.send(:indexes, table.lean)
61
+ index = indexes.find { |i| i.name == name.lean }
62
+ return unless index
63
+
64
+ line = PGTrunk.dumper.send(:index_parts, index).join(", ")
65
+ "add_index #{table.lean.inspect}, #{line}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # nodoc
4
+ module PGTrunk::Operations
5
+ # @private
6
+ # Namespace for operations with indexes
7
+ module Indexes
8
+ require_relative "indexes/add_index"
9
+ end
10
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: false
2
+
3
+ module PGTrunk::Operations::MaterializedViews
4
+ # @abstract
5
+ # @private
6
+ # Base class for operations with views
7
+ class Base < PGTrunk::Operation
8
+ # All attributes that can be used by view-related commands
9
+ attribute :algorithm, :pg_trunk_symbol
10
+ attribute :cluster_on, :string
11
+ attribute :columns, :pg_trunk_array_of_hashes, default: []
12
+ attribute :sql_definition, :pg_trunk_multiline_text
13
+ attribute :tablespace, :string
14
+ attribute :version, :integer, aliases: :revert_to_version
15
+ attribute :with_data, :boolean
16
+
17
+ def column(name, **opts)
18
+ columns << Column.new(name: name, **opts.except(:new_name))
19
+ end
20
+
21
+ # Load missed `sql_definition` from the external file
22
+ after_initialize { self.sql_definition ||= read_snippet_from(:materialized_views) }
23
+ after_initialize { columns.map! { |c| Column.build(c) } }
24
+
25
+ # Ensure correctness of present values
26
+ validates :algorithm, inclusion: %i[concurrently], allow_nil: true
27
+ validates :tablespace, exclusion: { in: [UNDEFINED] }, allow_nil: true
28
+ validates :columns, "PGTrunk/all_items_valid": true, allow_nil: true
29
+
30
+ # Use comparison by name from pg_trunk operations base class (default)
31
+ # Support name as the only positional argument (default)
32
+
33
+ ruby_snippet do |s|
34
+ s.ruby_param(name.lean) if name.present?
35
+ s.ruby_param(version: version) if version.present?
36
+ s.ruby_param(to: new_name.lean) if new_name.present?
37
+ s.ruby_param(if_exists: true) if if_exists
38
+ s.ruby_param(if_not_exists: true) if if_not_exists
39
+ s.ruby_param(force: :cascade) if force == :cascade
40
+
41
+ s.ruby_line(:sql_definition, sql_definition) if version.blank?
42
+ s.ruby_line(:tablespace, tablespace) if tablespace.present?
43
+ s.ruby_line(:cluster_on, cluster_on) if cluster_on.present?
44
+ columns.reject(&:new_name).each do |c|
45
+ s.ruby_line(:column, c.name, **c.changes)
46
+ end
47
+ columns.select(&:new_name).each do |c|
48
+ s.ruby_line(:rename_column, c.name, to: c.new_name)
49
+ end
50
+ s.ruby_line(:with_data, false) if with_data == false
51
+ s.ruby_line(:comment, comment, from: from_comment) if comment
52
+ end
53
+
54
+ private
55
+
56
+ # A special constant to distinct cluster resetting from nil
57
+ RESET = Object.new.freeze
58
+
59
+ def validate_naming!(name: nil, **)
60
+ errors.add :columns, "has undefined names" if name.blank?
61
+ end
62
+
63
+ def validate_definition!(name: nil, **opts)
64
+ return if opts.none? { |_, value| value == UNDEFINED }
65
+
66
+ errors.add :base, "Definition of column #{name} can't be reverted"
67
+ end
68
+
69
+ def validate_statistics!(name: nil, **opts)
70
+ opts.values_at(*STATISTICS).each do |value|
71
+ next if value.nil? || value == UNDEFINED
72
+ next if value.is_a?(Numeric) && value >= 0
73
+
74
+ errors.add :base, "Column #{name} has invalid statistics #{value}"
75
+ break
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#change_materialized_view(name, **options, &block)
4
+ # Modify a materialized view
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the view
7
+ # @option [Boolean] :if_exists (false) Suppress the error when the view is absent
8
+ # @yield [Proc] the block with the view's definition
9
+ # @yieldparam The receiver of methods specifying the view
10
+ #
11
+ # The operation enables to alter a view without recreating
12
+ # its from scratch. You can rename columns, change their
13
+ # storage settings (how the column is TOAST-ed), or
14
+ # customize their statistics.
15
+ #
16
+ # change_materialized_view "admin_users" do |v|
17
+ # v.rename_column "name", to: "full_name"
18
+ # v.column "name", storage: "extended", from_storage: "expanded"
19
+ # v.column "admin", n_distinct: 2
20
+ # v.column "role", statistics: 100
21
+ # end
22
+ #
23
+ # Notice that renaming will be done AFTER all changes even
24
+ # though the order of declarations can be different.
25
+ #
26
+ # As in the snippet above, to make the change invertible,
27
+ # you have to define a previous storage via `from_storage` option.
28
+ # The inversion would always reset statistics (set it to 0).
29
+ #
30
+ # In addition to changing columns, the operation enables
31
+ # to set a default clustering by given index:
32
+ #
33
+ # change_materialized_view "admin_users" do |v|
34
+ # v.cluster_on "admin_users_by_names_idx"
35
+ # end
36
+ #
37
+ # The clustering is invertible, but its inversion does nothing,
38
+ # keeping the clustering unchanged.
39
+ #
40
+ # The comment can also be changed:
41
+ #
42
+ # change_materialized_view "admin_users" do |v|
43
+ # v.comment "Admin users", from: "Admin users only"
44
+ # end
45
+ #
46
+ # Notice, that without `from` option the operation is still
47
+ # invertible, but its inversion would delete the comment.
48
+ # It can also be reset to the blank string explicitly:
49
+ #
50
+ # change_materialized_view "admin_users" do |v|
51
+ # v.comment "", from: "Admin users only"
52
+ # end
53
+ #
54
+ # With the `if_exists: true` option, the operation won't fail
55
+ # even when the view wasn't existed. At the same time,
56
+ # this option makes a migration irreversible due to uncertainty
57
+ # of the previous state of the database.
58
+
59
+ module PGTrunk::Operations::MaterializedViews
60
+ # @private
61
+ class ChangeMaterializedView < Base
62
+ # A method to be called in a block
63
+ def rename_column(name, to:)
64
+ columns << Column.new(name: name, new_name: to)
65
+ end
66
+
67
+ # Operation-specific validations
68
+ validate { errors.add :base, "Changes can't be blank" if changes.blank? }
69
+ validates :algorithm, :force, :if_not_exists, :new_name, :sql_definition,
70
+ :tablespace, :version, :with_data, absence: true
71
+
72
+ def to_sql(_version)
73
+ [
74
+ *change_columns,
75
+ *rename_columns,
76
+ *cluster_view,
77
+ *update_comment,
78
+ ].join(" ")
79
+ end
80
+
81
+ def invert
82
+ irreversible!("if_exists: true") if if_exists
83
+ undefined = inversion.select { |_, v| v.nil? }.keys.join(", ").presence
84
+ raise IrreversibleMigration.new(self, nil, <<~MSG.squish) if undefined
85
+ Undefined values to revert #{undefined}.
86
+ MSG
87
+
88
+ self.class.new(name: name, **inversion) if inversion.any?
89
+ end
90
+
91
+ private
92
+
93
+ def changes
94
+ @changes ||= {
95
+ columns: columns.presence,
96
+ cluster_on: cluster_on,
97
+ comment: comment,
98
+ }.compact
99
+ end
100
+
101
+ def inversion
102
+ @inversion ||= {
103
+ columns: columns.map(&:invert).presence,
104
+ comment: from_comment,
105
+ }.slice(*changes.keys)
106
+ end
107
+
108
+ def alter_view
109
+ @alter_view ||= begin
110
+ sql = "ALTER MATERIALIZED VIEW"
111
+ sql << " IF EXISTS" if if_exists
112
+ sql << " #{name.to_sql}"
113
+ end
114
+ end
115
+
116
+ def change_columns
117
+ changes = columns.reject(&:new_name).map(&:to_sql).join(", ")
118
+ "#{alter_view} #{changes};" if changes.present?
119
+ end
120
+
121
+ def rename_columns
122
+ changes = columns.select(&:new_name).map(&:to_sql).join(", ")
123
+ "#{alter_view} #{changes};" if changes.present?
124
+ end
125
+
126
+ def cluster_view
127
+ "#{alter_view} CLUSTER ON #{cluster_on.inspect};" if cluster_on.present?
128
+ end
129
+
130
+ def update_comment
131
+ return if comment.nil?
132
+
133
+ <<~SQL
134
+ COMMENT ON MATERIALIZED VIEW #{name.to_sql}
135
+ IS $comment$#{comment}$comment$;
136
+ SQL
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGTrunk::Operations::MaterializedViews
4
+ # @private
5
+ # Definition for the column change
6
+ class Column
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::Validations
10
+
11
+ def self.build(data)
12
+ data.is_a?(self) ? data : new(**data)
13
+ end
14
+
15
+ attribute :name, :string
16
+ attribute :new_name, :string
17
+ attribute :storage, :pg_trunk_symbol
18
+ attribute :from_storage, :pg_trunk_symbol
19
+ attribute :statistics, :integer
20
+ attribute :n_distinct, :float
21
+
22
+ # Hashify definitions
23
+
24
+ def to_h
25
+ @to_h ||=
26
+ attributes
27
+ .symbolize_keys
28
+ .transform_values(&:presence)
29
+ .compact
30
+ end
31
+
32
+ def opts
33
+ to_h.except(:name)
34
+ end
35
+
36
+ def changes
37
+ opts.except(:new_name)
38
+ end
39
+
40
+ def invert
41
+ return { name: new_name, new_name: name } if new_name.present?
42
+
43
+ {
44
+ name: name,
45
+ storage: (from_storage || :UNDEFINED if storage.present?),
46
+ statistics: (0 if statistics.present?),
47
+ n_distinct: (0 if n_distinct.present?),
48
+ }.compact
49
+ end
50
+
51
+ # Ensure if the definition was built properly
52
+
53
+ validates :name, presence: true
54
+ validate { errors.add(:base, :blank) if opts.none? }
55
+ validates :statistics,
56
+ numericality: { greater_than_or_equal_to: 0 },
57
+ allow_nil: true
58
+ validates :n_distinct,
59
+ numericality: { greater_than_or_equal_to: -1 },
60
+ allow_nil: true
61
+ validates :storage, :from_storage,
62
+ inclusion: { in: %i[plain extended external main] },
63
+ allow_nil: true
64
+ validate do
65
+ next unless n_distinct&.positive?
66
+ next if n_distinct.to_i == n_distinct
67
+
68
+ errors.add :n_distinct, "with positive value must be integer"
69
+ end
70
+
71
+ def error_messages
72
+ validate
73
+ errors&.messages&.flat_map do |k, v|
74
+ v.map do |msg|
75
+ "Column #{name.inspect}: #{k == :base ? msg : "#{k} #{msg}"}"
76
+ end
77
+ end
78
+ end
79
+
80
+ # Build SQL snippets for the column definition
81
+ # @return [Array<String>]
82
+ def to_sql(_version = "10")
83
+ return ["RENAME COLUMN #{name.inspect} TO #{new_name.inspect}"] if new_name
84
+
85
+ alter = "ALTER COLUMN #{name.inspect}"
86
+ [
87
+ *("#{alter} SET STATISTICS #{statistics}" if statistics),
88
+ *("#{alter} SET (n_distinct = #{n_distinct})" if n_distinct),
89
+ *("#{alter} RESET (n_distinct)" if n_distinct&.zero?),
90
+ *("#{alter} SET STORAGE #{storage.to_s.upcase}" if storage.present?),
91
+ ]
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#create_materialized_view(name, **options, &block)
4
+ # Create a materialized view
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the view
7
+ # @option [Boolean] :if_not_exists (false) Suppress the error when a view has been already created
8
+ # @option [#to_s] :sql_definition (nil) The snippet containing the query
9
+ # @option [#to_i] :version (nil)
10
+ # The alternative way to set sql_definition by referencing to a file containing the snippet
11
+ # @option [#to_s] :tablespace (nil) The tablespace for the view
12
+ # @option [Boolean] :with_data (true) If the view should be populated after creation
13
+ # @option [#to_s] :comment (nil) The comment describing the view
14
+ # @yield [Proc] the block with the view's definition
15
+ # @yieldparam The receiver of methods specifying the view
16
+ #
17
+ # The operation creates the view using its `sql_definition`:
18
+ #
19
+ # create_materialized_view("views.admin_users", sql_definition: <<~SQL)
20
+ # SELECT id, name FROM users WHERE admin;
21
+ # SQL
22
+ #
23
+ # For compatibility to the `scenic` gem, we also support
24
+ # adding a definition via its version:
25
+ #
26
+ # create_materialized_view "admin_users", version: 1
27
+ #
28
+ # It is expected, that a `db/materialized_views/admin_users_v01.sql`
29
+ # to contain the SQL snippet.
30
+ #
31
+ # The tablespace can be specified for the created view.
32
+ # Notice that later it can't be changed (in PostgreSQL all rows
33
+ # can be moved to another tablespace, but we don't support
34
+ # this feature yet).
35
+ #
36
+ # create_materialized_view "admin_users" do |v|
37
+ # v.tablespace "fast_ssd"
38
+ # v.sql_definition <<~SQL
39
+ # SELECT id, name, password, admin, on_duty
40
+ # FROM users
41
+ # WHERE admin
42
+ # SQL
43
+ # end
44
+ #
45
+ # You can also set a comment describing the view,
46
+ # and redefine the storage options for some TOAST-ed columns,
47
+ # as well as their custom statistics:
48
+ #
49
+ # create_materialized_view "admin_users" do |v|
50
+ # v.sql_definition <<~SQL
51
+ # SELECT id, name, password, admin, on_duty
52
+ # FROM users
53
+ # WHERE admin
54
+ # SQL
55
+ #
56
+ # v.column "password", storage: "external" # to avoid compression
57
+ # v.column "password", n_distinct: -1 # linear dependency
58
+ # v.column "admin", n_distinct: 1 # exact number of values
59
+ # v.column "on_duty", statistics: 2 # the total number of values
60
+ #
61
+ # v.comment "Admin users only"
62
+ # end
63
+ #
64
+ # With the `replace_existing: true` option the operation
65
+ # would use `CREATE OR REPLACE VIEW` command, so it
66
+ # can be used to "update" (or reload) the existing view.
67
+ #
68
+ # create_materialized_view "admin_users",
69
+ # version: 1,
70
+ # replace_existing: true
71
+ #
72
+ # This option makes the migration irreversible due to uncertainty
73
+ # of the previous state of the database.
74
+
75
+ module PGTrunk::Operations::MaterializedViews
76
+ # @private
77
+ class CreateMaterializedView < Base
78
+ validates :sql_definition, presence: true
79
+ # Forbid these attributes
80
+ validates :algorithm, :cluster_on, :force, :if_exists, :new_name, absence: true
81
+
82
+ from_sql do |_version|
83
+ <<~SQL
84
+ SELECT
85
+ c.oid,
86
+ (c.relnamespace::regnamespace || '.' || c.relname) AS name,
87
+ t.spcname AS "tablespace",
88
+ replace(pg_get_viewdef(c.oid, 60), ';', '') AS sql_definition,
89
+ (CASE WHEN NOT m.ispopulated THEN false END) AS with_data,
90
+ (
91
+ SELECT
92
+ json_agg(
93
+ json_build_object(
94
+ 'name', a.attname,
95
+ 'storage', (
96
+ CASE
97
+ WHEN a.attstorage = 'p' THEN 'plain'
98
+ WHEN a.attstorage = 'e' THEN 'external'
99
+ WHEN a.attstorage = 'x' THEN 'extended'
100
+ WHEN a.attstorage = 'm' THEN 'main'
101
+ END
102
+ )
103
+ ) ORDER BY a.attnum
104
+ )
105
+ FROM pg_attribute a LEFT JOIN pg_type t ON t.oid = a.atttypid
106
+ WHERE c.oid = a.attrelid AND t.typstorage != a.attstorage
107
+ ) AS "columns",
108
+ d.description AS comment
109
+ FROM pg_class c
110
+ JOIN pg_trunk e ON e.oid = c.oid AND e.classid = 'pg_class'::regclass
111
+ JOIN pg_matviews m ON m.matviewname = c.relname
112
+ AND m.schemaname::regnamespace = c.relnamespace::regnamespace
113
+ LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
114
+ LEFT JOIN pg_description d ON d.objoid = c.oid
115
+ AND d.classoid = 'pg_class'::regclass
116
+ WHERE c.relkind = 'm';
117
+ SQL
118
+ end
119
+
120
+ def to_sql(_version)
121
+ [create_view, *alter_columns, *create_comment, register_view].join(" ")
122
+ end
123
+
124
+ def invert
125
+ irreversible!("if_not_exists: true") if if_not_exists
126
+ DropMaterializedView.new(name: name)
127
+ end
128
+
129
+ private
130
+
131
+ def create_view
132
+ sql = "CREATE MATERIALIZED VIEW"
133
+ sql << " IF NOT EXISTS" if if_not_exists
134
+ sql << " #{name.to_sql}"
135
+ sql << " TABLESPACE #{tablespace.inspect}" if tablespace.present?
136
+ sql << " AS #{sql_definition}"
137
+ sql << " WITH NO DATA" if with_data == false
138
+ sql << ";"
139
+ end
140
+
141
+ def alter_columns
142
+ return if columns.blank?
143
+
144
+ sql = "ALTER MATERIALIZED VIEW #{name.to_sql}"
145
+ sql << columns.flat_map(&:to_sql).join(", ")
146
+ sql << ";"
147
+ end
148
+
149
+ def create_comment
150
+ return if comment.blank?
151
+
152
+ <<~SQL
153
+ COMMENT ON MATERIALIZED VIEW #{name.to_sql}
154
+ IS $comment$#{comment}$comment$;
155
+ SQL
156
+ end
157
+
158
+ def register_view
159
+ <<~SQL.squish
160
+ INSERT INTO pg_trunk (oid, classid)
161
+ SELECT oid, 'pg_class'::regclass
162
+ FROM pg_class
163
+ WHERE relname = #{name.quoted}
164
+ AND relnamespace = #{name.namespace}
165
+ AND relkind = 'm'
166
+ ON CONFLICT DO NOTHING;
167
+ SQL
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#drop_materialized_view(name, **options, &block)
4
+ # Drop a materialized view
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the view
7
+ # @option [Boolean] :if_exists (false) Suppress the error when the view is absent
8
+ # @option [Symbol] :force (:restrict) How to process dependent objects (`:cascade` or `:restrict`)
9
+ # @option [#to_s] :sql_definition (nil) The snippet containing the query
10
+ # @option [#to_i] :revert_to_version (nil)
11
+ # The alternative way to set sql_definition by referencing to a file containing the snippet
12
+ # @option [#to_s] :tablespace (nil) The tablespace for the view
13
+ # @option [Boolean] :with_data (true) If the view should be populated after creation
14
+ # @option [#to_s] :comment (nil) The comment describing the view
15
+ # @yield [Proc] the block with the view's definition
16
+ # @yieldparam The receiver of methods specifying the view
17
+ #
18
+ # The operation drops a materialized view identified by its
19
+ # qualified name (it can include a schema).
20
+ #
21
+ # drop_materialized_view "views.admin_users"
22
+ #
23
+ # To make the operation invertible, use the same options
24
+ # as in the `create_view` operation.
25
+ #
26
+ # drop_materialized_view "views.admin_users" do |v|
27
+ # v.sql_definition "SELECT name, password FROM users WHERE admin;"
28
+ # v.column "password", storage: "external" # prevent compression
29
+ # v.with_data false
30
+ # v.comment "Admin users only"
31
+ # end
32
+ #
33
+ # You can also use a version-base SQL definition like:
34
+ #
35
+ # drop_materialized_view "admin_users", revert_to_version: 1
36
+ #
37
+ # With the `force: :cascade` option the operation would remove
38
+ # all the objects which depend on the view.
39
+ #
40
+ # drop_materialized_view "admin_users", force: :cascade
41
+ #
42
+ # With the `if_exists: true` option the operation won't fail
43
+ # even when the view was absent in the database.
44
+ #
45
+ # drop_materialized_view "admin_users", if_exists: true
46
+ #
47
+ # Both options make a migration irreversible due to uncertainty
48
+ # of the previous state of the database.
49
+
50
+ module PGTrunk::Operations::MaterializedViews
51
+ # @private
52
+ class DropMaterializedView < Base
53
+ # Forbid these attributes
54
+ validates :algorithm, :cluster_on, :if_not_exists, :new_name, absence: true
55
+
56
+ def to_sql(_version)
57
+ sql = "DROP MATERIALIZED VIEW"
58
+ sql << " IF EXISTS" if if_exists
59
+ sql << " #{name.to_sql}"
60
+ sql << " CASCADE" if force == :cascade
61
+ sql << ";"
62
+ end
63
+
64
+ def invert
65
+ irreversible!("if_exists: true") if if_exists
66
+ irreversible!("force: :cascade") if force == :cascade
67
+ CreateMaterializedView.new(**to_h.except(:force))
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: false
2
+
3
+ # @!method ActiveRecord::Migration#refresh_materialized_view(name, **options)
4
+ # Refresh a materialized view
5
+ #
6
+ # @param [#to_s] name (nil) The qualified name of the view
7
+ # @option [Boolean] :with_data (true) If the view should be populated after creation
8
+ # @option [Symbol] :algorithm (nil) Makes the operation concurrent when set to :concurrently
9
+ #
10
+ # The operation enables refreshing a materialized view
11
+ # by reloading its underlying SQL query:
12
+ #
13
+ # refresh_materialized_view "admin_users"
14
+ #
15
+ # The option `algorithm: :concurrently` acts exactly
16
+ # like in the `create_index` definition. You should
17
+ # possibly add the `disable_ddl_transaction!` command
18
+ # to the migration as well.
19
+ #
20
+ # With option `with_data: false` the command won't
21
+ # update the data. This option can't be used along with
22
+ # the `:algorithm`.
23
+ #
24
+ # The operation is always reversible, though its
25
+ # inversion does nothing.
26
+
27
+ module PGTrunk::Operations::MaterializedViews
28
+ # @private
29
+ class RefreshMaterializedView < Base
30
+ validate do
31
+ errors.add :algorithm, :present if with_data == false && algorithm
32
+ end
33
+ validates :cluster_on, :columns, :force, :if_exists, :if_not_exists,
34
+ :new_name, :sql_definition, :tablespace, :version, :comment,
35
+ absence: true
36
+
37
+ def to_sql(_version)
38
+ sql = "REFRESH MATERIALIZED VIEW"
39
+ sql << " CONCURRENTLY" if algorithm == :concurrently
40
+ sql << " #{name.to_sql}"
41
+ sql << " WITH NO DATA" if with_data == false
42
+ sql << ";"
43
+ end
44
+
45
+ # The operation is reversible but its inversion does nothing
46
+ def invert; end
47
+ end
48
+ end