Problem/Motivation

PostgreSQL supports specialized index types, such as GIN (Generalized Inverted Index) and GiST (Generalized Search Tree), that can dramatically speed up queries on JSON columns, arrays, and full-text data. Other database engines supported by Drupal core do not offer these index types.

The Schema API can only describe regular (B-tree) indexes. There is no way for a module to request a GIN or GiST index on PostgreSQL while remaining compatible with MySQL/MariaDB and SQLite, so PostgreSQL sites cannot benefit from these index types through the standard schema definition workflow.

A concrete example is #2988018: [PP-1] Performance issues with path alias generated queries on PostgreSQL, where path alias lookups take multiple seconds on larger sites because the LIKE/ILIKE queries on the path_alias table cannot use a regular index; a GIN or GiST index would solve this. GIN index support is also needed for #3343634: Add 'json' as core data type to Schema and Database API, since on PostgreSQL jsonb columns need a GIN index to be queried efficiently.

Proposed resolution

Extend the object-oriented schema definition API (from #3113560) with specialized index definitions that drivers can support, skip, or fall back from:

  • Introduce the abstract base class \Drupal\Core\Database\SchemaDefinition\IndexBase. The existing Index definition now extends it, and concrete subclasses describe the index technology to be used.
  • Add two specialized index definitions to the PostgreSQL driver: \Drupal\pgsql\Driver\Database\pgsql\GinIndex and \Drupal\pgsql\Driver\Database\pgsql\GistIndex.
  • Add the \Drupal\Core\Database\SchemaDefinition\AlternativeIndexes value object, which lists alternative definitions for the same logical index in order of preference. The driver creates the first index it supports and ignores the others, so a GIN index on PostgreSQL can fall back to a regular index on other databases.
  • Each driver declares the index definition classes it supports via the new protected method Schema::getSupportedIndexDefinitionClasses(), and creates specialized indexes via the new protected method Schema::createSpecializedIndex(). Index definitions of a technology not supported by the driver are skipped.
  • Accept IndexBase and AlternativeIndexes objects in the indexes property of Table definitions, and in the indexes element of the keys specification passed to Schema::addField() and Schema::changeField() on all core drivers (MySQL/MariaDB, PostgreSQL, SQLite). The new protected helper Schema::prepareKeysSpecification() converts the definition objects and collects the specialized indexes to create.
  • Add the public methods addGinIndex() and addGistIndex() to the PostgreSQL driver's Schema class to add a specialized index to an existing table directly.
  • GIN and GiST indexes have no operator class for character type columns (text, character varying, character). For these columns the driver creates an expression index on the text search vector of the column, using to_tsvector(). The text search configuration matching the site default language is used when it exists in the pg_ts_config catalog; otherwise the english configuration is used. Queries must repeat the same expression to use the index.

Example, from a table schema definition:

indexes: [
  new AlternativeIndexes(
    name: 'ages',
    indexes: [
      new GinIndex(name: 'ages', columns: ['age']),
      new Index(name: 'ages', columns: ['age']),
    ],
  ),
],

An earlier approach wrapped legacy array-based index specifications in a value object implementing ArrayAccess and Traversable to carry driver-specific configuration. This was superseded by building on the object-oriented schema definition API instead.

Remaining tasks

  • Code review of the merge request.
  • Update the change record (#3409274) to match the current implementation.

User interface changes

None.

API changes

  • New abstract class \Drupal\Core\Database\SchemaDefinition\IndexBase; Index now extends it. The index name moved to the base class constructor.
  • New final class \Drupal\Core\Database\SchemaDefinition\AlternativeIndexes.
  • New PostgreSQL driver classes GinIndex and GistIndex, and new public methods Schema::addGinIndex() and Schema::addGistIndex().
  • New protected methods on \Drupal\Core\Database\Schema for driver authors: getSupportedIndexDefinitionClasses(), createSpecializedIndex(), resolveIndexDefinition(), and prepareKeysSpecification().
  • The indexes property of Table definitions and the indexes element of the keys specification of Schema::addField() and Schema::changeField() now also accept index definition objects. Existing array-based specifications keep working unchanged.

Data model changes

None.

Release notes snippet

Table schema definitions can now describe specialized index types, starting with PostgreSQL's GIN and GiST indexes, including fallback indexes for database engines that do not support them.

Issue fork drupal-3397622

Command icon 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

daffie created an issue. See original summary.

daffie’s picture

bradjones1’s picture

Title: Adding GIS and GIST indexes to PostgreSQL databases » Adding GIN and GIST indexes to PostgreSQL databases
Issue summary: View changes

Minor typo fix in IS and title.

bradjones1’s picture

Issue summary: View changes
bradjones1’s picture

A bit of rubber-ducking on the possibility of using an object implementing ArrayAccess for a value object that would allow for additional data on the index (beyond just an array of keys.) As proposed by @alexpott.

If I understand this properly, the idea is to be able to specify indexes something like (pseudocode):

$schema[$table]['indexes']['indexName'] = new DbIndexSpec($keys, $postgresIndexType);

If DbIndexSpec implements both ArrayAccess and Traversable, then it could be crafted in a BC-compatible way for any instances where it is foreach()'ed over currently. There is an instance of the Postgres driver doing a typecheck with is_array(), which would not be compatible here, but that code would be changed anyway because this is the whole reason for this conversation.

Setting the is_array() issue aside, the value object would need to be BC-compatible for nested foreach loops such as found in the mysql driver:

  protected function getNormalizedIndexes(array $spec) {
    $indexes = $spec['indexes'] ?? [];
    foreach ($indexes as $index_name => $index_fields) {
      foreach ($index_fields as $index_key => $index_field) {
        // Get the name of the field from the index specification.
        $field_name = is_array($index_field) ? $index_field[0] : $index_field;
        // Check whether the field is defined in the table specification.
        if (isset($spec['fields'][$field_name])) {
          // Get the MySQL type from the processed field.
          $mysql_field = $this->processField($spec['fields'][$field_name]);
          if (in_array($mysql_field['mysql_type'], $this->mysqlStringTypes)) {
            // Check whether we need to shorten the index.
            if ((!isset($mysql_field['type']) || $mysql_field['type'] != 'varchar_ascii') && (!isset($mysql_field['length']) || $mysql_field['length'] > 191)) {
              // Limit the index length to 191 characters.
              $this->shortenIndex($indexes[$index_name][$index_key]);
            }
          }
        }
        else {
          throw new SchemaException("MySQL needs the '$field_name' field specification in order to normalize the '$index_name' index");
        }
      }
    }
    return $indexes;
  }

That said, since there are contrib DB drivers out there, is this just "too much" to try and attempt?

bradjones1’s picture

Got some feedback and background in Slack on "ArrayPI" - the name appears to be a Drupalism (and a fun one at that). There are similar discussions around trying to chip away at particularly complex or DX-unfriendly structures, e.g. #3380145-19: ViewsData should not cache by language. Seems @catch is optimistic about being able to refactor some of these with limited BC impact.

While poking around the related code I also discovered that an index's "key column specifier" can either be an array of column names OR arrays which contain a column name and prefix length. Database drivers can elect to respect the prefix or just use the full column value. (IOW, they must support the syntax but not necessarily create a functional index as a result.) I _think_ this potentially opens us up to using this array syntax to also express driver-specific options, e.g. index type. I don't love it, but it is potentially better than the BC concerns around a value object.

So we could rephrase that docblock to say something like:

 * A key column specifier is either a string naming a column or an array of two
 * elements, column name and length, specifying a prefix of the named column.
 * Note that some DBMS drivers may opt to ignore the prefix length configuration
 * and still use the whole field value for the key. Code should therefore not
 * rely on this functionality.
 * A key column specifier may also contain a key, 'driver_options', which is an array
 * keyed by database module name (e.g., 'pgsql') which is an associative array of
 * keys and values to set driver-specific index options. Drivers supporting additional
 * options should respect a value of NULL for the prefix length, if it is unnecessary or
 * incompatible given the driver-specific options.

This shouldn't BC-break any code that accesses the numeric-indexed members.

bradjones1’s picture

bradjones1’s picture

Status: Active » Needs review

So I _think_ I might have found a way to implement a value object for the index specification that will allow for this kind of configuration to be conveyed. The only issue would be if a non-core database driver does an explicit is_array() check on the index definitions. This would only pass that kind of very strict (and not very useful) type checking if it changed to is_iterable(), which the value object would pass.

Given the talk in Slack and on related issues on this subject, I think that should be OK? And we really, really don't seem to have any other way to include this outside of sticking it in a very awkward new schema definition key like indexes_config or something, which is not very ergonomic and shards this information from the index definitions.

bradjones1’s picture

Issue summary: View changes
bradjones1’s picture

I've added a draft CR to help explain how this works and start the conversation on how to message this, since it's the first time we're doing a value object inside ArrayPI.

I think it could be interesting to encapsulate this into the coding standards re: is_array() is is_iterable() on type checks for ArrayPI members.

mondrake’s picture

A comment on the general approach here if I may.

I think the idea of having value objects to represent DB objects is a great step forward. Doctrine DBAL has done something similar and dropped usage of arrays to describe DB structures.

That said, and given this will set a precedent in Drupal DB API, I would suggest:

1. To have a Schema subfolder where to store both the value object and the enum.
2. With that, the value object name could easily be Index instead of IndexSpecification.
3. Why final? The value object could be extended by drivers exactly to cover DB specific features like here.
4. Testing of DB specifics should be done in the driver extensions of DriverSpecificSchemaTestBase, not in the base class.

bradjones1’s picture

Thanks for the vote of confidence and feedback. On points 1-3 that's the kind of feedback I needed because there is no existing pattern to follow so I was just winging it.

As for #4, I do think that we need to have testing of this in the base class, to detect compatibility breaks. Basically, we need to ensure that all drivers work when they don't necessarily support the extra data and successfully just treat it as an array. Perhaps annotating the test to state as such could make it clearer why it's there. The alternative would be having a base test that has just the object and the pgsql version has the pgsql-specific semantics.

bradjones1’s picture

I realize I missed addressing the question about making the value object final. My justification is that the index definition object itself is driver-agnostic; it's the configuration that is specific to each flavor. So there is no legitimate reason to fork this off as PostgresIndex or whatever because it's not a postgres index - it's an index definition with some postgres metadata attached.

daffie’s picture

Status: Needs review » Needs work

@bradjones1: The solution looks good to me.

We are missing some CRUD operations with the GIN/GIST indexes. We should also add functionality and testing to:
- to check that the GIN/GIST index exists;
- to add GIN/GIST index is created on an existing field;
- to remove a GIN/GIST index;
- when we change an existing field we can also add/remove a GIN/GIST index.

Add some testing that when we create a new table with a GIN/GIST index that the index exists and is a GIN/GIST index. Please create a query with the explain to check that we have created a GIN/GIST index and that the query is using it.

bradjones1’s picture

Status: Needs work » Needs review

Rebased.

Re: The requests in #16 -

to check that the GIN/GIST index exists

GIN/GIST indexes are cataloged by Postgres similar to other indexes, so checking they exist is already possible. To determine their type, you must dig a little deeper, so there is now an additional column of information about indexes (using pg_get_indexdef()) which we're also using in the new tests.

to add GIN/GIST index is created on an existing field
to remove a GIN/GIST index

Test coverage added.

when we change an existing field we can also add/remove a GIN/GIST index

I'm not sure we need to do this, and if we do, this amounts to a new feature for the driver/DBAL overall and nothing specific to GIN/GIST. There is a lot of logic already in ::changeField() but none of it currently touches indexes. In that respect GIN/GiST aren't special, and I think it's incumbent upon the caller to determine if the existing indexes will be compatible. I suggest addressing this in a separate issue. (GIN/GIST do introduce some interesting new considerations around index type support for changing field types, but I still don't think that means we must build a new feature into ::changeField() to get support in.)

Add some testing that when we create a new table with a GIN/GIST index that the index exists and is a GIN/GIST index.

This is in the test coverage. Note that we are in a cart-and-horse situation with respect to testing GIN - its test is almost identical to GIST, we just need a supported field type to test with. So I put in a skipped test as a placeholder and once we commit this, we can enable that test coverage with/after JSON data types go in.

daffie’s picture

Status: Needs review » Needs work
Issue tags: +Needs change record updates

The testbot is failing on PostgreSQL on your added code.

The CR needs examples on how the use the added GIN and GIST indexes.

bradjones1’s picture

Status: Needs work » Needs review

Ah, how embarassing, I forget you need to manually trigger the PgSQL tests. Fixed with some updated coverage of the introspection method.

Updating the CR now.

bradjones1’s picture

Status: Needs review » Needs work

Missed that there are also new MR comments.

poker10’s picture

Thanks for great work @bradjones1! I have added some other comments to the MR.

mondrake’s picture

Some comments inline.

bradjones1’s picture

Status: Needs work » Needs review

Replied to MR comments and made a few changes as a result. Back to NR.

mradcliffe’s picture

Issue summary: View changes
Issue tags: -Needs change record updates

I think this pretty good to commit. Everything makes sense based on the changes from daffie's review.

I updated the change record.

I think we can update the issue summary with the proposed resolution now. Maybe moving some of the initial resolution into the problem/motivation?

bradjones1’s picture

Issue summary: View changes
bradjones1’s picture

Issue summary: View changes
bradjones1’s picture

@mradcliffe would you mind RTBC'ing per your review?

mradcliffe’s picture

Status: Needs review » Reviewed & tested by the community

Agreed. We'll see if we need to do anything else :-)

quietone’s picture

Status: Reviewed & tested by the community » Needs work

I read the Issue Summary, the CR and then the MR. I reviewed the comments and the MR. I left comments in the MR and suggestions. At least one of the suggestions is incomplete, that is it still needs work.

The change record reads well but I was surprised to find that new enum is only mentioned at the end. I think that is important information and should be at the start. There is a code example for the 'after' situation. Can one be added for the 'before' case? And I don't follow this sentence, "The only backwards-compatibility break would be if a driver is explicitly checking the index definition with is_array(), which is currently unnecessary.' It is not clear to me when this is unnecessary, is it before this change or after. Maybe it is better to change this to something like this, "Sites that are using a driver that is explicitly checking the index definition with is_array() will need to ....'..

larowlan’s picture

larowlan’s picture

svenryen made their first commit to this issue’s fork.

steinmb made their first commit to this issue’s fork.

daffie’s picture

I have reviewed the MR and it looks great.

@steinmb Could you address the open remarks on the MR?

Added #3411490: Replace array-based DB Schema API with a value object structure as a related issue. Should not block this issue.

harivansh made their first commit to this issue’s fork.

mondrake’s picture

New MR!13850 is built on top of #3411490: Replace array-based DB Schema API with a value object structure, showing how we could address this issue with the feature contained there.

mondrake’s picture

Status: Needs work » Needs review

The new MR is green both on MySql and PgSql.

smustgrave’s picture

Not sure I'm a good person to review just bumping priority so fingers crossed 11.4!

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.

smustgrave’s picture

Status: Needs review » Needs work

Appears to need a rebase now. Going to post in core-development to try and help get eyes

daffie changed the visibility of the branch 3397622-add-postgresql-gin-and-gist-index to hidden.

mondrake’s picture

I no longer believe that MR!13850 is the right way to go, assuming that #3411490: Replace array-based DB Schema API with a value object structure finally gets in. In that scenario, I think we should strive to make the new index types an abstract definition, too.

However, these would only be possible to be concretely implemented by PostgreSql, at the moment. This means we need to manage a fallback situation where the abstract definition could not be fulfilled, with two possibilities:
a) do nothing if a GIN or GiST index cannot be created - this could happen if we are not interested in having an alternative index created to support data querying;
b) define an alternative index type to be created if a GIN or GiST index cannot be created - this could happen if we still want an index with a different technology created if we cannot creat the preferred one.

My current thinking:

  • we add two new index classes GinIndex and GiSTIndex
  • for case a), we add the new index definitions to the Table's indexes array property:
        $schemaDefinition = new Table(
          name: 'test',
          description: 'Test table',
          columns: [
            ...
          ],
          primaryKey: ...,
          uniqueKeys: [
            ...
          ],
          indexes: [
            new Index(name: 'ages', columns: ['age']),
            new GinIndex(name: ...),
            new GiSTIndex(name: ...),
          ],
        );
    
  • when a db that cannot support them encounters the definition, just skips it.

  • we add then add another 'container' class AlternativeIndexes that lists in order of preference the option for index creation
  • for case b), we still add it to the Table's indexes array property:
        $schemaDefinition = new Table(
          name: 'test',
          description: 'Test table',
          columns: [
            ...
          ],
          primaryKey: ...,
          uniqueKeys: [
            ...
          ],
          indexes: [
            new Index(name: 'ages', columns: ['age']),
            new AlternativeIndexes(
              name: 'foo',
              new GinIndex(name: 'foo', ....),
              new Index(name: 'foo', ....),
            ),
            new GiSTIndex(name: ...),
          ],
        );
    
  • when the definition is read by the db driver, if the GinIndex is supported it is created, if not the Index is created instead.

daffie’s picture

Assigned: Unassigned » daffie

daffie changed the visibility of the branch 3397622-add-postgresql-gin-and-gist-index to active.

daffie changed the visibility of the branch 3397622-adding-gin-and to hidden.

daffie’s picture

Issue summary: View changes

Updated the Is and the CR.

daffie’s picture

Status: Needs work » Needs review
Related issues: +#3586688: Add support for generated columns to the DB Schema Definition API

The CI pipeline failures are not related to the changes in the PR.

To fix #2988018: [PP-1] Performance issues with path alias generated queries on PostgreSQL , we shall need this issue and #3586688: Add support for generated columns to the DB Schema Definition API.

daffie’s picture

Issue summary: View changes
daffie’s picture

Assigned: daffie » Unassigned

Ready for a review.

mondrake’s picture

Status: Needs review » Needs work

Wow, quite a lot of work @daffie! I could not look at details and will be some time before I can get to it, but from the top of it I have a very fundamental comment, see comments inline - IMO we should be consistent in SchemaDefinition being immutable and database-abstract, so the new index classes should be added to SchemaDefinition and not only to the PgSql driver.

andrew.wang’s picture

Issue summary: View changes
daffie’s picture

I get why you would like to have the SchemaDefinition be immutable. The problem for me is that when you start using/supporting PostgreSQL. The immutable SchemaDefinition is a bit limiting. Fro instance, I am working on a new module https://www.drupal.org/project/search_api_paradedb. The module gives a SOLR like search experience, including AI search and speed, only the search indexes are stored in PostgreSQL. No extra search server needed. Just like Drupal has module, PostgreSQL has extensions. This module uses a PostgreSQL extension which adds a new index (BM25). For a complete fun fact, there are multiple extensions that all create their own BM25 index, each with different kinds of functionality.
My question is do we want to support that in out immutable SchemaDefinition or not? And if we do want to support that, then how? I see 3 possible options:
- We do not support the new BM25 index in the SchemaDefinition;
- We support the new BM25 index in Drupal core in the namespace Drupal\Core\Database\SchemaDefinition. The SchemaDefiniton will remain ummutable.
- We let (contrib) modules extend the SchemaDefinition. Only now will the SchemaDefinition be no longer be ummutable.

@mondrake: What is your take on this?

mondrake’s picture

In the most recent commits I worked on finding a better way to manage column addition/change to existing tables. I think this needs an issue of its own now, so I filed #3620121: Introduce SchemaDefinition-based alternatives to Schema::addField and ::changeField.

@daffie I saw #55, will come back on that later.

mondrake’s picture