activerecord-sqlserver-adapter 8.0.4 → 8.0.6

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: 34f478c68720d0070576ee92aeaaaf4d32ed24a9edf746d575ddc1b7633c02f4
4
+ data.tar.gz: 5c9bbd2821b3d0431d93e74b07dc1a729fa5a90ee213e2a53f93e8b8d67034fa
5
5
  SHA512:
6
- metadata.gz: d7dde4f70fbd4c665701f5765acfd194a2f60c3cf540ed966d43c264fffc747c7e14a1d61674eae17648fec758d20090b47bb5086f49d8046a7ebd2494b355cd
7
- data.tar.gz: 9c85aa63eedd0a1dd1387336cac08fbaf4596c4b0aeed7309af5b92183690f8a3d4803917f234258ceb3b8245e9edc5da0866c474c08031eb50149a028c2f53c
6
+ metadata.gz: 50dd731f8f49995aed5492cda26255234bb0c4b941cd67c26f9d8e7ca3e3ffe13d9d6470f3769617604388d9c92d3df99b21a2169d3c1c9c8c75906fe9d6d7bc
7
+ data.tar.gz: 66461bd72eaa9fd3cfb8235c6566f199eefae9e716ac59ed00908e3ddd19f99ec467c5fe83a9d1bf3ed3a8f7642e35c6267e6f6168344d9e1339bb950bc79e24
@@ -5,7 +5,7 @@ on: [push, pull_request]
5
5
  jobs:
6
6
  test:
7
7
  name: Run test suite
8
- runs-on: ubuntu-20.04 # TODO: Change back to 'ubuntu-latest' when https://github.com/microsoft/mssql-docker/issues/899 resolved.
8
+ runs-on: ubuntu-latest
9
9
 
10
10
  env:
11
11
  COMPOSE_FILE: compose.ci.yaml
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## v8.0.6
2
+
3
+ #### Fixed
4
+
5
+ - [#1318](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1318) Reverse order of values when upserting
6
+ - [#1321](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1321) Fix SQL statement to calculate `updated_at` when upserting
7
+
8
+ ## v8.0.5
9
+
10
+ #### Added
11
+
12
+ - [#1315](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1315) Add support for `insert_all` and `upsert_all`
13
+
1
14
  ## v8.0.4
2
15
 
3
16
  #### 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.6
@@ -143,18 +143,56 @@ 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
+ insert_all.inserts.reverse! if insert.update_duplicates?
163
+
164
+ sql = <<~SQL
165
+ MERGE INTO #{insert.model.quoted_table_name} WITH (UPDLOCK, HOLDLOCK) AS target
166
+ USING (
167
+ SELECT *
168
+ FROM (
169
+ SELECT #{insert.send(:columns_list)}, #{partition_by_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)}
170
+ FROM (#{insert.values_list})
171
+ AS t1 (#{insert.send(:columns_list)})
172
+ ) AS ranked_source
173
+ WHERE #{is_first_record_across_all_uniqueness_constraints(columns_with_uniqueness_constraints:)}
174
+ ) AS source
175
+ ON (#{joining_on_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)})
176
+ SQL
177
+
178
+ if insert.update_duplicates?
179
+ sql << " WHEN MATCHED THEN UPDATE SET "
180
+
181
+ if insert.raw_update_sql?
182
+ sql << insert.raw_update_sql
183
+ else
184
+ if insert.record_timestamps?
185
+ sql << build_sql_for_recording_timestamps_when_updating(insert:)
186
+ end
187
+
188
+ sql << insert.updatable_columns.map { |column| "target.#{quote_column_name(column)}=source.#{quote_column_name(column)}" }.join(",")
189
+ end
190
+ end
191
+ sql << " WHEN NOT MATCHED BY TARGET THEN"
192
+ sql << " INSERT (#{insert.send(:columns_list)}) VALUES (#{insert_all.keys_including_timestamps.map { |column| "source.#{quote_column_name(column)}" }.join(", ")})"
193
+ sql << build_sql_for_returning(insert:, insert_all: insert.send(:insert_all))
194
+ sql << ";"
156
195
 
157
- sql << " #{insert.values_list}"
158
196
  sql
159
197
  end
160
198
 
@@ -406,11 +444,18 @@ module ActiveRecord
406
444
  raw_table_name = get_raw_table_name(sql)
407
445
  id_column = identity_columns(raw_table_name).first
408
446
 
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
447
+ if id_column && (
448
+ sql =~ /^\s*(INSERT|EXEC sp_executesql N'INSERT)[^(]+\([^)]*\b(#{id_column.name})\b,?[^)]*\)/i ||
449
+ sql =~ /^\s*MERGE INTO.+THEN INSERT \([^)]*\b(#{id_column.name})\b,?[^)]*\)/im
450
+ )
451
+ SQLServer::Utils.extract_identifiers(raw_table_name).quoted
452
+ else
453
+ false
454
+ end
410
455
  end
411
456
 
412
457
  def insert_sql?(sql)
413
- !(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT)/i).nil?
458
+ !(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT|MERGE INTO.+THEN INSERT)/im).nil?
414
459
  end
415
460
 
416
461
  def identity_columns(table_name)
@@ -455,6 +500,96 @@ module ActiveRecord
455
500
 
456
501
  perform_do ? result.do : result
457
502
  end
503
+
504
+ # === SQLServer Specific (insert_all / upsert_all support) ===================== #
505
+ def build_sql_for_returning(insert:, insert_all:)
506
+ return "" unless insert_all.returning
507
+
508
+ returning_values_sql = if insert_all.returning.is_a?(String)
509
+ insert_all.returning
510
+ else
511
+ Array(insert_all.returning).map do |attribute|
512
+ if insert.model.attribute_alias?(attribute)
513
+ "INSERTED.#{quote_column_name(insert.model.attribute_alias(attribute))} AS #{quote_column_name(attribute)}"
514
+ else
515
+ "INSERTED.#{quote_column_name(attribute)}"
516
+ end
517
+ end.join(",")
518
+ end
519
+
520
+ " OUTPUT #{returning_values_sql}"
521
+ end
522
+ private :build_sql_for_returning
523
+
524
+ def get_columns_with_uniqueness_constraints(insert_all:, insert:)
525
+ if (unique_by = insert_all.unique_by)
526
+ [unique_by.columns]
527
+ else
528
+ # Compare against every unique constraint (primary key included).
529
+ # Discard constraints that are not fully included on insert.keys. Prevents invalid queries.
530
+ # Example: ignore unique index for columns ["name"] if insert keys is ["description"]
531
+ (insert_all.send(:unique_indexes).map(&:columns) + [insert_all.primary_keys]).select do |columns|
532
+ columns.to_set.subset?(insert.keys)
533
+ end
534
+ end
535
+ end
536
+ private :get_columns_with_uniqueness_constraints
537
+
538
+ def build_sql_for_regular_insert(insert:)
539
+ sql = "INSERT #{insert.into}"
540
+ sql << build_sql_for_returning(insert:, insert_all: insert.send(:insert_all))
541
+ sql << " #{insert.values_list}"
542
+ sql
543
+ end
544
+ private :build_sql_for_regular_insert
545
+
546
+ # why is the "PARTITION BY" clause needed?
547
+ # in every DBMS system, insert_all / upsert_all is usually implemented with INSERT, that allows to define what happens
548
+ # when duplicates are found (SKIP OR UPDATE)
549
+ # by default rows are considered to be unique by every unique index on the table
550
+ # but since we have to use MERGE in MSSQL, which in return is a JOIN, we have to perform the "de-duplication" ourselves
551
+ # otherwise the "JOIN" clause would complain about non-unique values and being unable to JOIN the two tables
552
+ # this works easiest by using PARTITION and make sure that any record
553
+ # we are trying to insert is "the first one seen across all the potential columns with uniqueness constraints"
554
+ def partition_by_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)
555
+ columns_with_uniqueness_constraints.map.with_index do |group_of_columns_with_uniqueness_constraints, index|
556
+ <<~PARTITION_BY
557
+ ROW_NUMBER() OVER (
558
+ PARTITION BY #{group_of_columns_with_uniqueness_constraints.map { |column| quote_column_name(column) }.join(",")}
559
+ ORDER BY #{group_of_columns_with_uniqueness_constraints.map { |column| "#{quote_column_name(column)} DESC" }.join(",")}
560
+ ) AS rn_#{index}
561
+ PARTITION_BY
562
+ end.join(", ")
563
+ end
564
+ private :partition_by_columns_with_uniqueness_constraints
565
+
566
+ def is_first_record_across_all_uniqueness_constraints(columns_with_uniqueness_constraints:)
567
+ columns_with_uniqueness_constraints.map.with_index do |group_of_columns_with_uniqueness_constraints, index|
568
+ "rn_#{index} = 1"
569
+ end.join(" AND ")
570
+ end
571
+ private :is_first_record_across_all_uniqueness_constraints
572
+
573
+ def joining_on_columns_with_uniqueness_constraints(columns_with_uniqueness_constraints:)
574
+ columns_with_uniqueness_constraints.map do |columns|
575
+ columns.map do |column|
576
+ "target.#{quote_column_name(column)} = source.#{quote_column_name(column)}"
577
+ end.join(" AND ")
578
+ end.join(") OR (")
579
+ end
580
+ private :joining_on_columns_with_uniqueness_constraints
581
+
582
+ # normally, generating the CASE SQL is done entirely by Rails
583
+ # and you would just hook into "touch_model_timestamps_unless" to add your database-specific instructions
584
+ # however, since we need to have "target." for the assignment, we also generate the CASE switch ourselves
585
+ def build_sql_for_recording_timestamps_when_updating(insert:)
586
+ insert.model.timestamp_attributes_for_update_in_model.filter_map do |column_name|
587
+ if insert.send(:touch_timestamp_attribute?, column_name)
588
+ "target.#{quote_column_name(column_name)}=CASE WHEN (#{insert.updatable_columns.map { |column| "(source.#{quote_column_name(column)} = target.#{quote_column_name(column)} OR (source.#{quote_column_name(column)} IS NULL AND target.#{quote_column_name(column)} IS NULL))" }.join(" AND ")}) THEN target.#{quote_column_name(column_name)} ELSE #{high_precision_current_timestamp} END,"
589
+ end
590
+ end.join
591
+ end
592
+ private :build_sql_for_recording_timestamps_when_updating
458
593
  end
459
594
  end
460
595
  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.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cases/helper_sqlserver"
4
+ require "models/book"
5
+ require "models/sqlserver/recurring_task"
6
+
7
+ class InsertAllTestSQLServer < ActiveRecord::TestCase
8
+ # Test ported from the Rails `main` branch that is not on the `8-0-stable` branch.
9
+ def test_insert_all_only_applies_last_value_when_given_duplicate_identifiers
10
+ skip unless supports_insert_on_duplicate_skip?
11
+
12
+ Book.insert_all [
13
+ { id: 111, name: "expected_new_name" },
14
+ { id: 111, name: "unexpected_new_name" }
15
+ ]
16
+ assert_equal "expected_new_name", Book.find(111).name
17
+ end
18
+
19
+ # Test ported from the Rails `main` branch that is not on the `8-0-stable` branch.
20
+ def test_upsert_all_only_applies_last_value_when_given_duplicate_identifiers
21
+ skip unless supports_insert_on_duplicate_update? && !current_adapter?(:PostgreSQLAdapter)
22
+
23
+ Book.create!(id: 112, name: "original_name")
24
+
25
+ Book.upsert_all [
26
+ { id: 112, name: "unexpected_new_name" },
27
+ { id: 112, name: "expected_new_name" }
28
+ ]
29
+ assert_equal "expected_new_name", Book.find(112).name
30
+ end
31
+
32
+ test "upsert_all recording of timestamps works with mixed datatypes" do
33
+ task = RecurringTask.create!(
34
+ key: "abcdef",
35
+ priority: 5
36
+ )
37
+
38
+ RecurringTask.upsert_all([{
39
+ id: task.id,
40
+ priority: nil
41
+ }])
42
+
43
+ assert_not_equal task.updated_at, RecurringTask.find(task.id).updated_at
44
+ end
45
+ end
@@ -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))")
@@ -0,0 +1,3 @@
1
+ class RecurringTask < ActiveRecord::Base
2
+ self.table_name = "recurring_tasks"
3
+ end
@@ -360,4 +360,12 @@ ActiveRecord::Schema.define do
360
360
  name varchar(255)
361
361
  )
362
362
  TABLE_IN_OTHER_SCHEMA_USED_BY_MODEL
363
+
364
+ create_table "recurring_tasks", force: true do |t|
365
+ t.string :key
366
+ t.integer :priority, default: 0
367
+
368
+ t.datetime2 :created_at
369
+ t.datetime2 :updated_at
370
+ end
363
371
  end
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.6
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-05-13 00:00:00.000000000 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  name: activerecord
@@ -164,6 +164,7 @@ files:
164
164
  - test/cases/helper_sqlserver.rb
165
165
  - test/cases/in_clause_test_sqlserver.rb
166
166
  - test/cases/index_test_sqlserver.rb
167
+ - test/cases/insert_all_test_sqlserver.rb
167
168
  - test/cases/json_test_sqlserver.rb
168
169
  - test/cases/lateral_test_sqlserver.rb
169
170
  - test/cases/migration_test_sqlserver.rb
@@ -205,6 +206,7 @@ files:
205
206
  - test/models/sqlserver/quoted_table.rb
206
207
  - test/models/sqlserver/quoted_view_1.rb
207
208
  - test/models/sqlserver/quoted_view_2.rb
209
+ - test/models/sqlserver/recurring_task.rb
208
210
  - test/models/sqlserver/sst_memory.rb
209
211
  - test/models/sqlserver/sst_string_collation.rb
210
212
  - test/models/sqlserver/string_default.rb
@@ -239,8 +241,8 @@ licenses:
239
241
  - MIT
240
242
  metadata:
241
243
  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
244
+ changelog_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/blob/v8.0.6/CHANGELOG.md
245
+ source_code_uri: https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/v8.0.6
244
246
  post_install_message:
245
247
  rdoc_options: []
246
248
  require_paths:
@@ -283,6 +285,7 @@ test_files:
283
285
  - test/cases/helper_sqlserver.rb
284
286
  - test/cases/in_clause_test_sqlserver.rb
285
287
  - test/cases/index_test_sqlserver.rb
288
+ - test/cases/insert_all_test_sqlserver.rb
286
289
  - test/cases/json_test_sqlserver.rb
287
290
  - test/cases/lateral_test_sqlserver.rb
288
291
  - test/cases/migration_test_sqlserver.rb
@@ -324,6 +327,7 @@ test_files:
324
327
  - test/models/sqlserver/quoted_table.rb
325
328
  - test/models/sqlserver/quoted_view_1.rb
326
329
  - test/models/sqlserver/quoted_view_2.rb
330
+ - test/models/sqlserver/recurring_task.rb
327
331
  - test/models/sqlserver/sst_memory.rb
328
332
  - test/models/sqlserver/sst_string_collation.rb
329
333
  - test/models/sqlserver/string_default.rb