pg_trunk 0.1.0

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.
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