From 9766c4f32513a0864e560f18132382f3e4a0847d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 12:33:26 +1200 Subject: [PATCH 01/11] refactor(schema): split dialect features into interfaces, traits, per-dialect Tables Moves dialect-specific Schema methods off the abstract base into feature interfaces and traits, then splits the fluent Table/Column/ForeignKey builder per-dialect so methods that fail on a given dialect are gone from its type, not throwing at runtime. New feature interfaces under Schema/Feature: Views, ReplaceView, Databases, RenameIndex, AnalyzeTable, Partitioning. Default implementations live in matching Schema/Trait/ traits and dialects opt-in via implements + use. SQLite no longer exposes Procedures/Triggers/Databases/RenameIndex/ReplaceView; MongoDB only exposes Views/Databases/AnalyzeTable. The base Schema::compileCreate now gates partition emission via instanceof Partitioning and the column-level/table-level TTL guards are gone (only ClickHouse constructs that state). Schema::table() becomes abstract and each dialect returns its own Table\X via covariant return. The shared Table\Trait\* traits supply optional surface (ForeignKeys, Checks, CompositePrimary, StandardPartitioning, FulltextSpatialIndex). Per-dialect Column\X / ForeignKey\X classes compose the Schema\Forwarder\X traits so chained column-to-table forwarders only exist for methods the dialect supports. ClickHouse-specific knobs (engine, orderBy, ttl, settings, partitionBy(expr)) live directly on Table\ClickHouse. Tests: drop now-impossible UnsupportedException assertions, add ForwarderTest covering each dialect-specific forwarder for success/failure/edge cases. --- src/Query/Schema.php | 69 +--- src/Query/Schema/ClickHouse.php | 21 +- src/Query/Schema/Column.php | 211 ++++------ src/Query/Schema/Column/ClickHouse.php | 31 ++ src/Query/Schema/Column/MongoDB.php | 15 + src/Query/Schema/Column/MySQL.php | 51 +++ src/Query/Schema/Column/PostgreSQL.php | 45 +++ src/Query/Schema/Column/SQLite.php | 45 +++ src/Query/Schema/Feature/AnalyzeTable.php | 10 + src/Query/Schema/Feature/Databases.php | 12 + src/Query/Schema/Feature/Partitioning.php | 10 + src/Query/Schema/Feature/RenameIndex.php | 10 + src/Query/Schema/Feature/ReplaceView.php | 11 + src/Query/Schema/Feature/Views.php | 13 + src/Query/Schema/ForeignKey.php | 91 +---- src/Query/Schema/ForeignKey/MySQL.php | 33 ++ src/Query/Schema/ForeignKey/PostgreSQL.php | 28 ++ src/Query/Schema/ForeignKey/SQLite.php | 28 ++ src/Query/Schema/Forwarder/ClickHouse.php | 47 +++ src/Query/Schema/Forwarder/MongoDB.php | 18 + src/Query/Schema/Forwarder/MySQL.php | 61 +++ src/Query/Schema/Forwarder/PostgreSQL.php | 66 +++ src/Query/Schema/Forwarder/SQLite.php | 30 ++ src/Query/Schema/MongoDB.php | 20 +- src/Query/Schema/MySQL.php | 39 +- src/Query/Schema/PostgreSQL.php | 41 +- src/Query/Schema/SQL.php | 115 +----- src/Query/Schema/SQLite.php | 28 +- src/Query/Schema/Table.php | 376 +++--------------- src/Query/Schema/Table/ClickHouse.php | 319 +++++++++++++++ src/Query/Schema/Table/MongoDB.php | 184 +++++++++ src/Query/Schema/Table/MySQL.php | 199 +++++++++ src/Query/Schema/Table/PostgreSQL.php | 210 ++++++++++ src/Query/Schema/Table/SQLite.php | 197 +++++++++ src/Query/Schema/Table/Trait/Checks.php | 21 + .../Schema/Table/Trait/CompositePrimary.php | 34 ++ src/Query/Schema/Table/Trait/ForeignKeys.php | 40 ++ .../Table/Trait/FulltextSpatialIndex.php | 35 ++ .../Table/Trait/StandardPartitioning.php | 49 +++ src/Query/Schema/Trait/AnalyzeTable.php | 13 + src/Query/Schema/Trait/Databases.php | 18 + src/Query/Schema/Trait/ForeignKeys.php | 44 ++ src/Query/Schema/Trait/Partitioning.php | 23 ++ src/Query/Schema/Trait/Procedures.php | 57 +++ src/Query/Schema/Trait/RenameIndex.php | 17 + src/Query/Schema/Trait/ReplaceView.php | 17 + src/Query/Schema/Trait/Triggers.php | 36 ++ src/Query/Schema/Trait/Views.php | 22 + .../Schema/ClickHouseIntegrationTest.php | 2 +- tests/Query/Schema/ClickHouseTest.php | 34 +- tests/Query/Schema/FluentBuilderTest.php | 77 +--- tests/Query/Schema/ForwarderTest.php | 224 +++++++++++ tests/Query/Schema/MongoDBTest.php | 14 - tests/Query/Schema/MySQLTest.php | 11 - tests/Query/Schema/SQLiteTest.php | 114 ------ tests/Query/Schema/TableTest.php | 2 +- 56 files changed, 2612 insertions(+), 976 deletions(-) create mode 100644 src/Query/Schema/Column/ClickHouse.php create mode 100644 src/Query/Schema/Column/MongoDB.php create mode 100644 src/Query/Schema/Column/MySQL.php create mode 100644 src/Query/Schema/Column/PostgreSQL.php create mode 100644 src/Query/Schema/Column/SQLite.php create mode 100644 src/Query/Schema/Feature/AnalyzeTable.php create mode 100644 src/Query/Schema/Feature/Databases.php create mode 100644 src/Query/Schema/Feature/Partitioning.php create mode 100644 src/Query/Schema/Feature/RenameIndex.php create mode 100644 src/Query/Schema/Feature/ReplaceView.php create mode 100644 src/Query/Schema/Feature/Views.php create mode 100644 src/Query/Schema/ForeignKey/MySQL.php create mode 100644 src/Query/Schema/ForeignKey/PostgreSQL.php create mode 100644 src/Query/Schema/ForeignKey/SQLite.php create mode 100644 src/Query/Schema/Forwarder/ClickHouse.php create mode 100644 src/Query/Schema/Forwarder/MongoDB.php create mode 100644 src/Query/Schema/Forwarder/MySQL.php create mode 100644 src/Query/Schema/Forwarder/PostgreSQL.php create mode 100644 src/Query/Schema/Forwarder/SQLite.php create mode 100644 src/Query/Schema/Table/ClickHouse.php create mode 100644 src/Query/Schema/Table/MongoDB.php create mode 100644 src/Query/Schema/Table/MySQL.php create mode 100644 src/Query/Schema/Table/PostgreSQL.php create mode 100644 src/Query/Schema/Table/SQLite.php create mode 100644 src/Query/Schema/Table/Trait/Checks.php create mode 100644 src/Query/Schema/Table/Trait/CompositePrimary.php create mode 100644 src/Query/Schema/Table/Trait/ForeignKeys.php create mode 100644 src/Query/Schema/Table/Trait/FulltextSpatialIndex.php create mode 100644 src/Query/Schema/Table/Trait/StandardPartitioning.php create mode 100644 src/Query/Schema/Trait/AnalyzeTable.php create mode 100644 src/Query/Schema/Trait/Databases.php create mode 100644 src/Query/Schema/Trait/ForeignKeys.php create mode 100644 src/Query/Schema/Trait/Partitioning.php create mode 100644 src/Query/Schema/Trait/Procedures.php create mode 100644 src/Query/Schema/Trait/RenameIndex.php create mode 100644 src/Query/Schema/Trait/ReplaceView.php create mode 100644 src/Query/Schema/Trait/Triggers.php create mode 100644 src/Query/Schema/Trait/Views.php create mode 100644 tests/Query/Schema/ForwarderTest.php diff --git a/src/Query/Schema.php b/src/Query/Schema.php index 2616492..4aa1df5 100644 --- a/src/Query/Schema.php +++ b/src/Query/Schema.php @@ -4,7 +4,6 @@ use Closure; use Utopia\Query\Builder\Statement; -use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\Column; use Utopia\Query\Schema\IndexType; @@ -35,18 +34,14 @@ abstract protected function compileAutoIncrement(): string; * Begin a fluent table builder. Terminal methods on the returned {@see Table} * (`create()`, `alter()`, `drop()`, `dropIfExists()`, `truncate()`, `rename()`) * compile and return the final {@see Statement}. + * + * Each dialect overrides this to return the dialect-specific {@see Table} + * subclass exposing only methods supported by that dialect. */ - public function table(string $name): Table - { - return new Table($this, $name); - } + abstract public function table(string $name): Table; public function compileCreate(Table $table, bool $ifNotExists = false): Statement { - if ($table->ttl !== null) { - throw new UnsupportedException('TTL is only supported in ClickHouse.'); - } - $columnDefs = []; $primaryKeys = []; $uniqueColumns = []; @@ -125,10 +120,10 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen $sql = 'CREATE TABLE ' . ($ifNotExists ? 'IF NOT EXISTS ' : '') . $this->quote($table->name) . ' (' . \implode(', ', $columnDefs) . ')'; - if ($table->partitionType !== null) { - $sql .= ' PARTITION BY ' . $table->partitionType->value . '(' . $table->partitionExpression . ')'; - if ($table->partitionCount !== null) { - $sql .= ' PARTITIONS ' . $table->partitionCount; + if ($this instanceof Schema\Feature\Partitioning) { + $partitioning = $this->compileCreatePartitioning($table); + if ($partitioning !== '') { + $sql .= ' ' . $partitioning; } } @@ -269,33 +264,8 @@ public function dropIndex(string $table, string $name): Statement ); } - public function createView(string $name, Builder $query): Statement - { - $result = $query->build(); - $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; - - return new Statement($sql, $result->bindings, executor: $this->executor); - } - - public function createOrReplaceView(string $name, Builder $query): Statement - { - $result = $query->build(); - $sql = 'CREATE OR REPLACE VIEW ' . $this->quote($name) . ' AS ' . $result->query; - - return new Statement($sql, $result->bindings, executor: $this->executor); - } - - public function dropView(string $name): Statement - { - return new Statement('DROP VIEW ' . $this->quote($name), [], executor: $this->executor); - } - protected function compileColumnDefinition(Column $column): string { - if ($column->ttl !== null) { - throw new UnsupportedException('TTL is only supported in ClickHouse.'); - } - $parts = [ $this->quote($column->name), $this->compileColumnType($column), @@ -434,27 +404,4 @@ protected function compileIndexColumns(Schema\Index $index): string return \implode(', ', $parts); } - public function renameIndex(string $table, string $from, string $to): Statement - { - return new Statement( - 'ALTER TABLE ' . $this->quote($table) . ' RENAME INDEX ' . $this->quote($from) . ' TO ' . $this->quote($to), - [], - executor: $this->executor, - ); - } - - public function createDatabase(string $name): Statement - { - return new Statement('CREATE DATABASE ' . $this->quote($name), [], executor: $this->executor); - } - - public function dropDatabase(string $name): Statement - { - return new Statement('DROP DATABASE ' . $this->quote($name), [], executor: $this->executor); - } - - public function analyzeTable(string $table): Statement - { - return new Statement('ANALYZE TABLE ' . $this->quote($table), [], executor: $this->executor); - } } diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 22d35b0..9528ba7 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -2,7 +2,6 @@ namespace Utopia\Query\Schema; -use Utopia\Query\Builder; use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; @@ -10,12 +9,22 @@ use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; use Utopia\Query\Schema\Feature\ColumnComments; +use Utopia\Query\Schema\Feature\Databases; use Utopia\Query\Schema\Feature\DropPartition; use Utopia\Query\Schema\Feature\TableComments; +use Utopia\Query\Schema\Feature\Views; -class ClickHouse extends Schema implements TableComments, ColumnComments, DropPartition +class ClickHouse extends Schema implements TableComments, ColumnComments, DropPartition, Views, Databases { use QuotesIdentifiers; + use Trait\Databases; + use Trait\Views; + + #[\Override] + public function table(string $name): Table\ClickHouse + { + return new Table\ClickHouse($this, $name); + } protected function compileColumnType(Column $column): string { @@ -310,14 +319,6 @@ private function compileEngine(Engine $engine, array $args): string }; } - public function createView(string $name, Builder $query): Statement - { - $result = $query->build(); - $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; - - return new Statement($sql, $result->bindings, executor: $this->executor); - } - /** * @param string[] $values */ diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 09d4075..06c9c43 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -4,53 +4,52 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\ValidationException; -use Utopia\Query\Schema\ClickHouse\Engine; use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; class Column { - public private(set) bool $isNullable = false; + public protected(set) bool $isNullable = false; - public private(set) mixed $default = null; + public protected(set) mixed $default = null; - public private(set) bool $hasDefault = false; + public protected(set) bool $hasDefault = false; - public private(set) bool $isUnsigned = false; + public protected(set) bool $isUnsigned = false; - public private(set) bool $isUnique = false; + public protected(set) bool $isUnique = false; - public private(set) bool $isPrimary = false; + public protected(set) bool $isPrimary = false; - public private(set) bool $isAutoIncrement = false; + public protected(set) bool $isAutoIncrement = false; - public private(set) ?string $after = null; + public protected(set) ?string $after = null; - public private(set) ?string $comment = null; + public protected(set) ?string $comment = null; /** @var string[] */ - public private(set) array $enumValues = []; + public protected(set) array $enumValues = []; - public private(set) ?int $srid = null; + public protected(set) ?int $srid = null; - public private(set) ?int $dimensions = null; + public protected(set) ?int $dimensions = null; - public private(set) bool $isModify = false; + public protected(set) bool $isModify = false; - public private(set) ?string $collation = null; + public protected(set) ?string $collation = null; - public private(set) ?string $checkExpression = null; + public protected(set) ?string $checkExpression = null; - public private(set) ?string $generatedExpression = null; + public protected(set) ?string $generatedExpression = null; /** * Null when {@see generatedAs()} has not been called. * True = STORED, false = VIRTUAL. */ - public private(set) ?bool $generatedStored = null; + public protected(set) ?bool $generatedStored = null; - public private(set) ?string $ttl = null; + public protected(set) ?string $ttl = null; - public private(set) ?string $userTypeName = null; + public protected(set) ?string $userTypeName = null; public function __construct( public Table $table, @@ -91,22 +90,15 @@ public function unique(): static } /** - * Mark this column as a primary key (no args), or declare a composite - * primary key on the parent table (when an array is passed). - * - * @param list $columns - * - * @phpstan-return ($columns is array{} ? static : Table) + * Mark this column as a primary key. Dialect Column subclasses that + * support composite primary keys also accept a list of column names to + * declare a composite key on the parent table. */ - public function primary(array $columns = []): static|Table + public function primary(): static|Table { - if ($columns === []) { - $this->isPrimary = true; - - return $this; - } + $this->isPrimary = true; - return $this->table->primary($columns); + return $this; } public function after(string $column): static @@ -184,21 +176,15 @@ public function modify(): static } /** - * Attach a CHECK constraint. Called with one argument it sets a column- - * level CHECK on this column; called with two arguments it adds a named - * table-level CHECK constraint via the parent table. - * - * @phpstan-return ($expression is null ? static : Table) + * Attach a column-level CHECK constraint. Dialect Column subclasses that + * support table-level CHECK constraints also accept a name and expression + * pair to declare a named table-level CHECK. */ - public function check(string $expressionOrName, ?string $expression = null): static|Table + public function check(string $expression): static|Table { - if ($expression === null) { - $this->checkExpression = $expressionOrName; + $this->checkExpression = $expression; - return $this; - } - - return $this->table->check($expressionOrName, $expression); + return $this; } /** @@ -263,118 +249,134 @@ public function userType(string $name): static return $this; } - public function id(string $name = 'id'): Column + public function id(string $name = 'id'): static { + /** @var static */ return $this->table->id($name); } - public function string(string $name, int $length = 255): Column + public function string(string $name, int $length = 255): static { + /** @var static */ return $this->table->string($name, $length); } - public function text(string $name): Column + public function text(string $name): static { + /** @var static */ return $this->table->text($name); } - public function mediumText(string $name): Column + public function mediumText(string $name): static { + /** @var static */ return $this->table->mediumText($name); } - public function longText(string $name): Column + public function longText(string $name): static { + /** @var static */ return $this->table->longText($name); } - public function integer(string $name): Column + public function integer(string $name): static { + /** @var static */ return $this->table->integer($name); } - public function bigInteger(string $name): Column + public function bigInteger(string $name): static { + /** @var static */ return $this->table->bigInteger($name); } - public function serial(string $name): Column + public function serial(string $name): static { + /** @var static */ return $this->table->serial($name); } - public function bigSerial(string $name): Column + public function bigSerial(string $name): static { + /** @var static */ return $this->table->bigSerial($name); } - public function smallSerial(string $name): Column + public function smallSerial(string $name): static { + /** @var static */ return $this->table->smallSerial($name); } - public function float(string $name): Column + public function float(string $name): static { + /** @var static */ return $this->table->float($name); } - public function boolean(string $name): Column + public function boolean(string $name): static { + /** @var static */ return $this->table->boolean($name); } - public function datetime(string $name, int $precision = 0): Column + public function datetime(string $name, int $precision = 0): static { + /** @var static */ return $this->table->datetime($name, $precision); } - public function timestamp(string $name, int $precision = 0): Column + public function timestamp(string $name, int $precision = 0): static { + /** @var static */ return $this->table->timestamp($name, $precision); } - public function json(string $name): Column + public function json(string $name): static { + /** @var static */ return $this->table->json($name); } - public function binary(string $name): Column + public function binary(string $name): static { + /** @var static */ return $this->table->binary($name); } - public function point(string $name, int $srid = 4326): Column + public function point(string $name, int $srid = 4326): static { + /** @var static */ return $this->table->point($name, $srid); } - public function linestring(string $name, int $srid = 4326): Column + public function linestring(string $name, int $srid = 4326): static { + /** @var static */ return $this->table->linestring($name, $srid); } - public function polygon(string $name, int $srid = 4326): Column + public function polygon(string $name, int $srid = 4326): static { + /** @var static */ return $this->table->polygon($name, $srid); } - public function vector(string $name, int $dimensions): Column - { - return $this->table->vector($name, $dimensions); - } - public function timestamps(int $precision = 3): Table { return $this->table->timestamps($precision); } - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column + public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): static { + /** @var static */ return $this->table->addColumn($name, $type, $lengthOrPrecision); } - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column + public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): static { + /** @var static */ return $this->table->modifyColumn($name, $type, $lengthOrPrecision); } @@ -437,22 +439,6 @@ public function uniqueIndex( return $this->table->uniqueIndex($columns, $name, $lengths, $orders, $collations); } - /** - * @param string[] $columns - */ - public function fulltextIndex(array $columns, string $name = ''): Table - { - return $this->table->fulltextIndex($columns, $name); - } - - /** - * @param string[] $columns - */ - public function spatialIndex(array $columns, string $name = ''): Table - { - return $this->table->spatialIndex($columns, $name); - } - /** * @param string[] $columns * @param array $lengths @@ -479,21 +465,6 @@ public function dropIndex(string $name): Table return $this->table->dropIndex($name); } - public function foreignKey(string $column): ForeignKey - { - return $this->table->foreignKey($column); - } - - public function addForeignKey(string $column): ForeignKey - { - return $this->table->addForeignKey($column); - } - - public function dropForeignKey(string $name): Table - { - return $this->table->dropForeignKey($name); - } - public function rawColumn(string $definition): Table { return $this->table->rawColumn($definition); @@ -504,42 +475,6 @@ public function rawIndex(string $definition): Table return $this->table->rawIndex($definition); } - public function partitionByRange(string $expression): Table - { - return $this->table->partitionByRange($expression); - } - - public function partitionByList(string $expression): Table - { - return $this->table->partitionByList($expression); - } - - public function partitionByHash(string $expression, ?int $partitions = null): Table - { - return $this->table->partitionByHash($expression, $partitions); - } - - public function engine(Engine $engine, string ...$args): Table - { - return $this->table->engine($engine, ...$args); - } - - /** - * @param array $settings - */ - public function settings(array $settings): Table - { - return $this->table->settings($settings); - } - - /** - * @param list $columns - */ - public function orderBy(array $columns): Table - { - return $this->table->orderBy($columns); - } - public function create(bool $ifNotExists = false): Statement { return $this->table->create($ifNotExists); diff --git a/src/Query/Schema/Column/ClickHouse.php b/src/Query/Schema/Column/ClickHouse.php new file mode 100644 index 0000000..c477feb --- /dev/null +++ b/src/Query/Schema/Column/ClickHouse.php @@ -0,0 +1,31 @@ + $columns + * + * @phpstan-return ($columns is array{} ? static : Table\ClickHouse) + */ + public function primary(array $columns = []): static|Table + { + if ($columns === []) { + $this->isPrimary = true; + + return $this; + } + + return $this->table->primary($columns); + } +} diff --git a/src/Query/Schema/Column/MongoDB.php b/src/Query/Schema/Column/MongoDB.php new file mode 100644 index 0000000..57dfb41 --- /dev/null +++ b/src/Query/Schema/Column/MongoDB.php @@ -0,0 +1,15 @@ + $columns + * + * @phpstan-return ($columns is array{} ? static : Table\MySQL) + */ + public function primary(array $columns = []): static|Table + { + if ($columns === []) { + $this->isPrimary = true; + + return $this; + } + + return $this->table->primary($columns); + } + + /** + * Single-arg form sets a column-level CHECK; two-arg form declares a + * named table-level CHECK on the parent table. + * + * @phpstan-return ($expression is null ? static : Table\MySQL) + */ + public function check(string $expressionOrName, ?string $expression = null): static|Table + { + if ($expression === null) { + $this->checkExpression = $expressionOrName; + + return $this; + } + + return $this->table->check($expressionOrName, $expression); + } +} diff --git a/src/Query/Schema/Column/PostgreSQL.php b/src/Query/Schema/Column/PostgreSQL.php new file mode 100644 index 0000000..10e5dd1 --- /dev/null +++ b/src/Query/Schema/Column/PostgreSQL.php @@ -0,0 +1,45 @@ + $columns + * + * @phpstan-return ($columns is array{} ? static : Table\PostgreSQL) + */ + public function primary(array $columns = []): static|Table + { + if ($columns === []) { + $this->isPrimary = true; + + return $this; + } + + return $this->table->primary($columns); + } + + /** + * @phpstan-return ($expression is null ? static : Table\PostgreSQL) + */ + public function check(string $expressionOrName, ?string $expression = null): static|Table + { + if ($expression === null) { + $this->checkExpression = $expressionOrName; + + return $this; + } + + return $this->table->check($expressionOrName, $expression); + } +} diff --git a/src/Query/Schema/Column/SQLite.php b/src/Query/Schema/Column/SQLite.php new file mode 100644 index 0000000..fb000f5 --- /dev/null +++ b/src/Query/Schema/Column/SQLite.php @@ -0,0 +1,45 @@ + $columns + * + * @phpstan-return ($columns is array{} ? static : Table\SQLite) + */ + public function primary(array $columns = []): static|Table + { + if ($columns === []) { + $this->isPrimary = true; + + return $this; + } + + return $this->table->primary($columns); + } + + /** + * @phpstan-return ($expression is null ? static : Table\SQLite) + */ + public function check(string $expressionOrName, ?string $expression = null): static|Table + { + if ($expression === null) { + $this->checkExpression = $expressionOrName; + + return $this; + } + + return $this->table->check($expressionOrName, $expression); + } +} diff --git a/src/Query/Schema/Feature/AnalyzeTable.php b/src/Query/Schema/Feature/AnalyzeTable.php new file mode 100644 index 0000000..df73f9d --- /dev/null +++ b/src/Query/Schema/Feature/AnalyzeTable.php @@ -0,0 +1,10 @@ +table->polygon($name, $srid); } - public function vector(string $name, int $dimensions): Column - { - return $this->table->vector($name, $dimensions); - } - public function timestamps(int $precision = 3): Table { return $this->table->timestamps($precision); @@ -216,22 +210,6 @@ public function uniqueIndex( return $this->table->uniqueIndex($columns, $name, $lengths, $orders, $collations); } - /** - * @param string[] $columns - */ - public function fulltextIndex(array $columns, string $name = ''): Table - { - return $this->table->fulltextIndex($columns, $name); - } - - /** - * @param string[] $columns - */ - public function spatialIndex(array $columns, string $name = ''): Table - { - return $this->table->spatialIndex($columns, $name); - } - /** * @param string[] $columns * @param array $lengths @@ -258,34 +236,6 @@ public function dropIndex(string $name): Table return $this->table->dropIndex($name); } - public function foreignKey(string $column): ForeignKey - { - return $this->table->foreignKey($column); - } - - public function addForeignKey(string $column): ForeignKey - { - return $this->table->addForeignKey($column); - } - - public function dropForeignKey(string $name): Table - { - return $this->table->dropForeignKey($name); - } - - /** - * @param list $columns - */ - public function primary(array $columns): Table - { - return $this->table->primary($columns); - } - - public function check(string $name, string $expression): Table - { - return $this->table->check($name, $expression); - } - public function rawColumn(string $definition): Table { return $this->table->rawColumn($definition); @@ -296,39 +246,6 @@ public function rawIndex(string $definition): Table return $this->table->rawIndex($definition); } - public function partitionByRange(string $expression): Table - { - return $this->table->partitionByRange($expression); - } - - public function partitionByList(string $expression): Table - { - return $this->table->partitionByList($expression); - } - - public function partitionByHash(string $expression, ?int $partitions = null): Table - { - return $this->table->partitionByHash($expression, $partitions); - } - - public function engine(Engine $engine, string ...$args): Table - { - return $this->table->engine($engine, ...$args); - } - - /** - * @param list $columns - */ - public function orderBy(array $columns): Table - { - return $this->table->orderBy($columns); - } - - public function ttl(string $expression): Table - { - return $this->table->ttl($expression); - } - public function create(bool $ifNotExists = false): Statement { return $this->table->create($ifNotExists); diff --git a/src/Query/Schema/ForeignKey/MySQL.php b/src/Query/Schema/ForeignKey/MySQL.php new file mode 100644 index 0000000..0b8f937 --- /dev/null +++ b/src/Query/Schema/ForeignKey/MySQL.php @@ -0,0 +1,33 @@ + $columns + */ + public function primary(array $columns): Table\MySQL + { + return $this->table->primary($columns); + } + + /** + * Add a named table-level CHECK constraint to the parent table. + */ + public function check(string $name, string $expression): Table\MySQL + { + return $this->table->check($name, $expression); + } +} diff --git a/src/Query/Schema/ForeignKey/PostgreSQL.php b/src/Query/Schema/ForeignKey/PostgreSQL.php new file mode 100644 index 0000000..53f6466 --- /dev/null +++ b/src/Query/Schema/ForeignKey/PostgreSQL.php @@ -0,0 +1,28 @@ + $columns + */ + public function primary(array $columns): Table\PostgreSQL + { + return $this->table->primary($columns); + } + + public function check(string $name, string $expression): Table\PostgreSQL + { + return $this->table->check($name, $expression); + } +} diff --git a/src/Query/Schema/ForeignKey/SQLite.php b/src/Query/Schema/ForeignKey/SQLite.php new file mode 100644 index 0000000..5236973 --- /dev/null +++ b/src/Query/Schema/ForeignKey/SQLite.php @@ -0,0 +1,28 @@ + $columns + */ + public function primary(array $columns): Table\SQLite + { + return $this->table->primary($columns); + } + + public function check(string $name, string $expression): Table\SQLite + { + return $this->table->check($name, $expression); + } +} diff --git a/src/Query/Schema/Forwarder/ClickHouse.php b/src/Query/Schema/Forwarder/ClickHouse.php new file mode 100644 index 0000000..bc72453 --- /dev/null +++ b/src/Query/Schema/Forwarder/ClickHouse.php @@ -0,0 +1,47 @@ +table->vector($name, $dimensions); + } + + public function engine(Engine $engine, string ...$args): Table\ClickHouse + { + return $this->table->engine($engine, ...$args); + } + + /** + * @param list $columns + */ + public function orderBy(array $columns): Table\ClickHouse + { + return $this->table->orderBy($columns); + } + + /** + * @param array $settings + */ + public function settings(array $settings): Table\ClickHouse + { + return $this->table->settings($settings); + } + + public function partitionBy(string $expression): Table\ClickHouse + { + return $this->table->partitionBy($expression); + } + +} diff --git a/src/Query/Schema/Forwarder/MongoDB.php b/src/Query/Schema/Forwarder/MongoDB.php new file mode 100644 index 0000000..91e49f4 --- /dev/null +++ b/src/Query/Schema/Forwarder/MongoDB.php @@ -0,0 +1,18 @@ +table->vector($name, $dimensions); + } +} diff --git a/src/Query/Schema/Forwarder/MySQL.php b/src/Query/Schema/Forwarder/MySQL.php new file mode 100644 index 0000000..1592f07 --- /dev/null +++ b/src/Query/Schema/Forwarder/MySQL.php @@ -0,0 +1,61 @@ +table->foreignKey($column); + } + + public function addForeignKey(string $column): ForeignKey\MySQL + { + return $this->table->addForeignKey($column); + } + + public function dropForeignKey(string $name): Table\MySQL + { + return $this->table->dropForeignKey($name); + } + + public function partitionByRange(string $expression): Table\MySQL + { + return $this->table->partitionByRange($expression); + } + + public function partitionByList(string $expression): Table\MySQL + { + return $this->table->partitionByList($expression); + } + + public function partitionByHash(string $expression, ?int $partitions = null): Table\MySQL + { + return $this->table->partitionByHash($expression, $partitions); + } + + /** + * @param string[] $columns + */ + public function fulltextIndex(array $columns, string $name = ''): Table\MySQL + { + return $this->table->fulltextIndex($columns, $name); + } + + /** + * @param string[] $columns + */ + public function spatialIndex(array $columns, string $name = ''): Table\MySQL + { + return $this->table->spatialIndex($columns, $name); + } + +} diff --git a/src/Query/Schema/Forwarder/PostgreSQL.php b/src/Query/Schema/Forwarder/PostgreSQL.php new file mode 100644 index 0000000..ddda5a2 --- /dev/null +++ b/src/Query/Schema/Forwarder/PostgreSQL.php @@ -0,0 +1,66 @@ +table->foreignKey($column); + } + + public function addForeignKey(string $column): ForeignKey\PostgreSQL + { + return $this->table->addForeignKey($column); + } + + public function dropForeignKey(string $name): Table\PostgreSQL + { + return $this->table->dropForeignKey($name); + } + + public function partitionByRange(string $expression): Table\PostgreSQL + { + return $this->table->partitionByRange($expression); + } + + public function partitionByList(string $expression): Table\PostgreSQL + { + return $this->table->partitionByList($expression); + } + + public function partitionByHash(string $expression, ?int $partitions = null): Table\PostgreSQL + { + return $this->table->partitionByHash($expression, $partitions); + } + + /** + * @param string[] $columns + */ + public function fulltextIndex(array $columns, string $name = ''): Table\PostgreSQL + { + return $this->table->fulltextIndex($columns, $name); + } + + /** + * @param string[] $columns + */ + public function spatialIndex(array $columns, string $name = ''): Table\PostgreSQL + { + return $this->table->spatialIndex($columns, $name); + } + + public function vector(string $name, int $dimensions): Column\PostgreSQL + { + return $this->table->vector($name, $dimensions); + } + +} diff --git a/src/Query/Schema/Forwarder/SQLite.php b/src/Query/Schema/Forwarder/SQLite.php new file mode 100644 index 0000000..ffd0e57 --- /dev/null +++ b/src/Query/Schema/Forwarder/SQLite.php @@ -0,0 +1,30 @@ +table->foreignKey($column); + } + + public function addForeignKey(string $column): ForeignKey\SQLite + { + return $this->table->addForeignKey($column); + } + + public function dropForeignKey(string $name): Table\SQLite + { + return $this->table->dropForeignKey($name); + } + +} diff --git a/src/Query/Schema/MongoDB.php b/src/Query/Schema/MongoDB.php index 87267cd..21fc0b4 100644 --- a/src/Query/Schema/MongoDB.php +++ b/src/Query/Schema/MongoDB.php @@ -7,9 +7,18 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Schema; +use Utopia\Query\Schema\Feature\AnalyzeTable; +use Utopia\Query\Schema\Feature\Databases; +use Utopia\Query\Schema\Feature\Views; -class MongoDB extends Schema +class MongoDB extends Schema implements Views, Databases, AnalyzeTable { + #[\Override] + public function table(string $name): Table\MongoDB + { + return new Table\MongoDB($this, $name); + } + protected function quote(string $identifier): string { return $identifier; @@ -281,6 +290,15 @@ public function createView(string $name, Builder $query): Statement ); } + public function dropView(string $name): Statement + { + return new Statement( + \json_encode(['command' => 'drop', 'view' => $name], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + public function createDatabase(string $name): Statement { return new Statement( diff --git a/src/Query/Schema/MySQL.php b/src/Query/Schema/MySQL.php index 707df77..4ea2d66 100644 --- a/src/Query/Schema/MySQL.php +++ b/src/Query/Schema/MySQL.php @@ -4,12 +4,49 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Schema\Feature\AnalyzeTable; use Utopia\Query\Schema\Feature\CreatePartition; +use Utopia\Query\Schema\Feature\Databases; use Utopia\Query\Schema\Feature\DropPartition; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Partitioning; +use Utopia\Query\Schema\Feature\Procedures; +use Utopia\Query\Schema\Feature\RenameIndex; +use Utopia\Query\Schema\Feature\ReplaceView; use Utopia\Query\Schema\Feature\TableComments; +use Utopia\Query\Schema\Feature\Triggers; +use Utopia\Query\Schema\Feature\Views; -class MySQL extends SQL implements TableComments, CreatePartition, DropPartition +class MySQL extends SQL implements + ForeignKeys, + Procedures, + Triggers, + TableComments, + CreatePartition, + DropPartition, + Views, + ReplaceView, + Databases, + RenameIndex, + AnalyzeTable, + Partitioning { + use Trait\AnalyzeTable; + use Trait\Databases; + use Trait\ForeignKeys; + use Trait\Partitioning; + use Trait\Procedures; + use Trait\RenameIndex; + use Trait\ReplaceView; + use Trait\Triggers; + use Trait\Views; + + #[\Override] + public function table(string $name): Table\MySQL + { + return new Table\MySQL($this, $name); + } + protected function compileColumnType(Column $column): string { if ($column->userTypeName !== null) { diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index 5a8bc2e..c89b1e7 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -5,17 +5,54 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Schema\Feature\AnalyzeTable; use Utopia\Query\Schema\Feature\ColumnComments; use Utopia\Query\Schema\Feature\CreatePartition; +use Utopia\Query\Schema\Feature\Databases; use Utopia\Query\Schema\Feature\DropPartition; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Partitioning; +use Utopia\Query\Schema\Feature\Procedures; +use Utopia\Query\Schema\Feature\RenameIndex; +use Utopia\Query\Schema\Feature\ReplaceView; use Utopia\Query\Schema\Feature\Sequences; use Utopia\Query\Schema\Feature\TableComments; +use Utopia\Query\Schema\Feature\Triggers; use Utopia\Query\Schema\Feature\Types; - -class PostgreSQL extends SQL implements Types, Sequences, TableComments, ColumnComments, CreatePartition, DropPartition +use Utopia\Query\Schema\Feature\Views; + +class PostgreSQL extends SQL implements + ForeignKeys, + Procedures, + Triggers, + Types, + Sequences, + TableComments, + ColumnComments, + CreatePartition, + DropPartition, + Views, + ReplaceView, + Databases, + RenameIndex, + AnalyzeTable, + Partitioning { + use Trait\ForeignKeys; + use Trait\Partitioning; + use Trait\Procedures; + use Trait\ReplaceView; + use Trait\Triggers; + use Trait\Views; + protected string $wrapChar = '"'; + #[\Override] + public function table(string $name): Table\PostgreSQL + { + return new Table\PostgreSQL($this, $name); + } + protected function compileColumnType(Column $column): string { if ($column->userTypeName !== null) { diff --git a/src/Query/Schema/SQL.php b/src/Query/Schema/SQL.php index 83bbf40..ac4fa41 100644 --- a/src/Query/Schema/SQL.php +++ b/src/Query/Schema/SQL.php @@ -2,123 +2,10 @@ namespace Utopia\Query\Schema; -use Utopia\Query\Builder\Statement; -use Utopia\Query\Exception\ValidationException; use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema; -use Utopia\Query\Schema\Feature\ForeignKeys; -use Utopia\Query\Schema\Feature\Procedures; -use Utopia\Query\Schema\Feature\Triggers; -abstract class SQL extends Schema implements ForeignKeys, Procedures, Triggers +abstract class SQL extends Schema { use QuotesIdentifiers; - - public function addForeignKey( - string $table, - string $name, - string $column, - string $refTable, - string $refColumn, - ?ForeignKeyAction $onDelete = null, - ?ForeignKeyAction $onUpdate = null, - ): Statement { - $sql = 'ALTER TABLE ' . $this->quote($table) - . ' ADD CONSTRAINT ' . $this->quote($name) - . ' FOREIGN KEY (' . $this->quote($column) . ')' - . ' REFERENCES ' . $this->quote($refTable) - . ' (' . $this->quote($refColumn) . ')'; - - if ($onDelete !== null) { - $sql .= ' ON DELETE ' . $onDelete->toSql(); - } - if ($onUpdate !== null) { - $sql .= ' ON UPDATE ' . $onUpdate->toSql(); - } - - return new Statement($sql, [], executor: $this->executor); - } - - public function dropForeignKey(string $table, string $name): Statement - { - return new Statement( - 'ALTER TABLE ' . $this->quote($table) - . ' DROP FOREIGN KEY ' . $this->quote($name), - [], - executor: $this->executor, - ); - } - - /** - * Validate and compile a procedure parameter list. - * - * @param list $params - * @return list - */ - protected function compileProcedureParams(array $params): array - { - $paramList = []; - foreach ($params as $param) { - $direction = $param[0]->value; - $name = $this->quote($param[1]); - - if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $param[2])) { - throw new ValidationException('Invalid procedure parameter type: ' . $param[2]); - } - - $paramList[] = $direction . ' ' . $name . ' ' . $param[2]; - } - - return $paramList; - } - - /** - * Create a stored procedure. - * - * $body is emitted verbatim into the generated DDL and must come from - * trusted (developer-controlled) source — never from untrusted input. - * - * @param list $params - */ - public function createProcedure(string $name, array $params, string $body): Statement - { - $paramList = $this->compileProcedureParams($params); - - $sql = 'CREATE PROCEDURE ' . $this->quote($name) - . '(' . \implode(', ', $paramList) . ')' - . ' BEGIN ' . $body . ' END'; - - return new Statement($sql, [], executor: $this->executor); - } - - public function dropProcedure(string $name): Statement - { - return new Statement('DROP PROCEDURE ' . $this->quote($name), [], executor: $this->executor); - } - - /** - * Create a trigger. - * - * $body is emitted verbatim into the generated DDL and must come from - * trusted (developer-controlled) source — never from untrusted input. - */ - public function createTrigger( - string $name, - string $table, - TriggerTiming $timing, - TriggerEvent $event, - string $body, - ): Statement { - $sql = 'CREATE TRIGGER ' . $this->quote($name) - . ' ' . $timing->value . ' ' . $event->value - . ' ON ' . $this->quote($table) - . ' FOR EACH ROW BEGIN ' . $body . ' END'; - - return new Statement($sql, [], executor: $this->executor); - } - - public function dropTrigger(string $name): Statement - { - return new Statement('DROP TRIGGER ' . $this->quote($name), [], executor: $this->executor); - } } diff --git a/src/Query/Schema/SQLite.php b/src/Query/Schema/SQLite.php index 69e5f70..25c0cd1 100644 --- a/src/Query/Schema/SQLite.php +++ b/src/Query/Schema/SQLite.php @@ -4,9 +4,20 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; +use Utopia\Query\Schema\Feature\ForeignKeys; +use Utopia\Query\Schema\Feature\Views; -class SQLite extends SQL +class SQLite extends SQL implements ForeignKeys, Views { + use Trait\ForeignKeys; + use Trait\Views; + + #[\Override] + public function table(string $name): Table\SQLite + { + return new Table\SQLite($this, $name); + } + protected function compileColumnType(Column $column): string { if ($column->userTypeName !== null) { @@ -106,16 +117,6 @@ protected function compileUnsigned(): string return ''; } - public function createDatabase(string $name): Statement - { - throw new UnsupportedException('SQLite does not support CREATE DATABASE.'); - } - - public function dropDatabase(string $name): Statement - { - throw new UnsupportedException('SQLite does not support DROP DATABASE.'); - } - #[\Override] public function compileRename(string $from, string $to): Statement { @@ -136,9 +137,4 @@ public function dropIndex(string $table, string $name): Statement { return new Statement('DROP INDEX ' . $this->quote($name), [], executor: $this->executor); } - - public function renameIndex(string $table, string $from, string $to): Statement - { - throw new UnsupportedException('SQLite does not support renaming indexes directly.'); - } } diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index b46121a..52b09e2 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -7,59 +7,58 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; -use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; class Table { /** @var list */ - public private(set) array $columns = []; + public protected(set) array $columns = []; /** @var list */ - public private(set) array $indexes = []; + public protected(set) array $indexes = []; /** @var list */ - public private(set) array $foreignKeys = []; + public protected(set) array $foreignKeys = []; /** @var list */ - public private(set) array $dropColumns = []; + public protected(set) array $dropColumns = []; /** @var list */ - public private(set) array $renameColumns = []; + public protected(set) array $renameColumns = []; /** @var list */ - public private(set) array $dropIndexes = []; + public protected(set) array $dropIndexes = []; /** @var list */ - public private(set) array $dropForeignKeys = []; + public protected(set) array $dropForeignKeys = []; /** @var list Raw SQL column definitions (bypass typed Column objects) */ - public private(set) array $rawColumnDefs = []; + public protected(set) array $rawColumnDefs = []; /** @var list Raw SQL index definitions (bypass typed Index objects) */ - public private(set) array $rawIndexDefs = []; + public protected(set) array $rawIndexDefs = []; /** @var list */ - public private(set) array $checks = []; + public protected(set) array $checks = []; /** @var list */ - public private(set) array $compositePrimaryKey = []; + public protected(set) array $compositePrimaryKey = []; /** @var list ClickHouse ORDER BY columns; falls back to primary key when empty */ - public private(set) array $orderBy = []; + public protected(set) array $orderBy = []; - public private(set) ?PartitionType $partitionType = null; - public private(set) string $partitionExpression = ''; - public private(set) ?int $partitionCount = null; + public protected(set) ?PartitionType $partitionType = null; + public protected(set) string $partitionExpression = ''; + public protected(set) ?int $partitionCount = null; - public private(set) ?Engine $engine = null; + public protected(set) ?Engine $engine = null; /** @var list */ - public private(set) array $engineArgs = []; + public protected(set) array $engineArgs = []; - public private(set) ?string $ttl = null; + public protected(set) ?string $ttl = null; /** @var array Table-level engine SETTINGS (ClickHouse only) */ - public private(set) array $settings = []; + public protected(set) array $settings = []; public function __construct( private readonly ?Schema $schema = null, @@ -112,46 +111,27 @@ private function requireSchema(): Schema } /** - * Add a table-level CHECK constraint. - * - * @throws ValidationException if $name is not a valid identifier. + * Construct a Column instance. Dialect Table subclasses override to + * construct their dialect-specific {@see Column} subclass. */ - public function check(string $name, string $expression): static + protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column { - $this->checks[] = new CheckConstraint($name, $expression); - - return $this; + return new Column($this, $name, $type, $length, $precision); } /** - * Declare a composite PRIMARY KEY across two or more columns. - * - * For a single-column primary key, use {@see Column::primary()} instead. - * - * @param list $columns - * - * @throws ValidationException if fewer than two columns are provided or any column name is invalid. + * Construct a ForeignKey instance. Dialect Table subclasses that support + * foreign keys override to construct their dialect-specific + * {@see ForeignKey} subclass. */ - public function primary(array $columns): static + protected function newForeignKey(string $column): ForeignKey { - if (\count($columns) < 2) { - throw new ValidationException('Table::primary(array) requires at least two columns; use Column::primary() for single-column keys.'); - } - - foreach ($columns as $column) { - if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { - throw new ValidationException('Invalid column name in composite primary key: ' . $column); - } - } - - $this->compositePrimaryKey = $columns; - - return $this; + return new ForeignKey($this, $column); } public function id(string $name = 'id'): Column { - $col = new Column($this, $name, ColumnType::BigInteger); + $col = $this->newColumn($name, ColumnType::BigInteger); $col->unsigned()->autoIncrement()->primary(); $this->columns[] = $col; @@ -160,7 +140,7 @@ public function id(string $name = 'id'): Column public function string(string $name, int $length = 255): Column { - $col = new Column($this, $name, ColumnType::String, $length); + $col = $this->newColumn($name, ColumnType::String, $length); $this->columns[] = $col; return $col; @@ -168,7 +148,7 @@ public function string(string $name, int $length = 255): Column public function text(string $name): Column { - $col = new Column($this, $name, ColumnType::Text); + $col = $this->newColumn($name, ColumnType::Text); $this->columns[] = $col; return $col; @@ -176,7 +156,7 @@ public function text(string $name): Column public function mediumText(string $name): Column { - $col = new Column($this, $name, ColumnType::MediumText); + $col = $this->newColumn($name, ColumnType::MediumText); $this->columns[] = $col; return $col; @@ -184,7 +164,7 @@ public function mediumText(string $name): Column public function longText(string $name): Column { - $col = new Column($this, $name, ColumnType::LongText); + $col = $this->newColumn($name, ColumnType::LongText); $this->columns[] = $col; return $col; @@ -192,7 +172,7 @@ public function longText(string $name): Column public function integer(string $name): Column { - $col = new Column($this, $name, ColumnType::Integer); + $col = $this->newColumn($name, ColumnType::Integer); $this->columns[] = $col; return $col; @@ -200,46 +180,45 @@ public function integer(string $name): Column public function bigInteger(string $name): Column { - $col = new Column($this, $name, ColumnType::BigInteger); + $col = $this->newColumn($name, ColumnType::BigInteger); $this->columns[] = $col; return $col; } /** - * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT on MySQL; - * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT + * on MySQL; INTEGER on SQLite). Not exposed on ClickHouse/MongoDB. */ public function serial(string $name): Column { - $col = (new Column($this, $name, ColumnType::Serial)) - ->autoIncrement(); + $col = $this->newColumn($name, ColumnType::Serial)->autoIncrement(); $this->columns[] = $col; return $col; } /** - * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; BIGINT AUTO_INCREMENT on MySQL; - * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; + * BIGINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on + * ClickHouse/MongoDB. */ public function bigSerial(string $name): Column { - $col = (new Column($this, $name, ColumnType::BigSerial)) - ->autoIncrement(); + $col = $this->newColumn($name, ColumnType::BigSerial)->autoIncrement(); $this->columns[] = $col; return $col; } /** - * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; SMALLINT AUTO_INCREMENT on MySQL; - * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; + * SMALLINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on + * ClickHouse/MongoDB. */ public function smallSerial(string $name): Column { - $col = (new Column($this, $name, ColumnType::SmallSerial)) - ->autoIncrement(); + $col = $this->newColumn($name, ColumnType::SmallSerial)->autoIncrement(); $this->columns[] = $col; return $col; @@ -247,7 +226,7 @@ public function smallSerial(string $name): Column public function float(string $name): Column { - $col = new Column($this, $name, ColumnType::Float); + $col = $this->newColumn($name, ColumnType::Float); $this->columns[] = $col; return $col; @@ -255,7 +234,7 @@ public function float(string $name): Column public function boolean(string $name): Column { - $col = new Column($this, $name, ColumnType::Boolean); + $col = $this->newColumn($name, ColumnType::Boolean); $this->columns[] = $col; return $col; @@ -263,7 +242,7 @@ public function boolean(string $name): Column public function datetime(string $name, int $precision = 0): Column { - $col = new Column($this, $name, ColumnType::Datetime, precision: $precision); + $col = $this->newColumn($name, ColumnType::Datetime, precision: $precision); $this->columns[] = $col; return $col; @@ -271,7 +250,7 @@ public function datetime(string $name, int $precision = 0): Column public function timestamp(string $name, int $precision = 0): Column { - $col = new Column($this, $name, ColumnType::Timestamp, precision: $precision); + $col = $this->newColumn($name, ColumnType::Timestamp, precision: $precision); $this->columns[] = $col; return $col; @@ -279,7 +258,7 @@ public function timestamp(string $name, int $precision = 0): Column public function json(string $name): Column { - $col = new Column($this, $name, ColumnType::Json); + $col = $this->newColumn($name, ColumnType::Json); $this->columns[] = $col; return $col; @@ -287,7 +266,7 @@ public function json(string $name): Column public function binary(string $name): Column { - $col = new Column($this, $name, ColumnType::Binary); + $col = $this->newColumn($name, ColumnType::Binary); $this->columns[] = $col; return $col; @@ -304,8 +283,7 @@ public function enum(string $name, array $values): Column throw new ValidationException('enum() requires at least one allowed value.'); } - $col = (new Column($this, $name, ColumnType::Enum)) - ->enum($values); + $col = $this->newColumn($name, ColumnType::Enum)->enum($values); $this->columns[] = $col; return $col; @@ -313,8 +291,7 @@ public function enum(string $name, array $values): Column public function point(string $name, int $srid = 4326): Column { - $col = (new Column($this, $name, ColumnType::Point)) - ->srid($srid); + $col = $this->newColumn($name, ColumnType::Point)->srid($srid); $this->columns[] = $col; return $col; @@ -322,8 +299,7 @@ public function point(string $name, int $srid = 4326): Column public function linestring(string $name, int $srid = 4326): Column { - $col = (new Column($this, $name, ColumnType::Linestring)) - ->srid($srid); + $col = $this->newColumn($name, ColumnType::Linestring)->srid($srid); $this->columns[] = $col; return $col; @@ -331,17 +307,7 @@ public function linestring(string $name, int $srid = 4326): Column public function polygon(string $name, int $srid = 4326): Column { - $col = (new Column($this, $name, ColumnType::Polygon)) - ->srid($srid); - $this->columns[] = $col; - - return $col; - } - - public function vector(string $name, int $dimensions): Column - { - $col = (new Column($this, $name, ColumnType::Vector)) - ->dimensions($dimensions); + $col = $this->newColumn($name, ColumnType::Polygon)->srid($srid); $this->columns[] = $col; return $col; @@ -370,7 +336,7 @@ public function index( array $lengths = [], array $orders = [], array $collations = [], - ?IndexAlgorithm $algorithm = null, + ?ClickHouse\IndexAlgorithm $algorithm = null, array $algorithmArgs = [], ?int $granularity = null, ): static { @@ -415,51 +381,9 @@ public function uniqueIndex( return $this; } - /** - * @param string[] $columns - */ - public function fulltextIndex(array $columns, string $name = ''): static - { - if ($name === '') { - $name = $this->autoIndexName('ft_', $columns); - } - $this->indexes[] = new Index($name, $columns, IndexType::Fulltext); - - return $this; - } - - /** - * @param string[] $columns - */ - public function spatialIndex(array $columns, string $name = ''): static - { - if ($name === '') { - $name = $this->autoIndexName('sp_', $columns); - } - $this->indexes[] = new Index($name, $columns, IndexType::Spatial); - - return $this; - } - - /** - * Declare a foreign key. The behaviour is identical for create and alter - * contexts — the dialect compiler switches between `FOREIGN KEY (...)` (in - * a CREATE TABLE column list) and `ADD FOREIGN KEY (...)` (in an ALTER - * TABLE clause) when emitting the statement. {@see addForeignKey()} is - * an alias for use in alter chains; both register the same FK exactly once. - */ - public function foreignKey(string $column): ForeignKey - { - $fk = new ForeignKey($this, $column); - $this->foreignKeys[] = $fk; - - return $fk; - } - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { - $col = new Column( - $this, + $col = $this->newColumn( $name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, @@ -472,13 +396,12 @@ public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecisio public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { - $col = (new Column( - $this, + $col = $this->newColumn( $name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null, - ))->modify(); + )->modify(); $this->columns[] = $col; return $col; @@ -528,23 +451,6 @@ public function dropIndex(string $name): static return $this; } - /** - * Alias of {@see foreignKey()}, for symmetry with the other `add*`/`drop*` - * alter helpers. Returns the same registered {@see ForeignKey}; calling - * both methods for the same column registers the FK twice. - */ - public function addForeignKey(string $column): ForeignKey - { - return $this->foreignKey($column); - } - - public function dropForeignKey(string $name): static - { - $this->dropForeignKeys[] = $name; - - return $this; - } - /** * Add a raw SQL column definition (bypass typed Column objects). * @@ -569,114 +475,6 @@ public function rawIndex(string $definition): static return $this; } - public function partitionByRange(string $expression): static - { - $this->partitionType = PartitionType::Range; - $this->partitionExpression = $expression; - $this->partitionCount = null; - - return $this; - } - - public function partitionByList(string $expression): static - { - $this->partitionType = PartitionType::List; - $this->partitionExpression = $expression; - $this->partitionCount = null; - - return $this; - } - - /** - * Partition by hash of the given expression. - * - * When $partitions is non-null, the DDL emits `PARTITIONS `. Per - * MySQL/MariaDB semantics, this only applies to HASH (and KEY/LINEAR HASH/ - * LINEAR KEY variants) partitioning. - * - * @throws ValidationException if $partitions is less than 1. - */ - public function partitionByHash(string $expression, ?int $partitions = null): static - { - if ($partitions !== null && $partitions < 1) { - throw new ValidationException('Partition count must be at least 1.'); - } - - $this->partitionType = PartitionType::Hash; - $this->partitionExpression = $expression; - $this->partitionCount = $partitions; - - return $this; - } - - /** - * Select the table engine (ClickHouse only). Other dialects ignore this. - * - * Engine-specific arguments are validated against the engine variant: - * - CollapsingMergeTree requires exactly one sign column. - * - ReplicatedMergeTree requires a zookeeper path and replica name. - * - * @throws ValidationException if required engine arguments are missing. - */ - public function engine(Engine $engine, string ...$args): static - { - if ($engine === Engine::CollapsingMergeTree && ! isset($args[0])) { - throw new ValidationException('CollapsingMergeTree requires a sign column.'); - } - - if ($engine === Engine::ReplicatedMergeTree && (! isset($args[0]) || ! isset($args[1]))) { - throw new ValidationException('ReplicatedMergeTree requires zookeeper_path and replica_name.'); - } - - $this->engine = $engine; - $this->engineArgs = \array_values($args); - - return $this; - } - - /** - * Set the ClickHouse ORDER BY clause. When unset, ClickHouse falls back to the - * primary key columns. - * - * @param list $columns - * - * @throws ValidationException if any column name is not a valid identifier. - */ - public function orderBy(array $columns): static - { - foreach ($columns as $column) { - if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { - throw new ValidationException('Invalid column name in ORDER BY: ' . $column); - } - } - - $this->orderBy = $columns; - - return $this; - } - - /** - * Attach a table-level TTL expression (ClickHouse only). - * - * @throws ValidationException if the expression is empty or contains a semicolon. - */ - public function ttl(string $expression): static - { - $trimmed = \trim($expression); - - if ($trimmed === '') { - throw new ValidationException('TTL expression must not be empty.'); - } - - if (\str_contains($trimmed, ';')) { - throw new ValidationException('TTL expression must not contain ";".'); - } - - $this->ttl = $trimmed; - - return $this; - } - /** * Build an auto-generated index name with a prefix, sanitising any * non-identifier characters in the column names so the result is always a @@ -684,7 +482,7 @@ public function ttl(string $expression): static * * @param string[] $columns */ - private function autoIndexName(string $prefix, array $columns): string + protected function autoIndexName(string $prefix, array $columns): string { $sanitised = \array_map( fn (string $c): string => \preg_replace('/[^A-Za-z0-9_]+/', '_', $c) ?? $c, @@ -693,56 +491,4 @@ private function autoIndexName(string $prefix, array $columns): string return $prefix . \implode('_', $sanitised); } - - /** - * Set table-level engine SETTINGS (ClickHouse only). Other dialects ignore. - * - * Compiled as `SETTINGS k=v, ...` after the TTL clause. Booleans become - * `1` / `0`, ints/floats are stringified, strings are passed through after - * a conservative character allow-list check. - * - * Calling this method replaces previously-set settings. - * - * @param array $settings - * - * @throws ValidationException if any key is not a valid identifier or any - * string value contains characters outside the - * allow-list. - */ - public function settings(array $settings): static - { - $sanitized = []; - - foreach ($settings as $key => $value) { - if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { - throw new ValidationException('Invalid setting name: ' . $key); - } - - if (\is_bool($value)) { - $sanitized[$key] = $value ? '1' : '0'; - } elseif (\is_int($value)) { - $sanitized[$key] = (string) $value; - } elseif (\is_float($value)) { - // Avoid scientific notation (e.g. 1.0E-5), which ClickHouse - // rejects in SETTINGS values; trim trailing zeros for clean - // output. - $sanitized[$key] = \rtrim(\rtrim(\sprintf('%F', $value), '0'), '.'); - } elseif (\is_string($value)) { - if (! \preg_match('/^[A-Za-z0-9_.\-+\/]+$/', $value)) { - throw new ValidationException( - 'Invalid setting value for ' . $key . ': must match [A-Za-z0-9_.\-+/]+' - ); - } - $sanitized[$key] = $value; - } else { - throw new ValidationException( - 'Setting value for ' . $key . ' must be string, int, float, or bool.' - ); - } - } - - $this->settings = $sanitized; - - return $this; - } } diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php new file mode 100644 index 0000000..95f91f1 --- /dev/null +++ b/src/Query/Schema/Table/ClickHouse.php @@ -0,0 +1,319 @@ +newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $this->columns[] = $col; + + return $col; + } + + /** + * Select the table engine. Engine-specific arguments are validated against + * the engine variant: + * - CollapsingMergeTree requires exactly one sign column. + * - ReplicatedMergeTree requires a zookeeper path and replica name. + * + * @throws ValidationException if required engine arguments are missing. + */ + public function engine(Engine $engine, string ...$args): static + { + if ($engine === Engine::CollapsingMergeTree && ! isset($args[0])) { + throw new ValidationException('CollapsingMergeTree requires a sign column.'); + } + + if ($engine === Engine::ReplicatedMergeTree && (! isset($args[0]) || ! isset($args[1]))) { + throw new ValidationException('ReplicatedMergeTree requires zookeeper_path and replica_name.'); + } + + $this->engine = $engine; + $this->engineArgs = \array_values($args); + + return $this; + } + + /** + * Set the ORDER BY clause. When unset, ClickHouse falls back to the + * primary key columns. + * + * @param list $columns + * + * @throws ValidationException if any column name is not a valid identifier. + */ + public function orderBy(array $columns): static + { + foreach ($columns as $column) { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + throw new ValidationException('Invalid column name in ORDER BY: ' . $column); + } + } + + $this->orderBy = $columns; + + return $this; + } + + /** + * Attach a table-level TTL expression. + * + * @throws ValidationException if the expression is empty or contains a semicolon. + */ + public function ttl(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('TTL expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('TTL expression must not contain ";".'); + } + + $this->ttl = $trimmed; + + return $this; + } + + /** + * Set table-level engine SETTINGS. + * + * Compiled as `SETTINGS k=v, ...` after the TTL clause. Booleans become + * `1` / `0`, ints/floats are stringified, strings are passed through after + * a conservative character allow-list check. + * + * Calling this method replaces previously-set settings. + * + * @param array $settings + * + * @throws ValidationException if any key is not a valid identifier or any + * string value contains characters outside the + * allow-list. + */ + public function settings(array $settings): static + { + $sanitized = []; + + foreach ($settings as $key => $value) { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + throw new ValidationException('Invalid setting name: ' . $key); + } + + if (\is_bool($value)) { + $sanitized[$key] = $value ? '1' : '0'; + } elseif (\is_int($value)) { + $sanitized[$key] = (string) $value; + } elseif (\is_float($value)) { + $sanitized[$key] = \rtrim(\rtrim(\sprintf('%F', $value), '0'), '.'); + } elseif (\is_string($value)) { + if (! \preg_match('/^[A-Za-z0-9_.\-+\/]+$/', $value)) { + throw new ValidationException( + 'Invalid setting value for ' . $key . ': must match [A-Za-z0-9_.\-+/]+' + ); + } + $sanitized[$key] = $value; + } else { + throw new ValidationException( + 'Setting value for ' . $key . ' must be string, int, float, or bool.' + ); + } + } + + $this->settings = $sanitized; + + return $this; + } + + /** + * Partition the table by an expression. ClickHouse uses a single expression + * (no Range/List/Hash distinction in the DDL) — the expression itself + * determines the partition shape (e.g. `toYYYYMM(event_date)`). + */ + public function partitionBy(string $expression): static + { + $this->partitionType = PartitionType::Range; + $this->partitionExpression = $expression; + $this->partitionCount = null; + + return $this; + } +} diff --git a/src/Query/Schema/Table/MongoDB.php b/src/Query/Schema/Table/MongoDB.php new file mode 100644 index 0000000..5825a15 --- /dev/null +++ b/src/Query/Schema/Table/MongoDB.php @@ -0,0 +1,184 @@ +newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $this->columns[] = $col; + + return $col; + } +} diff --git a/src/Query/Schema/Table/MySQL.php b/src/Query/Schema/Table/MySQL.php new file mode 100644 index 0000000..67a0f05 --- /dev/null +++ b/src/Query/Schema/Table/MySQL.php @@ -0,0 +1,199 @@ +newForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function addForeignKey(string $column): ForeignKey\MySQL + { + return $this->foreignKey($column); + } +} diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php new file mode 100644 index 0000000..5c2d445 --- /dev/null +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -0,0 +1,210 @@ +newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $this->columns[] = $col; + + return $col; + } + + public function foreignKey(string $column): ForeignKey\PostgreSQL + { + $fk = $this->newForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function addForeignKey(string $column): ForeignKey\PostgreSQL + { + return $this->foreignKey($column); + } +} diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php new file mode 100644 index 0000000..39e0784 --- /dev/null +++ b/src/Query/Schema/Table/SQLite.php @@ -0,0 +1,197 @@ +newForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function addForeignKey(string $column): ForeignKey\SQLite + { + return $this->foreignKey($column); + } +} diff --git a/src/Query/Schema/Table/Trait/Checks.php b/src/Query/Schema/Table/Trait/Checks.php new file mode 100644 index 0000000..431db54 --- /dev/null +++ b/src/Query/Schema/Table/Trait/Checks.php @@ -0,0 +1,21 @@ +checks[] = new CheckConstraint($name, $expression); + + return $this; + } +} diff --git a/src/Query/Schema/Table/Trait/CompositePrimary.php b/src/Query/Schema/Table/Trait/CompositePrimary.php new file mode 100644 index 0000000..938fe64 --- /dev/null +++ b/src/Query/Schema/Table/Trait/CompositePrimary.php @@ -0,0 +1,34 @@ + $columns + * + * @throws ValidationException if fewer than two columns are provided or any column name is invalid. + */ + public function primary(array $columns): static + { + if (\count($columns) < 2) { + throw new ValidationException('Table::primary(array) requires at least two columns; use Column::primary() for single-column keys.'); + } + + foreach ($columns as $column) { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + throw new ValidationException('Invalid column name in composite primary key: ' . $column); + } + } + + $this->compositePrimaryKey = $columns; + + return $this; + } +} diff --git a/src/Query/Schema/Table/Trait/ForeignKeys.php b/src/Query/Schema/Table/Trait/ForeignKeys.php new file mode 100644 index 0000000..a4e8dcb --- /dev/null +++ b/src/Query/Schema/Table/Trait/ForeignKeys.php @@ -0,0 +1,40 @@ +newForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + /** + * Alias of {@see foreignKey()}, for symmetry with the other `add*`/`drop*` + * alter helpers. Returns the same registered {@see ForeignKey}; calling + * both methods for the same column registers the FK twice. + */ + public function addForeignKey(string $column): ForeignKey + { + return $this->foreignKey($column); + } + + public function dropForeignKey(string $name): static + { + $this->dropForeignKeys[] = $name; + + return $this; + } +} diff --git a/src/Query/Schema/Table/Trait/FulltextSpatialIndex.php b/src/Query/Schema/Table/Trait/FulltextSpatialIndex.php new file mode 100644 index 0000000..64c61a6 --- /dev/null +++ b/src/Query/Schema/Table/Trait/FulltextSpatialIndex.php @@ -0,0 +1,35 @@ +autoIndexName('ft_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Fulltext); + + return $this; + } + + /** + * @param string[] $columns + */ + public function spatialIndex(array $columns, string $name = ''): static + { + if ($name === '') { + $name = $this->autoIndexName('sp_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Spatial); + + return $this; + } +} diff --git a/src/Query/Schema/Table/Trait/StandardPartitioning.php b/src/Query/Schema/Table/Trait/StandardPartitioning.php new file mode 100644 index 0000000..9e6b671 --- /dev/null +++ b/src/Query/Schema/Table/Trait/StandardPartitioning.php @@ -0,0 +1,49 @@ +partitionType = PartitionType::Range; + $this->partitionExpression = $expression; + $this->partitionCount = null; + + return $this; + } + + public function partitionByList(string $expression): static + { + $this->partitionType = PartitionType::List; + $this->partitionExpression = $expression; + $this->partitionCount = null; + + return $this; + } + + /** + * Partition by hash of the given expression. + * + * When $partitions is non-null, the DDL emits `PARTITIONS `. Per + * MySQL/MariaDB semantics, this only applies to HASH (and KEY/LINEAR HASH/ + * LINEAR KEY variants) partitioning. + * + * @throws ValidationException if $partitions is less than 1. + */ + public function partitionByHash(string $expression, ?int $partitions = null): static + { + if ($partitions !== null && $partitions < 1) { + throw new ValidationException('Partition count must be at least 1.'); + } + + $this->partitionType = PartitionType::Hash; + $this->partitionExpression = $expression; + $this->partitionCount = $partitions; + + return $this; + } +} diff --git a/src/Query/Schema/Trait/AnalyzeTable.php b/src/Query/Schema/Trait/AnalyzeTable.php new file mode 100644 index 0000000..bb7af8c --- /dev/null +++ b/src/Query/Schema/Trait/AnalyzeTable.php @@ -0,0 +1,13 @@ +quote($table), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/Trait/Databases.php b/src/Query/Schema/Trait/Databases.php new file mode 100644 index 0000000..61e0227 --- /dev/null +++ b/src/Query/Schema/Trait/Databases.php @@ -0,0 +1,18 @@ +quote($name), [], executor: $this->executor); + } + + public function dropDatabase(string $name): Statement + { + return new Statement('DROP DATABASE ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/Trait/ForeignKeys.php b/src/Query/Schema/Trait/ForeignKeys.php new file mode 100644 index 0000000..8e60ee2 --- /dev/null +++ b/src/Query/Schema/Trait/ForeignKeys.php @@ -0,0 +1,44 @@ +quote($table) + . ' ADD CONSTRAINT ' . $this->quote($name) + . ' FOREIGN KEY (' . $this->quote($column) . ')' + . ' REFERENCES ' . $this->quote($refTable) + . ' (' . $this->quote($refColumn) . ')'; + + if ($onDelete !== null) { + $sql .= ' ON DELETE ' . $onDelete->toSql(); + } + if ($onUpdate !== null) { + $sql .= ' ON UPDATE ' . $onUpdate->toSql(); + } + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropForeignKey(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP FOREIGN KEY ' . $this->quote($name), + [], + executor: $this->executor, + ); + } +} diff --git a/src/Query/Schema/Trait/Partitioning.php b/src/Query/Schema/Trait/Partitioning.php new file mode 100644 index 0000000..9d17bcf --- /dev/null +++ b/src/Query/Schema/Trait/Partitioning.php @@ -0,0 +1,23 @@ +partitionType === null) { + return ''; + } + + $sql = 'PARTITION BY ' . $table->partitionType->value . '(' . $table->partitionExpression . ')'; + + if ($table->partitionCount !== null) { + $sql .= ' PARTITIONS ' . $table->partitionCount; + } + + return $sql; + } +} diff --git a/src/Query/Schema/Trait/Procedures.php b/src/Query/Schema/Trait/Procedures.php new file mode 100644 index 0000000..b1d61ee --- /dev/null +++ b/src/Query/Schema/Trait/Procedures.php @@ -0,0 +1,57 @@ + $params + * @return list + */ + protected function compileProcedureParams(array $params): array + { + $paramList = []; + foreach ($params as $param) { + $direction = $param[0]->value; + $name = $this->quote($param[1]); + + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $param[2])) { + throw new ValidationException('Invalid procedure parameter type: ' . $param[2]); + } + + $paramList[] = $direction . ' ' . $name . ' ' . $param[2]; + } + + return $paramList; + } + + /** + * Create a stored procedure. + * + * $body is emitted verbatim into the generated DDL and must come from + * trusted (developer-controlled) source — never from untrusted input. + * + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): Statement + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE PROCEDURE ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' BEGIN ' . $body . ' END'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropProcedure(string $name): Statement + { + return new Statement('DROP PROCEDURE ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/Trait/RenameIndex.php b/src/Query/Schema/Trait/RenameIndex.php new file mode 100644 index 0000000..e925cc1 --- /dev/null +++ b/src/Query/Schema/Trait/RenameIndex.php @@ -0,0 +1,17 @@ +quote($table) . ' RENAME INDEX ' . $this->quote($from) . ' TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } +} diff --git a/src/Query/Schema/Trait/ReplaceView.php b/src/Query/Schema/Trait/ReplaceView.php new file mode 100644 index 0000000..1cf780d --- /dev/null +++ b/src/Query/Schema/Trait/ReplaceView.php @@ -0,0 +1,17 @@ +build(); + $sql = 'CREATE OR REPLACE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new Statement($sql, $result->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Schema/Trait/Triggers.php b/src/Query/Schema/Trait/Triggers.php new file mode 100644 index 0000000..885c047 --- /dev/null +++ b/src/Query/Schema/Trait/Triggers.php @@ -0,0 +1,36 @@ +quote($name) + . ' ' . $timing->value . ' ' . $event->value + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW BEGIN ' . $body . ' END'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropTrigger(string $name): Statement + { + return new Statement('DROP TRIGGER ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/Trait/Views.php b/src/Query/Schema/Trait/Views.php new file mode 100644 index 0000000..bccb305 --- /dev/null +++ b/src/Query/Schema/Trait/Views.php @@ -0,0 +1,22 @@ +build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new Statement($sql, $result->bindings, executor: $this->executor); + } + + public function dropView(string $name): Statement + { + return new Statement('DROP VIEW ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/tests/Integration/Schema/ClickHouseIntegrationTest.php b/tests/Integration/Schema/ClickHouseIntegrationTest.php index 2c6f394..47a05f5 100644 --- a/tests/Integration/Schema/ClickHouseIntegrationTest.php +++ b/tests/Integration/Schema/ClickHouseIntegrationTest.php @@ -297,7 +297,7 @@ public function testCreateTableWithPartitionBy(): void $result = $this->schema->table($table) ->integer('id')->primary() ->datetime('ts') - ->partitionByHash('toYYYYMM(`ts`)') + ->partitionBy('toYYYYMM(`ts`)') ->create(); $this->assertStringContainsString('PARTITION BY toYYYYMM(`ts`)', $result->query); diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php index 852ad04..468b3ce 100644 --- a/tests/Query/Schema/ClickHouseTest.php +++ b/tests/Query/Schema/ClickHouseTest.php @@ -101,17 +101,6 @@ public function testCreateTableWithSpatialTypes(): void $this->assertSame('CREATE TABLE `geo` (`coords` Tuple(Float64, Float64), `path` Array(Tuple(Float64, Float64)), `area` Array(Array(Tuple(Float64, Float64)))) ENGINE = MergeTree() ORDER BY tuple()', $result->query); } - public function testCreateTableForeignKeyThrows(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); - - $schema = new Schema(); - $schema->table('t') - ->foreignKey('user_id')->references('id')->on('users') - ->create(); - } - public function testCreateTableWithIndex(): void { $schema = new Schema(); @@ -169,17 +158,6 @@ public function testAlterDropColumn(): void $this->assertSame('ALTER TABLE `events` DROP COLUMN `old_col`', $result->query); } - public function testAlterForeignKeyThrows(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); - - $schema = new Schema(); - $schema->table('events') - ->addForeignKey('user_id')->references('id')->on('users') - ->alter(); - } - public function testDropTable(): void { $schema = new Schema(); @@ -394,16 +372,6 @@ public function testCreateTableWithCompositeIndex(): void $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, `type` String, INDEX `idx_name_type` (`name`, `type`) TYPE minmax GRANULARITY 3) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); } - public function testAlterForeignKeyStillThrows(): void - { - $this->expectException(UnsupportedException::class); - - $schema = new Schema(); - $schema->table('events') - ->dropForeignKey('fk_old') - ->alter(); - } - public function testExactCreateTableWithEngine(): void { $schema = new Schema(); @@ -496,7 +464,7 @@ public function testCreateTableWithPartition(): void ->bigInteger('id')->primary() ->string('name') ->datetime('created_at', 3) - ->partitionByRange('toYYYYMM(created_at)') + ->partitionBy('toYYYYMM(created_at)') ->create(); $this->assertBindingCount($result); diff --git a/tests/Query/Schema/FluentBuilderTest.php b/tests/Query/Schema/FluentBuilderTest.php index fc05ecd..5705b1a 100644 --- a/tests/Query/Schema/FluentBuilderTest.php +++ b/tests/Query/Schema/FluentBuilderTest.php @@ -8,16 +8,17 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\ClickHouse; use Utopia\Query\Schema\ClickHouse\Engine; -use Utopia\Query\Schema\Column; +use Utopia\Query\Schema\Column\PostgreSQL as Column; use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\ForeignKey; +use Utopia\Query\Schema\ForeignKey\PostgreSQL as ForeignKey; use Utopia\Query\Schema\ForeignKeyAction; use Utopia\Query\Schema\IndexType; use Utopia\Query\Schema\MongoDB; use Utopia\Query\Schema\MySQL; use Utopia\Query\Schema\PostgreSQL; use Utopia\Query\Schema\SQLite; -use Utopia\Query\Schema\Table; +use Utopia\Query\Schema\Table as BaseTable; +use Utopia\Query\Schema\Table\PostgreSQL as Table; /** * Behavioural tests for the fluent Schema builder. Covers: @@ -38,7 +39,7 @@ public function testTableEntryPointReturnsTableBoundToSchema(): void $schema = new MySQL(); $table = $schema->table('users'); - $this->assertInstanceOf(Table::class, $table); + $this->assertInstanceOf(BaseTable::class, $table); $this->assertSame('users', $table->name); } @@ -257,15 +258,15 @@ public function testColumnForwardsPartitionFamily(): void public function testColumnForwardsEngineAndOrderByAndTtl(): void { - $bp = new Table(); + $bp = (new ClickHouse())->table('events'); $bp->integer('id') ->engine(Engine::MergeTree) ->orderBy(['id']) - ->ttl('id + INTERVAL 1 DAY'); + ->partitionBy('toYYYYMM(id)'); $this->assertSame(Engine::MergeTree, $bp->engine); $this->assertSame(['id'], $bp->orderBy); - $this->assertSame('id + INTERVAL 1 DAY', $bp->ttl); + $this->assertSame('toYYYYMM(id)', $bp->partitionExpression); } public function testColumnPrimaryNoArgsMarksColumn(): void @@ -615,7 +616,7 @@ public function testClickHouseEndToEndExampleFromReadme(): void public function testTableOrderByRejectsInvalidIdentifier(): void { - $bp = new Table(); + $bp = (new ClickHouse())->table('events'); $this->expectException(ValidationException::class); $this->expectExceptionMessage('Invalid column name in ORDER BY'); @@ -645,31 +646,6 @@ public function testTablePrimaryArrayWithColumnLevelPrimaryThrowsOnCompile(): vo ->create(); } - public function testTableTtlOnNonClickHouseSchemaThrowsAtCompile(): void - { - $schema = new MySQL(); - - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('TTL is only supported in ClickHouse'); - - $schema->table('events') - ->integer('id') - ->ttl('id + INTERVAL 1 DAY') - ->create(); - } - - public function testColumnTtlOnNonClickHouseSchemaThrowsAtCompile(): void - { - $schema = new MySQL(); - - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('TTL is only supported in ClickHouse'); - - $schema->table('events') - ->datetime('ts')->ttl('ts + INTERVAL 1 DAY') - ->create(); - } - public function testColumnAfterIsHonouredOnAlter(): void { $schema = new MySQL(); @@ -820,7 +796,7 @@ public function testMultipleTablesFromSameSchemaCompileIndependently(): void public function testEngineWithRequiredArgsValidates(): void { - $bp = new Table(); + $bp = (new ClickHouse())->table('events'); $this->expectException(ValidationException::class); $this->expectExceptionMessage('CollapsingMergeTree requires a sign column'); @@ -829,7 +805,7 @@ public function testEngineWithRequiredArgsValidates(): void public function testReplicatedMergeTreeRequiresArgs(): void { - $bp = new Table(); + $bp = (new ClickHouse())->table('events'); $this->expectException(ValidationException::class); $this->expectExceptionMessage('ReplicatedMergeTree requires zookeeper_path and replica_name'); @@ -921,20 +897,20 @@ public function testColumnEnumStringDispatchRejectsMissingValueList(): void public function testDeepChainOfHeterogeneousMethodsCompiles(): void { $schema = new MySQL(); - $stmt = $schema->table('mixed') - ->id() + $table = $schema->table('mixed'); + $table->id() ->string('a')->nullable() ->integer('b')->default(7) ->datetime('c', 6) ->boolean('d') - ->json('e') - ->index(['a', 'b']) + ->json('e'); + $table->index(['a', 'b']) ->uniqueIndex(['a']) - ->fulltextIndex(['e'], 'ft_e') - ->foreignKey('b')->references('id')->on('parents')->onDelete(ForeignKeyAction::Cascade) - ->check('b_positive', '`b` >= 0') - ->rawColumn('`raw_col` TEXT') - ->create(); + ->fulltextIndex(['e'], 'ft_e'); + $table->foreignKey('b')->references('id')->on('parents')->onDelete(ForeignKeyAction::Cascade); + $table->check('b_positive', '`b` >= 0'); + $table->rawColumn('`raw_col` TEXT'); + $stmt = $table->create(); $this->assertBindingCount($stmt); $this->assertStringContainsString('PRIMARY KEY (`id`)', $stmt->query); @@ -1145,19 +1121,6 @@ public function testForeignKeyForwardsPartitionFamily(): void $this->assertSame(4, $c->partitionCount); } - public function testForeignKeyForwardsEngineAndOrderByAndTtl(): void - { - $bp = new Table(); - $bp->integer('id')->integer('user_id'); - $bp->foreignKey('user_id')->engine(Engine::MergeTree); - $bp->foreignKey('user_id')->orderBy(['id']); - $bp->foreignKey('user_id')->ttl('id + INTERVAL 1 DAY'); - - $this->assertSame(Engine::MergeTree, $bp->engine); - $this->assertSame(['id'], $bp->orderBy); - $this->assertSame('id + INTERVAL 1 DAY', $bp->ttl); - } - public function testForeignKeyForwardsAllTerminals(): void { $schema = new MySQL(); diff --git a/tests/Query/Schema/ForwarderTest.php b/tests/Query/Schema/ForwarderTest.php new file mode 100644 index 0000000..0b246ba --- /dev/null +++ b/tests/Query/Schema/ForwarderTest.php @@ -0,0 +1,224 @@ +table('orders'); + $col = $table->integer('user_id'); + + $this->assertInstanceOf(Column\MySQL::class, $col); + + $fk = $col->foreignKey('user_id'); + $this->assertInstanceOf(ForeignKey\MySQL::class, $fk); + $this->assertSame($fk, $table->foreignKeys[0]); + + $alias = $col->addForeignKey('user_id'); + $this->assertInstanceOf(ForeignKey\MySQL::class, $alias); + $this->assertCount(2, $table->foreignKeys); + + $this->assertSame($table, $col->dropForeignKey('fk_old')); + $this->assertSame(['fk_old'], $table->dropForeignKeys); + + $this->assertSame($table, $col->partitionByRange('toYear(created_at)')); + $this->assertSame($table, $col->partitionByList('region')); + $this->assertSame($table, $col->partitionByHash('id', 4)); + $this->assertSame(4, $table->partitionCount); + + $this->assertSame($table, $col->fulltextIndex(['body'], 'ft_body')); + $this->assertSame($table, $col->spatialIndex(['location'], 'sp_loc')); + } + + public function testMySQLColumnDualPurposePrimaryAndCheck(): void + { + $table = (new MySQL())->table('order_items'); + $a = $table->integer('order_id'); + $b = $table->integer('product_id'); + + // No-args primary stays on the column. + $this->assertSame($a, $a->primary()); + $this->assertTrue($a->isPrimary); + + // Array form delegates to table. + $this->assertSame($table, $b->primary(['order_id', 'product_id'])); + $this->assertSame(['order_id', 'product_id'], $table->compositePrimaryKey); + + // Single-arg check stays on the column. + $this->assertSame($a, $a->check('`order_id` > 0')); + $this->assertSame('`order_id` > 0', $a->checkExpression); + + // Two-arg check delegates to table. + $this->assertSame($table, $b->check('product_positive', '`product_id` > 0')); + $this->assertCount(1, $table->checks); + $this->assertSame('product_positive', $table->checks[0]->name); + } + + public function testMySQLForeignKeyForwarderExposesAllMethods(): void + { + $table = (new MySQL())->table('orders'); + $table->integer('user_id'); + $fk = $table->foreignKey('user_id'); + + $second = $fk->foreignKey('order_id'); + $this->assertInstanceOf(ForeignKey\MySQL::class, $second); + $this->assertCount(2, $table->foreignKeys); + + $third = $fk->addForeignKey('promo_id'); + $this->assertInstanceOf(ForeignKey\MySQL::class, $third); + + $this->assertSame($table, $fk->dropForeignKey('fk_old')); + $this->assertSame($table, $fk->partitionByRange('toYear(created_at)')); + $this->assertSame($table, $fk->partitionByList('region')); + $this->assertSame($table, $fk->partitionByHash('id', 8)); + $this->assertSame(8, $table->partitionCount); + + $this->assertSame($table, $fk->fulltextIndex(['body'])); + $this->assertSame($table, $fk->spatialIndex(['location'])); + + // Table-level forms only — no dual purpose on FK. + $this->assertSame($table, $fk->primary(['a', 'b'])); + $this->assertSame(['a', 'b'], $table->compositePrimaryKey); + + $this->assertSame($table, $fk->check('age_min', '`age` >= 18')); + $this->assertCount(1, $table->checks); + } + + public function testPostgreSQLColumnForwarderIncludesVector(): void + { + $table = (new PostgreSQL())->table('items'); + $col = $table->integer('id'); + + $this->assertInstanceOf(Column\PostgreSQL::class, $col); + + $vector = $col->vector('embedding', 1536); + $this->assertInstanceOf(Column\PostgreSQL::class, $vector); + $this->assertSame(1536, $vector->dimensions); + + $this->assertSame($table, $col->partitionByRange('created_at')); + $this->assertSame($table, $col->partitionByList('region')); + $this->assertSame($table, $col->partitionByHash('id', 2)); + $this->assertSame($table, $col->fulltextIndex(['body'])); + $this->assertSame($table, $col->spatialIndex(['location'])); + + $fk = $col->foreignKey('order_id'); + $this->assertInstanceOf(ForeignKey\PostgreSQL::class, $fk); + $this->assertSame($table, $col->dropForeignKey('fk_old')); + } + + public function testSQLiteColumnForwarderHasOnlyForeignKeysAndDualPurpose(): void + { + $table = (new SQLite())->table('orders'); + $col = $table->integer('user_id'); + + $this->assertInstanceOf(Column\SQLite::class, $col); + + $fk = $col->foreignKey('user_id'); + $this->assertInstanceOf(ForeignKey\SQLite::class, $fk); + + $this->assertInstanceOf(ForeignKey\SQLite::class, $col->addForeignKey('order_id')); + $this->assertSame($table, $col->dropForeignKey('fk_old')); + + // Dual-purpose primary/check from the dialect Column class. + $this->assertSame($col, $col->primary()); + $this->assertTrue($col->isPrimary); + + $second = $table->integer('a'); + $this->assertSame($table, $second->primary(['a', 'b'])); + $this->assertSame(['a', 'b'], $table->compositePrimaryKey); + + $this->assertSame($col, $col->check('`user_id` > 0')); + $this->assertSame('`user_id` > 0', $col->checkExpression); + + $this->assertSame($table, $second->check('age_min', '`age` >= 18')); + $this->assertCount(1, $table->checks); + } + + public function testSQLiteForeignKeyForwarderTableLevelMethods(): void + { + $table = (new SQLite())->table('orders'); + $table->integer('user_id'); + $fk = $table->foreignKey('user_id'); + + $this->assertSame($table, $fk->primary(['a', 'b'])); + $this->assertSame($table, $fk->check('age_min', '`age` >= 18')); + } + + public function testClickHouseColumnForwarderEngineOrderBySettingsPartition(): void + { + $table = (new ClickHouse())->table('events'); + $col = $table->integer('id'); + + $this->assertInstanceOf(Column\ClickHouse::class, $col); + + $vector = $col->vector('embedding', 768); + $this->assertInstanceOf(Column\ClickHouse::class, $vector); + $this->assertSame(768, $vector->dimensions); + + $this->assertSame($table, $col->engine(Engine::MergeTree)); + $this->assertSame(Engine::MergeTree, $table->engine); + + $this->assertSame($table, $col->orderBy(['id'])); + $this->assertSame(['id'], $table->orderBy); + + $this->assertSame($table, $col->settings(['index_granularity' => 8192])); + $this->assertSame(['index_granularity' => '8192'], $table->settings); + + $this->assertSame($table, $col->partitionBy('toYYYYMM(created_at)')); + $this->assertSame('toYYYYMM(created_at)', $table->partitionExpression); + } + + public function testClickHouseColumnPrimaryDualPurpose(): void + { + $table = (new ClickHouse())->table('events'); + $col = $table->integer('id'); + + // Column-level primary stays on the column. + $this->assertSame($col, $col->primary()); + $this->assertTrue($col->isPrimary); + + // ClickHouse supports composite primary via the array form. + $a = $table->integer('a'); + $this->assertSame($table, $a->primary(['a', 'b'])); + $this->assertSame(['a', 'b'], $table->compositePrimaryKey); + } + + public function testMongoDBColumnForwarderExposesVectorOnly(): void + { + $table = (new MongoDB())->table('items'); + $col = $table->integer('id'); + + $this->assertInstanceOf(Column\MongoDB::class, $col); + + $vector = $col->vector('embedding', 384); + $this->assertInstanceOf(Column\MongoDB::class, $vector); + $this->assertSame(384, $vector->dimensions); + } + + public function testTableEntryPointReturnsDialectSpecificType(): void + { + $this->assertInstanceOf(Table\MySQL::class, (new MySQL())->table('t')); + $this->assertInstanceOf(Table\PostgreSQL::class, (new PostgreSQL())->table('t')); + $this->assertInstanceOf(Table\SQLite::class, (new SQLite())->table('t')); + $this->assertInstanceOf(Table\ClickHouse::class, (new ClickHouse())->table('t')); + $this->assertInstanceOf(Table\MongoDB::class, (new MongoDB())->table('t')); + } +} diff --git a/tests/Query/Schema/MongoDBTest.php b/tests/Query/Schema/MongoDBTest.php index 25a760d..1f17d8a 100644 --- a/tests/Query/Schema/MongoDBTest.php +++ b/tests/Query/Schema/MongoDBTest.php @@ -107,20 +107,6 @@ public function testCreateCollectionWithRequired(): void $this->assertNotContains('email', $required); } - public function testCreateCollectionRejectsCompositePrimaryKey(): void - { - $schema = new Schema(); - - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Composite primary keys are not supported in MongoDB'); - - $schema->table('order_items') - ->integer('order_id') - ->integer('product_id') - ->primary(['order_id', 'product_id']) - ->create(); - } - public function testDrop(): void { $schema = new Schema(); diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php index 0db7e24..afd4b78 100644 --- a/tests/Query/Schema/MySQLTest.php +++ b/tests/Query/Schema/MySQLTest.php @@ -164,17 +164,6 @@ public function testCreateTableWithSpatialTypes(): void $this->assertSame('CREATE TABLE `locations` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `coords` POINT SRID 4326 NOT NULL, `path` LINESTRING SRID 4326 NOT NULL, `area` POLYGON SRID 4326 NOT NULL, PRIMARY KEY (`id`))', $result->query); } - public function testCreateTableVectorThrows(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Vector type is not supported in MySQL.'); - - $schema = new Schema(); - $schema->table('embeddings') - ->vector('embedding', 768) - ->create(); - } - public function testCreateTableWithComment(): void { $schema = new Schema(); diff --git a/tests/Query/Schema/SQLiteTest.php b/tests/Query/Schema/SQLiteTest.php index 246ed52..b89630c 100644 --- a/tests/Query/Schema/SQLiteTest.php +++ b/tests/Query/Schema/SQLiteTest.php @@ -10,13 +10,8 @@ use Utopia\Query\Query; use Utopia\Query\Schema\ColumnType; use Utopia\Query\Schema\Feature\ForeignKeys; -use Utopia\Query\Schema\Feature\Procedures; -use Utopia\Query\Schema\Feature\Triggers; use Utopia\Query\Schema\ForeignKeyAction; -use Utopia\Query\Schema\ParameterDirection; use Utopia\Query\Schema\SQLite as Schema; -use Utopia\Query\Schema\TriggerEvent; -use Utopia\Query\Schema\TriggerTiming; class SQLiteTest extends TestCase { @@ -27,16 +22,6 @@ public function testImplementsForeignKeys(): void $this->assertInstanceOf(ForeignKeys::class, new Schema()); } - public function testImplementsProcedures(): void - { - $this->assertInstanceOf(Procedures::class, new Schema()); - } - - public function testImplementsTriggers(): void - { - $this->assertInstanceOf(Triggers::class, new Schema()); - } - public function testCreateTableBasic(): void { $schema = new Schema(); @@ -176,17 +161,6 @@ public function testColumnTypeUuid7MapsToVarchar36(): void $this->assertSame('CREATE TABLE `t` (`uid` VARCHAR(36) NOT NULL)', $result->query); } - public function testColumnTypeVectorThrowsUnsupported(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Vector type is not supported in SQLite.'); - - $schema = new Schema(); - $schema->table('t') - ->vector('embedding', 768) - ->create(); - } - public function testAutoIncrementUsesAutoincrement(): void { $schema = new Schema(); @@ -210,24 +184,6 @@ public function testUnsignedIsEmptyString(): void $this->assertStringNotContainsString('UNSIGNED', $result->query); } - public function testCreateDatabaseThrowsUnsupported(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('SQLite does not support CREATE DATABASE.'); - - $schema = new Schema(); - $schema->createDatabase('mydb'); - } - - public function testDropDatabaseThrowsUnsupported(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('SQLite does not support DROP DATABASE.'); - - $schema = new Schema(); - $schema->dropDatabase('mydb'); - } - public function testRenameUsesAlterTable(): void { $schema = new Schema(); @@ -261,15 +217,6 @@ public function testDropIndexWithoutTableName(): void $this->assertSame([], $result->bindings); } - public function testRenameIndexThrowsUnsupported(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('SQLite does not support renaming indexes directly.'); - - $schema = new Schema(); - $schema->renameIndex('users', 'old_idx', 'new_idx'); - } - public function testCreateTableWithNullableAndDefault(): void { $schema = new Schema(); @@ -400,19 +347,6 @@ public function testCreateView(): void $this->assertSame([true], $result->bindings); } - public function testCreateOrReplaceView(): void - { - $schema = new Schema(); - $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); - $result = $schema->createOrReplaceView('active_users', $builder); - - $this->assertSame( - 'CREATE OR REPLACE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', - $result->query - ); - $this->assertSame([true], $result->bindings); - } - public function testDropView(): void { $schema = new Schema(); @@ -460,54 +394,6 @@ public function testDropForeignKeyStandalone(): void ); } - public function testCreateProcedure(): void - { - $schema = new Schema(); - $result = $schema->createProcedure( - 'update_stats', - params: [[ParameterDirection::In, 'user_id', 'INT'], [ParameterDirection::Out, 'total', 'INT']], - body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' - ); - - $this->assertSame( - 'CREATE PROCEDURE `update_stats`(IN `user_id` INT, OUT `total` INT) BEGIN SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id; END', - $result->query - ); - } - - public function testDropProcedure(): void - { - $schema = new Schema(); - $result = $schema->dropProcedure('update_stats'); - - $this->assertSame('DROP PROCEDURE `update_stats`', $result->query); - } - - public function testCreateTrigger(): void - { - $schema = new Schema(); - $result = $schema->createTrigger( - 'trg_updated_at', - 'users', - timing: TriggerTiming::Before, - event: TriggerEvent::Update, - body: 'SET NEW.updated_at = datetime();' - ); - - $this->assertSame( - 'CREATE TRIGGER `trg_updated_at` BEFORE UPDATE ON `users` FOR EACH ROW BEGIN SET NEW.updated_at = datetime(); END', - $result->query - ); - } - - public function testDropTrigger(): void - { - $schema = new Schema(); - $result = $schema->dropTrigger('trg_updated_at'); - - $this->assertSame('DROP TRIGGER `trg_updated_at`', $result->query); - } - public function testCreateTableWithMultiplePrimaryKeys(): void { $schema = new Schema(); diff --git a/tests/Query/Schema/TableTest.php b/tests/Query/Schema/TableTest.php index 873a0f7..2cdbeac 100644 --- a/tests/Query/Schema/TableTest.php +++ b/tests/Query/Schema/TableTest.php @@ -10,7 +10,7 @@ use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Index; use Utopia\Query\Schema\RenameColumn; -use Utopia\Query\Schema\Table; +use Utopia\Query\Schema\Table\PostgreSQL as Table; class TableTest extends TestCase { From 1fec919f96b7b46e73ded2b07a7b546d94df6da5 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 12:33:49 +1200 Subject: [PATCH 02/11] refactor(builder): split Spatial/FullTextSearch/Upsert per dialect Pulls Spatial, FullTextSearch, and Upsert off the SQL abstract base so SQLite stops inheriting filterDistance/filterSearch/etc. that only threw at compile time. Each SQL dialect now opts in via implements + use of the matching trait. The Upsert interface is split into three single-method interfaces: Upsert (upsert), InsertOrIgnore (insertOrIgnore), and UpsertSelect (upsertSelect). MongoDB implements Upsert + InsertOrIgnore (it has real impls) and no longer exposes the upsertSelect() that was a runtime UnsupportedException. The default-throw upsert() on the base Builder class is removed; builders opt in. PostgreSQL aliases the trait methods (baseUpsert/baseUpsertSelect) so its overrides can wrap with appendReturning while still sharing the trait body. Tests: drop now-impossible UnsupportedException assertions for SQLite spatial/search and MongoDB upsertSelect. --- src/Query/Builder.php | 9 ---- src/Query/Builder/Feature/InsertOrIgnore.php | 10 +++++ src/Query/Builder/Feature/Upsert.php | 4 -- src/Query/Builder/Feature/UpsertSelect.php | 10 +++++ src/Query/Builder/MongoDB.php | 9 +--- src/Query/Builder/MySQL.php | 22 ++++++++- src/Query/Builder/PostgreSQL.php | 45 +++++++++++++++---- src/Query/Builder/SQL.php | 10 +---- src/Query/Builder/SQLite.php | 7 ++- src/Query/Builder/Trait/Upsert.php | 38 ---------------- src/Query/Builder/Trait/UpsertSelect.php | 45 +++++++++++++++++++ tests/Query/Builder/MongoDBTest.php | 12 ----- tests/Query/Builder/SQLiteTest.php | 21 --------- .../Regression/CorrectnessRegressionTest.php | 25 ----------- 14 files changed, 131 insertions(+), 136 deletions(-) create mode 100644 src/Query/Builder/Feature/InsertOrIgnore.php create mode 100644 src/Query/Builder/Feature/UpsertSelect.php create mode 100644 src/Query/Builder/Trait/UpsertSelect.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b2c5298..48ce8b6 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -390,15 +390,6 @@ private function compileWhenCondition(WhenClause $when): string } } - /** - * Build an INSERT ... ON CONFLICT/DUPLICATE KEY UPDATE statement. - * Requires onConflict() to be called first to configure conflict keys and update columns. - */ - public function upsert(): Statement - { - throw new UnsupportedException('UPSERT is not supported by this dialect.'); - } - #[\Override] public function build(): Statement { diff --git a/src/Query/Builder/Feature/InsertOrIgnore.php b/src/Query/Builder/Feature/InsertOrIgnore.php new file mode 100644 index 0000000..70375e4 --- /dev/null +++ b/src/Query/Builder/Feature/InsertOrIgnore.php @@ -0,0 +1,10 @@ +bindings = []; @@ -436,12 +437,6 @@ public function insertOrIgnore(): Statement ); } - #[\Override] - public function upsertSelect(): Statement - { - throw new UnsupportedException('upsertSelect() is not supported in MongoDB builder.'); - } - private function needsAggregation(ParsedQuery $grouped): bool { if (! empty(Query::getByType($this->pendingQueries, [Method::OrderRandom], false))) { diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php index 563036e..4ffb62d 100644 --- a/src/Query/Builder/MySQL.php +++ b/src/Query/Builder/MySQL.php @@ -3,21 +3,41 @@ namespace Utopia\Query\Builder; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\FullTextSearch; use Utopia\Query\Builder\Feature\GroupByModifiers; use Utopia\Query\Builder\Feature\Hints; +use Utopia\Query\Builder\Feature\InsertOrIgnore; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\LateralJoins; +use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StringAggregates; +use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\Feature\UpsertSelect; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; -class MySQL extends SQL implements Json, Hints, ConditionalAggregates, LateralJoins, StringAggregates, GroupByModifiers +class MySQL extends SQL implements + Json, + Hints, + ConditionalAggregates, + LateralJoins, + StringAggregates, + GroupByModifiers, + Spatial, + FullTextSearch, + Upsert, + UpsertSelect, + InsertOrIgnore { use Trait\ConditionalAggregates; + use Trait\FullTextSearch; use Trait\GroupByModifiers; use Trait\Hints; use Trait\LateralJoins; + use Trait\Spatial; use Trait\StringAggregates; + use Trait\Upsert; + use Trait\UpsertSelect; protected string $updateJoinTable = ''; diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php index 8585c8c..42c7fe0 100644 --- a/src/Query/Builder/PostgreSQL.php +++ b/src/Query/Builder/PostgreSQL.php @@ -6,7 +6,9 @@ use Utopia\Query\AST\Serializer\PostgreSQL as PostgreSQLSerializer; use Utopia\Query\Builder\Feature\ConditionalAggregates; use Utopia\Query\Builder\Feature\FullOuterJoins; +use Utopia\Query\Builder\Feature\FullTextSearch; use Utopia\Query\Builder\Feature\GroupByModifiers; +use Utopia\Query\Builder\Feature\InsertOrIgnore; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\LateralJoins; use Utopia\Query\Builder\Feature\PostgreSQL\AggregateFilter; @@ -17,8 +19,11 @@ use Utopia\Query\Builder\Feature\PostgreSQL\Returning; use Utopia\Query\Builder\Feature\PostgreSQL\VectorSearch; use Utopia\Query\Builder\Feature\Sequences; +use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StringAggregates; use Utopia\Query\Builder\Feature\TableSampling; +use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\Feature\UpsertSelect; use Utopia\Query\Builder\PostgreSQL\DeleteUsing; use Utopia\Query\Builder\PostgreSQL\MergeTarget; use Utopia\Query\Builder\PostgreSQL\UpdateFrom; @@ -27,9 +32,30 @@ use Utopia\Query\Query; use Utopia\Query\Schema\ColumnType; -class PostgreSQL extends SQL implements VectorSearch, Json, Returning, LockingOf, ConditionalAggregates, Merge, LateralJoins, TableSampling, FullOuterJoins, StringAggregates, OrderedSetAggregates, DistinctOn, AggregateFilter, GroupByModifiers, Sequences +class PostgreSQL extends SQL implements + VectorSearch, + Json, + Returning, + LockingOf, + ConditionalAggregates, + Merge, + LateralJoins, + TableSampling, + FullOuterJoins, + StringAggregates, + OrderedSetAggregates, + DistinctOn, + AggregateFilter, + GroupByModifiers, + Sequences, + Spatial, + FullTextSearch, + Upsert, + UpsertSelect, + InsertOrIgnore { use Trait\FullOuterJoins; + use Trait\FullTextSearch; use Trait\GroupByModifiers; use Trait\LateralJoins; use Trait\PostgreSQL\AggregateFilter; @@ -40,7 +66,14 @@ class PostgreSQL extends SQL implements VectorSearch, Json, Returning, LockingOf use Trait\PostgreSQL\Sequences; use Trait\PostgreSQL\VectorSearch; use Trait\Returning; + use Trait\Spatial; use Trait\StringAggregates; + use Trait\Upsert { + upsert as private baseUpsert; + } + use Trait\UpsertSelect { + upsertSelect as private baseUpsertSelect; + } protected string $wrapChar = '"'; @@ -337,20 +370,14 @@ private function mergeIntoWhereClause(array &$parts, array $extra): void $parts[] = 'WHERE ' . \implode(' AND ', $extra); } - #[\Override] public function upsert(): Statement { - $result = parent::upsert(); - - return $this->appendReturning($result); + return $this->appendReturning($this->baseUpsert()); } - #[\Override] public function upsertSelect(): Statement { - $result = parent::upsertSelect(); - - return $this->appendReturning($result); + return $this->appendReturning($this->baseUpsertSelect()); } #[\Override] diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php index edf87b4..d55ede9 100644 --- a/src/Query/Builder/SQL.php +++ b/src/Query/Builder/SQL.php @@ -4,34 +4,26 @@ use Utopia\Query\Builder as BaseBuilder; use Utopia\Query\Builder\Feature\BitwiseAggregates; -use Utopia\Query\Builder\Feature\FullTextSearch; use Utopia\Query\Builder\Feature\Locking; -use Utopia\Query\Builder\Feature\Spatial; use Utopia\Query\Builder\Feature\StatisticalAggregates; use Utopia\Query\Builder\Feature\Transactions; -use Utopia\Query\Builder\Feature\Upsert; use Utopia\Query\Method; use Utopia\Query\Query; use Utopia\Query\QuotesIdentifiers; use Utopia\Query\Schema\ColumnType; -abstract class SQL extends BaseBuilder implements Locking, Transactions, Upsert, Spatial, FullTextSearch, StatisticalAggregates, BitwiseAggregates +abstract class SQL extends BaseBuilder implements Locking, Transactions, StatisticalAggregates, BitwiseAggregates { use QuotesIdentifiers; use Trait\BitwiseAggregates; - use Trait\FullTextSearch; use Trait\Json; use Trait\Locking; - use Trait\Spatial; use Trait\StatisticalAggregates; use Trait\Transactions; - use Trait\Upsert; /** @var array */ protected array $jsonSets = []; - abstract public function insertOrIgnore(): Statement; - abstract protected function compileConflictHeader(): string; abstract protected function compileConflictAssignment(string $wrapped): string; diff --git a/src/Query/Builder/SQLite.php b/src/Query/Builder/SQLite.php index 4625fab..04441cf 100644 --- a/src/Query/Builder/SQLite.php +++ b/src/Query/Builder/SQLite.php @@ -5,16 +5,21 @@ use Utopia\Query\AST\Serializer; use Utopia\Query\AST\Serializer\SQLite as SQLiteSerializer; use Utopia\Query\Builder\Feature\ConditionalAggregates; +use Utopia\Query\Builder\Feature\InsertOrIgnore; use Utopia\Query\Builder\Feature\Json; use Utopia\Query\Builder\Feature\StringAggregates; +use Utopia\Query\Builder\Feature\Upsert; +use Utopia\Query\Builder\Feature\UpsertSelect; use Utopia\Query\Exception\UnsupportedException; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; -class SQLite extends SQL implements Json, ConditionalAggregates, StringAggregates +class SQLite extends SQL implements Json, ConditionalAggregates, StringAggregates, InsertOrIgnore, Upsert, UpsertSelect { use Trait\ConditionalAggregates; use Trait\StringAggregates; + use Trait\Upsert; + use Trait\UpsertSelect; /** @var array */ protected array $jsonSets = []; diff --git a/src/Query/Builder/Trait/Upsert.php b/src/Query/Builder/Trait/Upsert.php index c539e8d..a81a42b 100644 --- a/src/Query/Builder/Trait/Upsert.php +++ b/src/Query/Builder/Trait/Upsert.php @@ -7,7 +7,6 @@ trait Upsert { - #[\Override] public function upsert(): Statement { $this->bindings = []; @@ -62,41 +61,4 @@ public function upsert(): Statement return new Statement($sql, $this->bindings, executor: $this->executor); } - - #[\Override] - public function upsertSelect(): Statement - { - $this->bindings = []; - $this->validateTable(); - - if ($this->insertSelectSource === null) { - throw new ValidationException('No SELECT source specified. Call fromSelect() before upsertSelect().'); - } - if (empty($this->insertSelectColumns)) { - throw new ValidationException('No columns specified. Call fromSelect() with columns before upsertSelect().'); - } - if (empty($this->conflictKeys)) { - throw new ValidationException('No conflict keys specified. Call onConflict() before upsertSelect().'); - } - if (empty($this->conflictUpdateColumns)) { - throw new ValidationException('No conflict update columns specified. Call onConflict() with update columns before upsertSelect().'); - } - - $wrappedColumns = \array_map( - fn (string $col): string => $this->resolveAndWrap($col), - $this->insertSelectColumns - ); - - $sourceResult = $this->insertSelectSource->build(); - - $sql = 'INSERT INTO ' . $this->quote($this->table) - . ' (' . \implode(', ', $wrappedColumns) . ')' - . ' ' . $sourceResult->query; - - $this->addBindings($sourceResult->bindings); - - $sql .= ' ' . $this->compileConflictClause(); - - return new Statement($sql, $this->bindings, executor: $this->executor); - } } diff --git a/src/Query/Builder/Trait/UpsertSelect.php b/src/Query/Builder/Trait/UpsertSelect.php new file mode 100644 index 0000000..a87499e --- /dev/null +++ b/src/Query/Builder/Trait/UpsertSelect.php @@ -0,0 +1,45 @@ +bindings = []; + $this->validateTable(); + + if ($this->insertSelectSource === null) { + throw new ValidationException('No SELECT source specified. Call fromSelect() before upsertSelect().'); + } + if (empty($this->insertSelectColumns)) { + throw new ValidationException('No columns specified. Call fromSelect() with columns before upsertSelect().'); + } + if (empty($this->conflictKeys)) { + throw new ValidationException('No conflict keys specified. Call onConflict() before upsertSelect().'); + } + if (empty($this->conflictUpdateColumns)) { + throw new ValidationException('No conflict update columns specified. Call onConflict() with update columns before upsertSelect().'); + } + + $wrappedColumns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $this->insertSelectColumns + ); + + $sourceResult = $this->insertSelectSource->build(); + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' ' . $sourceResult->query; + + $this->addBindings($sourceResult->bindings); + + $sql .= ' ' . $this->compileConflictClause(); + + return new Statement($sql, $this->bindings, executor: $this->executor); + } +} diff --git a/tests/Query/Builder/MongoDBTest.php b/tests/Query/Builder/MongoDBTest.php index 4d5347c..ed96b4c 100644 --- a/tests/Query/Builder/MongoDBTest.php +++ b/tests/Query/Builder/MongoDBTest.php @@ -1587,18 +1587,6 @@ public function testContainsAnyOnString(): void $this->assertSame(['php', 'js'], $result->bindings); } - public function testUpsertSelectThrowsException(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('upsertSelect() is not supported in MongoDB builder.'); - - (new Builder()) - ->into('users') - ->set(['name' => 'Alice', 'email' => 'a@b.com']) - ->onConflict(['email'], ['name']) - ->upsertSelect(); - } - public function testUpsertWithoutExplicitUpdateColumns(): void { $result = (new Builder()) diff --git a/tests/Query/Builder/SQLiteTest.php b/tests/Query/Builder/SQLiteTest.php index c75599d..55f0789 100644 --- a/tests/Query/Builder/SQLiteTest.php +++ b/tests/Query/Builder/SQLiteTest.php @@ -435,17 +435,6 @@ public function testSpatialNotIntersectsThrowsUnsupported(): void ->build(); } - public function testSpatialCoversThrowsUnsupported(): void - { - $this->expectException(UnsupportedException::class); - $this->expectExceptionMessage('Spatial covers predicates are not supported in SQLite.'); - - (new Builder()) - ->from('t') - ->filterCovers('area', [1.0, 2.0]) - ->build(); - } - public function testFilterJsonContains(): void { $result = (new Builder()) @@ -830,16 +819,6 @@ public function testSpatialDistanceGreaterThanThrows(): void ->build(); } - public function testSpatialEqualsThrows(): void - { - $this->expectException(UnsupportedException::class); - - (new Builder()) - ->from('t') - ->filterSpatialEquals('area', [1.0, 2.0]) - ->build(); - } - public function testSpatialCrossesThrows(): void { $this->expectException(UnsupportedException::class); diff --git a/tests/Query/Regression/CorrectnessRegressionTest.php b/tests/Query/Regression/CorrectnessRegressionTest.php index d51cb87..84cda98 100644 --- a/tests/Query/Regression/CorrectnessRegressionTest.php +++ b/tests/Query/Regression/CorrectnessRegressionTest.php @@ -390,31 +390,6 @@ public function testUnsupportedJoinMethodMatchThrows(): void ); } - public function testBaseUpsertThrowsUnsupported(): void - { - $builder = new class () extends Builder { - use \Utopia\Query\Builder\Trait\Selects; - - protected function quote(string $identifier): string - { - return '`' . $identifier . '`'; - } - - protected function compileRandom(): string - { - return 'RAND()'; - } - - protected function compileRegex(string $attribute, array $values): string - { - return $attribute . ' REGEXP ?'; - } - }; - - $this->expectException(UnsupportedException::class); - $builder->from('t')->upsert(); - } - public function testMongoDbHasNoForUpdate(): void { $this->assertFalse( From 69ed5168cd1beeff9da5f3397e634882abf5a067 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 12:46:41 +1200 Subject: [PATCH 03/11] refactor(schema): drop ClickHouse PartitionType::Range sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClickHouse only uses partitionExpression — the PartitionType enum is meaningless for it. Gate the partition emission on the expression itself instead of flipping a Range sentinel just to satisfy a non-null check. --- src/Query/Schema/ClickHouse.php | 2 +- src/Query/Schema/Table/ClickHouse.php | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php index 9528ba7..ced5544 100644 --- a/src/Query/Schema/ClickHouse.php +++ b/src/Query/Schema/ClickHouse.php @@ -212,7 +212,7 @@ public function compileCreate(Table $table, bool $ifNotExists = false): Statemen . ' (' . \implode(', ', $columnDefs) . ')' . ' ENGINE = ' . $this->compileEngine($engine, $table->engineArgs); - if ($table->partitionType !== null) { + if ($table->partitionExpression !== '') { $sql .= ' PARTITION BY ' . $table->partitionExpression; } diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php index 95f91f1..d11d9f0 100644 --- a/src/Query/Schema/Table/ClickHouse.php +++ b/src/Query/Schema/Table/ClickHouse.php @@ -6,7 +6,6 @@ use Utopia\Query\Schema\ClickHouse\Engine; use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\PartitionType; use Utopia\Query\Schema\Table; class ClickHouse extends Table @@ -310,9 +309,7 @@ public function settings(array $settings): static */ public function partitionBy(string $expression): static { - $this->partitionType = PartitionType::Range; $this->partitionExpression = $expression; - $this->partitionCount = null; return $this; } From 2751bb46f12f45da07912e10fd13e84f034d479e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 12:53:46 +1200 Subject: [PATCH 04/11] fix(schema): drop PARTITIONS N from PostgreSQL hash partitioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Schema\Trait\Partitioning emits `PARTITIONS N` after the PARTITION BY clause when a count is set — but that's MySQL-only syntax. PostgreSQL rejects it: hash partitions are declared with separate `CREATE TABLE … PARTITION OF parent FOR VALUES WITH (modulus N, remainder R)` statements. Override compileCreatePartitioning in Schema\PostgreSQL to omit the count clause so $table->partitionByHash($expr, 4) silently drops the 4 instead of generating invalid DDL. Includes a regression test asserting the count is not emitted. --- .claude/scheduled_tasks.lock | 1 + src/Query/Schema/PostgreSQL.php | 15 +++++++++++++++ tests/Query/Schema/PostgreSQLTest.php | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..61a5f4e --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"1d4a85b1-8448-43af-af56-fde14c42dbc3","pid":21827,"procStart":"Fri May 8 10:26:06 2026","acquiredAt":1778287476666} \ No newline at end of file diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index c89b1e7..44ed577 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -588,4 +588,19 @@ public function dropPartition(string $table, string $name): Statement executor: $this->executor, ); } + + /** + * PostgreSQL accepts `PARTITION BY {RANGE|LIST|HASH} (expr)` but not the + * `PARTITIONS N` count modifier — that is MySQL-only. Override the shared + * trait to omit the count. + */ + #[\Override] + public function compileCreatePartitioning(Table $table): string + { + if ($table->partitionType === null) { + return ''; + } + + return 'PARTITION BY ' . $table->partitionType->value . '(' . $table->partitionExpression . ')'; + } } diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index a0f3ba3..8f748a7 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -1028,6 +1028,24 @@ public function testCreateTableWithPartitionByHash(): void $this->assertSame('CREATE TABLE "events" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY ("id")) PARTITION BY HASH(id)', $result->query); } + public function testCreateTableWithPartitionByHashIgnoresCount(): void + { + // PostgreSQL does not support `PARTITIONS N` (MySQL-only). The count + // argument must be silently dropped from the generated DDL. + $schema = new Schema(); + $result = $schema->table('events') + ->id() + ->partitionByHash('id', 4) + ->create(); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE "events" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY ("id")) PARTITION BY HASH(id)', + $result->query, + ); + $this->assertStringNotContainsString('PARTITIONS', $result->query); + } + public function testAlterWithForeignKeyOnDeleteAndUpdate(): void { $schema = new Schema(); From 31c95b6138c8d5b4dd8650ec8aa6f929c820e15a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 12:54:21 +1200 Subject: [PATCH 05/11] chore: ignore .claude/ runtime files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .claude/ directory holds Claude Code's runtime state (worktrees, scheduled task lockfiles) — none of it belongs in the repo. Broaden the existing .claude/worktrees/ ignore to .claude/ and untrack the scheduled_tasks.lock that slipped in. --- .claude/scheduled_tasks.lock | 1 - .gitignore | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 61a5f4e..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"1d4a85b1-8448-43af-af56-fde14c42dbc3","pid":21827,"procStart":"Fri May 8 10:26:06 2026","acquiredAt":1778287476666} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6598de5..ae6950b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ composer.phar coverage coverage.xml .DS_Store -.claude/worktrees/ +.claude/ .phpunit.cache/ coverage/ From bc4db3f37dc451dc57d3c753c56761afc726b51b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 13:01:30 +1200 Subject: [PATCH 06/11] fix(schema): drop SQLite Feature\ForeignKeys (ALTER FK not supported) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Schema\Trait\ForeignKeys emits ALTER TABLE ADD CONSTRAINT and ALTER TABLE DROP FOREIGN KEY — neither is valid SQLite. Schema\SQLite should not implement Feature\ForeignKeys; users that need foreign keys on SQLite declare them inline at CREATE TABLE time via the Table-level \$table->foreignKey() helper, which Table\SQLite still supports. Removes the schema-level addForeignKey/dropForeignKey from SQLite and the corresponding tests that asserted invalid SQLite DDL. --- src/Query/Schema/SQLite.php | 4 +-- tests/Query/Schema/SQLiteTest.php | 45 ------------------------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/src/Query/Schema/SQLite.php b/src/Query/Schema/SQLite.php index 25c0cd1..868c66a 100644 --- a/src/Query/Schema/SQLite.php +++ b/src/Query/Schema/SQLite.php @@ -4,12 +4,10 @@ use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\UnsupportedException; -use Utopia\Query\Schema\Feature\ForeignKeys; use Utopia\Query\Schema\Feature\Views; -class SQLite extends SQL implements ForeignKeys, Views +class SQLite extends SQL implements Views { - use Trait\ForeignKeys; use Trait\Views; #[\Override] diff --git a/tests/Query/Schema/SQLiteTest.php b/tests/Query/Schema/SQLiteTest.php index b89630c..52dd196 100644 --- a/tests/Query/Schema/SQLiteTest.php +++ b/tests/Query/Schema/SQLiteTest.php @@ -9,7 +9,6 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\Feature\ForeignKeys; use Utopia\Query\Schema\ForeignKeyAction; use Utopia\Query\Schema\SQLite as Schema; @@ -17,11 +16,6 @@ class SQLiteTest extends TestCase { use AssertsBindingCount; - public function testImplementsForeignKeys(): void - { - $this->assertInstanceOf(ForeignKeys::class, new Schema()); - } - public function testCreateTableBasic(): void { $schema = new Schema(); @@ -355,45 +349,6 @@ public function testDropView(): void $this->assertSame('DROP VIEW `active_users`', $result->query); } - public function testAddForeignKeyStandalone(): void - { - $schema = new Schema(); - $result = $schema->addForeignKey( - 'orders', - 'fk_user', - 'user_id', - 'users', - 'id', - onDelete: ForeignKeyAction::Cascade, - onUpdate: ForeignKeyAction::SetNull - ); - - $this->assertSame( - 'ALTER TABLE `orders` ADD CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', - $result->query - ); - } - - public function testAddForeignKeyNoActions(): void - { - $schema = new Schema(); - $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id'); - - $this->assertStringNotContainsString('ON DELETE', $result->query); - $this->assertStringNotContainsString('ON UPDATE', $result->query); - } - - public function testDropForeignKeyStandalone(): void - { - $schema = new Schema(); - $result = $schema->dropForeignKey('orders', 'fk_user'); - - $this->assertSame( - 'ALTER TABLE `orders` DROP FOREIGN KEY `fk_user`', - $result->query - ); - } - public function testCreateTableWithMultiplePrimaryKeys(): void { $schema = new Schema(); From bff8e071efc51692a45c9a9a16fb5844726e8325 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 18:25:13 +1200 Subject: [PATCH 07/11] refactor(schema): templatize Table/Column/ForeignKey, drop covariant override boilerplate Make Table generic over , Column over , and ForeignKey over with call-site covariance on the Table bound. Each dialect binds the templates via @extends, so PHPStan now narrows the return of $table->id() / $fk->id() / etc. to the dialect's Column subclass without per-dialect parent::id() pass-through overrides. Net 688 lines of pure-narrowing boilerplate deleted across MySQL, PostgreSQL, SQLite, MongoDB, ClickHouse Table/Column/ForeignKey classes. The @property \$table hacks on Column\X and ForeignKey\X are also gone. Trait\ForeignKeys is now templated over TForeignKey so foreignKey()/addForeignKey() return the dialect FK type when used via @use Trait\ForeignKeys. --- src/Query/Schema/Column.php | 15 ++ src/Query/Schema/Column/ClickHouse.php | 2 +- src/Query/Schema/Column/MongoDB.php | 2 +- src/Query/Schema/Column/MySQL.php | 2 +- src/Query/Schema/Column/PostgreSQL.php | 2 +- src/Query/Schema/Column/SQLite.php | 2 +- src/Query/Schema/ForeignKey.php | 38 ++++ src/Query/Schema/ForeignKey/MySQL.php | 3 +- src/Query/Schema/ForeignKey/PostgreSQL.php | 3 +- src/Query/Schema/ForeignKey/SQLite.php | 3 +- src/Query/Schema/Table.php | 67 +++++-- src/Query/Schema/Table/ClickHouse.php | 161 +---------------- src/Query/Schema/Table/MongoDB.php | 161 +---------------- src/Query/Schema/Table/MySQL.php | 174 +------------------ src/Query/Schema/Table/PostgreSQL.php | 174 +------------------ src/Query/Schema/Table/SQLite.php | 174 +------------------ src/Query/Schema/Table/Trait/ForeignKeys.php | 7 + 17 files changed, 147 insertions(+), 843 deletions(-) diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php index 06c9c43..6ff60ee 100644 --- a/src/Query/Schema/Column.php +++ b/src/Query/Schema/Column.php @@ -6,6 +6,9 @@ use Utopia\Query\Exception\ValidationException; use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; +/** + * @template TTable of Table = Table + */ class Column { public protected(set) bool $isNullable = false; @@ -51,6 +54,9 @@ class Column public protected(set) ?string $userTypeName = null; + /** + * @param TTable $table + */ public function __construct( public Table $table, public string $name, @@ -363,6 +369,7 @@ public function polygon(string $name, int $srid = 4326): static return $this->table->polygon($name, $srid); } + /** @return TTable */ public function timestamps(int $precision = 3): Table { return $this->table->timestamps($precision); @@ -380,11 +387,13 @@ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPreci return $this->table->modifyColumn($name, $type, $lengthOrPrecision); } + /** @return TTable */ public function renameColumn(string $from, string $to): Table { return $this->table->renameColumn($from, $to); } + /** @return TTable */ public function dropColumn(string $name): Table { return $this->table->dropColumn($name); @@ -396,6 +405,7 @@ public function dropColumn(string $name): Table * @param array $orders * @param array $collations * @param list $algorithmArgs ClickHouse skip-index algorithm args + * @return TTable */ public function index( array $columns, @@ -428,6 +438,7 @@ public function index( * @param array $lengths * @param array $orders * @param array $collations + * @return TTable */ public function uniqueIndex( array $columns, @@ -445,6 +456,7 @@ public function uniqueIndex( * @param array $orders * @param array $collations * @param list $rawColumns + * @return TTable */ public function addIndex( string $name, @@ -460,16 +472,19 @@ public function addIndex( return $this->table->addIndex($name, $columns, $type, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); } + /** @return TTable */ public function dropIndex(string $name): Table { return $this->table->dropIndex($name); } + /** @return TTable */ public function rawColumn(string $definition): Table { return $this->table->rawColumn($definition); } + /** @return TTable */ public function rawIndex(string $definition): Table { return $this->table->rawIndex($definition); diff --git a/src/Query/Schema/Column/ClickHouse.php b/src/Query/Schema/Column/ClickHouse.php index c477feb..14528a9 100644 --- a/src/Query/Schema/Column/ClickHouse.php +++ b/src/Query/Schema/Column/ClickHouse.php @@ -7,7 +7,7 @@ use Utopia\Query\Schema\Table; /** - * @property Table\ClickHouse $table + * @extends Column */ class ClickHouse extends Column { diff --git a/src/Query/Schema/Column/MongoDB.php b/src/Query/Schema/Column/MongoDB.php index 57dfb41..7dc230c 100644 --- a/src/Query/Schema/Column/MongoDB.php +++ b/src/Query/Schema/Column/MongoDB.php @@ -7,7 +7,7 @@ use Utopia\Query\Schema\Table; /** - * @property Table\MongoDB $table + * @extends Column */ class MongoDB extends Column { diff --git a/src/Query/Schema/Column/MySQL.php b/src/Query/Schema/Column/MySQL.php index a1231ad..6a34499 100644 --- a/src/Query/Schema/Column/MySQL.php +++ b/src/Query/Schema/Column/MySQL.php @@ -7,7 +7,7 @@ use Utopia\Query\Schema\Table; /** - * @property Table\MySQL $table + * @extends Column */ class MySQL extends Column { diff --git a/src/Query/Schema/Column/PostgreSQL.php b/src/Query/Schema/Column/PostgreSQL.php index 10e5dd1..aeafb50 100644 --- a/src/Query/Schema/Column/PostgreSQL.php +++ b/src/Query/Schema/Column/PostgreSQL.php @@ -7,7 +7,7 @@ use Utopia\Query\Schema\Table; /** - * @property Table\PostgreSQL $table + * @extends Column */ class PostgreSQL extends Column { diff --git a/src/Query/Schema/Column/SQLite.php b/src/Query/Schema/Column/SQLite.php index fb000f5..39649c2 100644 --- a/src/Query/Schema/Column/SQLite.php +++ b/src/Query/Schema/Column/SQLite.php @@ -7,7 +7,7 @@ use Utopia\Query\Schema\Table; /** - * @property Table\SQLite $table + * @extends Column */ class SQLite extends Column { diff --git a/src/Query/Schema/ForeignKey.php b/src/Query/Schema/ForeignKey.php index 5148d3a..2cfb079 100644 --- a/src/Query/Schema/ForeignKey.php +++ b/src/Query/Schema/ForeignKey.php @@ -4,6 +4,10 @@ use Utopia\Query\Builder\Statement; +/** + * @template TColumn of Column = Column + * @template TTable of Table = Table + */ class ForeignKey { public protected(set) string $refTable = ''; @@ -14,6 +18,9 @@ class ForeignKey public protected(set) ?ForeignKeyAction $onUpdate = null; + /** + * @param TTable $table + */ public function __construct( public readonly Table $table, public readonly string $column, @@ -48,81 +55,97 @@ public function onUpdate(ForeignKeyAction $action): static return $this; } + /** @return TColumn */ public function id(string $name = 'id'): Column { return $this->table->id($name); } + /** @return TColumn */ public function string(string $name, int $length = 255): Column { return $this->table->string($name, $length); } + /** @return TColumn */ public function text(string $name): Column { return $this->table->text($name); } + /** @return TColumn */ public function mediumText(string $name): Column { return $this->table->mediumText($name); } + /** @return TColumn */ public function longText(string $name): Column { return $this->table->longText($name); } + /** @return TColumn */ public function integer(string $name): Column { return $this->table->integer($name); } + /** @return TColumn */ public function bigInteger(string $name): Column { return $this->table->bigInteger($name); } + /** @return TColumn */ public function serial(string $name): Column { return $this->table->serial($name); } + /** @return TColumn */ public function bigSerial(string $name): Column { return $this->table->bigSerial($name); } + /** @return TColumn */ public function smallSerial(string $name): Column { return $this->table->smallSerial($name); } + /** @return TColumn */ public function float(string $name): Column { return $this->table->float($name); } + /** @return TColumn */ public function boolean(string $name): Column { return $this->table->boolean($name); } + /** @return TColumn */ public function datetime(string $name, int $precision = 0): Column { return $this->table->datetime($name, $precision); } + /** @return TColumn */ public function timestamp(string $name, int $precision = 0): Column { return $this->table->timestamp($name, $precision); } + /** @return TColumn */ public function json(string $name): Column { return $this->table->json($name); } + /** @return TColumn */ public function binary(string $name): Column { return $this->table->binary($name); @@ -130,47 +153,56 @@ public function binary(string $name): Column /** * @param string[] $values + * @return TColumn */ public function enum(string $name, array $values): Column { return $this->table->enum($name, $values); } + /** @return TColumn */ public function point(string $name, int $srid = 4326): Column { return $this->table->point($name, $srid); } + /** @return TColumn */ public function linestring(string $name, int $srid = 4326): Column { return $this->table->linestring($name, $srid); } + /** @return TColumn */ public function polygon(string $name, int $srid = 4326): Column { return $this->table->polygon($name, $srid); } + /** @return TTable */ public function timestamps(int $precision = 3): Table { return $this->table->timestamps($precision); } + /** @return TColumn */ public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { return $this->table->addColumn($name, $type, $lengthOrPrecision); } + /** @return TColumn */ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { return $this->table->modifyColumn($name, $type, $lengthOrPrecision); } + /** @return TTable */ public function renameColumn(string $from, string $to): Table { return $this->table->renameColumn($from, $to); } + /** @return TTable */ public function dropColumn(string $name): Table { return $this->table->dropColumn($name); @@ -181,6 +213,7 @@ public function dropColumn(string $name): Table * @param array $lengths * @param array $orders * @param array $collations + * @return TTable */ public function index( array $columns, @@ -199,6 +232,7 @@ public function index( * @param array $lengths * @param array $orders * @param array $collations + * @return TTable */ public function uniqueIndex( array $columns, @@ -216,6 +250,7 @@ public function uniqueIndex( * @param array $orders * @param array $collations * @param list $rawColumns + * @return TTable */ public function addIndex( string $name, @@ -231,16 +266,19 @@ public function addIndex( return $this->table->addIndex($name, $columns, $type, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); } + /** @return TTable */ public function dropIndex(string $name): Table { return $this->table->dropIndex($name); } + /** @return TTable */ public function rawColumn(string $definition): Table { return $this->table->rawColumn($definition); } + /** @return TTable */ public function rawIndex(string $definition): Table { return $this->table->rawIndex($definition); diff --git a/src/Query/Schema/ForeignKey/MySQL.php b/src/Query/Schema/ForeignKey/MySQL.php index 0b8f937..6b2db42 100644 --- a/src/Query/Schema/ForeignKey/MySQL.php +++ b/src/Query/Schema/ForeignKey/MySQL.php @@ -2,12 +2,13 @@ namespace Utopia\Query\Schema\ForeignKey; +use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Forwarder; use Utopia\Query\Schema\Table; /** - * @property Table\MySQL $table + * @extends ForeignKey */ class MySQL extends ForeignKey { diff --git a/src/Query/Schema/ForeignKey/PostgreSQL.php b/src/Query/Schema/ForeignKey/PostgreSQL.php index 53f6466..f11dba1 100644 --- a/src/Query/Schema/ForeignKey/PostgreSQL.php +++ b/src/Query/Schema/ForeignKey/PostgreSQL.php @@ -2,12 +2,13 @@ namespace Utopia\Query\Schema\ForeignKey; +use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Forwarder; use Utopia\Query\Schema\Table; /** - * @property Table\PostgreSQL $table + * @extends ForeignKey */ class PostgreSQL extends ForeignKey { diff --git a/src/Query/Schema/ForeignKey/SQLite.php b/src/Query/Schema/ForeignKey/SQLite.php index 5236973..14100fe 100644 --- a/src/Query/Schema/ForeignKey/SQLite.php +++ b/src/Query/Schema/ForeignKey/SQLite.php @@ -2,12 +2,13 @@ namespace Utopia\Query\Schema\ForeignKey; +use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Forwarder; use Utopia\Query\Schema\Table; /** - * @property Table\SQLite $table + * @extends ForeignKey */ class SQLite extends ForeignKey { diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php index 52b09e2..78ecf1a 100644 --- a/src/Query/Schema/Table.php +++ b/src/Query/Schema/Table.php @@ -8,15 +8,19 @@ use Utopia\Query\Schema; use Utopia\Query\Schema\ClickHouse\Engine; +/** + * @template TColumn of Column = Column + * @template TForeignKey of ForeignKey = ForeignKey + */ class Table { - /** @var list */ + /** @var list */ public protected(set) array $columns = []; /** @var list */ public protected(set) array $indexes = []; - /** @var list */ + /** @var list */ public protected(set) array $foreignKeys = []; /** @var list */ @@ -113,9 +117,12 @@ private function requireSchema(): Schema /** * Construct a Column instance. Dialect Table subclasses override to * construct their dialect-specific {@see Column} subclass. + * + * @return TColumn */ protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column { + /** @var TColumn */ return new Column($this, $name, $type, $length, $precision); } @@ -123,21 +130,28 @@ protected function newColumn(string $name, ColumnType $type, ?int $length = null * Construct a ForeignKey instance. Dialect Table subclasses that support * foreign keys override to construct their dialect-specific * {@see ForeignKey} subclass. + * + * @return TForeignKey */ protected function newForeignKey(string $column): ForeignKey { + /** @var TForeignKey */ return new ForeignKey($this, $column); } + /** @return TColumn */ public function id(string $name = 'id'): Column { $col = $this->newColumn($name, ColumnType::BigInteger); - $col->unsigned()->autoIncrement()->primary(); + $col->unsigned(); + $col->autoIncrement(); + $col->primary(); $this->columns[] = $col; return $col; } + /** @return TColumn */ public function string(string $name, int $length = 255): Column { $col = $this->newColumn($name, ColumnType::String, $length); @@ -146,6 +160,7 @@ public function string(string $name, int $length = 255): Column return $col; } + /** @return TColumn */ public function text(string $name): Column { $col = $this->newColumn($name, ColumnType::Text); @@ -154,6 +169,7 @@ public function text(string $name): Column return $col; } + /** @return TColumn */ public function mediumText(string $name): Column { $col = $this->newColumn($name, ColumnType::MediumText); @@ -162,6 +178,7 @@ public function mediumText(string $name): Column return $col; } + /** @return TColumn */ public function longText(string $name): Column { $col = $this->newColumn($name, ColumnType::LongText); @@ -170,6 +187,7 @@ public function longText(string $name): Column return $col; } + /** @return TColumn */ public function integer(string $name): Column { $col = $this->newColumn($name, ColumnType::Integer); @@ -178,6 +196,7 @@ public function integer(string $name): Column return $col; } + /** @return TColumn */ public function bigInteger(string $name): Column { $col = $this->newColumn($name, ColumnType::BigInteger); @@ -189,10 +208,13 @@ public function bigInteger(string $name): Column /** * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT * on MySQL; INTEGER on SQLite). Not exposed on ClickHouse/MongoDB. + * + * @return TColumn */ public function serial(string $name): Column { - $col = $this->newColumn($name, ColumnType::Serial)->autoIncrement(); + $col = $this->newColumn($name, ColumnType::Serial); + $col->autoIncrement(); $this->columns[] = $col; return $col; @@ -202,10 +224,13 @@ public function serial(string $name): Column * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; * BIGINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on * ClickHouse/MongoDB. + * + * @return TColumn */ public function bigSerial(string $name): Column { - $col = $this->newColumn($name, ColumnType::BigSerial)->autoIncrement(); + $col = $this->newColumn($name, ColumnType::BigSerial); + $col->autoIncrement(); $this->columns[] = $col; return $col; @@ -215,15 +240,19 @@ public function bigSerial(string $name): Column * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; * SMALLINT AUTO_INCREMENT on MySQL; INTEGER on SQLite). Not exposed on * ClickHouse/MongoDB. + * + * @return TColumn */ public function smallSerial(string $name): Column { - $col = $this->newColumn($name, ColumnType::SmallSerial)->autoIncrement(); + $col = $this->newColumn($name, ColumnType::SmallSerial); + $col->autoIncrement(); $this->columns[] = $col; return $col; } + /** @return TColumn */ public function float(string $name): Column { $col = $this->newColumn($name, ColumnType::Float); @@ -232,6 +261,7 @@ public function float(string $name): Column return $col; } + /** @return TColumn */ public function boolean(string $name): Column { $col = $this->newColumn($name, ColumnType::Boolean); @@ -240,6 +270,7 @@ public function boolean(string $name): Column return $col; } + /** @return TColumn */ public function datetime(string $name, int $precision = 0): Column { $col = $this->newColumn($name, ColumnType::Datetime, precision: $precision); @@ -248,6 +279,7 @@ public function datetime(string $name, int $precision = 0): Column return $col; } + /** @return TColumn */ public function timestamp(string $name, int $precision = 0): Column { $col = $this->newColumn($name, ColumnType::Timestamp, precision: $precision); @@ -256,6 +288,7 @@ public function timestamp(string $name, int $precision = 0): Column return $col; } + /** @return TColumn */ public function json(string $name): Column { $col = $this->newColumn($name, ColumnType::Json); @@ -264,6 +297,7 @@ public function json(string $name): Column return $col; } + /** @return TColumn */ public function binary(string $name): Column { $col = $this->newColumn($name, ColumnType::Binary); @@ -274,6 +308,7 @@ public function binary(string $name): Column /** * @param string[] $values + * @return TColumn * * @throws ValidationException if the value list is empty. */ @@ -283,31 +318,38 @@ public function enum(string $name, array $values): Column throw new ValidationException('enum() requires at least one allowed value.'); } - $col = $this->newColumn($name, ColumnType::Enum)->enum($values); + $col = $this->newColumn($name, ColumnType::Enum); + $col->enum($values); $this->columns[] = $col; return $col; } + /** @return TColumn */ public function point(string $name, int $srid = 4326): Column { - $col = $this->newColumn($name, ColumnType::Point)->srid($srid); + $col = $this->newColumn($name, ColumnType::Point); + $col->srid($srid); $this->columns[] = $col; return $col; } + /** @return TColumn */ public function linestring(string $name, int $srid = 4326): Column { - $col = $this->newColumn($name, ColumnType::Linestring)->srid($srid); + $col = $this->newColumn($name, ColumnType::Linestring); + $col->srid($srid); $this->columns[] = $col; return $col; } + /** @return TColumn */ public function polygon(string $name, int $srid = 4326): Column { - $col = $this->newColumn($name, ColumnType::Polygon)->srid($srid); + $col = $this->newColumn($name, ColumnType::Polygon); + $col->srid($srid); $this->columns[] = $col; return $col; @@ -381,6 +423,7 @@ public function uniqueIndex( return $this; } + /** @return TColumn */ public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { $col = $this->newColumn( @@ -394,6 +437,7 @@ public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecisio return $col; } + /** @return TColumn */ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column { $col = $this->newColumn( @@ -401,7 +445,8 @@ public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPreci $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null, - )->modify(); + ); + $col->modify(); $this->columns[] = $col; return $col; diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php index d11d9f0..e65d8a1 100644 --- a/src/Query/Schema/Table/ClickHouse.php +++ b/src/Query/Schema/Table/ClickHouse.php @@ -6,8 +6,12 @@ use Utopia\Query\Schema\ClickHouse\Engine; use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Table; +/** + * @extends Table + */ class ClickHouse extends Table { use Trait\CompositePrimary; @@ -18,163 +22,6 @@ protected function newColumn(string $name, ColumnType $type, ?int $length = null return new Column\ClickHouse($this, $name, $type, $length, $precision); } - #[\Override] - public function id(string $name = 'id'): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::id($name); - } - - #[\Override] - public function string(string $name, int $length = 255): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::string($name, $length); - } - - #[\Override] - public function text(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::text($name); - } - - #[\Override] - public function mediumText(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::mediumText($name); - } - - #[\Override] - public function longText(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::longText($name); - } - - #[\Override] - public function integer(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::integer($name); - } - - #[\Override] - public function bigInteger(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::bigInteger($name); - } - - #[\Override] - public function serial(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::serial($name); - } - - #[\Override] - public function bigSerial(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::bigSerial($name); - } - - #[\Override] - public function smallSerial(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::smallSerial($name); - } - - #[\Override] - public function float(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::float($name); - } - - #[\Override] - public function boolean(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::boolean($name); - } - - #[\Override] - public function datetime(string $name, int $precision = 0): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::datetime($name, $precision); - } - - #[\Override] - public function timestamp(string $name, int $precision = 0): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::timestamp($name, $precision); - } - - #[\Override] - public function json(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::json($name); - } - - #[\Override] - public function binary(string $name): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::binary($name); - } - - /** - * @param string[] $values - */ - #[\Override] - public function enum(string $name, array $values): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::enum($name, $values); - } - - #[\Override] - public function point(string $name, int $srid = 4326): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::point($name, $srid); - } - - #[\Override] - public function linestring(string $name, int $srid = 4326): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::linestring($name, $srid); - } - - #[\Override] - public function polygon(string $name, int $srid = 4326): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::polygon($name, $srid); - } - - #[\Override] - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::addColumn($name, $type, $lengthOrPrecision); - } - - #[\Override] - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\ClickHouse - { - /** @var Column\ClickHouse */ - return parent::modifyColumn($name, $type, $lengthOrPrecision); - } - /** * @return Column\ClickHouse */ diff --git a/src/Query/Schema/Table/MongoDB.php b/src/Query/Schema/Table/MongoDB.php index 5825a15..7c134d7 100644 --- a/src/Query/Schema/Table/MongoDB.php +++ b/src/Query/Schema/Table/MongoDB.php @@ -4,8 +4,12 @@ use Utopia\Query\Schema\Column; use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Table; +/** + * @extends Table + */ class MongoDB extends Table { #[\Override] @@ -14,163 +18,6 @@ protected function newColumn(string $name, ColumnType $type, ?int $length = null return new Column\MongoDB($this, $name, $type, $length, $precision); } - #[\Override] - public function id(string $name = 'id'): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::id($name); - } - - #[\Override] - public function string(string $name, int $length = 255): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::string($name, $length); - } - - #[\Override] - public function text(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::text($name); - } - - #[\Override] - public function mediumText(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::mediumText($name); - } - - #[\Override] - public function longText(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::longText($name); - } - - #[\Override] - public function integer(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::integer($name); - } - - #[\Override] - public function bigInteger(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::bigInteger($name); - } - - #[\Override] - public function serial(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::serial($name); - } - - #[\Override] - public function bigSerial(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::bigSerial($name); - } - - #[\Override] - public function smallSerial(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::smallSerial($name); - } - - #[\Override] - public function float(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::float($name); - } - - #[\Override] - public function boolean(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::boolean($name); - } - - #[\Override] - public function datetime(string $name, int $precision = 0): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::datetime($name, $precision); - } - - #[\Override] - public function timestamp(string $name, int $precision = 0): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::timestamp($name, $precision); - } - - #[\Override] - public function json(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::json($name); - } - - #[\Override] - public function binary(string $name): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::binary($name); - } - - /** - * @param string[] $values - */ - #[\Override] - public function enum(string $name, array $values): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::enum($name, $values); - } - - #[\Override] - public function point(string $name, int $srid = 4326): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::point($name, $srid); - } - - #[\Override] - public function linestring(string $name, int $srid = 4326): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::linestring($name, $srid); - } - - #[\Override] - public function polygon(string $name, int $srid = 4326): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::polygon($name, $srid); - } - - #[\Override] - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::addColumn($name, $type, $lengthOrPrecision); - } - - #[\Override] - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\MongoDB - { - /** @var Column\MongoDB */ - return parent::modifyColumn($name, $type, $lengthOrPrecision); - } - /** * @return Column\MongoDB */ diff --git a/src/Query/Schema/Table/MySQL.php b/src/Query/Schema/Table/MySQL.php index 67a0f05..47290d8 100644 --- a/src/Query/Schema/Table/MySQL.php +++ b/src/Query/Schema/Table/MySQL.php @@ -7,10 +7,14 @@ use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Table; +/** + * @extends Table + */ class MySQL extends Table { use Trait\Checks; use Trait\CompositePrimary; + /** @use Trait\ForeignKeys */ use Trait\ForeignKeys; use Trait\FulltextSpatialIndex; use Trait\StandardPartitioning; @@ -26,174 +30,4 @@ protected function newForeignKey(string $column): ForeignKey\MySQL { return new ForeignKey\MySQL($this, $column); } - - #[\Override] - public function id(string $name = 'id'): Column\MySQL - { - /** @var Column\MySQL */ - return parent::id($name); - } - - #[\Override] - public function string(string $name, int $length = 255): Column\MySQL - { - /** @var Column\MySQL */ - return parent::string($name, $length); - } - - #[\Override] - public function text(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::text($name); - } - - #[\Override] - public function mediumText(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::mediumText($name); - } - - #[\Override] - public function longText(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::longText($name); - } - - #[\Override] - public function integer(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::integer($name); - } - - #[\Override] - public function bigInteger(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::bigInteger($name); - } - - #[\Override] - public function serial(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::serial($name); - } - - #[\Override] - public function bigSerial(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::bigSerial($name); - } - - #[\Override] - public function smallSerial(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::smallSerial($name); - } - - #[\Override] - public function float(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::float($name); - } - - #[\Override] - public function boolean(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::boolean($name); - } - - #[\Override] - public function datetime(string $name, int $precision = 0): Column\MySQL - { - /** @var Column\MySQL */ - return parent::datetime($name, $precision); - } - - #[\Override] - public function timestamp(string $name, int $precision = 0): Column\MySQL - { - /** @var Column\MySQL */ - return parent::timestamp($name, $precision); - } - - #[\Override] - public function json(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::json($name); - } - - #[\Override] - public function binary(string $name): Column\MySQL - { - /** @var Column\MySQL */ - return parent::binary($name); - } - - /** - * @param string[] $values - */ - #[\Override] - public function enum(string $name, array $values): Column\MySQL - { - /** @var Column\MySQL */ - return parent::enum($name, $values); - } - - #[\Override] - public function point(string $name, int $srid = 4326): Column\MySQL - { - /** @var Column\MySQL */ - return parent::point($name, $srid); - } - - #[\Override] - public function linestring(string $name, int $srid = 4326): Column\MySQL - { - /** @var Column\MySQL */ - return parent::linestring($name, $srid); - } - - #[\Override] - public function polygon(string $name, int $srid = 4326): Column\MySQL - { - /** @var Column\MySQL */ - return parent::polygon($name, $srid); - } - - #[\Override] - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\MySQL - { - /** @var Column\MySQL */ - return parent::addColumn($name, $type, $lengthOrPrecision); - } - - #[\Override] - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\MySQL - { - /** @var Column\MySQL */ - return parent::modifyColumn($name, $type, $lengthOrPrecision); - } - - public function foreignKey(string $column): ForeignKey\MySQL - { - $fk = $this->newForeignKey($column); - $this->foreignKeys[] = $fk; - - return $fk; - } - - public function addForeignKey(string $column): ForeignKey\MySQL - { - return $this->foreignKey($column); - } } diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php index 5c2d445..d5ab68d 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -7,10 +7,14 @@ use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Table; +/** + * @extends Table + */ class PostgreSQL extends Table { use Trait\Checks; use Trait\CompositePrimary; + /** @use Trait\ForeignKeys */ use Trait\ForeignKeys; use Trait\FulltextSpatialIndex; use Trait\StandardPartitioning; @@ -27,163 +31,6 @@ protected function newForeignKey(string $column): ForeignKey\PostgreSQL return new ForeignKey\PostgreSQL($this, $column); } - #[\Override] - public function id(string $name = 'id'): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::id($name); - } - - #[\Override] - public function string(string $name, int $length = 255): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::string($name, $length); - } - - #[\Override] - public function text(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::text($name); - } - - #[\Override] - public function mediumText(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::mediumText($name); - } - - #[\Override] - public function longText(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::longText($name); - } - - #[\Override] - public function integer(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::integer($name); - } - - #[\Override] - public function bigInteger(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::bigInteger($name); - } - - #[\Override] - public function serial(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::serial($name); - } - - #[\Override] - public function bigSerial(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::bigSerial($name); - } - - #[\Override] - public function smallSerial(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::smallSerial($name); - } - - #[\Override] - public function float(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::float($name); - } - - #[\Override] - public function boolean(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::boolean($name); - } - - #[\Override] - public function datetime(string $name, int $precision = 0): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::datetime($name, $precision); - } - - #[\Override] - public function timestamp(string $name, int $precision = 0): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::timestamp($name, $precision); - } - - #[\Override] - public function json(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::json($name); - } - - #[\Override] - public function binary(string $name): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::binary($name); - } - - /** - * @param string[] $values - */ - #[\Override] - public function enum(string $name, array $values): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::enum($name, $values); - } - - #[\Override] - public function point(string $name, int $srid = 4326): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::point($name, $srid); - } - - #[\Override] - public function linestring(string $name, int $srid = 4326): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::linestring($name, $srid); - } - - #[\Override] - public function polygon(string $name, int $srid = 4326): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::polygon($name, $srid); - } - - #[\Override] - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::addColumn($name, $type, $lengthOrPrecision); - } - - #[\Override] - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\PostgreSQL - { - /** @var Column\PostgreSQL */ - return parent::modifyColumn($name, $type, $lengthOrPrecision); - } - /** * @return Column\PostgreSQL */ @@ -194,17 +41,4 @@ public function vector(string $name, int $dimensions): Column return $col; } - - public function foreignKey(string $column): ForeignKey\PostgreSQL - { - $fk = $this->newForeignKey($column); - $this->foreignKeys[] = $fk; - - return $fk; - } - - public function addForeignKey(string $column): ForeignKey\PostgreSQL - { - return $this->foreignKey($column); - } } diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php index 39e0784..d49e01a 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -7,10 +7,14 @@ use Utopia\Query\Schema\ForeignKey; use Utopia\Query\Schema\Table; +/** + * @extends Table + */ class SQLite extends Table { use Trait\Checks; use Trait\CompositePrimary; + /** @use Trait\ForeignKeys */ use Trait\ForeignKeys; #[\Override] @@ -24,174 +28,4 @@ protected function newForeignKey(string $column): ForeignKey\SQLite { return new ForeignKey\SQLite($this, $column); } - - #[\Override] - public function id(string $name = 'id'): Column\SQLite - { - /** @var Column\SQLite */ - return parent::id($name); - } - - #[\Override] - public function string(string $name, int $length = 255): Column\SQLite - { - /** @var Column\SQLite */ - return parent::string($name, $length); - } - - #[\Override] - public function text(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::text($name); - } - - #[\Override] - public function mediumText(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::mediumText($name); - } - - #[\Override] - public function longText(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::longText($name); - } - - #[\Override] - public function integer(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::integer($name); - } - - #[\Override] - public function bigInteger(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::bigInteger($name); - } - - #[\Override] - public function serial(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::serial($name); - } - - #[\Override] - public function bigSerial(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::bigSerial($name); - } - - #[\Override] - public function smallSerial(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::smallSerial($name); - } - - #[\Override] - public function float(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::float($name); - } - - #[\Override] - public function boolean(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::boolean($name); - } - - #[\Override] - public function datetime(string $name, int $precision = 0): Column\SQLite - { - /** @var Column\SQLite */ - return parent::datetime($name, $precision); - } - - #[\Override] - public function timestamp(string $name, int $precision = 0): Column\SQLite - { - /** @var Column\SQLite */ - return parent::timestamp($name, $precision); - } - - #[\Override] - public function json(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::json($name); - } - - #[\Override] - public function binary(string $name): Column\SQLite - { - /** @var Column\SQLite */ - return parent::binary($name); - } - - /** - * @param string[] $values - */ - #[\Override] - public function enum(string $name, array $values): Column\SQLite - { - /** @var Column\SQLite */ - return parent::enum($name, $values); - } - - #[\Override] - public function point(string $name, int $srid = 4326): Column\SQLite - { - /** @var Column\SQLite */ - return parent::point($name, $srid); - } - - #[\Override] - public function linestring(string $name, int $srid = 4326): Column\SQLite - { - /** @var Column\SQLite */ - return parent::linestring($name, $srid); - } - - #[\Override] - public function polygon(string $name, int $srid = 4326): Column\SQLite - { - /** @var Column\SQLite */ - return parent::polygon($name, $srid); - } - - #[\Override] - public function addColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\SQLite - { - /** @var Column\SQLite */ - return parent::addColumn($name, $type, $lengthOrPrecision); - } - - #[\Override] - public function modifyColumn(string $name, ColumnType $type, ?int $lengthOrPrecision = null): Column\SQLite - { - /** @var Column\SQLite */ - return parent::modifyColumn($name, $type, $lengthOrPrecision); - } - - public function foreignKey(string $column): ForeignKey\SQLite - { - $fk = $this->newForeignKey($column); - $this->foreignKeys[] = $fk; - - return $fk; - } - - public function addForeignKey(string $column): ForeignKey\SQLite - { - return $this->foreignKey($column); - } } diff --git a/src/Query/Schema/Table/Trait/ForeignKeys.php b/src/Query/Schema/Table/Trait/ForeignKeys.php index a4e8dcb..b42e7ea 100644 --- a/src/Query/Schema/Table/Trait/ForeignKeys.php +++ b/src/Query/Schema/Table/Trait/ForeignKeys.php @@ -4,6 +4,9 @@ use Utopia\Query\Schema\ForeignKey; +/** + * @template TForeignKey of ForeignKey + */ trait ForeignKeys { /** @@ -12,6 +15,8 @@ trait ForeignKeys * a CREATE TABLE column list) and `ADD FOREIGN KEY (...)` (in an ALTER * TABLE clause) when emitting the statement. {@see addForeignKey()} is * an alias for use in alter chains; both register the same FK exactly once. + * + * @return TForeignKey */ public function foreignKey(string $column): ForeignKey { @@ -25,6 +30,8 @@ public function foreignKey(string $column): ForeignKey * Alias of {@see foreignKey()}, for symmetry with the other `add*`/`drop*` * alter helpers. Returns the same registered {@see ForeignKey}; calling * both methods for the same column registers the FK twice. + * + * @return TForeignKey */ public function addForeignKey(string $column): ForeignKey { From ef7ed8cb812e78d860bb301ee500f7c58285adf3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 9 May 2026 19:53:57 +1200 Subject: [PATCH 08/11] fix(schema): PostgreSQL dropTrigger requires table; emit ON table_name DDL The shared Trait\Triggers emitted MySQL-style DROP TRIGGER \"name\", which PostgreSQL rejects (it requires DROP TRIGGER name ON table_name). Schema\PostgreSQL inherited the trait without overriding, so the produced SQL was invalid against any real PostgreSQL server. Widen the Feature\Triggers / Trait\Triggers signature to accept an optional \$table; MySQL and SQLite ignore it, PostgreSQL overrides to require it and emit the correct DDL plus drop the backing PL/pgSQL function in the same statement. Updated the existing PG dropTrigger test to assert the corrected DDL and added a regression test for the throw when \$table is omitted. --- src/Query/Schema/Feature/Triggers.php | 5 ++++- src/Query/Schema/PostgreSQL.php | 23 +++++++++++++++++++++++ src/Query/Schema/Trait/Triggers.php | 2 +- tests/Query/Schema/PostgreSQLTest.php | 17 +++++++++++++++-- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Query/Schema/Feature/Triggers.php b/src/Query/Schema/Feature/Triggers.php index 28e9fa8..fc1ebf4 100644 --- a/src/Query/Schema/Feature/Triggers.php +++ b/src/Query/Schema/Feature/Triggers.php @@ -16,5 +16,8 @@ public function createTrigger( string $body, ): Statement; - public function dropTrigger(string $name): Statement; + /** + * Drop a trigger. PostgreSQL requires $table; MySQL/SQLite ignore it. + */ + public function dropTrigger(string $name, ?string $table = null): Statement; } diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index 44ed577..d359e49 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -302,6 +302,29 @@ public function createTrigger( return new Statement($sql, [], executor: $this->executor); } + /** + * Drop a trigger and its backing PL/pgSQL function. PostgreSQL requires + * the table name (`DROP TRIGGER name ON table`); the function created by + * {@see createTrigger()} is dropped at the same time so callers don't need + * a separate {@see dropFunction()} call. + * + * @throws ValidationException if $table is null. + */ + #[\Override] + public function dropTrigger(string $name, ?string $table = null): Statement + { + if ($table === null) { + throw new ValidationException('PostgreSQL dropTrigger() requires the table name.'); + } + + $funcName = $name . '_func'; + + $sql = 'DROP TRIGGER ' . $this->quote($name) . ' ON ' . $this->quote($table) + . '; DROP FUNCTION ' . $this->quote($funcName); + + return new Statement($sql, [], executor: $this->executor); + } + /** * Reject bodies that would break out of the surrounding `$$ ... $$` * dollar-quoted string. diff --git a/src/Query/Schema/Trait/Triggers.php b/src/Query/Schema/Trait/Triggers.php index 885c047..9ae4da5 100644 --- a/src/Query/Schema/Trait/Triggers.php +++ b/src/Query/Schema/Trait/Triggers.php @@ -29,7 +29,7 @@ public function createTrigger( return new Statement($sql, [], executor: $this->executor); } - public function dropTrigger(string $name): Statement + public function dropTrigger(string $name, ?string $table = null): Statement { return new Statement('DROP TRIGGER ' . $this->quote($name), [], executor: $this->executor); } diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index 8f748a7..9ab01dc 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -515,9 +515,22 @@ public function testDropTriggerFunction(): void { $schema = new Schema(); - $result = $schema->dropTrigger('trg_old'); + $result = $schema->dropTrigger('trg_old', 'users'); - $this->assertSame('DROP TRIGGER "trg_old"', $result->query); + $this->assertSame( + 'DROP TRIGGER "trg_old" ON "users"; DROP FUNCTION "trg_old_func"', + $result->query, + ); + } + + public function testDropTriggerWithoutTableThrows(): void + { + $schema = new Schema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('PostgreSQL dropTrigger() requires the table name.'); + + $schema->dropTrigger('trg_old'); } public function testAlterWithUniqueIndex(): void From 93687a138607ac4eeb3c18a9d4d5de264975f7ba Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 10 May 2026 00:14:39 +1200 Subject: [PATCH 09/11] fix(schema): narrow vector() return types and correct MongoDB dropView payload Table\PostgreSQL/MongoDB/ClickHouse vector() declared base Column return; narrow to Column\X to match the docblock and keep dialect-specific chaining type-safe without a /** @var */ cast. MongoDB dropView() emitted {command: 'drop', view: }; the executor uses 'collection' as the target key for all drop commands (compileDrop already does this). Switch to 'collection' for consistency and add a regression test. --- src/Query/Schema/MongoDB.php | 2 +- src/Query/Schema/Table/ClickHouse.php | 8 +++----- src/Query/Schema/Table/MongoDB.php | 8 +++----- src/Query/Schema/Table/PostgreSQL.php | 8 +++----- tests/Query/Schema/MongoDBTest.php | 11 +++++++++++ 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/Query/Schema/MongoDB.php b/src/Query/Schema/MongoDB.php index 21fc0b4..fc504ee 100644 --- a/src/Query/Schema/MongoDB.php +++ b/src/Query/Schema/MongoDB.php @@ -293,7 +293,7 @@ public function createView(string $name, Builder $query): Statement public function dropView(string $name): Statement { return new Statement( - \json_encode(['command' => 'drop', 'view' => $name], JSON_THROW_ON_ERROR), + \json_encode(['command' => 'drop', 'collection' => $name], JSON_THROW_ON_ERROR), [], executor: $this->executor, ); diff --git a/src/Query/Schema/Table/ClickHouse.php b/src/Query/Schema/Table/ClickHouse.php index e65d8a1..da9b392 100644 --- a/src/Query/Schema/Table/ClickHouse.php +++ b/src/Query/Schema/Table/ClickHouse.php @@ -22,12 +22,10 @@ protected function newColumn(string $name, ColumnType $type, ?int $length = null return new Column\ClickHouse($this, $name, $type, $length, $precision); } - /** - * @return Column\ClickHouse - */ - public function vector(string $name, int $dimensions): Column + public function vector(string $name, int $dimensions): Column\ClickHouse { - $col = $this->newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $col = $this->newColumn($name, ColumnType::Vector); + $col->dimensions($dimensions); $this->columns[] = $col; return $col; diff --git a/src/Query/Schema/Table/MongoDB.php b/src/Query/Schema/Table/MongoDB.php index 7c134d7..522efc8 100644 --- a/src/Query/Schema/Table/MongoDB.php +++ b/src/Query/Schema/Table/MongoDB.php @@ -18,12 +18,10 @@ protected function newColumn(string $name, ColumnType $type, ?int $length = null return new Column\MongoDB($this, $name, $type, $length, $precision); } - /** - * @return Column\MongoDB - */ - public function vector(string $name, int $dimensions): Column + public function vector(string $name, int $dimensions): Column\MongoDB { - $col = $this->newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $col = $this->newColumn($name, ColumnType::Vector); + $col->dimensions($dimensions); $this->columns[] = $col; return $col; diff --git a/src/Query/Schema/Table/PostgreSQL.php b/src/Query/Schema/Table/PostgreSQL.php index d5ab68d..623a379 100644 --- a/src/Query/Schema/Table/PostgreSQL.php +++ b/src/Query/Schema/Table/PostgreSQL.php @@ -31,12 +31,10 @@ protected function newForeignKey(string $column): ForeignKey\PostgreSQL return new ForeignKey\PostgreSQL($this, $column); } - /** - * @return Column\PostgreSQL - */ - public function vector(string $name, int $dimensions): Column + public function vector(string $name, int $dimensions): Column\PostgreSQL { - $col = $this->newColumn($name, ColumnType::Vector)->dimensions($dimensions); + $col = $this->newColumn($name, ColumnType::Vector); + $col->dimensions($dimensions); $this->columns[] = $col; return $col; diff --git a/tests/Query/Schema/MongoDBTest.php b/tests/Query/Schema/MongoDBTest.php index 1f17d8a..923fd69 100644 --- a/tests/Query/Schema/MongoDBTest.php +++ b/tests/Query/Schema/MongoDBTest.php @@ -363,6 +363,17 @@ public function testCreateViewFromAggregation(): void $this->assertNotEmpty($pipeline); } + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $op = $this->decode($result->query); + $this->assertSame('drop', $op['command']); + $this->assertSame('active_users', $op['collection']); + $this->assertArrayNotHasKey('view', $op); + } + public function testCreateCollectionWithAllBsonTypes(): void { $schema = new Schema(); From defae92106db85c6016cfc9939389bf01b07ccd7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 10 May 2026 00:31:24 +1200 Subject: [PATCH 10/11] fix(schema): split Table FK trait so SQLite gets only inline foreignKey() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite's ALTER TABLE doesn't support FK add/drop. Table\SQLite previously mixed in Table\Trait\ForeignKeys (which bundled foreignKey + addForeignKey + dropForeignKey) and Forwarder\SQLite re-exposed all three on Column\SQLite / ForeignKey\SQLite, so chains like \$table->dropForeignKey('fk')->alter() produced ALTER TABLE … DROP FOREIGN KEY DDL that SQLite rejects at runtime. Extract foreignKey() into Table\Trait\InlineForeignKey (CREATE-time inline FK, valid on every dialect including SQLite). Table\Trait\ForeignKeys now composes InlineForeignKey and adds the ALTER-only methods, used only by MySQL and PostgreSQL. Table\SQLite uses InlineForeignKey alone; Forwarder\SQLite drops the ALTER forwarders. Added testSQLiteHasNoAlterForeignKeyMethods regression asserting addForeignKey/dropForeignKey are absent across Table/Column/FK\SQLite. --- src/Query/Schema/Forwarder/SQLite.php | 15 +++------- src/Query/Schema/Table/SQLite.php | 4 +-- src/Query/Schema/Table/Trait/ForeignKeys.php | 25 ++++++---------- .../Schema/Table/Trait/InlineForeignKey.php | 29 +++++++++++++++++++ tests/Query/Schema/ForwarderTest.php | 17 +++++++++-- 5 files changed, 58 insertions(+), 32 deletions(-) create mode 100644 src/Query/Schema/Table/Trait/InlineForeignKey.php diff --git a/src/Query/Schema/Forwarder/SQLite.php b/src/Query/Schema/Forwarder/SQLite.php index ffd0e57..9f8623c 100644 --- a/src/Query/Schema/Forwarder/SQLite.php +++ b/src/Query/Schema/Forwarder/SQLite.php @@ -9,6 +9,10 @@ /** * Forwarders that delegate SQLite-specific calls back to the parent Table. * Used by {@see Column\SQLite} and {@see ForeignKey\SQLite}. + * + * Note: SQLite ALTER TABLE does not support FK add/drop, so only the inline + * `foreignKey()` (used at CREATE time) is forwarded — `addForeignKey()` and + * `dropForeignKey()` are intentionally omitted. */ trait SQLite { @@ -16,15 +20,4 @@ public function foreignKey(string $column): ForeignKey\SQLite { return $this->table->foreignKey($column); } - - public function addForeignKey(string $column): ForeignKey\SQLite - { - return $this->table->addForeignKey($column); - } - - public function dropForeignKey(string $name): Table\SQLite - { - return $this->table->dropForeignKey($name); - } - } diff --git a/src/Query/Schema/Table/SQLite.php b/src/Query/Schema/Table/SQLite.php index d49e01a..41152ad 100644 --- a/src/Query/Schema/Table/SQLite.php +++ b/src/Query/Schema/Table/SQLite.php @@ -14,8 +14,8 @@ class SQLite extends Table { use Trait\Checks; use Trait\CompositePrimary; - /** @use Trait\ForeignKeys */ - use Trait\ForeignKeys; + /** @use Trait\InlineForeignKey */ + use Trait\InlineForeignKey; #[\Override] protected function newColumn(string $name, ColumnType $type, ?int $length = null, ?int $precision = null): Column\SQLite diff --git a/src/Query/Schema/Table/Trait/ForeignKeys.php b/src/Query/Schema/Table/Trait/ForeignKeys.php index b42e7ea..80ec2d5 100644 --- a/src/Query/Schema/Table/Trait/ForeignKeys.php +++ b/src/Query/Schema/Table/Trait/ForeignKeys.php @@ -5,26 +5,19 @@ use Utopia\Query\Schema\ForeignKey; /** + * Foreign-key ALTER operations. Only dialects whose `ALTER TABLE` supports + * adding and dropping FK constraints (MySQL, PostgreSQL) should mix this in. + * SQLite must NOT use this trait — its ALTER TABLE rejects constraint changes. + * + * Composes {@see InlineForeignKey} so the using class also gets `foreignKey()` + * for inline create-time declarations. + * * @template TForeignKey of ForeignKey */ trait ForeignKeys { - /** - * Declare a foreign key. The behaviour is identical for create and alter - * contexts — the dialect compiler switches between `FOREIGN KEY (...)` (in - * a CREATE TABLE column list) and `ADD FOREIGN KEY (...)` (in an ALTER - * TABLE clause) when emitting the statement. {@see addForeignKey()} is - * an alias for use in alter chains; both register the same FK exactly once. - * - * @return TForeignKey - */ - public function foreignKey(string $column): ForeignKey - { - $fk = $this->newForeignKey($column); - $this->foreignKeys[] = $fk; - - return $fk; - } + /** @use InlineForeignKey */ + use InlineForeignKey; /** * Alias of {@see foreignKey()}, for symmetry with the other `add*`/`drop*` diff --git a/src/Query/Schema/Table/Trait/InlineForeignKey.php b/src/Query/Schema/Table/Trait/InlineForeignKey.php new file mode 100644 index 0000000..53dbc04 --- /dev/null +++ b/src/Query/Schema/Table/Trait/InlineForeignKey.php @@ -0,0 +1,29 @@ +newForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } +} diff --git a/tests/Query/Schema/ForwarderTest.php b/tests/Query/Schema/ForwarderTest.php index 0b246ba..2c90497 100644 --- a/tests/Query/Schema/ForwarderTest.php +++ b/tests/Query/Schema/ForwarderTest.php @@ -134,9 +134,6 @@ public function testSQLiteColumnForwarderHasOnlyForeignKeysAndDualPurpose(): voi $fk = $col->foreignKey('user_id'); $this->assertInstanceOf(ForeignKey\SQLite::class, $fk); - $this->assertInstanceOf(ForeignKey\SQLite::class, $col->addForeignKey('order_id')); - $this->assertSame($table, $col->dropForeignKey('fk_old')); - // Dual-purpose primary/check from the dialect Column class. $this->assertSame($col, $col->primary()); $this->assertTrue($col->isPrimary); @@ -152,6 +149,20 @@ public function testSQLiteColumnForwarderHasOnlyForeignKeysAndDualPurpose(): voi $this->assertCount(1, $table->checks); } + public function testSQLiteHasNoAlterForeignKeyMethods(): void + { + $table = (new SQLite())->table('orders'); + $col = $table->integer('user_id'); + $fk = $table->foreignKey('user_id'); + + $this->assertFalse(\method_exists($table, 'addForeignKey')); + $this->assertFalse(\method_exists($table, 'dropForeignKey')); + $this->assertFalse(\method_exists($col, 'addForeignKey')); + $this->assertFalse(\method_exists($col, 'dropForeignKey')); + $this->assertFalse(\method_exists($fk, 'addForeignKey')); + $this->assertFalse(\method_exists($fk, 'dropForeignKey')); + } + public function testSQLiteForeignKeyForwarderTableLevelMethods(): void { $table = (new SQLite())->table('orders'); From 3450ce54889ebf91a7f996aff99344c0b96380c3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 10 May 2026 00:39:40 +1200 Subject: [PATCH 11/11] fix(schema): explicit () on PostgreSQL DROP FUNCTION for pre-12 compatibility dropFunction() and the trigger-cleanup chain in dropTrigger() emitted DROP FUNCTION "name" without the trailing argument list. PostgreSQL 12+ accepts the bare name when the function is unique, but PostgreSQL <12 requires DROP FUNCTION name() with the explicit empty argument list. Add () in both places and update the assertions. --- src/Query/Schema/PostgreSQL.php | 4 ++-- tests/Query/Schema/PostgreSQLTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Query/Schema/PostgreSQL.php b/src/Query/Schema/PostgreSQL.php index d359e49..5bdf04e 100644 --- a/src/Query/Schema/PostgreSQL.php +++ b/src/Query/Schema/PostgreSQL.php @@ -268,7 +268,7 @@ public function createProcedure(string $name, array $params, string $body): Stat public function dropProcedure(string $name): Statement { - return new Statement('DROP FUNCTION ' . $this->quote($name), [], executor: $this->executor); + return new Statement('DROP FUNCTION ' . $this->quote($name) . '()', [], executor: $this->executor); } /** @@ -320,7 +320,7 @@ public function dropTrigger(string $name, ?string $table = null): Statement $funcName = $name . '_func'; $sql = 'DROP TRIGGER ' . $this->quote($name) . ' ON ' . $this->quote($table) - . '; DROP FUNCTION ' . $this->quote($funcName); + . '; DROP FUNCTION ' . $this->quote($funcName) . '()'; return new Statement($sql, [], executor: $this->executor); } diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php index 9ab01dc..d858945 100644 --- a/tests/Query/Schema/PostgreSQLTest.php +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -198,7 +198,7 @@ public function testDropProcedureUsesFunction(): void $schema = new Schema(); $result = $schema->dropProcedure('update_stats'); - $this->assertSame('DROP FUNCTION "update_stats"', $result->query); + $this->assertSame('DROP FUNCTION "update_stats"()', $result->query); } public function testCreateTriggerUsesExecuteFunction(): void @@ -518,7 +518,7 @@ public function testDropTriggerFunction(): void $result = $schema->dropTrigger('trg_old', 'users'); $this->assertSame( - 'DROP TRIGGER "trg_old" ON "users"; DROP FUNCTION "trg_old_func"', + 'DROP TRIGGER "trg_old" ON "users"; DROP FUNCTION "trg_old_func"()', $result->query, ); }