Skip to content

Commit 7fa5930

Browse files
committed
Identity Inserts with Triggers
The adapter uses `OUTPUT INSERTED` so that we can select any data type key, for example UUID tables. However, this poses a problem with tables that use triggers. The solution requires that we use a more complex insert statement which uses a temporary table to select the inserted identity. To use this format you must declare your table exempt from the simple output inserted style with the table name into a concurrent hash. Optionally, you can set the data type of the table's primary key to return. ```ruby adapter = ActiveRecord::ConnectionAdapters::SQLServerAdapter adapter.exclude_output_inserted_table_names['my_table_name'] = true adapter.exclude_output_inserted_table_names['my_uuid_table_name'] = 'uniqueidentifier' ```
1 parent 3a2e404 commit 7fa5930

File tree

9 files changed

+131
-10
lines changed

9 files changed

+131
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Fixed
44

55
* Patched `Relation#build_count_subquery`. Fixes #613.
6+
* Inserts to tables with triggers using default `OUTPUT INSERTED` style. Fixes #595.
67

78

89
## v5.1.2

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ The SQL Server adapter for ActiveRecord v5.1 using SQL Server 2012 or higher.
1818

1919
Interested in older versions? We follow a rational versioning policy that tracks Rails. That means that our 5.0.x version of the adapter is only for the latest 5.0 version of Rails. If you need the adapter for SQL Server 2008 or 2005, you are still in the right spot. Just install the latest 3.2.x to 4.1.x version of the adapter that matches your Rails version. We also have stable branches for each major/minor release of ActiveRecord.
2020

21-
2221
#### Native Data Type Support
2322

2423
We support every data type supported by FreeTDS. All simplified Rails types in migrations will coorespond to a matching SQL Server national (unicode) data type. Always check the `initialize_native_database_types` [(here)](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/blob/master/lib/active_record/connection_adapters/sqlserver/schema_statements.rb#L243) for an updated list.
@@ -28,6 +27,21 @@ The following types (date, datetime2, datetimeoffset, time) all require TDS vers
2827
The Rails v5 adapter supports ActiveRecord's `datetime_with_precision` setting. This means that passing `:precision` to a datetime column is supported. Using a pecision with the `:datetime` type will signal the adapter to use the `datetime2` type under the hood.
2928

3029

30+
#### Identity Inserts with Triggers
31+
32+
The adapter uses `OUTPUT INSERTED` so that we can select any data type key, for example UUID tables. However, this poses a problem with tables that use triggers. The solution requires that we use a more complex insert statement which uses a temporary table to select the inserted identity. To use this format you must declare your table exempt from the simple output inserted style with the table name into a concurrent hash. Optionally, you can set the data type of the table's primary key to return.
33+
34+
```ruby
35+
adapter = ActiveRecord::ConnectionAdapters::SQLServerAdapter
36+
37+
# Will assume `bigint` as the id key temp table type.
38+
adapter.exclude_output_inserted_table_names['my_table_name'] = true
39+
40+
# Explicitly set the data type for the temporary key table.
41+
adapter.exclude_output_inserted_table_names['my_uuid_table_name'] = 'uniqueidentifier'
42+
```
43+
44+
3145
#### Force Schema To Lowercase
3246

3347
Although it is not necessary, the Ruby convention is to use lowercase method names. If your database schema is in upper or mixed case, we can force all table and column names during the schema reflection process to be lowercase. Add this to your config/initializers file for the adapter.

lib/active_record/connection_adapters/sqlserver/database_statements.rb

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,19 @@ def sql_for_insert(sql, pk, id_value, sequence_name, binds)
192192
table_name = query_requires_identity_insert?(sql)
193193
pk = primary_key(table_name)
194194
end
195-
sql = if pk && self.class.use_output_inserted && !database_prefix_remote_server?
195+
sql = if pk && use_output_inserted? && !database_prefix_remote_server?
196196
quoted_pk = SQLServer::Utils.extract_identifiers(pk).quoted
197-
sql.dup.insert sql.index(/ (DEFAULT )?VALUES/), " OUTPUT INSERTED.#{quoted_pk}"
197+
exclude_output_inserted = exclude_output_inserted_table_name?(table_name, sql)
198+
if exclude_output_inserted
199+
id_sql_type = exclude_output_inserted.is_a?(TrueClass) ? 'bigint' : exclude_output_inserted
200+
<<-SQL.strip_heredoc
201+
DECLARE @ssaIdInsertTable table (#{quoted_pk} #{id_sql_type});
202+
#{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/), " OUTPUT INSERTED.#{quoted_pk} INTO @ssaIdInsertTable"}
203+
SELECT CAST(#{quoted_pk} AS #{id_sql_type}) FROM @ssaIdInsertTable
204+
SQL
205+
else
206+
sql.dup.insert sql.index(/ (DEFAULT )?VALUES/), " OUTPUT INSERTED.#{quoted_pk}"
207+
end
198208
else
199209
"#{sql}; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident"
200210
end
@@ -279,6 +289,21 @@ def raw_connection_do(sql)
279289

280290
# === SQLServer Specific (Identity Inserts) ===================== #
281291

292+
def use_output_inserted?
293+
self.class.use_output_inserted
294+
end
295+
296+
def exclude_output_inserted_table_names?
297+
!self.class.exclude_output_inserted_table_names.empty?
298+
end
299+
300+
def exclude_output_inserted_table_name?(table_name, sql)
301+
return false unless exclude_output_inserted_table_names?
302+
table_name ||= get_table_name(sql)
303+
return false unless table_name
304+
self.class.exclude_output_inserted_table_names[table_name]
305+
end
306+
282307
def exec_insert_requires_identity?(sql, pk, binds)
283308
query_requires_identity_insert?(sql) if pk && binds.map(&:name).include?(pk)
284309
end
@@ -287,7 +312,8 @@ def query_requires_identity_insert?(sql)
287312
if insert_sql?(sql)
288313
table_name = get_table_name(sql)
289314
id_column = identity_columns(table_name).first
290-
id_column && sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ? quote_table_name(table_name) : false
315+
# id_column && sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ? quote_table_name(table_name) : false
316+
id_column && sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ? table_name : false
291317
else
292318
false
293319
end

lib/active_record/connection_adapters/sqlserver_adapter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ class SQLServerAdapter < AbstractAdapter
4545

4646
cattr_accessor :cs_equality_operator, instance_accessor: false
4747
cattr_accessor :use_output_inserted, instance_accessor: false
48+
cattr_accessor :exclude_output_inserted_table_names, instance_accessor: false
4849
cattr_accessor :showplan_option, instance_accessor: false
4950
cattr_accessor :lowercase_schema_reflection
5051

5152
self.cs_equality_operator = 'COLLATE Latin1_General_CS_AS_WS'
5253
self.use_output_inserted = true
54+
self.exclude_output_inserted_table_names = Concurrent::Map.new { false }
5355

5456
def initialize(connection, logger = nil, config = {})
5557
super(connection, logger, config)

test/cases/adapter_test_sqlserver.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,12 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
140140
end
141141

142142
it 'return quoted table_name to #query_requires_identity_insert? when INSERT sql contains id column' do
143-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql)
144-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unquoted)
145-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unordered)
146-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql_sp)
147-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unquoted_sp)
148-
assert_equal '[funny_jokes]', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unordered_sp)
143+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql)
144+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unquoted)
145+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unordered)
146+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql_sp)
147+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unquoted_sp)
148+
assert_equal 'funny_jokes', connection.send(:query_requires_identity_insert?,@identity_insert_sql_unordered_sp)
149149
end
150150

151151
it 'return false to #query_requires_identity_insert? for normal SQL' do

test/cases/trigger_test_sqlserver.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# encoding: UTF-8
2+
require 'cases/helper_sqlserver'
3+
4+
class SQLServerTriggerTest < ActiveRecord::TestCase
5+
after { exclude_output_inserted_table_names.clear }
6+
7+
let(:exclude_output_inserted_table_names) do
8+
ActiveRecord::ConnectionAdapters::SQLServerAdapter.exclude_output_inserted_table_names
9+
end
10+
11+
it 'can insert into a table with output inserted - with a true setting for table name' do
12+
exclude_output_inserted_table_names['sst_table_with_trigger'] = true
13+
assert SSTestTriggerHistory.all.empty?
14+
obj = SSTestTrigger.create! event_name: 'test trigger'
15+
['Fixnum', 'Integer'].must_include obj.id.class.name
16+
obj.event_name.must_equal 'test trigger'
17+
obj.id.must_be :present?
18+
obj.id.to_s.must_equal SSTestTriggerHistory.first.id_source
19+
end
20+
21+
it 'can insert into a table with output inserted - with a uniqueidentifier value' do
22+
exclude_output_inserted_table_names['sst_table_with_uuid_trigger'] = 'uniqueidentifier'
23+
assert SSTestTriggerHistory.all.empty?
24+
obj = SSTestTriggerUuid.create! event_name: 'test uuid trigger'
25+
obj.id.class.name.must_equal 'String'
26+
obj.event_name.must_equal 'test uuid trigger'
27+
obj.id.must_be :present?
28+
obj.id.to_s.must_equal SSTestTriggerHistory.first.id_source
29+
end
30+
end

test/models/sqlserver/trigger.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class SSTestTrigger < ActiveRecord::Base
2+
self.table_name = 'sst_table_with_trigger'
3+
end
4+
5+
class SSTestTriggerUuid < ActiveRecord::Base
6+
self.table_name = 'sst_table_with_uuid_trigger'
7+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class SSTestTriggerHistory < ActiveRecord::Base
2+
self.table_name = 'sst_table_with_trigger_history'
3+
end

test/schema/sqlserver_specific_schema.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,44 @@
177177
FROM sst_string_defaults
178178
STRINGDEFAULTSBIGVIEW
179179

180+
# Trigger
181+
182+
execute "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sst_table_with_trigger') DROP TABLE sst_table_with_trigger"
183+
execute "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sst_table_with_trigger_history') DROP TABLE sst_table_with_trigger_history"
184+
execute <<-SQL
185+
CREATE TABLE sst_table_with_trigger(
186+
id bigint IDENTITY NOT NULL PRIMARY KEY,
187+
event_name nvarchar(255)
188+
)
189+
CREATE TABLE sst_table_with_trigger_history(
190+
id bigint IDENTITY NOT NULL PRIMARY KEY,
191+
id_source nvarchar(36),
192+
event_name nvarchar(255)
193+
)
194+
SQL
195+
execute <<-SQL
196+
CREATE TRIGGER sst_table_with_trigger_t ON sst_table_with_trigger
197+
FOR INSERT
198+
AS
199+
INSERT INTO sst_table_with_trigger_history (id_source, event_name)
200+
SELECT id AS id_source, event_name FROM INSERTED
201+
SQL
202+
203+
execute "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sst_table_with_uuid_trigger') DROP TABLE sst_table_with_uuid_trigger"
204+
execute <<-SQL
205+
CREATE TABLE sst_table_with_uuid_trigger(
206+
id uniqueidentifier DEFAULT NEWID() PRIMARY KEY,
207+
event_name nvarchar(255)
208+
)
209+
SQL
210+
execute <<-SQL
211+
CREATE TRIGGER sst_table_with_uuid_trigger_t ON sst_table_with_uuid_trigger
212+
FOR INSERT
213+
AS
214+
INSERT INTO sst_table_with_trigger_history (id_source, event_name)
215+
SELECT id AS id_source, event_name FROM INSERTED
216+
SQL
217+
180218
# Another schema.
181219

182220
create_table :sst_schema_columns, force: true do |t|

0 commit comments

Comments
 (0)