diff --git a/README.md b/README.md index 1793450..4474359 100644 --- a/README.md +++ b/README.md @@ -1774,9 +1774,11 @@ $result = $schema->table('users') ->createIfNotExists(); ``` -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`. +Available column types: `id`, `uuid`, `string`, `text`, `mediumText`, `longText`, `tinyInteger`, `smallInteger`, `integer`, `bigInteger`, `serial`, `bigSerial`, `smallSerial`, `float`, `decimal`, `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)`, `defaultRaw($expression)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`, `collation($collation)`, `check($expression)`, `generatedAs($expression)` + `stored()` / `virtual()`, `ttl($expression)` (ClickHouse), `userType($name)` (PostgreSQL). + +**Raw default expressions** — use `defaultRaw($expression)` for dialect-specific server-generated defaults that `default()` would otherwise quote as a string literal (`now()`, `CURRENT_TIMESTAMP`, `gen_random_uuid()`, `generateUUIDv4()`, `UUID()`, …). The expression is emitted verbatim and must come from a trusted source; it must not be empty or contain a semicolon. Takes precedence over `default()` when both are set. **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`: @@ -2195,7 +2197,102 @@ $schema->table('events') 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. -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. +**`UInt8` / `Int8` via `tinyInteger()` and `UInt16` / `Int16` via `smallInteger()`** — small integer columns are useful for bounded enumerations, percentage values, scroll depth, and similar fields where the value range fits well below 32 bits. Storing them as `UInt8` saves 75% of the disk and memory footprint compared to the default `UInt32` produced by `integer()->unsigned()`: + +```php +$schema->table('events') + ->bigInteger('id')->primary() + ->tinyInteger('scroll_depth')->unsigned() // 0–100 percentage + ->smallInteger('year_offset') // signed, fits years from epoch + ->create(); + +// CREATE TABLE `events` (`id` Int64, `scroll_depth` UInt8, `year_offset` Int16) +// ENGINE = MergeTree() ORDER BY (`id`) +``` + +`tinyInteger()` and `smallInteger()` are on the base builder, so the same calls map to `TINYINT` / `SMALLINT` on MySQL, `SMALLINT` on PostgreSQL (both shapes — PostgreSQL has no `TINYINT`), and `INTEGER` on SQLite. + +**`Array(T)` and `Tuple(...)` column types** — model multi-valued attributes (tags, labels, parallel-array nested records) and fixed-arity composites (geo points, key/value pairs) directly on the builder: + +```php +use Utopia\Query\Schema\ColumnType; + +$schema->table('events') + ->bigInteger('id')->primary() + ->array('meta.key', ColumnType::String) + ->array('meta.value', ColumnType::String) + ->array('user_ids', ColumnType::BigInteger)->unsigned() + ->tuple('coords', [ColumnType::Float, ColumnType::Float]) + ->array('scores', ColumnType::String)->nullable() + ->create(); + +// CREATE TABLE `events` (`id` Int64, +// `meta.key` Array(String), `meta.value` Array(String), +// `user_ids` Array(UInt64), +// `coords` Tuple(Float64, Float64), +// `scores` Nullable(Array(String))) ENGINE = MergeTree() ORDER BY (`id`) +``` + +The element type runs back through the standard column-type compiler, so the parent column's `unsigned()` and `precision` flags carry through to the inner type. `Nullable(...)` wraps the whole `Array`/`Tuple`; `LowCardinality(...)` is rejected on these columns because ClickHouse only permits it on scalar types. Both methods are only available on the ClickHouse builder. + +**`decimal(precision, scale)`** — fixed-point numeric column for monetary or precision-sensitive values where binary floating-point error is unacceptable: + +```php +$schema->table('orders') + ->bigInteger('id')->primary() + ->decimal('amount', precision: 18, scale: 3) + ->decimal('rate', precision: 5, scale: 4)->nullable() + ->create(); + +// CREATE TABLE `orders` (`id` Int64, +// `amount` Decimal(18, 3), +// `rate` Nullable(Decimal(5, 4))) ENGINE = MergeTree() ORDER BY (`id`) +``` + +`decimal()` is on the base builder: ClickHouse emits `Decimal(P, S)`, MySQL and PostgreSQL emit `DECIMAL(P, S)`, SQLite emits `NUMERIC(P, S)`, and MongoDB maps to the `decimal` BSON type. Scale must not be negative or exceed precision. + +**`UUID` column type with `defaultRaw()`** — UUIDs are a first-class, fixed-width identifier type in ClickHouse and PostgreSQL, and a 36-character string elsewhere. Pair with `defaultRaw()` to attach a server-generated default expression that the standard `default()` would otherwise quote as a literal: + +```php +$schema->table('events') + ->uuid('event_id')->defaultRaw('generateUUIDv4()')->primary() + ->datetime('ts', 3) + ->create(); + +// CREATE TABLE `events` (`event_id` UUID DEFAULT generateUUIDv4(), `ts` DateTime64(3)) +// ENGINE = MergeTree() ORDER BY (`event_id`) +``` + +`uuid()` compiles to the native `UUID` type on ClickHouse and PostgreSQL, `CHAR(36)` on MySQL, `TEXT` on SQLite, and the `string` BSON type on MongoDB. `defaultRaw(string)` is on the base `Column` and emits the expression verbatim — use for `generateUUIDv4()` (ClickHouse), `gen_random_uuid()` (PostgreSQL), `UUID()` (MySQL), `now()`, `CURRENT_TIMESTAMP`, and similar dialect-specific server-generated defaults. The expression must come from a trusted source; it must not be empty or contain a semicolon. `defaultRaw()` takes precedence over `default()` when both are set. + +**Raw expressions in `ORDER BY`** — MergeTree `ORDER BY` clauses routinely include scalar function calls (`toDate(ts)`, `cityHash64(...)`, `intHash32(user_id)`) to control sparse-index cardinality. `orderBy(array)` restricts each entry to a plain identifier; use `orderByRaw(string)` to emit the full tuple verbatim: + +```php +$schema->table('events') + ->string('tenant') + ->bigInteger('id') + ->datetime('ts') + ->orderByRaw('(`tenant`, toDate(`ts`), `id`)') + ->create(); + +// CREATE TABLE `events` (`tenant` String, `id` Int64, `ts` DateTime) +// ENGINE = MergeTree() ORDER BY (`tenant`, toDate(`ts`), `id`) +``` + +The expression is emitted verbatim and must come from a trusted source. `orderByRaw()` takes precedence over `orderBy()` when both are set. Mirrors the existing `partitionBy(string)` convention. Only available on the ClickHouse builder. + +**`rawColumn()` passthrough** — `Table::rawColumn(string $definition)` is the standard escape hatch for column types the builder does not yet model. It is honoured on every dialect, including ClickHouse: + +```php +$schema->table('events') + ->bigInteger('id')->primary() + ->rawColumn('`payload` JSON CODEC(ZSTD(3))') + ->create(); + +// CREATE TABLE `events` (`id` Int64, `payload` JSON CODEC(ZSTD(3))) ... +``` + +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()`, `->sampleBy()`, `->array()`, `->tuple()`, or `->orderByRaw()` on a `MySQL`, `PostgreSQL`, `SQLite`, or `MongoDB` builder fails at the type level, with no runtime branch needed. ### SQLite Schema diff --git a/src/Query/Schema.php b/src/Query/Schema.php index 4aa1df5..0604e1c 100644 --- a/src/Query/Schema.php +++ b/src/Query/Schema.php @@ -308,7 +308,9 @@ protected function compileColumnDefinition(Column $column): string $parts[] = 'NULL'; } - if ($column->hasDefault) { + if ($column->defaultRaw !== null) { + $parts[] = 'DEFAULT ' . $column->defaultRaw; + } elseif ($column->hasDefault) { $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); } diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 6cf2318..a5ceb98 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -46,13 +46,52 @@ protected function compileColumnType(Column $column): string return $type; } + if ($column instanceof Column\ClickHouse && $column->arrayElementType !== null) { + if ($column->isLowCardinality) { + throw new UnsupportedException('LowCardinality is not supported inside Array(...). Wrap the element type instead.'); + } + + $inner = $this->compileNestedElementType($column->arrayElementType, $column); + $type = 'Array(' . $inner . ')'; + + if ($column->isNullable) { + $type = 'Nullable(' . $type . ')'; + } + + return $type; + } + + if ($column instanceof Column\ClickHouse && $column->tupleElementTypes !== []) { + if ($column->isLowCardinality) { + throw new UnsupportedException('LowCardinality is not supported on Tuple(...) columns.'); + } + + $inner = \implode( + ', ', + \array_map( + fn (ColumnType $element): string => $this->compileNestedElementType($element, $column), + $column->tupleElementTypes, + ), + ); + $type = 'Tuple(' . $inner . ')'; + + if ($column->isNullable) { + $type = 'Nullable(' . $type . ')'; + } + + return $type; + } + $type = match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'String', ColumnType::Text => 'String', ColumnType::MediumText, ColumnType::LongText => 'String', + ColumnType::TinyInteger => $column->isUnsigned ? 'UInt8' : 'Int8', + ColumnType::SmallInteger => $column->isUnsigned ? 'UInt16' : 'Int16', ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32', ColumnType::BigInteger, ColumnType::Id => $column->isUnsigned ? 'UInt64' : 'Int64', ColumnType::Float, ColumnType::Double => 'Float64', + ColumnType::Decimal => 'Decimal(' . ($column->precision ?? 10) . ', ' . ($column->scale ?? 0) . ')', ColumnType::Boolean => 'UInt8', ColumnType::Datetime => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', ColumnType::Timestamp => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', @@ -62,9 +101,13 @@ protected function compileColumnType(Column $column): string ColumnType::Point => 'Tuple(Float64, Float64)', ColumnType::Linestring => 'Array(Tuple(Float64, Float64))', ColumnType::Polygon => 'Array(Array(Tuple(Float64, Float64)))', + ColumnType::Uuid => 'UUID', ColumnType::Uuid7 => 'FixedString(36)', ColumnType::Vector => 'Array(Float64)', ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse.'), + ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException( + 'Array/Tuple columns must be declared via Table\\ClickHouse::array() or ::tuple().' + ), }; if ($column instanceof Column\ClickHouse && $column->isLowCardinality) { @@ -103,7 +146,9 @@ protected function compileColumnDefinition(Column $column): string $this->compileColumnType($column), ]; - if ($column->hasDefault) { + if ($column->defaultRaw !== null) { + $parts[] = 'DEFAULT ' . $column->defaultRaw; + } elseif ($column->hasDefault) { $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); } @@ -211,6 +256,10 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen $primaryKeys = \array_map(fn (string $c): string => $this->quote($c), $table->compositePrimaryKey); } + foreach ($table->rawColumnDefs as $rawDef) { + $columnDefs[] = $rawDef; + } + foreach ($table->indexes as $index) { if ($index->type !== IndexType::Index) { throw new UnsupportedException( @@ -239,13 +288,17 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen } if ($engine->requiresOrderBy()) { - $orderBy = ! empty($table->orderBy) - ? \array_map(fn (string $c): string => $this->quote($c), $table->orderBy) - : $primaryKeys; - - $sql .= ! empty($orderBy) - ? ' ORDER BY (' . \implode(', ', $orderBy) . ')' - : ' ORDER BY tuple()'; + if ($table instanceof Table\ClickHouse && $table->orderByRaw !== null) { + $sql .= ' ORDER BY ' . $table->orderByRaw; + } else { + $orderBy = ! empty($table->orderBy) + ? \array_map(fn (string $c): string => $this->quote($c), $table->orderBy) + : $primaryKeys; + + $sql .= ! empty($orderBy) + ? ' ORDER BY (' . \implode(', ', $orderBy) . ')' + : ' ORDER BY tuple()'; + } } if ($table instanceof Table\ClickHouse && $table->sampleBy !== null) { @@ -350,6 +403,46 @@ private function compileEngine(Engine $engine, array $args): string }; } + /** + * Compile an element type for use inside `Array(T)` or `Tuple(...)`. + * + * Element types come from the {@see ColumnType} enum directly, so they + * lack the per-column state (precision, unsigned flag, etc.) that + * {@see compileColumnType()} relies on. This helper falls back to the + * parent column's `isUnsigned` flag for integer elements and to the + * parent's `precision` for `Decimal` elements so callers can spell common + * shapes (`Array(UInt64)`, `Array(Decimal(18, 3))`) without leaking the + * inner-type complexity into the public API. + */ + private function compileNestedElementType(ColumnType $element, Column $parent): string + { + return match ($element) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship, + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText, + ColumnType::Json, ColumnType::Object, ColumnType::Binary => 'String', + ColumnType::TinyInteger => $parent->isUnsigned ? 'UInt8' : 'Int8', + ColumnType::SmallInteger => $parent->isUnsigned ? 'UInt16' : 'Int16', + ColumnType::Integer => $parent->isUnsigned ? 'UInt32' : 'Int32', + ColumnType::BigInteger, ColumnType::Id => $parent->isUnsigned ? 'UInt64' : 'Int64', + ColumnType::Float, ColumnType::Double => 'Float64', + ColumnType::Decimal => 'Decimal(' . ($parent->precision ?? 10) . ', ' . ($parent->scale ?? 0) . ')', + ColumnType::Boolean => 'UInt8', + ColumnType::Datetime, ColumnType::Timestamp => $parent->precision + ? 'DateTime64(' . $parent->precision . ')' + : 'DateTime', + ColumnType::Uuid => 'UUID', + ColumnType::Uuid7 => 'FixedString(36)', + ColumnType::Point => 'Tuple(Float64, Float64)', + ColumnType::Linestring => 'Array(Tuple(Float64, Float64))', + ColumnType::Polygon => 'Array(Array(Tuple(Float64, Float64)))', + ColumnType::Vector => 'Array(Float64)', + ColumnType::Enum, ColumnType::Serial, ColumnType::BigSerial, + ColumnType::SmallSerial, ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException( + 'Nested element type ' . $element->value . ' is not supported inside Array/Tuple.' + ), + }; + } + /** * @param string[] $values */ diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 6ff60ee..51af14d 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -17,6 +17,13 @@ class Column public protected(set) bool $hasDefault = false; + /** + * Raw default expression emitted verbatim after `DEFAULT` (e.g. `now()`, + * `generateUUIDv4()`, `gen_random_uuid()`). Distinct from {@see $default}, + * which is rendered as a quoted literal. + */ + public protected(set) ?string $defaultRaw = null; + public protected(set) bool $isUnsigned = false; public protected(set) bool $isUnique = false; @@ -63,6 +70,7 @@ public function __construct( public ColumnType $type, public ?int $length = null, public ?int $precision = null, + public ?int $scale = null, ) { } @@ -81,6 +89,33 @@ public function default(mixed $value): static return $this; } + /** + * Set a raw default expression rendered verbatim after `DEFAULT`. + * + * Use for dialect-specific server-generated defaults that {@see default()} + * would otherwise quote: `now()`, `CURRENT_TIMESTAMP`, `gen_random_uuid()`, + * `generateUUIDv4()`, etc. The expression is emitted unquoted and must come + * from a trusted (developer-controlled) source. + * + * @throws ValidationException if the expression is empty or contains ";". + */ + public function defaultRaw(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('Raw default expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('Raw default expression must not contain ";".'); + } + + $this->defaultRaw = $trimmed; + + return $this; + } + public function unsigned(): static { $this->isUnsigned = true; @@ -285,6 +320,18 @@ public function longText(string $name): static return $this->table->longText($name); } + public function tinyInteger(string $name): static + { + /** @var static */ + return $this->table->tinyInteger($name); + } + + public function smallInteger(string $name): static + { + /** @var static */ + return $this->table->smallInteger($name); + } + public function integer(string $name): static { /** @var static */ @@ -297,6 +344,18 @@ public function bigInteger(string $name): static return $this->table->bigInteger($name); } + public function decimal(string $name, int $precision = 10, int $scale = 0): static + { + /** @var static */ + return $this->table->decimal($name, $precision, $scale); + } + + public function uuid(string $name): static + { + /** @var static */ + return $this->table->uuid($name); + } + public function serial(string $name): static { /** @var static */ diff --git a/src/Query/Schema/Column/ClickHouse.php b/src/Query/Schema/Column/ClickHouse.php index a056f42..73068cf 100644 --- a/src/Query/Schema/Column/ClickHouse.php +++ b/src/Query/Schema/Column/ClickHouse.php @@ -4,6 +4,7 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\Forwarder; use Utopia\Query\Schema\Table; @@ -22,6 +23,12 @@ class ClickHouse extends Column /** @var list Column-level CODEC clauses, e.g. ['Delta(4)', 'LZ4'] */ public protected(set) array $codecs = []; + /** Element type when the column is emitted as `Array(T)`; null otherwise. */ + public protected(set) ?ColumnType $arrayElementType = null; + + /** @var list Element types when the column is emitted as `Tuple(...)`. */ + public protected(set) array $tupleElementTypes = []; + /** * Mark the column as `FixedString(N)`. * @@ -48,6 +55,44 @@ public function isFixedString(): bool return $this->fixedStringLength !== null; } + /** + * Mark the column as `Array(T)` wrapping the given element type. + */ + public function asArray(ColumnType $element): static + { + $this->arrayElementType = $element; + + return $this; + } + + public function isArray(): bool + { + return $this->arrayElementType !== null; + } + + /** + * Mark the column as `Tuple(t1, t2, ...)` over the given element types. + * + * @param list $elements + * + * @throws ValidationException if the element list is empty. + */ + public function asTuple(array $elements): static + { + if ($elements === []) { + throw new ValidationException('Tuple() requires at least one element type.'); + } + + $this->tupleElementTypes = $elements; + + return $this; + } + + public function isTuple(): bool + { + return $this->tupleElementTypes !== []; + } + /** * @param list $columns * diff --git a/src/Query/Schema/ColumnType.php b/src/Query/Schema/ColumnType.php index a7ff7a1..949b3d7 100644 --- a/src/Query/Schema/ColumnType.php +++ b/src/Query/Schema/ColumnType.php @@ -9,10 +9,13 @@ enum ColumnType: string case Text = 'text'; case MediumText = 'mediumtext'; case LongText = 'longtext'; + case TinyInteger = 'tinyinteger'; + case SmallInteger = 'smallinteger'; case Integer = 'integer'; case BigInteger = 'biginteger'; case Float = 'float'; case Double = 'double'; + case Decimal = 'decimal'; case Boolean = 'boolean'; case Datetime = 'datetime'; case Timestamp = 'timestamp'; @@ -24,10 +27,13 @@ enum ColumnType: string case Polygon = 'polygon'; case Vector = 'vector'; case Id = 'id'; + case Uuid = 'uuid'; case Uuid7 = 'uuid7'; case Object = 'object'; case Relationship = 'relationship'; case Serial = 'serial'; case BigSerial = 'bigserial'; case SmallSerial = 'smallserial'; + case Array = 'array'; + case Tuple = 'tuple'; } diff --git a/src/Query/Schema/Forwarder/ClickHouse.php b/src/Query/Schema/Forwarder/ClickHouse.php index ce54e07..3d98b46 100644 --- a/src/Query/Schema/Forwarder/ClickHouse.php +++ b/src/Query/Schema/Forwarder/ClickHouse.php @@ -4,6 +4,7 @@ use Utopia\Query\Schema\ClickHouse\Engine; use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\Table; /** @@ -23,6 +24,19 @@ public function fixedString(string $name, int $length): Column\ClickHouse return $this->table->fixedString($name, $length); } + public function array(string $name, ColumnType $element): Column\ClickHouse + { + return $this->table->array($name, $element); + } + + /** + * @param list $elements + */ + public function tuple(string $name, array $elements): Column\ClickHouse + { + return $this->table->tuple($name, $elements); + } + public function engine(Engine $engine, string ...$args): Table\ClickHouse { return $this->table->engine($engine, ...$args); @@ -36,6 +50,11 @@ public function orderBy(array $columns): Table\ClickHouse return $this->table->orderBy($columns); } + public function orderByRaw(string $expression): Table\ClickHouse + { + return $this->table->orderByRaw($expression); + } + /** * @param array $settings */ diff --git a/src/Query/Schema/MongoDB.php b/src/Query/Schema/MongoDB.php index fc504ee..ee42be3 100644 --- a/src/Query/Schema/MongoDB.php +++ b/src/Query/Schema/MongoDB.php @@ -33,9 +33,11 @@ protected function compileColumnType(Column $column): string return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'string', ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'string', + ColumnType::TinyInteger, ColumnType::SmallInteger, ColumnType::Integer, ColumnType::BigInteger, ColumnType::Id, ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => 'int', ColumnType::Float, ColumnType::Double => 'double', + ColumnType::Decimal => 'decimal', ColumnType::Boolean => 'bool', ColumnType::Datetime, ColumnType::Timestamp => 'date', ColumnType::Json, ColumnType::Object => 'object', @@ -43,8 +45,9 @@ protected function compileColumnType(Column $column): string ColumnType::Enum => 'string', ColumnType::Point => 'object', ColumnType::Linestring, ColumnType::Polygon => 'object', - ColumnType::Uuid7 => 'string', - ColumnType::Vector => 'array', + ColumnType::Uuid, ColumnType::Uuid7 => 'string', + ColumnType::Vector, ColumnType::Array => 'array', + ColumnType::Tuple => 'array', }; } diff --git a/src/Query/Schema/MySQL.php b/src/Query/Schema/MySQL.php index 4ea2d66..0a4eb6a 100644 --- a/src/Query/Schema/MySQL.php +++ b/src/Query/Schema/MySQL.php @@ -58,10 +58,12 @@ protected function compileColumnType(Column $column): string ColumnType::Text => 'TEXT', ColumnType::MediumText => 'MEDIUMTEXT', ColumnType::LongText => 'LONGTEXT', + ColumnType::TinyInteger => 'TINYINT', + ColumnType::SmallInteger, ColumnType::SmallSerial => 'SMALLINT', ColumnType::Integer, ColumnType::Serial => 'INT', ColumnType::BigInteger, ColumnType::Id, ColumnType::BigSerial => 'BIGINT', - ColumnType::SmallSerial => 'SMALLINT', ColumnType::Float, ColumnType::Double => 'DOUBLE', + ColumnType::Decimal => 'DECIMAL(' . ($column->precision ?? 10) . ', ' . ($column->scale ?? 0) . ')', ColumnType::Boolean => 'TINYINT(1)', ColumnType::Datetime => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', @@ -71,8 +73,10 @@ protected function compileColumnType(Column $column): string ColumnType::Point => 'POINT' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), ColumnType::Linestring => 'LINESTRING' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), ColumnType::Polygon => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Uuid => 'CHAR(36)', ColumnType::Uuid7 => 'VARCHAR(36)', ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in MySQL.'), + ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException('Array/Tuple column types are not supported in MySQL.'), }; } diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index 5bdf04e..01b4e6a 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -62,9 +62,11 @@ protected function compileColumnType(Column $column): string return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::TinyInteger, ColumnType::SmallInteger => 'SMALLINT', ColumnType::Integer => 'INTEGER', ColumnType::BigInteger, ColumnType::Id => 'BIGINT', ColumnType::Float, ColumnType::Double => 'DOUBLE PRECISION', + ColumnType::Decimal => 'DECIMAL(' . ($column->precision ?? 10) . ', ' . ($column->scale ?? 0) . ')', ColumnType::Boolean => 'BOOLEAN', ColumnType::Datetime => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ') WITHOUT TIME ZONE' : 'TIMESTAMP WITHOUT TIME ZONE', @@ -74,11 +76,13 @@ protected function compileColumnType(Column $column): string ColumnType::Point => 'GEOMETRY(POINT' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', ColumnType::Linestring => 'GEOMETRY(LINESTRING' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', ColumnType::Polygon => 'GEOMETRY(POLYGON' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Uuid => 'UUID', ColumnType::Uuid7 => 'VARCHAR(36)', ColumnType::Vector => 'VECTOR(' . ($column->dimensions ?? 0) . ')', ColumnType::Serial => 'SERIAL', ColumnType::BigSerial => 'BIGSERIAL', ColumnType::SmallSerial => 'SMALLSERIAL', + ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException('Array/Tuple column types are not supported in PostgreSQL.'), }; } @@ -136,7 +140,9 @@ protected function compileColumnDefinition(Column $column): string $parts[] = 'NULL'; } - if ($column->hasDefault) { + if ($column->defaultRaw !== null) { + $parts[] = 'DEFAULT ' . $column->defaultRaw; + } elseif ($column->hasDefault) { $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); } diff --git a/src/Query/Schema/SQLite.php b/src/Query/Schema/SQLite.php index 868c66a..99ede13 100644 --- a/src/Query/Schema/SQLite.php +++ b/src/Query/Schema/SQLite.php @@ -25,17 +25,21 @@ protected function compileColumnType(Column $column): string return match ($column->type) { ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::TinyInteger, ColumnType::SmallInteger, ColumnType::Integer, ColumnType::BigInteger, ColumnType::Id, ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => 'INTEGER', ColumnType::Float, ColumnType::Double => 'REAL', + ColumnType::Decimal => 'NUMERIC(' . ($column->precision ?? 10) . ', ' . ($column->scale ?? 0) . ')', ColumnType::Boolean => 'INTEGER', ColumnType::Datetime, ColumnType::Timestamp => 'TEXT', ColumnType::Json, ColumnType::Object => 'TEXT', ColumnType::Binary => 'BLOB', ColumnType::Enum => 'TEXT', ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon => 'TEXT', + ColumnType::Uuid => 'TEXT', ColumnType::Uuid7 => 'VARCHAR(36)', ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in SQLite.'), + ColumnType::Array, ColumnType::Tuple => throw new UnsupportedException('Array/Tuple column types are not supported in SQLite.'), }; } @@ -68,7 +72,9 @@ protected function compileColumnDefinition(Column $column): string $parts[] = 'NOT NULL'; } - if ($column->hasDefault) { + if ($column->defaultRaw !== null) { + $parts[] = 'DEFAULT ' . $column->defaultRaw; + } elseif ($column->hasDefault) { $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); } diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index 78ecf1a..da8331d 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -120,10 +120,10 @@ private function requireSchema(): Schema * * @return TColumn */ - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column { /** @var TColumn */ - return new Column($this, $name, $type, $length, $precision); + return new Column($this, $name, $type, $length, $precision, $scale); } /** @@ -187,6 +187,24 @@ public function longText(string $name): Column return $col; } + /** @return TColumn */ + public function tinyInteger(string $name): Column + { + $col = $this->newColumn($name, ColumnType::TinyInteger); + $this->columns[] = $col; + + return $col; + } + + /** @return TColumn */ + public function smallInteger(string $name): Column + { + $col = $this->newColumn($name, ColumnType::SmallInteger); + $this->columns[] = $col; + + return $col; + } + /** @return TColumn */ public function integer(string $name): Column { @@ -205,6 +223,56 @@ public function bigInteger(string $name): Column return $col; } + /** + * Add a fixed-point `DECIMAL(precision, scale)` column. + * + * Default precision/scale matches MySQL's documented defaults (10, 0). For + * monetary values prefer an explicit precision and scale, e.g. + * `decimal('amount', precision: 18, scale: 3)`. + * + * @throws ValidationException if precision is less than 1 or scale is negative. + * + * @return TColumn + */ + public function decimal(string $name, int $precision = 10, int $scale = 0): Column + { + if ($precision < 1) { + throw new ValidationException('DECIMAL precision must be at least 1.'); + } + + if ($scale < 0) { + throw new ValidationException('DECIMAL scale must not be negative.'); + } + + if ($scale > $precision) { + throw new ValidationException('DECIMAL scale must not exceed precision.'); + } + + $col = $this->newColumn($name, ColumnType::Decimal, precision: $precision, scale: $scale); + $this->columns[] = $col; + + return $col; + } + + /** + * Add a UUID column. Compiled to the dialect's native UUID type when one + * exists (`UUID` on ClickHouse and PostgreSQL) and to a 36-character + * string column otherwise (`CHAR(36)` on MySQL, `TEXT` on SQLite, `string` + * on MongoDB). + * + * Pair with {@see Column::defaultRaw()} for server-generated defaults such + * as `generateUUIDv4()` (ClickHouse) or `gen_random_uuid()` (PostgreSQL). + * + * @return TColumn + */ + public function uuid(string $name): Column + { + $col = $this->newColumn($name, ColumnType::Uuid); + $this->columns[] = $col; + + return $col; + } + /** * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT * on MySQL; INTEGER on SQLite). Not exposed on ClickHouse/MongoDB. diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php index 0ca5a91..4ae2100 100644 --- a/src/Query/Schema/Table/ClickHouse.php +++ b/src/Query/Schema/Table/ClickHouse.php @@ -19,10 +19,13 @@ class ClickHouse extends Table /** ClickHouse SAMPLE BY expression. Emitted after ORDER BY when set. */ public protected(set) ?string $sampleBy = null; + /** Raw ORDER BY expression. Emitted verbatim when set; takes precedence over {@see $orderBy}. */ + public protected(set) ?string $orderByRaw = null; + #[\Override] - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\ClickHouse + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\ClickHouse { - return new Column\ClickHouse($this, $name, $type, $length, $precision); + return new Column\ClickHouse($this, $name, $type, $length, $precision, $scale); } public function vector(string $name, int $dimensions): Column\ClickHouse @@ -34,6 +37,41 @@ public function vector(string $name, int $dimensions): Column\ClickHouse return $col; } + /** + * Add an `Array(T)` column. + * + * The element type is taken from {@see ColumnType} and recursed back + * through the standard column-type compiler, so `array('tags', + * ColumnType::String)` emits `Array(String)`, `array('values', + * ColumnType::BigInteger)->unsigned()` emits `Array(UInt64)`, etc. + * Pair with `nullable()` and `lowCardinality()` exactly as with scalar + * columns; ClickHouse's required wrapping order is applied by the compiler. + */ + public function array(string $name, ColumnType $element): Column\ClickHouse + { + $col = $this->newColumn($name, ColumnType::Array); + $col->asArray($element); + $this->columns[] = $col; + + return $col; + } + + /** + * Add a `Tuple(t1, t2, ...)` column over the given element types. + * + * @param list $elements + * + * @throws ValidationException if the element list is empty. + */ + public function tuple(string $name, array $elements): Column\ClickHouse + { + $col = $this->newColumn($name, ColumnType::Tuple); + $col->asTuple($elements); + $this->columns[] = $col; + + return $col; + } + /** * Add a `FixedString(N)` column. * @@ -103,6 +141,36 @@ public function orderBy(array $columns): static return $this; } + /** + * Set the ORDER BY clause to a raw expression emitted verbatim. + * + * Mirrors {@see partitionBy()} — accepts the full parenthesised tuple, + * including function calls (`toDate(ts)`, `cityHash64(user_id)`) that are + * common in MergeTree ORDER BY clauses for sparse-index cardinality + * control. The expression is emitted unquoted and must come from a trusted + * (developer-controlled) source. + * + * Takes precedence over {@see orderBy()} when both are set. + * + * @throws ValidationException if the expression is empty or contains ";". + */ + public function orderByRaw(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('Raw ORDER BY expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('Raw ORDER BY expression must not contain ";".'); + } + + $this->orderByRaw = $trimmed; + + return $this; + } + /** * Attach a table-level TTL expression. * diff --git a/src/Query/Schema/Table/MongoDB.php b/src/Query/Schema/Table/MongoDB.php index 522efc8..7a64f6d 100644 --- a/src/Query/Schema/Table/MongoDB.php +++ b/src/Query/Schema/Table/MongoDB.php @@ -13,9 +13,9 @@ class MongoDB extends Table { #[\Override] - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\MongoDB + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\MongoDB { - return new Column\MongoDB($this, $name, $type, $length, $precision); + return new Column\MongoDB($this, $name, $type, $length, $precision, $scale); } public function vector(string $name, int $dimensions): Column\MongoDB diff --git a/src/Query/Schema/Table/MySQL.php b/src/Query/Schema/Table/MySQL.php index 47290d8..19a3e72 100644 --- a/src/Query/Schema/Table/MySQL.php +++ b/src/Query/Schema/Table/MySQL.php @@ -20,9 +20,9 @@ class MySQL extends Table use Trait\StandardPartitioning; #[\Override] - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\MySQL + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\MySQL { - return new Column\MySQL($this, $name, $type, $length, $precision); + return new Column\MySQL($this, $name, $type, $length, $precision, $scale); } #[\Override] diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php index 623a379..a6bc755 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -20,9 +20,9 @@ class PostgreSQL extends Table use Trait\StandardPartitioning; #[\Override] - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\PostgreSQL + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\PostgreSQL { - return new Column\PostgreSQL($this, $name, $type, $length, $precision); + return new Column\PostgreSQL($this, $name, $type, $length, $precision, $scale); } #[\Override] diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php index 41152ad..fe0218e 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -18,9 +18,9 @@ class SQLite extends Table use Trait\InlineForeignKey; #[\Override] - protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\SQLite + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null, ?int $scale = null): Column\SQLite { - return new Column\SQLite($this, $name, $type, $length, $precision); + return new Column\SQLite($this, $name, $type, $length, $precision, $scale); } #[\Override] diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php index 80a1a55..4c94866 100644 --- a/tests/Query/Schema/ClickHouseTest.php +++ b/tests/Query/Schema/ClickHouseTest.php @@ -1163,4 +1163,283 @@ public function testSampleByRejectedOnEnginesWithoutOrderBy(): void ->sampleBy('id') ->create(); } + + public function testCreateTableUuidColumn(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->uuid('event_id')->primary() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`event_id` UUID) ENGINE = MergeTree() ORDER BY (`event_id`)', + $result->query, + ); + } + + public function testCreateTableUuidColumnWithDefaultRaw(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->uuid('event_id')->defaultRaw('generateUUIDv4()')->primary() + ->datetime('ts', 3) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`event_id` UUID DEFAULT generateUUIDv4(), `ts` DateTime64(3))' + . ' ENGINE = MergeTree() ORDER BY (`event_id`)', + $result->query, + ); + } + + public function testCreateTableUuidNullable(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->nullable() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `t` (`id` Nullable(UUID)) ENGINE = MergeTree() ORDER BY tuple()', + $result->query, + ); + } + + public function testDefaultRawRejectsEmpty(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('t')->uuid('id')->defaultRaw(''); + } + + public function testDefaultRawRejectsSemicolon(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('t')->uuid('id')->defaultRaw('generateUUIDv4();'); + } + + public function testDefaultRawTakesPrecedenceOverDefault(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->default('00000000-0000-0000-0000-000000000000')->defaultRaw('generateUUIDv4()') + ->create(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DEFAULT generateUUIDv4()', $result->query); + $this->assertStringNotContainsString("DEFAULT '00000000", $result->query); + } + + public function testCreateTableTinyIntegerColumn(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->tinyInteger('signed_depth') + ->tinyInteger('unsigned_depth')->unsigned() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`signed_depth` Int8, `unsigned_depth` UInt8) ENGINE = MergeTree() ORDER BY tuple()', + $result->query, + ); + } + + public function testCreateTableSmallIntegerColumn(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->smallInteger('signed_year') + ->smallInteger('unsigned_year')->unsigned() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`signed_year` Int16, `unsigned_year` UInt16) ENGINE = MergeTree() ORDER BY tuple()', + $result->query, + ); + } + + public function testCreateTableDecimalColumn(): void + { + $schema = new Schema(); + $result = $schema->table('orders') + ->bigInteger('id')->primary() + ->decimal('amount', precision: 18, scale: 3) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `orders` (`id` Int64, `amount` Decimal(18, 3)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testCreateTableDecimalNullable(): void + { + $schema = new Schema(); + $result = $schema->table('orders') + ->bigInteger('id')->primary() + ->decimal('amount', precision: 18, scale: 3)->nullable() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `orders` (`id` Int64, `amount` Nullable(Decimal(18, 3))) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testCreateTableArrayColumn(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->array('tags', ColumnType::String) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `tags` Array(String)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testCreateTableArrayUnsignedInteger(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->array('counts', ColumnType::BigInteger)->unsigned() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `counts` Array(UInt64)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testCreateTableArrayNullable(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->array('tags', ColumnType::String)->nullable() + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `tags` Nullable(Array(String))) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testArrayRejectsLowCardinalityWrap(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->table('events') + ->array('tags', ColumnType::String)->lowCardinality() + ->create(); + } + + public function testCreateTableTupleColumn(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->tuple('point', [ColumnType::Float, ColumnType::Float]) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `point` Tuple(Float64, Float64)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } + + public function testTupleRejectsEmptyElementList(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events')->tuple('bad', []); + } + + public function testCreateTableOrderByRaw(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->string('tenant') + ->bigInteger('id') + ->datetime('ts') + ->orderByRaw('(`tenant`, toDate(`ts`), `id`)') + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`tenant` String, `id` Int64, `ts` DateTime)' + . ' ENGINE = MergeTree() ORDER BY (`tenant`, toDate(`ts`), `id`)', + $result->query, + ); + } + + public function testOrderByRawTakesPrecedenceOverOrderBy(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id') + ->datetime('ts') + ->orderBy(['id']) + ->orderByRaw('(toDate(`ts`), `id`)') + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `ts` DateTime)' + . ' ENGINE = MergeTree() ORDER BY (toDate(`ts`), `id`)', + $result->query, + ); + } + + public function testOrderByRawRejectsEmpty(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events')->orderByRaw(''); + } + + public function testOrderByRawRejectsSemicolon(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('events')->orderByRaw('(`id`);'); + } + + public function testRawColumnHonouredInCreateTable(): void + { + $schema = new Schema(); + $result = $schema->table('events') + ->bigInteger('id')->primary() + ->rawColumn('`meta.key` Array(String)') + ->rawColumn('`meta.value` Array(String)') + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `events` (`id` Int64, `meta.key` Array(String), `meta.value` Array(String))' + . ' ENGINE = MergeTree() ORDER BY (`id`)', + $result->query, + ); + } } diff --git a/tests/Query/Schema/MongoDBTest.php b/tests/Query/Schema/MongoDBTest.php index 923fd69..93909be 100644 --- a/tests/Query/Schema/MongoDBTest.php +++ b/tests/Query/Schema/MongoDBTest.php @@ -374,6 +374,28 @@ public function testDropView(): void $this->assertArrayNotHasKey('view', $op); } + public function testCreateCollectionWithDecimalAndUuid(): void + { + $schema = new Schema(); + $result = $schema->table('payments') + ->uuid('id') + ->tinyInteger('priority') + ->decimal('amount', precision: 18, scale: 3) + ->create(); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $props */ + $props = $jsonSchema['properties']; + + $this->assertSame('string', $props['id']['bsonType']); + $this->assertSame('int', $props['priority']['bsonType']); + $this->assertSame('decimal', $props['amount']['bsonType']); + } + public function testCreateCollectionWithAllBsonTypes(): void { $schema = new Schema(); diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php index afd4b78..1d6dfcf 100644 --- a/tests/Query/Schema/MySQLTest.php +++ b/tests/Query/Schema/MySQLTest.php @@ -1216,4 +1216,88 @@ public function testUserTypeColumnThrowsUnsupported(): void ->string('mood')->userType('mood_type') ->create(); } + + public function testTinyIntegerColumn(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->tinyInteger('flag') + ->tinyInteger('depth')->unsigned() + ->create(); + + $this->assertSame( + 'CREATE TABLE `t` (`flag` TINYINT NOT NULL, `depth` TINYINT UNSIGNED NOT NULL)', + $result->query, + ); + } + + public function testSmallIntegerColumn(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->smallInteger('year') + ->smallInteger('count')->unsigned() + ->create(); + + $this->assertSame( + 'CREATE TABLE `t` (`year` SMALLINT NOT NULL, `count` SMALLINT UNSIGNED NOT NULL)', + $result->query, + ); + } + + public function testDecimalColumn(): void + { + $schema = new Schema(); + $result = $schema->table('orders') + ->decimal('amount', precision: 18, scale: 3) + ->decimal('rate', precision: 5, scale: 4)->nullable() + ->create(); + + $this->assertSame( + 'CREATE TABLE `orders` (`amount` DECIMAL(18, 3) NOT NULL, `rate` DECIMAL(5, 4) NULL)', + $result->query, + ); + } + + public function testDecimalRejectsNegativeScale(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('t')->decimal('bad', precision: 10, scale: -1); + } + + public function testDecimalRejectsScaleGreaterThanPrecision(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->table('t')->decimal('bad', precision: 5, scale: 6); + } + + public function testUuidColumn(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->primary() + ->create(); + + $this->assertSame( + 'CREATE TABLE `t` (`id` CHAR(36) NOT NULL, PRIMARY KEY (`id`))', + $result->query, + ); + } + + public function testUuidColumnWithDefaultRaw(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->defaultRaw('UUID()')->primary() + ->create(); + + $this->assertSame( + 'CREATE TABLE `t` (`id` CHAR(36) NOT NULL DEFAULT UUID(), PRIMARY KEY (`id`))', + $result->query, + ); + } } diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index d858945..906d83e 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -1300,4 +1300,70 @@ public function testUserTypeRejectsInvalidIdentifier(): void $col = new Column($bp, 'mood', ColumnType::String); $col->userType('bad; DROP TABLE users'); } + + public function testTinyIntegerColumnMapsToSmallInt(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->tinyInteger('depth') + ->create(); + + $this->assertSame( + 'CREATE TABLE "t" ("depth" SMALLINT NOT NULL)', + $result->query, + ); + } + + public function testSmallIntegerColumn(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->smallInteger('year') + ->create(); + + $this->assertSame( + 'CREATE TABLE "t" ("year" SMALLINT NOT NULL)', + $result->query, + ); + } + + public function testDecimalColumn(): void + { + $schema = new Schema(); + $result = $schema->table('orders') + ->decimal('amount', precision: 18, scale: 3) + ->decimal('rate', precision: 5, scale: 4)->nullable() + ->create(); + + $this->assertSame( + 'CREATE TABLE "orders" ("amount" DECIMAL(18, 3) NOT NULL, "rate" DECIMAL(5, 4) NULL)', + $result->query, + ); + } + + public function testUuidColumn(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->primary() + ->create(); + + $this->assertSame( + 'CREATE TABLE "t" ("id" UUID NOT NULL, PRIMARY KEY ("id"))', + $result->query, + ); + } + + public function testUuidColumnWithDefaultRaw(): void + { + $schema = new Schema(); + $result = $schema->table('t') + ->uuid('id')->defaultRaw('gen_random_uuid()')->primary() + ->create(); + + $this->assertSame( + 'CREATE TABLE "t" ("id" UUID NOT NULL DEFAULT gen_random_uuid(), PRIMARY KEY ("id"))', + $result->query, + ); + } } diff --git a/tests/Query/Schema/SQLiteTest.php b/tests/Query/Schema/SQLiteTest.php index 52dd196..a9583a5 100644 --- a/tests/Query/Schema/SQLiteTest.php +++ b/tests/Query/Schema/SQLiteTest.php @@ -556,4 +556,38 @@ public function testUserTypeColumnThrowsUnsupported(): void ->string('mood')->userType('mood_type') ->create(); } + + public function testTinyIntegerMapsToInteger(): void + { + $schema = new Schema(); + $result = $schema->table('t')->tinyInteger('depth')->create(); + + $this->assertSame('CREATE TABLE `t` (`depth` INTEGER NOT NULL)', $result->query); + } + + public function testSmallIntegerMapsToInteger(): void + { + $schema = new Schema(); + $result = $schema->table('t')->smallInteger('year')->create(); + + $this->assertSame('CREATE TABLE `t` (`year` INTEGER NOT NULL)', $result->query); + } + + public function testDecimalMapsToNumeric(): void + { + $schema = new Schema(); + $result = $schema->table('orders') + ->decimal('amount', precision: 18, scale: 3) + ->create(); + + $this->assertSame('CREATE TABLE `orders` (`amount` NUMERIC(18, 3) NOT NULL)', $result->query); + } + + public function testUuidMapsToText(): void + { + $schema = new Schema(); + $result = $schema->table('t')->uuid('id')->primary()->create(); + + $this->assertSame('CREATE TABLE `t` (`id` TEXT NOT NULL, PRIMARY KEY (`id`))', $result->query); + } }