Skip to content

Commit dfdb318

Browse files
authored
Added support for computed columns (#1367)
1 parent a727245 commit dfdb318

File tree

10 files changed

+221
-54
lines changed

10 files changed

+221
-54
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- [#1301](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1301) Add support for `INDEX INCLUDE`.
66
- [#1312](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1312) Add support for `insert_all` and `upsert_all`.
7+
- [#1367](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1367) Added support for computed columns.
78

89
#### Changed
910

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,19 @@ The removal of duplicates happens during the SQL query.
186186

187187
Because of this implementation, if you pass `on_duplicate` to `upsert_all`, make sure to assign your value to `target.[column_name]` (e.g. `target.status = GREATEST(target.status, 1)`). To access the values that you want to upsert, use `source.[column_name]`.
188188

189+
#### Computed Columns
190+
191+
The adapter supports computed columns. They can either be virtual `stored: false` (default) and persisted `stored: true`. You can create a computed column in a migration like so:
192+
193+
```ruby
194+
create_table :users do |t|
195+
t.string :name
196+
t.virtual :lower_name, as: "LOWER(name)", stored: false
197+
t.virtual :upper_name, as: "UPPER(name)", stored: true
198+
t.virtual :name_length, as: "LEN(name)"
199+
end
200+
```
201+
189202
## New Rails Applications
190203

191204
When creating a new Rails application you need to perform the following steps to connect a Rails application to a

lib/active_record/connection_adapters/sqlserver/schema_creation.rb

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ def supports_index_using?
1212
false
1313
end
1414

15+
def visit_ColumnDefinition(o)
16+
column_sql = super
17+
column_sql = column_sql.sub(" #{o.sql_type}", "") if o.options[:as].present?
18+
column_sql
19+
end
20+
1521
def visit_TableDefinition(o)
1622
if_not_exists = o.if_not_exists
1723

@@ -58,18 +64,17 @@ def quoted_include_columns(o)
5864

5965
def add_column_options!(sql, options)
6066
sql << " DEFAULT #{quote_default_expression_for_column_definition(options[:default], options[:column])}" if options_include_default?(options)
61-
if options[:collation].present?
62-
sql << " COLLATE #{options[:collation]}"
63-
end
64-
if options[:null] == false
65-
sql << " NOT NULL"
66-
end
67-
if options[:is_identity] == true
68-
sql << " IDENTITY(1,1)"
69-
end
70-
if options[:primary_key] == true
71-
sql << " PRIMARY KEY"
67+
68+
sql << " COLLATE #{options[:collation]}" if options[:collation].present?
69+
sql << " NOT NULL" if options[:null] == false
70+
sql << " IDENTITY(1,1)" if options[:is_identity] == true
71+
sql << " PRIMARY KEY" if options[:primary_key] == true
72+
73+
if (as = options[:as])
74+
sql << " AS #{as}"
75+
sql << " PERSISTED" if options[:stored]
7276
end
77+
7378
sql
7479
end
7580

lib/active_record/connection_adapters/sqlserver/schema_dumper.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,31 @@ module ActiveRecord
44
module ConnectionAdapters
55
module SQLServer
66
class SchemaDumper < ConnectionAdapters::SchemaDumper
7-
SQLSEVER_NO_LIMIT_TYPES = [
8-
"text",
9-
"ntext",
10-
"varchar(max)",
11-
"nvarchar(max)",
12-
"varbinary(max)"
13-
].freeze
7+
SQLSERVER_NO_LIMIT_TYPES = %w[text ntext varchar(max) nvarchar(max) varbinary(max)].freeze
148

159
private
1610

11+
def prepare_column_options(column)
12+
spec = super
13+
14+
if @connection.supports_virtual_columns? && column.virtual?
15+
spec[:as] = extract_expression_for_virtual_column(column)
16+
spec[:stored] = column.virtual_stored?
17+
end
18+
19+
spec
20+
end
21+
22+
def extract_expression_for_virtual_column(column)
23+
column.default_function.inspect
24+
end
25+
1726
def explicit_primary_key_default?(column)
1827
column.type == :integer && !column.is_identity?
1928
end
2029

2130
def schema_limit(column)
22-
return if SQLSEVER_NO_LIMIT_TYPES.include?(column.sql_type)
31+
return if SQLSERVER_NO_LIMIT_TYPES.include?(column.sql_type)
2332

2433
super
2534
end

lib/active_record/connection_adapters/sqlserver/schema_statements.rb

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -88,38 +88,47 @@ def index_include_columns(table_name, index_name)
8888
def columns(table_name)
8989
return [] if table_name.blank?
9090

91-
column_definitions(table_name).map do |ci|
92-
sqlserver_options = ci.slice :ordinal_position, :is_primary, :is_identity, :table_name
93-
sql_type_metadata = fetch_type_metadata ci[:type], sqlserver_options
94-
95-
new_column(
96-
ci[:name],
97-
lookup_cast_type(ci[:type]),
98-
ci[:default_value],
99-
sql_type_metadata,
100-
ci[:null],
101-
ci[:default_function],
102-
ci[:collation],
103-
nil,
104-
sqlserver_options
105-
)
91+
definitions = column_definitions(table_name)
92+
definitions.map do |field|
93+
new_column_from_field(table_name, field, definitions)
10694
end
10795
end
10896

109-
def new_column(name, cast_type, default, sql_type_metadata, null, default_function = nil, collation = nil, comment = nil, sqlserver_options = {})
97+
def new_column_from_field(_table_name, field, _definitions)
98+
sqlserver_options = field.slice(:ordinal_position, :is_primary, :is_identity, :table_name)
99+
sql_type_metadata = fetch_type_metadata(field[:type], sqlserver_options)
100+
generated_type = extract_generated_type(field)
101+
102+
default_function = if generated_type.present?
103+
field[:computed_formula]
104+
else
105+
field[:default_function]
106+
end
107+
110108
SQLServer::Column.new(
111-
name,
112-
cast_type,
113-
default,
109+
field[:name],
110+
lookup_cast_type(field[:type]),
111+
field[:default_value],
114112
sql_type_metadata,
115-
null,
113+
field[:null],
116114
default_function,
117-
collation: collation,
118-
comment: comment,
115+
collation: field[:collation],
116+
comment: nil,
117+
generated_type: generated_type,
119118
**sqlserver_options
120119
)
121120
end
122121

122+
def extract_generated_type(field)
123+
if field[:is_computed]
124+
if field[:is_persisted]
125+
:stored
126+
else
127+
:virtual
128+
end
129+
end
130+
end
131+
123132
def primary_keys(table_name)
124133
primaries = primary_keys_select(table_name)
125134
primaries.present? ? primaries : identity_columns(table_name).map(&:name)
@@ -512,15 +521,7 @@ def column_definitions(table_name)
512521
raise ActiveRecord::StatementInvalid, "Table '#{table_name}' doesn't exist" if results.empty?
513522

514523
results.map do |ci|
515-
col = {
516-
name: ci["name"],
517-
numeric_scale: ci["numeric_scale"],
518-
numeric_precision: ci["numeric_precision"],
519-
datetime_precision: ci["datetime_precision"],
520-
collation: ci["collation"],
521-
ordinal_position: ci["ordinal_position"],
522-
length: ci["length"]
523-
}
524+
col = ci.slice("name", "numeric_scale", "numeric_precision", "datetime_precision", "collation", "ordinal_position", "length", "is_computed", "is_persisted", "computed_formula").symbolize_keys
524525

525526
col[:table_name] = view_exists ? view_table_name(table_name) : table_name
526527
col[:type] = column_type(ci: ci)
@@ -640,7 +641,10 @@ def column_definitions_sql(database, identifier)
640641
WHEN ic.object_id IS NOT NULL
641642
THEN 1
642643
END AS [is_primary],
643-
c.is_identity AS [is_identity]
644+
c.is_identity AS [is_identity],
645+
c.is_computed AS [is_computed],
646+
cc.is_persisted AS [is_persisted],
647+
cc.definition AS [computed_formula]
644648
FROM #{database}.sys.columns c
645649
INNER JOIN #{database}.sys.objects o
646650
ON c.object_id = o.object_id
@@ -659,6 +663,9 @@ def column_definitions_sql(database, identifier)
659663
ON k.parent_object_id = ic.object_id
660664
AND k.unique_index_id = ic.index_id
661665
AND c.column_id = ic.column_id
666+
LEFT OUTER JOIN #{database}.sys.computed_columns cc
667+
ON c.object_id = cc.object_id
668+
AND c.column_id = cc.column_id
662669
WHERE
663670
o.Object_ID = Object_ID(#{object_id_arg})
664671
AND s.name = #{schema_name}

lib/active_record/connection_adapters/sqlserver/table_definition.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ def new_column_definition(name, type, **options)
109109
type = :datetime2 unless options[:precision].nil?
110110
when :primary_key
111111
options[:is_identity] = true
112+
when :virtual
113+
type = options[:type]
112114
end
113115

114116
super
@@ -117,7 +119,7 @@ def new_column_definition(name, type, **options)
117119
private
118120

119121
def valid_column_definition_options
120-
super + [:is_identity]
122+
super + [:is_identity, :as, :stored]
121123
end
122124
end
123125

lib/active_record/connection_adapters/sqlserver_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ def supports_insert_conflict_target?
265265
false
266266
end
267267

268+
def supports_virtual_columns?
269+
true
270+
end
271+
268272
def return_value_after_insert?(column) # :nodoc:
269273
column.is_primary? || column.is_identity?
270274
end

lib/active_record/connection_adapters/sqlserver_column.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ module SQLServer
66
class Column < ConnectionAdapters::Column
77
delegate :is_identity, :is_primary, :table_name, :ordinal_position, to: :sql_type_metadata
88

9-
def initialize(*, is_identity: nil, is_primary: nil, table_name: nil, ordinal_position: nil, **)
9+
def initialize(*, is_identity: nil, is_primary: nil, table_name: nil, ordinal_position: nil, generated_type: nil, **)
1010
super
1111
@is_identity = is_identity
1212
@is_primary = is_primary
1313
@table_name = table_name
1414
@ordinal_position = ordinal_position
15+
@generated_type = generated_type
1516
end
1617

1718
def is_identity?
@@ -31,6 +32,18 @@ def case_sensitive?
3132
collation&.match(/_CS/)
3233
end
3334

35+
def virtual?
36+
@generated_type.present?
37+
end
38+
39+
def virtual_stored?
40+
@generated_type == :stored
41+
end
42+
43+
def has_default?
44+
super && !virtual?
45+
end
46+
3447
def init_with(coder)
3548
@is_identity = coder["is_identity"]
3649
@is_primary = coder["is_primary"]

test/cases/coerced_tests.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2626,7 +2626,7 @@ class InvalidOptionsTest < ActiveRecord::TestCase
26262626
undef_method :invalid_add_column_option_exception_message
26272627
def invalid_add_column_option_exception_message(key)
26282628
default_keys = [":limit", ":precision", ":scale", ":default", ":null", ":collation", ":comment", ":primary_key", ":if_exists", ":if_not_exists"]
2629-
default_keys.concat([":is_identity"]) # SQL Server additional valid keys
2629+
default_keys.concat([":is_identity", ":as", ":stored"]) # SQL Server additional valid keys
26302630

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

0 commit comments

Comments
 (0)