Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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

#### Changed

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ The removal of duplicates happens during the SQL query.

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]`.

#### Computed Columns

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:

```ruby
create_table :users do |t|
t.string :name
t.virtual :lower_name, as: "LOWER(name)", stored: false
t.virtual :upper_name, as: "UPPER(name)", stored: true
t.virtual :name_length, as: "LEN(name)"
end
```

## New Rails Applications

When creating a new Rails application you need to perform the following steps to connect a Rails application to a
Expand Down
27 changes: 16 additions & 11 deletions lib/active_record/connection_adapters/sqlserver/schema_creation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ def supports_index_using?
false
end

def visit_ColumnDefinition(o)
column_sql = super
column_sql = column_sql.sub(" #{o.sql_type}", "") if o.options[:as].present?
column_sql
end

def visit_TableDefinition(o)
if_not_exists = o.if_not_exists

Expand Down Expand Up @@ -58,18 +64,17 @@ def quoted_include_columns(o)

def add_column_options!(sql, options)
sql << " DEFAULT #{quote_default_expression_for_column_definition(options[:default], options[:column])}" if options_include_default?(options)
if options[:collation].present?
sql << " COLLATE #{options[:collation]}"
end
if options[:null] == false
sql << " NOT NULL"
end
if options[:is_identity] == true
sql << " IDENTITY(1,1)"
end
if options[:primary_key] == true
sql << " PRIMARY KEY"

sql << " COLLATE #{options[:collation]}" if options[:collation].present?
sql << " NOT NULL" if options[:null] == false
sql << " IDENTITY(1,1)" if options[:is_identity] == true
sql << " PRIMARY KEY" if options[:primary_key] == true

if (as = options[:as])
sql << " AS #{as}"
sql << " PERSISTED" if options[:stored]
end

sql
end

Expand Down
25 changes: 17 additions & 8 deletions lib/active_record/connection_adapters/sqlserver/schema_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ module ActiveRecord
module ConnectionAdapters
module SQLServer
class SchemaDumper < ConnectionAdapters::SchemaDumper
SQLSEVER_NO_LIMIT_TYPES = [
"text",
"ntext",
"varchar(max)",
"nvarchar(max)",
"varbinary(max)"
].freeze
SQLSERVER_NO_LIMIT_TYPES = %w[text ntext varchar(max) nvarchar(max) varbinary(max)].freeze

private

def prepare_column_options(column)
spec = super

if @connection.supports_virtual_columns? && column.virtual?
spec[:as] = extract_expression_for_virtual_column(column)
spec[:stored] = column.virtual_stored?
end

spec
end

def extract_expression_for_virtual_column(column)
column.default_function.inspect
end

def explicit_primary_key_default?(column)
column.type == :integer && !column.is_identity?
end

def schema_limit(column)
return if SQLSEVER_NO_LIMIT_TYPES.include?(column.sql_type)
return if SQLSERVER_NO_LIMIT_TYPES.include?(column.sql_type)

super
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,38 +88,47 @@ def index_include_columns(table_name, index_name)
def columns(table_name)
return [] if table_name.blank?

column_definitions(table_name).map do |ci|
sqlserver_options = ci.slice :ordinal_position, :is_primary, :is_identity, :table_name
sql_type_metadata = fetch_type_metadata ci[:type], sqlserver_options

new_column(
ci[:name],
lookup_cast_type(ci[:type]),
ci[:default_value],
sql_type_metadata,
ci[:null],
ci[:default_function],
ci[:collation],
nil,
sqlserver_options
)
definitions = column_definitions(table_name)
definitions.map do |field|
new_column_from_field(table_name, field, definitions)
end
end

def new_column(name, cast_type, default, sql_type_metadata, null, default_function = nil, collation = nil, comment = nil, sqlserver_options = {})
def new_column_from_field(_table_name, field, _definitions)
sqlserver_options = field.slice(:ordinal_position, :is_primary, :is_identity, :table_name)
sql_type_metadata = fetch_type_metadata(field[:type], sqlserver_options)
generated_type = extract_generated_type(field)

default_function = if generated_type.present?
field[:computed_formula]
else
field[:default_function]
end

SQLServer::Column.new(
name,
cast_type,
default,
field[:name],
lookup_cast_type(field[:type]),
field[:default_value],
sql_type_metadata,
null,
field[:null],
default_function,
collation: collation,
comment: comment,
collation: field[:collation],
comment: nil,
generated_type: generated_type,
**sqlserver_options
)
end

def extract_generated_type(field)
if field[:is_computed]
if field[:is_persisted]
:stored
else
:virtual
end
end
end

def primary_keys(table_name)
primaries = primary_keys_select(table_name)
primaries.present? ? primaries : identity_columns(table_name).map(&:name)
Expand Down Expand Up @@ -512,15 +521,7 @@ def column_definitions(table_name)
raise ActiveRecord::StatementInvalid, "Table '#{table_name}' doesn't exist" if results.empty?

results.map do |ci|
col = {
name: ci["name"],
numeric_scale: ci["numeric_scale"],
numeric_precision: ci["numeric_precision"],
datetime_precision: ci["datetime_precision"],
collation: ci["collation"],
ordinal_position: ci["ordinal_position"],
length: ci["length"]
}
col = ci.slice("name", "numeric_scale", "numeric_precision", "datetime_precision", "collation", "ordinal_position", "length", "is_computed", "is_persisted", "computed_formula").symbolize_keys

col[:table_name] = view_exists ? view_table_name(table_name) : table_name
col[:type] = column_type(ci: ci)
Expand Down Expand Up @@ -640,7 +641,10 @@ def column_definitions_sql(database, identifier)
WHEN ic.object_id IS NOT NULL
THEN 1
END AS [is_primary],
c.is_identity AS [is_identity]
c.is_identity AS [is_identity],
c.is_computed AS [is_computed],
cc.is_persisted AS [is_persisted],
cc.definition AS [computed_formula]
FROM #{database}.sys.columns c
INNER JOIN #{database}.sys.objects o
ON c.object_id = o.object_id
Expand All @@ -659,6 +663,9 @@ def column_definitions_sql(database, identifier)
ON k.parent_object_id = ic.object_id
AND k.unique_index_id = ic.index_id
AND c.column_id = ic.column_id
LEFT OUTER JOIN #{database}.sys.computed_columns cc
ON c.object_id = cc.object_id
AND c.column_id = cc.column_id
WHERE
o.Object_ID = Object_ID(#{object_id_arg})
AND s.name = #{schema_name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ def new_column_definition(name, type, **options)
type = :datetime2 unless options[:precision].nil?
when :primary_key
options[:is_identity] = true
when :virtual
type = options[:type]
end

super
Expand All @@ -117,7 +119,7 @@ def new_column_definition(name, type, **options)
private

def valid_column_definition_options
super + [:is_identity]
super + [:is_identity, :as, :stored]
end
end

Expand Down
4 changes: 4 additions & 0 deletions lib/active_record/connection_adapters/sqlserver_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ def supports_insert_conflict_target?
false
end

def supports_virtual_columns?
true
end

def return_value_after_insert?(column) # :nodoc:
column.is_primary? || column.is_identity?
end
Expand Down
15 changes: 14 additions & 1 deletion lib/active_record/connection_adapters/sqlserver_column.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ module SQLServer
class Column < ConnectionAdapters::Column
delegate :is_identity, :is_primary, :table_name, :ordinal_position, to: :sql_type_metadata

def initialize(*, is_identity: nil, is_primary: nil, table_name: nil, ordinal_position: nil, **)
def initialize(*, is_identity: nil, is_primary: nil, table_name: nil, ordinal_position: nil, generated_type: nil, **)
super
@is_identity = is_identity
@is_primary = is_primary
@table_name = table_name
@ordinal_position = ordinal_position
@generated_type = generated_type
end

def is_identity?
Expand All @@ -31,6 +32,18 @@ def case_sensitive?
collation&.match(/_CS/)
end

def virtual?
@generated_type.present?
end

def virtual_stored?
@generated_type == :stored
end

def has_default?
super && !virtual?
end

def init_with(coder)
@is_identity = coder["is_identity"]
@is_primary = coder["is_primary"]
Expand Down
2 changes: 1 addition & 1 deletion test/cases/coerced_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2626,7 +2626,7 @@ class InvalidOptionsTest < ActiveRecord::TestCase
undef_method :invalid_add_column_option_exception_message
def invalid_add_column_option_exception_message(key)
default_keys = [":limit", ":precision", ":scale", ":default", ":null", ":collation", ":comment", ":primary_key", ":if_exists", ":if_not_exists"]
default_keys.concat([":is_identity"]) # SQL Server additional valid keys
default_keys.concat([":is_identity", ":as", ":stored"]) # SQL Server additional valid keys

"Unknown key: :#{key}. Valid keys are: #{default_keys.join(", ")}"
end
Expand Down
Loading