Skip to content

Commit

Permalink
Make enum column definitions db-agnostic
Browse files Browse the repository at this point in the history
This is done by introducing a values option to specify the values of an enum for MySQL adapters.
Using the values option in PostgreSQL would allow implicit creation of enum types without
create_enum. If no enum_type was specified, the name of the enum will default to the column
name. Enums in SQLite are represented as strings. Loading a schema with enum columns are
now supported with any adapter.

    def up
      create_table :cats do |t|
        t.enum :current_mood, enum_type: mood, values: [happy, sad], default: happy, null: false
      end
    end
  • Loading branch information
jenshenny committed Feb 3, 2025
1 parent ce96809 commit d662e54
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 186 deletions.
17 changes: 17 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
* Introduce enum column type to MySQL and SQLite

All database adapters now support the `enum` column type to allow database
agnostic operations. To specify values for an enum column in MySQL adapters,
use the `values` option. Using the `values` option in PostgreSQL would allow
implicit creation of enum types. Enums in SQLite are represented as strings.

```ruby
def up
create_table :cats do |t|
t.enum :current_mood, enum_type: "mood", values: ["happy", "sad"], default: "happy", null: false
end
end
```

*Jenny Shen*

* Introduce a before-fork hook in `ActiveSupport::Testing::Parallelization` to clear existing
connections, to avoid fork-safety issues with the mysql2 adapter.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def visit_AlterTable(o)
end

def visit_ColumnDefinition(o)
o.sql_type = type_to_sql(o.type, **o.options)
o.sql_type = type_to_sql(o.type, column_name: o.name, **o.options)
column_sql = +"#{quote_column_name(o.name)} #{o.sql_type}"
add_column_options!(column_sql, column_options(o)) unless o.type == :primary_key
column_sql
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def concise_options(options)
:comment,
:primary_key,
:if_exists,
:if_not_exists
:if_not_exists,
:values,
:enum_type
]

def primary_key?
Expand Down Expand Up @@ -339,7 +341,7 @@ def primary_key(name, type = :primary_key, **options)
#
# See TableDefinition#column

define_column_methods :bigint, :binary, :boolean, :date, :datetime, :decimal,
define_column_methods :bigint, :binary, :boolean, :date, :datetime, :decimal, :enum,
:float, :integer, :json, :string, :text, :time, :timestamp, :virtual

alias :blob :binary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class AbstractMysqlAdapter < AbstractAdapter
blob: { name: "blob" },
boolean: { name: "boolean" },
json: { name: "json" },
enum: {}, # set dynamically based on values spec
}

class StatementPool < ConnectionAdapters::StatementPool # :nodoc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ def prepare_column_options(column)
spec[:unsigned] = "true" if column.unsigned?
spec[:auto_increment] = "true" if column.auto_increment?

if /\A(?<size>tiny|medium|long)(?:text|blob)/ =~ column.sql_type
spec = { size: size.to_sym.inspect }.merge!(spec)
case column.sql_type
when /\A(?<size>tiny|medium|long)(?:text|blob)/
spec = { size: $~[:size].to_sym.inspect }.merge!(spec)
when /\Aenum\((?<values>.*)\)\z/
spec[:values] = $~[:values].split(",").map { |value| value.tr("'", "") }
end

if @connection.supports_virtual_columns? && column.virtual?
Expand Down Expand Up @@ -41,8 +44,10 @@ def schema_type(column)
case column.sql_type
when /\Atimestamp\b/
:timestamp
when /\A(?:enum|set)\b/
when /\Aset\b/
column.sql_type
when /\Aenum\b/
:enum
else
super
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def create_schema_dumper(options)
end

# Maps logical Rails types to MySQL-specific data types.
def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, **)
def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, values: nil, **)
sql =
case type.to_s
when "integer"
Expand All @@ -124,6 +124,10 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_siz
else
type_with_size_to_sql("blob", size)
end
when "enum"
raise ArgumentError, "values are required for enums" if values.nil?

"ENUM(#{values.map { |value| "'#{value}'" }.join(",")})"
else
super
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,30 @@ module ConnectionAdapters
module PostgreSQL
class SchemaCreation < SchemaCreation # :nodoc:
private
delegate :quoted_include_columns_for_index, to: :@conn
delegate :quoted_include_columns_for_index, :create_enum, to: :@conn

def visit_TableDefinition(o)
create_enums(o.columns)
super
end

def visit_AlterTable(o)
create_enums(o.adds.map(&:column))
sql = super
sql << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ")
sql << o.exclusion_constraint_adds.map { |con| visit_AddExclusionConstraint con }.join(" ")
sql << o.unique_constraint_adds.map { |con| visit_AddUniqueConstraint con }.join(" ")
end

def create_enums(columns)
columns.each do |c|
next unless c.type == :enum && c.options[:values]

enum_type = c.options[:enum_type] || c.name
create_enum(enum_type, c.options[:values])
end
end

def visit_AddForeignKey(o)
super.dup.tap do |sql|
sql << " NOT VALID" unless o.validate?
Expand Down Expand Up @@ -77,7 +92,7 @@ def visit_AddUniqueConstraint(o)

def visit_ChangeColumnDefinition(o)
column = o.column
column.sql_type = type_to_sql(column.type, **column.options)
column.sql_type = type_to_sql(column.type, column_name: column.name, **column.options)
quoted_column_name = quote_column_name(o.name)

change_column_sql = +"ALTER COLUMN #{quoted_column_name} TYPE #{column.sql_type}"
Expand All @@ -91,7 +106,7 @@ def visit_ChangeColumnDefinition(o)
if options[:using]
change_column_sql << " USING #{options[:using]}"
elsif options[:cast_as]
cast_as_type = type_to_sql(options[:cast_as], **options)
cast_as_type = type_to_sql(options[:cast_as], column_name: column.name, **options)
change_column_sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})"
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def primary_key(name, type = :primary_key, **options)
define_column_methods :bigserial, :bit, :bit_varying, :cidr, :citext, :daterange,
:hstore, :inet, :interval, :int4range, :int8range, :jsonb, :ltree, :macaddr,
:money, :numrange, :oid, :point, :line, :lseg, :box, :path, :polygon, :circle,
:serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml, :timestamptz, :enum
:serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml, :timestamptz
end

ExclusionConstraintDefinition = Struct.new(:table_name, :expression, :options) do
Expand Down Expand Up @@ -282,7 +282,7 @@ def new_column_definition(name, type, **options) # :nodoc:

private
def valid_column_definition_options
super + [:array, :using, :cast_as, :as, :type, :enum_type, :stored]
super + [:array, :using, :cast_as, :as, :type, :stored]
end

def aliased_types(name, fallback)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ def prepare_column_options(column)
spec = { type: schema_type(column).inspect }.merge!(spec)
end

spec[:enum_type] = column.sql_type.inspect if column.enum?
if column.enum?
spec[:enum_type] = column.sql_type.inspect unless column.name == column.sql_type
_name, values = @connection.enum_types.find { |name, _values| name == column.sql_type }
spec[:values] = values.inspect
end

spec
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ def remove_unique_constraint(table_name, column_name = nil, **options)
end

# Maps logical Rails types to PostgreSQL-specific data types.
def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, enum_type: nil, **) # :nodoc:
def type_to_sql(type, column_name: nil, limit: nil, precision: nil, scale: nil, array: nil, enum_type: nil, values: nil, **) # :nodoc:
sql = \
case type.to_s
when "binary"
Expand All @@ -873,9 +873,9 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, enum_t
else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
end
when "enum"
raise ArgumentError, "enum_type is required for enums" if enum_type.nil?
raise ArgumentError, "enum_type or values is required for enums" if enum_type.nil? && values.nil?

enum_type
enum_type || column_name
else
super
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,11 @@ def create_enum(name, values, **options)
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE t.typname = #{scope[:name]}
AND n.nspname = #{scope[:schema]}
AND (
SELECT array_agg(e.enumlabel ORDER BY e.enumsortorder)
FROM pg_enum e
WHERE e.enumtypid = t.oid
) = ARRAY[#{sql_values}]::name[]
) THEN
CREATE TYPE #{quote_table_name(name)} AS ENUM (#{sql_values});
END IF;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def dbconsole(config, options = {})
binary: { name: "blob" },
boolean: { name: "boolean" },
json: { name: "json" },
enum: { name: "varchar" },
}

DEFAULT_PRAGMAS = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,66 @@
# frozen_string_literal: true

require "cases/helper"
require "support/schema_dumping_helper"
require "cases/enum_shared_test_cases"

class MySQLEnumTest < ActiveRecord::AbstractMysqlTestCase
self.use_transactional_tests = false

include SchemaDumpingHelper

class EnumTest < ActiveRecord::Base
attribute :state, :integer

enum :state, {
start: 0,
middle: 1,
finish: 2
}
end

def setup
EnumTest.lease_connection.create_table :enum_tests, id: false, force: true do |t|
t.column :enum_column, "enum('text','blob','tiny','medium','long','unsigned','bigint')"
t.column :state, "TINYINT(1)"
end
end
module MySQLSharedEnumTestCases
include SharedEnumTestCases

def test_should_not_be_unsigned
column = EnumTest.columns_hash["enum_column"]
column = EnumTest.columns_hash["current_mood"]
assert_not_predicate column, :unsigned?
end

def test_should_not_be_bigint
column = EnumTest.columns_hash["enum_column"]
column = EnumTest.columns_hash["current_mood"]
assert_not_predicate column, :bigint?
end

def test_schema_dumping
schema = dump_table_schema "enum_tests"
assert_match %r{t\.column "enum_column", "enum\('text','blob','tiny','medium','long','unsigned','bigint'\)"$}, schema
assert_match %r{t\.enum "current_mood", default: "sad", values: \["sad", "ok", "happy"\]}, schema
end
end

class MySQLEnumTest < ActiveRecord::AbstractMysqlTestCase
include MySQLSharedEnumTestCases

self.use_transactional_tests = false

def test_enum_with_attribute
enum_test = EnumTest.create!(state: :middle)
assert_equal "middle", enum_test.state
def setup
@connection = ActiveRecord::Base.lease_connection
@connection.create_table :enum_tests, force: true do |t|
t.column :current_mood, 'enum("sad","ok","happy")', default: "sad"
end
end

def test_schema_load
original, $stdout = $stdout, StringIO.new

ActiveRecord::Schema.define do
create_enum :color, ["blue", "green"]

change_table :enum_tests do |t|
t.enum :best_color, enum_type: "color", values: ["blue", "green"], default: "blue", null: false
end
end

assert @connection.column_exists?(:enum_tests, :best_color, "string", values: ["blue", "green"], default: "blue", null: false)
ensure
$stdout = original
end

def test_enum_column_without_values_raises_error
error = assert_raises(ArgumentError) do
@connection.add_column :enum_tests, :best_color, :enum, null: false
end

assert_equal "values are required for enums", error.message
end
end

class MySQLEnumWithValuesTest < ActiveRecord::AbstractMysqlTestCase
include MySQLSharedEnumTestCases

self.use_transactional_tests = false
end
Loading

0 comments on commit d662e54

Please sign in to comment.