Skip to content

Commit 18a77b0

Browse files
committed
feat(schema): ClickHouse data-skipping indexes and engine SETTINGS
The ClickHouse schema previously hard-coded `TYPE minmax GRANULARITY 3` for every index and had no way to emit a `SETTINGS k=v` clause. Both are needed by real ClickHouse-backed services that pick algorithms per column and tune engine settings (e.g. `index_granularity`, `allow_nullable_key`). This change adds: - `Schema\ClickHouse\SkipIndexAlgorithm` enum — `MinMax`, `Set`, `BloomFilter`, `NgramBloomFilter`, `TokenBloomFilter`, `Inverted`. - `Schema\ClickHouse\SkipIndex` value object carrying the algorithm, granularity, and algorithm-specific args (e.g. `[100]` for `set(100)`, `[4, 1024, 3, 0]` for `ngrambf_v1(...)`). - `Table::dataSkippingIndex()` — attach a skip index to the blueprint. Default granularity 1, default no algorithm args. - `Table::settings()` — set table-level engine SETTINGS, with a conservative key/value allow-list to keep DDL safe. - `Schema\ClickHouse::create()` renders both. SETTINGS is emitted after TTL. The existing minmax compile path is unchanged for backward compatibility — callers using `index()` keep their current output. Other dialects ignore the new fields, matching how `engine()` and `ttl()` are already handled. Tests cover each skip algorithm shape, composite-column skip indexes, SETTINGS rendering with and without TTL, and validation of bad granularity, empty columns, bad setting names, and unsafe string values.
1 parent ca96081 commit 18a77b0

6 files changed

Lines changed: 361 additions & 0 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,6 +2081,49 @@ $schema->create('events', function (Table $table) {
20812081

20822082
TTL expressions are emitted verbatim; they must not be empty or contain semicolons. Dialects other than ClickHouse throw `UnsupportedException`.
20832083

2084+
**Data-skipping indexes** — accelerate WHERE pruning by letting ClickHouse skip whole granules:
2085+
2086+
```php
2087+
use Utopia\Query\Schema\ClickHouse\SkipIndexAlgorithm;
2088+
2089+
$schema->create('events', function (Table $table) {
2090+
$table->bigInteger('id')->primary();
2091+
$table->string('user_id');
2092+
$table->string('country');
2093+
$table->string('text');
2094+
2095+
// Default granularity = 1, no algorithm args
2096+
$table->dataSkippingIndex(['user_id'], SkipIndexAlgorithm::BloomFilter);
2097+
2098+
// Set(N) — small fixed value sets
2099+
$table->dataSkippingIndex(['country'], SkipIndexAlgorithm::Set, granularity: 4, algorithmArgs: [100]);
2100+
2101+
// NgramBloomFilter(n, size_bytes, hashes, seed) — text search on `LIKE` / `match`
2102+
$table->dataSkippingIndex(['text'], SkipIndexAlgorithm::NgramBloomFilter, algorithmArgs: [4, 1024, 3, 0]);
2103+
});
2104+
2105+
// CREATE TABLE `events` (..., INDEX `skip_user_id` `user_id` TYPE bloom_filter GRANULARITY 1, ...)
2106+
```
2107+
2108+
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 call.
2109+
2110+
**Engine SETTINGS** — emit `SETTINGS k=v` after the TTL clause:
2111+
2112+
```php
2113+
$schema->create('events', function (Table $table) {
2114+
$table->bigInteger('id')->primary();
2115+
$table->settings([
2116+
'index_granularity' => 8192,
2117+
'allow_nullable_key' => true, // booleans become 1/0
2118+
]);
2119+
});
2120+
2121+
// CREATE TABLE `events` (...) ENGINE = MergeTree() ORDER BY (`id`)
2122+
// SETTINGS index_granularity = 8192, allow_nullable_key = 1
2123+
```
2124+
2125+
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.
2126+
20842127
### SQLite Schema
20852128

20862129
```php

src/Query/Schema/ClickHouse.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Utopia\Query\QuotesIdentifiers;
1010
use Utopia\Query\Schema;
1111
use Utopia\Query\Schema\ClickHouse\Engine;
12+
use Utopia\Query\Schema\ClickHouse\SkipIndex;
1213
use Utopia\Query\Schema\Feature\ColumnComments;
1314
use Utopia\Query\Schema\Feature\DropPartition;
1415
use Utopia\Query\Schema\Feature\TableComments;
@@ -183,6 +184,14 @@ public function create(string $table, callable $definition, bool $ifNotExists =
183184
. ' ' . $expr . ' TYPE minmax GRANULARITY 3';
184185
}
185186

187+
foreach ($blueprint->skipIndexes as $skip) {
188+
$cols = \array_map(fn (string $c): string => $this->quote($c), $skip->columns);
189+
$expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')';
190+
$columnDefs[] = 'INDEX ' . $this->quote($skip->name)
191+
. ' ' . $expr . ' TYPE ' . $this->compileSkipAlgorithm($skip)
192+
. ' GRANULARITY ' . $skip->granularity;
193+
}
194+
186195
if (! empty($blueprint->foreignKeys)) {
187196
throw new UnsupportedException('Foreign keys are not supported in ClickHouse.');
188197
}
@@ -211,9 +220,40 @@ public function create(string $table, callable $definition, bool $ifNotExists =
211220
$sql .= ' TTL ' . $blueprint->ttl;
212221
}
213222

223+
if (! empty($blueprint->settings)) {
224+
$kv = [];
225+
foreach ($blueprint->settings as $k => $v) {
226+
$kv[] = $k . ' = ' . $v;
227+
}
228+
$sql .= ' SETTINGS ' . \implode(', ', $kv);
229+
}
230+
214231
return new Statement($sql, [], executor: $this->executor);
215232
}
216233

234+
/**
235+
* Render a `TYPE <algorithm>(args)` fragment for a data-skipping index.
236+
*
237+
* String args are emitted as single-quoted SQL literals (with `'` doubled);
238+
* numeric args are emitted verbatim. Argument values come from the
239+
* application — never from untrusted input.
240+
*/
241+
private function compileSkipAlgorithm(SkipIndex $skip): string
242+
{
243+
if ($skip->algorithmArgs === []) {
244+
return $skip->algorithm->value;
245+
}
246+
247+
$args = \array_map(
248+
fn (string|int|float $arg): string => \is_string($arg)
249+
? "'" . \str_replace("'", "''", $arg) . "'"
250+
: (string) $arg,
251+
$skip->algorithmArgs,
252+
);
253+
254+
return $skip->algorithm->value . '(' . \implode(', ', $args) . ')';
255+
}
256+
217257
/**
218258
* Compile an engine declaration: `<Name>` or `<Name>(<args...>)`.
219259
*
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Utopia\Query\Schema\ClickHouse;
4+
5+
use Utopia\Query\Exception\ValidationException;
6+
7+
readonly class SkipIndex
8+
{
9+
/**
10+
* @param list<string> $columns
11+
* @param list<string|int|float> $algorithmArgs Args for parameterized algorithms
12+
* (e.g. [3] for set(3),
13+
* [0.01] for bloom_filter(0.01),
14+
* [4, 1024, 3, 0] for ngrambf_v1(n, size_bytes, hashes, seed))
15+
*/
16+
public function __construct(
17+
public string $name,
18+
public array $columns,
19+
public SkipIndexAlgorithm $algorithm,
20+
public array $algorithmArgs = [],
21+
public int $granularity = 1,
22+
) {
23+
if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) {
24+
throw new ValidationException('Invalid skip index name: ' . $name);
25+
}
26+
if ($columns === []) {
27+
throw new ValidationException('Skip index requires at least one column.');
28+
}
29+
if ($granularity < 1) {
30+
throw new ValidationException('Skip index granularity must be >= 1.');
31+
}
32+
}
33+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Utopia\Query\Schema\ClickHouse;
4+
5+
enum SkipIndexAlgorithm: string
6+
{
7+
case MinMax = 'minmax';
8+
case Set = 'set';
9+
case BloomFilter = 'bloom_filter';
10+
case NgramBloomFilter = 'ngrambf_v1';
11+
case TokenBloomFilter = 'tokenbf_v1';
12+
case Inverted = 'inverted';
13+
}

src/Query/Schema/Table.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Utopia\Query\Exception\ValidationException;
66
use Utopia\Query\Schema\ClickHouse\Engine;
7+
use Utopia\Query\Schema\ClickHouse\SkipIndex;
8+
use Utopia\Query\Schema\ClickHouse\SkipIndexAlgorithm;
79

810
class Table
911
{
@@ -51,6 +53,12 @@ class Table
5153

5254
public private(set) ?string $ttl = null;
5355

56+
/** @var list<SkipIndex> ClickHouse data-skipping indexes (other dialects ignore) */
57+
public private(set) array $skipIndexes = [];
58+
59+
/** @var array<string, string> Table-level engine SETTINGS (ClickHouse only) */
60+
public private(set) array $settings = [];
61+
5462
/**
5563
* Add a table-level CHECK constraint.
5664
*
@@ -543,4 +551,87 @@ public function ttl(string $expression): static
543551

544552
return $this;
545553
}
554+
555+
/**
556+
* Attach a ClickHouse data-skipping index. Other dialects ignore this.
557+
*
558+
* Skip indexes accelerate WHERE clauses by letting ClickHouse skip whole
559+
* granules during scanning. Choose the algorithm that matches the column
560+
* cardinality and predicate type:
561+
*
562+
* - `MinMax` — numeric ranges, low cardinality
563+
* - `Set(N)` — small fixed value sets (N is the set size cap)
564+
* - `BloomFilter(p)` — high cardinality string columns with `=` / `IN`
565+
* predicates (p is the false-positive probability, e.g. 0.01)
566+
* - `NgramBloomFilter(n, size, hashes, seed)` — `LIKE` / `match` on text
567+
* - `TokenBloomFilter(size, hashes, seed)` — token-style search
568+
* - `Inverted` — `LIKE`, `match`, `hasToken` (experimental)
569+
*
570+
* @param list<string> $columns
571+
* @param list<string|int|float> $algorithmArgs Algorithm-specific arguments
572+
*
573+
* @throws ValidationException if the index name or columns are invalid.
574+
*/
575+
public function dataSkippingIndex(
576+
array $columns,
577+
SkipIndexAlgorithm $algorithm,
578+
int $granularity = 1,
579+
array $algorithmArgs = [],
580+
string $name = '',
581+
): static {
582+
if ($name === '') {
583+
$name = 'skip_' . \implode('_', $columns);
584+
}
585+
586+
$this->skipIndexes[] = new SkipIndex($name, $columns, $algorithm, $algorithmArgs, $granularity);
587+
588+
return $this;
589+
}
590+
591+
/**
592+
* Set table-level engine SETTINGS (ClickHouse only). Other dialects ignore.
593+
*
594+
* Compiled as `SETTINGS k=v, ...` after the TTL clause. Booleans become
595+
* `1` / `0`, ints/floats are stringified, strings are passed through after
596+
* a conservative character allow-list check.
597+
*
598+
* Calling this method replaces previously-set settings.
599+
*
600+
* @param array<string, string|int|float|bool> $settings
601+
*
602+
* @throws ValidationException if any key is not a valid identifier or any
603+
* string value contains characters outside the
604+
* allow-list.
605+
*/
606+
public function settings(array $settings): static
607+
{
608+
$sanitized = [];
609+
610+
foreach ($settings as $key => $value) {
611+
if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
612+
throw new ValidationException('Invalid setting name: ' . $key);
613+
}
614+
615+
if (\is_bool($value)) {
616+
$sanitized[$key] = $value ? '1' : '0';
617+
} elseif (\is_int($value) || \is_float($value)) {
618+
$sanitized[$key] = (string) $value;
619+
} elseif (\is_string($value)) {
620+
if (! \preg_match('/^[A-Za-z0-9_.\-+\/]*$/', $value)) {
621+
throw new ValidationException(
622+
'Invalid setting value for ' . $key . ': must match [A-Za-z0-9_.\\-+/]*'
623+
);
624+
}
625+
$sanitized[$key] = $value;
626+
} else {
627+
throw new ValidationException(
628+
'Setting value for ' . $key . ' must be string, int, float, or bool.'
629+
);
630+
}
631+
}
632+
633+
$this->settings = $sanitized;
634+
635+
return $this;
636+
}
546637
}

0 commit comments

Comments
 (0)