Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
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
113 changes: 113 additions & 0 deletions test/cases/virtual_column_test_sqlserver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

require "cases/helper_sqlserver"
require "support/schema_dumping_helper"

class VirtualColumnTestSQLServer < ActiveRecord::TestCase
include SchemaDumpingHelper

class VirtualColumn < ActiveRecord::Base
end

def setup
@connection = ActiveRecord::Base.lease_connection
@connection.create_table :virtual_columns, force: true do |t|
t.string :name
t.virtual :upper_name, as: "UPPER(name)", stored: true
t.virtual :lower_name, as: "LOWER(name)", stored: false
t.virtual :octet_name, as: "LEN(name)"
t.virtual :mutated_name, as: "REPLACE(name, 'l', 'L')"
t.integer :column1
end
VirtualColumn.create(name: "Rails", column1: 10)
end

def teardown
@connection.drop_table :virtual_columns, if_exists: true
VirtualColumn.reset_column_information
end

def test_virtual_column_with_full_inserts
partial_inserts_was = VirtualColumn.partial_inserts
VirtualColumn.partial_inserts = false
assert_nothing_raised do
VirtualColumn.create!(name: "Rails")
end
ensure
VirtualColumn.partial_inserts = partial_inserts_was
end

def test_stored_column
column = VirtualColumn.columns_hash["upper_name"]
assert_predicate column, :virtual?
assert_predicate column, :virtual_stored?
assert_equal "RAILS", VirtualColumn.take.upper_name
end

def test_explicit_virtual_column
column = VirtualColumn.columns_hash["lower_name"]
assert_predicate column, :virtual?
assert_not_predicate column, :virtual_stored?
assert_equal "rails", VirtualColumn.take.lower_name
end

def test_implicit_virtual_column
column = VirtualColumn.columns_hash["octet_name"]
assert_predicate column, :virtual?
assert_not_predicate column, :virtual_stored?
assert_equal 5, VirtualColumn.take.octet_name
end

def test_virtual_column_with_comma_in_definition
column = VirtualColumn.columns_hash["mutated_name"]
assert_predicate column, :virtual?
assert_not_predicate column, :virtual_stored?
assert_not_nil column.default_function
assert_equal "RaiLs", VirtualColumn.take.mutated_name
end

def test_change_table_with_stored_generated_column
@connection.change_table :virtual_columns do |t|
t.virtual :decr_column1, as: "column1 - 1", stored: true
end
VirtualColumn.reset_column_information
column = VirtualColumn.columns_hash["decr_column1"]
assert_predicate column, :virtual?
assert_predicate column, :virtual_stored?
assert_equal 9, VirtualColumn.take.decr_column1
end

def test_change_table_with_explicit_virtual_generated_column
@connection.change_table :virtual_columns do |t|
t.virtual :incr_column1, as: "column1 + 1", stored: false
end
VirtualColumn.reset_column_information
column = VirtualColumn.columns_hash["incr_column1"]
assert_predicate column, :virtual?
assert_not_predicate column, :virtual_stored?
assert_equal 11, VirtualColumn.take.incr_column1
end

def test_change_table_with_implicit_virtual_generated_column
@connection.change_table :virtual_columns do |t|
t.virtual :sqr_column1, as: "power(column1, 2)"
end
VirtualColumn.reset_column_information
column = VirtualColumn.columns_hash["sqr_column1"]
assert_predicate column, :virtual?
assert_not_predicate column, :virtual_stored?
assert_equal 100, VirtualColumn.take.sqr_column1
end

def test_schema_dumping
output = dump_table_schema("virtual_columns")
assert_match(/t\.virtual\s+"lower_name",\s+as: "\(lower\(\[name\]\)\)", stored: false$/i, output)
assert_match(/t\.virtual\s+"upper_name",\s+as: "\(upper\(\[name\]\)\)", stored: true$/i, output)
assert_match(/t\.virtual\s+"octet_name",\s+as: "\(len\(\[name\]\)\)", stored: false$/i, output)
end

def test_build_fixture_sql
fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :virtual_columns).first
assert_equal 2, fixtures.size
end
end