Problem/Motivation
TL;DR The problem occurs when the mapping table name has either a hyphen in the name or a name that has only digits or a name that has a digit at the start and an "e".
@joachim noticed this problem 2.5 years ago in #2986452-79: Database reserved keywords need to be quoted as per the ANSI standard.
He explained it in more detail in subsequent comments.
It was suggested in #2986452-83: Database reserved keywords need to be quoted as per the ANSI standard that the problem was the hyphen in the database name, for which #2767595: Support "-" in database table names existed. But that's not the case. The problem is simply that the necessary quoting of identifiers is missing.
I traced the problem further over at #3218978-11: MySQL driver allows settings.php to remove ANSI_QUOTES from sql_mode, but doesn't work when it is, quoting in full here:
Seems like this problem is even worse when database or tables names consists solely of digits:
Certain objects within MySQL, including database, table, index, column, alias, view, stored procedure, partition, tablespace, resource group and other object names are known as identifiers. This section describes the permissible syntax for identifiers in MySQL.
[…]
Identifiers may begin with a digit but unless quoted may not consist solely of digits.
[…]
The identifier quote character is the backtick (`):
[…]
If the ANSI_QUOTES SQL mode is enabled, it is also permissible to quote identifiers within double quotation marks:
— https://dev.mysql.com/doc/refman/8.0/en/identifiers.html
IOW: On MySQL, you should always quote identifiers, either using
"whenANSI_QUOTESis on, otherwise using`.That makes this at least major IMHO, bordering on critical. Any site that turns off
ANSI_QUOTESwhich uses a fully numerical database name will get a SQL error, and fail to work.
Worse, counter to the docs, I can confirm that even database names that just start with a digit reproduce this behavior:
SELECT * FROM d8.users u INNER JOIN 101.foo f ON f.id = u.uid⇒
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '101.foo f ON f.id = u.uid' at line 1SELECT * FROM d8.users u INNER JOIN 73829e0c4090444f85fbae2e92f8925a.foo f ON f.id = u.uid⇒
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '73829e0c4090444f85fbae2e92f8925a.foo f ON f.id = u.uid' at line 1- Turns out this is because there's an
e(the letter "e") in the database name, which makes MySQL interpret it as a number in exponential notation! 🤪🤯🤯🤯🤯🤯🤯🤯🤯🤯🤯 Check yourself:
SELECT * FROM d8.users u INNER JOIN 1e0.foo f ON f.id = u.uid⇒
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '1e0.foo f ON f.id = u.uid' at line 1
… yet1a0works fine!
To reproduce:
- create three database names above
- create an empty
footable like so in each:
CREATE TABLE `foo` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️ Databases named using only digits (unlikely) or named with a hash (kinda likely) that happen to contain the letter "e" (unlikely) will fail hard! ⚠️
So this problem is not the hyphen, the problem is much simpler:
- all tests in Drupal core only use simple database names that do not use database or table names that need escaping in the first place
- the necessary quoting of "identifiers" is obviously missing
This is very clear in @joachim's #2986452-86: Database reserved keywords need to be quoted as per the ANSI standard, where he cites this:
SELECT m.id AS id, m.title AS title, m.changed AS changed, map.sourceid1 AS migrate_map_sourceid1, map.source_row_status AS migrate_map_source_row_status
FROM
{high_water_node} m
LEFT OUTER JOIN drupalaccounts8.test55734627migrate_map_high_water_test map ON id = map.sourceid1
WHERE (map.sourceid1 IS NULL) OR (map.source_row_status = :db_condition_placeholder_0) OR (changed > :db_condition_placeholder_1)
GROUP BY id, title, changed, map.sourceid1, map.source_row_status
ORDER BY changed ASC
(before that patch)
and
SELECT "m"."id" AS "id", "m"."title" AS "title", "m"."changed" AS "changed", "map"."sourceid1" AS "migrate_map_sourceid1", "map"."source_row_status" AS "migrate_map_source_row_status"
FROM
{high_water_node} "m"
LEFT OUTER JOIN drupalaccounts8.test77520843migrate_map_high_water_test "map" ON id = map.sourceid1
WHERE ("map"."sourceid1" IS NULL) OR ("map"."source_row_status" = :db_condition_placeholder_0) OR ("changed" > :db_condition_placeholder_1)
GROUP BY id, title, changed, map.sourceid1, map.source_row_status
ORDER BY "changed" ASC
A proper follow-up was never filed. This is then that follow-up.
Any migration that stores the mapping table in a database with either a hyphen in the name or a name that has only digits or a name that has a digit at the start and an "e" somewhere will encounter this problem, both before and after #2986452. But since #2986452, the ANSI mode being turned on causes it to fail 100% of the time.
Steps to reproduce
See above. Easier to see:
Do the following on a new DB called d9-destination (dash: not allowed):
-- Simulate users 0 and 1 have been created as part of the default Drupal 9 installation.
CREATE TABLE `users` (
`uid` int(10) unsigned NOT NULL,
`uuid` varchar(128) CHARACTER SET ascii NOT NULL,
`langcode` varchar(12) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`uid`),
UNIQUE KEY `user_field__uuid__value` (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='The base table for user entities.';
INSERT INTO `users` (`uid`, `uuid`, `langcode`)
VALUES
(0,'f214b7ad-94fb-4202-bd68-a6d4d1119c27','en'),
(1,'a500cd13-6aac-485d-8454-14e035b4b61e','en');
-- Simulate the migration system being active; but no migrations having run yet.
CREATE TABLE `migrate_map_d7_users` (
`source_ids_hash` varchar(64) NOT NULL COMMENT 'Hash of source ids. Used as primary key',
`sourceid1` int(11) NOT NULL,
`destid1` int(10) unsigned DEFAULT NULL,
`source_row_status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT 'Indicates current status of the source row',
`rollback_action` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT 'Flag indicating what to do for this item on rollback',
`last_imported` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'UNIX timestamp of the last time this row was imported',
`hash` varchar(64) DEFAULT NULL COMMENT 'Hash of source row data, for detecting changes',
PRIMARY KEY (`source_ids_hash`),
KEY `source` (`sourceid1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Mappings from source identifier value(s) to destination…';
-- Match \Drupal\Core\Database\Driver\mysql\Connection::open()
SET SQL_MODE='ANSI,TRADITIONAL';
-- Use the DB query Drupal 9 HEAD generates (but simplified slightly, to focus on the problem at hand).
SELECT "m"."uid" AS "id", "map"."sourceid1" AS "migrate_map_sourceid1", "map"."source_row_status" AS "migrate_map_source_row_status"
FROM
users "m"
LEFT OUTER JOIN d9-destination.migrate_map_d7_users "map" ON m.uid = map.sourceid1
WHERE ("map"."sourceid1" IS NULL) OR ("map"."source_row_status" = 1);
-- Result: ❌ You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '-destination.migrate_map_d7_users "map" ON m.uid = map.sourceid1
WHERE ("map"."s' at line 4
-- Use the DB query that is generated with the patch in #3 applied
SELECT "m"."uid" AS "id", "map"."sourceid1" AS "migrate_map_sourceid1", "map"."source_row_status" AS "migrate_map_source_row_status"
FROM
users "m"
LEFT OUTER JOIN "d9-destination"."migrate_map_d7_users" "map" ON "m"."uid" = "map"."sourceid1"
WHERE ("map"."sourceid1" IS NULL) OR ("map"."source_row_status" = 1)
-- Result: `1 NULL NULL` and `0 NULL NULL`, which is expected ✅
Rename the database from d9-destination to 1a1 and update the queries. Both queries will now work.
Rename the database from 1a1 to 1e1 and update the queries. Now once again only the second query will work.
Proposed resolution
Ensure cross-database joins are correctly escaped.
.
Remaining tasks
Review
User interface changes
None.
API changes
TBD
Data model changes
None.
Release notes snippet
TBD
| Comment | File | Size | Author |
|---|---|---|---|
| #39 | core-3227361-38.patch | 967 bytes | alex.skrypnyk |
| #34 | 3227361_34.patch | 2.32 KB | joseph.olstad |
Issue fork drupal-3227361
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
wim leersWhile #2986452: Database reserved keywords need to be quoted as per the ANSI standard made the problem worse, the real problems are:
\Drupal\migrate\Plugin\migrate\source\SqlBase::initializeIterator()has been doing this incorrectly since it was committed to Drupal core on November 11, 2013, in #2125717: Migrate in core: patch #1.Comment #3
wim leersComment #5
wim leersCrediting @joachim for his super valuable comments in #2986452: Database reserved keywords need to be quoted as per the ANSI standard.
Comment #6
wim leersMassive revamp for the issue summary to make the problem very easy to reproduce locally. Still needs tests here though.
Comment #7
wim leersClarify STR further.
Comment #8
wim leersNote that we do not need prefixing anymore because
\Drupal\Core\Database\Connection::getFullQualifiedTableName()already returns the prefixed table name:(If that weren't the case, we would need to iterate over all known connections, try to find the matching database name, verify it is unique, and then automatically associate that prefix. But that would not be reliable, because the same database could have multiple connections with a different prefix each. Hence the migration system's use of
\Drupal\Core\Database\Connection::getFullQualifiedTableName()is correct and necessary. And hence the simplicity of this patch.Comment #9
wim leersI'm actually not sure this is true:
… it would seem that this has actually been a problem since day 1 in all of these cases. 😨 The ANSI mode was a red herring it seems.
With ANSI mode being enabled, the problem just becomes much more obvious. 👍
Comment #11
wim leersI think we might be able to copy the explicit test coverage pattern in
\Drupal\KernelTests\Core\Entity\EntityQueryTest::testToString().Although obviously this is currently causing lots of failures 🤓
Comment #12
mcdruid commentedDoes this have any overlap with #3171091: \MigrateSQLMap::getQualifiedMapTable may break with MySQL 8 support in D7 core?
Comment #13
wim leersGot slightly over eager 😅
Comment #14
wim leers#12: Yes, this is basically the same root cause. It is that very bug in the Drupal 7 migrate system that was committed to Drupal 8 too in #2125717: Migrate in core: patch #1…
Comment #15
wim leersTested in production. Works great 👍
Comment #16
wim leersSo, let's try to figure out how/why this bug was introduced 🕵️♂️
The need for this addition was introduced in #2180539: Do not attempt prefixing cross database / schema queries., which added that if-statement above it.
That issue was actually opened specifically based on the migration system, i.e. specifically for the problem we're facing here! We're going in circles 🤪
The difference is that back then, correct escaping (quoting) was less important than it is now (hold on, you'll see why).
And so, that explains this decision/proposed resolution:
Which is still true after this patch: we cannot prefix another database's tables: we don't know enough about it. Only database connections defined in Drupal's
settings.phpcan allow us to know the prefix. Drupal could not ever automatically do this.Always a good idea! 👍
This is the problematic part. At least in a world where #2986452: Database reserved keywords need to be quoted as per the ANSI standard was introduced.
Yes, the dots are kept. But since #2986452, all identifiers need to be quoted. And a database name and table name are both distinct identifiers. That's how #2180539 + #2986452 caused this together AFAICT 🤯
👉 This also means we really do need both changes in this patch AFAICT:
\Drupal\Core\Database\Connection::getFullQualifiedTableName()to add the necessary square brackets, then\Drupal\Core\Database\Query\Select::__toString()'s indiscriminate call to::escapeTable()would strip those square brackets away … which is why this patch is explicitly restoring them in cross-database case.Comment #17
wim leersComment #18
daffie commentedWith this construction the table name will no longer be prefixed. As is done in the if part with
$table_string = '{' . $table_string . '}';.Comment #19
daffie commentedWhy do we not do:
Why do we not do:
Why do we not do:
Why do we not do:
This can for PostgreSQL databases be 3 parts: database name, schema name and table name.
Comment #20
wim leersThanks for the review, @daffie! 😊
#18 + #19.1:
{…}applies the prefix for the current DB connection, not the prefix in use on the other database you're joining against.#19.2+3+4: sure, these are possible. But almost everything else in the migration system uses explicit
[…], so I figured I'd match that. Does this solve a problem or is this just a matter of preference?#19.5: So … that'd mean we need to make it possible to have driver-specific behavior in this generic logic, since I do not think we want PostgreSQL-specific checks here. Thoughts on how we should do that?
Comment #21
daffie commentedThe testbot is failing for PostgreSQL, because of #19.5. Maybe we could do something like:
Not sure about the part:
$table_string .= "[$table_name]";This way it works for all relational databases, including PostgreSQL.
I would very much to like have testing for that.
#19.2-4: ok, lets wait with that for the moment. Lets fix the important parts first.
Comment #22
ghost of drupal pastIt is becoming crystal clear that the slapdash join we have in dbtng is just not enough.
I have been thinking on that for a long, long time. And Views has a join builder, for a very different reason, of course.
Rule of thumb when writing DBTNG was: never parse a query. It will always fail in unexpected ways and if I gather correctly, that's what happens here.
We need an expression builder for DBTNG which we partially have but not fully. like ->condition('foo', 1) translates to foo = 1 but if you wanted foo.bar = 1 properly escaped? sucks to be you. And for any complex expression we have ->expression() and ->where().
That's the full solution to this. And then you don't escape anything because the builder does.
It's quite a bit of work -- but perhaps ... we just need to build a tree. The leaves can have value objects based on escaping strategy necessary (variable/fieldname/tablename/dbname) and then one level up, what do we have? we need an infix operator class and a function call.
That was just an example. Make the
joinmethod accept n expression besides a string and then the objects can deal with printing themselves via __toString(). Everywhere where migrate uses the database name, use an expression. Do not attempt to solve this. Make it smart and make it solve itself.Bonus: the builder is instantly usable for
whereandexpression.Here's a start: https://gist.github.com/chx/e74ac22799ba6cd4a7beebad92d1301a
Comment #23
wim leers#22:
💯
Oh … very interesting! :)
After all these years I still get really really really confused by
->condition()vs->where()! 🙈That sounds like DBTNG v1.5? 🤓
I totally agree that we should have a more elegant fundamental solution, but I think expanding the scope to that is making it likely this will not land in the next year 😅
So … can you live with the general approach in here? (I'm happy to create a new issue for #22!)
Comment #24
ghost of drupal pastThe expression builder I suggested would be very limited -- just enough for writing the join conditions and such. I will write it over the weekend.
Edit: however, while writing it, I found a bug, I think: Connection::getFullQualifiedTableName does not escape
$options['database']. I think we should and that would fix this?Comment #25
ghost of drupal pastThis seems a simple but better route. If the problem is funny database names, just .... escape those? Especially since we already have the escape method for those?
I have changed the [] in the plugin base to explicit escapes. We already have an escapeField there and half explicit half magic beans is not a tasty soup.
Comment #27
ghost of drupal pastIt had a JavaScript fail, that was unrelated.
Comment #28
daffie commentedI think these changes are wrong. The method getFullQualifiedTableName() returns it unquoted. With this change, only the database name gets quoted.
The code comes from Drupal\Core\Database\Query\SelectInterface and modules/views/src/Plugin/views/join/JoinPluginBase.php
We should also add
addLeftJoin()for a left join.Comment #29
quietone commentedLong issue titles hurt my brain. Since this in migration system I don't think it will be confused with \Drupal\views\Plugin\views\pager\SqlBase.
Comment #31
quietone commentedUpdated IS to start with the database name formats that cause the error.
Changing status to NW for the tests and the comments in #28.
Comment #34
joseph.olstadPatch #25 no longer applies, I rerolled patch #25 however then I read the comments from @daffie , @daffie says this is wrong. I'd like this issue to be fixed because it's not only affecting Symfony Drupal it's also affecting Drupal 7.94 which has modeled it's db abstraction layer changes from changes made to symfony Drupals abstraction layer.
For now, handing this off to someone else, good luck!
I am pushing the reroll of patch 25 to see if it still passes testing just for curiosity sake.
Apparently this patch needs changes as per #28 @daffie comment and #31 @quietone
Comment #35
joseph.olstadhmm, checking between branches, 9.5.x, 10.0.x, 10.1.x, the patch #34 conflicts with 10.1.x
core/lib/Drupal/Core/Database/Connection.phpPerhaps part/or/all of this has been fixed already in 10.1.x ?
Probably not, but ya, hopefully someone with more experience working on the db abstraction layer can make the expected changes.
Comment #36
joachim commentedI tried applying the latest patch to 10.0 and it doesn't fix the problem -- running a migration from D7 in the UI shows lots of:
> Migration failed with source plugin exception: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'drupalfirecore10.migrate_map_d7_action' doesn't exist: SELECT "a".*, "map"."sourceid1" AS "migrate_map_sourceid1", "map"."source_row_status" AS "migrate_map_source_row_status" FROM "actions" "a" LEFT OUTER JOIN drupalfirecore10.migrate_map_d7_action "map" ON aid = map.sourceid1 WHERE ("map"."sourceid1" IS NULL) OR ("map"."source_row_status" = :db_condition_placeholder_0); Array ( [:db_condition_placeholder_0] => 1 ) in /Users/joachim/Sites/firecore/web/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php line 46
My DB is called drupal-firecore-10, not drupalfirecore10!
Comment #38
joachim commentedI've received this suggestion as a fix:
Comment #39
alex.skrypnyk#38 worked for me. thank you @joachim
Patch for 11.2.5 attached