Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1776,7 +1776,7 @@ $result = $schema->table('users')

Available column types: `id`, `string`, `text`, `mediumText`, `longText`, `integer`, `bigInteger`, `serial`, `bigSerial`, `smallSerial`, `float`, `boolean`, `datetime`, `timestamp`, `json`, `binary`, `enum`, `point`, `linestring`, `polygon`, `vector` (PostgreSQL only), `timestamps`.

Column modifiers: `nullable()`, `default($value)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`, `collation($collation)`, `check($expression)`, `generatedAs($expression)` + `stored()` / `virtual()`, `ttl($expression)` (ClickHouse), `userType($name)` (PostgreSQL).
Column modifiers: `nullable()`, `default($value)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`, `collation($collation)`, `check($expression)`, `generatedAs($expression)` + `stored()` / `virtual()`, `ttl($expression)` (ClickHouse), `lowCardinality()` (ClickHouse), `codec($codec)` (ClickHouse), `userType($name)` (PostgreSQL).

**SERIAL types** — auto-incrementing integers. PostgreSQL emits native `SERIAL` / `BIGSERIAL` / `SMALLSERIAL`; MySQL/MariaDB compile to `INT AUTO_INCREMENT` / `BIGINT AUTO_INCREMENT` / `SMALLINT AUTO_INCREMENT`; SQLite maps to `INTEGER`. ClickHouse and MongoDB throw `UnsupportedException`:

Expand Down Expand Up @@ -2130,6 +2130,71 @@ $schema->table('events')

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.

**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):

```php
$schema->table('events')
->bigInteger('id')->primary()
->string('status')->lowCardinality()
->string('country')->lowCardinality()->nullable()
->create();

// CREATE TABLE `events` (`id` Int64, `status` LowCardinality(String),
// `country` Nullable(LowCardinality(String))) ENGINE = MergeTree() ORDER BY (`id`)
```

`Nullable` is applied outside `LowCardinality` to match ClickHouse's required wrapping order. Other dialects throw `UnsupportedException` at compile time.

**FixedString(N)** — fixed-length string column. Use for ISO codes, hash digests, and other values whose byte length is known and constant:

```php
$schema->table('locations')
->bigInteger('id')->primary()
->fixedString('country_code', 2) // ISO 3166-1 alpha-2
->fixedString('currency_code', 3) // ISO 4217
->fixedString('digest', 32) // raw MD5
->create();

// CREATE TABLE `locations` (`id` Int64, `country_code` FixedString(2),
// `currency_code` FixedString(3), `digest` FixedString(32))
// ENGINE = MergeTree() ORDER BY (`id`)
```

Length must be at least 1. Other dialects throw `UnsupportedException` at compile time.

**Column-level CODEC** — append one or more compression codecs to a column. Multiple `codec()` calls accumulate and emit `CODEC(c1, c2, ...)`:

```php
$schema->table('metrics')
->bigInteger('id')->primary()
->datetime('ts', 3)->codec('Delta(4)')->codec('LZ4') // monotonic timestamps
->bigInteger('value')->codec('T64')->codec('LZ4') // integer column
->string('payload')->codec('ZSTD(3)') // text column
->create();

// CREATE TABLE `metrics` (`id` Int64,
// `ts` DateTime64(3) CODEC(Delta(4), LZ4),
// `value` Int64 CODEC(T64, LZ4),
// `payload` String CODEC(ZSTD(3))) ENGINE = MergeTree() ORDER BY (`id`)
```

Each codec string is emitted verbatim; supply codec arguments inline (`'Delta(4)'`, `'ZSTD(3)'`). Codec strings must not be empty or contain a semicolon. Other dialects throw `UnsupportedException` at compile time.

**SAMPLE BY** — declare a sampling expression for approximate-query support (`SELECT ... SAMPLE k`). Emitted after `ORDER BY` and before `TTL` / `SETTINGS`:

```php
$schema->table('events')
->bigInteger('id')->primary()
->bigInteger('user_id')->unsigned()
->sampleBy('user_id')
->create();

// CREATE TABLE `events` (`id` Int64, `user_id` UInt64) ENGINE = MergeTree()
// ORDER BY (`id`) SAMPLE BY user_id
```

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`. Other dialects throw `UnsupportedException` at compile time.

### SQLite Schema

```php
Expand Down
12 changes: 12 additions & 0 deletions src/Query/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen
throw new UnsupportedException('TTL is only supported in ClickHouse.');
}

if ($table->sampleBy !== null) {
throw new UnsupportedException('SAMPLE BY is only supported in ClickHouse.');
}

$columnDefs = [];
$primaryKeys = [];
$uniqueColumns = [];
Expand Down Expand Up @@ -296,6 +300,14 @@ protected function compileColumnDefinition(Column $column): string
throw new UnsupportedException('TTL is only supported in ClickHouse.');
}

if ($column->isLowCardinality) {
throw new UnsupportedException('LowCardinality is only supported in ClickHouse.');
}

if ($column->codecs !== []) {
throw new UnsupportedException('Column-level CODEC is only supported in ClickHouse.');
}

$parts = [
$this->quote($column->name),
$this->compileColumnType($column),
Expand Down
18 changes: 18 additions & 0 deletions src/Query/Schema/ClickHouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ protected function compileColumnType(Column $column): string

$type = match ($column->type) {
ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'String',
ColumnType::FixedString => 'FixedString(' . ($column->length ?? throw new ValidationException('FixedString requires a length.')) . ')',
ColumnType::Text => 'String',
ColumnType::MediumText, ColumnType::LongText => 'String',
ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32',
Expand All @@ -44,6 +45,10 @@ protected function compileColumnType(Column $column): string
ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse.'),
};

if ($column->isLowCardinality) {
$type = 'LowCardinality(' . $type . ')';
}

if ($column->isNullable) {
$type = 'Nullable(' . $type . ')';
}
Expand Down Expand Up @@ -80,6 +85,10 @@ protected function compileColumnDefinition(Column $column): string
$parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default);
}

if ($column->codecs !== []) {
$parts[] = 'CODEC(' . \implode(', ', $column->codecs) . ')';
}

if ($column->ttl !== null) {
$parts[] = 'TTL ' . $column->ttl;
}
Expand Down Expand Up @@ -217,6 +226,15 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen
: ' ORDER BY tuple()';
}

if ($table->sampleBy !== null) {
if (! $engine->requiresOrderBy()) {
throw new UnsupportedException(
'SAMPLE BY is only supported on engines that take an ORDER BY clause.'
);
}
$sql .= ' SAMPLE BY ' . $table->sampleBy;
}

if ($table->ttl !== null) {
$sql .= ' TTL ' . $table->ttl;
}
Expand Down
57 changes: 57 additions & 0 deletions src/Query/Schema/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class Column

public private(set) ?string $userTypeName = null;

public private(set) bool $isLowCardinality = false;

/** @var list<string> ClickHouse column-level CODEC clauses, e.g. ['Delta(4)', 'LZ4'] */
public private(set) array $codecs = [];

public function __construct(
public Table $table,
public string $name,
Expand Down Expand Up @@ -247,6 +252,48 @@ public function ttl(string $expression): static
return $this;
}

/**
* Wrap the column type in `LowCardinality(...)` (ClickHouse only).
*
* Suitable for string columns with a small number of distinct values
* (status enums, type discriminators, country codes). Other dialects
* throw `UnsupportedException` at compile time.
*/
public function lowCardinality(): static
{
$this->isLowCardinality = true;

return $this;
}

/**
* Append a column-level CODEC clause (ClickHouse only).
*
* Multiple calls accumulate and emit `CODEC(c1, c2, ...)`. Pass either
* a bare codec name (`->codec('LZ4')`) or one with arguments
* (`->codec('Delta(4)')`, `->codec('ZSTD(3)')`). The codec string is
* emitted verbatim and must come from a trusted source.
*
* @throws ValidationException if the codec string is empty or contains
* a semicolon.
*/
public function codec(string $codec): static
{
$trimmed = \trim($codec);

if ($trimmed === '') {
throw new ValidationException('CODEC expression must not be empty.');
}

if (\str_contains($trimmed, ';')) {
throw new ValidationException('CODEC expression must not contain ";".');
}

$this->codecs[] = $trimmed;

return $this;
}

/**
* Reference a user-defined type (e.g. a PostgreSQL enum type created via CREATE TYPE).
*
Expand All @@ -273,6 +320,11 @@ public function string(string $name, int $length = 255): Column
return $this->table->string($name, $length);
}

public function fixedString(string $name, int $length): Column
{
return $this->table->fixedString($name, $length);
}

public function text(string $name): Column
{
return $this->table->text($name);
Expand Down Expand Up @@ -540,6 +592,11 @@ public function orderBy(array $columns): Table
return $this->table->orderBy($columns);
}

public function sampleBy(string $expression): Table
{
return $this->table->sampleBy($expression);
}

public function create(bool $ifNotExists = false): Statement
{
return $this->table->create($ifNotExists);
Expand Down
1 change: 1 addition & 0 deletions src/Query/Schema/ColumnType.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
enum ColumnType: string
{
case String = 'string';
case FixedString = 'fixedstring';
case Varchar = 'varchar';
case Text = 'text';
case MediumText = 'mediumtext';
Expand Down
1 change: 1 addition & 0 deletions src/Query/Schema/MongoDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ protected function compileColumnType(Column $column): string
ColumnType::Linestring, ColumnType::Polygon => 'object',
ColumnType::Uuid7 => 'string',
ColumnType::Vector => 'array',
ColumnType::FixedString => throw new UnsupportedException('FixedString is only supported in ClickHouse.'),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/Query/Schema/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ protected function compileColumnType(Column $column): string
ColumnType::Polygon => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''),
ColumnType::Uuid7 => 'VARCHAR(36)',
ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in MySQL.'),
ColumnType::FixedString => throw new UnsupportedException('FixedString is only supported in ClickHouse.'),
};
}

Expand Down
9 changes: 9 additions & 0 deletions src/Query/Schema/PostgreSQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ protected function compileColumnType(Column $column): string
ColumnType::Serial => 'SERIAL',
ColumnType::BigSerial => 'BIGSERIAL',
ColumnType::SmallSerial => 'SMALLSERIAL',
ColumnType::FixedString => throw new UnsupportedException('FixedString is only supported in ClickHouse.'),
};
}

Expand All @@ -57,6 +58,14 @@ protected function compileUnsigned(): string

protected function compileColumnDefinition(Column $column): string
{
if ($column->isLowCardinality) {
throw new UnsupportedException('LowCardinality is only supported in ClickHouse.');
}

if ($column->codecs !== []) {
throw new UnsupportedException('Column-level CODEC is only supported in ClickHouse.');
}

$parts = [
$this->quote($column->name),
$this->compileColumnType($column),
Expand Down
9 changes: 9 additions & 0 deletions src/Query/Schema/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ protected function compileColumnType(Column $column): string
ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon => 'TEXT',
ColumnType::Uuid7 => 'VARCHAR(36)',
ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in SQLite.'),
ColumnType::FixedString => throw new UnsupportedException('FixedString is only supported in ClickHouse.'),
};
}

Expand All @@ -48,6 +49,14 @@ protected function compileColumnDefinition(Column $column): string
return parent::compileColumnDefinition($column);
}

if ($column->isLowCardinality) {
throw new UnsupportedException('LowCardinality is only supported in ClickHouse.');
}

if ($column->codecs !== []) {
throw new UnsupportedException('Column-level CODEC is only supported in ClickHouse.');
}

$parts = [
$this->quote($column->name),
$this->compileColumnType($column),
Expand Down
46 changes: 46 additions & 0 deletions src/Query/Schema/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class Table
/** @var array<string, string> Table-level engine SETTINGS (ClickHouse only) */
public private(set) array $settings = [];

/** ClickHouse SAMPLE BY expression. Emitted after ORDER BY when set. */
public private(set) ?string $sampleBy = null;

public function __construct(
private readonly ?Schema $schema = null,
public readonly string $name = '',
Expand Down Expand Up @@ -166,6 +169,24 @@ public function string(string $name, int $length = 255): Column
return $col;
}

/**
* Add a fixed-length string column. Compiled to ClickHouse `FixedString(N)`;
* other dialects throw {@see UnsupportedException} at compile time.
*
* @throws ValidationException if $length is less than 1.
*/
public function fixedString(string $name, int $length): Column
{
if ($length < 1) {
throw new ValidationException('FixedString length must be at least 1.');
}

$col = new Column($this, $name, ColumnType::FixedString, $length);
$this->columns[] = $col;

return $col;
}

public function text(string $name): Column
{
$col = new Column($this, $name, ColumnType::Text);
Expand Down Expand Up @@ -655,6 +676,31 @@ public function orderBy(array $columns): static
return $this;
}

/**
* Set the ClickHouse SAMPLE BY expression. Emitted after ORDER BY at table
* creation time. Required to model tables that need approximate-query
* support via `SELECT ... SAMPLE k`. Other dialects throw
* {@see UnsupportedException} at compile time.
*
* @throws ValidationException if the expression is empty or contains a semicolon.
*/
public function sampleBy(string $expression): static
{
$trimmed = \trim($expression);

if ($trimmed === '') {
throw new ValidationException('SAMPLE BY expression must not be empty.');
}

if (\str_contains($trimmed, ';')) {
throw new ValidationException('SAMPLE BY expression must not contain ";".');
}

$this->sampleBy = $trimmed;

return $this;
}

/**
* Attach a table-level TTL expression (ClickHouse only).
*
Expand Down
Loading
Loading