diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff2dfdbb..810a557b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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_creation.rb b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb index 866d2fab3..c7661a47d 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..775754b5b 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb @@ -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 diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index ff9eaa5de..d5a28f438 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -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) @@ -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) @@ -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 @@ -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} diff --git a/lib/active_record/connection_adapters/sqlserver/table_definition.rb b/lib/active_record/connection_adapters/sqlserver/table_definition.rb index 5800ef0ca..e68a17785 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, :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..d30ba7444 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,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"] diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index f3ec9de32..a7429f7d1 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 diff --git a/test/cases/virtual_column_test_sqlserver.rb b/test/cases/virtual_column_test_sqlserver.rb new file mode 100644 index 000000000..44d740552 --- /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, 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