Skip to content

Commit 31b4824

Browse files
authored
Merge pull request rails#41469 from ghiculescu/postgres-enum
PostgreSQL: support custom enum types
2 parents 581b9fc + 4eef348 commit 31b4824

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)