Skip to content

Commit 4e7bdcf

Browse files
fractaledmindbyroot
authored andcommitted
Add support for generated columns in SQLite3 adapter
Generated columns (both stored and dynamic) are supported since version 3.31.0 of SQLite. This adds support for those to the SQLite3 adapter. ```ruby create_table :users do |t| t.string :name t.virtual :name_upper, type: :string, as: 'UPPER(name)' t.virtual :name_lower, type: :string, as: 'LOWER(name)', stored: true end ```
1 parent b9cc0a2 commit 4e7bdcf

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)