Adding PG enum rename, add value, and rename value migration helpers

This commit is contained in:
Ray Faddis 2022-04-14 09:50:15 -04:00
parent e10e35dd32
commit ce6047f84f
7 changed files with 182 additions and 13 deletions

View File

@ -1,3 +1,25 @@
* Added PostgreSQL migration commands for enum rename, add value, and rename value.
`rename_enum` and `rename_enum_value` are reversible. Due to Postgres
limitation, `add_enum_value` is not reversible since you cannot delete enum
values. As an alternative you should drop and recreate the enum entirely.
```ruby
rename_enum :article_status, to: :article_state
```
```ruby
add_enum_value :article_state, "archived" # will be at the end of existing values
add_enum_value :article_state, "in review", before: "published"
add_enum_value :article_state, "approved", after: "in review"
```
```ruby
rename_enum_value :article_state, from: "archived", to: "deleted"
```
*Ray Faddis*
* Allow composite primary key to be derived from schema
Booting an application with a schema that contains composite primary keys

View File

@ -605,6 +605,18 @@ module ActiveRecord
def drop_enum(*) # :nodoc:
end
# This is meant to be implemented by the adapters that support custom enum types
def rename_enum(*) # :nodoc:
end
# This is meant to be implemented by the adapters that support custom enum types
def add_enum_value(*) # :nodoc:
end
# This is meant to be implemented by the adapters that support custom enum types
def rename_enum_value(*) # :nodoc:
end
def advisory_locks_enabled? # :nodoc:
supports_advisory_locks? && @advisory_locks_enabled
end

View File

@ -550,6 +550,43 @@ module ActiveRecord
internal_exec_query(query)
end
# Rename an existing enum type to something else.
def rename_enum(name, options = {})
to = options.fetch(:to) { raise ArgumentError, ":to is required" }
exec_query("ALTER TYPE #{quote_table_name(name)} RENAME TO #{to}").tap { reload_type_map }
end
# Add enum value to an existing enum type.
def add_enum_value(type_name, value, options = {})
before, after = options.values_at(:before, :after)
sql = +"ALTER TYPE #{quote_table_name(type_name)} ADD VALUE '#{value}'"
if before && after
raise ArgumentError, "Cannot have both :before and :after at the same time"
elsif before
sql << " BEFORE '#{before}'"
elsif after
sql << " AFTER '#{after}'"
end
execute(sql).tap { reload_type_map }
end
# Rename enum value on an existing enum type.
def rename_enum_value(type_name, options = {})
unless database_version >= 10_00_00 # >= 10.0
raise ArgumentError, "Renaming enum values is only supported in PostgreSQL 10 or later"
end
from = options.fetch(:from) { raise ArgumentError, ":from is required" }
to = options.fetch(:to) { raise ArgumentError, ":to is required" }
execute("ALTER TYPE #{quote_table_name(type_name)} RENAME VALUE '#{from}' TO '#{to}'").tap {
reload_type_map
}
end
# Returns the configured supported identifier length supported by PostgreSQL
def max_identifier_length
@max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i

View File

@ -38,6 +38,8 @@ module ActiveRecord
# * remove_reference
# * remove_timestamps
# * rename_column
# * rename_enum (must supply a +:to+ option)
# * rename_enum_value (must supply a +:from+ and +:to+ option)
# * rename_index
# * rename_table
class CommandRecorder
@ -52,7 +54,7 @@ module ActiveRecord
:add_check_constraint, :remove_check_constraint,
:add_exclusion_constraint, :remove_exclusion_constraint,
:add_unique_key, :remove_unique_key,
:create_enum, :drop_enum,
:create_enum, :drop_enum, :rename_enum, :add_enum_value, :rename_enum_value,
]
include JoinTable
@ -340,6 +342,26 @@ module ActiveRecord
super
end
def invert_rename_enum(args)
name, options = args
unless options.is_a?(Hash) && options.has_key?(:to)
raise ActiveRecord::IrreversibleMigration, "rename_enum is only reversible if given a :to option."
end
[:rename_enum, [options[:to], to: name]]
end
def invert_rename_enum_value(args)
type_name, options = args
unless options.is_a?(Hash) && options.has_key?(:from) && options.has_key?(:to)
raise ActiveRecord::IrreversibleMigration, "rename_enum_value is only reversible if given a :from and :to option."
end
[:rename_enum_value, [type_name, from: options[:to], to: options[:from]]]
end
def respond_to_missing?(method, _)
super || delegate.respond_to?(method)
end

View File

@ -111,6 +111,34 @@ class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase
assert_includes output, 't.enum "good_mood", default: "happy", null: false, enum_type: "mood"'
end
def test_schema_dump_renamed_enum
@connection.rename_enum :mood, to: :feeling
output = dump_table_schema("postgresql_enums")
assert_includes output, 'create_enum "feeling", ["sad", "ok", "happy"]'
assert_includes output, 't.enum "current_mood", enum_type: "feeling"'
end
def test_schema_dump_added_enum_value
@connection.add_enum_value :mood, :angry, before: :ok
@connection.add_enum_value :mood, :nervous, after: :ok
@connection.add_enum_value :mood, :glad
output = dump_table_schema("postgresql_enums")
assert_includes output, 'create_enum "mood", ["sad", "angry", "ok", "nervous", "happy", "glad"]'
end
def test_schema_dump_renamed_enum_value
@connection.rename_enum_value :mood, from: :ok, to: :okay
output = dump_table_schema("postgresql_enums")
assert_includes output, 'create_enum "mood", ["sad", "okay", "happy"]'
end
def test_schema_load
original, $stdout = $stdout, StringIO.new

View File

@ -512,6 +512,40 @@ module ActiveRecord
@recorder.inverse_of :drop_enum, [:color, if_exists: true]
end
end
def test_invert_rename_enum
enum = @recorder.inverse_of :rename_enum, [:dog_breed, to: :breed]
assert_equal [:rename_enum, [:breed, to: :dog_breed]], enum
end
def test_invert_rename_enum_without_to
assert_raises(ActiveRecord::IrreversibleMigration) do
@recorder.inverse_of :rename_enum, [:breed]
end
end
def test_invert_add_enum_value
assert_raises(ActiveRecord::IrreversibleMigration) do
@recorder.inverse_of :add_enum_value, [:dog_breed, :beagle]
end
end
def test_invert_rename_enum_value
enum_value = @recorder.inverse_of :rename_enum_value, [:dog_breed, from: :retriever, to: :beagle]
assert_equal [:rename_enum_value, [:dog_breed, from: :beagle, to: :retriever]], enum_value
end
def test_invert_rename_enum_value_without_from
assert_raises(ActiveRecord::IrreversibleMigration) do
@recorder.inverse_of :rename_enum_value, [:dog_breed, to: :retriever]
end
end
def test_invert_rename_enum_value_without_to
assert_raises(ActiveRecord::IrreversibleMigration) do
@recorder.inverse_of :rename_enum_value, [:dog_breed, from: :beagle]
end
end
end
end
end

View File

@ -314,25 +314,39 @@ irb> article.status = "deleted"
ArgumentError: 'deleted' is not a valid status
```
To add a new value (before or after an existing one) or to rename a value you should use [ALTER TYPE](https://www.postgresql.org/docs/current/static/sql-altertype.html):
To rename the enum you can use `rename_enum` along with updating any model
usage:
```ruby
# db/migrate/20150720144913_add_new_state_to_articles.rb
disable_ddl_transaction!
def up
execute <<-SQL
ALTER TYPE article_status ADD VALUE IF NOT EXISTS 'deleted' AFTER 'archived';
ALTER TYPE article_status RENAME VALUE 'archived' TO 'hidden';
SQL
# db/migrate/20150718144917_rename_article_status.rb
def change
rename_enum :article_status, to: :article_state
end
```
NOTE: `ALTER TYPE ... ADD VALUE` cannot be executed inside of a transaction block so here we are using `disable_ddl_transaction!`
To add a new value you can use `add_enum_value`:
WARNING. Enum values [can't be dropped or reordered](https://www.postgresql.org/docs/current/datatype-enum.html). Adding a value is not easily reversed.
```ruby
# db/migrate/20150720144913_add_new_state_to_articles.rb
def up
add_enum_value :article_state, "archived", # will be at the end after published
add_enum_value :article_state, "in review", before: "published"
add_enum_value :article_state, "approved", after: "in review"
end
```
Hint: to show all the values of the all enums you have, you should call this query in `bin/rails db` or `psql` console:
NOTE: Enum values can't be dropped or renamed which also means add_enum_value is irreversible. You can read why [here](https://www.postgresql.org/message-id/29F36C7C98AB09499B1A209D48EAA615B7653DBC8A@mail2a.alliedtesting.com).
To rename a value you can use `rename_enum_value`:
```ruby
# db/migrate/20150722144915_rename_article_state.rb
def change
rename_enum_value :article_state, from: "archived", to: "deleted"
end
```
Hint: to show all the values of the all enums you have, you can call this query in `bin/rails db` or `psql` console:
```sql
SELECT n.nspname AS enum_schema,