Skip to content

Commit 5c8bba8

Browse files
Merge pull request #8 from utopia-php/feat/clickhouse-schema-extras
feat(clickhouse): support LowCardinality, FixedString, CODEC, and SAMPLE BY
2 parents d925101 + d408b3e commit 5c8bba8

6 files changed

Lines changed: 455 additions & 1 deletion

File tree

README.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2033,7 +2033,7 @@ $result = $schema->table('events')
20332033

20342034
ClickHouse uses `Nullable(type)` wrapping for nullable columns, `Enum8(...)` for enums, `Tuple(Float64, Float64)` for points, and `TYPE minmax GRANULARITY 3` for indexes. Foreign keys, stored procedures, triggers, generated columns, and CHECK constraints throw `UnsupportedException`.
20352035

2036-
Supports the `TableComments`, `ColumnComments`, and `DropPartition` interfaces.
2036+
Supports the `TableComments`, `ColumnComments`, `DropPartition`, `Views`, and `Databases` interfaces.
20372037

20382038
**Engine selection** — choose from 10 variants of the `Engine` enum:
20392039

@@ -2130,6 +2130,73 @@ $schema->table('events')
21302130

21312131
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.
21322132

2133+
**LowCardinality** — wrap a column type in `LowCardinality(...)` for compact dictionary-encoded storage on string columns with a small number of distinct values (status enums, type discriminators, country codes, category labels):
2134+
2135+
```php
2136+
$schema->table('events')
2137+
->bigInteger('id')->primary()
2138+
->string('status')->lowCardinality()
2139+
->string('country')->lowCardinality()->nullable()
2140+
->create();
2141+
2142+
// CREATE TABLE `events` (`id` Int64, `status` LowCardinality(String),
2143+
// `country` Nullable(LowCardinality(String))) ENGINE = MergeTree() ORDER BY (`id`)
2144+
```
2145+
2146+
`Nullable` is applied outside `LowCardinality` to match ClickHouse's required wrapping order. The `lowCardinality()` method is only available on the ClickHouse builder — callers on other dialects (`MySQL`, `PostgreSQL`, `SQLite`, `MongoDB`) cannot reach this method at all.
2147+
2148+
**FixedString(N)** — fixed-length string column. Use for ISO codes, hash digests, and other values whose byte length is known and constant:
2149+
2150+
```php
2151+
$schema->table('locations')
2152+
->bigInteger('id')->primary()
2153+
->fixedString('country_code', 2) // ISO 3166-1 alpha-2
2154+
->fixedString('currency_code', 3) // ISO 4217
2155+
->fixedString('digest', 32) // raw MD5
2156+
->create();
2157+
2158+
// CREATE TABLE `locations` (`id` Int64, `country_code` FixedString(2),
2159+
// `currency_code` FixedString(3), `digest` FixedString(32))
2160+
// ENGINE = MergeTree() ORDER BY (`id`)
2161+
```
2162+
2163+
Length must be at least 1. The `fixedString()` method is only available on the ClickHouse builder — the type has no portable mapping.
2164+
2165+
**Column-level CODEC** — append one or more compression codecs to a column. Multiple `codec()` calls accumulate and emit `CODEC(c1, c2, ...)`:
2166+
2167+
```php
2168+
$schema->table('metrics')
2169+
->bigInteger('id')->primary()
2170+
->datetime('ts', 3)->codec('Delta(4)')->codec('LZ4') // monotonic timestamps
2171+
->bigInteger('value')->codec('T64')->codec('LZ4') // integer column
2172+
->string('payload')->codec('ZSTD(3)') // text column
2173+
->create();
2174+
2175+
// CREATE TABLE `metrics` (`id` Int64,
2176+
// `ts` DateTime64(3) CODEC(Delta(4), LZ4),
2177+
// `value` Int64 CODEC(T64, LZ4),
2178+
// `payload` String CODEC(ZSTD(3))) ENGINE = MergeTree() ORDER BY (`id`)
2179+
```
2180+
2181+
Each codec string is emitted verbatim; supply codec arguments inline (`'Delta(4)'`, `'ZSTD(3)'`). Codec strings must not be empty or contain a semicolon. The `codec()` method is only available on the ClickHouse builder.
2182+
2183+
**SAMPLE BY** — declare a sampling expression for approximate-query support (`SELECT ... SAMPLE k`). Emitted after `ORDER BY` and before `TTL` / `SETTINGS`:
2184+
2185+
```php
2186+
$schema->table('events')
2187+
->bigInteger('id')->primary()
2188+
->bigInteger('user_id')->unsigned()
2189+
->sampleBy('user_id')
2190+
->create();
2191+
2192+
// CREATE TABLE `events` (`id` Int64, `user_id` UInt64) ENGINE = MergeTree()
2193+
// ORDER BY (`id`) SAMPLE BY user_id
2194+
```
2195+
2196+
The expression is emitted verbatim and must not be empty or contain a semicolon. `SAMPLE BY` only applies to engines that take an `ORDER BY` clause (the MergeTree family); using it with `Memory`, `Log`, `TinyLog`, or `StripeLog` throws `UnsupportedException`. The `sampleBy()` method is only available on the ClickHouse builder.
2197+
2198+
These OLAP-shaped modifiers live on the ClickHouse-specific `Column\ClickHouse` and `Table\ClickHouse` builders. Because the methods only exist on the dialect's own builder subclasses, calling `->lowCardinality()` or `->sampleBy()` on a `MySQL`, `PostgreSQL`, `SQLite`, or `MongoDB` builder fails at the type level, with no runtime branch needed.
2199+
21332200
### SQLite Schema
21342201

21352202
```php

src/Query/Schema/ClickHouse.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ protected function compileColumnType(Column $column): string
3232
throw new UnsupportedException('User-defined types are not supported in ClickHouse.');
3333
}
3434

35+
if ($column instanceof Column\ClickHouse && $column->isFixedString()) {
36+
$type = 'FixedString(' . $column->fixedStringLength . ')';
37+
38+
if ($column->isLowCardinality) {
39+
$type = 'LowCardinality(' . $type . ')';
40+
}
41+
42+
if ($column->isNullable) {
43+
$type = 'Nullable(' . $type . ')';
44+
}
45+
46+
return $type;
47+
}
48+
3549
$type = match ($column->type) {
3650
ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'String',
3751
ColumnType::Text => 'String',
@@ -53,6 +67,10 @@ protected function compileColumnType(Column $column): string
5367
ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse.'),
5468
};
5569

70+
if ($column instanceof Column\ClickHouse && $column->isLowCardinality) {
71+
$type = 'LowCardinality(' . $type . ')';
72+
}
73+
5674
if ($column->isNullable) {
5775
$type = 'Nullable(' . $type . ')';
5876
}
@@ -89,6 +107,10 @@ protected function compileColumnDefinition(Column $column): string
89107
$parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default);
90108
}
91109

110+
if ($column instanceof Column\ClickHouse && $column->codecs !== []) {
111+
$parts[] = 'CODEC(' . \implode(', ', $column->codecs) . ')';
112+
}
113+
92114
if ($column->ttl !== null) {
93115
$parts[] = 'TTL ' . $column->ttl;
94116
}
@@ -226,6 +248,15 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen
226248
: ' ORDER BY tuple()';
227249
}
228250

251+
if ($table instanceof Table\ClickHouse && $table->sampleBy !== null) {
252+
if (! $engine->requiresOrderBy()) {
253+
throw new UnsupportedException(
254+
'SAMPLE BY is only supported on engines that take an ORDER BY clause.'
255+
);
256+
}
257+
$sql .= ' SAMPLE BY ' . $table->sampleBy;
258+
}
259+
229260
if ($table->ttl !== null) {
230261
$sql .= ' TTL ' . $table->ttl;
231262
}

src/Query/Schema/Column/ClickHouse.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Utopia\Query\Schema\Column;
44

5+
use Utopia\Query\Exception\ValidationException;
56
use Utopia\Query\Schema\Column;
67
use Utopia\Query\Schema\Forwarder;
78
use Utopia\Query\Schema\Table;
@@ -13,6 +14,40 @@ class ClickHouse extends Column
1314
{
1415
use Forwarder\ClickHouse;
1516

17+
public protected(set) bool $isLowCardinality = false;
18+
19+
/** Length when the column should be emitted as `FixedString(N)`; null otherwise. */
20+
public protected(set) ?int $fixedStringLength = null;
21+
22+
/** @var list<string> Column-level CODEC clauses, e.g. ['Delta(4)', 'LZ4'] */
23+
public protected(set) array $codecs = [];
24+
25+
/**
26+
* Mark the column as `FixedString(N)`.
27+
*
28+
* Used by {@see Table\ClickHouse::fixedString()} to attach the
29+
* ClickHouse-specific FixedString width to a column whose generic
30+
* {@see \Utopia\Query\Schema\ColumnType} is `String`. The compiler reads
31+
* this state when emitting DDL.
32+
*
33+
* @throws ValidationException if $length is less than 1.
34+
*/
35+
public function asFixedString(int $length): static
36+
{
37+
if ($length < 1) {
38+
throw new ValidationException('FixedString length must be at least 1.');
39+
}
40+
41+
$this->fixedStringLength = $length;
42+
43+
return $this;
44+
}
45+
46+
public function isFixedString(): bool
47+
{
48+
return $this->fixedStringLength !== null;
49+
}
50+
1651
/**
1752
* @param list<string> $columns
1853
*
@@ -28,4 +63,47 @@ public function primary(array $columns = []): static|Table
2863

2964
return $this->table->primary($columns);
3065
}
66+
67+
/**
68+
* Wrap the column type in `LowCardinality(...)`.
69+
*
70+
* Suitable for string columns with a small number of distinct values
71+
* (status enums, type discriminators, country codes). `Nullable` is
72+
* applied outside `LowCardinality` to match ClickHouse's required
73+
* wrapping order: `Nullable(LowCardinality(String))`.
74+
*/
75+
public function lowCardinality(): static
76+
{
77+
$this->isLowCardinality = true;
78+
79+
return $this;
80+
}
81+
82+
/**
83+
* Append a column-level CODEC clause.
84+
*
85+
* Multiple calls accumulate and emit `CODEC(c1, c2, ...)`. Pass either
86+
* a bare codec name (`->codec('LZ4')`) or one with arguments
87+
* (`->codec('Delta(4)')`, `->codec('ZSTD(3)')`). The codec string is
88+
* emitted verbatim and must come from a trusted source.
89+
*
90+
* @throws ValidationException if the codec string is empty or contains
91+
* a semicolon.
92+
*/
93+
public function codec(string $codec): static
94+
{
95+
$trimmed = \trim($codec);
96+
97+
if ($trimmed === '') {
98+
throw new ValidationException('CODEC expression must not be empty.');
99+
}
100+
101+
if (\str_contains($trimmed, ';')) {
102+
throw new ValidationException('CODEC expression must not contain ";".');
103+
}
104+
105+
$this->codecs[] = $trimmed;
106+
107+
return $this;
108+
}
31109
}

src/Query/Schema/Forwarder/ClickHouse.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ public function vector(string $name, int $dimensions): Column\ClickHouse
1818
return $this->table->vector($name, $dimensions);
1919
}
2020

21+
public function fixedString(string $name, int $length): Column\ClickHouse
22+
{
23+
return $this->table->fixedString($name, $length);
24+
}
25+
2126
public function engine(Engine $engine, string ...$args): Table\ClickHouse
2227
{
2328
return $this->table->engine($engine, ...$args);
@@ -44,4 +49,8 @@ public function partitionBy(string $expression): Table\ClickHouse
4449
return $this->table->partitionBy($expression);
4550
}
4651

52+
public function sampleBy(string $expression): Table\ClickHouse
53+
{
54+
return $this->table->sampleBy($expression);
55+
}
4756
}

src/Query/Schema/Table/ClickHouse.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ class ClickHouse extends Table
1616
{
1717
use Trait\CompositePrimary;
1818

19+
/** ClickHouse SAMPLE BY expression. Emitted after ORDER BY when set. */
20+
public protected(set) ?string $sampleBy = null;
21+
1922
#[\Override]
2023
protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\ClickHouse
2124
{
@@ -31,6 +34,30 @@ public function vector(string $name, int $dimensions): Column\ClickHouse
3134
return $col;
3235
}
3336

37+
/**
38+
* Add a `FixedString(N)` column.
39+
*
40+
* Used for fixed-length string values whose byte length is known and
41+
* constant — ISO 3166 country codes, ISO 4217 currency codes, hash
42+
* digests, and similar values that benefit from ClickHouse's columnar
43+
* storage of fixed-width data.
44+
*
45+
* The column is registered with the generic `ColumnType::String` type and
46+
* tagged with FixedString state on {@see Column\ClickHouse}; the compiler
47+
* reads that state when emitting DDL, so the global `ColumnType` enum
48+
* stays free of ClickHouse-only cases.
49+
*
50+
* @throws ValidationException if $length is less than 1.
51+
*/
52+
public function fixedString(string $name, int $length): Column\ClickHouse
53+
{
54+
$col = $this->newColumn($name, ColumnType::String, $length);
55+
$col->asFixedString($length);
56+
$this->columns[] = $col;
57+
58+
return $col;
59+
}
60+
3461
/**
3562
* Select the table engine. Engine-specific arguments are validated against
3663
* the engine variant:
@@ -158,4 +185,28 @@ public function partitionBy(string $expression): static
158185

159186
return $this;
160187
}
188+
189+
/**
190+
* Set the SAMPLE BY expression. Emitted after ORDER BY at table creation
191+
* time. Required to model tables that need approximate-query support via
192+
* `SELECT ... SAMPLE k` on MergeTree-family engines.
193+
*
194+
* @throws ValidationException if the expression is empty or contains a semicolon.
195+
*/
196+
public function sampleBy(string $expression): static
197+
{
198+
$trimmed = \trim($expression);
199+
200+
if ($trimmed === '') {
201+
throw new ValidationException('SAMPLE BY expression must not be empty.');
202+
}
203+
204+
if (\str_contains($trimmed, ';')) {
205+
throw new ValidationException('SAMPLE BY expression must not contain ";".');
206+
}
207+
208+
$this->sampleBy = $trimmed;
209+
210+
return $this;
211+
}
161212
}

0 commit comments

Comments
 (0)