Problem/Motivation
After upgrading from Drupal core 11.3.13 to 11.4.1, database upserts that have worked for years on a MySQL system starting failing with duplicate key errors. Reverting back to 11.3.13 fixed the issue.
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '' at line 1: INSERT INTO "example_table" ("name") VALUES (:db_insert_placeholder_0), (:db_insert_placeholder_1), (:db_insert_placeholder_2) ON DUPLICATE KEY UPDATE; Array
(
[:db_insert_placeholder_0] => Example value 0
[:db_insert_placeholder_1] => Example value 1
[:db_insert_placeholder_2] => Example value 2
)
The code in question uses \Drupal\Core\Database\Database connection to do the following:
$upsert = $connection->upsert($table)
->values($data_item)
->fields($field_names)
->key($key)
->execute();
Comments
Comment #2
john.oltman commentedComment #3
john.oltman commentedComment #4
john.oltman commentedComment #5
john.oltman commentedComment #6
john.oltman commentedComment #7
john.oltman commentedComment #8
cilefen commented#2547493: Add support for unique / primary key constraints composed of multiple fields for Upsert queries and #3586760: Use composite key Upsert queries in core actually changed upsert, so I would start there.
Comment #9
john.oltman commentedComment #10
john.oltman commentedComment #11
john.oltman commentedThanks for the tip @cilefen, this seems like the most likely culprit:
https://git.drupalcode.org/project/drupal/-/commit/2e8705a4bb18750227d23...
Will revert locally and see if that fixes it.
Comment #12
mondrakeThe error is SQL syntax, not duplicated key: nothing is specified after
ON DUPLICATE KEY UPDATEin the SQL statement string. This happens if the 'key' column(s) correspond with the 'field' column(s). To prove that you may try to patch with #3604286: Upsert - Allow to customize the behavior of the update when the insert fails and use more Upsert queries in core, that adds a specific\LogicException('There must be at least one column to update in an upsert operation')error in that case.Comment #13
john.oltman commentedReverting #2547493: Add support for unique / primary key constraints composed of multiple fields for Upsert queries fixed it, although I got an exception afterwards
Cannot unset offset of type array on array in /var/www/html/docroot/core/modules/mysql/src/Driver/Database/mysql/Upsert.php:31which could be an artifact of my build and being in a hurry. Trying the patch from #3604286: Upsert - Allow to customize the behavior of the update when the insert fails and use more Upsert queries in core next after "reverting the revert".And yes, both the update field and primary key is the
namecolumn in this particular upsert.Comment #14
mondrake#13 then using upsert in this case is nonsense, because the key columns, being duplicated, are not changing and there are not columns to update.
Comment #15
john.oltman commentedThanks @mondrake, I just tried #3604286: Upsert - Allow to customize the behavior of the update when the insert fails and use more Upsert queries in core and I did indeed get the new logic error in that MR. Here are my thoughts on it:
The code is in a service in a custom module that services various requests and is passed various update fields and key fields. If the key field does not exist yet, then it should insert a record, otherwise in this particular case it becomes a "no change" update (but in 90% of the calls it services, the fields and key are not the same). It is not nonsense conceptually to have a service like this. And it has worked for years. Having the behavior change for this "key field = update field" case is an unexpected regression, in my view. I have not seen any documentation indicating this case is not allowed.
Comment #16
john.oltman commentedFollowing up ... with 11.4 as it exists now, or with the patch in #3604286: Upsert - Allow to customize the behavior of the update when the insert fails and use more Upsert queries in core, the workaround is to detect the "key field = update field" in the service, then do a select query to see if the key exists, and if not, then do an insert query (and do nothing if the key exists). But this is what the upsert call should be doing, and has done, for many years.
Comment #17
mondrakeIt's in fact an insert that should keep going in case of
IntegrityConstraintViolationException, then.But if an upsert was abused for this, let's see if we can fix it not to break things. Maybe we just deprecate the degenerate case for now and throw the exception in a later major.
Comment #19
john.oltman commentedThanks I will try that MR and report back.
Comment #20
mondrakeComment #21
john.oltman commentedI tried the MR and although it avoids an exception or error, it does not behave the same as 11.3 for a case when some keys are new and some already exist. In 11.3, the ON DUPLICATE UPDATE key = VALUES(key) allows the insert to succeed for the new values, and the other values are updated in a "no change" update. In 11.4 with the MR, the INSERT fails without a fallback. You may wonder how 11.3 does this when line 31 tries to remove the key field from the ON DUPLICATE UPDATE clause:
https://git.drupalcode.org/project/drupal/-/blob/11.3.x/core/modules/mys...
I stepped in with a debugger, and line 31 does nothing because $insert_fields is a zero-based array. So it has been an accident of history that this did not happen before. In 11.4, the array_combine at line 21 changes the array from zero-based to string keys:
https://git.drupalcode.org/project/drupal/-/blob/11.4.x/core/modules/mys...
This allows lines 32-34 to remove the key fields from the ON DUPLICATE UPDATE clause.
My suggestion is to do a new MR that simply removes lines 32-34 (and possibly line 21), so as to be 100% BC with 11.3. And repeat this in the other drivers. Personally I do not see the need to ever deprecate this, as I do not see it as degenerate to have a way of making one database call to guarantee that a set of keys exists. I do understand that if the key is an auto-incrementing field, it will fail on SQL Server - that would have been true already though.
Comment #22
mondrakeAh but then it's a multi-value insert, i.e. in the same statement there is input for multiple rows to be inserted, and failure of one will fail the whole batch. We certainly need a test first of all, as coverage of that scenario is missing; and we need to see that it can work on all the dbs.
I still believe that using upsert in this case is not efficient, as it will force an useless additional update on all the rows where the key is already present. So directionally IMHO regardless of how we end up here, we should deprecate supporting the degenerate case.
Comment #23
mondrakeluckily all dbs have syntax that covers this case natively
Comment #24
john.oltman commentedExcellent work @mondrake! This worked perfectly. I left one nit on the MR for an improved comment. Otherwise I think this is ready to go.
May I kindly suggest not deprecating this functionality, especially since it is supported in all drivers. For a schema with 40 tables, and 2 of the tables only have a single column, it seems arbitrary that those 2 tables cannot use the upsert command at some point in the future. Would create a ton of extra work for those of us in this boat.
Comment #25
mondrake#24 yeah in the end if there's native support and with that we can avoid db roundtrips for multiple inserts in favor of a single SQL statement, we might just call this a supported case. Even if core is not using it at the moment (and that's why it got missed), now there's test coverage that would prevent regressions. So I retract the proposal to deprecate.
Comment #26
john.oltman commentedSounds good, thank you again for the quick turn on getting this resolved
Comment #27
john.oltman commentedComment #28
mondrakeAsked AI for a review, and it suggested adding a test for the composite key degenerate case, which I think is a good idea.
Comment #29
mondrakeAdded testDegeneratedCompositeKeyUpsert. AI assisted in the making.
Comment #30
mondrakeComment #31
john.oltman commentedNew test makes sense and looks good. There are a couple spell check errors on "Upserting" and "Zaphod".
Comment #32
mondrakeComment #33
john.oltman commentedPossible to rerun the failed tests? I would do it myself but no permissions. I would feel better if the PGSQL test did not fail. Although I doubt the failure is related to this MR. Guessing a rerun of that one will solve it.
Comment #34
mondrakeall green now
Comment #35
john.oltman commentedTests passed this time
Comment #36
mondrakeSmall tuning of the comments. Leaving at RTBC.
Comment #39
catchFollowing the discussion I was having similar reactions to @mondrake including this one:
The changes here aren't necessarily easy to follow but I don't think the resulting logic is particularly more difficult than it was before, and we'd need to detect the case to throw an exception, so seems OK to leave it supported as long as it's not causing problems for other cases.
Committed/pushed to main, cherry-picked to 11.x and 11.4.x, thanks!
Comment #42
john.oltman commentedThanks @catch. I think you missed the cherry pick to 11.x