From b177c8b0a7f896c4aaf53a6a6f99c35b2afa547a Mon Sep 17 00:00:00 2001 From: Sergei Tigrov Date: Thu, 30 May 2024 15:55:05 +0700 Subject: [PATCH] ColumnSchema classes for performance of typecasting (#303) --- CHANGELOG.md | 2 + src/ColumnSchema.php | 87 ----------------- src/Schema.php | 151 ++++++++++++------------------ tests/ColumnSchemaTest.php | 53 +++++------ tests/Provider/SchemaProvider.php | 14 +++ tests/SchemaTest.php | 11 ++- tests/Support/Fixture/mysql.sql | 1 + 7 files changed, 111 insertions(+), 208 deletions(-) delete mode 100644 src/ColumnSchema.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b80cad409..8a7abc8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - Chg #297: Remove `QueryBuilder::getColumnType()` child method as legacy code (@Tigrov) - Enh #300: Refactor insert default values (@Tigrov) +- Enh #303: Implement `ColumnSchemaInterface` classes according to the data type of database table columns + for type casting performance. Related with yiisoft/db#752 (@Tigrov) - Enh #309: Move methods from `Command` to `AbstractPdoCommand` class (@Tigrov) - Bug #302: Refactor `DMLQueryBuilder`, related with yiisoft/db#746 (@Tigrov) diff --git a/src/ColumnSchema.php b/src/ColumnSchema.php deleted file mode 100644 index 90d5852cb..000000000 --- a/src/ColumnSchema.php +++ /dev/null @@ -1,87 +0,0 @@ -name('id'); - * $column->allowNull(false); - * $column->dbType('int(11)'); - * $column->phpType('integer'); - * $column->type('integer'); - * $column->defaultValue(0); - * $column->autoIncrement(true); - * $column->primaryKey(true); - * ``` - */ -final class ColumnSchema extends AbstractColumnSchema -{ - /** - * Converts the input value according to {@see phpType} after retrieval from the database. - * - * If the value is `null` or an {@see \Yiisoft\Db\Expression\Expression}, it won't be converted. - * - * @param mixed $value The value to convert. - * - * @throws JsonException If the value can't be decoded. - * - * @return mixed The converted value. - */ - public function phpTypecast(mixed $value): mixed - { - if ($value === null) { - return null; - } - - if ($this->getType() === SchemaInterface::TYPE_JSON) { - return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); - } - - return parent::phpTypecast($value); - } - - /** - * Converts the input value according to {@see type} and {@see dbType} for use in a db query. - * - * If the value is `null` or an {@see \Yiisoft\Db\Expression\Expression}, it won't be converted. - * - * @param mixed $value The value to convert. - * - * @return mixed The converted value. This may also be an array containing the value as the first element and the - * PDO type as the second element. - */ - public function dbTypecast(mixed $value): mixed - { - if ($value === null || $value instanceof ExpressionInterface) { - return $value; - } - - if ($this->getType() === SchemaInterface::TYPE_JSON) { - return new JsonExpression($value, $this->getDbType()); - } - - return parent::dbTypecast($value); - } -} diff --git a/src/Schema.php b/src/Schema.php index dc32199f2..619fe58af 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -4,7 +4,6 @@ namespace Yiisoft\Db\Mysql; -use JsonException; use Throwable; use Yiisoft\Db\Constraint\Constraint; use Yiisoft\Db\Constraint\ForeignKeyConstraint; @@ -16,7 +15,7 @@ use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Helper\DbArrayHelper; use Yiisoft\Db\Schema\Builder\ColumnInterface; -use Yiisoft\Db\Schema\ColumnSchemaInterface; +use Yiisoft\Db\Schema\Column\ColumnSchemaInterface; use Yiisoft\Db\Schema\TableSchemaInterface; use function array_change_key_case; @@ -39,26 +38,6 @@ /** * Implements MySQL, MariaDB specific schema, supporting MySQL Server 5.7, MariaDB Server 10.4 and higher. * - * @psalm-type ColumnArray = array{ - * table_schema: string, - * table_name: string, - * column_name: string, - * data_type: string, - * type_type: string|null, - * character_maximum_length: int, - * column_comment: string|null, - * modifier: int, - * is_nullable: bool, - * column_default: mixed, - * is_autoinc: bool, - * sequence_name: string|null, - * enum_values: array|string|null, - * numeric_precision: int|null, - * numeric_scale: int|null, - * size: string|null, - * is_pkey: bool|null, - * dimension: int - * } * @psalm-type ColumnInfoArray = array{ * field: string, * type: string, @@ -69,7 +48,11 @@ * extra: string, * extra_default_value: string|null, * privileges: string, - * comment: string + * comment: string, + * enum_values?: string[], + * size?: int, + * precision?: int, + * scale?: int, * } * @psalm-type RowConstraint = array{ * constraint_name: string, @@ -195,8 +178,7 @@ protected function findColumns(TableSchemaInterface $table): bool // Chapter 1: crutches for MariaDB. {@see https://github.com/yiisoft/yii2/issues/19747} $columnsExtra = []; if (str_contains($this->db->getServerVersion(), 'MariaDB')) { - /** @psalm-var array[] $columnsExtra */ - $columnsExtra = $this->db->createCommand( + $rows = $this->db->createCommand( <<queryAll(); /** @psalm-var string[] $cols */ - foreach ($columnsExtra as $cols) { + foreach ($rows as $cols) { $columnsExtra[$cols['name']] = $cols['default_value']; } } @@ -231,20 +213,20 @@ protected function findColumns(TableSchemaInterface $table): bool /** @psalm-var ColumnInfoArray $info */ foreach ($columns as $info) { + /** @psalm-var ColumnInfoArray $info */ $info = array_change_key_case($info); - $info['extra_default_value'] = $columnsExtra[(string) $info['field']] ?? ''; + $info['extra_default_value'] = $columnsExtra[$info['field']] ?? ''; if (in_array($info['field'], $jsonColumns, true)) { $info['type'] = self::TYPE_JSON; } - /** @psalm-var ColumnInfoArray $info */ $column = $this->loadColumnSchema($info); - $table->column($column->getName(), $column); + $table->column($info['field'], $column); if ($column->isPrimaryKey()) { - $table->primaryKey($column->getName()); + $table->primaryKey($info['field']); if ($column->isAutoIncrement()) { $table->sequenceName(''); } @@ -469,64 +451,27 @@ protected function getCreateTableSql(TableSchemaInterface $table): string * * @param array $info The column information. * - * @throws JsonException - * * @return ColumnSchemaInterface The column schema object. * * @psalm-param ColumnInfoArray $info The column information. */ - protected function loadColumnSchema(array $info): ColumnSchemaInterface + private function loadColumnSchema(array $info): ColumnSchemaInterface { $dbType = $info['type']; - - $column = $this->createColumnSchema($info['field']); - + $type = $this->getColumnType($dbType, $info); + $isUnsigned = stripos($dbType, 'unsigned') !== false; /** @psalm-var ColumnInfoArray $info */ + $column = $this->createColumnSchema($type, unsigned: $isUnsigned); + $column->name($info['field']); + $column->enumValues($info['enum_values'] ?? null); + $column->size($info['size'] ?? null); + $column->precision($info['precision'] ?? null); + $column->scale($info['scale'] ?? null); $column->allowNull($info['null'] === 'YES'); $column->primaryKey(str_contains($info['key'], 'PRI')); $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false); $column->comment($info['comment']); $column->dbType($dbType); - $column->unsigned(stripos($dbType, 'unsigned') !== false); - $column->type(self::TYPE_STRING); - - if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $dbType, $matches)) { - $type = strtolower($matches[1]); - - if (isset(self::TYPE_MAP[$type])) { - $column->type(self::TYPE_MAP[$type]); - } - - if (!empty($matches[2])) { - if ($type === 'enum') { - preg_match_all("/'[^']*'/", $matches[2], $values); - - foreach ($values[0] as $i => $value) { - $values[$i] = trim($value, "'"); - } - - $column->enumValues($values); - } else { - $values = explode(',', $matches[2]); - $column->precision((int) $values[0]); - $column->size((int) $values[0]); - - if (isset($values[1])) { - $column->scale((int) $values[1]); - } - - if ($type === 'bit') { - if ($column->getSize() === 1) { - $column->type(self::TYPE_BOOLEAN); - } elseif ($column->getSize() > 32) { - $column->type(self::TYPE_BIGINT); - } elseif ($column->getSize() === 32) { - $column->type(self::TYPE_INTEGER); - } - } - } - } - } // Chapter 2: crutches for MariaDB {@see https://github.com/yiisoft/yii2/issues/19747} $extra = $info['extra']; @@ -534,7 +479,7 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface empty($extra) && !empty($info['extra_default_value']) && !str_starts_with($info['extra_default_value'], '\'') - && in_array($column->getType(), [ + && in_array($type, [ self::TYPE_CHAR, self::TYPE_STRING, self::TYPE_TEXT, self::TYPE_DATETIME, self::TYPE_TIMESTAMP, self::TYPE_TIME, self::TYPE_DATE, ], true) @@ -543,7 +488,6 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface } $column->extra($extra); - $column->phpType($this->getColumnPhpType($column)); $column->defaultValue($this->normalizeDefaultValue($info['default'], $column)); if (str_starts_with($extra, 'DEFAULT_GENERATED')) { @@ -553,6 +497,45 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface return $column; } + /** + * Get the abstract data type for the database data type. + * + * @param string $dbType The database data type + * @param array $info Column information. + * + * @return string The abstract data type. + */ + private function getColumnType(string $dbType, array &$info): string + { + preg_match('/^(\w*)(?:\(([^)]+)\))?/', $dbType, $matches); + $dbType = strtolower($matches[1]); + + if (!empty($matches[2])) { + if ($dbType === 'enum') { + preg_match_all("/'([^']*)'/", $matches[2], $values); + $info['enum_values'] = $values[1]; + } else { + $values = explode(',', $matches[2], 2); + $info['size'] = (int) $values[0]; + $info['precision'] = (int) $values[0]; + + if (isset($values[1])) { + $info['scale'] = (int) $values[1]; + } + + if ($dbType === 'bit') { + return match (true) { + $info['size'] === 1 => self::TYPE_BOOLEAN, + $info['size'] > 32 => self::TYPE_BIGINT, + default => self::TYPE_INTEGER, + }; + } + } + } + + return self::TYPE_MAP[$dbType] ?? self::TYPE_STRING; + } + /** * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database. * @@ -907,18 +890,6 @@ protected function resolveTableCreateSql(TableSchemaInterface $table): void $table->createSql($sql); } - /** - * Creates a column schema for the database. - * - * This method may be overridden by child classes to create a DBMS-specific column schema. - * - * @param string $name Name of the column. - */ - private function createColumnSchema(string $name): ColumnSchema - { - return new ColumnSchema($name); - } - /** * @throws Exception * @throws InvalidConfigException diff --git a/tests/ColumnSchemaTest.php b/tests/ColumnSchemaTest.php index ec89e1ceb..1f026d0e3 100644 --- a/tests/ColumnSchemaTest.php +++ b/tests/ColumnSchemaTest.php @@ -4,15 +4,17 @@ namespace Yiisoft\Db\Mysql\Tests; -use JsonException; -use PHPUnit\Framework\TestCase; use Throwable; use Yiisoft\Db\Exception\Exception; -use Yiisoft\Db\Expression\JsonExpression; -use Yiisoft\Db\Mysql\ColumnSchema; use Yiisoft\Db\Mysql\Tests\Support\TestTrait; use Yiisoft\Db\Query\Query; -use Yiisoft\Db\Schema\SchemaInterface; +use Yiisoft\Db\Schema\Column\BinaryColumnSchema; +use Yiisoft\Db\Schema\Column\BooleanColumnSchema; +use Yiisoft\Db\Schema\Column\DoubleColumnSchema; +use Yiisoft\Db\Schema\Column\IntegerColumnSchema; +use Yiisoft\Db\Schema\Column\JsonColumnSchema; +use Yiisoft\Db\Schema\Column\StringColumnSchema; +use Yiisoft\Db\Tests\Common\CommonColumnSchemaTest; use function str_repeat; @@ -21,7 +23,7 @@ * * @psalm-suppress PropertyNotSetInConstructor */ -final class ColumnSchemaTest extends TestCase +final class ColumnSchemaTest extends CommonColumnSchemaTest { use TestTrait; @@ -43,28 +45,6 @@ public function testColumnBigInt(string $bigint): void $this->assertSame($bigint, $query['bigint_col']); } - public function testDbTypeCastJson(): void - { - $columnSchema = new ColumnSchema('json'); - - $columnSchema->dbType(SchemaInterface::TYPE_JSON); - $columnSchema->type(SchemaInterface::TYPE_JSON); - - $this->assertEquals(new JsonExpression('{"a":1}', SchemaInterface::TYPE_JSON), $columnSchema->dbTypeCast('{"a":1}')); - } - - /** - * @throws JsonException - */ - public function testPhpTypeCastJson(): void - { - $columnSchema = new ColumnSchema('json'); - - $columnSchema->type(SchemaInterface::TYPE_JSON); - - $this->assertSame(['a' => 1], $columnSchema->phpTypeCast('{"a":1}')); - } - public function testPhpTypeCast(): void { $db = $this->getConnection(true); @@ -77,6 +57,7 @@ public function testPhpTypeCast(): void 'type', [ 'int_col' => 1, + 'bigunsigned_col' => 1234567890, 'char_col' => str_repeat('x', 100), 'char_col3' => null, 'float_col' => 1.234, @@ -93,6 +74,7 @@ public function testPhpTypeCast(): void $this->assertNotNull($tableSchema); $intColPhpType = $tableSchema->getColumn('int_col')?->phpTypecast($query['int_col']); + $bigUnsignedColPhpType = $tableSchema->getColumn('bigunsigned_col')?->phpTypecast($query['bigunsigned_col']); $charColPhpType = $tableSchema->getColumn('char_col')?->phpTypecast($query['char_col']); $charCol3PhpType = $tableSchema->getColumn('char_col3')?->phpTypecast($query['char_col3']); $floatColPhpType = $tableSchema->getColumn('float_col')?->phpTypecast($query['float_col']); @@ -103,6 +85,7 @@ public function testPhpTypeCast(): void $jsonColPhpType = $tableSchema->getColumn('json_col')?->phpTypecast($query['json_col']); $this->assertSame(1, $intColPhpType); + $this->assertSame('1234567890', $bigUnsignedColPhpType); $this->assertSame(str_repeat('x', 100), $charColPhpType); $this->assertNull($charCol3PhpType); $this->assertSame(1.234, $floatColPhpType); @@ -114,4 +97,18 @@ public function testPhpTypeCast(): void $db->close(); } + + public function testColumnSchemaInstance() + { + $db = $this->getConnection(true); + $schema = $db->getSchema(); + $tableSchema = $schema->getTableSchema('type'); + + $this->assertInstanceOf(IntegerColumnSchema::class, $tableSchema->getColumn('int_col')); + $this->assertInstanceOf(StringColumnSchema::class, $tableSchema->getColumn('char_col')); + $this->assertInstanceOf(DoubleColumnSchema::class, $tableSchema->getColumn('float_col')); + $this->assertInstanceOf(BinaryColumnSchema::class, $tableSchema->getColumn('blob_col')); + $this->assertInstanceOf(BooleanColumnSchema::class, $tableSchema->getColumn('bool_col')); + $this->assertInstanceOf(JsonColumnSchema::class, $tableSchema->getColumn('json_col')); + } } diff --git a/tests/Provider/SchemaProvider.php b/tests/Provider/SchemaProvider.php index 960578df3..880f597e4 100644 --- a/tests/Provider/SchemaProvider.php +++ b/tests/Provider/SchemaProvider.php @@ -42,6 +42,20 @@ public static function columns(): array 'scale' => null, 'defaultValue' => 1, ], + 'bigunsigned_col' => [ + 'type' => 'bigint', + 'dbType' => 'bigint(20) unsigned', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => 20, + 'precision' => 20, + 'scale' => null, + 'unsigned' => true, + 'defaultValue' => '12345678901234567890', + ], 'tinyint_col' => [ 'type' => 'tinyint', 'dbType' => 'tinyint(3)', diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 067078801..6e5b0a5c7 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -15,10 +15,10 @@ use Yiisoft\Db\Exception\NotSupportedException; use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Mysql\Column; -use Yiisoft\Db\Mysql\ColumnSchema; use Yiisoft\Db\Mysql\Schema; use Yiisoft\Db\Mysql\Tests\Support\TestTrait; use Yiisoft\Db\Query\Query; +use Yiisoft\Db\Schema\Column\StringColumnSchema; use Yiisoft\Db\Schema\SchemaInterface; use Yiisoft\Db\Tests\Common\CommonSchemaTest; use Yiisoft\Db\Tests\Support\Assert; @@ -67,7 +67,7 @@ public function testAlternativeDisplayOfDefaultCurrentTimestampInMariaDB(): void 'comment' => '', ]]); - $this->assertInstanceOf(ColumnSchema::class, $column); + $this->assertInstanceOf(StringColumnSchema::class, $column); $this->assertInstanceOf(Expression::class, $column->getDefaultValue()); $this->assertEquals('CURRENT_TIMESTAMP', $column->getDefaultValue()); } @@ -96,7 +96,7 @@ public function testAlternativeDisplayOfDefaultCurrentTimestampAsNullInMariaDB() 'comment' => '', ]]); - $this->assertInstanceOf(ColumnSchema::class, $column); + $this->assertInstanceOf(StringColumnSchema::class, $column); $this->assertEquals(null, $column->getDefaultValue()); } @@ -124,6 +124,11 @@ public function testColumnSchema(array $columns, string $tableName): void $columns['int_col2']['size'] = null; $columns['int_col2']['precision'] = null; + // bigunsigned_col Mysql 8.0.17+. + $columns['bigunsigned_col']['dbType'] = 'bigint unsigned'; + $columns['bigunsigned_col']['size'] = null; + $columns['bigunsigned_col']['precision'] = null; + // tinyint_col Mysql 8.0.17+. $columns['tinyint_col']['dbType'] = 'tinyint'; $columns['tinyint_col']['size'] = null; diff --git a/tests/Support/Fixture/mysql.sql b/tests/Support/Fixture/mysql.sql index 92ea625b3..f18170a61 100644 --- a/tests/Support/Fixture/mysql.sql +++ b/tests/Support/Fixture/mysql.sql @@ -148,6 +148,7 @@ CREATE TABLE `negative_default_values` ( CREATE TABLE `type` ( `int_col` integer NOT NULL, `int_col2` integer DEFAULT '1', + `bigunsigned_col` bigint unsigned DEFAULT '12345678901234567890', `tinyint_col` tinyint(3) DEFAULT '1', `smallint_col` smallint(1) DEFAULT '1', `char_col` char(100) NOT NULL,