activerecord-sqlserver-adapter 8.0.4 → 8.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4eb3a0a586688c3993353fb977268ed3440893c15277decb16cfa76ea6704f1f
4
- data.tar.gz: b439d90171035e52adcfe1a03b40ab55a228fd0e6d6fc272b8bde37104a8fc64
3
+ metadata.gz: 488cfb3fda58b07e4bacc2db733d766492f84d4d0af50ecd6e5548f066ac34b3
4
+ data.tar.gz: b64864d043dd035dde5413736d9fc9adb5d2bf1e364b25ef57ca2ea69bc386cf
5
5
  SHA512:
6
- metadata.gz: d7dde4f70fbd4c665701f5765acfd194a2f60c3cf540ed966d43c264fffc747c7e14a1d61674eae17648fec758d20090b47bb5086f49d8046a7ebd2494b355cd
7
- data.tar.gz: 9c85aa63eedd0a1dd1387336cac08fbaf4596c4b0aeed7309af5b92183690f8a3d4803917f234258ceb3b8245e9edc5da0866c474c08031eb50149a028c2f53c
6
+ metadata.gz: fe2eb7bbc39edbc855bba7403792e532a43cae178d1cd8943189142f61c4203b346cb0191366626422ec287fde5d77d174240bcdef37152d177acda8d1e5dad4
7
+ data.tar.gz: 88ae10b6aa5b67f2bbc6a760a2f5af7679422fea1d10086aa3bb6592aaeadc202a2657c48906e4bb8b237acd7ca87e57588de6f02590a20a32278c855777b5c8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## v8.0.5
2
+
3
+ #### Added
4
+
5
+ - [#1315](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1315) Add support for `insert_all` and `upsert_all`
6
+
1
7
  ## v8.0.4
2
8
 
3
9
  #### Fixed
data/README.md CHANGED
@@ -168,6 +168,22 @@ ActiveRecord::ConnectionAdapters::SQLServerAdapter.showplan_option = 'SHOWPLAN_X
168
168
  ```
169
169
  **NOTE:** The method we utilize to make SHOWPLANs work is very brittle to complex SQL. There is no getting around this as we have to deconstruct an already prepared statement for the sp_executesql method. If you find that explain breaks your app, simple disable it. Do not open a github issue unless you have a patch. Please [consult the Rails guides](http://guides.rubyonrails.org/active_record_querying.html#running-explain) for more info.
170
170
 
171
+ #### `insert_all` / `upsert_all` support
172
+
173
+ `insert_all` and `upsert_all` on other database system like MySQL, SQlite or PostgreSQL use a clause with their `INSERT` statement to either skip duplicates (`ON DUPLICATE KEY IGNORE`) or to update the existing record (`ON DUPLICATE KEY UPDATE`). Microsoft SQL Server does not offer these clauses, so the support for these two options is implemented slightly different.
174
+
175
+ Behind the scenes, we execute a `MERGE` query, which joins your data that you want to insert or update into the table existing on the server. The emphasis here is "JOINING", so we also need to remove any duplicates that might make the `JOIN` operation fail, e.g. something like this:
176
+
177
+ ```ruby
178
+ Book.insert_all [
179
+ { id: 200, author_id: 8, name: "Refactoring" },
180
+ { id: 200, author_id: 8, name: "Refactoring" }
181
+ ]
182
+ ```
183
+
184
+ The removal of duplicates happens during the SQL query.
185
+
186
+ 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]`.
171
187
 
172
188
  ## New Rails Applications
173
189
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 8.0.4
1
+ 8.0.5
@@ -143,18 +143,54 @@ module ActiveRecord
143
143
  private :default_insert_value
144
144
 
145
145
  def build_insert_sql(insert) # :nodoc:
146
- sql = +"INSERT #{insert.into}"
147
-
148
- if returning = insert.send(:insert_all).returning
149
- returning_sql = if returning.is_a?(String)
150
- returning
151
- else
152
- Array(returning).map { |column| "INSERTED.#{quote_column_name(column)}" }.join(", ")
153
- end
154
- sql << " OUTPUT #{returning_sql}"
146
+ # Use regular insert if not skipping/updating duplicates.
147
+ return build_sql_for_regular_insert(insert:) unless insert.skip_duplicates? || insert.update_duplicates?
148
+
149
+ insert_all = insert.send(:insert_all)
150
+ columns_with_uniqueness_constraints = get_columns_with_uniqueness_constraints(insert_all:, insert:)
151
+
152
+ # If we do not have any columns that might have conflicting values just execute a regular insert, else use merge.
153
+ if columns_with_uniqueness_constraints.flatten.empty?
154
+ build_sql_for_regular_insert(insert:)
155
+ else
156
+ build_sql_for_merge_insert(insert:, insert_all:, columns_with_uniqueness_constraints:)
155
157
  end
158
+ end
159
+
160
+
161
+ def build_sql_for_merge_insert(insert:, insert_all:, columns_with_uniqueness_constraints:) # :nodoc:
162
+ sql = <<~SQL
163
+ MERGE INTO #{insert.model.quoted_table_name} WITH (UPDLOCK, HOLDLOCK) AS target
164
+ USING (
165
+ SELECT *
166
+ FROM (
167
+ SELECT #{insert.send(:columns_list)}, #{partition_by_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)}
168
+ FROM (#{insert.values_list})
169
+ AS t1 (#{insert.send(:columns_list)})
170
+ ) AS ranked_source
171
+ WHERE #{is_first_record_across_all_uniqueness_constraints(columns_with_uniqueness_constraints:)}
172
+ ) AS source
173
+ ON (#{joining_on_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)})
174
+ SQL
175
+
176
+ if insert.update_duplicates?
177
+ sql << " WHEN MATCHED THEN UPDATE SET "
178
+
179
+ if insert.raw_update_sql?
180
+ sql << insert.raw_update_sql
181
+ else
182
+ if insert.record_timestamps?
183
+ sql << build_sql_for_recording_timestamps_when_updating(insert:)
184
+ end
185
+
186
+ sql << insert.updatable_columns.map { |column| "target.#{quote_column_name(column)}=source.#{quote_column_name(column)}" }.join(",")
187
+ end
188
+ end
189
+ sql << " WHEN NOT MATCHED BY TARGET THEN"
190
+ sql << " INSERT (#{insert.send(:columns_list)}) VALUES (#{insert_all.keys_including_timestamps.map { |column| "source.#{quote_column_name(column)}" }.join(", ")})"
191
+ sql << build_sql_for_returning(insert:, insert_all: insert.send(:insert_all))
192
+ sql << ";"
156
193
 
157
- sql << " #{insert.values_list}"
158
194
  sql
159
195
  end
160
196
 
@@ -406,11 +442,18 @@ module ActiveRecord
406
442
  raw_table_name = get_raw_table_name(sql)
407
443
  id_column = identity_columns(raw_table_name).first
408
444
 
409
- id_column && sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ? SQLServer::Utils.extract_identifiers(raw_table_name).quoted : false
445
+ if id_column && (
446
+ sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ||
447
+ sql =~ /^\s*MERGE INTO.+THEN INSERT \([^)]*\b(#{id_column.name})\b,?[^)]*\)/im
448
+ )
449
+ SQLServer::Utils.extract_identifiers(raw_table_name).quoted
450
+ else
451
+ false
452
+ end
410
453
  end
411
454
 
412
455
  def insert_sql?(sql)
413
- !(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT)/i).nil?
456
+ !(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT|MERGE INTO.+THEN INSERT)/im).nil?
414
457
  end
415
458
 
416
459
  def identity_columns(table_name)
@@ -455,6 +498,96 @@ module ActiveRecord
455
498
 
456
499
  perform_do ? result.do : result
457
500
  end
501
+
502
+ # === SQLServer Specific (insert_all / upsert_all support) ===================== #
503
+ def build_sql_for_returning(insert:, insert_all:)
504
+ return "" unless insert_all.returning
505
+
506
+ returning_values_sql = if insert_all.returning.is_a?(String)
507
+ insert_all.returning
508
+ else
509
+ Array(insert_all.returning).map do |attribute|
510
+ if insert.model.attribute_alias?(attribute)
511
+ "INSERTED.#{quote_column_name(insert.model.attribute_alias(attribute))} AS #{quote_column_name(attribute)}"
512
+ else
513
+ "INSERTED.#{quote_column_name(attribute)}"
514
+ end
515
+ end.join(",")
516
+ end
517
+
518
+ " OUTPUT #{returning_values_sql}"
519
+ end
520
+ private :build_sql_for_returning
521
+
522
+ def get_columns_with_uniqueness_constraints(insert_all:, insert:)
523
+ if (unique_by = insert_all.unique_by)
524
+ [unique_by.columns]
525
+ else
526
+ # Compare against every unique constraint (primary key included).
527
+ # Discard constraints that are not fully included on insert.keys. Prevents invalid queries.
528
+ # Example: ignore unique index for columns ["name"] if insert keys is ["description"]
529
+ (insert_all.send(:unique_indexes).map(&:columns) + [insert_all.primary_keys]).select do |columns|
530
+ columns.to_set.subset?(insert.keys)
531
+ end
532
+ end
533
+ end
534
+ private :get_columns_with_uniqueness_constraints
535
+
536
+ def build_sql_for_regular_insert(insert:)
537
+ sql = "INSERT #{insert.into}"
538
+ sql << build_sql_for_returning(insert:, insert_all: insert.send(:insert_all))
539
+ sql << " #{insert.values_list}"
540
+ sql
541
+ end
542
+ private :build_sql_for_regular_insert
543
+
544
+ # why is the "PARTITION BY" clause needed?
545
+ # in every DBMS system, insert_all / upsert_all is usually implemented with INSERT, that allows to define what happens
546
+ # when duplicates are found (SKIP OR UPDATE)
547
+ # by default rows are considered to be unique by every unique index on the table
548
+ # but since we have to use MERGE in MSSQL, which in return is a JOIN, we have to perform the "de-duplication" ourselves
549
+ # otherwise the "JOIN" clause would complain about non-unique values and being unable to JOIN the two tables
550
+ # this works easiest by using PARTITION and make sure that any record
551
+ # we are trying to insert is "the first one seen across all the potential columns with uniqueness constraints"
552
+ def partition_by_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)
553
+ columns_with_uniqueness_constraints.map.with_index do |group_of_columns_with_uniqueness_constraints, index|
554
+ <<~PARTITION_BY
555
+ ROW_NUMBER() OVER (
556
+ PARTITION BY #{group_of_columns_with_uniqueness_constraints.map { |column| quote_column_name(column) }.join(",")}
557
+ ORDER BY #{group_of_columns_with_uniqueness_constraints.map { |column| "#{quote_column_name(column)} DESC" }.join(",")}
558
+ ) AS rn_#{index}
559
+ PARTITION_BY
560
+ end.join(", ")
561
+ end
562
+ private :partition_by_columns_with_uniqueness_constraints
563
+
564
+ def is_first_record_across_all_uniqueness_constraints(columns_with_uniqueness_constraints:)
565
+ columns_with_uniqueness_constraints.map.with_index do |group_of_columns_with_uniqueness_constraints, index|
566
+ "rn_#{index} = 1"
567
+ end.join(" AND ")
568
+ end
569
+ private :is_first_record_across_all_uniqueness_constraints
570
+
571
+ def joining_on_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)
572
+ columns_with_uniqueness_constraints.map do |columns|
573
+ columns.map do |column|
574
+ "target.#{quote_column_name(column)} = source.#{quote_column_name(column)}"
575
+ end.join(" AND ")
576
+ end.join(") OR (")
577
+ end
578
+ private :joining_on_columns_with_uniqueness_constraints
579
+
580
+ # normally, generating the CASE SQL is done entirely by Rails
581
+ # and you would just hook into "touch_model_timestamps_unless" to add your database-specific instructions
582
+ # however, since we need to have "target." for the assignment, we also generate the CASE switch ourselves
583
+ def build_sql_for_recording_timestamps_when_updating(insert:)
584
+ insert.model.timestamp_attributes_for_update_in_model.filter_map do |column_name|
585
+ if insert.send(:touch_timestamp_attribute?, column_name)
586
+ "target.#{quote_column_name(column_name)}=CASE WHEN (#{insert.updatable_columns.map { |column| "(COALESCE(target.#{quote_column_name(column)}, 'NULL') = COALESCE(source.#{quote_column_name(column)}, 'NULL'))" }.join(" AND ")}) THEN target.#{quote_column_name(column_name)} ELSE #{high_precision_current_timestamp} END,"
587
+ end
588
+ end.join
589
+ end
590
+ private :build_sql_for_recording_timestamps_when_updating
458
591
  end
459
592
  end
460
593
  end
@@ -720,6 +720,8 @@ module ActiveRecord
720
720
  .match(/\s*([^(]*)/i)[0]
721
721
  elsif s.match?(/^\s*UPDATE\s+.*/i)
722
722
  s.match(/UPDATE\s+([^\(\s]+)\s*/i)[1]
723
+ elsif s.match?(/^\s*MERGE INTO.*/i)
724
+ s.match(/^\s*MERGE\s+INTO\s+(\[?[a-z_ -]+\]?\.?\[?[a-z_ -]+\]?)\s+(AS|WITH|USING)/i)[1]
723
725
  else
724
726
  s.match(/FROM[\s|\(]+((\[[^\(\]]+\])|[^\(\s]+)\s*/i)[1]
725
727
  end.strip
@@ -212,11 +212,11 @@ module ActiveRecord
212
212
  end
213
213
 
214
214
  def supports_insert_on_duplicate_skip?
215
- false
215
+ true
216
216
  end
217
217
 
218
218
  def supports_insert_on_duplicate_update?
219
- false
219
+ true
220
220
  end
221
221
 
222
222
  def supports_insert_conflict_target?
@@ -13,6 +13,7 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
13
13
  fixtures :tasks
14
14
 
15
15
  let(:basic_insert_sql) { "INSERT INTO [funny_jokes] ([name]) VALUES('Knock knock')" }
16
+ let(:basic_merge_sql) { "MERGE INTO [ships] WITH (UPDLOCK, HOLDLOCK) AS target USING ( SELECT * FROM ( SELECT [id], [name], ROW_NUMBER() OVER ( PARTITION BY [id] ORDER BY [id] DESC ) AS rn_0 FROM ( VALUES (101, N'RSS Sir David Attenborough') ) AS t1 ([id], [name]) ) AS ranked_source WHERE rn_0 = 1 ) AS source ON (target.[id] = source.[id]) WHEN MATCHED THEN UPDATE SET target.[name] = source.[name]" }
16
17
  let(:basic_update_sql) { "UPDATE [customers] SET [address_street] = NULL WHERE [id] = 2" }
17
18
  let(:basic_select_sql) { "SELECT * FROM [customers] WHERE ([customers].[id] = 1)" }
18
19
 
@@ -93,6 +94,7 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
93
94
 
94
95
  it "return unquoted table name object from basic INSERT UPDATE and SELECT statements" do
95
96
  assert_equal "funny_jokes", connection.send(:get_table_name, basic_insert_sql)
97
+ assert_equal "ships", connection.send(:get_table_name, basic_merge_sql)
96
98
  assert_equal "customers", connection.send(:get_table_name, basic_update_sql)
97
99
  assert_equal "customers", connection.send(:get_table_name, basic_select_sql)
98
100
  end
@@ -213,6 +215,10 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
213
215
  @identity_insert_sql_unquoted_sp = "EXEC sp_executesql N'INSERT INTO funny_jokes (id, name) VALUES (@0, @1)', N'@0 int, @1 nvarchar(255)', @0 = 420, @1 = N'Knock knock'"
214
216
  @identity_insert_sql_unordered_sp = "EXEC sp_executesql N'INSERT INTO [funny_jokes] ([name],[id]) VALUES (@0, @1)', N'@0 nvarchar(255), @1 int', @0 = N'Knock knock', @1 = 420"
215
217
 
218
+ @identity_merge_sql = "MERGE INTO [ships] WITH (UPDLOCK, HOLDLOCK) AS target USING ( SELECT * FROM ( SELECT [id], [name], ROW_NUMBER() OVER ( PARTITION BY [id] ORDER BY [id] DESC ) AS rn_0 FROM ( VALUES (101, N'RSS Sir David Attenborough') ) AS t1 ([id], [name]) ) AS ranked_source WHERE rn_0 = 1 ) AS source ON (target.[id] = source.[id]) WHEN MATCHED THEN UPDATE SET target.[name] = source.[name] WHEN NOT MATCHED BY TARGET THEN INSERT ([id], [name]) VALUES (source.[id], source.[name]) OUTPUT INSERTED.[id]"
219
+ @identity_merge_sql_unquoted = "MERGE INTO ships WITH (UPDLOCK, HOLDLOCK) AS target USING ( SELECT * FROM ( SELECT id, name, ROW_NUMBER() OVER ( PARTITION BY id ORDER BY id DESC ) AS rn_0 FROM ( VALUES (101, N'RSS Sir David Attenborough') ) AS t1 (id, name) ) AS ranked_source WHERE rn_0 = 1 ) AS source ON (target.id = source.id) WHEN MATCHED THEN UPDATE SET target.name = source.name WHEN NOT MATCHED BY TARGET THEN INSERT (id, name) VALUES (source.id, source.name) OUTPUT INSERTED.id"
220
+ @identity_merge_sql_unordered = "MERGE INTO [ships] WITH (UPDLOCK, HOLDLOCK) AS target USING ( SELECT * FROM ( SELECT [name], [id], ROW_NUMBER() OVER ( PARTITION BY [id] ORDER BY [id] DESC ) AS rn_0 FROM ( VALUES (101, N'RSS Sir David Attenborough') ) AS t1 ([name], [id]) ) AS ranked_source WHERE rn_0 = 1 ) AS source ON (target.[id] = source.[id]) WHEN MATCHED THEN UPDATE SET target.[name] = source.[name] WHEN NOT MATCHED BY TARGET THEN INSERT ([name], [id]) VALUES (source.[name], source.[id]) OUTPUT INSERTED.[id]"
221
+
216
222
  @identity_insert_sql_non_dbo = "INSERT INTO [test].[aliens] ([id],[name]) VALUES(420,'Mork')"
217
223
  @identity_insert_sql_non_dbo_unquoted = "INSERT INTO test.aliens ([id],[name]) VALUES(420,'Mork')"
218
224
  @identity_insert_sql_non_dbo_unordered = "INSERT INTO [test].[aliens] ([name],[id]) VALUES('Mork',420)"
@@ -229,6 +235,10 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
229
235
  assert_equal "[funny_jokes]", connection.send(:query_requires_identity_insert?, @identity_insert_sql_unquoted_sp)
230
236
  assert_equal "[funny_jokes]", connection.send(:query_requires_identity_insert?, @identity_insert_sql_unordered_sp)
231
237
 
238
+ assert_equal "[ships]", connection.send(:query_requires_identity_insert?, @identity_merge_sql)
239
+ assert_equal "[ships]", connection.send(:query_requires_identity_insert?, @identity_merge_sql_unquoted)
240
+ assert_equal "[ships]", connection.send(:query_requires_identity_insert?, @identity_merge_sql_unordered)
241
+
232
242
  assert_equal "[test].[aliens]", connection.send(:query_requires_identity_insert?, @identity_insert_sql_non_dbo)
233
243
  assert_equal "[test].[aliens]", connection.send(:query_requires_identity_insert?, @identity_insert_sql_non_dbo_unquoted)
234
244
  assert_equal "[test].[aliens]", connection.send(:query_requires_identity_insert?, @identity_insert_sql_non_dbo_unordered)
@@ -238,7 +248,7 @@ class AdapterTestSQLServer < ActiveRecord::TestCase
238
248
  end
239
249
 
240
250
  it "return false to #query_requires_identity_insert? for normal SQL" do
241
- [basic_insert_sql, basic_update_sql, basic_select_sql].each do |sql|
251
+ [basic_insert_sql, basic_merge_sql, basic_update_sql, basic_select_sql].each do |sql|
242
252
  assert !connection.send(:query_requires_identity_insert?, sql), "SQL was #{sql}"
243
253
  end
244
254
  end
@@ -376,6 +376,22 @@ module ActiveRecord
376
376
  end
377
377
 
378
378
  class CalculationsTest < ActiveRecord::TestCase
379
+ # SELECT columns must be in the GROUP clause.
380
+ coerce_tests! :test_should_count_with_group_by_qualified_name_on_loaded
381
+ def test_should_count_with_group_by_qualified_name_on_loaded_coerced
382
+ accounts = Account.group("accounts.id").select("accounts.id")
383
+
384
+ expected = {1 => 1, 2 => 1, 3 => 1, 4 => 1, 5 => 1, 6 => 1}
385
+
386
+ assert_not_predicate accounts, :loaded?
387
+ assert_equal expected, accounts.count
388
+
389
+ accounts.load
390
+
391
+ assert_predicate accounts, :loaded?
392
+ assert_equal expected, accounts.count(:id)
393
+ end
394
+
379
395
  # Fix randomly failing test. The loading of the model's schema was affecting the test.
380
396
  coerce_tests! :test_offset_is_kept
381
397
  def test_offset_is_kept_coerced
@@ -2200,35 +2216,6 @@ class EnumTest < ActiveRecord::TestCase
2200
2216
  end
2201
2217
  end
2202
2218
 
2203
- require "models/task"
2204
- class QueryCacheExpiryTest < ActiveRecord::TestCase
2205
- # SQL Server does not support skipping or upserting duplicates.
2206
- coerce_tests! :test_insert_all
2207
- def test_insert_all_coerced
2208
- assert_raises(ArgumentError, /does not support skipping duplicates/) do
2209
- Task.cache { Task.insert({ starting: Time.now }) }
2210
- end
2211
-
2212
- assert_raises(ArgumentError, /does not support upsert/) do
2213
- Task.cache { Task.upsert({ starting: Time.now }) }
2214
- end
2215
-
2216
- assert_raises(ArgumentError, /does not support upsert/) do
2217
- Task.cache { Task.upsert_all([{ starting: Time.now }]) }
2218
- end
2219
-
2220
- Task.cache do
2221
- assert_called(ActiveRecord::Base.connection_pool.query_cache, :clear, times: 1) do
2222
- Task.insert_all!([ starting: Time.now ])
2223
- end
2224
-
2225
- assert_called(ActiveRecord::Base.connection_pool.query_cache, :clear, times: 1) do
2226
- Task.insert!({ starting: Time.now })
2227
- end
2228
- end
2229
- end
2230
- end
2231
-
2232
2219
  require "models/citation"
2233
2220
  class EagerLoadingTooManyIdsTest < ActiveRecord::TestCase
2234
2221
  fixtures :citations
@@ -2507,6 +2494,24 @@ class InsertAllTest < ActiveRecord::TestCase
2507
2494
  Book.where(author_id: nil, name: '["Array"]').delete_all
2508
2495
  Book.lease_connection.add_index(:books, [:author_id, :name], unique: true)
2509
2496
  end
2497
+
2498
+ # Same as original but using target.status for assignment and CASE instead of GREATEST for operator
2499
+ coerce_tests! :test_upsert_all_updates_using_provided_sql
2500
+ def test_upsert_all_updates_using_provided_sql_coerced
2501
+ Book.upsert_all(
2502
+ [{id: 1, status: 1}, {id: 2, status: 1}],
2503
+ on_duplicate: Arel.sql(<<~SQL
2504
+ target.status = CASE
2505
+ WHEN target.status > 1 THEN target.status
2506
+ ELSE 1
2507
+ END
2508
+ SQL
2509
+ )
2510
+ )
2511
+
2512
+ assert_equal "published", Book.find(1).status
2513
+ assert_equal "written", Book.find(2).status
2514
+ end
2510
2515
  end
2511
2516
 
2512
2517
  module ActiveRecord
@@ -2524,21 +2529,6 @@ module ActiveRecord
2524
2529
  end
2525
2530
  end
2526
2531
 
2527
- # SQL Server does not support upsert. Removed dependency on `insert_all` that uses upsert.
2528
- class ActiveRecord::Encryption::ConcurrencyTest < ActiveRecord::EncryptionTestCase
2529
- undef_method :thread_encrypting_and_decrypting
2530
- def thread_encrypting_and_decrypting(thread_label)
2531
- posts = 100.times.collect { |index| EncryptedPost.create! title: "Article #{index} (#{thread_label})", body: "Body #{index} (#{thread_label})" }
2532
-
2533
- Thread.new do
2534
- posts.each.with_index do |article, index|
2535
- assert_encrypted_attribute article, :title, "Article #{index} (#{thread_label})"
2536
- article.decrypt
2537
- assert_not_encrypted_attribute article, :title, "Article #{index} (#{thread_label})"
2538
- end
2539
- end
2540
- end
2541
- end
2542
2532
 
2543
2533
  # Need to use `install_unregistered_type_fallback` instead of `install_unregistered_type_error` so that message-pack
2544
2534
  # can read and write `ActiveRecord::ConnectionAdapters::SQLServer::Type::Data` objects.
@@ -102,6 +102,24 @@ class SchemaTestSQLServer < ActiveRecord::TestCase
102
102
  end
103
103
  end
104
104
 
105
+ describe "MERGE statements" do
106
+ it do
107
+ assert_equal "[dashboards]", connection.send(:get_raw_table_name, "MERGE INTO [dashboards] AS target")
108
+ end
109
+
110
+ it do
111
+ assert_equal "lock_without_defaults", connection.send(:get_raw_table_name, "MERGE INTO lock_without_defaults AS target")
112
+ end
113
+
114
+ it do
115
+ assert_equal "[WITH - SPACES]", connection.send(:get_raw_table_name, "MERGE INTO [WITH - SPACES] AS target")
116
+ end
117
+
118
+ it do
119
+ assert_equal "[with].[select notation]", connection.send(:get_raw_table_name, "MERGE INTO [with].[select notation] AS target")
120
+ end
121
+ end
122
+
105
123
  describe 'CREATE VIEW statements' do
106
124
  it do
107
125
  assert_equal "test_table_as", connection.send(:get_raw_table_name, "CREATE VIEW test_views ( test_table_a_id, test_table_b_id ) AS SELECT test_table_as.id as test_table_a_id, test_table_bs.id as test_table_b_id FROM (test_table_as with(nolock) LEFT JOIN test_table_bs with(nolock) ON (test_table_as.id = test_table_bs.test_table_a_id))")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-sqlserver-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.4
4
+ version: 8.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken Collins
@@ -15,7 +15,7 @@ authors:
15
15
  autorequire:
16
16
  bindir: bin
17
17
  cert_chain: []
18
- date: 2025-03-10 00:00:00.000000000 Z
18
+ date: 2025-03-20 00:00:00.000000000 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  name: activerecord
@@ -239,8 +239,8 @@ licenses:
239
239
  - MIT
240
240
  metadata:
241
241
  bug_tracker_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/issues
242
- changelog_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/blob/v8.0.4/CHANGELOG.md
243
- source_code_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/v8.0.4
242
+ changelog_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/blob/v8.0.5/CHANGELOG.md
243
+ source_code_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/v8.0.5
244
244
  post_install_message:
245
245
  rdoc_options: []
246
246
  require_paths: