From dcfc7e114bf35b7f1fb25ec163a9f95f9d3d0e9d Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 4 Oct 2025 14:30:45 +0100 Subject: [PATCH 1/8] Added support for computed columns --- CHANGELOG.md | 3 +- .../sqlserver/schema_creation.rb | 27 +++-- .../sqlserver/schema_dumper.rb | 26 ++-- .../sqlserver/schema_statements.rb | 99 +++++++++------ .../sqlserver/table_definition.rb | 4 +- .../connection_adapters/sqlserver_adapter.rb | 4 + .../connection_adapters/sqlserver_column.rb | 18 ++- test/cases/virtual_column_test_sqlserver.rb | 113 ++++++++++++++++++ 8 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 test/cases/virtual_column_test_sqlserver.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 050b62f36..2bcf7231f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ - [#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`. -- [#1317](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1317) Reverse order of values when upserting. +- [#](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/) Added support for computed columns. #### Changed - [#1273](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1273) TinyTDS v3+ is now required. +- [#1317](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1317) Reverse order of values when upserting. - [#1343](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1343) Support more Azure services by changing language source. #### Fixed diff --git a/lib/active_record/connection_adapters/sqlserver/schema_creation.rb b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb index 866d2fab3..93a624dcc 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_creation.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb @@ -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 @@ -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 diff --git a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb index d5bb1b348..0c8101d83 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb @@ -4,22 +4,32 @@ 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? + spec = { type: schema_type(column).inspect }.merge!(spec) + 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 diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index ff9eaa5de..b6da46043 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -85,41 +85,58 @@ def index_include_columns(table_name, index_name) select_all(sql, "SCHEMA").map { |row| row["column_name"] } end - 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 - ) + + # def columns(table_name) + # return [] if table_name.blank? + # + # definitions = column_definitions(table_name) + # definitions.map do |field| + # new_column_from_field(table_name, field, definitions) + # end + # end + + 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) + + + + + if generated_type.present? + # binding.pry if field[:name] == "mutated_name" + + + default_function = field[:computed_formula] + else + default_function = field[:default_function] end - end - def new_column(name, cast_type, default, sql_type_metadata, null, default_function = nil, collation = nil, comment = nil, sqlserver_options = {}) + 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) @@ -512,15 +529,19 @@ 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 = { + # 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"], + # is_computed: ci["is_computed"], + # is_persisted: ci["is_persisted"] + # } + + 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) @@ -640,7 +661,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 @@ -659,6 +683,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} diff --git a/lib/active_record/connection_adapters/sqlserver/table_definition.rb b/lib/active_record/connection_adapters/sqlserver/table_definition.rb index 5800ef0ca..2707e801c 100644 --- a/lib/active_record/connection_adapters/sqlserver/table_definition.rb +++ b/lib/active_record/connection_adapters/sqlserver/table_definition.rb @@ -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 @@ -117,7 +119,7 @@ def new_column_definition(name, type, **options) private def valid_column_definition_options - super + [:is_identity] + super + [:is_identity, :as, :type, :stored] end end diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index c33c0bb67..9f8810ed1 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -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 diff --git a/lib/active_record/connection_adapters/sqlserver_column.rb b/lib/active_record/connection_adapters/sqlserver_column.rb index 86d106750..8ab251d0b 100644 --- a/lib/active_record/connection_adapters/sqlserver_column.rb +++ b/lib/active_record/connection_adapters/sqlserver_column.rb @@ -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? @@ -31,6 +32,21 @@ def case_sensitive? collation&.match(/_CS/) end + def virtual? + + # binding.pry + + @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"] diff --git a/test/cases/virtual_column_test_sqlserver.rb b/test/cases/virtual_column_test_sqlserver.rb new file mode 100644 index 000000000..e92999dc8 --- /dev/null +++ b/test/cases/virtual_column_test_sqlserver.rb @@ -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, type: :string, as: "UPPER(name)", stored: true + t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false + t.virtual :octet_name, type: :integer, as: "LEN(name)" + t.virtual :mutated_name, type: :string, 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, type: :integer, 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, type: :integer, 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, type: :integer, 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+type: :string,\s+as: "\(lower\(\[name\]\)\)", stored: false$/i, output) + assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "\(upper\(\[name\]\)\)", stored: true$/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 + From c2b99521ac71d6f5a3273d4ccdd50b10cfef18b6 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 12:20:42 +0100 Subject: [PATCH 2/8] Standardrb --- CHANGELOG.md | 2 +- .../sqlserver/schema_creation.rb | 8 +++--- .../sqlserver/schema_dumper.rb | 2 +- .../sqlserver/schema_statements.rb | 28 +++++++------------ .../connection_adapters/sqlserver_column.rb | 3 -- test/cases/virtual_column_test_sqlserver.rb | 3 +- 6 files changed, 17 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcf7231f..810a557b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +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`. -- [#](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/) Added support for computed columns. +- [#1367](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1367) Added support for computed columns. #### Changed diff --git a/lib/active_record/connection_adapters/sqlserver/schema_creation.rb b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb index 93a624dcc..c7661a47d 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_creation.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb @@ -66,11 +66,11 @@ def add_column_options!(sql, options) sql << " DEFAULT #{quote_default_expression_for_column_definition(options[:default], options[:column])}" if options_include_default?(options) 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 + 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] + if (as = options[:as]) sql << " AS #{as}" sql << " PERSISTED" if options[:stored] end diff --git a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb index 0c8101d83..ce04e30b0 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb @@ -14,7 +14,7 @@ def prepare_column_options(column) if @connection.supports_virtual_columns? && column.virtual? spec[:as] = extract_expression_for_virtual_column(column) spec[:stored] = column.virtual_stored? - spec = { type: schema_type(column).inspect }.merge!(spec) + spec = {type: schema_type(column).inspect}.merge!(spec) end spec diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index b6da46043..1f7c1a3f3 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -85,34 +85,26 @@ def index_include_columns(table_name, index_name) select_all(sql, "SCHEMA").map { |row| row["column_name"] } end + def columns(table_name) + return [] if table_name.blank? - # def columns(table_name) - # return [] if table_name.blank? - # - # definitions = column_definitions(table_name) - # definitions.map do |field| - # new_column_from_field(table_name, field, definitions) - # end - # end + definitions = column_definitions(table_name) + definitions.map do |field| + new_column_from_field(table_name, field, definitions) + end + end 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) - - - - if generated_type.present? - # binding.pry if field[:name] == "mutated_name" - - - default_function = field[:computed_formula] + default_function = if generated_type.present? + field[:computed_formula] else - default_function = field[:default_function] + field[:default_function] end - SQLServer::Column.new( field[:name], lookup_cast_type(field[:type]), diff --git a/lib/active_record/connection_adapters/sqlserver_column.rb b/lib/active_record/connection_adapters/sqlserver_column.rb index 8ab251d0b..d30ba7444 100644 --- a/lib/active_record/connection_adapters/sqlserver_column.rb +++ b/lib/active_record/connection_adapters/sqlserver_column.rb @@ -33,9 +33,6 @@ def case_sensitive? end def virtual? - - # binding.pry - @generated_type.present? end diff --git a/test/cases/virtual_column_test_sqlserver.rb b/test/cases/virtual_column_test_sqlserver.rb index e92999dc8..d75176eb4 100644 --- a/test/cases/virtual_column_test_sqlserver.rb +++ b/test/cases/virtual_column_test_sqlserver.rb @@ -12,7 +12,7 @@ class VirtualColumn < ActiveRecord::Base def setup @connection = ActiveRecord::Base.lease_connection @connection.create_table :virtual_columns, force: true do |t| - t.string :name + t.string :name t.virtual :upper_name, type: :string, as: "UPPER(name)", stored: true t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false t.virtual :octet_name, type: :integer, as: "LEN(name)" @@ -110,4 +110,3 @@ def test_build_fixture_sql assert_equal 2, fixtures.size end end - From c5042004594bcc861504e7dafc8103d071df5e25 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 12:31:42 +0100 Subject: [PATCH 3/8] Update table_definition.rb --- .../connection_adapters/sqlserver/table_definition.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver/table_definition.rb b/lib/active_record/connection_adapters/sqlserver/table_definition.rb index 2707e801c..e68a17785 100644 --- a/lib/active_record/connection_adapters/sqlserver/table_definition.rb +++ b/lib/active_record/connection_adapters/sqlserver/table_definition.rb @@ -119,7 +119,7 @@ def new_column_definition(name, type, **options) private def valid_column_definition_options - super + [:is_identity, :as, :type, :stored] + super + [:is_identity, :as, :stored] end end From ec766d0520b33e625913c0a847ed151b1d922a16 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 12:33:44 +0100 Subject: [PATCH 4/8] Update coerced_tests.rb --- test/cases/coerced_tests.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index caee1e15b..92658d785 100644 --- a/test/cases/coerced_tests.rb +++ b/test/cases/coerced_tests.rb @@ -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 From 7a9b2b7b5f2a393d966b3cbeed46bf83b1f815ae Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 13:39:57 +0100 Subject: [PATCH 5/8] Type not required for virtual column --- .../sqlserver/schema_dumper.rb | 1 - test/cases/virtual_column_test_sqlserver.rb | 18 +++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb index ce04e30b0..775754b5b 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb @@ -14,7 +14,6 @@ def prepare_column_options(column) if @connection.supports_virtual_columns? && column.virtual? spec[:as] = extract_expression_for_virtual_column(column) spec[:stored] = column.virtual_stored? - spec = {type: schema_type(column).inspect}.merge!(spec) end spec diff --git a/test/cases/virtual_column_test_sqlserver.rb b/test/cases/virtual_column_test_sqlserver.rb index d75176eb4..1a102f9ba 100644 --- a/test/cases/virtual_column_test_sqlserver.rb +++ b/test/cases/virtual_column_test_sqlserver.rb @@ -13,10 +13,10 @@ def setup @connection = ActiveRecord::Base.lease_connection @connection.create_table :virtual_columns, force: true do |t| t.string :name - t.virtual :upper_name, type: :string, as: "UPPER(name)", stored: true - t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false - t.virtual :octet_name, type: :integer, as: "LEN(name)" - t.virtual :mutated_name, type: :string, as: "REPLACE(name, 'l', 'L')" + 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) @@ -68,7 +68,7 @@ def test_virtual_column_with_comma_in_definition def test_change_table_with_stored_generated_column @connection.change_table :virtual_columns do |t| - t.virtual :decr_column1, type: :integer, as: "column1 - 1", stored: true + t.virtual :decr_column1, as: "column1 - 1", stored: true end VirtualColumn.reset_column_information column = VirtualColumn.columns_hash["decr_column1"] @@ -79,7 +79,7 @@ def test_change_table_with_stored_generated_column def test_change_table_with_explicit_virtual_generated_column @connection.change_table :virtual_columns do |t| - t.virtual :incr_column1, type: :integer, as: "column1 + 1", stored: false + t.virtual :incr_column1, as: "column1 + 1", stored: false end VirtualColumn.reset_column_information column = VirtualColumn.columns_hash["incr_column1"] @@ -90,7 +90,7 @@ def test_change_table_with_explicit_virtual_generated_column def test_change_table_with_implicit_virtual_generated_column @connection.change_table :virtual_columns do |t| - t.virtual :sqr_column1, type: :integer, as: "power(column1, 2)" + t.virtual :sqr_column1, as: "power(column1, 2)" end VirtualColumn.reset_column_information column = VirtualColumn.columns_hash["sqr_column1"] @@ -101,8 +101,8 @@ def test_change_table_with_implicit_virtual_generated_column def test_schema_dumping output = dump_table_schema("virtual_columns") - assert_match(/t\.virtual\s+"lower_name",\s+type: :string,\s+as: "\(lower\(\[name\]\)\)", stored: false$/i, output) - assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "\(upper\(\[name\]\)\)", stored: true$/i, output) + 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) end def test_build_fixture_sql From e53a0b792487d6299683b74e3f6b948399eece03 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 14:53:20 +0100 Subject: [PATCH 6/8] Update schema_statements.rb --- .../sqlserver/schema_statements.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index 1f7c1a3f3..8cedbba5d 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -521,18 +521,6 @@ 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"], - # is_computed: ci["is_computed"], - # is_persisted: ci["is_persisted"] - # } - 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 From 409c1a3ceecc5a4bba34ea9384e71c4871df13e7 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 8 Oct 2025 15:55:49 +0100 Subject: [PATCH 7/8] Test that by default virtual columns are not persisted --- test/cases/virtual_column_test_sqlserver.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/cases/virtual_column_test_sqlserver.rb b/test/cases/virtual_column_test_sqlserver.rb index 1a102f9ba..44d740552 100644 --- a/test/cases/virtual_column_test_sqlserver.rb +++ b/test/cases/virtual_column_test_sqlserver.rb @@ -103,6 +103,7 @@ 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 From c2d05aeda589c835f778f296e2b8b3e81e588ea0 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Thu, 9 Oct 2025 09:46:26 +0100 Subject: [PATCH 8/8] Add documentation --- README.md | 13 +++++++++++++ .../sqlserver/schema_statements.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fca8a2489..0aa011417 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index 8cedbba5d..d5a28f438 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -94,7 +94,7 @@ def columns(table_name) end end - def new_column_from_field(table_name, field, definitions) + 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)