Skip to content

Commit 2cf41d7

Browse files
fanfilmuMSNexploder
authored andcommitted
Add support for generated columns in PostgreSQL
1 parent e2781c2 commit 2cf41d7

File tree

9 files changed

+188
-10
lines changed

9 files changed

+188
-10
lines changed

activerecord/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
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)'
10+
end
11+
```
12+
13+
*Michał Begejowicz*
14+
115
* Ensure `has_one` autosave association callbacks get called once.
216

317
Change the `has_one` autosave callback to be non cyclic as well.

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,24 @@ module PostgreSQL
66
class Column < ConnectionAdapters::Column # :nodoc:
77
delegate :oid, :fmod, to: :sql_type_metadata
88

9-
def initialize(*, serial: nil, **)
9+
def initialize(*, serial: nil, generated: nil, **)
1010
super
1111
@serial = serial
12+
@generated = generated
1213
end
1314

1415
def serial?
1516
@serial
1617
end
1718

19+
def virtual?
20+
@generated == "s"
21+
end
22+
23+
def has_default?
24+
super && !virtual?
25+
end
26+
1827
def array
1928
sql_type_metadata.sql_type.end_with?("[]")
2029
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 integer_like_primary_key_type(type, options)
196205
if type == :bigint || options[:limit] == 8

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
@@ -158,7 +158,7 @@ def supports_index_sort_order?
158158
end
159159

160160
def supports_partitioned_indexes?
161-
database_version >= 110_000
161+
database_version >= 110_000 # >= 11.0
162162
end
163163

164164
def supports_partial_index?
@@ -210,12 +210,16 @@ def supports_insert_returning?
210210
end
211211

212212
def supports_insert_on_conflict?
213-
database_version >= 90500
213+
database_version >= 90500 # >= 9.5
214214
end
215215
alias supports_insert_on_duplicate_skip? supports_insert_on_conflict?
216216
alias supports_insert_on_duplicate_update? supports_insert_on_conflict?
217217
alias supports_insert_conflict_target? supports_insert_on_conflict?
218218

219+
def supports_virtual_columns?
220+
database_version >= 120_000 # >= 12.0
221+
end
222+
219223
def index_algorithms
220224
{ concurrently: "CONCURRENTLY" }
221225
end
@@ -351,7 +355,7 @@ def supports_foreign_tables?
351355
end
352356

353357
def supports_pgcrypto_uuid?
354-
database_version >= 90400
358+
database_version >= 90400 # >= 9.4
355359
end
356360

357361
def supports_optimizer_hints?
@@ -452,7 +456,7 @@ def build_insert_sql(insert) # :nodoc:
452456
end
453457

454458
def check_version # :nodoc:
455-
if database_version < 90300
459+
if database_version < 90300 # < 9.3
456460
raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3."
457461
end
458462
end
@@ -827,7 +831,8 @@ def column_definitions(table_name)
827831
query(<<~SQL, "SCHEMA")
828832
SELECT a.attname, format_type(a.atttypid, a.atttypmod),
829833
pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod,
830-
c.collname, col_description(a.attrelid, a.attnum) AS comment
834+
c.collname, col_description(a.attrelid, a.attnum) AS comment,
835+
#{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated
831836
FROM pg_attribute a
832837
LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
833838
LEFT JOIN pg_type t ON a.atttypid = t.oid
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
end
73+
end

guides/source/active_record_postgresql.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,14 +503,36 @@ irb> device.id
503503
NOTE: `gen_random_uuid()` (from `pgcrypto`) is assumed if no `:default` option was
504504
passed to `create_table`.
505505

506+
Generated Columns
507+
-----------------
508+
509+
NOTE: Generated columns are supported since version 12.0 of PostgreSQL.
510+
511+
```ruby
512+
# db/migrate/20131220144913_create_users.rb
513+
create_table :users do |t|
514+
t.string :name
515+
t.virtual :name_upcased, type: :string, as: 'upper(name)'
516+
end
517+
518+
# app/models/user.rb
519+
class User < ApplicationRecord
520+
end
521+
522+
# Usage
523+
user = User.create(name: 'John')
524+
User.last.name_upcased # => "JOHN"
525+
```
526+
527+
506528
Full Text Search
507529
----------------
508530

509531
```ruby
510532
# db/migrate/20131220144913_create_documents.rb
511533
create_table :documents do |t|
512-
t.string 'title'
513-
t.string 'body'
534+
t.string :title
535+
t.string :body
514536
end
515537

516538
add_index :documents, "to_tsvector('english', title || ' ' || body)", using: :gin, name: 'documents_idx'
@@ -531,6 +553,27 @@ Document.where("to_tsvector('english', title || ' ' || body) @@ to_tsquery(?)",
531553
"cat & dog")
532554
```
533555

556+
Optionally, you can store the vector as automatically generated column (from PostgreSQL 12.0):
557+
558+
```ruby
559+
# db/migrate/20131220144913_create_documents.rb
560+
create_table :documents do |t|
561+
t.string :title
562+
t.string :body
563+
564+
t.virtual :textsearchable_index_col,
565+
type: :tsvector, as: "to_tsvector('english', title || ' ' || body)"
566+
end
567+
568+
add_index :documents, :textsearchable_index_col, using: :gin, name: 'documents_idx'
569+
570+
# Usage
571+
Document.create(title: "Cats and Dogs", body: "are nice!")
572+
573+
## all documents matching 'cat & dog'
574+
Document.where("textsearchable_index_col @@ to_tsquery(?)", "cat & dog")
575+
```
576+
534577
Database Views
535578
--------------
536579

0 commit comments

Comments
 (0)