diff --git a/docs/en/writing-migrations.md b/docs/en/writing-migrations.md index fd2fd88b..b415820e 100644 --- a/docs/en/writing-migrations.md +++ b/docs/en/writing-migrations.md @@ -1335,7 +1335,14 @@ class MyNewMigration extends BaseMigration } ``` -PostgreSQL adapters also supports Generalized Inverted Index `gin` indexes: +#### PostgreSQL Index Access Methods + +PostgreSQL supports several index access methods beyond the default B-tree. +Use the `type` option to specify the access method. + +**GIN (Generalized Inverted Index)** + +GIN indexes are useful for full-text search, arrays, and JSONB columns: ```php table('users'); - $table->addColumn('address', 'string') - ->addIndex('address', ['type' => 'gin']) + $table = $this->table('articles'); + $table->addColumn('tags', 'jsonb') + ->addIndex('tags', ['type' => 'gin']) + ->create(); + } +} +``` + +**GiST (Generalized Search Tree)** + +GiST indexes support geometric data, range types, and full-text search. +For trigram similarity searches (requires the `pg_trgm` extension), use the +`opclass` option: + +```php +table('products'); + $table->addColumn('name', 'string') + ->addIndex('name', [ + 'type' => 'gist', + 'opclass' => ['name' => 'gist_trgm_ops'], + ]) + ->create(); + } +} +``` + +**BRIN (Block Range Index)** + +BRIN indexes are highly efficient for large, naturally-ordered tables like +time-series data. They are much smaller than B-tree indexes but only work well +when data is physically ordered by the indexed column: + +```php +table('sensor_readings'); + $table->addColumn('recorded_at', 'timestamp') + ->addColumn('value', 'decimal') + ->addIndex('recorded_at', ['type' => 'brin']) ->create(); } } ``` +**SP-GiST (Space-Partitioned GiST)** + +SP-GiST indexes work well for data with natural clustering, like IP addresses +or phone numbers: + +```php +table('access_logs'); + $table->addColumn('client_ip', 'inet') + ->addIndex('client_ip', ['type' => 'spgist']) + ->create(); + } +} +``` + +**Hash** + +Hash indexes handle simple equality comparisons. They are rarely needed since +B-tree handles equality efficiently too: + +```php +table('sessions'); + $table->addColumn('session_id', 'string', ['limit' => 64]) + ->addIndex('session_id', ['type' => 'hash']) + ->create(); + } +} +``` + +::: warning +Until CakePHP 5.4 ships the reflection side of these access methods, schema +snapshots generated from tables using non-default index types may be lossy - +the `USING ` clause and `opclass` options are not yet reflected back +into generated migration code. +::: + Removing indexes is as easy as calling the `removeIndex()` method. You must call this method for each index: diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 7fe0f744..3ff57a1f 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -58,7 +58,18 @@ class PostgresAdapter extends AbstractAdapter self::TYPE_NATIVE_UUID, ]; - private const GIN_INDEX_TYPE = 'gin'; + /** + * PostgreSQL index access methods that require USING clause. + * + * @var array + */ + private const ACCESS_METHOD_TYPES = [ + Index::GIN, + Index::GIST, + Index::SPGIST, + Index::BRIN, + Index::HASH, + ]; /** * Columns with comments @@ -925,8 +936,16 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin } $order = $index->getOrder() ?? []; - $columnNames = array_map(function (string $columnName) use ($order): string { + $opclass = $index->getOpclass() ?? []; + $columnNames = array_map(function (string $columnName) use ($order, $opclass): string { $ret = '"' . $columnName . '"'; + + // Add operator class if specified (e.g., gist_trgm_ops) + if (isset($opclass[$columnName])) { + $ret .= ' ' . $opclass[$columnName]; + } + + // Add ordering if specified (e.g., ASC NULLS FIRST) if (isset($order[$columnName])) { $ret .= ' ' . $order[$columnName]; } @@ -937,11 +956,11 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin $include = $index->getInclude(); $includedColumns = $include ? sprintf(' INCLUDE ("%s")', implode('","', $include)) : ''; - $createIndexSentence = 'CREATE %sINDEX%s %s ON %s '; - if ($index->getType() === self::GIN_INDEX_TYPE) { - $createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;'; - } else { - $createIndexSentence .= '(%s)%s%s;'; + // Build USING clause for access method types (gin, gist, spgist, brin, hash) + $indexType = $index->getType(); + $usingClause = ''; + if (in_array($indexType, self::ACCESS_METHOD_TYPES, true)) { + $usingClause = ' USING ' . $indexType; } $where = ''; $whereClause = $index->getWhere(); @@ -950,11 +969,12 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin } return sprintf( - $createIndexSentence, - $index->getType() === Index::UNIQUE ? 'UNIQUE ' : '', + 'CREATE %sINDEX%s %s ON %s%s (%s)%s%s;', + $indexType === Index::UNIQUE ? 'UNIQUE ' : '', $index->getConcurrently() ? ' CONCURRENTLY' : '', $this->quoteColumnName($indexName), $this->quoteTableName($tableName), + $usingClause, implode(',', $columnNames), $includedColumns, $where, diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php index af965397..507509af 100644 --- a/src/Db/Table/Index.php +++ b/src/Db/Table/Index.php @@ -36,6 +36,46 @@ class Index extends DatabaseIndex */ public const FULLTEXT = 'fulltext'; + /** + * PostgreSQL index access method: Generalized Inverted Index. + * Useful for full-text search, arrays, and JSONB columns. + * + * @var string + */ + public const GIN = 'gin'; + + /** + * PostgreSQL index access method: Generalized Search Tree. + * Useful for geometric data, range types, and full-text search. + * + * @var string + */ + public const GIST = 'gist'; + + /** + * PostgreSQL index access method: Space-Partitioned GiST. + * Useful for data with natural clustering like IP addresses or phone numbers. + * + * @var string + */ + public const SPGIST = 'spgist'; + + /** + * PostgreSQL index access method: Block Range Index. + * Highly efficient for large, naturally-ordered tables like time-series data. + * + * @var string + */ + public const BRIN = 'brin'; + + /** + * PostgreSQL index access method: Hash index. + * Handles simple equality comparisons. Rarely needed since B-tree handles equality efficiently. + * + * @var string + */ + public const HASH = 'hash'; + /** * Constructor * @@ -49,6 +89,7 @@ class Index extends DatabaseIndex * @param bool $concurrent Whether to create the index concurrently. * @param ?string $algorithm The ALTER TABLE algorithm (MySQL-specific). * @param ?string $lock The ALTER TABLE lock mode (MySQL-specific). + * @param array|null $opclass The operator class for each column (PostgreSQL). */ public function __construct( protected string $name = '', @@ -61,6 +102,7 @@ public function __construct( protected bool $concurrent = false, protected ?string $algorithm = null, protected ?string $lock = null, + protected ?array $opclass = null, ) { } @@ -199,6 +241,34 @@ public function getLock(): ?string return $this->lock; } + /** + * Set the operator class for index columns. + * + * Operator classes specify which operators the index can use. This is primarily + * useful in PostgreSQL for specialized index types like GiST with trigram support. + * + * Example: ['column_name' => 'gist_trgm_ops'] + * + * @param array $opclass Map of column names to operator classes. + * @return $this + */ + public function setOpclass(array $opclass) + { + $this->opclass = $opclass; + + return $this; + } + + /** + * Get the operator class configuration for index columns. + * + * @return array|null + */ + public function getOpclass(): ?array + { + return $this->opclass; + } + /** * Utility method that maps an array of index options to this object's methods. * @@ -209,7 +279,7 @@ public function getLock(): ?string public function setOptions(array $options) { // Valid Options - $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where', 'algorithm', 'lock']; + $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where', 'algorithm', 'lock', 'opclass']; foreach ($options as $option => $value) { if (!in_array($option, $validOptions, true)) { throw new RuntimeException(sprintf('"%s" is not a valid index option.', $option)); diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 22ce3bf9..8e9fb253 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -8,6 +8,7 @@ use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Database\Connection; use Cake\Datasource\ConnectionManager; +use Exception; use InvalidArgumentException; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\PostgresAdapter; @@ -1497,6 +1498,162 @@ public function testDropIndexByNameWithSchema(): void $this->adapter->dropSchema('schema1'); } + public function testAddGistIndex(): void + { + // GiST indexes require specific data types with GiST support. + // We use int4range which has built-in GiST support in PostgreSQL. + $this->adapter->execute('CREATE TABLE table1 (id SERIAL PRIMARY KEY, int_range int4range)'); + + $table = new Table('table1', [], $this->adapter); + $table->addIndex('int_range', ['type' => 'gist']) + ->save(); + + $this->assertTrue($table->hasIndex('int_range')); + + // Verify the index uses the GIST access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_int_range'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gist', $rows[0]['access_method']); + } + + public function testAddGinIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('tags', 'jsonb') + ->save(); + + $table->addIndex('tags', ['type' => 'gin']) + ->save(); + + $this->assertTrue($table->hasIndex('tags')); + + // Verify the index uses the GIN access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_tags'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gin', $rows[0]['access_method']); + } + + public function testAddBrinIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('created_at', 'timestamp') + ->save(); + + $table->addIndex('created_at', ['type' => 'brin']) + ->save(); + + $this->assertTrue($table->hasIndex('created_at')); + + // Verify the index uses the BRIN access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_created_at'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('brin', $rows[0]['access_method']); + } + + public function testAddHashIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('session_id', 'string', ['limit' => 64]) + ->save(); + + $table->addIndex('session_id', ['type' => 'hash']) + ->save(); + + $this->assertTrue($table->hasIndex('session_id')); + + // Verify the index uses the HASH access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_session_id'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('hash', $rows[0]['access_method']); + } + + public function testAddSpgistIndex(): void + { + // SP-GiST indexes on text require the text_ops operator class + $table = new Table('table1', [], $this->adapter); + $table->addColumn('data', 'text') + ->save(); + + $table->addIndex('data', ['type' => 'spgist', 'opclass' => ['data' => 'text_ops']]) + ->save(); + + $this->assertTrue($table->hasIndex('data')); + + // Verify the index uses the SP-GIST access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_data'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('spgist', $rows[0]['access_method']); + } + + public function testAddIndexWithOpclass(): void + { + // Test opclass with GiST using pg_trgm extension + // Skip if extension is not available + try { + $this->adapter->execute('CREATE EXTENSION IF NOT EXISTS pg_trgm'); + } catch (Exception) { + $this->markTestSkipped('pg_trgm extension is not available'); + } + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('name', 'string') + ->save(); + + $table->addIndex('name', [ + 'type' => 'gist', + 'opclass' => ['name' => 'gist_trgm_ops'], + ])->save(); + + $this->assertTrue($table->hasIndex('name')); + + // Verify the index was created with the correct access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_name'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gist', $rows[0]['access_method']); + } + public function testAddForeignKey(): void { $refTable = new Table('ref_table', [], $this->adapter);