Skip to content

Commit 1a78485

Browse files
committed
Support supports_insert_on_duplicate_skip
1 parent 8739eea commit 1a78485

File tree

2 files changed

+69
-1
lines changed

2 files changed

+69
-1
lines changed

lib/active_record/connection_adapters/sqlserver/database_statements.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,73 @@ def default_insert_value(column)
154154
private :default_insert_value
155155

156156
def build_insert_sql(insert) # :nodoc:
157+
if insert.skip_duplicates?
158+
insert_all = insert.send(:insert_all)
159+
conflict_columns = get_conflicted_columns(insert_all:, insert:)
160+
161+
# if we do not have any columns that might have conflicting values, just execute a regular insert
162+
return build_sql_for_regular_insert(insert) if conflict_columns.empty?
163+
164+
make_inserts_unique(insert_all:, conflict_columns:)
165+
166+
primary_keys_for_insert = insert_all.primary_keys.to_set
167+
168+
# if we receive a composite primary key, MSSQL will not be happy when we want to call "IDENTITY_INSERT"
169+
# as there is likely no IDENTITY column
170+
# so we need to check if there is exacty one
171+
# TODO: Refactor to use existing "SET IDENTITY_INSERT" settings
172+
enable_identity_insert = primary_keys_for_insert.length == 1 &&
173+
(insert_all.primary_keys.to_set & insert.keys).present?
174+
175+
sql = +""
176+
sql << "SET IDENTITY_INSERT #{insert.model.quoted_table_name} ON;" if enable_identity_insert
177+
sql << "MERGE INTO #{insert.model.quoted_table_name} WITH (UPDLOCK, HOLDLOCK) AS target"
178+
sql << " USING (SELECT DISTINCT * FROM (#{insert.values_list}) AS t1 (#{insert.send(:columns_list)})) AS source"
179+
sql << " ON (#{conflict_columns.map do |columns|
180+
columns.map do |column|
181+
"target.#{quote_column_name(column)} = source.#{quote_column_name(column)}"
182+
end.join(" AND ")
183+
end.join(") OR (")})"
184+
sql << " WHEN NOT MATCHED BY TARGET THEN"
185+
sql << " INSERT (#{insert.send(:columns_list)}) #{insert.values_list}"
186+
if (returning = insert_all.returning)
187+
sql << " OUTPUT " << returning.map { |column| "INSERTED.#{quote_column_name(column)}" }.join(", ")
188+
end
189+
sql << ";"
190+
sql << "SET IDENTITY_INSERT #{insert.model.quoted_table_name} OFF;" if enable_identity_insert
191+
return sql
192+
end
193+
194+
build_sql_for_regular_insert(insert)
195+
end
196+
197+
# MERGE executes a JOIN between our data we would like to insert and the existing data in the table
198+
# but since it is a JOIN, it requires the data in the source also to be unique (aka our values to insert)
199+
# here we modify @inserts in place of the "insert_all" object to be unique
200+
# keeping the last occurence
201+
# note that for other DBMS, this job is usually handed off to them by specifying something like
202+
# "ON DUPLICATE SKIP" or "ON DUPLICATE UPDATE"
203+
def make_inserts_unique(insert_all:, conflict_columns:)
204+
unique_inserts = insert_all.inserts.reverse.uniq { |insert| conflict_columns.map { |column| insert[column] } }.reverse
205+
insert_all.instance_variable_set(:@inserts, unique_inserts)
206+
end
207+
private :make_inserts_unique
208+
209+
def get_conflicted_columns(insert_all:, insert:)
210+
if (unique_by = insert_all.unique_by)
211+
[unique_by.columns]
212+
else
213+
# Compare against every unique constraint (primary key included).
214+
# Discard constraints that are not fully included on insert.keys. Prevents invalid queries.
215+
# Example: ignore unique index for columns ["name"] if insert keys is ["description"]
216+
(insert_all.send(:unique_indexes).map(&:columns) + [insert_all.primary_keys]).select do |columns|
217+
columns.to_set.subset?(insert.keys)
218+
end
219+
end
220+
end
221+
private :get_conflicted_columns
222+
223+
def build_sql_for_regular_insert(insert)
157224
sql = "INSERT #{insert.into}"
158225

159226
returning = insert.send(:insert_all).returning
@@ -170,6 +237,7 @@ def build_insert_sql(insert) # :nodoc:
170237
sql << " #{insert.values_list}"
171238
sql
172239
end
240+
private :build_sql_for_regular_insert
173241

174242
# === SQLServer Specific ======================================== #
175243

lib/active_record/connection_adapters/sqlserver_adapter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def supports_insert_returning?
216216
end
217217

218218
def supports_insert_on_duplicate_skip?
219-
false
219+
true
220220
end
221221

222222
def supports_insert_on_duplicate_update?

0 commit comments

Comments
 (0)