Skip to content

Commit e1a09e6

Browse files
committed
Merge pull request rails#41856 from MSNexploder/virtual-columns
Add support for generated columns in PostgreSQL (Redux)
2 parents 4bbdad5 + 64fd666 commit e1a09e6

File tree

11 files changed

+202
-11
lines changed

11 files changed

+202
-11
lines changed

activerecord/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
* Add support for generated columns in PostgreSQL adapter
2+
3+
Generated columns are supported since version 12.0 of PostgreSQL. This adds
4+
support of those to the PostgreSQL adapter.
5+
6+
```ruby
7+
create_table :users do |t|
8+
t.string :name
9+
t.virtual :name_upcased, type: :string, as: 'upper(name)', stored: true
10+
end
11+
```
12+
13+
*Michał Begejowicz*
14+
15+
116
## Rails 7.0.0.alpha2 (September 15, 2021) ##
217

318
* No changes.

activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ def default_insert_value(column)
469469
end
470470

471471
def build_fixture_sql(fixtures, table_name)
472-
columns = schema_cache.columns_hash(table_name)
472+
columns = schema_cache.columns_hash(table_name).reject { |_, column| supports_virtual_columns? && column.virtual? }
473473

474474
values_list = fixtures.map do |fixture|
475475
fixture = fixture.stringify_keys

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
# frozen_string_literal: true
22

3+
require "active_support/core_ext/object/blank"
4+
35
module ActiveRecord
46
module ConnectionAdapters
57
module PostgreSQL
68
class Column < ConnectionAdapters::Column # :nodoc:
79
delegate :oid, :fmod, to: :sql_type_metadata
810

9-
def initialize(*, serial: nil, **)
11+
def initialize(*, serial: nil, generated: nil, **)
1012
super
1113
@serial = serial
14+
@generated = generated
1215
end
1316

1417
def serial?
1518
@serial
1619
end
1720

21+
def virtual?
22+
# We assume every generated column is virtual, no matter the concrete type
23+
@generated.present?
24+
end
25+
26+
def has_default?
27+
super && !virtual?
28+
end
29+
1830
def array
1931
sql_type_metadata.sql_type.end_with?("[]")
2032
end

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ def add_column_options!(sql, options)
6161
if options[:collation]
6262
sql << " COLLATE \"#{options[:collation]}\""
6363
end
64+
65+
if as = options[:as]
66+
sql << " GENERATED ALWAYS AS (#{as})"
67+
68+
if options[:stored]
69+
sql << " STORED"
70+
else
71+
raise ArgumentError, <<~MSG
72+
PostgreSQL currently does not support VIRTUAL (not persisted) generated columns.
73+
Specify 'stored: true' option for '#{options[:column].name}'
74+
MSG
75+
end
76+
end
6477
super
6578
end
6679

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ def initialize(*, **)
191191
@unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables
192192
end
193193

194+
def new_column_definition(name, type, **options) # :nodoc:
195+
case type
196+
when :virtual
197+
type = options[:type]
198+
end
199+
200+
super
201+
end
202+
194203
private
195204
def aliased_types(name, fallback)
196205
fallback

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ def extensions(stream)
1919
def prepare_column_options(column)
2020
spec = super
2121
spec[:array] = "true" if column.array?
22+
23+
if @connection.supports_virtual_columns? && column.virtual?
24+
spec[:as] = extract_expression_for_virtual_column(column)
25+
spec[:stored] = true
26+
spec = { type: schema_type(column).inspect }.merge!(spec)
27+
end
28+
2229
spec
2330
end
2431

@@ -43,6 +50,10 @@ def schema_type(column)
4350
def schema_expression(column)
4451
super unless column.serial?
4552
end
53+
54+
def extract_expression_for_virtual_column(column)
55+
column.default_function.inspect
56+
end
4657
end
4758
end
4859
end

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ def create_alter_table(name)
654654
end
655655

656656
def new_column_from_field(table_name, field)
657-
column_name, type, default, notnull, oid, fmod, collation, comment = field
657+
column_name, type, default, notnull, oid, fmod, collation, comment, attgenerated = field
658658
type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i)
659659
default_value = extract_value_from_default(default)
660660
default_function = extract_default_function(default_value, default)
@@ -671,7 +671,8 @@ def new_column_from_field(table_name, field)
671671
default_function,
672672
collation: collation,
673673
comment: comment.presence,
674-
serial: serial
674+
serial: serial,
675+
generated: attgenerated
675676
)
676677
end
677678

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def supports_index_sort_order?
181181
end
182182

183183
def supports_partitioned_indexes?
184-
database_version >= 110_000
184+
database_version >= 110_000 # >= 11.0
185185
end
186186

187187
def supports_partial_index?
@@ -233,12 +233,16 @@ def supports_insert_returning?
233233
end
234234

235235
def supports_insert_on_conflict?
236-
database_version >= 90500
236+
database_version >= 90500 # >= 9.5
237237
end
238238
alias supports_insert_on_duplicate_skip? supports_insert_on_conflict?
239239
alias supports_insert_on_duplicate_update? supports_insert_on_conflict?
240240
alias supports_insert_conflict_target? supports_insert_on_conflict?
241241

242+
def supports_virtual_columns?
243+
database_version >= 120_000 # >= 12.0
244+
end
245+
242246
def index_algorithms
243247
{ concurrently: "CONCURRENTLY" }
244248
end
@@ -388,7 +392,7 @@ def supports_foreign_tables?
388392
end
389393

390394
def supports_pgcrypto_uuid?
391-
database_version >= 90400
395+
database_version >= 90400 # >= 9.4
392396
end
393397

394398
def supports_optimizer_hints?
@@ -489,7 +493,7 @@ def build_insert_sql(insert) # :nodoc:
489493
end
490494

491495
def check_version # :nodoc:
492-
if database_version < 90300
496+
if database_version < 90300 # < 9.3
493497
raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3."
494498
end
495499
end
@@ -874,7 +878,8 @@ def column_definitions(table_name)
874878
query(<<~SQL, "SCHEMA")
875879
SELECT a.attname, format_type(a.atttypid, a.atttypmod),
876880
pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod,
877-
c.collname, col_description(a.attrelid, a.attnum) AS comment
881+
c.collname, col_description(a.attrelid, a.attnum) AS comment,
882+
#{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated
878883
FROM pg_attribute a
879884
LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
880885
LEFT JOIN pg_type t ON a.atttypid = t.oid
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "support/schema_dumping_helper"
5+
6+
if ActiveRecord::Base.connection.supports_virtual_columns?
7+
class PostgresqlVirtualColumnTest < ActiveRecord::PostgreSQLTestCase
8+
include SchemaDumpingHelper
9+
10+
self.use_transactional_tests = false
11+
12+
class VirtualColumn < ActiveRecord::Base
13+
end
14+
15+
def setup
16+
@connection = ActiveRecord::Base.connection
17+
@connection.create_table :virtual_columns, force: true do |t|
18+
t.string :name
19+
t.virtual :upper_name, type: :string, as: "UPPER(name)", stored: true
20+
t.virtual :name_length, type: :integer, as: "LENGTH(name)", stored: true
21+
t.virtual :name_octet_length, type: :integer, as: "OCTET_LENGTH(name)", stored: true
22+
end
23+
VirtualColumn.create(name: "Rails")
24+
end
25+
26+
def teardown
27+
@connection.drop_table :virtual_columns, if_exists: true
28+
VirtualColumn.reset_column_information
29+
end
30+
31+
def test_virtual_column
32+
column = VirtualColumn.columns_hash["upper_name"]
33+
assert_predicate column, :virtual?
34+
assert_equal "RAILS", VirtualColumn.take.upper_name
35+
end
36+
37+
def test_stored_column
38+
column = VirtualColumn.columns_hash["name_length"]
39+
assert_predicate column, :virtual?
40+
assert_equal 5, VirtualColumn.take.name_length
41+
end
42+
43+
def test_change_table
44+
@connection.change_table :virtual_columns do |t|
45+
t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: true
46+
end
47+
VirtualColumn.reset_column_information
48+
column = VirtualColumn.columns_hash["lower_name"]
49+
assert_predicate column, :virtual?
50+
assert_equal "rails", VirtualColumn.take.lower_name
51+
end
52+
53+
def test_non_persisted_column
54+
message = <<~MSG
55+
PostgreSQL currently does not support VIRTUAL (not persisted) generated columns.
56+
Specify 'stored: true' option for 'invalid_definition'
57+
MSG
58+
59+
assert_raise ArgumentError, message do
60+
@connection.change_table :virtual_columns do |t|
61+
t.virtual :invalid_definition, type: :string, as: "LOWER(name)"
62+
end
63+
end
64+
end
65+
66+
def test_schema_dumping
67+
output = dump_table_schema("virtual_columns")
68+
assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "upper\(\(name\)::text\)", stored: true$/i, output)
69+
assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "length\(\(name\)::text\)", stored: true$/i, output)
70+
assert_match(/t\.virtual\s+"name_octet_length",\s+type: :integer,\s+as: "octet_length\(\(name\)::text\)", stored: true$/i, output)
71+
end
72+
73+
def test_build_fixture_sql
74+
ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :virtual_columns)
75+
end
76+
end
77+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
one:
2+
name: hello
3+
4+
two:
5+
name: world

0 commit comments

Comments
 (0)