From cca3453a7f1b578c50f3a04d925ba40843f3e74d Mon Sep 17 00:00:00 2001 From: Claudear <262350598+claudear@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:56:09 +0000 Subject: [PATCH] fix(sources): resolve column type, format and size the way Appwrite does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appwrite/appwrite#13174 taught the inline `columns`/`attributes` array to accept the whole string type family — `text`, `varchar`, `mediumtext`, `longtext` — plus the format shorthands `email`, `url`, `ip` and `enum` as types, and centralised the type/format-to-size mapping in Appwrite\Utopia\Database\Attribute so the inline and dedicated endpoints can't drift. The same mapping was spread across this library as literals: once per Column subclass default and again per branch of Appwrite::getColumn(). It already agreed with the server on the fixed width sizes, but the enum default was 256 where Appwrite uses Database::LENGTH_KEY, and a definition carrying a format shorthand as its type threw "Unsupported column type: email" instead of resolving to a string of that format. Column::resolve() now mirrors the server's Attribute::resolve(): a format shorthand becomes a string of that format, fixed width types take the size their type implies, and a formatted string with no size falls back to the format's size. Appwrite reports no size at all for those types, so this is what keeps a migrated column the width it was on the source. Co-Authored-By: Claude Opus 5 --- src/Migration/Resources/Database/Column.php | 77 ++++++ .../Resources/Database/Columns/Email.php | 2 +- .../Resources/Database/Columns/Enum.php | 2 +- .../Resources/Database/Columns/IP.php | 2 +- .../Resources/Database/Columns/LongText.php | 2 +- .../Resources/Database/Columns/MediumText.php | 2 +- .../Database/Columns/RegularText.php | 2 +- .../Resources/Database/Columns/URL.php | 2 +- .../Resources/Database/Columns/Varchar.php | 2 +- src/Migration/Sources/Appwrite.php | 29 ++- tests/Migration/Unit/Resources/ColumnTest.php | 109 ++++++++ .../Unit/Sources/AppwriteColumnTest.php | 232 ++++++++++++++++++ 12 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 tests/Migration/Unit/Resources/ColumnTest.php create mode 100644 tests/Migration/Unit/Sources/AppwriteColumnTest.php diff --git a/src/Migration/Resources/Database/Column.php b/src/Migration/Resources/Database/Column.php index 3a3ef4fa..cc056504 100644 --- a/src/Migration/Resources/Database/Column.php +++ b/src/Migration/Resources/Database/Column.php @@ -2,6 +2,7 @@ namespace Utopia\Migration\Resources\Database; +use Utopia\Database\Database as UtopiaDatabase; use Utopia\Migration\Resource; use Utopia\Migration\Transfer; @@ -31,6 +32,82 @@ abstract class Column extends Resource public const TYPE_OBJECT = 'object'; public const TYPE_VECTOR = 'vector'; + /** + * Types whose size is fixed by the type itself. Appwrite leaves the size + * off the API response for these, so it has to be derived from the type. + * + * Mirrors Appwrite\Utopia\Database\Attribute::SIZES. + * + * @var array + */ + public const SIZES = [ + self::TYPE_TEXT => 65535, + self::TYPE_MEDIUMTEXT => 16777215, + self::TYPE_LONGTEXT => 2147483647, + ]; + + /** + * String formats, mapped to the size Appwrite creates them with. Each + * format is also accepted as a shorthand type on an inline column + * definition, where it means a string of that format. + * + * Mirrors Appwrite\Utopia\Database\Attribute::FORMAT_SIZES. + * + * @var array + */ + public const FORMAT_SIZES = [ + self::TYPE_EMAIL => 254, + self::TYPE_ENUM => UtopiaDatabase::LENGTH_KEY, + self::TYPE_IP => 39, + self::TYPE_URL => 2000, + ]; + + /** + * Size to fall back on for a varchar that arrives without one. Appwrite + * requires an explicit size for varchar, so this only guards a source + * that omits it. + */ + public const DEFAULT_VARCHAR_SIZE = 255; + + /** + * Resolve a raw column definition into the type, format and size Appwrite + * stores for it. A format shorthand (`email`, `url`, `ip`, `enum`) becomes + * a string of that format, and an omitted size is filled in from the type + * or the format. + * + * Mirrors Appwrite\Utopia\Database\Attribute::resolve() so a column read + * from a source ends up with the size the destination would have stored. + * + * @param array $column + * @return array{type: string, format: string, size: int} + */ + public static function resolve(array $column): array + { + $type = \is_string($column['type'] ?? null) ? $column['type'] : ''; + $format = \is_string($column['format'] ?? null) ? $column['format'] : ''; + + if (isset(self::FORMAT_SIZES[$type])) { + $format = $type; + $type = self::TYPE_STRING; + } + + $size = $column['size'] ?? null; + $size = \is_numeric($size) ? (int) $size : 0; + + if (isset(self::SIZES[$type])) { + // Fixed width types ignore any size the source reported. + $size = self::SIZES[$type]; + } elseif ($size < 1) { + $size = self::FORMAT_SIZES[$format] ?? $size; + } + + return [ + 'type' => $type, + 'format' => $format, + 'size' => $size, + ]; + } + /** * @param string $key * @param Table $table diff --git a/src/Migration/Resources/Database/Columns/Email.php b/src/Migration/Resources/Database/Columns/Email.php index d5874a94..0458c385 100644 --- a/src/Migration/Resources/Database/Columns/Email.php +++ b/src/Migration/Resources/Database/Columns/Email.php @@ -13,7 +13,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 254, + int $size = Column::FORMAT_SIZES[Column::TYPE_EMAIL], string $createdAt = '', string $updatedAt = '' ) { diff --git a/src/Migration/Resources/Database/Columns/Enum.php b/src/Migration/Resources/Database/Columns/Enum.php index fcc20dba..37e23f69 100644 --- a/src/Migration/Resources/Database/Columns/Enum.php +++ b/src/Migration/Resources/Database/Columns/Enum.php @@ -17,7 +17,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 256, + int $size = Column::FORMAT_SIZES[Column::TYPE_ENUM], string $createdAt = '', string $updatedAt = '' ) { diff --git a/src/Migration/Resources/Database/Columns/IP.php b/src/Migration/Resources/Database/Columns/IP.php index 06634d59..fa926a7f 100644 --- a/src/Migration/Resources/Database/Columns/IP.php +++ b/src/Migration/Resources/Database/Columns/IP.php @@ -13,7 +13,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 39, + int $size = Column::FORMAT_SIZES[Column::TYPE_IP], string $createdAt = '', string $updatedAt = '' ) { diff --git a/src/Migration/Resources/Database/Columns/LongText.php b/src/Migration/Resources/Database/Columns/LongText.php index 7f6230c6..ab5dfffb 100644 --- a/src/Migration/Resources/Database/Columns/LongText.php +++ b/src/Migration/Resources/Database/Columns/LongText.php @@ -14,7 +14,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 2147483647, + int $size = Column::SIZES[Column::TYPE_LONGTEXT], string $format = '', string $createdAt = '', string $updatedAt = '' diff --git a/src/Migration/Resources/Database/Columns/MediumText.php b/src/Migration/Resources/Database/Columns/MediumText.php index 35acc319..6b0df0c2 100644 --- a/src/Migration/Resources/Database/Columns/MediumText.php +++ b/src/Migration/Resources/Database/Columns/MediumText.php @@ -14,7 +14,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 16777215, + int $size = Column::SIZES[Column::TYPE_MEDIUMTEXT], string $format = '', string $createdAt = '', string $updatedAt = '' diff --git a/src/Migration/Resources/Database/Columns/RegularText.php b/src/Migration/Resources/Database/Columns/RegularText.php index 9ae55b13..938f842f 100644 --- a/src/Migration/Resources/Database/Columns/RegularText.php +++ b/src/Migration/Resources/Database/Columns/RegularText.php @@ -14,7 +14,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 65535, + int $size = Column::SIZES[Column::TYPE_TEXT], string $format = '', string $createdAt = '', string $updatedAt = '' diff --git a/src/Migration/Resources/Database/Columns/URL.php b/src/Migration/Resources/Database/Columns/URL.php index 643fe43b..9622bf0b 100644 --- a/src/Migration/Resources/Database/Columns/URL.php +++ b/src/Migration/Resources/Database/Columns/URL.php @@ -13,7 +13,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 2000, + int $size = Column::FORMAT_SIZES[Column::TYPE_URL], string $createdAt = '', string $updatedAt = '' ) { diff --git a/src/Migration/Resources/Database/Columns/Varchar.php b/src/Migration/Resources/Database/Columns/Varchar.php index 9df3a4bb..ad614c01 100644 --- a/src/Migration/Resources/Database/Columns/Varchar.php +++ b/src/Migration/Resources/Database/Columns/Varchar.php @@ -14,7 +14,7 @@ public function __construct( bool $required = false, ?string $default = null, bool $array = false, - int $size = 255, + int $size = Column::DEFAULT_VARCHAR_SIZE, string $format = '', string $createdAt = '', string $updatedAt = '' diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index d057e5d0..e649443a 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -3269,15 +3269,20 @@ public static function getRecord(string $databaseType, array $record): Resource public static function getColumn(Table $table, mixed $column): Column { - return match ($column['type']) { - Column::TYPE_STRING => match ($column['format'] ?? '') { + // Appwrite accepts a format (`email`, `url`, `ip`, `enum`) as a type on + // an inline column definition, and reports no size for the types whose + // size the type itself implies. Resolve both the way the server does. + ['type' => $type, 'format' => $format, 'size' => $size] = Column::resolve($column); + + return match ($type) { + Column::TYPE_STRING => match ($format) { Column::TYPE_EMAIL => new Email( $column['key'], $table, required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 254, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3288,7 +3293,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? UtopiaDatabase::LENGTH_KEY, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3298,7 +3303,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 2000, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3308,7 +3313,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 39, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3318,7 +3323,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 0, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3446,7 +3451,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 255, + size: $size ?: Column::DEFAULT_VARCHAR_SIZE, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3457,7 +3462,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 65535, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3468,7 +3473,7 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 16777215, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), @@ -3479,13 +3484,13 @@ public static function getColumn(Table $table, mixed $column): Column required: $column['required'], default: $column['default'], array: $column['array'], - size: $column['size'] ?? 2147483647, + size: $size, createdAt: $column['$createdAt'] ?? '', updatedAt: $column['$updatedAt'] ?? '', ), - default => throw new \InvalidArgumentException("Unsupported column type: {$column['type']}"), + default => throw new \InvalidArgumentException("Unsupported column type: {$type}"), }; } diff --git a/tests/Migration/Unit/Resources/ColumnTest.php b/tests/Migration/Unit/Resources/ColumnTest.php new file mode 100644 index 00000000..045f155e --- /dev/null +++ b/tests/Migration/Unit/Resources/ColumnTest.php @@ -0,0 +1,109 @@ +assertSame( + ['type' => Column::TYPE_TEXT, 'format' => '', 'size' => 65535], + Column::resolve(['key' => 'body', 'type' => Column::TYPE_TEXT]), + ); + + $this->assertSame( + ['type' => Column::TYPE_MEDIUMTEXT, 'format' => '', 'size' => 16777215], + Column::resolve(['key' => 'summary', 'type' => Column::TYPE_MEDIUMTEXT]), + ); + + $this->assertSame( + ['type' => Column::TYPE_LONGTEXT, 'format' => '', 'size' => 2147483647], + Column::resolve(['key' => 'archive', 'type' => Column::TYPE_LONGTEXT]), + ); + } + + public function testFixedWidthTypesIgnoreAReportedSize(): void + { + $this->assertSame( + ['type' => Column::TYPE_TEXT, 'format' => '', 'size' => 65535], + Column::resolve(['key' => 'body', 'type' => Column::TYPE_TEXT, 'size' => 128]), + ); + } + + public function testFormatShorthandsBecomeAString(): void + { + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_EMAIL, 'size' => 254], + Column::resolve(['key' => 'email', 'type' => Column::TYPE_EMAIL]), + ); + + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_URL, 'size' => 2000], + Column::resolve(['key' => 'website', 'type' => Column::TYPE_URL]), + ); + + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_IP, 'size' => 39], + Column::resolve(['key' => 'address', 'type' => Column::TYPE_IP]), + ); + + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_ENUM, 'size' => UtopiaDatabase::LENGTH_KEY], + Column::resolve(['key' => 'status', 'type' => Column::TYPE_ENUM]), + ); + } + + public function testFormattedStringWithoutSizeFallsBackToTheFormatSize(): void + { + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_EMAIL, 'size' => 254], + Column::resolve([ + 'key' => 'email', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_EMAIL, + ]), + ); + } + + public function testExplicitSizeWins(): void + { + $this->assertSame( + ['type' => Column::TYPE_STRING, 'format' => Column::TYPE_EMAIL, 'size' => 512], + Column::resolve(['key' => 'email', 'type' => Column::TYPE_EMAIL, 'size' => 512]), + ); + + $this->assertSame( + ['type' => Column::TYPE_VARCHAR, 'format' => '', 'size' => 64], + Column::resolve(['key' => 'slug', 'type' => Column::TYPE_VARCHAR, 'size' => 64]), + ); + + // A size that survived a round trip through a string stays a size. + $this->assertSame( + ['type' => Column::TYPE_VARCHAR, 'format' => '', 'size' => 64], + Column::resolve(['key' => 'slug', 'type' => Column::TYPE_VARCHAR, 'size' => '64']), + ); + } + + public function testUnsizedAndUnknownDefinitionsResolveToZero(): void + { + $this->assertSame( + ['type' => Column::TYPE_VARCHAR, 'format' => '', 'size' => 0], + Column::resolve(['key' => 'slug', 'type' => Column::TYPE_VARCHAR]), + ); + + $this->assertSame( + ['type' => '', 'format' => '', 'size' => 0], + Column::resolve(['key' => 'unknown']), + ); + } +} diff --git a/tests/Migration/Unit/Sources/AppwriteColumnTest.php b/tests/Migration/Unit/Sources/AppwriteColumnTest.php new file mode 100644 index 00000000..743b9ace --- /dev/null +++ b/tests/Migration/Unit/Sources/AppwriteColumnTest.php @@ -0,0 +1,232 @@ +table = new Table(new Database('main', 'Main'), 'Modules', 'modules'); + } + + /** + * @param array $overrides + * @return array + */ + private function payload(array $overrides): array + { + return \array_merge([ + 'key' => 'column', + 'required' => false, + 'default' => null, + 'array' => false, + '$createdAt' => '2026-01-01T00:00:00.000+00:00', + '$updatedAt' => '2026-01-01T00:00:00.000+00:00', + ], $overrides); + } + + public function testFixedWidthTypesWithoutASize(): void + { + $text = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'modulePath', + 'type' => Column::TYPE_TEXT, + ])); + + $this->assertInstanceOf(RegularText::class, $text); + $this->assertSame(Column::TYPE_TEXT, $text->getType()); + $this->assertSame(65535, $text->getSize()); + + $medium = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'summary', + 'type' => Column::TYPE_MEDIUMTEXT, + ])); + + $this->assertInstanceOf(MediumText::class, $medium); + $this->assertSame(Column::TYPE_MEDIUMTEXT, $medium->getType()); + $this->assertSame(16777215, $medium->getSize()); + + $long = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'archive', + 'type' => Column::TYPE_LONGTEXT, + ])); + + $this->assertInstanceOf(LongText::class, $long); + $this->assertSame(Column::TYPE_LONGTEXT, $long->getType()); + $this->assertSame(2147483647, $long->getSize()); + } + + public function testVarcharKeepsItsSize(): void + { + $varchar = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'slug', + 'type' => Column::TYPE_VARCHAR, + 'size' => 64, + ])); + + $this->assertInstanceOf(Varchar::class, $varchar); + $this->assertSame(Column::TYPE_VARCHAR, $varchar->getType()); + $this->assertSame(64, $varchar->getSize()); + } + + public function testStringKeepsItsSize(): void + { + $string = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'title', + 'type' => Column::TYPE_STRING, + 'size' => 128, + 'format' => '', + ])); + + $this->assertInstanceOf(Text::class, $string); + $this->assertSame(Column::TYPE_STRING, $string->getType()); + $this->assertSame(128, $string->getSize()); + } + + public function testFormattedStringsWithoutASize(): void + { + $email = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'email', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_EMAIL, + ])); + + $this->assertInstanceOf(Email::class, $email); + $this->assertSame(Column::TYPE_EMAIL, $email->getFormat()); + $this->assertSame(254, $email->getSize()); + + $url = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'website', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_URL, + ])); + + $this->assertInstanceOf(URL::class, $url); + $this->assertSame(2000, $url->getSize()); + + $ip = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'address', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_IP, + ])); + + $this->assertInstanceOf(IP::class, $ip); + $this->assertSame(39, $ip->getSize()); + + $enum = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'status', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_ENUM, + 'elements' => ['on', 'off'], + ])); + + $this->assertInstanceOf(Enum::class, $enum); + $this->assertSame(UtopiaDatabase::LENGTH_KEY, $enum->getSize()); + $this->assertSame(['on', 'off'], $enum->getElements()); + } + + public function testFormatShorthandResolvesLikeTheFormattedString(): void + { + $shorthand = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'email', + 'type' => Column::TYPE_EMAIL, + ])); + + $formatted = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'email', + 'type' => Column::TYPE_STRING, + 'format' => Column::TYPE_EMAIL, + ])); + + $this->assertEquals($formatted->jsonSerialize(), $shorthand->jsonSerialize()); + } + + public function testFormatShorthandUsedAsAType(): void + { + $email = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'email', + 'type' => Column::TYPE_EMAIL, + ])); + + $this->assertInstanceOf(Email::class, $email); + $this->assertSame(Column::TYPE_EMAIL, $email->getFormat()); + $this->assertSame(254, $email->getSize()); + + $url = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'website', + 'type' => Column::TYPE_URL, + ])); + + $this->assertInstanceOf(URL::class, $url); + $this->assertSame(Column::TYPE_URL, $url->getFormat()); + $this->assertSame(2000, $url->getSize()); + + $ip = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'address', + 'type' => Column::TYPE_IP, + ])); + + $this->assertInstanceOf(IP::class, $ip); + $this->assertSame(Column::TYPE_IP, $ip->getFormat()); + $this->assertSame(39, $ip->getSize()); + + $enum = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'status', + 'type' => Column::TYPE_ENUM, + 'elements' => ['on', 'off'], + 'default' => 'on', + ])); + + $this->assertInstanceOf(Enum::class, $enum); + $this->assertSame(Column::TYPE_ENUM, $enum->getFormat()); + $this->assertSame(UtopiaDatabase::LENGTH_KEY, $enum->getSize()); + $this->assertSame('on', $enum->getDefault()); + } + + public function testDerivedSizeSurvivesTheAttributeConversion(): void + { + $attribute = Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'modulePath', + 'type' => Column::TYPE_TEXT, + ]))->getAttribute(); + + $this->assertSame(Column::TYPE_TEXT, $attribute->getType()); + $this->assertSame(65535, $attribute->getSize()); + } + + public function testUnsupportedTypeThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported column type: blob'); + + Appwrite::getColumn($this->table, $this->payload([ + 'key' => 'unknown', + 'type' => 'blob', + ])); + } +}