pg_trunk 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +87 -0
- data/.gitignore +9 -0
- data/.rspec +4 -0
- data/.rubocop.yml +92 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +31 -0
- data/CONTRIBUTING.md +17 -0
- data/Gemfile +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +16 -0
- data/bin/console +8 -0
- data/bin/rake +19 -0
- data/bin/rspec +19 -0
- data/bin/setup +8 -0
- data/bin/yard +19 -0
- data/lib/pg_trunk/core/adapters/postgres.rb +80 -0
- data/lib/pg_trunk/core/dependencies_resolver.rb +101 -0
- data/lib/pg_trunk/core/generators.rb +140 -0
- data/lib/pg_trunk/core/operation/attributes.rb +78 -0
- data/lib/pg_trunk/core/operation/callbacks.rb +40 -0
- data/lib/pg_trunk/core/operation/generators.rb +51 -0
- data/lib/pg_trunk/core/operation/inversion.rb +70 -0
- data/lib/pg_trunk/core/operation/registration.rb +55 -0
- data/lib/pg_trunk/core/operation/ruby_builder.rb +112 -0
- data/lib/pg_trunk/core/operation/ruby_helpers.rb +99 -0
- data/lib/pg_trunk/core/operation/sql_helpers.rb +44 -0
- data/lib/pg_trunk/core/operation/validations.rb +21 -0
- data/lib/pg_trunk/core/operation.rb +78 -0
- data/lib/pg_trunk/core/qualified_name.rb +165 -0
- data/lib/pg_trunk/core/railtie/command_recorder.rb +30 -0
- data/lib/pg_trunk/core/railtie/custom_types.rb +37 -0
- data/lib/pg_trunk/core/railtie/migration.rb +50 -0
- data/lib/pg_trunk/core/railtie/migrator.rb +22 -0
- data/lib/pg_trunk/core/railtie/schema_dumper.rb +75 -0
- data/lib/pg_trunk/core/railtie/schema_migration.rb +22 -0
- data/lib/pg_trunk/core/railtie/statements.rb +21 -0
- data/lib/pg_trunk/core/railtie.rb +35 -0
- data/lib/pg_trunk/core/registry.rb +159 -0
- data/lib/pg_trunk/core/serializers/array_of_hashes_serializer.rb +28 -0
- data/lib/pg_trunk/core/serializers/array_of_strings_serializer.rb +29 -0
- data/lib/pg_trunk/core/serializers/array_of_symbols_serializer.rb +28 -0
- data/lib/pg_trunk/core/serializers/array_serializer.rb +22 -0
- data/lib/pg_trunk/core/serializers/lowercase_string_serializer.rb +21 -0
- data/lib/pg_trunk/core/serializers/multiline_text_serializer.rb +21 -0
- data/lib/pg_trunk/core/serializers/qualified_name_serializer.rb +27 -0
- data/lib/pg_trunk/core/serializers/symbol_serializer.rb +22 -0
- data/lib/pg_trunk/core/serializers.rb +16 -0
- data/lib/pg_trunk/core/validators/all_items_valid_validator.rb +15 -0
- data/lib/pg_trunk/core/validators/difference_validator.rb +19 -0
- data/lib/pg_trunk/core/validators.rb +10 -0
- data/lib/pg_trunk/core.rb +21 -0
- data/lib/pg_trunk/generators.rb +7 -0
- data/lib/pg_trunk/operations/check_constraints/add_check_constraint.rb +109 -0
- data/lib/pg_trunk/operations/check_constraints/base.rb +69 -0
- data/lib/pg_trunk/operations/check_constraints/drop_check_constraint.rb +60 -0
- data/lib/pg_trunk/operations/check_constraints/rename_check_constraint.rb +54 -0
- data/lib/pg_trunk/operations/check_constraints/validate_check_constraint.rb +39 -0
- data/lib/pg_trunk/operations/check_constraints.rb +14 -0
- data/lib/pg_trunk/operations/composite_types/base.rb +61 -0
- data/lib/pg_trunk/operations/composite_types/change_composite_type.rb +136 -0
- data/lib/pg_trunk/operations/composite_types/column.rb +118 -0
- data/lib/pg_trunk/operations/composite_types/create_composite_type.rb +99 -0
- data/lib/pg_trunk/operations/composite_types/drop_composite_type.rb +67 -0
- data/lib/pg_trunk/operations/composite_types/rename_composite_type.rb +44 -0
- data/lib/pg_trunk/operations/composite_types.rb +15 -0
- data/lib/pg_trunk/operations/domains/base.rb +46 -0
- data/lib/pg_trunk/operations/domains/change_domain.rb +140 -0
- data/lib/pg_trunk/operations/domains/constraint.rb +93 -0
- data/lib/pg_trunk/operations/domains/create_domain.rb +124 -0
- data/lib/pg_trunk/operations/domains/drop_domain.rb +65 -0
- data/lib/pg_trunk/operations/domains/rename_domain.rb +44 -0
- data/lib/pg_trunk/operations/domains.rb +15 -0
- data/lib/pg_trunk/operations/enums/base.rb +47 -0
- data/lib/pg_trunk/operations/enums/change.rb +55 -0
- data/lib/pg_trunk/operations/enums/change_enum.rb +119 -0
- data/lib/pg_trunk/operations/enums/create_enum.rb +83 -0
- data/lib/pg_trunk/operations/enums/drop_enum.rb +63 -0
- data/lib/pg_trunk/operations/enums/rename_enum.rb +44 -0
- data/lib/pg_trunk/operations/enums.rb +15 -0
- data/lib/pg_trunk/operations/foreign_keys/add_foreign_key.rb +174 -0
- data/lib/pg_trunk/operations/foreign_keys/base.rb +155 -0
- data/lib/pg_trunk/operations/foreign_keys/drop_foreign_key.rb +76 -0
- data/lib/pg_trunk/operations/foreign_keys/rename_foreign_key.rb +63 -0
- data/lib/pg_trunk/operations/foreign_keys.rb +16 -0
- data/lib/pg_trunk/operations/functions/base.rb +54 -0
- data/lib/pg_trunk/operations/functions/change_function.rb +108 -0
- data/lib/pg_trunk/operations/functions/create_function.rb +198 -0
- data/lib/pg_trunk/operations/functions/drop_function.rb +88 -0
- data/lib/pg_trunk/operations/functions/rename_function.rb +57 -0
- data/lib/pg_trunk/operations/functions.rb +14 -0
- data/lib/pg_trunk/operations/indexes/add_index.rb +68 -0
- data/lib/pg_trunk/operations/indexes.rb +10 -0
- data/lib/pg_trunk/operations/materialized_views/base.rb +79 -0
- data/lib/pg_trunk/operations/materialized_views/change_materialized_view.rb +139 -0
- data/lib/pg_trunk/operations/materialized_views/column.rb +94 -0
- data/lib/pg_trunk/operations/materialized_views/create_materialized_view.rb +170 -0
- data/lib/pg_trunk/operations/materialized_views/drop_materialized_view.rb +70 -0
- data/lib/pg_trunk/operations/materialized_views/refresh_materialized_view.rb +48 -0
- data/lib/pg_trunk/operations/materialized_views/rename_materialized_view.rb +61 -0
- data/lib/pg_trunk/operations/materialized_views.rb +17 -0
- data/lib/pg_trunk/operations/procedures/base.rb +42 -0
- data/lib/pg_trunk/operations/procedures/change_procedure.rb +107 -0
- data/lib/pg_trunk/operations/procedures/create_procedure.rb +146 -0
- data/lib/pg_trunk/operations/procedures/drop_procedure.rb +66 -0
- data/lib/pg_trunk/operations/procedures/rename_procedure.rb +57 -0
- data/lib/pg_trunk/operations/procedures.rb +14 -0
- data/lib/pg_trunk/operations/statistics/base.rb +94 -0
- data/lib/pg_trunk/operations/statistics/create_statistics.rb +181 -0
- data/lib/pg_trunk/operations/statistics/drop_statistics.rb +75 -0
- data/lib/pg_trunk/operations/statistics/rename_statistics.rb +48 -0
- data/lib/pg_trunk/operations/statistics.rb +13 -0
- data/lib/pg_trunk/operations/tables/create_table.rb +75 -0
- data/lib/pg_trunk/operations/tables.rb +10 -0
- data/lib/pg_trunk/operations/triggers/base.rb +119 -0
- data/lib/pg_trunk/operations/triggers/change_trigger.rb +82 -0
- data/lib/pg_trunk/operations/triggers/create_trigger.rb +208 -0
- data/lib/pg_trunk/operations/triggers/drop_trigger.rb +66 -0
- data/lib/pg_trunk/operations/triggers/rename_trigger.rb +71 -0
- data/lib/pg_trunk/operations/triggers.rb +14 -0
- data/lib/pg_trunk/operations/views/base.rb +38 -0
- data/lib/pg_trunk/operations/views/change_view.rb +90 -0
- data/lib/pg_trunk/operations/views/create_view.rb +115 -0
- data/lib/pg_trunk/operations/views/drop_view.rb +69 -0
- data/lib/pg_trunk/operations/views/rename_view.rb +58 -0
- data/lib/pg_trunk/operations/views.rb +14 -0
- data/lib/pg_trunk/operations.rb +23 -0
- data/lib/pg_trunk/version.rb +6 -0
- data/lib/pg_trunk.rb +27 -0
- data/pg_trunk.gemspec +34 -0
- data/spec/dummy/.gitignore +16 -0
- data/spec/dummy/Rakefile +15 -0
- data/spec/dummy/bin/bundle +6 -0
- data/spec/dummy/bin/rails +6 -0
- data/spec/dummy/bin/rake +6 -0
- data/spec/dummy/config/application.rb +18 -0
- data/spec/dummy/config/boot.rb +7 -0
- data/spec/dummy/config/database.yml +14 -0
- data/spec/dummy/config/environment.rb +7 -0
- data/spec/dummy/config.ru +6 -0
- data/spec/dummy/db/materialized_views/admin_users_v01.sql +1 -0
- data/spec/dummy/db/migrate/.keep +0 -0
- data/spec/dummy/db/schema.rb +18 -0
- data/spec/dummy/db/views/admin_users_v01.sql +1 -0
- data/spec/dummy/db/views/admin_users_v02.sql +1 -0
- data/spec/operations/check_constraints/add_check_constraint_spec.rb +85 -0
- data/spec/operations/check_constraints/drop_check_constraint_spec.rb +111 -0
- data/spec/operations/check_constraints/rename_check_constraint_spec.rb +90 -0
- data/spec/operations/composite_types/change_composite_type_spec.rb +257 -0
- data/spec/operations/composite_types/create_composite_type_spec.rb +55 -0
- data/spec/operations/composite_types/drop_composite_type_spec.rb +109 -0
- data/spec/operations/composite_types/rename_composite_type_spec.rb +74 -0
- data/spec/operations/dependency_resolver_spec.rb +177 -0
- data/spec/operations/domains/change_domain_spec.rb +287 -0
- data/spec/operations/domains/create_domain_spec.rb +69 -0
- data/spec/operations/domains/drop_domain_spec.rb +119 -0
- data/spec/operations/domains/rename_domain_spec.rb +70 -0
- data/spec/operations/enums/change_enum_spec.rb +157 -0
- data/spec/operations/enums/create_enum_spec.rb +40 -0
- data/spec/operations/enums/drop_enum_spec.rb +120 -0
- data/spec/operations/enums/rename_enum_spec.rb +72 -0
- data/spec/operations/foreign_keys/add_foreign_key_spec.rb +208 -0
- data/spec/operations/foreign_keys/drop_foreign_key_spec.rb +167 -0
- data/spec/operations/foreign_keys/rename_foreign_key_spec.rb +101 -0
- data/spec/operations/functions/change_function_spec.rb +166 -0
- data/spec/operations/functions/create_function_spec.rb +192 -0
- data/spec/operations/functions/drop_function_spec.rb +182 -0
- data/spec/operations/functions/rename_function_spec.rb +101 -0
- data/spec/operations/indexes/add_index_spec.rb +94 -0
- data/spec/operations/materialized_views/change_materialized_view_spec.rb +190 -0
- data/spec/operations/materialized_views/create_materialized_view_spec.rb +144 -0
- data/spec/operations/materialized_views/drop_materialized_view_spec.rb +145 -0
- data/spec/operations/materialized_views/refresh_materialized_view_spec.rb +79 -0
- data/spec/operations/materialized_views/rename_materialized_view_spec.rb +88 -0
- data/spec/operations/procedures/change_procedure_spec.rb +175 -0
- data/spec/operations/procedures/create_procedure_spec.rb +151 -0
- data/spec/operations/procedures/drop_procedure_spec.rb +159 -0
- data/spec/operations/procedures/rename_procedure_spec.rb +107 -0
- data/spec/operations/statistics/create_statistics_spec.rb +230 -0
- data/spec/operations/statistics/drop_statistics_spec.rb +106 -0
- data/spec/operations/statistics/rename_statistics_spec.rb +129 -0
- data/spec/operations/tables/create_table_spec.rb +53 -0
- data/spec/operations/tables/rename_table_spec.rb +37 -0
- data/spec/operations/triggers/change_trigger_spec.rb +195 -0
- data/spec/operations/triggers/create_trigger_spec.rb +104 -0
- data/spec/operations/triggers/drop_trigger_spec.rb +124 -0
- data/spec/operations/triggers/rename_trigger_spec.rb +160 -0
- data/spec/operations/views/change_view_spec.rb +144 -0
- data/spec/operations/views/create_view_spec.rb +134 -0
- data/spec/operations/views/drop_view_spec.rb +146 -0
- data/spec/operations/views/rename_view_spec.rb +85 -0
- data/spec/pg_trunk/dependencies_resolver_spec.rb +43 -0
- data/spec/spec_helper.rb +28 -0
- data/spec/support/migrations_helper.rb +376 -0
- 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,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
|