Skip to content

Commit 9064735

Browse files
authored
Merge pull request rails#49346 from fractaledmind/ar-sqlite-virtual-columns
Add support for generated columns in SQLite3 adapter
2 parents b9cc0a2 + 4e7bdcf commit 9064735

File tree

10 files changed

+211
-7
lines changed

10 files changed

+211
-7
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 SQLite3 adapter
2+
3+
Generated columns (both stored and dynamic) are supported since version 3.31.0 of SQLite.
4+
This adds support for those to the SQLite3 adapter.
5+
6+
```ruby
7+
create_table :users do |t|
8+
t.string :name
9+
t.virtual :name_upper, type: :string, as: 'UPPER(name)'
10+
t.virtual :name_lower, type: :string, as: 'LOWER(name)', stored: true
11+
end
12+
```
13+
14+
*Stephen Margheim*
15+
116
* TrilogyAdapter: ignore `host` if `socket` parameter is set.
217

318
This allows to configure a connection on a UNIX socket via DATABASE_URL:

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ module SQLite3
66
class Column < ConnectionAdapters::Column # :nodoc:
77
attr_reader :rowid
88

9-
def initialize(*, auto_increment: nil, rowid: false, **)
9+
def initialize(*, auto_increment: nil, rowid: false, generated_type: nil, **)
1010
super
1111
@auto_increment = auto_increment
1212
@rowid = rowid
13+
@generated_type = generated_type
1314
end
1415

1516
def auto_increment?
@@ -20,6 +21,18 @@ def auto_incremented_by_db?
2021
auto_increment? || rowid
2122
end
2223

24+
def virtual?
25+
!@generated_type.nil?
26+
end
27+
28+
def virtual_stored?
29+
virtual? && @generated_type == :stored
30+
end
31+
32+
def has_default?
33+
super && !virtual?
34+
end
35+
2336
def init_with(coder)
2437
@auto_increment = coder["auto_increment"]
2538
super

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ def add_column_options!(sql, options)
2525
if options[:collation]
2626
sql << " COLLATE \"#{options[:collation]}\""
2727
end
28+
29+
if as = options[:as]
30+
sql << " GENERATED ALWAYS AS (#{as})"
31+
32+
if options[:stored]
33+
sql << " STORED"
34+
else
35+
sql << " VIRTUAL"
36+
end
37+
end
2838
super
2939
end
3040
end

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,23 @@ def references(*args, **options)
1616
end
1717
alias :belongs_to :references
1818

19+
def new_column_definition(name, type, **options) # :nodoc:
20+
case type
21+
when :virtual
22+
type = options[:type]
23+
end
24+
25+
super
26+
end
27+
1928
private
2029
def integer_like_primary_key_type(type, options)
2130
:primary_key
2231
end
32+
33+
def valid_column_definition_options
34+
super + [:as, :type, :stored]
35+
end
2336
end
2437
end
2538
end

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ def default_primary_key?(column)
1212
def explicit_primary_key_default?(column)
1313
column.bigint?
1414
end
15+
16+
def prepare_column_options(column)
17+
spec = super
18+
19+
if @connection.supports_virtual_columns? && column.virtual?
20+
spec[:as] = extract_expression_for_virtual_column(column)
21+
spec[:stored] = column.virtual_stored?
22+
spec = { type: schema_type(column).inspect }.merge!(spec)
23+
end
24+
25+
spec
26+
end
27+
28+
def extract_expression_for_virtual_column(column)
29+
column.default_function.inspect
30+
end
1531
end
1632
end
1733
end

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,14 @@ def new_column_from_field(table_name, field, definitions)
147147

148148
type_metadata = fetch_type_metadata(field["type"])
149149
default_value = extract_value_from_default(default)
150-
default_function = extract_default_function(default_value, default)
150+
generated_type = extract_generated_type(field)
151+
152+
if generated_type.present?
153+
default_function = default
154+
else
155+
default_function = extract_default_function(default_value, default)
156+
end
157+
151158
rowid = is_column_the_rowid?(field, definitions)
152159

153160
Column.new(
@@ -158,7 +165,8 @@ def new_column_from_field(table_name, field, definitions)
158165
default_function,
159166
collation: field["collation"],
160167
auto_increment: field["auto_increment"],
161-
rowid: rowid
168+
rowid: rowid,
169+
generated_type: generated_type
162170
)
163171
end
164172

@@ -201,6 +209,13 @@ def assert_valid_deferrable(deferrable)
201209

202210
raise ArgumentError, "deferrable must be `:immediate` or `:deferred`, got: `#{deferrable.inspect}`"
203211
end
212+
213+
def extract_generated_type(field)
214+
case field["hidden"]
215+
when 2 then :virtual
216+
when 3 then :stored
217+
end
218+
end
204219
end
205220
end
206221
end

activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ def supports_concurrent_connections?
186186
!@memory_database
187187
end
188188

189+
def supports_virtual_columns?
190+
database_version >= "3.31.0"
191+
end
192+
189193
def connected?
190194
!(@raw_connection.nil? || @raw_connection.closed?)
191195
end
@@ -282,6 +286,7 @@ def rename_table(table_name, new_name, **options)
282286
end
283287

284288
def add_column(table_name, column_name, type, **options) # :nodoc:
289+
type = type.to_sym
285290
if invalid_alter_table_type?(type, options)
286291
alter_table(table_name) do |definition|
287292
definition.column(column_name, type, **options)
@@ -462,7 +467,11 @@ def bind_params_length
462467
end
463468

464469
def table_structure(table_name)
465-
structure = internal_exec_query("PRAGMA table_info(#{quote_table_name(table_name)})", "SCHEMA")
470+
structure = if supports_virtual_columns?
471+
internal_exec_query("PRAGMA table_xinfo(#{quote_table_name(table_name)})", "SCHEMA")
472+
else
473+
internal_exec_query("PRAGMA table_info(#{quote_table_name(table_name)})", "SCHEMA")
474+
end
466475
raise(ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'") if structure.empty?
467476
table_structure_with_collation(table_name, structure)
468477
end
@@ -502,8 +511,9 @@ def has_default_function?(default_value, default)
502511
# See: https://www.sqlite.org/lang_altertable.html
503512
# SQLite has an additional restriction on the ALTER TABLE statement
504513
def invalid_alter_table_type?(type, options)
505-
type.to_sym == :primary_key || options[:primary_key] ||
506-
options[:null] == false && options[:default].nil?
514+
type == :primary_key || options[:primary_key] ||
515+
options[:null] == false && options[:default].nil? ||
516+
(type == :virtual && options[:stored])
507517
end
508518

509519
def alter_table(
@@ -651,10 +661,12 @@ def translate_exception(exception, message:, sql:, binds:)
651661

652662
COLLATE_REGEX = /.*"(\w+)".*collate\s+"(\w+)".*/i
653663
PRIMARY_KEY_AUTOINCREMENT_REGEX = /.*"(\w+)".+PRIMARY KEY AUTOINCREMENT/i
664+
GENERATED_ALWAYS_AS_REGEX = /.*"(\w+)".+GENERATED ALWAYS AS \((.+)\) (?:STORED|VIRTUAL)/i
654665

655666
def table_structure_with_collation(table_name, basic_structure)
656667
collation_hash = {}
657668
auto_increments = {}
669+
generated_columns = {}
658670

659671
column_strings = table_structure_sql(table_name)
660672

@@ -664,6 +676,7 @@ def table_structure_with_collation(table_name, basic_structure)
664676
# the value in $1 and $2 respectively.
665677
collation_hash[$1] = $2 if COLLATE_REGEX =~ column_string
666678
auto_increments[$1] = true if PRIMARY_KEY_AUTOINCREMENT_REGEX =~ column_string
679+
generated_columns[$1] = $2 if GENERATED_ALWAYS_AS_REGEX =~ column_string
667680
end
668681

669682
basic_structure.map do |column|
@@ -677,6 +690,10 @@ def table_structure_with_collation(table_name, basic_structure)
677690
column["auto_increment"] = true
678691
end
679692

693+
if generated_columns.has_key?(column_name)
694+
column["dflt_value"] = generated_columns[column_name]
695+
end
696+
680697
column
681698
end
682699
else

activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def test_insert_logged
263263
sql = "INSERT INTO ex (number) VALUES (10)"
264264
name = "foo"
265265

266-
pragma_query = ["PRAGMA table_info(\"ex\")", "SCHEMA", []]
266+
pragma_query = ["PRAGMA table_xinfo(\"ex\")", "SCHEMA", []]
267267
schema_query = ["SELECT sql FROM (SELECT * FROM sqlite_master UNION ALL SELECT * FROM sqlite_temp_master) WHERE type = 'table' AND name = 'ex'", "SCHEMA", []]
268268
modified_insert_query = [(sql + ' RETURNING "id"'), name, []]
269269
assert_logged [pragma_query, schema_query, modified_insert_query] do
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 SQLite3VirtualColumnTest < ActiveRecord::SQLite3TestCase
8+
include SchemaDumpingHelper
9+
10+
class VirtualColumn < ActiveRecord::Base
11+
end
12+
13+
def setup
14+
@connection = ActiveRecord::Base.connection
15+
@connection.create_table :virtual_columns, force: true do |t|
16+
t.string :name
17+
t.virtual :upper_name, type: :string, as: "UPPER(name)", stored: true
18+
t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false
19+
t.virtual :octet_name, type: :integer, as: "LENGTH(name)"
20+
t.integer :column1
21+
end
22+
VirtualColumn.create(name: "Rails", column1: 10)
23+
end
24+
25+
def teardown
26+
@connection.drop_table :virtual_columns, if_exists: true
27+
VirtualColumn.reset_column_information
28+
end
29+
30+
def test_virtual_column_with_full_inserts
31+
partial_inserts_was = VirtualColumn.partial_inserts
32+
VirtualColumn.partial_inserts = false
33+
assert_nothing_raised do
34+
VirtualColumn.create!(name: "Rails")
35+
end
36+
ensure
37+
VirtualColumn.partial_inserts = partial_inserts_was
38+
end
39+
40+
def test_stored_column
41+
column = VirtualColumn.columns_hash["upper_name"]
42+
assert_predicate column, :virtual?
43+
assert_predicate column, :virtual_stored?
44+
assert_equal "RAILS", VirtualColumn.take.upper_name
45+
end
46+
47+
def test_explicit_virtual_column
48+
column = VirtualColumn.columns_hash["lower_name"]
49+
assert_predicate column, :virtual?
50+
assert_not_predicate column, :virtual_stored?
51+
assert_equal "rails", VirtualColumn.take.lower_name
52+
end
53+
54+
def test_implicit_virtual_column
55+
column = VirtualColumn.columns_hash["octet_name"]
56+
assert_predicate column, :virtual?
57+
assert_not_predicate column, :virtual_stored?
58+
assert_equal 5, VirtualColumn.take.octet_name
59+
end
60+
61+
def test_change_table_with_stored_generated_column
62+
@connection.change_table :virtual_columns do |t|
63+
t.virtual :decr_column1, type: :integer, as: "column1 - 1", stored: true
64+
end
65+
VirtualColumn.reset_column_information
66+
column = VirtualColumn.columns_hash["decr_column1"]
67+
assert_predicate column, :virtual?
68+
assert_predicate column, :virtual_stored?
69+
assert_equal 9, VirtualColumn.take.decr_column1
70+
end
71+
72+
def test_change_table_with_explicit_virtual_generated_column
73+
@connection.change_table :virtual_columns do |t|
74+
t.virtual :incr_column1, type: :integer, as: "column1 + 1", stored: false
75+
end
76+
VirtualColumn.reset_column_information
77+
column = VirtualColumn.columns_hash["incr_column1"]
78+
assert_predicate column, :virtual?
79+
assert_not_predicate column, :virtual_stored?
80+
assert_equal 11, VirtualColumn.take.incr_column1
81+
end
82+
83+
def test_change_table_with_implicit_virtual_generated_column
84+
@connection.change_table :virtual_columns do |t|
85+
t.virtual :sqr_column1, type: :integer, as: "pow(column1, 2)"
86+
end
87+
VirtualColumn.reset_column_information
88+
column = VirtualColumn.columns_hash["sqr_column1"]
89+
assert_predicate column, :virtual?
90+
assert_not_predicate column, :virtual_stored?
91+
assert_equal 100, VirtualColumn.take.sqr_column1
92+
end
93+
94+
def test_schema_dumping
95+
output = dump_table_schema("virtual_columns")
96+
assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "UPPER\(name\)", stored: true$/i, output)
97+
end
98+
99+
def test_build_fixture_sql
100+
ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :virtual_columns)
101+
end
102+
end
103+
end

activerecord/test/cases/migration/invalid_options_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def invalid_add_column_option_exception_message(key)
1414
default_keys.concat([":auto_increment", ":charset", ":as", ":size", ":unsigned", ":first", ":after", ":type", ":stored"])
1515
elsif current_adapter?(:PostgreSQLAdapter)
1616
default_keys.concat([":array", ":using", ":cast_as", ":as", ":type", ":enum_type", ":stored"])
17+
elsif current_adapter?(:SQLite3Adapter)
18+
default_keys.concat([":as", ":type", ":stored"])
1719
end
1820

1921
"Unknown key: :#{key}. Valid keys are: #{default_keys.join(", ")}"

0 commit comments

Comments
 (0)