Skip to content

Commit 1f811df

Browse files
committed
fix(schema): address review feedback on ClickHouse skip indexes
Four follow-ups based on the greptile review on PR #6: - alter() now renders ADD INDEX for any skipIndexes set on the blueprint, matching ClickHouse's `ALTER TABLE ... ADD INDEX name expr TYPE algo GRANULARITY n` syntax. Previously dataSkippingIndex() inside an alter() callback was silently dropped (P1). - alter() throws UnsupportedException if the blueprint has settings — ClickHouse does not accept SETTINGS in ALTER TABLE; callers must emit `ALTER TABLE ... MODIFY SETTING` directly. Surfaces what was previously a silent no-op. - SkipIndex constructor rejects algorithmArgs for MinMax and Inverted (P2) — both are emitted without parentheses in ClickHouse DDL, so any args would have produced invalid SQL like `minmax(1)`. - Table::dataSkippingIndex() now sanitises auto-generated index names when columns contain non-identifier characters (P2) — `event-type` no longer produces a confusing `Invalid skip index name: skip_event-type` exception. Non-identifier characters are collapsed to `_`. - compileSkipAlgorithm() formats float args with sprintf('%F', ...) instead of (string) cast (P2) — the cast can produce scientific notation like `1.0E-5`, which ClickHouse rejects in index type arguments. Trailing zeros are trimmed for readability so 0.01 stays "0.01" rather than "0.010000". Adds tests for each fix plus one for ALTER ADD INDEX with composite columns and algorithm args.
1 parent 18a77b0 commit 1f811df

4 files changed

Lines changed: 148 additions & 4 deletions

File tree

src/Query/Schema/ClickHouse.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ public function alter(string $table, callable $definition): Statement
130130
$alterations[] = 'DROP INDEX ' . $this->quote($name);
131131
}
132132

133+
foreach ($blueprint->skipIndexes as $skip) {
134+
$cols = \array_map(fn (string $c): string => $this->quote($c), $skip->columns);
135+
$expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')';
136+
$alterations[] = 'ADD INDEX ' . $this->quote($skip->name)
137+
. ' ' . $expr . ' TYPE ' . $this->compileSkipAlgorithm($skip)
138+
. ' GRANULARITY ' . $skip->granularity;
139+
}
140+
133141
if (! empty($blueprint->foreignKeys)) {
134142
throw new UnsupportedException('Foreign keys are not supported in ClickHouse.');
135143
}
@@ -138,6 +146,12 @@ public function alter(string $table, callable $definition): Statement
138146
throw new UnsupportedException('Foreign keys are not supported in ClickHouse.');
139147
}
140148

149+
if (! empty($blueprint->settings)) {
150+
throw new UnsupportedException(
151+
'Table SETTINGS can only be set on CREATE TABLE; emit `ALTER TABLE ... MODIFY SETTING` directly to change them.'
152+
);
153+
}
154+
141155
if (empty($alterations)) {
142156
throw new ValidationException('ALTER TABLE requires at least one alteration.');
143157
}
@@ -245,9 +259,14 @@ private function compileSkipAlgorithm(SkipIndex $skip): string
245259
}
246260

247261
$args = \array_map(
248-
fn (string|int|float $arg): string => \is_string($arg)
249-
? "'" . \str_replace("'", "''", $arg) . "'"
250-
: (string) $arg,
262+
fn (string|int|float $arg): string => match (true) {
263+
\is_string($arg) => "'" . \str_replace("'", "''", $arg) . "'",
264+
// sprintf('%F', ...) avoids scientific notation (e.g. 1.0E-5)
265+
// which ClickHouse rejects in index type arguments. Trim
266+
// trailing zeros so 0.01 stays "0.010000" → "0.01".
267+
\is_float($arg) => \rtrim(\rtrim(\sprintf('%F', $arg), '0'), '.'),
268+
default => (string) $arg,
269+
},
251270
$skip->algorithmArgs,
252271
);
253272

src/Query/Schema/ClickHouse/SkipIndex.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,23 @@ public function __construct(
2929
if ($granularity < 1) {
3030
throw new ValidationException('Skip index granularity must be >= 1.');
3131
}
32+
if ($algorithmArgs !== [] && ! self::algorithmAcceptsArgs($algorithm)) {
33+
throw new ValidationException(
34+
$algorithm->value . ' does not accept algorithm arguments.'
35+
);
36+
}
37+
}
38+
39+
/**
40+
* MinMax and Inverted are emitted without parentheses; passing args to
41+
* them would produce DDL that ClickHouse rejects at parse time.
42+
*/
43+
private static function algorithmAcceptsArgs(SkipIndexAlgorithm $algorithm): bool
44+
{
45+
return match ($algorithm) {
46+
SkipIndexAlgorithm::MinMax,
47+
SkipIndexAlgorithm::Inverted => false,
48+
default => true,
49+
};
3250
}
3351
}

src/Query/Schema/Table.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,15 @@ public function dataSkippingIndex(
580580
string $name = '',
581581
): static {
582582
if ($name === '') {
583-
$name = 'skip_' . \implode('_', $columns);
583+
// Sanitise column names — substring matches like `event-type` or
584+
// `ns.col` are valid SQL identifiers when quoted, but the
585+
// generated index name must still pass the strict identifier
586+
// regex on `SkipIndex`.
587+
$sanitised = \array_map(
588+
fn (string $c): string => \preg_replace('/[^A-Za-z0-9_]+/', '_', $c) ?? $c,
589+
$columns,
590+
);
591+
$name = 'skip_' . \implode('_', $sanitised);
584592
}
585593

586594
$this->skipIndexes[] = new SkipIndex($name, $columns, $algorithm, $algorithmArgs, $granularity);

tests/Query/Schema/ClickHouseTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,4 +861,103 @@ public function testTableSettingsRejectsInvalidValue(): void
861861
$table->settings(['ok_key' => "evil'; DROP TABLE x; --"]);
862862
});
863863
}
864+
865+
public function testDataSkippingIndexNoArgAlgorithmRejectsArgs(): void
866+
{
867+
$this->expectException(ValidationException::class);
868+
$this->expectExceptionMessage('minmax does not accept algorithm arguments.');
869+
870+
$schema = new Schema();
871+
$schema->create('events', function (Table $table) {
872+
$table->bigInteger('id')->primary();
873+
$table->integer('score');
874+
$table->dataSkippingIndex(['score'], SkipIndexAlgorithm::MinMax, algorithmArgs: [3]);
875+
});
876+
}
877+
878+
public function testDataSkippingIndexInvertedRejectsArgs(): void
879+
{
880+
$this->expectException(ValidationException::class);
881+
882+
$schema = new Schema();
883+
$schema->create('events', function (Table $table) {
884+
$table->bigInteger('id')->primary();
885+
$table->string('text');
886+
$table->dataSkippingIndex(['text'], SkipIndexAlgorithm::Inverted, algorithmArgs: [42]);
887+
});
888+
}
889+
890+
public function testDataSkippingIndexAutoNameSanitisesNonIdentifierColumns(): void
891+
{
892+
$schema = new Schema();
893+
$result = $schema->create('events', function (Table $table) {
894+
$table->bigInteger('id')->primary();
895+
$table->string('event-type');
896+
$table->dataSkippingIndex(['event-type'], SkipIndexAlgorithm::BloomFilter);
897+
});
898+
$this->assertBindingCount($result);
899+
900+
// Auto name: skip_event_type (non-identifier chars collapsed to _)
901+
$this->assertSame(
902+
'CREATE TABLE `events` (`id` Int64, `event-type` String,'
903+
. ' INDEX `skip_event_type` `event-type` TYPE bloom_filter GRANULARITY 1)'
904+
. ' ENGINE = MergeTree() ORDER BY (`id`)',
905+
$result->query,
906+
);
907+
}
908+
909+
public function testDataSkippingIndexFloatArgAvoidsScientificNotation(): void
910+
{
911+
$schema = new Schema();
912+
$result = $schema->create('events', function (Table $table) {
913+
$table->bigInteger('id')->primary();
914+
$table->string('user_id');
915+
// 1e-5 false positive rate: the bug pre-fix is `(string) 1e-5` returning "1.0E-5"
916+
$table->dataSkippingIndex(['user_id'], SkipIndexAlgorithm::BloomFilter, algorithmArgs: [1.0e-5]);
917+
});
918+
$this->assertBindingCount($result);
919+
920+
$this->assertStringContainsString('TYPE bloom_filter(0.00001)', $result->query);
921+
// Numeric arg should be fixed-point — no 'E-' or 'E+' anywhere
922+
$this->assertDoesNotMatchRegularExpression('/[Ee][+-]\d/', $result->query);
923+
}
924+
925+
public function testAlterAddSkipIndex(): void
926+
{
927+
$schema = new Schema();
928+
$result = $schema->alter('events', function (Table $table) {
929+
$table->dataSkippingIndex(['user_id'], SkipIndexAlgorithm::BloomFilter);
930+
});
931+
$this->assertBindingCount($result);
932+
933+
$this->assertSame(
934+
'ALTER TABLE `events` ADD INDEX `skip_user_id` `user_id` TYPE bloom_filter GRANULARITY 1',
935+
$result->query,
936+
);
937+
}
938+
939+
public function testAlterAddSkipIndexComposite(): void
940+
{
941+
$schema = new Schema();
942+
$result = $schema->alter('events', function (Table $table) {
943+
$table->dataSkippingIndex(['user_id', 'event'], SkipIndexAlgorithm::Set, granularity: 4, algorithmArgs: [100], name: 'idx_user_event');
944+
});
945+
$this->assertBindingCount($result);
946+
947+
$this->assertSame(
948+
'ALTER TABLE `events` ADD INDEX `idx_user_event` (`user_id`, `event`) TYPE set(100) GRANULARITY 4',
949+
$result->query,
950+
);
951+
}
952+
953+
public function testAlterRejectsSettings(): void
954+
{
955+
$this->expectException(UnsupportedException::class);
956+
$this->expectExceptionMessage('SETTINGS');
957+
958+
$schema = new Schema();
959+
$schema->alter('events', function (Table $table) {
960+
$table->settings(['index_granularity' => 4096]);
961+
});
962+
}
864963
}

0 commit comments

Comments
 (0)