Problem/Motivation

#1013034: PostgreSQL constraints do not get renamed by db_rename_table() provided an initial fix for renaming constraints automatically when a table is renamed, but it didn't cover the case when the table name combined with the serial column name exceed 63 characters, which is the identifier limit in PostgreSQL.

Proposed resolution

Fix the way we auto-generate constraint names on table renaming and when changing fields into serial columns.

The most important change is that we now use the pg_get_serial_sequence() function to get the current name of a constraint, which will return an accurate result regardless of the naming scheme used for auto-renamed constraints in the past.

Remaining tasks

Review.

User interface changes

Nope.

API changes

Nope.

Data model changes

Nope.

Release notes snippet

Nope.

Comments

amateescu created an issue. See original summary.

plach’s picture

Priority: Major » Critical

Anything blocking a critical is critical in my book :)

jibran’s picture

Do we need a change notice here?

amateescu’s picture

I don't think so, it's just a bug fix not a behavior change or addition.

jibran’s picture

Status: Needs review » Reviewed & tested by the community

Thanks, for clearing that up. The patch is green and #2984782-56: Add an API for converting entity type schemas to (non-)revisionable/(non-)translatable, with or without data is also green. The changes look good and are well documented so setting it to RTBC.

plach’s picture

Status: Reviewed & tested by the community » Needs review

Wow, impressive detective work :)

I have a few doubts, but I'm no PgSql expert so they might be moot. Anyway, better safe than sorry:

  1. +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
    @@ -561,12 +575,21 @@ public function renameTable($table, $new_name) {
    -        $old_sequence = $this->prefixNonTable($table, $field, 'seq');
    ...
    +        // The initial name of the sequence is generated automatically by
    +        // PostgreSQL when the table is created, so we need to use
    +        // pg_get_serial_sequence() to retrieve it.
    +        $old_sequence = $this->connection->query("SELECT pg_get_serial_sequence('" . $old_full_name . "', '" . $field . "')")->fetchField();
    

    Does this mean that $this->prefixNonTable($table, $field, 'seq'); generates a sequence name that matches PostgreSQL's default behavior, unless its length is greater than 63, in which case the actual name is unpredictable? If this is not the case, then I don't understand how this code was working before the change :)

  2. +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
    @@ -561,12 +575,21 @@ public function renameTable($table, $new_name) {
    -        $new_sequence = $this->prefixNonTable($new_name, $field, 'seq');
    ...
    +        // The new sequence name generated with ::ensureIdentifiersLength() does
    +        // not match the pattern that is automatically applied by PostgreSQL on
    +        // table creation, but that's ok because pg_get_serial_sequence() will
    +        // return our non-standard name on subsequent table renames.
    +        $new_sequence = $this->ensureIdentifiersLength($new_name, $field, 'seq');
    

    Assuming a pg_ prefix, $this->prefixNonTable('new_name', 'field', 'seq') returns pg_new_name_field_seq, however the new code would return pg_new_name__field__seq. To minimize the risk of introducing a BC break, I think we should change this line to:

    $new_sequence = $this->ensureIdentifiersLength($new_name . '_' . $field . '_seq');
    
  3. +++ b/core/tests/Drupal/KernelTests/Core/Database/SchemaTest.php
    @@ -214,7 +214,13 @@ public function testSchema() {
    +    // PostgreSQL has a max identifier length of 63 characters, MySQL has 64 and
    +    // SQLite does not have any limit. Use the lowest common value and create a
    +    // table name as long as possible in order to cover edge cases around
    +    // identifier names for the table's primary or unique key constraints.
    +    $table_name = strtolower($this->getRandomGenerator()->name(63 - strlen($this->getDatabasePrefix())));
    +    $this->schema->createTable($table_name, $table_specification);
    
  4. Since we are at this, what about using 64 for MySql? That way we actually test limits for all db types we support. We could introduce an helper method to avoid repeating this logic all over the place.

xjm’s picture

Issue tags: +PostgreSQL
amateescu’s picture

Status: Needs review » Reviewed & tested by the community

@plach, thanks, it quite fun to figure out all this :)

Re #7:

  1. Does this mean that $this->prefixNonTable($table, $field, 'seq'); generates a sequence name that matches PostgreSQL's default behavior, unless its length is greater than 63, in which case the actual name is unpredictable?

    Yup, that's exactly what it means.

  2. There's no risk of introducing a BC break :) The difference between the "old" naming scheme and the new one doesn't matter at all, because after this patch we will retrieve the current name of the sequence via pg_get_serial_sequence(), which will be correct regardless whether the sequence is using the default name generated by PostgreSQL, a name generated before this patch (with prefixNonTable()) or a name generated after this patch (with ensureIdentifiersLength())
  3. I considered that when writing the test, but MySQL does not create any index names automatically, it relies on what is specified in the table schema definition, so if any identifier length exceeds 64 chars it's user error and they will see it as soon as the table is created :)
plach’s picture

There's no risk of introducing a BC break :)

I agree that the current code does not break renaming tables for existing sites and is BC with the previous names, but what if some custom code is querying those values for any reason? I know it's far fetched, but given that the solution for that is just tweaking one line, I think it would be worth it, unless the tweak I suggested has some drawbacks I'm missing.

amateescu’s picture

Unfortunately, your suggestion doesn't work because \Drupal\Core\Database\Driver\pgsql\Schema::ensureIdentifiersLength() calls $this->getPrefixInfo() on the first argument it receives, which means it has to be a table name otherwise the call to getPrefixInfo() will not return the expected result..

plach’s picture

Mmmh, I tested it both manually and automatically and it appeared to work. However, upon a closer look, I guess it might fail if a per-table prefix has been set, so scratch that :)

mradcliffe’s picture

I added the additional test runs a few days ago to compare against any performance regressions on the test bots, but I never got back to looking at the results. These look fine.

The internals of pg_get_serial_sequence look good and I don't see anything costly like getting from information_schema, which is slow.

For that aspect, +1 to the patch.

I agree that the current code does not break renaming tables for existing sites and is BC with the previous names, but what if some custom code is querying those values for any reason?

In the past, not related to Drupal, I've used currval, nextval and setval functions, which allow me to specify the sequence name. I think in a custom application that's not a CMS that would be fine, but I think that if we're working with a dynamic system, then using pg_get_serial_sequence inside one of those functions would be the best.

The Database API handbook doesn't reference anything here as a guide for developers other than ("You should almost never be making database calls directly unless you are developing core APIs."). However since our DBAL isn't fully-featured, if someone were to be concatenating values to query a sequence dynamically for any reason, then they would be caught off-guard and their code would break.

Maybe a change record that demonstrates usage change would help?

If you're doing something like this,

  $node->save();
  $seq = $connection->select("SELECT currval('node_nid_seq'::regclass)")->execute();

Then do something like this instead

  $node->save();
  $seq = $connection->select("SELECT currval(pg_get_serial_sequence('{node}', 'nid'))")->execute();

That might not be accurate usage of table prefixes, and I'm having a hard time coming up with a good Drupal example. The above doesn't really make sense in actual usage because I'd just get nid from the new Node entity? I think I'd need to be doing something without entities, directly querying database on a Drupal web application that was querying and inserting data into a shared database?

amateescu’s picture

@mradcliffe, thanks for reviewing! I think a CR is a great idea for this issue. Here it is: https://www.drupal.org/node/3027618

mradcliffe’s picture

Status: Reviewed & tested by the community » Needs review

I was reviewing the CR and how table prefixes work when I came across

makeSequenceName method in Connection. This is used by pgsql driver only (in core, but maybe a contrib driver also uses it?).

I think that may need to change as well..?

I'm setting to Needs review to answer that.

mradcliffe’s picture

Wow, that method is fairly old. nextId() is now only called by user module in core from what I can tell. There's a mention in the entity storage API. The change to nextId() would then be an actual example for the change record.

We might remove the makeSequenceName usage in nextId() and replace with getting via pg_get_serial_sequence(), and then should we deprecate makeSequenceName in favor of Schema::prefixNonTable() (or the aforementioned change record) since it would then be unused?

If makeSequenceName is in the API, then are there more BC concerns?

More questions, sorry. :|

Edit: grammar

amateescu’s picture

StatusFileSize
new13.75 KB
new5.38 KB

Very good questions though :)

I think the crux of the problem boils down to automatic vs. manually generated sequences. I did a bit of research and at least SQL Server and Oracle also have the concept of sequences, but the difference with Postgres is that they don't create them automatically on table creation.

With that in mind, I think makeSequenceName() does its job perfectly well, because it is about manual generation of a sequence name. And its usage in \Drupal\Core\Database\Driver\pgsql\Connection::nextId() is legitimate because we know that the resulting name will not exceed 63 chars. If anything, I think makeSequenceName() needs to be used more, not less, and I even found another place where it should be used. If folks think that's not in scope of this issue, I don't mind moving that one-line change to a followup.

IMO, what we should do instead is make the developers aware of manual vs. auto-created sequences in the doxygen block of makeSequenceName(), and point out that they should be using database-specific ways of getting the name of an auto-created existing sequence.

Also, as suggested by @plach a few days ago when we were discussing this issue, let's add a way for ensureIdentifiersLength() to use a custom separator, which will make it return a "standard" sequence name if it doesn't exceed the 63 char limit.

plach’s picture

StatusFileSize
new252.7 KB
new252.7 KB
  1. +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
    @@ -584,11 +593,12 @@ public function renameTable($table, $new_name) {
    +        $new_sequence = $this->ensureIdentifiersLength($new_name, $field, 'seq', '_');
    
    @@ -952,7 +962,7 @@ public function changeField($table, $field, $field_new, $spec, $new_keys = []) {
    -      $seq = "{" . $table . "}_" . $field_new . "_seq";
    +      $seq = $this->connection->makeSequenceName($table, $field_new);
    

    ::makeSequenceName() does not handle the PG identifier limit, shouldn't we override it and call ::ensureIdentifiersLength() internally? This way both places would be correctly dealing with long identifiers.

  2. +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
    @@ -74,7 +83,7 @@ protected function ensureIdentifiersLength($identifier) {
    -      $saveIdentifier = '"drupal_' . $this->hashBase64($identifierName) . '_' . $args[2] . '"';
    +      $saveIdentifier = '"drupal_' . $this->hashBase64($identifierName) . '_' . $tag . '"';
    

    I just noticed that the identifier name is enclosed in double quotes, so those will end up in the sequence name (see the attached screenshots). Shouldn't we trim() them?

plach’s picture

StatusFileSize
new256.15 KB

(posting the same screenshot twice doesn't help)

amateescu’s picture

@plach:

  1. I don't think we should. This is about the manual vs. automatic explanation from #17: the intention of makeSequenceName() is to used as a manual way of generating a sequence name, and the developer will receive feedback immediately from the database (i.e. an error) that the sequence name is too long, so they will be able to correct the problem. However, in the automatic case (table renames), Drupal handles the sequence auto-renaming so the user has no way to intervene in the process.
  2. Those quotes from "after.png" are added by pgsql when the sequence name contains a mix of lower and uppercase characters, the actual sequence name is not quoted :)
plach’s picture

@#20:
1: 👍
2: Ha, tricky :) Confirmed this behavior by creating a Test table (see attached screen).

plach’s picture

It seems indeed that if we want to keep uppercase characters we need to leave double quotes around, however those seem to be actually part of the sequence name, or at least there's no way to refer to the sequence name without them:

drupal_8x_dev=> SELECT pg_get_serial_sequence('Test', 'id');
ERROR:  relation "test" does not exist

drupal_8x_dev=> SELECT pg_get_serial_sequence('"Test"', 'id');
 pg_get_serial_sequence 
------------------------
 public."Test_id_seq"
(1 row)
amateescu’s picture

or at least there's no way to refer to the sequence name without them:

That's the problem :) You can read more about that here: https://www.postgresql.org/docs/9.1/sql-syntax-lexical.html

Quoting an identifier also makes it case-sensitive, whereas unquoted names are always folded to lower case. For example, the identifiers FOO, foo, and "foo" are considered the same by PostgreSQL, but "Foo" and "FOO" are different from these three and each other. (The folding of unquoted names to lower case in PostgreSQL is incompatible with the SQL standard, which says that unquoted names should be folded to upper case. Thus, foo should be equivalent to "FOO" not "foo" according to the standard. If you want to write portable applications you are advised to always quote a particular name or never quote it.)

For your specific example, SELECT pg_get_serial_sequence('Test', 'id'); treats Test as test, which doesn't exist, so you need to quote it in order to prevent the automatic conversion to lowercase.

mradcliffe’s picture

Issue summary: View changes
Issue tags: +Needs issue summary update

And its usage in \Drupal\Core\Database\Driver\pgsql\Connection::nextId() is legitimate because we know that the resulting name will not exceed 63 chars.

Ah, thanks, @amateescu. This seems odd. The two usages of nextId are UserStorage and batches in form.inc? And in the pgsql driver it's always going to use the "sequences_value_seq" sequence for.. both? So user ids are tied to batch ids? Uh, hrm, maybe we should change that in a follow-up. That's probably going to require some extensive changes to either oul DBAL or UserStorage (and drupal 7).

@@ -929,7 +962,7 @@ public function changeField($table, $field, $field_new, $spec, $new_keys = []) {
       // Type "serial" is known to PostgreSQL, but *only* during table creation,
       // not when altering. Because of that, the sequence needs to be created
       // and initialized by hand.
-      $seq = "{" . $table . "}_" . $field_new . "_seq";
+      $seq = $this->connection->makeSequenceName($table, $field_new);
       $this->connection->query("CREATE SEQUENCE " . $seq);
       // Set sequence to maximal field value to not conflict with existing
       // entries.

I found another Major issue with changeField, but it's not related here. I don't see where we're relating a new sequence to the table field. It should use the OWNED BY statement, but it's not. This means anyone who's changed a field to be serial has floating sequences. This probably doesn't matter, but pg_get_serial_sequence will not find it.

That seems like database update territory especially if we publish the CR about using pg_get_serial_sequence.

I also found that the sequences automagically created with CREATE TABLE and sequences created with CREATE SEQUENCE are not truncated in the same way. The former will truncate the table name adding the trailing _, but the latter will truncate entire thing. But that shouldn't matter if you always (and can) use pg_get_serial_sequence.

So I think that the change record should reflect a better example on manually created sequences or for non-entity tables.

I feel like the issue summary could be updated after the discussion in the last week to provide some background information about driver things and answers to some of the questions.

amateescu’s picture

Issue summary: View changes
Issue tags: -Needs issue summary update

Wow, good find with the changeField() issue. I can confirm it and we should definitely open a new issue for that.

So I think that the change record should reflect a better example on manually created sequences or for non-entity tables.

I updated the CR to mention that using pg_get_serial_sequence() is also the recommended best practice for manually created sequences. Note that the example in the CR does not mention on purpose anything about entity tables, so it's pretty generic already.

Also updated the IS to be more explicit about the proposed resolution, but please feel free to improve it further if you think there's any additional info needed :)

mradcliffe’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community

Thank you, @amateescu.

I created follow-up issues for pgsql driver. I think it's safe to postpone the CREATE SEQUENCE one after this because there are no core updates that change fields into serial columns.

- #3028706: New serial columns do not own sequences
- #3028709: Change behavior of PostgreSQL to not share sequence for batch and users

Let's flip it back to RTBC for #17.

plach’s picture

Thanks for the very thorough reviews, @mradcliffe!

plach’s picture

Status: Reviewed & tested by the community » Fixed

Committed 1b46f2f and pushed to 8.7.x. Thanks!

Since this was only marked critical because it is blocking a critical issue and it might require to follow the change record to be fully compliant with the new logic, I think it's ok to just commit this to 8.7.x. I will ping a release manager to confirm this.

  • plach committed 1b46f2f on 8.7.x
    Issue #3026290 by amateescu, plach, mradcliffe, jibran: PostgreSQL...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

kattekrab’s picture

Issue tags: +Needs release note
xjm’s picture

I think the title of the CR is sufficient information for the release note here, so I've used that. This is potentially disruptive so tagging for the release notes directly.