From 6e227013a6625388ad8b7c42546939ed39e08f1e Mon Sep 17 00:00:00 2001 From: Alexandros Giouzenis Date: Fri, 9 May 2014 19:46:46 +0300 Subject: [PATCH] Rails 4 compatible UUID Support --- .../sqlserver/database_statements.rb | 11 +- .../connection_adapters/sqlserver/quoting.rb | 11 ++ .../sqlserver/schema_creation.rb | 29 ++++ .../sqlserver/schema_statements.rb | 15 ++- .../sqlserver/table_definition.rb | 25 ++++ .../connection_adapters/sqlserver_adapter.rb | 13 +- test/cases/uuid_test_sqlserver.rb | 127 ++++++++++++++++++ 7 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 lib/active_record/connection_adapters/sqlserver/schema_creation.rb create mode 100644 lib/active_record/connection_adapters/sqlserver/table_definition.rb create mode 100644 test/cases/uuid_test_sqlserver.rb diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 35a74ae3c..359e5e445 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -305,14 +305,15 @@ def select(sql, name = nil, binds = []) end def sql_for_insert(sql, pk, id_value, sequence_name, binds) - sql = "#{sql}; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident"# unless binds.empty? + sql = + if pk + sql.insert(sql.index(/ (DEFAULT )?VALUES/), " OUTPUT inserted.#{pk}") + else + "#{sql}; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident" + end super end - def last_inserted_id(result) - super || select_value('SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident') - end - # === SQLServer Specific ======================================== # def valid_isolation_levels diff --git a/lib/active_record/connection_adapters/sqlserver/quoting.rb b/lib/active_record/connection_adapters/sqlserver/quoting.rb index 1aa1bd12a..bac830173 100644 --- a/lib/active_record/connection_adapters/sqlserver/quoting.rb +++ b/lib/active_record/connection_adapters/sqlserver/quoting.rb @@ -12,6 +12,8 @@ def quote(value, column = nil) value.to_i.to_s elsif column && column.type == :binary column.class.string_to_binary(value) + elsif column && [:uuid, :uniqueidentifier].include?(column.type) + "'#{quote_string(value)}'" elsif value.is_utf8? || (column && column.type == :string) "#{quoted_string_prefix}'#{quote_string(value)}'" else @@ -52,6 +54,15 @@ def quote_database_name(name) schema_cache.quote_name(name, false) end + # Does not quote function default values for UUID columns + def quote_default_value(value, column) + if column.type == :uuid && value =~ /\(\)/ + value + else + quote(value) + end + end + def substitute_at(column, index) if column.respond_to?(:sql_type) && column.sql_type == 'timestamp' nil diff --git a/lib/active_record/connection_adapters/sqlserver/schema_creation.rb b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb new file mode 100644 index 000000000..920d8585e --- /dev/null +++ b/lib/active_record/connection_adapters/sqlserver/schema_creation.rb @@ -0,0 +1,29 @@ +module ActiveRecord + module ConnectionAdapters + module Sqlserver + class SchemaCreation < AbstractAdapter::SchemaCreation + private + + def visit_ColumnDefinition(o) + sql = super + if o.primary_key? && o.type == :uuid + sql << " PRIMARY KEY " + add_column_options!(sql, column_options(o)) + end + sql + end + + def add_column_options!(sql, options) + column = options.fetch(:column) { return super } + if [:uniqueidentifier, :uuid].include?(column.type) && options[:default] =~ /\(\)/ + sql << " DEFAULT #{options.delete(:default)}" + super + else + super + end + end + end + end + end +end + diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index c3216ebd1..66a4b18f8 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -80,11 +80,11 @@ def change_column(table_name, column_name, type, options = {}) indexes = indexes(table_name).select { |index| index.columns.include?(column_name.to_s) } remove_indexes(table_name, column_name) end - sql_commands << "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(options[:default])} WHERE #{quote_column_name(column_name)} IS NULL" if !options[:null].nil? && options[:null] == false && !options[:default].nil? + sql_commands << "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_value(options[:default], column_object)} WHERE #{quote_column_name(column_name)} IS NULL" if !options[:null].nil? && options[:null] == false && !options[:default].nil? sql_commands << "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}" sql_commands[-1] << ' NOT NULL' if !options[:null].nil? && options[:null] == false if options_include_default?(options) - sql_commands << "ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{default_constraint_name(table_name, column_name)} DEFAULT #{quote(options[:default])} FOR #{quote_column_name(column_name)}" + sql_commands << "ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{default_constraint_name(table_name, column_name)} DEFAULT #{quote_default_value(options[:default], column_object)} FOR #{quote_column_name(column_name)}" end # Add any removed indexes back @@ -96,7 +96,9 @@ def change_column(table_name, column_name, type, options = {}) def change_column_default(table_name, column_name, default) remove_default_constraint(table_name, column_name) - do_execute "ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{default_constraint_name(table_name, column_name)} DEFAULT #{quote(default)} FOR #{quote_column_name(column_name)}" + column_object = schema_cache.columns(table_name).find { |c| c.name.to_s == column_name.to_s } + do_execute "ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{default_constraint_name(table_name, column_name)} DEFAULT #{quote_default_value(default, column_object)} FOR #{quote_column_name(column_name)}" + schema_cache.clear_table_cache!(table_name) end def rename_column(table_name, column_name, new_column_name) @@ -165,6 +167,7 @@ def initialize_native_database_types date: { name: native_date_database_type }, binary: { name: native_binary_database_type }, boolean: { name: 'bit' }, + uuid: { name: 'uniqueidentifier'}, # These are custom types that may move somewhere else for good schema_dumper.rb hacking to output them. char: { name: 'char' }, varchar_max: { name: 'varchar(max)' }, @@ -386,6 +389,12 @@ def set_identity_insert(table_name, enable = true) def identity_column(table_name) schema_cache.columns(table_name).find(&:is_identity?) end + + private + + def create_table_definition(name, temporary, options) + TableDefinition.new native_database_types, name, temporary, options + end end end end diff --git a/lib/active_record/connection_adapters/sqlserver/table_definition.rb b/lib/active_record/connection_adapters/sqlserver/table_definition.rb new file mode 100644 index 000000000..18d119c7e --- /dev/null +++ b/lib/active_record/connection_adapters/sqlserver/table_definition.rb @@ -0,0 +1,25 @@ +module ActiveRecord + module ConnectionAdapters + module Sqlserver + class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition + def uuid(name, options = {}) + column(name, 'uniqueidentifier', options) + end + + def primary_key(name, type = :primary_key, options = {}) + return super unless type == :uuid + options[:default] = options.fetch(:default, 'NEWID()') + options[:primary_key] = true + column name, type, options + end + + def column(name, type = nil, options = {}) + super + self + end + end + end + end +end + + diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 4e4edaa8f..0bc59cfe7 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -14,8 +14,10 @@ require 'active_record/connection_adapters/sqlserver/database_statements' require 'active_record/connection_adapters/sqlserver/errors' require 'active_record/connection_adapters/sqlserver/schema_cache' +require 'active_record/connection_adapters/sqlserver/schema_creation' require 'active_record/connection_adapters/sqlserver/schema_statements' require 'active_record/connection_adapters/sqlserver/showplan' +require 'active_record/connection_adapters/sqlserver/table_definition' require 'active_record/connection_adapters/sqlserver/quoting' require 'active_record/connection_adapters/sqlserver/utils' @@ -140,7 +142,7 @@ def simplified_type(field_type) when /money/i then :decimal when /image/i then :binary when /bit/i then :boolean - when /uniqueidentifier/i then :string + when /uniqueidentifier/i then :uuid when /datetime/i then simplified_datetime when /varchar\(max\)/ then :text when /timestamp/ then :binary @@ -301,14 +303,18 @@ def reset! # === Abstract Adapter (Misc Support) =========================== # def pk_and_sequence_for(table_name) - idcol = identity_column(table_name) - idcol ? [idcol.name, nil] : nil + pk = primary_key(table_name) + pk ? [pk, nil] : nil end def primary_key(table_name) identity_column(table_name).try(:name) || schema_cache.columns(table_name).find(&:is_primary?).try(:name) end + def schema_creation + Sqlserver::SchemaCreation.new self + end + # === SQLServer Specific (DB Reflection) ======================== # def sqlserver? @@ -525,3 +531,4 @@ def auto_reconnected? end # class SQLServerAdapter < AbstractAdapter end # module ConnectionAdapters end # module ActiveRecord + diff --git a/test/cases/uuid_test_sqlserver.rb b/test/cases/uuid_test_sqlserver.rb new file mode 100644 index 000000000..f59abfc48 --- /dev/null +++ b/test/cases/uuid_test_sqlserver.rb @@ -0,0 +1,127 @@ +require 'cases/sqlserver_helper' + +class SQLServerUUIDTest < ActiveRecord::TestCase + class UUID < ActiveRecord::Base + self.table_name = 'sql_server_uuids' + end + + def setup + @connection = ActiveRecord::Base.connection + + @connection.reconnect! + + @connection.transaction do + @connection.create_table('sql_server_uuids', id: :uuid, default: 'NEWSEQUENTIALID()') do |t| + t.string 'name' + t.uuid 'other_uuid', default: 'NEWID()' + end + end + end + + def teardown + @connection.execute "IF OBJECT_ID('sql_server_uuids', 'U') IS NOT NULL DROP TABLE sql_server_uuids" + end + + def test_id_is_uuid + assert_equal :uuid, UUID.columns_hash['id'].type + assert UUID.primary_key + end + + def test_id_has_a_default + u = UUID.create + assert_not_nil u.id + end + + def test_auto_create_uuid + u = UUID.create + u.reload + assert_not_nil u.other_uuid + end + + def test_pk_and_sequence_for_uuid_primary_key + pk, seq = @connection.pk_and_sequence_for('sql_server_uuids') + assert_equal 'id', pk + assert_equal nil, seq + end + + def primary_key_for_uuid_primary_key + assert_equal 'id', @connection.primary_key('sql_server_uuids') + end + + def test_change_column_default + @connection.add_column :sql_server_uuids, :thingy, :uuid, null: false, default: "NEWSEQUENTIALID()" + UUID.reset_column_information + column = UUID.columns.find { |c| c.name == 'thingy' } + assert_equal "newsequentialid()", column.default_function + + @connection.change_column :sql_server_uuids, :thingy, :uuid, null: false, default: "NEWID()" + + UUID.reset_column_information + column = UUID.columns.find { |c| c.name == 'thingy' } + assert_equal "newid()", column.default_function + end +end + +class SQLServerUUIDTestNilDefault < ActiveRecord::TestCase + class UUID < ActiveRecord::Base + self.table_name = 'sql_server_uuids' + end + + def setup + @connection = ActiveRecord::Base.connection + + @connection.reconnect! + + @connection.transaction do + @connection.create_table('sql_server_uuids', id: false) do |t| + t.primary_key :id, :uuid, default: nil + t.string 'name' + end + end + end + + def teardown + @connection.execute "IF OBJECT_ID('sql_server_uuids', 'U') IS NOT NULL DROP TABLE sql_server_uuids" + end + +end + +class SQLServerUUIDTestInverseOf < ActiveRecord::TestCase + class UuidPost < ActiveRecord::Base + self.table_name = 'sql_server_uuid_posts' + has_many :uuid_comments, inverse_of: :uuid_post + end + + class UuidComment < ActiveRecord::Base + self.table_name = 'sql_server_uuid_comments' + belongs_to :uuid_post + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.reconnect! + + @connection.transaction do + @connection.create_table('sql_server_uuid_posts', id: :uuid) do |t| + t.string 'title' + end + @connection.create_table('sql_server_uuid_comments', id: :uuid) do |t| + t.uuid :uuid_post_id, default: 'NEWID()' + t.string 'content' + end + end + end + + def teardown + @connection.transaction do + @connection.execute "IF OBJECT_ID('sql_server_uuid_comments', 'U') IS NOT NULL DROP TABLE sql_server_uuid_comments" + @connection.execute "IF OBJECT_ID('sql_server_uuid_posts', 'U') IS NOT NULL DROP TABLE sql_server_uuid_posts" + end + end + + def test_collection_association_with_uuid + post = UuidPost.create! + comment = post.uuid_comments.create! + assert post.uuid_comments.find(comment.id) + end +end