Skip to content

Commit 4eef348

Browse files
committed
PostgreSQL: support custom enum types
Something I've seen on many Rails + PostgreSQL apps is that the only reason `structure.sql` is used instead of `schema.rb` is to support custom [enum types](https://www.postgresql.org/docs/current/datatype-enum.html). Enum types are great for type integrity and they work well with [`ActiveRecord::Enum`](https://api.rubyonrails.org/classes/ActiveRecord/Enum.html). And schema.rb is much easier to use than structure.sql. It would be great if more PostgreSQL projects could use schema.rb. To enable that, this PR adds native support for PostgreSQL enums in schema.rb. In migrations, you can now use `create_enum` to add a new enum type, and `t.enum` to add a column: ```ruby def up # note that enums cannot be dropped create_enum :mood, ["happy", "sad"] change_table :cats do |t| t.enum :current_mood, enum_type: "mood", default: "happy", null: false end end ``` The enum definitions and enum columns will be presented in schema.rb, so you can load them into a test database and they'll work correctly. ------------------------------- It's worth noting again that this is *not* compatible with other database engines. So this will not work with `rails db:system:change` (or the equivalent manual process). My assumption is that this is a fairly unlikely thing to happen midway through a project - as opposed to when the app is first spun up - so it's safe to assume that once you've dug into using enums you probably aren't going to switch databases on a whim. For what it's worth, the MySQL adapter also supports enums (https://github.com/rails/rails/blob/main/activerecord/test/cases/adapters/mysql2/enum_test.rb) but the syntax doesn't look easy to translate (I also don't have much MySQL experience so don't really want to wade into this).
1 parent 4e35354 commit 4eef348

File tree

9 files changed

+149
-8
lines changed

9 files changed

+149
-8
lines changed

activerecord/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
* PostgreSQL: support custom enum types
2+
3+
In migrations, use `create_enum` to add a new enum type, and `t.enum` to add a column.
4+
5+
```ruby
6+
def up
7+
create_enum :mood, ["happy", "sad"]
8+
9+
change_table :cats do |t|
10+
t.enum :current_mood, enum_type: "mood", default: "happy", null: false
11+
end
12+
end
13+
```
14+
15+
Enums will be presented correctly in `schema.rb`. Note that this is only supported by
16+
the PostgreSQL adapter.
17+
18+
*Alex Ghiculescu*
19+
120
* Avoid COMMENT statements in PostgreSQL structure dumps
221

322
COMMENT statements are now omitted from the output of `db:structure:dump` when using PostgreSQL >= 11.

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ def disable_extension(name)
454454
def enable_extension(name)
455455
end
456456

457+
# This is meant to be implemented by the adapters that support custom enum types
458+
def create_enum(*) # :nodoc:
459+
end
460+
457461
def advisory_locks_enabled? # :nodoc:
458462
supports_advisory_locks? && @advisory_locks_enabled
459463
end

activerecord/lib/active_record/connection_adapters/postgresql/column.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def array
3232
end
3333
alias :array? :array
3434

35+
def enum?
36+
type == :enum
37+
end
38+
3539
def sql_type
3640
super.delete_suffix("[]")
3741
end

activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,19 @@ def primary_key(name, type = :primary_key, **options)
173173
# :method: xml
174174
# :call-seq: xml(*names, **options)
175175

176+
##
177+
# :method: timestamptz
178+
# :call-seq: timestamptz(*names, **options)
179+
180+
##
181+
# :method: enum
182+
# :call-seq: enum(*names, **options)
183+
176184
included do
177185
define_column_methods :bigserial, :bit, :bit_varying, :cidr, :citext, :daterange,
178186
:hstore, :inet, :interval, :int4range, :int8range, :jsonb, :ltree, :macaddr,
179187
:money, :numrange, :oid, :point, :line, :lseg, :box, :path, :polygon, :circle,
180-
:serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml, :timestamptz
188+
:serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml, :timestamptz, :enum
181189
end
182190
end
183191

activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ def extensions(stream)
1616
end
1717
end
1818

19+
def types(stream)
20+
types = @connection.enum_types
21+
if types.any?
22+
stream.puts " # Custom types defined in this database."
23+
stream.puts " # Note that some types may not work with other database engines. Be careful if changing database."
24+
types.sort.each do |name, values|
25+
stream.puts " create_enum #{name.inspect}, #{values.split(",").inspect}"
26+
end
27+
stream.puts
28+
end
29+
end
30+
1931
def prepare_column_options(column)
2032
spec = super
2133
spec[:array] = "true" if column.array?
@@ -26,6 +38,8 @@ def prepare_column_options(column)
2638
spec = { type: schema_type(column).inspect }.merge!(spec)
2739
end
2840

41+
spec[:enum_type] = "\"#{column.sql_type}\"" if column.enum?
42+
2943
spec
3044
end
3145

activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ def check_constraints(table_name) # :nodoc:
542542
end
543543

544544
# Maps logical Rails types to PostgreSQL-specific data types.
545-
def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) # :nodoc:
545+
def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, enum_type: nil, **) # :nodoc:
546546
sql = \
547547
case type.to_s
548548
when "binary"
@@ -566,6 +566,10 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) #
566566
when 5..8; "bigint"
567567
else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
568568
end
569+
when "enum"
570+
raise ArgumentError "enum_type is required for enums" if enum_type.nil?
571+
572+
enum_type
569573
else
570574
super
571575
end

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def new_client(conn_params)
164164
money: { name: "money" },
165165
interval: { name: "interval" },
166166
oid: { name: "oid" },
167+
enum: {} # special type https://www.postgresql.org/docs/current/datatype-enum.html
167168
}
168169

169170
OID = PostgreSQL::OID # :nodoc:
@@ -449,6 +450,38 @@ def extensions
449450
exec_query("SELECT extname FROM pg_extension", "SCHEMA").cast_values
450451
end
451452

453+
# Returns a list of defined enum types, and their values.
454+
def enum_types
455+
query = <<~SQL
456+
SELECT
457+
type.typname AS name,
458+
string_agg(enum.enumlabel, ',') AS value
459+
FROM pg_enum AS enum
460+
JOIN pg_type AS type
461+
ON (type.oid = enum.enumtypid)
462+
GROUP BY type.typname;
463+
SQL
464+
exec_query(query, "SCHEMA").cast_values
465+
end
466+
467+
# Given a name and an array of values, creates an enum type.
468+
def create_enum(name, values)
469+
sql_values = values.map { |s| "'#{s}'" }.join(", ")
470+
query = <<~SQL
471+
DO $$
472+
BEGIN
473+
IF NOT EXISTS (
474+
SELECT 1 FROM pg_type t
475+
WHERE t.typname = '#{name}'
476+
) THEN
477+
CREATE TYPE \"#{name}\" AS ENUM (#{sql_values});
478+
END IF;
479+
END
480+
$$;
481+
SQL
482+
exec_query(query)
483+
end
484+
452485
# Returns the configured supported identifier length supported by PostgreSQL
453486
def max_identifier_length
454487
@max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i

activerecord/lib/active_record/schema_dumper.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def generate_options(config)
4747
def dump(stream)
4848
header(stream)
4949
extensions(stream)
50+
types(stream)
5051
tables(stream)
5152
trailer(stream)
5253
stream
@@ -99,6 +100,10 @@ def trailer(stream)
99100
def extensions(stream)
100101
end
101102

103+
# (enum) types are only supported by PostgreSQL
104+
def types(stream)
105+
end
106+
102107
def tables(stream)
103108
sorted_tables = @connection.tables.sort
104109

@@ -154,6 +159,7 @@ def table(table, stream)
154159
columns.each do |column|
155160
raise StandardError, "Unknown type '#{column.sql_type}' for column '#{column.name}'" unless @connection.valid_type?(column.type)
156161
next if column.name == pk
162+
157163
type, colspec = column_spec(column)
158164
if type.is_a?(Symbol)
159165
tbl.print " t.#{type} #{column.name.inspect}"

activerecord/test/cases/adapters/postgresql/enum_test.rb

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@
22

33
require "cases/helper"
44
require "support/connection_helper"
5+
require "support/schema_dumping_helper"
56

67
class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase
78
include ConnectionHelper
9+
include SchemaDumpingHelper
810

911
class PostgresqlEnum < ActiveRecord::Base
1012
self.table_name = "postgresql_enums"
13+
14+
enum current_mood: {
15+
sad: "sad",
16+
okay: "ok", # different spelling
17+
happy: "happy",
18+
aliased_field: "happy"
19+
}, _prefix: true
1120
end
1221

1322
def setup
1423
@connection = ActiveRecord::Base.connection
1524
@connection.transaction do
16-
@connection.execute <<~SQL
17-
CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');
18-
SQL
25+
@connection.create_enum("mood", ["sad", "ok", "happy"])
1926
@connection.create_table("postgresql_enums") do |t|
2027
t.column :current_mood, :mood
2128
end
@@ -62,10 +69,9 @@ def test_enum_mapping
6269
def test_invalid_enum_update
6370
@connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');"
6471
enum = PostgresqlEnum.first
65-
enum.current_mood = "angry"
6672

67-
assert_raise ActiveRecord::StatementInvalid do
68-
enum.save
73+
assert_raise ArgumentError do
74+
enum.current_mood = "angry"
6975
end
7076
end
7177

@@ -90,4 +96,47 @@ def test_assigning_enum_to_nil
9096
assert model.save
9197
assert_nil model.reload.current_mood
9298
end
99+
100+
def test_schema_dump
101+
@connection.add_column "postgresql_enums", "good_mood", :mood, default: "happy", null: false
102+
103+
output = dump_table_schema("postgresql_enums")
104+
105+
assert output.include?("# Note that some types may not work with other database engines. Be careful if changing database."), output
106+
107+
assert output.include?('create_enum "mood", ["sad", "ok", "happy"]'), output
108+
109+
assert output.include?('t.enum "current_mood", enum_type: "mood"'), output
110+
assert output.include?('t.enum "good_mood", default: "happy", null: false, enum_type: "mood"'), output
111+
end
112+
113+
def test_schema_load
114+
original, $stdout = $stdout, StringIO.new
115+
116+
ActiveRecord::Schema.define do
117+
create_enum :color, ["blue", "green"]
118+
119+
change_table :postgresql_enums do |t|
120+
t.enum :best_color, enum_type: "color", default: "blue", null: false
121+
end
122+
end
123+
124+
assert @connection.column_exists?(:postgresql_enums, :best_color, sql_type: "color", default: "blue", null: false)
125+
ensure
126+
$stdout = original
127+
end
128+
129+
def test_works_with_activerecord_enum
130+
model = PostgresqlEnum.create!
131+
model.current_mood_okay!
132+
133+
model = PostgresqlEnum.find(model.id)
134+
assert_equal "okay", model.current_mood
135+
136+
model.current_mood = "happy"
137+
model.save!
138+
139+
model = PostgresqlEnum.find(model.id)
140+
assert model.current_mood_happy?
141+
end
93142
end

0 commit comments

Comments
 (0)