diff --git a/README.md b/README.md index ac760c5..72e2640 100644 --- a/README.md +++ b/README.md @@ -2086,6 +2086,50 @@ $schema->table('events') TTL expressions are emitted verbatim; they must not be empty or contain semicolons. Dialects other than ClickHouse throw `UnsupportedException`. +**Skip-index algorithms** — every ClickHouse index is a data-skipping index that accelerates WHERE pruning by letting the engine skip whole granules. Pick the algorithm that matches the column shape via the `algorithm` argument on `Table::index()`: + +```php +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; + +$schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->string('country') + ->string('text') + // BloomFilter — high-cardinality strings with `=` / `IN` predicates + ->index(['user_id'], algorithm: IndexAlgorithm::BloomFilter) + // Set(N) — small fixed value sets, custom granularity + ->index(['country'], algorithm: IndexAlgorithm::Set, algorithmArgs: [100], granularity: 4) + // NgramBloomFilter(n, size_bytes, hashes, seed) — text search on `LIKE` / `match` + ->index(['text'], algorithm: IndexAlgorithm::NgramBloomFilter, algorithmArgs: [4, 1024, 3, 0]) + // No algorithm specified → defaults to `TYPE minmax GRANULARITY 3` + ->index(['id']) + ->create(); + +// CREATE TABLE `events` (..., INDEX `idx_user_id` `user_id` TYPE bloom_filter GRANULARITY 1, ...) +``` + +The 6 algorithms are `MinMax`, `Set`, `BloomFilter`, `NgramBloomFilter`, `TokenBloomFilter`, `Inverted`. Algorithm-specific arguments are passed via `algorithmArgs` and rendered verbatim — supply them from trusted (developer-controlled) source. Other dialects ignore the ClickHouse-only `algorithm` / `algorithmArgs` / `granularity` arguments. + +`MinMax` and `Inverted` take no parenthesised arguments in ClickHouse DDL — passing `algorithmArgs` for them throws `ValidationException`. Skip indexes can also be added via `ALTER TABLE … ADD INDEX` by calling `alter()` on the builder. + +**Engine SETTINGS** — emit `SETTINGS k=v` after the TTL clause: + +```php +$schema->table('events') + ->bigInteger('id')->primary() + ->settings([ + 'index_granularity' => 8192, + 'allow_nullable_key' => true, // booleans become 1/0 + ]) + ->create(); + +// CREATE TABLE `events` (...) ENGINE = MergeTree() ORDER BY (`id`) +// SETTINGS index_granularity = 8192, allow_nullable_key = 1 +``` + +Setting names must match `[A-Za-z_][A-Za-z0-9_]*`; string values are restricted to `[A-Za-z0-9_.\-+/]*`. Use ints / floats / booleans for everything else. Other dialects ignore the call. + ### SQLite Schema ```php diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index e76df4a..22d35b0 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -124,6 +124,15 @@ public function compileAlter(Table $table): Statement $alterations[] = 'DROP INDEX ' . $this->quote($name); } + foreach ($table->indexes as $index) { + if ($index->type !== IndexType::Index) { + throw new UnsupportedException( + 'Only data-skipping indexes (index()) are supported in ClickHouse ALTER TABLE.' + ); + } + $alterations[] = 'ADD ' . $this->compileSkipIndex($index); + } + if (! empty($table->foreignKeys)) { throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); } @@ -132,6 +141,12 @@ public function compileAlter(Table $table): Statement throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); } + if (! empty($table->settings)) { + throw new UnsupportedException( + 'Table SETTINGS can only be set on CREATE TABLE; emit `ALTER TABLE ... MODIFY SETTING` directly to change them.' + ); + } + if (empty($alterations)) { throw new ValidationException('ALTER TABLE requires at least one alteration.'); } @@ -165,12 +180,13 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen $primaryKeys = \array_map(fn (string $c): string => $this->quote($c), $table->compositePrimaryKey); } - // Indexes (ClickHouse uses INDEX ... TYPE ... GRANULARITY ...) foreach ($table->indexes as $index) { - $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); - $expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')'; - $columnDefs[] = 'INDEX ' . $this->quote($index->name) - . ' ' . $expr . ' TYPE minmax GRANULARITY 3'; + if ($index->type !== IndexType::Index) { + throw new UnsupportedException( + 'Only data-skipping indexes (index()) are supported in ClickHouse CREATE TABLE.' + ); + } + $columnDefs[] = $this->compileSkipIndex($index); } if (! empty($table->foreignKeys)) { @@ -205,9 +221,57 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen $sql .= ' TTL ' . $table->ttl; } + if (! empty($table->settings)) { + $kv = []; + foreach ($table->settings as $k => $v) { + $kv[] = $k . ' = ' . $v; + } + $sql .= ' SETTINGS ' . \implode(', ', $kv); + } + return new Statement($sql, [], executor: $this->executor); } + /** + * Render a full `INDEX TYPE [(args)] GRANULARITY ` + * fragment, used by both CREATE TABLE and ALTER TABLE ADD INDEX. + * + * Defaults to `TYPE minmax GRANULARITY 3` when no algorithm is set on the + * index — matches the ClickHouse default behaviour for callers using the + * generic `Table::index()` without picking an algorithm. + */ + private function compileSkipIndex(Index $index): string + { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')'; + + if ($index->algorithm === null) { + return 'INDEX ' . $this->quote($index->name) . ' ' . $expr + . ' TYPE minmax GRANULARITY ' . ($index->granularity ?? 3); + } + + $type = $index->algorithm->value; + + if ($index->algorithmArgs !== []) { + $args = \array_map( + fn (string|int|float $arg): string => match (true) { + \is_string($arg) => "'" . \str_replace("'", "''", $arg) . "'", + // sprintf('%F', ...) avoids scientific notation (e.g. 1.0E-5) + // which ClickHouse rejects in index type arguments. Trim + // trailing zeros so 0.01 stays "0.010000" → "0.01". + \is_float($arg) => \rtrim(\rtrim(\sprintf('%F', $arg), '0'), '.'), + default => (string) $arg, + }, + $index->algorithmArgs, + ); + + $type .= '(' . \implode(', ', $args) . ')'; + } + + return 'INDEX ' . $this->quote($index->name) . ' ' . $expr + . ' TYPE ' . $type . ' GRANULARITY ' . ($index->granularity ?? 1); + } + /** * Compile an engine declaration: `` or `()`. * diff --git a/src/Query/Schema/ClickHouse/IndexAlgorithm.php b/src/Query/Schema/ClickHouse/IndexAlgorithm.php new file mode 100644 index 0000000..96d5ffa --- /dev/null +++ b/src/Query/Schema/ClickHouse/IndexAlgorithm.php @@ -0,0 +1,13 @@ + $lengths * @param array $orders * @param array $collations + * @param list $algorithmArgs ClickHouse skip-index algorithm args */ public function index( array $columns, @@ -401,8 +403,22 @@ public function index( array $lengths = [], array $orders = [], array $collations = [], + ?IndexAlgorithm $algorithm = null, + array $algorithmArgs = [], + ?int $granularity = null, ): Table { - return $this->table->index($columns, $name, $method, $operatorClass, $lengths, $orders, $collations); + return $this->table->index( + $columns, + $name, + $method, + $operatorClass, + $lengths, + $orders, + $collations, + $algorithm, + $algorithmArgs, + $granularity, + ); } /** @@ -508,6 +524,14 @@ public function engine(Engine $engine, string ...$args): Table return $this->table->engine($engine, ...$args); } + /** + * @param array $settings + */ + public function settings(array $settings): Table + { + return $this->table->settings($settings); + } + /** * @param list $columns */ diff --git a/src/Query/Schema/Index.php b/src/Query/Schema/Index.php index f0c48c2..154ef26 100644 --- a/src/Query/Schema/Index.php +++ b/src/Query/Schema/Index.php @@ -3,6 +3,7 @@ namespace Utopia\Query\Schema; use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; readonly class Index { @@ -12,6 +13,10 @@ * @param array $orders * @param array $collations Column-specific collations (column name => collation) * @param list $rawColumns Raw SQL expressions appended to the column list (bypass quoting) + * @param list $algorithmArgs ClickHouse skip-index algorithm args + * (e.g. [3] for set(3), + * [0.01] for bloom_filter(0.01), + * [4, 1024, 3, 0] for ngrambf_v1(n, size_bytes, hashes, seed)) */ public function __construct( public string $name, @@ -23,7 +28,19 @@ public function __construct( public string $operatorClass = '', public array $collations = [], public array $rawColumns = [], + public ?IndexAlgorithm $algorithm = null, + public array $algorithmArgs = [], + public ?int $granularity = null, ) { + // Only ClickHouse data-skipping indexes require an unquoted identifier + // for the name; other dialects emit the name backtick-quoted, so + // hyphens, dots, and other characters are valid there. + if ($algorithm !== null && ! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new ValidationException('Invalid index name: ' . $name); + } + if ($columns === [] && $rawColumns === []) { + throw new ValidationException('Index requires at least one column.'); + } if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { throw new ValidationException('Invalid index method: ' . $method); } @@ -35,5 +52,26 @@ public function __construct( throw new ValidationException('Invalid collation: ' . $collation); } } + if ($granularity !== null && $granularity < 1) { + throw new ValidationException('Index granularity must be >= 1.'); + } + if ($algorithm !== null && $algorithmArgs !== [] && ! self::algorithmAcceptsArgs($algorithm)) { + throw new ValidationException( + $algorithm->value . ' does not accept algorithm arguments.' + ); + } + } + + /** + * MinMax and Inverted are emitted without parentheses in ClickHouse DDL; + * passing args to them would produce invalid SQL. + */ + private static function algorithmAcceptsArgs(IndexAlgorithm $algorithm): bool + { + return match ($algorithm) { + IndexAlgorithm::MinMax, + IndexAlgorithm::Inverted => false, + default => true, + }; } } diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index 9274a28..b46121a 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -7,6 +7,7 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; class Table { @@ -57,6 +58,9 @@ class Table public private(set) ?string $ttl = null; + /** @var array Table-level engine SETTINGS (ClickHouse only) */ + public private(set) array $settings = []; + public function __construct( private readonly ?Schema $schema = null, public readonly string $name = '', @@ -356,6 +360,7 @@ public function timestamps(int $precision = 3): static * @param array $lengths * @param array $orders * @param array $collations + * @param list $algorithmArgs ClickHouse skip-index algorithm args */ public function index( array $columns, @@ -365,11 +370,26 @@ public function index( array $lengths = [], array $orders = [], array $collations = [], + ?IndexAlgorithm $algorithm = null, + array $algorithmArgs = [], + ?int $granularity = null, ): static { if ($name === '') { - $name = 'idx_' . \implode('_', $columns); + $name = $this->autoIndexName('idx_', $columns); } - $this->indexes[] = new Index($name, $columns, IndexType::Index, $lengths, $orders, $method, $operatorClass, $collations); + $this->indexes[] = new Index( + $name, + $columns, + IndexType::Index, + $lengths, + $orders, + $method, + $operatorClass, + $collations, + algorithm: $algorithm, + algorithmArgs: $algorithmArgs, + granularity: $granularity, + ); return $this; } @@ -388,7 +408,7 @@ public function uniqueIndex( array $collations = [], ): static { if ($name === '') { - $name = 'uniq_' . \implode('_', $columns); + $name = $this->autoIndexName('uniq_', $columns); } $this->indexes[] = new Index($name, $columns, IndexType::Unique, $lengths, $orders, collations: $collations); @@ -401,7 +421,7 @@ public function uniqueIndex( public function fulltextIndex(array $columns, string $name = ''): static { if ($name === '') { - $name = 'ft_' . \implode('_', $columns); + $name = $this->autoIndexName('ft_', $columns); } $this->indexes[] = new Index($name, $columns, IndexType::Fulltext); @@ -414,7 +434,7 @@ public function fulltextIndex(array $columns, string $name = ''): static public function spatialIndex(array $columns, string $name = ''): static { if ($name === '') { - $name = 'sp_' . \implode('_', $columns); + $name = $this->autoIndexName('sp_', $columns); } $this->indexes[] = new Index($name, $columns, IndexType::Spatial); @@ -656,4 +676,73 @@ public function ttl(string $expression): static return $this; } + + /** + * Build an auto-generated index name with a prefix, sanitising any + * non-identifier characters in the column names so the result is always a + * valid SQL identifier. + * + * @param string[] $columns + */ + private function autoIndexName(string $prefix, array $columns): string + { + $sanitised = \array_map( + fn (string $c): string => \preg_replace('/[^A-Za-z0-9_]+/', '_', $c) ?? $c, + $columns, + ); + + return $prefix . \implode('_', $sanitised); + } + + /** + * Set table-level engine SETTINGS (ClickHouse only). Other dialects ignore. + * + * Compiled as `SETTINGS k=v, ...` after the TTL clause. Booleans become + * `1` / `0`, ints/floats are stringified, strings are passed through after + * a conservative character allow-list check. + * + * Calling this method replaces previously-set settings. + * + * @param array $settings + * + * @throws ValidationException if any key is not a valid identifier or any + * string value contains characters outside the + * allow-list. + */ + public function settings(array $settings): static + { + $sanitized = []; + + foreach ($settings as $key => $value) { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + throw new ValidationException('Invalid setting name: ' . $key); + } + + if (\is_bool($value)) { + $sanitized[$key] = $value ? '1' : '0'; + } elseif (\is_int($value)) { + $sanitized[$key] = (string) $value; + } elseif (\is_float($value)) { + // Avoid scientific notation (e.g. 1.0E-5), which ClickHouse + // rejects in SETTINGS values; trim trailing zeros for clean + // output. + $sanitized[$key] = \rtrim(\rtrim(\sprintf('%F', $value), '0'), '.'); + } elseif (\is_string($value)) { + if (! \preg_match('/^[A-Za-z0-9_.\-+\/]+$/', $value)) { + throw new ValidationException( + 'Invalid setting value for ' . $key . ': must match [A-Za-z0-9_.\-+/]+' + ); + } + $sanitized[$key] = $value; + } else { + throw new ValidationException( + 'Setting value for ' . $key . ' must be string, int, float, or bool.' + ); + } + } + + $this->settings = $sanitized; + + return $this; + } } diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php index 013a5bf..852ad04 100644 --- a/tests/Query/Schema/ClickHouseTest.php +++ b/tests/Query/Schema/ClickHouseTest.php @@ -10,6 +10,7 @@ use Utopia\Query\Query; use Utopia\Query\Schema\ClickHouse as Schema; use Utopia\Query\Schema\ClickHouse\Engine; +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\Feature\ColumnComments; use Utopia\Query\Schema\Feature\DropPartition; @@ -705,4 +706,275 @@ public function testColumnLevelTTL(): void $this->assertSame('CREATE TABLE `events` (`id` Int32, `temporary` String TTL ts + INTERVAL 1 DAY, `ts` DateTime) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); } + + // ClickHouse skip-index algorithm selection + + public function testIndexBloomFilter(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->index(['user_id'], algorithm: IndexAlgorithm::BloomFilter) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `user_id` String, INDEX `idx_user_id` `user_id` TYPE bloom_filter GRANULARITY 1) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testIndexWithAlgorithmArgs(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('country') + ->string('text') + ->index(['country'], algorithm: IndexAlgorithm::Set, algorithmArgs: [100], granularity: 4) + ->index(['text'], algorithm: IndexAlgorithm::NgramBloomFilter, algorithmArgs: [4, 1024, 3, 0]) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `country` String, `text` String,' + . ' INDEX `idx_country` `country` TYPE set(100) GRANULARITY 4,' + . ' INDEX `idx_text` `text` TYPE ngrambf_v1(4, 1024, 3, 0) GRANULARITY 1)' + . ' ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testIndexCompositeColumnsWithAlgorithm(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->string('event') + ->index(['user_id', 'event'], name: 'idx_user_event', algorithm: IndexAlgorithm::BloomFilter) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `user_id` String, `event` String,' + . ' INDEX `idx_user_event` (`user_id`, `event`) TYPE bloom_filter GRANULARITY 1)' + . ' ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testIndexInvalidGranularityThrows(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->index(['user_id'], algorithm: IndexAlgorithm::BloomFilter, granularity: 0); + } + + public function testIndexEmptyColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->index([]); + } + + public function testIndexNameRegexOnlyEnforcedForClickHouseAlgorithms(): void + { + // No algorithm → permissive name allowed (other dialects quote names) + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->index(['user_id'], name: 'idx-with-hyphens') + ->create(); + $this->assertBindingCount($result); + $this->assertStringContainsString('INDEX `idx-with-hyphens`', $result->query); + } + + public function testIndexNameRegexEnforcedWhenAlgorithmIsSet(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid index name: idx-with-hyphens'); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->index(['user_id'], name: 'idx-with-hyphens', algorithm: IndexAlgorithm::BloomFilter); + } + + // SETTINGS + + public function testTableSettings(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => true]) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64) ENGINE = MergeTree() ORDER BY (`id`)' + . ' SETTINGS index_granularity = 8192, allow_nullable_key = 1', + $result->query, + ); + } + + public function testTableSettingsWithTtlOrdering(): void + { + $schema = new Schema(); + $table = $schema->table('events'); + $table->bigInteger('id')->primary(); + $table->datetime('created_at'); + $result = $table + ->ttl('`created_at` + INTERVAL 30 DAY') + ->settings(['index_granularity' => 4096]) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `created_at` DateTime) ENGINE = MergeTree() ORDER BY (`id`)' + . ' TTL `created_at` + INTERVAL 30 DAY' + . ' SETTINGS index_granularity = 4096', + $result->query, + ); + } + + public function testTableSettingsRejectsInvalidKey(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->settings(['1bad-key' => 8192]); + } + + public function testTableSettingsRejectsInvalidValue(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->settings(['ok_key' => "evil'; DROP TABLE x; --"]); + } + + public function testTableSettingsFloatAvoidsScientificNotation(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->settings(['merge_with_ttl_timeout' => 1.0e-5]) + ->create(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS merge_with_ttl_timeout = 0.00001', $result->query); + $this->assertDoesNotMatchRegularExpression('/[Ee][+-]\d/', $result->query); + } + + public function testIndexNoArgAlgorithmRejectsArgs(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('minmax does not accept algorithm arguments.'); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->integer('score') + ->index(['score'], algorithm: IndexAlgorithm::MinMax, algorithmArgs: [3]); + } + + public function testIndexInvertedRejectsArgs(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->string('text') + ->index(['text'], algorithm: IndexAlgorithm::Inverted, algorithmArgs: [42]); + } + + public function testIndexAutoNameSanitisesNonIdentifierColumns(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('event-type') + ->index(['event-type'], algorithm: IndexAlgorithm::BloomFilter) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `event-type` String,' + . ' INDEX `idx_event_type` `event-type` TYPE bloom_filter GRANULARITY 1)' + . ' ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testIndexFloatArgAvoidsScientificNotation(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->string('user_id') + ->index(['user_id'], algorithm: IndexAlgorithm::BloomFilter, algorithmArgs: [1.0e-5]) + ->create(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('TYPE bloom_filter(0.00001)', $result->query); + // Numeric arg should be fixed-point — no 'E-' or 'E+' anywhere + $this->assertDoesNotMatchRegularExpression('/[Ee][+-]\d/', $result->query); + } + + public function testAlterAddIndexWithAlgorithm(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->index(['user_id'], algorithm: IndexAlgorithm::BloomFilter) + ->alter(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` ADD INDEX `idx_user_id` `user_id` TYPE bloom_filter GRANULARITY 1', + $result->query, + ); + } + + public function testAlterAddIndexComposite(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->index(['user_id', 'event'], name: 'idx_user_event', algorithm: IndexAlgorithm::Set, algorithmArgs: [100], granularity: 4) + ->alter(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` ADD INDEX `idx_user_event` (`user_id`, `event`) TYPE set(100) GRANULARITY 4', + $result->query, + ); + } + + public function testAlterRejectsSettings(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('SETTINGS'); + + $schema = new Schema(); + $schema->table('events') + ->bigInteger('id')->primary() + ->settings(['index_granularity' => 4096]) + ->alter(); + } }