├── tools ├── .gitignore ├── psalm │ └── composer.json └── infection │ └── composer.json ├── tests ├── .env ├── bootstrap.php ├── PdoCommandTest.php ├── BatchQueryResultTest.php ├── QueryGetTableAliasTest.php ├── ColumnDefinitionParserTest.php ├── Support │ ├── IntegrationTestTrait.php │ ├── TestConnection.php │ └── Fixture │ │ └── mysql.sql ├── SqlParserTest.php ├── Provider │ ├── CommandProvider.php │ ├── SqlParserProvider.php │ ├── ColumnBuilderProvider.php │ ├── QuoterProvider.php │ ├── ColumnFactoryProvider.php │ └── ColumnProvider.php ├── ColumnBuilderTest.php ├── Column │ ├── ColumnDefinitionBuilderTest.php │ └── EnumColumnTest.php ├── PdoDriverTest.php ├── DsnSocketTest.php ├── DsnTest.php ├── QuoterTest.php ├── ColumnFactoryTest.php ├── QueryTest.php ├── PdoConnectionTest.php ├── CommandTest.php ├── ConnectionTest.php ├── ColumnTest.php ├── DeadLockTest.php └── SchemaTest.php ├── .phpunit-watcher.yml ├── src ├── Transaction.php ├── IndexMethod.php ├── IndexType.php ├── Quoter.php ├── Builder │ ├── LikeBuilder.php │ ├── LongestBuilder.php │ ├── ShortestBuilder.php │ ├── JsonOverlapsBuilder.php │ └── ArrayMergeBuilder.php ├── ServerInfo.php ├── Column │ ├── StringColumn.php │ ├── ColumnDefinitionParser.php │ ├── DateTimeColumn.php │ ├── ColumnBuilder.php │ ├── ColumnDefinitionBuilder.php │ └── ColumnFactory.php ├── QueryBuilder.php ├── Driver.php ├── SqlParser.php ├── DsnSocket.php ├── Dsn.php ├── Connection.php ├── DQLQueryBuilder.php ├── Command.php ├── DDLQueryBuilder.php ├── DMLQueryBuilder.php └── Schema.php ├── infection.json.dist ├── docker-compose.yml ├── docker-compose-mariadb.yml ├── .php-cs-fixer.dist.php ├── psalm.xml ├── rector.php ├── LICENSE.md ├── composer.json ├── README.md └── CHANGELOG.md /tools/.gitignore: -------------------------------------------------------------------------------- 1 | /*/vendor 2 | /*/composer.lock 3 | -------------------------------------------------------------------------------- /tools/psalm/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "vimeo/psalm": "^5.26.1 || ^6.8.8" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/.env: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=local 2 | YII_MYSQL_DATABASE=yii 3 | YII_MYSQL_HOST=mysql 4 | YII_MYSQL_PORT=3306 5 | YII_MYSQL_USER=root 6 | YII_MYSQL_PASSWORD=root 7 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | load(); 8 | } 9 | -------------------------------------------------------------------------------- /tools/infection/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "infection/infection": "^0.26 || ^0.31.9" 4 | }, 5 | "config": { 6 | "allow-plugins": { 7 | "infection/extension-installer": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.phpunit-watcher.yml: -------------------------------------------------------------------------------- 1 | watch: 2 | directories: 3 | - src 4 | - tests 5 | fileMask: '*.php' 6 | notifications: 7 | passingTests: false 8 | failingTests: false 9 | phpunit: 10 | binaryPath: vendor/bin/phpunit 11 | timeout: 180 12 | -------------------------------------------------------------------------------- /src/Transaction.php: -------------------------------------------------------------------------------- 1 | : 11 | - 3306:3306 12 | volumes: 13 | - type: tmpfs 14 | target: /var/lib/mysql 15 | -------------------------------------------------------------------------------- /tests/BatchQueryResultTest.php: -------------------------------------------------------------------------------- 1 | : < MySQL Port running inside container> 14 | - '3306:3306' 15 | expose: 16 | # Opens port 3306 on the container 17 | - '3306' 18 | -------------------------------------------------------------------------------- /src/IndexMethod.php: -------------------------------------------------------------------------------- 1 | in([ 10 | __DIR__ . '/src', 11 | __DIR__ . '/tests', 12 | ]); 13 | 14 | return (new Config()) 15 | ->setParallelConfig(ParallelConfigFactory::detect()) 16 | ->setRules([ 17 | '@PER-CS3.0' => true, 18 | 'no_unused_imports' => true, 19 | 'ordered_class_elements' => true, 20 | 'class_attributes_separation' => ['elements' => ['method' => 'one']], 21 | ]) 22 | ->setFinder($finder); 23 | -------------------------------------------------------------------------------- /src/Quoter.php: -------------------------------------------------------------------------------- 1 | '\\\\', 20 | "\x00" => '\\0', 21 | "\n" => '\\n', 22 | "\r" => '\\r', 23 | "'" => "\'", 24 | '"' => '\"', 25 | "\x1a" => '\\Z', 26 | ]) . "'"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Builder/LikeBuilder.php: -------------------------------------------------------------------------------- 1 | caseSensitive === true) { 20 | $column = 'BINARY ' . $column; 21 | } 22 | 23 | return $column; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Support/IntegrationTestTrait.php: -------------------------------------------------------------------------------- 1 | timezone) || $refresh) { 18 | /** @var string */ 19 | $this->timezone = $this->db->createCommand( 20 | "SELECT LPAD(TIME_FORMAT(TIMEDIFF(NOW(), UTC_TIMESTAMP), '%H:%i'), 6, '+')", 21 | )->queryScalar(); 22 | } 23 | 24 | return $this->timezone; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/SqlParserTest.php: -------------------------------------------------------------------------------- 1 | characterSet = $characterSet; 23 | return $this; 24 | } 25 | 26 | /** 27 | * Returns the character set of the column. 28 | * 29 | * @psalm-mutation-free 30 | */ 31 | public function getCharacterSet(): ?string 32 | { 33 | return $this->characterSet; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Provider/CommandProvider.php: -------------------------------------------------------------------------------- 1 | ColumnBuilder::integer()], ['col1'], IndexType::UNIQUE, null], 18 | [['col1' => ColumnBuilder::text()], ['col1'], IndexType::FULLTEXT, null], 19 | [['col1' => 'point NOT NULL'], ['col1'], IndexType::SPATIAL, null], 20 | [['col1' => ColumnBuilder::integer()], ['col1'], null, IndexMethod::BTREE], 21 | [['col1' => ColumnBuilder::integer()], ['col1'], null, IndexMethod::HASH], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/ColumnBuilderTest.php: -------------------------------------------------------------------------------- 1 | withPaths([ 14 | __DIR__ . '/src', 15 | __DIR__ . '/tests', 16 | ]) 17 | ->withPhpSets(php81: true) 18 | ->withRules([ 19 | InlineConstructorDefaultToPropertyRector::class, 20 | ]) 21 | ->withSkip([ 22 | NullToStrictStringFuncCallArgRector::class, 23 | ReadOnlyPropertyRector::class, 24 | RemoveParentCallWithoutParentRector::class, 25 | AddParamBasedOnParentClassMethodRector::class, 26 | ]); 27 | -------------------------------------------------------------------------------- /tests/Provider/SqlParserProvider.php: -------------------------------------------------------------------------------- 1 | getQuoter(); 19 | $schema = $db->getSchema(); 20 | 21 | parent::__construct( 22 | $db, 23 | new DDLQueryBuilder($this, $quoter, $schema), 24 | new DMLQueryBuilder($this, $quoter, $schema), 25 | new DQLQueryBuilder($this, $quoter), 26 | new ColumnDefinitionBuilder($this), 27 | ); 28 | } 29 | 30 | protected function createSqlParser(string $sql): SqlParser 31 | { 32 | return new SqlParser($sql); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Driver.php: -------------------------------------------------------------------------------- 1 | attributes += [ 20 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 21 | PDO::ATTR_STRINGIFY_FETCHES => true, 22 | ]; 23 | 24 | $pdo = parent::createConnection(); 25 | 26 | if ($this->charset !== null) { 27 | $pdo->exec('SET NAMES ' . $pdo->quote($this->charset)); 28 | } elseif (!str_contains($this->dsn, 'charset')) { 29 | $pdo->exec('SET NAMES ' . $pdo->quote('utf8mb4')); 30 | } 31 | 32 | return $pdo; 33 | } 34 | 35 | public function getDriverName(): string 36 | { 37 | return 'mysql'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/SqlParser.php: -------------------------------------------------------------------------------- 1 | length - 1; 15 | 16 | while ($this->position < $length) { 17 | $pos = $this->position++; 18 | 19 | match ($this->sql[$pos]) { 20 | ':' => ($word = $this->parseWord()) === '' 21 | ? $this->skipChars(':') 22 | : $result = ':' . $word, 23 | '"', "'", '`' => $this->skipQuotedWithEscape($this->sql[$pos]), 24 | '-' => $this->sql[$this->position] === '-' 25 | ? ++$this->position && $this->skipToAfterChar("\n") 26 | : null, 27 | '/' => $this->sql[$this->position] === '*' 28 | ? ++$this->position && $this->skipToAfterString('*/') 29 | : null, 30 | default => null, 31 | }; 32 | 33 | if ($result !== null) { 34 | $position = $pos; 35 | 36 | return $result; 37 | } 38 | } 39 | 40 | return null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Column/ColumnDefinitionParser.php: -------------------------------------------------------------------------------- 1 | parseStringValue($info['extra'], '/\s*\b(?:CHARACTER SET|CHARSET)\s+(\S+)/i', 'characterSet', $info); 37 | 38 | /** @psalm-var ExtraInfo $info */ 39 | if (!empty($extra)) { 40 | $info['extra'] = $extra; 41 | } else { 42 | unset($info['extra']); 43 | } 44 | 45 | return $info; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Column/ColumnDefinitionBuilderTest.php: -------------------------------------------------------------------------------- 1 | ['int', new IntegerColumn()]; 20 | yield 'enum' => ["enum('a','b','c')", new EnumColumn(values: ['a', 'b', 'c'])]; 21 | yield 'enum-upper-case' => ["ENUM('a','b','c')", new EnumColumn(dbType: 'ENUM', values: ['a', 'b', 'c'])]; 22 | } 23 | 24 | #[DataProvider('dataBuild')] 25 | public function testBuild(string $expected, ColumnInterface $column): void 26 | { 27 | $builder = $this->createColumnDefinitionBuilder(); 28 | 29 | $result = $builder->build($column); 30 | 31 | $this->assertSame($expected, $result); 32 | } 33 | 34 | private function createColumnDefinitionBuilder(): ColumnDefinitionBuilder 35 | { 36 | return new ColumnDefinitionBuilder( 37 | TestConnection::getShared()->getQueryBuilder(), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Builder/LongestBuilder.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class LongestBuilder extends MultiOperandFunctionBuilder 23 | { 24 | /** 25 | * Builds a SQL expression to represent the function which returns the longest string. 26 | * 27 | * @param Longest $expression The expression to build. 28 | * @param array $params The parameters to bind. 29 | * 30 | * @return string The SQL expression. 31 | */ 32 | protected function buildFromExpression(MultiOperandFunction $expression, array &$params): string 33 | { 34 | $selects = []; 35 | 36 | foreach ($expression->getOperands() as $operand) { 37 | $selects[] = 'SELECT ' . $this->buildOperand($operand, $params) . ' AS value'; 38 | } 39 | 40 | $unions = implode(' UNION ', $selects); 41 | 42 | return "($unions ORDER BY LENGTH(value) DESC LIMIT 1)"; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Builder/ShortestBuilder.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class ShortestBuilder extends MultiOperandFunctionBuilder 23 | { 24 | /** 25 | * Builds a SQL expression to represent the function which returns the shortest string. 26 | * 27 | * @param Shortest $expression The expression to build. 28 | * @param array $params The parameters to bind. 29 | * 30 | * @return string The SQL expression. 31 | */ 32 | protected function buildFromExpression(MultiOperandFunction $expression, array &$params): string 33 | { 34 | $selects = []; 35 | 36 | foreach ($expression->getOperands() as $operand) { 37 | $selects[] = 'SELECT ' . $this->buildOperand($operand, $params) . ' AS value'; 38 | } 39 | 40 | $unions = implode(' UNION ', $selects); 41 | 42 | return "($unions ORDER BY LENGTH(value) ASC LIMIT 1)"; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/PdoDriverTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 22 | 23 | $pdo = $db->getActivePdo(); 24 | $charset = $pdo->query('SHOW VARIABLES LIKE \'character_set_client\'', PDO::FETCH_ASSOC)->fetch(); 25 | 26 | $this->assertEqualsIgnoringCase('utf8mb4', array_values($charset)[1]); 27 | 28 | $pdoDriver = TestConnection::createDriver(); 29 | $newCharset = 'latin1'; 30 | $pdoDriver->charset($newCharset); 31 | $pdo = $pdoDriver->createConnection(); 32 | $charset = $pdo->query('SHOW VARIABLES LIKE \'character_set_client\'', PDO::FETCH_ASSOC)->fetch(); 33 | 34 | $this->assertEqualsIgnoringCase($newCharset, array_values($charset)[1]); 35 | 36 | unset($pdo); 37 | } 38 | 39 | public function testCharsetDefault(): void 40 | { 41 | $db = $this->getSharedConnection(); 42 | $db->open(); 43 | $command = $db->createCommand(); 44 | 45 | $this->assertSame('utf8mb4', $command->setSql('SELECT @@character_set_client')->queryScalar()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Provider/ColumnBuilderProvider.php: -------------------------------------------------------------------------------- 1 | ) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/DsnSocketTest.php: -------------------------------------------------------------------------------- 1 | 'utf8']); 18 | 19 | $this->assertSame('mysql', $dsn->driver); 20 | $this->assertSame('/var/run/mysql/mysql.sock', $dsn->unixSocket); 21 | $this->assertSame('yiitest', $dsn->databaseName); 22 | $this->assertSame(['charset' => 'utf8'], $dsn->options); 23 | $this->assertSame('mysql:unix_socket=/var/run/mysql/mysql.sock;dbname=yiitest;charset=utf8', (string) $dsn); 24 | } 25 | 26 | public function testConstructDefaults(): void 27 | { 28 | $dsn = new DsnSocket(); 29 | 30 | $this->assertSame('mysql', $dsn->driver); 31 | $this->assertSame('/var/run/mysqld/mysqld.sock', $dsn->unixSocket); 32 | $this->assertSame('', $dsn->databaseName); 33 | $this->assertSame([], $dsn->options); 34 | $this->assertSame('mysql:unix_socket=/var/run/mysqld/mysqld.sock', (string) $dsn); 35 | } 36 | 37 | public function testConstructWithEmptyDatabase(): void 38 | { 39 | $dsn = new DsnSocket('mysql', '/var/run/mysqld/mysqld.sock', '', ['charset' => 'utf8']); 40 | 41 | $this->assertSame('mysql:unix_socket=/var/run/mysqld/mysqld.sock;charset=utf8', (string) $dsn); 42 | $this->assertEmpty($dsn->databaseName); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Column/DateTimeColumn.php: -------------------------------------------------------------------------------- 1 | [!WARNING] 13 | * > MySQL DBMS converts `TIMESTAMP` column type values from database session time zone to UTC for storage, and back 14 | * > from UTC to the session time zone when retrieve the values. 15 | * 16 | * `TIMESTAMP` database type does not store time zone offset and require to convert datetime values to the database 17 | * session time zone before insert and back to the PHP time zone after retrieve the values. This will be done in the 18 | * {@see dbTypecast()} and {@see phpTypecast()} methods and guarantees that the values are stored in the database 19 | * in the correct time zone. 20 | * 21 | * To avoid possible time zone issues with the datetime values conversion, it is recommended to set the PHP and database 22 | * time zones to UTC. 23 | */ 24 | final class DateTimeColumn extends \Yiisoft\Db\Schema\Column\DateTimeColumn 25 | { 26 | protected function getFormat(): string 27 | { 28 | return $this->format ??= match ($this->getType()) { 29 | ColumnType::DATETIMETZ => 'Y-m-d H:i:s' . $this->getMillisecondsFormat(), 30 | ColumnType::TIMETZ => 'H:i:s' . $this->getMillisecondsFormat(), 31 | default => parent::getFormat(), 32 | }; 33 | } 34 | 35 | protected function shouldConvertTimezone(): bool 36 | { 37 | return $this->shouldConvertTimezone ??= !empty($this->dbTimezone) && $this->getType() !== ColumnType::DATE; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Column/EnumColumnTest.php: -------------------------------------------------------------------------------- 1 | 'abc')"])] 18 | public function testNonEnumCheck(string $columnDefinition): void 19 | { 20 | $this->dropTable('test_enum_table'); 21 | $this->executeStatements( 22 | <<getSharedConnection(); 31 | $column = $db->getTableSchema('test_enum_table')->getColumn('status'); 32 | 33 | $this->assertNotInstanceOf(EnumColumn::class, $column); 34 | 35 | $this->dropTable('test_enum_table'); 36 | } 37 | 38 | protected function createDatabaseObjectsStatements(): array 39 | { 40 | return [ 41 | << 'utf8']); 18 | 19 | $this->assertSame('mysql', $dsn->driver); 20 | $this->assertSame('localhost', $dsn->host); 21 | $this->assertSame('yiitest', $dsn->databaseName); 22 | $this->assertSame('3307', $dsn->port); 23 | $this->assertSame(['charset' => 'utf8'], $dsn->options); 24 | $this->assertSame('mysql:host=localhost;dbname=yiitest;port=3307;charset=utf8', (string) $dsn); 25 | } 26 | 27 | public function testConstructDefaults(): void 28 | { 29 | $dsn = new Dsn(); 30 | 31 | $this->assertSame('mysql', $dsn->driver); 32 | $this->assertSame('127.0.0.1', $dsn->host); 33 | $this->assertSame('', $dsn->databaseName); 34 | $this->assertSame('3306', $dsn->port); 35 | $this->assertSame([], $dsn->options); 36 | $this->assertSame('mysql:host=127.0.0.1;port=3306', (string) $dsn); 37 | } 38 | 39 | public function testConstructWithEmptyPort(): void 40 | { 41 | $dsn = new Dsn(port: ''); 42 | 43 | $this->assertSame('mysql', $dsn->driver); 44 | $this->assertSame('127.0.0.1', $dsn->host); 45 | $this->assertSame('', $dsn->databaseName); 46 | $this->assertSame('', $dsn->port); 47 | $this->assertSame([], $dsn->options); 48 | $this->assertSame('mysql:host=127.0.0.1', (string) $dsn); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Column/ColumnBuilder.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class JsonOverlapsBuilder implements ExpressionBuilderInterface 23 | { 24 | public function __construct( 25 | private readonly QueryBuilderInterface $queryBuilder, 26 | ) {} 27 | 28 | /** 29 | * Build SQL for {@see JsonOverlaps}. 30 | * 31 | * @param JsonOverlaps $expression The {@see JsonOverlaps} to be built. 32 | * 33 | * @throws Exception 34 | * @throws InvalidArgumentException 35 | * @throws InvalidConfigException 36 | * @throws NotSupportedException 37 | */ 38 | public function build(ExpressionInterface $expression, array &$params = []): string 39 | { 40 | $column = $expression->column instanceof ExpressionInterface 41 | ? $this->queryBuilder->buildExpression($expression->column) 42 | : $this->queryBuilder->getQuoter()->quoteColumnName($expression->column); 43 | $values = $expression->values; 44 | 45 | if (!$values instanceof ExpressionInterface) { 46 | $values = new JsonValue($values); 47 | } 48 | 49 | $values = $this->queryBuilder->buildExpression($values, $params); 50 | 51 | return "JSON_OVERLAPS($column, $values)"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Provider/QuoterProvider.php: -------------------------------------------------------------------------------- 1 | '']], 55 | ['``', ['name' => '']], 56 | ['animal', ['name' => 'animal']], 57 | ['`animal`', ['name' => 'animal']], 58 | ['dbo.animal', ['schemaName' => 'dbo', 'name' => 'animal']], 59 | ['`dbo`.`animal`', ['schemaName' => 'dbo', 'name' => 'animal']], 60 | ['`dbo`.animal', ['schemaName' => 'dbo', 'name' => 'animal']], 61 | ['dbo.`animal`', ['schemaName' => 'dbo', 'name' => 'animal']], 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/QuoterTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 49 | 50 | $quoter = $db->getQuoter(); 51 | 52 | $this->assertSame("'1.1'", $quoter->quoteValue('1.1')); 53 | $this->assertSame("'1.1e0'", $quoter->quoteValue('1.1e0')); 54 | $this->assertSame("'test'", $quoter->quoteValue('test')); 55 | $this->assertSame("'test\'test'", $quoter->quoteValue("test'test")); 56 | 57 | $db->close(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DsnSocket.php: -------------------------------------------------------------------------------- 1 | 'utf8mb4']); 42 | * $driver = new Driver($dsn, 'username', 'password'); 43 | * $connection = new Connection($driver, $schemaCache); 44 | * ``` 45 | * 46 | * Will result in the DSN string `mysql:unix_socket=/var/run/mysqld/mysqld.sock;dbname=yiitest;charset=utf8mb4`. 47 | */ 48 | public function __toString(): string 49 | { 50 | $dsn = "$this->driver:unix_socket=$this->unixSocket"; 51 | 52 | if ($this->databaseName !== '') { 53 | $dsn .= ";dbname=$this->databaseName"; 54 | } 55 | 56 | foreach ($this->options as $key => $value) { 57 | $dsn .= ";$key=$value"; 58 | } 59 | 60 | return $dsn; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/ColumnFactoryTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 56 | $columnFactory = $db->getColumnFactory(); 57 | 58 | $column = $columnFactory->fromType(ColumnType::DATETIME, ['defaultValueRaw' => 'now()', 'extra' => 'DEFAULT_GENERATED']); 59 | 60 | $this->assertEquals(new Expression('now()'), $column->getDefaultValue()); 61 | } 62 | 63 | protected function getColumnFactoryClass(): string 64 | { 65 | return ColumnFactory::class; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Dsn.php: -------------------------------------------------------------------------------- 1 | $options 26 | */ 27 | public function __construct( 28 | public readonly string $driver = 'mysql', 29 | public readonly string $host = '127.0.0.1', 30 | public readonly string $databaseName = '', 31 | public readonly string $port = '3306', 32 | public readonly array $options = [], 33 | ) {} 34 | 35 | /** 36 | * @return string The Data Source Name, or DSN, has the information required to connect to the database. 37 | * 38 | * Please refer to the [PHP manual](https://php.net/manual/en/pdo.construct.php) on the format of the DSN string. 39 | * 40 | * The `driver` property is used as the driver prefix of the DSN, all further property-value pairs 41 | * or key-value pairs of `options` property are rendered as `key=value` and concatenated by `;`. For example: 42 | * 43 | * ```php 44 | * $dsn = new Dsn('mysql', '127.0.0.1', 'yiitest', '3306', ['charset' => 'utf8mb4']); 45 | * $driver = new Driver($dsn, 'username', 'password'); 46 | * $connection = new Connection($driver, $schemaCache); 47 | * ``` 48 | * 49 | * Will result in the DSN string `mysql:host=127.0.0.1;dbname=yiitest;port=3306;charset=utf8mb4`. 50 | */ 51 | public function __toString(): string 52 | { 53 | $dsn = "$this->driver:host=$this->host"; 54 | 55 | if ($this->databaseName !== '') { 56 | $dsn .= ";dbname=$this->databaseName"; 57 | } 58 | 59 | if ($this->port !== '') { 60 | $dsn .= ";port=$this->port"; 61 | } 62 | 63 | foreach ($this->options as $key => $value) { 64 | $dsn .= ";$key=$value"; 65 | } 66 | 67 | return $dsn; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/db-mysql", 3 | "type": "library", 4 | "description": "MySQL/MariaDB driver for Yii Database", 5 | "keywords": [ 6 | "yii", 7 | "mysql", 8 | "database", 9 | "sql", 10 | "dbal", 11 | "query-builder" 12 | ], 13 | "homepage": "https://www.yiiframework.com/", 14 | "license": "BSD-3-Clause", 15 | "support": { 16 | "issues": "https://github.com/yiisoft/db-mysql/issues?state=open", 17 | "source": "https://github.com/yiisoft/db-mysql", 18 | "forum": "https://www.yiiframework.com/forum/", 19 | "wiki": "https://www.yiiframework.com/wiki/", 20 | "irc": "ircs://irc.libera.chat:6697/yii", 21 | "chat": "https://t.me/yii3en" 22 | }, 23 | "funding": [ 24 | { 25 | "type": "opencollective", 26 | "url": "https://opencollective.com/yiisoft" 27 | }, 28 | { 29 | "type": "github", 30 | "url": "https://github.com/sponsors/yiisoft" 31 | } 32 | ], 33 | "require": { 34 | "php": "8.1 - 8.5", 35 | "ext-ctype": "*", 36 | "ext-pdo": "*", 37 | "psr/log": "^2.0|^3.0", 38 | "yiisoft/db": "^2.0" 39 | }, 40 | "require-dev": { 41 | "bamarni/composer-bin-plugin": "^1.8.3", 42 | "friendsofphp/php-cs-fixer": "^3.89.1", 43 | "maglnet/composer-require-checker": "^4.7.1", 44 | "phpunit/phpunit": "^10.5.45", 45 | "rector/rector": "^2.0.10", 46 | "spatie/phpunit-watcher": "^1.24", 47 | "vlucas/phpdotenv": "^5.6.1", 48 | "yiisoft/aliases": "^2.0", 49 | "yiisoft/log-target-file": "^2.0.1", 50 | "yiisoft/psr-dummy-provider": "^1.0", 51 | "yiisoft/test-support": "^3.0", 52 | "yiisoft/var-dumper": "^1.7" 53 | }, 54 | "provide": { 55 | "yiisoft/db-implementation": "1.0.0" 56 | }, 57 | "autoload": { 58 | "psr-4": { 59 | "Yiisoft\\Db\\Mysql\\": "src" 60 | } 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "Yiisoft\\Db\\Mysql\\Tests\\": "tests", 65 | "Yiisoft\\Db\\Tests\\": "vendor/yiisoft/db/tests" 66 | }, 67 | "files": ["tests/bootstrap.php"] 68 | }, 69 | "extra": { 70 | "bamarni-bin": { 71 | "bin-links": true, 72 | "target-directory": "tools", 73 | "forward-command": true 74 | } 75 | }, 76 | "config": { 77 | "sort-packages": true, 78 | "allow-plugins": { 79 | "bamarni/composer-bin-plugin": true, 80 | "composer/package-versions-deprecated": true 81 | } 82 | }, 83 | "scripts": { 84 | "test": "phpunit --testdox --no-interaction", 85 | "test-watch": "phpunit-watcher watch" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Builder/ArrayMergeBuilder.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | final class ArrayMergeBuilder extends MultiOperandFunctionBuilder 31 | { 32 | private const DEFAULT_OPERAND_TYPE = 'json'; 33 | 34 | /** 35 | * Builds a SQL expression which merges arrays from the given {@see ArrayMerge} object. 36 | * 37 | * @param ArrayMerge $expression The expression to build. 38 | * @param array $params The parameters to bind. 39 | * 40 | * @return string The SQL expression. 41 | */ 42 | protected function buildFromExpression(MultiOperandFunction $expression, array &$params): string 43 | { 44 | $operandType = $this->buildOperandType($expression->getType()); 45 | $selects = []; 46 | 47 | foreach ($expression->getOperands() as $operand) { 48 | $builtOperand = $this->buildOperand($operand, $params); 49 | 50 | $selects[] = "SELECT value FROM JSON_TABLE($builtOperand, '$[*]' COLUMNS(value $operandType PATH '$')) AS t"; 51 | } 52 | 53 | $unions = implode(' UNION ', $selects); 54 | 55 | if ($expression->getOrdered()) { 56 | $unions .= ' ORDER BY value'; 57 | } 58 | 59 | return '(SELECT JSON_ARRAYAGG(value) AS value FROM (' . $unions . ') AS t)'; 60 | } 61 | 62 | private function buildOperandType(string|ColumnInterface $type): string 63 | { 64 | if (is_string($type)) { 65 | return $type === '' ? self::DEFAULT_OPERAND_TYPE : rtrim($type, '[]'); 66 | } 67 | 68 | if ($type instanceof AbstractArrayColumn) { 69 | if ($type->getDimension() > 1) { 70 | return self::DEFAULT_OPERAND_TYPE; 71 | } 72 | 73 | $type = $type->getColumn(); 74 | 75 | if ($type === null) { 76 | return self::DEFAULT_OPERAND_TYPE; 77 | } 78 | } 79 | 80 | return $this->queryBuilder->getColumnDefinitionBuilder()->buildType($type); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Support/TestConnection.php: -------------------------------------------------------------------------------- 1 | getSchema()->refresh(); 21 | return $db; 22 | } 23 | 24 | public static function getServerVersion(): string 25 | { 26 | return self::getShared()->getServerInfo()->getVersion(); 27 | } 28 | 29 | public static function dsn(): string 30 | { 31 | return self::$dsn ??= (string) new Dsn( 32 | host: self::host(), 33 | databaseName: self::databaseName(), 34 | port: self::port(), 35 | options: ['charset' => 'utf8mb4'], 36 | ); 37 | } 38 | 39 | public static function create(?string $dsn = null): Connection 40 | { 41 | return new Connection(self::createDriver($dsn), TestHelper::createMemorySchemaCache()); 42 | } 43 | 44 | public static function createDriver(?string $dsn = null): Driver 45 | { 46 | $driver = new Driver($dsn ?? self::dsn(), self::username(), self::password()); 47 | $driver->charset('utf8mb4'); 48 | return $driver; 49 | } 50 | 51 | public static function databaseName(): string 52 | { 53 | if (self::isMariadb()) { 54 | return getenv('YII_MARIADB_DATABASE') ?: 'yiitest'; 55 | } 56 | 57 | return getenv('YII_MYSQL_DATABASE') ?: 'yiitest'; 58 | } 59 | 60 | public static function isMariadb(): bool 61 | { 62 | return getenv('YII_MYSQL_TYPE') === 'mariadb'; 63 | } 64 | 65 | private static function host(): string 66 | { 67 | if (self::isMariadb()) { 68 | return getenv('YII_MARIADB_HOST') ?: '127.0.0.1'; 69 | } 70 | 71 | return getenv('YII_MYSQL_HOST') ?: '127.0.0.1'; 72 | } 73 | 74 | private static function port(): string 75 | { 76 | if (self::isMariadb()) { 77 | return getenv('YII_MARIADB_PORT') ?: '3306'; 78 | } 79 | 80 | return getenv('YII_MYSQL_PORT') ?: '3306'; 81 | } 82 | 83 | private static function username(): string 84 | { 85 | if (self::isMariadb()) { 86 | return getenv('YII_MARIADB_USER') ?: 'root'; 87 | } 88 | 89 | return getenv('YII_MYSQL_USER') ?: 'root'; 90 | } 91 | 92 | private static function password(): string 93 | { 94 | if (self::isMariadb()) { 95 | return getenv('YII_MARIADB_PASSWORD') ?: ''; 96 | } 97 | 98 | return getenv('YII_MYSQL_PASSWORD') ?: ''; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | pdo !== null) { 30 | $this->logger?->log( 31 | LogLevel::DEBUG, 32 | 'Closing DB connection: ' . $this->driver->getDsn() . ' ' . __METHOD__, 33 | ); 34 | 35 | // Solution for close connections {@link https://stackoverflow.com/questions/18277233/pdo-closing-connection} 36 | try { 37 | $this->pdo->exec('KILL CONNECTION_ID()'); 38 | } catch (Throwable) { 39 | } 40 | 41 | $this->pdo = null; 42 | $this->transaction = null; 43 | } 44 | } 45 | 46 | public function createCommand(?string $sql = null, array $params = []): PdoCommandInterface 47 | { 48 | $command = new Command($this); 49 | 50 | if ($sql !== null) { 51 | $command->setSql($sql); 52 | } 53 | 54 | if ($this->logger !== null) { 55 | $command->setLogger($this->logger); 56 | } 57 | 58 | if ($this->profiler !== null) { 59 | $command->setProfiler($this->profiler); 60 | } 61 | 62 | return $command->bindValues($params); 63 | } 64 | 65 | public function createTransaction(): TransactionInterface 66 | { 67 | return new Transaction($this); 68 | } 69 | 70 | public function getColumnBuilderClass(): string 71 | { 72 | return ColumnBuilder::class; 73 | } 74 | 75 | public function getColumnFactory(): ColumnFactoryInterface 76 | { 77 | return $this->columnFactory ??= new ColumnFactory(); 78 | } 79 | 80 | public function getQueryBuilder(): QueryBuilderInterface 81 | { 82 | return $this->queryBuilder ??= new QueryBuilder($this); 83 | } 84 | 85 | public function getQuoter(): QuoterInterface 86 | { 87 | return $this->quoter ??= new Quoter('`', '`', $this->getTablePrefix()); 88 | } 89 | 90 | public function getSchema(): SchemaInterface 91 | { 92 | return $this->schema ??= new Schema($this, $this->schemaCache); 93 | } 94 | 95 | public function getServerInfo(): ServerInfoInterface 96 | { 97 | return $this->serverInfo ??= new ServerInfo($this); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/DQLQueryBuilder.php: -------------------------------------------------------------------------------- 1 | hasLimit($limit)) { 33 | $sql = 'LIMIT ' . ($limit instanceof ExpressionInterface ? $this->buildExpression($limit) : (string) $limit); 34 | 35 | if ($this->hasOffset($offset)) { 36 | $sql .= ' OFFSET ' . ($offset instanceof ExpressionInterface ? $this->buildExpression($offset) : (string) $offset); 37 | } 38 | } elseif ($this->hasOffset($offset)) { 39 | /** 40 | * Limit isn't optional in MySQL. 41 | * 42 | * @link https://stackoverflow.com/a/271650/1106908 43 | * @link https://dev.mysql.com/doc/refman/5.0/en/select.html#idm47619502796240 44 | */ 45 | $sql = 'LIMIT ' 46 | . ($offset instanceof ExpressionInterface ? $this->buildExpression($offset) : (string) $offset) 47 | . ', 18446744073709551615'; // 2^64-1 48 | } 49 | 50 | return $sql; 51 | } 52 | 53 | /** 54 | * Checks to see if the given limit is effective. 55 | * 56 | * @param mixed $limit The given limit. 57 | * 58 | * @return bool Whether the limit is effective. 59 | */ 60 | protected function hasLimit(mixed $limit): bool 61 | { 62 | /** In MySQL limit argument must be a non-negative integer constant */ 63 | return ctype_digit((string) $limit); 64 | } 65 | 66 | /** 67 | * Checks to see if the given offset is effective. 68 | * 69 | * @param mixed $offset The given offset. 70 | * 71 | * @return bool Whether the offset is effective. 72 | */ 73 | protected function hasOffset(mixed $offset): bool 74 | { 75 | /** In MySQL offset argument must be a non-negative integer constant */ 76 | $offset = (string) $offset; 77 | return ctype_digit($offset) && $offset !== '0'; 78 | } 79 | 80 | protected function defaultExpressionBuilders(): array 81 | { 82 | return [ 83 | ...parent::defaultExpressionBuilders(), 84 | JsonOverlaps::class => JsonOverlapsBuilder::class, 85 | Like::class => LikeBuilder::class, 86 | NotLike::class => LikeBuilder::class, 87 | ArrayMerge::class => ArrayMergeBuilder::class, 88 | Longest::class => LongestBuilder::class, 89 | Shortest::class => ShortestBuilder::class, 90 | ]; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/QueryTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 28 | $this->loadFixture(); 29 | 30 | $selectExpression = "concat(customer.name,' in ', p.description) name"; 31 | 32 | $result = (new Query($db)) 33 | ->select([$selectExpression]) 34 | ->from('customer') 35 | ->innerJoin('profile p', '[[customer]].[[profile_id]] = [[p]].[[id]]') 36 | ->indexBy('id') 37 | ->column(); 38 | 39 | $this->assertSame([1 => 'user1 in profile customer 1', 3 => 'user3 in profile customer 3'], $result); 40 | } 41 | 42 | public function testQueryIndexHint(): void 43 | { 44 | $db = $this->getSharedConnection(); 45 | $this->loadFixture(); 46 | 47 | $query = (new Query($db))->from([new Expression('{{%customer}} USE INDEX (primary)')]); 48 | 49 | $row = $query->one(); 50 | 51 | $this->assertArrayHasKey('id', $row); 52 | $this->assertArrayHasKey('name', $row); 53 | $this->assertArrayHasKey('email', $row); 54 | 55 | $db->close(); 56 | } 57 | 58 | public function testLimitOffsetWithExpression(): void 59 | { 60 | $db = $this->getSharedConnection(); 61 | $this->loadFixture(); 62 | 63 | $query = (new Query($db))->from('customer')->select('id')->orderBy('id'); 64 | 65 | /* In MySQL limit and offset arguments must both be non negative integer constant */ 66 | $query->limit(new Expression('2'))->offset(new Expression('1')); 67 | 68 | $result = $query->column(); 69 | 70 | $this->assertCount(2, $result); 71 | $this->assertContains('2', $result); 72 | $this->assertContains('3', $result); 73 | $this->assertNotContains('1', $result); 74 | 75 | $db->close(); 76 | } 77 | 78 | public function testWithQuery(): void 79 | { 80 | $serverVersion = TestConnection::getServerVersion(); 81 | 82 | if ( 83 | !str_contains($serverVersion, 'MariaDB') 84 | && version_compare($serverVersion, '8.0.0', '<') 85 | ) { 86 | self::markTestSkipped('CTE not supported in MySQL versions below 8.0.0'); 87 | } 88 | 89 | parent::testWithQuery(); 90 | } 91 | 92 | public function testWithQueryRecursive(): void 93 | { 94 | $serverVersion = TestConnection::getServerVersion(); 95 | 96 | if ( 97 | !str_contains($serverVersion, 'MariaDB') 98 | && version_compare($serverVersion, '8.0.0', '<') 99 | ) { 100 | self::markTestSkipped('CTE not supported in MySQL versions below 8.0.0'); 101 | } 102 | 103 | parent::testWithQueryRecursive(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 | 6 | MySQL 7 | 8 |

Yii Database MySQL/MariaDB Driver

9 |
10 |

11 | 12 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/db-mysql/v)](https://packagist.org/packages/yiisoft/db-mysql) 13 | [![Total Downloads](https://poser.pugx.org/yiisoft/db-mysql/downloads)](https://packagist.org/packages/yiisoft/db-mysql) 14 | [![Build status](https://github.com/yiisoft/db-mysql/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/db-mysql/actions/workflows/build.yml) 15 | [![Code Coverage](https://codecov.io/gh/yiisoft/db-mysql/branch/master/graph/badge.svg?token=UF9VERNMYU)](https://codecov.io/gh/yiisoft/db-mysql) 16 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fdb-mysql%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/db-mysql/master) 17 | [![static analysis](https://github.com/yiisoft/db-mysql/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/db-mysql/actions?query=workflow%3A%22static+analysis%22) 18 | [![type-coverage](https://shepherd.dev/github/yiisoft/db-mysql/coverage.svg)](https://shepherd.dev/github/yiisoft/db-mysql) 19 | [![psalm-level](https://shepherd.dev/github/yiisoft/db-mysql/level.svg)](https://shepherd.dev/github/yiisoft/db-mysql) 20 | 21 | MySQL and MariaDB driver for [Yii Database](https://github.com/yiisoft/db) is a package for working with 22 | [MySQL](https://www.mysql.com/) and [MariaDB](https://mariadb.org/) databases in PHP. It includes a database connection 23 | class, a command builder class, and a set of classes for representing database tables and rows as PHP objects. 24 | 25 | Driver supports MySQL 5.7 or higher, and MariaDB 10.4 or higher. 26 | 27 | ## Requirements 28 | 29 | - PHP 8.1 - 8.5. 30 | - `pdo_mysql` PHP extension. 31 | - `ctype` PHP extension. 32 | 33 | ## Installation 34 | 35 | The package could be installed with [Composer](https://getcomposer.org): 36 | 37 | ```shell 38 | composer require yiisoft/db-mysql 39 | ``` 40 | 41 | > [!IMPORTANT] 42 | > See also [installation notes](https://github.com/yiisoft/db/?tab=readme-ov-file#installation) for `yiisoft/db` 43 | > package. 44 | 45 | ## Documentation 46 | 47 | For config connection to MySQL and MariaDB database check 48 | [Connection config for MySQL and MariaDB](https://github.com/yiisoft/db/blob/master/docs/guide/en/connection/mysql.md). 49 | 50 | Check the `yiisoft/db` [docs](https://github.com/yiisoft/db/blob/master/docs/guide/en/README.md) to learn about usage. 51 | 52 | - [Internals](docs/internals.md) 53 | 54 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 55 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 56 | 57 | ## License 58 | 59 | The Yii Database MySQL/MariaDB Driver is free software. It is released under the terms of the BSD License. 60 | Please see [`LICENSE`](./LICENSE.md) for more information. 61 | 62 | Maintained by [Yii Software](https://www.yiiframework.com/). 63 | 64 | ## Support the project 65 | 66 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 67 | 68 | ## Follow updates 69 | 70 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 71 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 72 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 73 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 74 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 75 | -------------------------------------------------------------------------------- /tests/PdoConnectionTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 22 | 23 | $tableName = 'test'; 24 | 25 | $command = $db->createCommand(); 26 | 27 | if ($db->getSchema()->getTableSchema($tableName) !== null) { 28 | $command->dropTable($tableName)->execute(); 29 | } 30 | 31 | $this->assertSame( 32 | 1, 33 | $command->setSql( 34 | <<execute(), 38 | ); 39 | 40 | $command->setSql( 41 | <<execute(); 50 | $command->setSQL( 51 | <<execute(); 55 | 56 | $this->assertSame('1', $db->getLastInsertId()); 57 | 58 | $command->setSQL( 59 | <<execute(); 63 | 64 | /** 65 | * Although the second INSERT statement inserted three new rows into test, the ID generated for the first of 66 | * these rows was 3, and it is this value that is returned by LAST_INSERT_ID() for the following SELECT 67 | * statement. 68 | */ 69 | $this->assertSame('3', $db->getLastInsertId()); 70 | 71 | $command->setSQL( 72 | <<execute(); 76 | 77 | /** 78 | * If you use INSERT IGNORE and the row is ignored, the LAST_INSERT_ID() remains unchanged from the current 79 | * value (or 0 is returned if the connection has not yet performed a successful INSERT) and, for 80 | * non-transactional tables, the AUTO_INCREMENT counter is not incremented. 81 | */ 82 | $this->assertSame('0', $db->getLastInsertId()); 83 | 84 | $db->close(); 85 | } 86 | 87 | public function testTransactionAutocommit(): void 88 | { 89 | $db = $this->getSharedConnection(); 90 | $this->loadFixture(); 91 | 92 | $db->transaction(function (PdoConnectionInterface $db) { 93 | $this->assertTrue($db->getTransaction()->isActive()); 94 | 95 | // create table will cause the transaction to be implicitly committed 96 | // (see https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html) 97 | $name = 'test_implicit_transaction_table'; 98 | $db->createCommand()->createTable($name, ['id' => 'pk'])->execute(); 99 | $db->createCommand()->dropTable($name)->execute(); 100 | }); 101 | // If we made it this far without an error, then everything's working 102 | 103 | $db->close(); 104 | } 105 | 106 | public function testGetServerInfo(): void 107 | { 108 | $db = $this->createConnection(); 109 | $serverInfo = $db->getServerInfo(); 110 | 111 | $this->assertInstanceOf(ServerInfo::class, $serverInfo); 112 | 113 | $dbTimezone = $serverInfo->getTimezone(); 114 | 115 | $this->assertSame(6, strlen($dbTimezone)); 116 | 117 | $db->createCommand("SET @@session.time_zone = '+06:15'")->execute(); 118 | 119 | $this->assertSame($dbTimezone, $serverInfo->getTimezone()); 120 | $this->assertNotSame($dbTimezone, $serverInfo->getTimezone(true)); 121 | $this->assertSame('+06:15', $serverInfo->getTimezone()); 122 | 123 | $db->close(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/Provider/ColumnFactoryProvider.php: -------------------------------------------------------------------------------- 1 | getDbType($column); 69 | if (strtolower($dbType) === 'enum') { 70 | $values = array_map( 71 | $this->queryBuilder->getQuoter()->quoteValue(...), 72 | $column->getValues(), 73 | ); 74 | return $dbType . '(' . implode(',', $values) . ')'; 75 | } 76 | } 77 | 78 | $dbType = parent::buildType($column); 79 | 80 | if (!$column instanceof StringColumn || empty($column->getCharacterSet())) { 81 | return $dbType; 82 | } 83 | 84 | return "$dbType CHARACTER SET " . $column->getCharacterSet(); 85 | } 86 | 87 | protected function buildCheck(ColumnInterface $column): string 88 | { 89 | $check = $column->getCheck(); 90 | 91 | if ($column instanceof EnumColumn && $column->getDbType() === null && empty($check)) { 92 | $dbType = $this->getDbType($column); 93 | if ($dbType === 'enum') { 94 | return ''; 95 | } 96 | } 97 | 98 | return parent::buildCheck($column); 99 | } 100 | 101 | protected function buildComment(ColumnInterface $column): string 102 | { 103 | $comment = $column->getComment(); 104 | 105 | return $comment === null ? '' : ' COMMENT ' . $this->queryBuilder->getQuoter()->quoteValue($comment); 106 | } 107 | 108 | protected function getDbType(ColumnInterface $column): string 109 | { 110 | /** @psalm-suppress DocblockTypeContradiction */ 111 | $dbType = $column->getDbType() ?? match ($column->getType()) { 112 | ColumnType::BOOLEAN => 'bit(1)', 113 | ColumnType::BIT => 'bit', 114 | ColumnType::TINYINT => 'tinyint', 115 | ColumnType::SMALLINT => 'smallint', 116 | ColumnType::INTEGER => 'int', 117 | ColumnType::BIGINT => 'bigint', 118 | ColumnType::FLOAT => 'float', 119 | ColumnType::DOUBLE => 'double', 120 | ColumnType::DECIMAL => 'decimal', 121 | ColumnType::MONEY => 'decimal', 122 | ColumnType::CHAR => 'char', 123 | ColumnType::STRING => 'varchar(' . ($column->getSize() ?? 255) . ')', 124 | ColumnType::TEXT => 'text', 125 | ColumnType::BINARY => 'blob', 126 | ColumnType::UUID => 'binary(16)', 127 | ColumnType::TIMESTAMP => 'timestamp', 128 | ColumnType::DATETIME => 'datetime', 129 | ColumnType::DATETIMETZ => 'datetime', 130 | ColumnType::TIME => 'time', 131 | ColumnType::TIMETZ => 'time', 132 | ColumnType::DATE => 'date', 133 | ColumnType::ARRAY => 'json', 134 | ColumnType::STRUCTURED => 'json', 135 | ColumnType::JSON => 'json', 136 | ColumnType::ENUM => 'enum', 137 | default => 'varchar', 138 | }; 139 | 140 | if ($dbType === 'double' && $column->getSize() !== null) { 141 | return 'double(' . $column->getSize() . ',' . ($column->getScale() ?? 0) . ')'; 142 | } 143 | 144 | return $dbType; 145 | } 146 | 147 | protected function getDefaultUuidExpression(): string 148 | { 149 | $serverVersion = $this->queryBuilder->getServerInfo()->getVersion(); 150 | 151 | if (!str_contains($serverVersion, 'MariaDB') 152 | && version_compare($serverVersion, '8', '<') 153 | ) { 154 | return ''; 155 | } 156 | 157 | return "(unhex(replace(uuid(),'-','')))"; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Column/ColumnFactory.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | protected const TYPE_MAP = [ 27 | 'bit' => ColumnType::BIT, 28 | 'tinyint' => ColumnType::TINYINT, 29 | 'smallint' => ColumnType::SMALLINT, 30 | 'mediumint' => ColumnType::INTEGER, 31 | 'int' => ColumnType::INTEGER, 32 | 'integer' => ColumnType::INTEGER, 33 | 'bigint' => ColumnType::BIGINT, 34 | 'float' => ColumnType::FLOAT, 35 | 'real' => ColumnType::FLOAT, 36 | 'double' => ColumnType::DOUBLE, 37 | 'decimal' => ColumnType::DECIMAL, 38 | 'numeric' => ColumnType::DECIMAL, 39 | 'char' => ColumnType::CHAR, 40 | 'varchar' => ColumnType::STRING, 41 | 'enum' => ColumnType::STRING, 42 | 'tinytext' => ColumnType::TEXT, 43 | 'mediumtext' => ColumnType::TEXT, 44 | 'longtext' => ColumnType::TEXT, 45 | 'text' => ColumnType::TEXT, 46 | 'binary' => ColumnType::BINARY, 47 | 'varbinary' => ColumnType::BINARY, 48 | 'blob' => ColumnType::BINARY, 49 | 'tinyblob' => ColumnType::BINARY, 50 | 'mediumblob' => ColumnType::BINARY, 51 | 'longblob' => ColumnType::BINARY, 52 | 'year' => ColumnType::SMALLINT, 53 | 'timestamp' => ColumnType::TIMESTAMP, 54 | 'datetime' => ColumnType::DATETIME, 55 | 'time' => ColumnType::TIME, 56 | 'date' => ColumnType::DATE, 57 | 'json' => ColumnType::JSON, 58 | ]; 59 | 60 | protected function columnDefinitionParser(): ColumnDefinitionParser 61 | { 62 | return new ColumnDefinitionParser(); 63 | } 64 | 65 | protected function getColumnClass(string $type, array $info = []): string 66 | { 67 | return match ($type) { 68 | ColumnType::CHAR => StringColumn::class, 69 | ColumnType::STRING => StringColumn::class, 70 | ColumnType::TEXT => StringColumn::class, 71 | ColumnType::UUID => StringColumn::class, 72 | ColumnType::TIMESTAMP => DateTimeColumn::class, 73 | ColumnType::DATETIME => DateTimeColumn::class, 74 | ColumnType::DATETIMETZ => DateTimeColumn::class, 75 | ColumnType::TIME => DateTimeColumn::class, 76 | ColumnType::TIMETZ => DateTimeColumn::class, 77 | ColumnType::DATE => DateTimeColumn::class, 78 | ColumnType::DECIMAL => StringColumn::class, 79 | default => parent::getColumnClass($type, $info), 80 | }; 81 | } 82 | 83 | protected function getType(string $dbType, array $info = []): string 84 | { 85 | if ($dbType === 'bit' && isset($info['size']) && $info['size'] === 1) { 86 | return ColumnType::BOOLEAN; 87 | } 88 | 89 | return parent::getType($dbType, $info); 90 | } 91 | 92 | protected function normalizeDefaultValue(?string $defaultValue, ColumnInterface $column): mixed 93 | { 94 | if ( 95 | $defaultValue === null 96 | || $column->isPrimaryKey() 97 | || $column->isComputed() 98 | ) { 99 | return null; 100 | } 101 | 102 | return $this->normalizeNotNullDefaultValue($defaultValue, $column); 103 | } 104 | 105 | protected function normalizeNotNullDefaultValue(string $defaultValue, ColumnInterface $column): mixed 106 | { 107 | if ($defaultValue === '') { 108 | return $column->phpTypecast($defaultValue); 109 | } 110 | 111 | if ( 112 | in_array($column->getType(), [ColumnType::TIMESTAMP, ColumnType::DATETIME, ColumnType::DATE, ColumnType::TIME], true) 113 | && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', $defaultValue, $matches) === 1 114 | ) { 115 | return new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1]) ? '(' . $matches[1] . ')' : '')); 116 | } 117 | 118 | if (!empty($column->getExtra()) 119 | || $defaultValue[0] === '(' 120 | && !in_array($column->getType(), [ColumnType::CHAR, ColumnType::STRING, ColumnType::TEXT, ColumnType::BINARY], true) 121 | ) { 122 | return new Expression($defaultValue); 123 | } 124 | 125 | if ($defaultValue[0] === "'" && $defaultValue[-1] === "'") { 126 | $value = substr($defaultValue, 1, -1); 127 | $value = str_replace("''", "'", $value); 128 | 129 | return $column->phpTypecast($value); 130 | } 131 | 132 | if ( 133 | str_starts_with($defaultValue, "b'") 134 | && in_array($column->getType(), [ColumnType::BOOLEAN, ColumnType::BIT], true) 135 | ) { 136 | return $column->phpTypecast(bindec(substr($defaultValue, 2, -1))); 137 | } 138 | 139 | return $column->phpTypecast($defaultValue); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | db->getSchema()->getTableSchema($table); 29 | $primaryKeys = $tableSchema?->getPrimaryKey() ?? []; 30 | $tableColumns = $tableSchema?->getColumns() ?? []; 31 | 32 | foreach ($primaryKeys as $name) { 33 | $column = $tableColumns[$name]; 34 | 35 | if ($column->isAutoIncrement()) { 36 | continue; 37 | } 38 | 39 | if ($columns instanceof QueryInterface) { 40 | throw new NotSupportedException( 41 | __METHOD__ . '() is not supported by MySQL for tables without auto increment when inserting sub-query.', 42 | ); 43 | } 44 | 45 | break; 46 | } 47 | 48 | $params = []; 49 | $insertSql = $this->db->getQueryBuilder()->insert($table, $columns, $params); 50 | $this->setSql($insertSql)->bindValues($params); 51 | 52 | $this->execute(); 53 | 54 | if (empty($primaryKeys)) { 55 | return []; 56 | } 57 | 58 | $result = []; 59 | 60 | foreach ($primaryKeys as $name) { 61 | $column = $tableColumns[$name]; 62 | 63 | if ($column->isAutoIncrement()) { 64 | $value = $this->db->getLastInsertId(); 65 | } else { 66 | /** @var array $columns */ 67 | $value = $columns[$name] ?? $column->getDefaultValue(); 68 | } 69 | 70 | if ($this->phpTypecasting) { 71 | $value = $column->phpTypecast($value); 72 | } 73 | 74 | $result[$name] = $value; 75 | } 76 | 77 | return $result; 78 | } 79 | 80 | public function upsertReturning( 81 | string $table, 82 | array|QueryInterface $insertColumns, 83 | array|bool $updateColumns = true, 84 | ?array $returnColumns = null, 85 | ): array { 86 | $returnColumns ??= $this->db->getTableSchema($table)?->getColumnNames(); 87 | 88 | if (empty($returnColumns)) { 89 | $this->upsert($table, $insertColumns, $updateColumns)->execute(); 90 | return []; 91 | } 92 | 93 | $params = []; 94 | $sql = $this->getQueryBuilder() 95 | ->upsertReturning($table, $insertColumns, $updateColumns, $returnColumns, $params); 96 | 97 | $this->setSql($sql)->bindValues($params); 98 | $this->queryInternal(self::QUERY_MODE_EXECUTE); 99 | 100 | /** @psalm-var PDOStatement $this->pdoStatement */ 101 | $this->pdoStatement->nextRowset(); 102 | /** @psalm-var array $result */ 103 | $result = $this->pdoStatement->fetch(PDO::FETCH_ASSOC); 104 | $this->pdoStatement->closeCursor(); 105 | 106 | if (!$this->phpTypecasting) { 107 | return $result; 108 | } 109 | 110 | $columns = $this->db->getTableSchema($table)?->getColumns(); 111 | 112 | if (empty($columns)) { 113 | return $result; 114 | } 115 | 116 | foreach ($result as $name => &$value) { 117 | $value = $columns[$name]->phpTypecast($value); 118 | } 119 | 120 | return $result; 121 | } 122 | 123 | public function showDatabases(): array 124 | { 125 | $sql = <<setSql($sql)->queryColumn(); 130 | } 131 | 132 | protected function queryInternal(int $queryMode): mixed 133 | { 134 | try { 135 | return parent::queryInternal($queryMode); 136 | } catch (IntegrityException $e) { 137 | if ( 138 | str_starts_with($e->getMessage(), 'SQLSTATE[HY000]: General error: ') 139 | && in_array(substr($e->getMessage(), 32, 5), ['2006 ', '4031 '], true) 140 | && $this->db->getTransaction() === null 141 | ) { 142 | $this->cancel(); 143 | $this->db->close(); 144 | 145 | return parent::queryInternal($queryMode); 146 | } 147 | 148 | throw $e; 149 | } 150 | } 151 | 152 | protected function pdoStatementExecute(): void 153 | { 154 | set_error_handler( 155 | static fn(int $errorNumber, string $errorString): bool 156 | => str_starts_with($errorString, 'Packets out of order. Expected '), 157 | E_WARNING, 158 | ); 159 | 160 | try { 161 | $this->pdoStatement?->execute(); 162 | } finally { 163 | restore_error_handler(); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # MySQL/MariaDB driver for Yii Database Change Log 2 | 3 | ## 2.0.1 under development 4 | 5 | - no changes in this release. 6 | 7 | ## 2.0.0 December 05, 2025 8 | 9 | - New #342, #430: Add JSON overlaps condition builder (@Tigrov) 10 | - New #346, #361, #454: Implement `ColumnFactory` class (@Tigrov, @vjik) 11 | - New #355: Realize `ColumnBuilder` class (@Tigrov) 12 | - New #358, #365: Add `ColumnDefinitionBuilder` class (@Tigrov) 13 | - New #374: Add `IndexType` and `IndexMethod` classes (@Tigrov) 14 | - New #379: Add parameters `$ifExists` and `$cascade` to `CommandInterface::dropTable()` and 15 | `DDLQueryBuilderInterface::dropTable()` methods (@vjik) 16 | - New #383: Add `caseSensitive` option to like condition (@vjik) 17 | - New #387: Realize `Schema::loadResultColumn()` method (@Tigrov) 18 | - New #393: Use `DateTimeColumn` class for datetime column types (@Tigrov) 19 | - New #394, #395, #398, #425, #435, #437: Implement `Command::upsertReturning()` method (@Tigrov, @vjik) 20 | - New #420, #427: Implement `ArrayMergeBuilder`, `LongestBuilder` and `ShortestBuilder` classes (@Tigrov) 21 | - New #421: Add `Connection::getColumnBuilderClass()` method (@Tigrov) 22 | - New #448: Add enumeration column type support (@vjik) 23 | - New #453: Add source of column information (@Tigrov) 24 | - Chg #339: Replace call of `SchemaInterface::getRawTableName()` to `QuoterInterface::getRawTableName()` (@Tigrov) 25 | - Chg #368: Update `QueryBuilder` constructor (@Tigrov) 26 | - Chg #378, #451: Change supported PHP versions to `8.1 - 8.5` (@Tigrov, @vjik) 27 | - Chg #382: Remove `yiisoft/json` dependency (@Tigrov) 28 | - Chg #399: Rename `insertWithReturningPks()` to `insertReturningPks()` in `Command` and `DMLQueryBuilder` classes (@Tigrov) 29 | - Chg #401: Use `\InvalidArgumentException` instead of `Yiisoft\Db\Exception\InvalidArgumentException` (@DikoIbragimov) 30 | - Chg #428: Update expression namespaces according to changes in `yiisoft/db` package (@Tigrov) 31 | - Enh #320: Minor refactoring of `DDLQueryBuilder::getColumnDefinition()` method (@Tigrov) 32 | - Enh #321, #391: Implement and use `SqlParser` class (@Tigrov) 33 | - Enh #344: Update `bit` type according to main PR yiisoft/db#860 (@Tigrov) 34 | - Enh #347, #353: Raise minimum PHP version to `^8.1` with minor refactoring (@Tigrov) 35 | - Enh #354: Separate column type constants (@Tigrov) 36 | - Enh #357: Update according changes in `ColumnSchemaInterface` (@Tigrov) 37 | - Enh #359, #410: Refactor `Dsn` class (@Tigrov) 38 | - Enh #361, #362: Refactor `Schema::findColumns()` method (@Tigrov) 39 | - Enh #363: Refactor `Schema::normalizeDefaultValue()` method and move it to `ColumnFactory` class (@Tigrov) 40 | - Enh #366: Refactor `Quoter::quoteValue()` method (@Tigrov) 41 | - Enh #367: Use `ColumnDefinitionBuilder` to generate table column SQL representation (@Tigrov) 42 | - Enh #371: Remove `ColumnInterface` (@Tigrov) 43 | - Enh #372: Rename `ColumnSchemaInterface` to `ColumnInterface` (@Tigrov) 44 | - Enh #373: Replace `DbArrayHelper::getColumn()` with `array_column()` (@Tigrov) 45 | - Enh #376: Move `JsonExpressionBuilder` and JSON type tests to `yiisoft/db` package (@Tigrov) 46 | - Enh #378: Minor refactoring (@Tigrov) 47 | - Enh #384, #413: Refactor according changes in `db` package (@Tigrov) 48 | - Enh #386: Remove `getCacheKey()` and `getCacheTag()` methods from `Schema` class (@Tigrov) 49 | - Enh #389, #390: Use `DbArrayHelper::arrange()` instead of `DbArrayHelper::index()` method (@Tigrov) 50 | - Enh #394, #395: Refactor `Command::insertWithReturningPks()` method (@Tigrov) 51 | - Enh #396, #409: Refactor constraints (@Tigrov) 52 | - Enh #403: Refactor `DMLQueryBuilder::upsert()`, allow use `EXCLUDED` table alias to access inserted values (@Tigrov) 53 | - Enh #405: Provide `yiisoft/db-implementation` virtual package (@vjik) 54 | - Enh #407, #408, #411: Adapt to conditions refactoring in `yiisoft/db` package (@vjik) 55 | - Enh #414: Remove `TableSchema` class and refactor `Schema` class (@Tigrov) 56 | - Enh #415: Support column's collation (@Tigrov) 57 | - Enh #423: Refactor `DMLQueryBuilder::upsert()` method (@Tigrov) 58 | - Enh #432, #433: Update `DMLQueryBuilder::update()` method to adapt changes in `yiisoft/db` (@rustamwin, @Tigrov) 59 | - Enh #439: Move "Packets out of order" warning suppression from Yii DB (@vjik) 60 | - Bug #320: Change visibility of `DDLQueryBuilder::getColumnDefinition()` method to `private` (@Tigrov) 61 | - Bug #349, #352: Restore connection if closed by connection timeout (@Tigrov) 62 | - Bug #377: Explicitly mark nullable parameters (@vjik) 63 | - Bug #388: Set empty `comment` and `extra` properties to `null` when loading table columns (@Tigrov) 64 | 65 | ## 1.2.0 March 21, 2024 66 | 67 | - Enh #312: Change property `Schema::$typeMap` to constant `Schema::TYPE_MAP` (@Tigrov) 68 | - Enh #318: Resolve deprecated methods (@Tigrov) 69 | - Enh #319: Minor refactoring of `DDLQueryBuilder` and `Schema` (@Tigrov) 70 | - Bug #314: Fix `Command::insertWithReturningPks()` method for empty values (@Tigrov) 71 | 72 | ## 1.1.0 November 12, 2023 73 | 74 | - Chg #297: Remove `QueryBuilder::getColumnType()` child method as legacy code (@Tigrov) 75 | - Enh #300: Refactor insert default values (@Tigrov) 76 | - Enh #303: Implement `ColumnSchemaInterface` classes according to the data type of database table columns 77 | for type casting performance. Related with yiisoft/db#752 (@Tigrov) 78 | - Enh #309: Move methods from `Command` to `AbstractPdoCommand` class (@Tigrov) 79 | - Bug #302: Refactor `DMLQueryBuilder`, related with yiisoft/db#746 (@Tigrov) 80 | 81 | ## 1.0.1 July 24, 2023 82 | 83 | - Enh #295: Typecast refactoring (@Tigrov) 84 | 85 | ## 1.0.0 April 12, 2023 86 | 87 | - Initial release. 88 | -------------------------------------------------------------------------------- /tests/CommandTest.php: -------------------------------------------------------------------------------- 1 | expectException(NotSupportedException::class); 27 | $this->expectExceptionMessage('Yiisoft\Db\Mysql\Schema::loadTableChecks is not supported by MySQL.'); 28 | 29 | parent::testAddCheck(); 30 | } 31 | 32 | public function testAddDefaultValue(): void 33 | { 34 | $this->expectException(NotSupportedException::class); 35 | $this->expectExceptionMessage('Yiisoft\Db\Mysql\Schema::loadTableDefaultValues is not supported by MySQL.'); 36 | 37 | parent::testAddDefaultValue(); 38 | } 39 | 40 | public function testAlterColumn(): void 41 | { 42 | $db = $this->getSharedConnection(); 43 | $this->loadFixture(); 44 | 45 | $command = $db->createCommand(); 46 | $command->alterColumn('{{customer}}', 'email', 'text')->execute(); 47 | $schema = $db->getSchema(); 48 | $columns = $schema->getTableSchema('{{customer}}')?->getColumns(); 49 | 50 | $this->assertArrayHasKey('email', $columns); 51 | $this->assertSame('text', $columns['email']->getDbType()); 52 | 53 | $db->close(); 54 | } 55 | 56 | #[DataProviderExternal(CommandProvider::class, 'batchInsert')] 57 | public function testBatchInsert( 58 | string $table, 59 | iterable $values, 60 | array $columns, 61 | string $expected, 62 | array $expectedParams = [], 63 | int $insertedRow = 1, 64 | ): void { 65 | parent::testBatchInsert($table, $values, $columns, $expected, $expectedParams, $insertedRow); 66 | } 67 | 68 | public function testDropCheck(): void 69 | { 70 | $this->expectException(NotSupportedException::class); 71 | $this->expectExceptionMessage('Yiisoft\Db\Mysql\Schema::loadTableChecks is not supported by MySQL.'); 72 | 73 | parent::testDropCheck(); 74 | } 75 | 76 | public function testDropDefaultValue(): void 77 | { 78 | $this->expectException(NotSupportedException::class); 79 | $this->expectExceptionMessage('Yiisoft\Db\Mysql\Schema::loadTableDefaultValues is not supported by MySQL.'); 80 | 81 | parent::testDropDefaultValue(); 82 | } 83 | 84 | public function testDropTableCascade(): void 85 | { 86 | $db = $this->getSharedConnection(); 87 | $command = $db->createCommand(); 88 | 89 | $this->expectException(NotSupportedException::class); 90 | $this->expectExceptionMessage('MySQL doesn\'t support cascade drop table.'); 91 | $command->dropTable('{{table}}', cascade: true); 92 | 93 | $db->close(); 94 | } 95 | 96 | #[DataProviderExternal(CommandProvider::class, 'rawSql')] 97 | public function testGetRawSql(string $sql, array $params, string $expectedRawSql): void 98 | { 99 | parent::testGetRawSql($sql, $params, $expectedRawSql); 100 | } 101 | 102 | public function testInsertReturningPksWithSubqueryAndNoAutoincrement(): void 103 | { 104 | $db = $this->getSharedConnection(); 105 | $this->loadFixture(); 106 | 107 | $command = $db->createCommand(); 108 | 109 | $query = (new Query($db))->select(['order_id' => 1, 'item_id' => 2, 'quantity' => 3, 'subtotal' => 4]); 110 | 111 | $this->expectException(NotSupportedException::class); 112 | $this->expectExceptionMessage( 113 | 'Yiisoft\Db\Mysql\Command::insertReturningPks() is not supported by MySQL for tables without auto increment when inserting sub-query.', 114 | ); 115 | 116 | $command->insertReturningPks('order_item', $query); 117 | } 118 | 119 | #[DataProviderExternal(CommandProvider::class, 'update')] 120 | public function testUpdate( 121 | string $table, 122 | array $columns, 123 | array|ExpressionInterface|string $conditions, 124 | Closure|array|ExpressionInterface|string|null $from, 125 | array $params, 126 | array $expectedValues, 127 | int $expectedCount, 128 | ): void { 129 | parent::testUpdate($table, $columns, $conditions, $from, $params, $expectedValues, $expectedCount); 130 | } 131 | 132 | #[DataProviderExternal(CommandProvider::class, 'upsert')] 133 | public function testUpsert(Closure|array $firstData, Closure|array $secondData): void 134 | { 135 | parent::testUpsert($firstData, $secondData); 136 | } 137 | 138 | public function testShowDatabases(): void 139 | { 140 | $db = $this->getSharedConnection(); 141 | 142 | $this->assertSame([TestConnection::databaseName()], $db->createCommand()->showDatabases()); 143 | } 144 | 145 | #[DataProviderExternal(CommandProvider::class, 'createIndex')] 146 | public function testCreateIndex(array $columns, array $indexColumns, ?string $indexType, ?string $indexMethod): void 147 | { 148 | parent::testCreateIndex($columns, $indexColumns, $indexType, $indexMethod); 149 | } 150 | 151 | protected function getUpsertTestCharCast(): string 152 | { 153 | return 'CONVERT([[address]], CHAR)'; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | createConnection(); 30 | 31 | $db->setEmulatePrepare(true); 32 | $db->open(); 33 | 34 | $this->assertTrue($db->getEmulatePrepare()); 35 | 36 | $db->setEmulatePrepare(false); 37 | 38 | $this->assertFalse($db->getEmulatePrepare()); 39 | 40 | $db->close(); 41 | } 42 | 43 | public function testSettingDefaultAttributes(): void 44 | { 45 | $db = $this->createConnection(); 46 | 47 | $this->assertSame(PDO::ERRMODE_EXCEPTION, $db->getActivePdo()?->getAttribute(PDO::ATTR_ERRMODE)); 48 | 49 | $db->close(); 50 | $db->setEmulatePrepare(true); 51 | $db->open(); 52 | 53 | $this->assertEquals(true, $db->getActivePdo()?->getAttribute(PDO::ATTR_EMULATE_PREPARES)); 54 | 55 | $db->close(); 56 | $db->setEmulatePrepare(false); 57 | $db->open(); 58 | 59 | $this->assertEquals(false, $db->getActivePdo()?->getAttribute(PDO::ATTR_EMULATE_PREPARES)); 60 | 61 | $db->close(); 62 | } 63 | 64 | public function testTransactionIsolation(): void 65 | { 66 | $db = $this->createConnection(); 67 | $this->loadFixture(db: $db); 68 | 69 | $transaction = $db->beginTransaction(TransactionInterface::READ_UNCOMMITTED); 70 | $transaction->commit(); 71 | 72 | $transaction = $db->beginTransaction(TransactionInterface::READ_COMMITTED); 73 | $transaction->commit(); 74 | 75 | $transaction = $db->beginTransaction(TransactionInterface::REPEATABLE_READ); 76 | $transaction->commit(); 77 | 78 | $transaction = $db->beginTransaction(TransactionInterface::SERIALIZABLE); 79 | $transaction->commit(); 80 | 81 | /* should not be any exception so far */ 82 | $this->assertTrue(true); 83 | 84 | $db->close(); 85 | } 86 | 87 | public function testTransactionShortcutCustom(): void 88 | { 89 | $db = $this->createConnection(); 90 | $this->loadFixture(db: $db); 91 | 92 | $result = $db->transaction( 93 | static function (ConnectionInterface $db) { 94 | $db->createCommand()->insert('profile', ['description' => 'test transaction shortcut'])->execute(); 95 | 96 | return true; 97 | }, 98 | TransactionInterface::READ_UNCOMMITTED, 99 | ); 100 | 101 | $this->assertTrue($result, 'transaction shortcut valid value should be returned from callback'); 102 | 103 | $profilesCount = $db->createCommand( 104 | "SELECT COUNT(*) FROM profile WHERE description = 'test transaction shortcut';", 105 | )->queryScalar(); 106 | 107 | $this->assertSame('1', $profilesCount, 'profile should be inserted in transaction shortcut'); 108 | 109 | $db->close(); 110 | } 111 | 112 | /** 113 | * @link https://github.com/yiisoft/db-mysql/issues/348 114 | */ 115 | public function testRestartConnectionOnTimeout(): void 116 | { 117 | $db = $this->createConnection(); 118 | 119 | $db->createCommand('SET SESSION wait_timeout = 1')->execute(); 120 | 121 | sleep(2); 122 | 123 | $result = $db->createCommand('SELECT 1')->queryScalar(); 124 | 125 | $this->assertSame('1', $result); 126 | 127 | $db->close(); 128 | } 129 | 130 | public function testNotRestartConnectionOnTimeoutInTransaction(): void 131 | { 132 | $db = $this->createConnection(); 133 | $db->beginTransaction(); 134 | 135 | $db->createCommand('SET SESSION wait_timeout = 1')->execute(); 136 | 137 | sleep(2); 138 | 139 | $command = $db->createCommand('SELECT 1'); 140 | 141 | $exception = null; 142 | try { 143 | $command->queryScalar(); 144 | } catch (Throwable $exception) { 145 | } 146 | 147 | $this->assertInstanceOf(IntegrityException::class, $exception); 148 | $this->assertMatchesRegularExpression( 149 | '/SQLSTATE\[HY000\]: General error: (?:2006|4031) /', 150 | $exception->getMessage(), 151 | ); 152 | 153 | $db->close(); 154 | } 155 | 156 | public function getColumnBuilderClass(): void 157 | { 158 | $db = $this->getSharedConnection(); 159 | 160 | $this->assertSame(ColumnBuilder::class, $db->getColumnBuilderClass()); 161 | } 162 | 163 | public function testGetColumnFactory(): void 164 | { 165 | $db = $this->getSharedConnection(); 166 | 167 | $this->assertInstanceOf(ColumnFactory::class, $db->getColumnFactory()); 168 | } 169 | 170 | public function testUserDefinedColumnFactory(): void 171 | { 172 | $columnFactory = new ColumnFactory(); 173 | 174 | $db = new Connection( 175 | TestConnection::createDriver(), 176 | TestHelper::createMemorySchemaCache(), 177 | $columnFactory, 178 | ); 179 | 180 | $this->assertSame($columnFactory, $db->getColumnFactory()); 181 | 182 | $db->close(); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/DDLQueryBuilder.php: -------------------------------------------------------------------------------- 1 | getColumnDefinition($table, $column)); 37 | $definition = trim($definition); 38 | 39 | $checkRegex = '/CHECK *(\(([^()]|(?-2))*\))/'; 40 | $check = preg_match($checkRegex, $definition, $checkMatches); 41 | 42 | if ($check === 1) { 43 | $definition = preg_replace($checkRegex, '', $definition); 44 | } 45 | 46 | $alterSql = 'ALTER TABLE ' 47 | . $this->quoter->quoteTableName($table) 48 | . ' CHANGE ' 49 | . $this->quoter->quoteColumnName($column) 50 | . ' ' 51 | . $this->quoter->quoteColumnName($column) 52 | . (empty($definition) ? '' : ' ' . $definition) 53 | . ' COMMENT ' 54 | . $this->quoter->quoteValue($comment); 55 | 56 | if ($check === 1) { 57 | $alterSql .= ' ' . $checkMatches[0]; 58 | } 59 | 60 | return $alterSql; 61 | } 62 | 63 | public function addCommentOnTable(string $table, string $comment): string 64 | { 65 | return 'ALTER TABLE ' 66 | . $this->quoter->quoteTableName($table) 67 | . ' COMMENT ' 68 | . $this->quoter->quoteValue($comment); 69 | } 70 | 71 | public function addDefaultValue(string $table, string $name, string $column, mixed $value): string 72 | { 73 | throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.'); 74 | } 75 | 76 | public function createIndex( 77 | string $table, 78 | string $name, 79 | array|string $columns, 80 | ?string $indexType = null, 81 | ?string $indexMethod = null, 82 | ): string { 83 | return 'CREATE ' . (!empty($indexType) ? $indexType . ' ' : '') . 'INDEX ' 84 | . $this->quoter->quoteTableName($name) 85 | . (!empty($indexMethod) ? " USING $indexMethod" : '') 86 | . ' ON ' . $this->quoter->quoteTableName($table) 87 | . ' (' . $this->queryBuilder->buildColumns($columns) . ')'; 88 | } 89 | 90 | public function checkIntegrity(string $schema = '', string $table = '', bool $check = true): string 91 | { 92 | return 'SET FOREIGN_KEY_CHECKS = ' . ($check ? 1 : 0); 93 | } 94 | 95 | /** 96 | * @throws NotSupportedException 97 | */ 98 | public function dropCheck(string $table, string $name): string 99 | { 100 | throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.'); 101 | } 102 | 103 | /** 104 | * @throws Exception 105 | * @throws Throwable 106 | */ 107 | public function dropCommentFromColumn(string $table, string $column): string 108 | { 109 | return $this->addCommentOnColumn($table, $column, ''); 110 | } 111 | 112 | /** 113 | * @throws \Exception 114 | */ 115 | public function dropCommentFromTable(string $table): string 116 | { 117 | return $this->addCommentOnTable($table, ''); 118 | } 119 | 120 | public function dropDefaultValue(string $table, string $name): string 121 | { 122 | throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.'); 123 | } 124 | 125 | public function dropForeignKey(string $table, string $name): string 126 | { 127 | return 'ALTER TABLE ' 128 | . $this->quoter->quoteTableName($table) 129 | . ' DROP FOREIGN KEY ' 130 | . $this->quoter->quoteColumnName($name); 131 | } 132 | 133 | public function dropPrimaryKey(string $table, string $name): string 134 | { 135 | return 'ALTER TABLE ' . $this->quoter->quoteTableName($table) . ' DROP PRIMARY KEY'; 136 | } 137 | 138 | public function dropUnique(string $table, string $name): string 139 | { 140 | return $this->dropIndex($table, $name); 141 | } 142 | 143 | /** 144 | * @throws Exception 145 | * @throws Throwable 146 | */ 147 | public function renameColumn(string $table, string $oldName, string $newName): string 148 | { 149 | $quotedTable = $this->quoter->quoteTableName($table); 150 | 151 | $columnDefinition = $this->getColumnDefinition($table, $oldName); 152 | 153 | /* try to give back an SQL anyway */ 154 | return "ALTER TABLE $quotedTable CHANGE " 155 | . $this->quoter->quoteColumnName($oldName) . ' ' 156 | . $this->quoter->quoteColumnName($newName) 157 | . (!empty($columnDefinition) ? ' ' . $columnDefinition : ''); 158 | } 159 | 160 | /** 161 | * @throws NotSupportedException MySQL doesn't support cascade drop table. 162 | */ 163 | public function dropTable(string $table, bool $ifExists = false, bool $cascade = false): string 164 | { 165 | if ($cascade) { 166 | throw new NotSupportedException('MySQL doesn\'t support cascade drop table.'); 167 | } 168 | return parent::dropTable($table, $ifExists, false); 169 | } 170 | 171 | /** 172 | * Gets column definition. 173 | * 174 | * @param string $table The table name. 175 | * @param string $column The column name. 176 | * 177 | * @return string The column definition or empty string in case when schema does not contain the table 178 | * or the table doesn't contain the column. 179 | */ 180 | private function getColumnDefinition(string $table, string $column): string 181 | { 182 | $sql = $this->schema->getTableSchema($table)?->getCreateSql(); 183 | 184 | if (empty($sql)) { 185 | return ''; 186 | } 187 | 188 | $quotedColumn = preg_quote($column, '/'); 189 | 190 | if (preg_match("/^\s*([`\"])$quotedColumn\\1\s+(.*?),?$/m", $sql, $matches) !== 1) { 191 | return ''; 192 | } 193 | 194 | return $matches[2]; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/Provider/ColumnProvider.php: -------------------------------------------------------------------------------- 1 | schema->getTableSchema($table); 42 | 43 | if ($tableSchema === null) { 44 | throw new InvalidArgumentException("Table not found: '$table'."); 45 | } 46 | 47 | $sequenceName = $tableSchema->getSequenceName(); 48 | if ($sequenceName === null) { 49 | throw new InvalidArgumentException("There is not sequence associated with table '$table'."); 50 | } 51 | 52 | $tableName = $this->quoter->quoteTableName($table); 53 | 54 | if ($value !== null) { 55 | return 'ALTER TABLE ' . $tableName . ' AUTO_INCREMENT=' . (string) $value . ';'; 56 | } 57 | 58 | $key = $tableSchema->getPrimaryKey()[0]; 59 | 60 | return "SET @new_autoincrement_value := (SELECT MAX(`$key`) + 1 FROM $tableName); 61 | SET @sql = CONCAT('ALTER TABLE $tableName AUTO_INCREMENT =', @new_autoincrement_value); 62 | PREPARE autoincrement_stmt FROM @sql; 63 | EXECUTE autoincrement_stmt"; 64 | } 65 | 66 | public function update( 67 | string $table, 68 | array $columns, 69 | array|ExpressionInterface|string $condition, 70 | array|ExpressionInterface|string|null $from = null, 71 | array &$params = [], 72 | ): string { 73 | $sql = 'UPDATE ' . $this->quoter->quoteTableName($table); 74 | 75 | if ($from !== null) { 76 | $fromClause = $this->queryBuilder->buildFrom(DbArrayHelper::normalizeExpressions($from), $params); 77 | $sql .= ', ' . substr($fromClause, 5); 78 | 79 | $updateSets = $this->prepareUpdateSets($table, $columns, $params, useTableName: true); 80 | } else { 81 | $updateSets = $this->prepareUpdateSets($table, $columns, $params); 82 | } 83 | 84 | $sql .= ' SET ' . implode(', ', $updateSets); 85 | 86 | $where = $this->queryBuilder->buildWhere($condition, $params); 87 | 88 | return $where === '' ? $sql : "$sql $where"; 89 | } 90 | 91 | public function upsert( 92 | string $table, 93 | array|QueryInterface $insertColumns, 94 | array|bool $updateColumns = true, 95 | array &$params = [], 96 | ): string { 97 | [$uniqueNames, , $updateNames] = $this->prepareUpsertColumns($table, $insertColumns, $updateColumns); 98 | 99 | if (empty($uniqueNames)) { 100 | return $this->insert($table, $insertColumns, $params); 101 | } 102 | 103 | if (empty($updateColumns) || $updateNames === []) { 104 | /** there are no columns to update */ 105 | $insertSql = $this->insert($table, $insertColumns, $params); 106 | return 'INSERT IGNORE' . substr($insertSql, 6); 107 | } 108 | 109 | [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params); 110 | 111 | $quotedNames = array_map($this->quoter->quoteColumnName(...), $names); 112 | 113 | if (!empty($placeholders)) { 114 | $values = $this->buildSimpleSelect(array_combine($names, $placeholders)); 115 | } 116 | 117 | $fields = implode(', ', $quotedNames); 118 | 119 | $insertSql = 'INSERT INTO ' . $this->quoter->quoteTableName($table) 120 | . " ($fields) SELECT $fields FROM ($values) AS EXCLUDED"; 121 | 122 | $updates = $this->prepareUpsertSets($table, $updateColumns, $updateNames, $params); 123 | 124 | return $insertSql . ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates); 125 | } 126 | 127 | public function upsertReturning( 128 | string $table, 129 | array|QueryInterface $insertColumns, 130 | array|bool $updateColumns = true, 131 | ?array $returnColumns = null, 132 | array &$params = [], 133 | ): string { 134 | $tableSchema = $this->schema->getTableSchema($table); 135 | $returnColumns ??= $tableSchema?->getColumnNames(); 136 | 137 | if (empty($returnColumns)) { 138 | return $this->upsert($table, $insertColumns, $updateColumns, $params); 139 | } 140 | 141 | [$uniqueNames, $insertNames] = $this->prepareUpsertColumns($table, $insertColumns, $updateColumns); 142 | /** @var TableSchema $tableSchema */ 143 | $primaryKeys = $tableSchema->getPrimaryKey(); 144 | $uniqueColumns = $primaryKeys ?: $uniqueNames; 145 | 146 | if (is_array($insertColumns)) { 147 | $insertColumns = array_combine($insertNames, $insertColumns); 148 | } 149 | 150 | if (empty($uniqueColumns)) { 151 | $upsertSql = $this->upsert($table, $insertColumns, $updateColumns, $params); 152 | $returnValues = $this->prepareColumnValues($tableSchema, $returnColumns, $insertColumns, $params); 153 | 154 | return $upsertSql . ';' . $this->buildSimpleSelect($returnValues); 155 | } 156 | 157 | if (is_array($updateColumns) 158 | && !empty($uniqueUpdateValues = array_intersect_key($updateColumns, array_fill_keys($uniqueColumns, null))) 159 | ) { 160 | if (!is_array($insertColumns) 161 | || $uniqueUpdateValues !== array_intersect_key($insertColumns, $uniqueUpdateValues) 162 | ) { 163 | throw new NotSupportedException( 164 | __METHOD__ . '() is not supported by MySQL when updating different primary key or unique values.', 165 | ); 166 | } 167 | 168 | $updateColumns = array_diff_key($updateColumns, $uniqueUpdateValues); 169 | } 170 | 171 | $quoter = $this->quoter; 172 | $quotedTable = $quoter->quoteTableName($table); 173 | $upsertSql = $this->upsert($table, $insertColumns, $updateColumns, $params); 174 | $isAutoIncrement = count($primaryKeys) === 1 && $tableSchema->getColumn($primaryKeys[0])?->isAutoIncrement(); 175 | 176 | if ($isAutoIncrement) { 177 | $id = $quoter->quoteSimpleColumnName($primaryKeys[0]); 178 | $setLastInsertId = "$id=LAST_INSERT_ID($quotedTable.$id)"; 179 | 180 | if (str_starts_with($upsertSql, 'INSERT IGNORE INTO')) { 181 | $upsertSql = 'INSERT' . substr($upsertSql, 13) . " ON DUPLICATE KEY UPDATE $setLastInsertId"; 182 | } elseif (str_contains($upsertSql, ' ON DUPLICATE KEY UPDATE ')) { 183 | $upsertSql .= ", $setLastInsertId"; 184 | } 185 | } 186 | 187 | $uniqueValues = $this->prepareColumnValues($tableSchema, $uniqueColumns, $insertColumns, $params); 188 | 189 | if (empty(array_diff($returnColumns, array_keys($uniqueValues)))) { 190 | $selectValues = array_intersect_key($uniqueValues, array_fill_keys($returnColumns, null)); 191 | 192 | return $upsertSql . ';' . $this->buildSimpleSelect($selectValues); 193 | } 194 | 195 | $conditions = []; 196 | 197 | foreach ($uniqueValues as $name => $value) { 198 | if ($value === 'NULL') { 199 | throw new NotSupportedException( 200 | __METHOD__ . '() is not supported by MySQL when inserting `null` primary key or unique values.', 201 | ); 202 | } 203 | 204 | $conditions[] = $quoter->quoteSimpleColumnName($name) . ' = ' . $value; 205 | } 206 | 207 | $quotedReturnColumns = array_map($quoter->quoteSimpleColumnName(...), $returnColumns); 208 | 209 | return $upsertSql 210 | . ';SELECT ' . implode(', ', $quotedReturnColumns) 211 | . ' FROM ' . $quotedTable 212 | . ' WHERE ' . implode(' AND ', $conditions); 213 | } 214 | 215 | protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array 216 | { 217 | if (empty($columns)) { 218 | return [[], [], 'VALUES ()', []]; 219 | } 220 | 221 | return parent::prepareInsertValues($table, $columns, $params); 222 | } 223 | 224 | /** 225 | * @param string[] $columnNames 226 | * 227 | * @return string[] Prepared column values for using in a SQL statement. 228 | * @psalm-return array 229 | */ 230 | private function prepareColumnValues( 231 | TableSchema $tableSchema, 232 | array $columnNames, 233 | array|QueryInterface $insertColumns, 234 | array &$params, 235 | ): array { 236 | $columnValues = []; 237 | 238 | $tableColumns = $tableSchema->getColumns(); 239 | 240 | foreach ($columnNames as $name) { 241 | $column = $tableColumns[$name]; 242 | 243 | if ($column->isAutoIncrement()) { 244 | $columnValues[$name] = 'LAST_INSERT_ID()'; 245 | } elseif ($insertColumns instanceof QueryInterface) { 246 | throw new NotSupportedException( 247 | self::class . '::upsertReturning() is not supported by MySQL' 248 | . ' for tables without auto increment when inserting sub-query.', 249 | ); 250 | } else { 251 | $value = $insertColumns[$name] ?? $column->getDefaultValue(); 252 | $columnValues[$name] = $this->queryBuilder->buildValue($value, $params); 253 | } 254 | } 255 | 256 | return $columnValues; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /tests/ColumnTest.php: -------------------------------------------------------------------------------- 1 | getSharedConnection(); 39 | 40 | $sql = << null, 52 | 1 => 1, 53 | '2.5' => '2.5', 54 | 'true' => 1, 55 | 'false' => 0, 56 | 'string' => 'string', 57 | ]; 58 | 59 | $result = $db->createCommand($sql) 60 | ->withPhpTypecasting() 61 | ->queryOne(); 62 | 63 | $this->assertSame($expected, $result); 64 | 65 | $result = $db->createCommand($sql) 66 | ->withPhpTypecasting() 67 | ->queryAll(); 68 | 69 | $this->assertSame([$expected], $result); 70 | 71 | $result = $db->createCommand($sql) 72 | ->withPhpTypecasting() 73 | ->query(); 74 | 75 | $this->assertSame([$expected], iterator_to_array($result)); 76 | 77 | $result = $db->createCommand('SELECT 2.5') 78 | ->withPhpTypecasting() 79 | ->queryScalar(); 80 | 81 | $this->assertSame('2.5', $result); 82 | 83 | $result = $db->createCommand('SELECT 2.5 UNION SELECT 3.3') 84 | ->withPhpTypecasting() 85 | ->queryColumn(); 86 | 87 | $this->assertSame(['2.5', '3.3'], $result); 88 | 89 | $db->close(); 90 | } 91 | 92 | #[DataProviderExternal(ColumnProvider::class, 'bigIntValue')] 93 | public function testColumnBigInt(string $bigint): void 94 | { 95 | $db = $this->getSharedConnection(); 96 | $this->loadFixture(); 97 | 98 | $command = $db->createCommand(); 99 | $command->insert('negative_default_values', ['bigint_col' => $bigint]); 100 | $command->execute(); 101 | $query = (new Query($db))->from('negative_default_values')->one(); 102 | 103 | $this->assertSame($bigint, $query['bigint_col']); 104 | 105 | $db->close(); 106 | } 107 | 108 | public function testColumnInstance(): void 109 | { 110 | $db = $this->getSharedConnection(); 111 | $this->loadFixture(); 112 | $schema = $db->getSchema(); 113 | $tableSchema = $schema->getTableSchema('type'); 114 | 115 | $this->assertInstanceOf(IntegerColumn::class, $tableSchema->getColumn('int_col')); 116 | $this->assertInstanceOf(StringColumn::class, $tableSchema->getColumn('char_col')); 117 | $this->assertInstanceOf(DoubleColumn::class, $tableSchema->getColumn('float_col')); 118 | $this->assertInstanceOf(BinaryColumn::class, $tableSchema->getColumn('blob_col')); 119 | $this->assertInstanceOf(BooleanColumn::class, $tableSchema->getColumn('bool_col')); 120 | $this->assertInstanceOf(JsonColumn::class, $tableSchema->getColumn('json_col')); 121 | 122 | $db->close(); 123 | } 124 | 125 | public function testLongtextType(): void 126 | { 127 | $db = $this->getSharedConnection(); 128 | $command = $db->createCommand(); 129 | 130 | try { 131 | $command->dropTable('text_type')->execute(); 132 | } catch (Exception) { 133 | } 134 | 135 | $command->createTable( 136 | 'text_type', 137 | [ 138 | 'tinytext' => ColumnBuilder::text(63), 139 | 'text' => ColumnBuilder::text(16_383), 140 | 'mediumtext' => ColumnBuilder::text(4_194_303), 141 | 'longtext' => ColumnBuilder::text(4_294_967_295), 142 | ], 143 | )->execute(); 144 | 145 | $table = $db->getSchema()->getTableSchema('text_type'); 146 | 147 | $this->assertSame('tinytext', $table->getColumn('tinytext')->getDbType()); 148 | $this->assertSame('text', $table->getColumn('text')->getDbType()); 149 | $this->assertSame('mediumtext', $table->getColumn('mediumtext')->getDbType()); 150 | $this->assertSame('longtext', $table->getColumn('longtext')->getDbType()); 151 | 152 | $db->close(); 153 | } 154 | 155 | public function testTimestampColumnOnDifferentTimezones(): void 156 | { 157 | $db = $this->createConnection(); 158 | $schema = $db->getSchema(); 159 | $command = $db->createCommand(); 160 | $tableName = 'timestamp_column_test'; 161 | 162 | $command->setSql("SET @@session.time_zone = '+03:00'")->execute(); 163 | 164 | $this->assertSame('+03:00', $db->getServerInfo()->getTimezone()); 165 | 166 | $phpTimezone = date_default_timezone_get(); 167 | date_default_timezone_set('America/New_York'); 168 | 169 | if ($schema->hasTable($tableName)) { 170 | $command->dropTable($tableName)->execute(); 171 | } 172 | 173 | $command->createTable( 174 | $tableName, 175 | [ 176 | 'timestamp_col' => ColumnBuilder::timestamp(), 177 | 'datetime_col' => ColumnBuilder::datetime(), 178 | ], 179 | )->execute(); 180 | 181 | $command->insert($tableName, [ 182 | 'timestamp_col' => new DateTimeImmutable('2025-04-19 14:11:35'), 183 | 'datetime_col' => new DateTimeImmutable('2025-04-19 14:11:35'), 184 | ])->execute(); 185 | 186 | $command->setSql("SET @@session.time_zone = '+04:00'")->execute(); 187 | 188 | $this->assertSame('+04:00', $db->getServerInfo()->getTimezone(true)); 189 | 190 | $columns = $schema->getTableSchema($tableName, true)->getColumns(); 191 | $query = (new Query($db))->from($tableName); 192 | 193 | $result = $query->one(); 194 | 195 | $this->assertEquals(new DateTimeImmutable('2025-04-19 14:11:35'), $columns['timestamp_col']->phpTypecast($result['timestamp_col'])); 196 | $this->assertEquals(new DateTimeImmutable('2025-04-19 14:11:35'), $columns['datetime_col']->phpTypecast($result['datetime_col'])); 197 | 198 | $result = $query->withTypecasting()->one(); 199 | 200 | $this->assertEquals(new DateTimeImmutable('2025-04-19 14:11:35'), $result['timestamp_col']); 201 | $this->assertEquals(new DateTimeImmutable('2025-04-19 14:11:35'), $result['datetime_col']); 202 | 203 | date_default_timezone_set($phpTimezone); 204 | 205 | $db->close(); 206 | } 207 | 208 | #[DataProviderExternal(ColumnProvider::class, 'predefinedTypes')] 209 | public function testPredefinedType(string $className, string $type) 210 | { 211 | parent::testPredefinedType($className, $type); 212 | } 213 | 214 | #[DataProviderExternal(ColumnProvider::class, 'dbTypecastColumns')] 215 | public function testDbTypecastColumns(ColumnInterface $column, array $values) 216 | { 217 | parent::testDbTypecastColumns($column, $values); 218 | } 219 | 220 | #[DataProviderExternal(ColumnProvider::class, 'phpTypecastColumns')] 221 | public function testPhpTypecastColumns(ColumnInterface $column, array $values) 222 | { 223 | parent::testPhpTypecastColumns($column, $values); 224 | } 225 | 226 | public function testStringColumnCharacterSet(): void 227 | { 228 | $stringCol = new StringColumn(); 229 | 230 | $this->assertNull($stringCol->getCharacterSet()); 231 | $this->assertSame($stringCol, $stringCol->characterSet('utf8mb4_bin')); 232 | $this->assertSame('utf8mb4_bin', $stringCol->getCharacterSet()); 233 | } 234 | 235 | protected function insertTypeValues(ConnectionInterface $db): void 236 | { 237 | $db->createCommand()->insert( 238 | 'type', 239 | [ 240 | 'int_col' => 1, 241 | 'bigunsigned_col' => '12345678901234567890', 242 | 'char_col' => str_repeat('x', 100), 243 | 'char_col3' => null, 244 | 'float_col' => 1.234, 245 | 'blob_col' => "\x10\x11\x12", 246 | 'timestamp_col' => '2023-07-11 14:50:23', 247 | 'timestamp_default' => new DateTimeImmutable('2023-07-11 14:50:23'), 248 | 'bool_col' => false, 249 | 'bit_col' => 0b0110_0100, // 100 250 | 'json_col' => [['a' => 1, 'b' => null, 'c' => [1, 3, 5]]], 251 | ], 252 | )->execute(); 253 | } 254 | 255 | protected function assertTypecastedValues(array $result, bool $allTypecasted = false): void 256 | { 257 | $this->assertSame(1, $result['int_col']); 258 | $this->assertSame('12345678901234567890', $result['bigunsigned_col']); 259 | $this->assertSame(str_repeat('x', 100), $result['char_col']); 260 | $this->assertNull($result['char_col3']); 261 | $this->assertSame(1.234, $result['float_col']); 262 | $this->assertSame("\x10\x11\x12", $result['blob_col']); 263 | $this->assertEquals(new DateTimeImmutable('2023-07-11 14:50:23', new DateTimeZone('UTC')), $result['timestamp_col']); 264 | $this->assertEquals(new DateTimeImmutable('2023-07-11 14:50:23'), $result['timestamp_default']); 265 | $this->assertFalse($result['bool_col']); 266 | $this->assertSame(0b0110_0100, $result['bit_col']); 267 | 268 | // JSON column is always typecasted in MySQL after this fix: https://github.com/php/php-src/issues/20122 269 | // PHP 8.3.28+, 8.4.15+ 270 | $isPhpWithFix = (PHP_VERSION_ID >= 80328 && PHP_VERSION_ID < 80400) || PHP_VERSION_ID >= 80415; 271 | if ($allTypecasted || (!TestConnection::isMariadb() && $isPhpWithFix)) { 272 | $this->assertSame([['a' => 1, 'b' => null, 'c' => [1, 3, 5]]], $result['json_col']); 273 | } else { 274 | $this->assertJsonStringEqualsJsonString('[{"a":1,"b":null,"c":[1,3,5]}]', $result['json_col']); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /tests/DeadLockTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('pcntl_fork() is not available'); 60 | } 61 | 62 | if (!function_exists('posix_kill')) { 63 | $this->markTestSkipped('posix_kill() is not available'); 64 | } 65 | 66 | if (!function_exists('pcntl_sigtimedwait')) { 67 | $this->markTestSkipped('pcntl_sigtimedwait() is not available'); 68 | } 69 | 70 | $this->setLogFile(sys_get_temp_dir() . '/deadlock_' . posix_getpid()); 71 | 72 | $this->deleteLog(); 73 | 74 | try { 75 | /** 76 | * to cause deadlock we do: 77 | * 78 | * 1. FIRST errornously forgot "FOR UPDATE" while read the row for next update. 79 | * 2. SECOND does update the row and locks it exclusively. 80 | * 3. FIRST tryes to update the row too, but it already has shared lock. Here comes deadlock. 81 | * FIRST child will send the signal to the SECOND child. 82 | * So, SECOND child should be forked at first to obtain its PID. 83 | */ 84 | $pidSecond = pcntl_fork(); 85 | 86 | if (-1 === $pidSecond) { 87 | $this->markTestIncomplete('cannot fork'); 88 | } 89 | 90 | if (0 === $pidSecond) { 91 | /* SECOND child */ 92 | $this->setErrorHandler(); 93 | 94 | exit($this->childrenUpdateLocked()); 95 | } 96 | 97 | $pidFirst = pcntl_fork(); 98 | 99 | if (-1 === $pidFirst) { 100 | $this->markTestIncomplete('cannot fork second child'); 101 | } 102 | 103 | if (0 === $pidFirst) { 104 | /* FIRST child */ 105 | $this->setErrorHandler(); 106 | 107 | exit($this->childrenSelectAndAccidentUpdate($pidSecond)); 108 | } 109 | 110 | /** 111 | * PARENT 112 | * nothing to do 113 | */ 114 | } catch (\Exception|Throwable $e) { 115 | /* wait all children */ 116 | while (-1 !== pcntl_wait($status)) { 117 | /* nothing to do */ 118 | } 119 | 120 | $this->deleteLog(); 121 | 122 | throw $e; 123 | } 124 | 125 | /** 126 | * wait all children all must exit with success 127 | */ 128 | $errors = []; 129 | $deadlockHitCount = 0; 130 | 131 | while (-1 !== pcntl_wait($status)) { 132 | if (!pcntl_wifexited($status)) { 133 | $errors[] = 'child did not exit itself'; 134 | } else { 135 | $exitStatus = pcntl_wexitstatus($status); 136 | if (self::CHILD_EXIT_CODE_DEADLOCK === $exitStatus) { 137 | $deadlockHitCount++; 138 | } elseif (0 !== $exitStatus) { 139 | $errors[] = 'child exited with error status'; 140 | } 141 | } 142 | } 143 | 144 | $logContent = $this->getLogContentAndDelete(); 145 | 146 | if ($errors) { 147 | $this->fail( 148 | implode('; ', $errors) 149 | . ($logContent ? ". Shared children log:\n$logContent" : ''), 150 | ); 151 | } 152 | 153 | $this->assertEquals( 154 | 1, 155 | $deadlockHitCount, 156 | "exactly one child must hit deadlock; shared children log:\n" . $logContent, 157 | ); 158 | } 159 | 160 | /** 161 | * Main body of first child process. 162 | * 163 | * First child initializes test row and runs two nested {@see ConnectionInterface::transaction()} to perform 164 | * following operations: 165 | * 1. `SELECT ... LOCK IN SHARE MODE` the test row with shared lock instead of needed exclusive lock. 166 | * 2. Send signal to SECOND child identified by PID {@see $pidSecond}. 167 | * 3. Waits few seconds. 168 | * 4. `UPDATE` the test row. 169 | * 170 | * @return int Exit code. In case of deadlock exit code is {@see CHILD_EXIT_CODE_DEADLOCK}. In case of success exit 171 | * code is 0. Other codes means an error. 172 | */ 173 | private function childrenSelectAndAccidentUpdate(int $pidSecond): int 174 | { 175 | try { 176 | $this->log('child 1: connect'); 177 | 178 | $first = $this->createConnection(); 179 | 180 | $this->log('child 1: delete'); 181 | 182 | $first->createCommand() 183 | ->delete('{{customer}}', ['id' => 97]) 184 | ->execute(); 185 | 186 | $this->log('child 1: insert'); 187 | 188 | /* insert test row */ 189 | $first->createCommand() 190 | ->insert('{{customer}}', [ 191 | 'id' => 97, 192 | 'email' => 'deadlock@example.com', 193 | 'name' => 'test', 194 | 'address' => 'test address', 195 | ]) 196 | ->execute(); 197 | 198 | $this->log('child 1: transaction'); 199 | 200 | $first->transaction(function (ConnectionInterface $first) use ($pidSecond) { 201 | $first->transaction(function (ConnectionInterface $first) use ($pidSecond) { 202 | $this->log('child 1: select'); 203 | 204 | /* SELECT with shared lock */ 205 | $first->createCommand('SELECT id FROM {{customer}} WHERE id = 97 LOCK IN SHARE MODE') 206 | ->execute(); 207 | 208 | $this->log('child 1: send signal to child 2'); 209 | 210 | /* let child to continue */ 211 | if (!posix_kill($pidSecond, SIGUSR1)) { 212 | throw new RuntimeException('Cannot send signal'); 213 | } 214 | 215 | /** 216 | * Now child 2 tries to do the 2nd update, and hits the lock and waits delay to let child hit the 217 | * lock. 218 | */ 219 | 220 | sleep(2); 221 | 222 | $this->log('child 1: update'); 223 | 224 | /* now do the 3rd update for deadlock */ 225 | $first->createCommand() 226 | ->update('{{customer}}', ['name' => 'first'], ['id' => 97]) 227 | ->execute(); 228 | 229 | $this->log('child 1: commit'); 230 | }); 231 | }, TransactionInterface::REPEATABLE_READ); 232 | } catch (Exception $e) { 233 | $this->assertIsArray($e->errorInfo); 234 | [$sqlError, $driverError, $driverMessage] = $e->errorInfo; 235 | 236 | /* Deadlock found when trying to get lock; try restarting transaction */ 237 | if ('40001' === $sqlError && 1213 === $driverError) { 238 | return self::CHILD_EXIT_CODE_DEADLOCK; 239 | } 240 | 241 | $this->log("child 1: ! sql error $sqlError: $driverError: $driverMessage"); 242 | 243 | return 1; 244 | } catch (\Exception|Throwable $e) { 245 | $this->log( 246 | 'child 1: ! exit <<' . $e::class . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" 247 | . $e->getTraceAsString() . '>>', 248 | ); 249 | 250 | return 1; 251 | } finally { 252 | $first->close(); 253 | } 254 | 255 | $this->log('child 1: exit'); 256 | 257 | return 0; 258 | } 259 | 260 | /** 261 | * Main body of second child process. 262 | * 263 | * Second child at first will wait the signal from the first child in some seconds. 264 | * 265 | * After receiving the signal it runs two nested {@see ConnectionInterface::transaction()} to perform `UPDATE` with 266 | * the test row. 267 | * 268 | * @return int Exit code. In case of deadlock exit code is {@see CHILD_EXIT_CODE_DEADLOCK}. In case of success exit 269 | * code is 0. Other codes means an error. 270 | */ 271 | private function childrenUpdateLocked(): int 272 | { 273 | /* install no-op signal handler to prevent termination */ 274 | if ( 275 | !pcntl_signal(SIGUSR1, static function () {}, false) 276 | ) { 277 | $this->log('child 2: cannot install signal handler'); 278 | 279 | return 1; 280 | } 281 | 282 | try { 283 | /* at first, parent should do 1st select */ 284 | $this->log('child 2: wait signal from child 1'); 285 | 286 | if (pcntl_sigtimedwait([SIGUSR1], $info, 10) <= 0) { 287 | $this->log('child 2: wait timeout exceeded'); 288 | 289 | return 1; 290 | } 291 | 292 | $this->log('child 2: connect'); 293 | 294 | $second = $this->createConnection(); 295 | $this->loadFixture(db: $second); 296 | $second->open(); 297 | 298 | /* sleep(1); */ 299 | $this->log('child 2: transaction'); 300 | 301 | $second->transaction(function (ConnectionInterface $second) { 302 | $second->transaction(function (ConnectionInterface $second) { 303 | $this->log('child 2: update'); 304 | /* do the 2nd update */ 305 | $second->createCommand() 306 | ->update('{{customer}}', ['name' => 'second'], ['id' => 97]) 307 | ->execute(); 308 | 309 | $this->log('child 2: commit'); 310 | }); 311 | }, TransactionInterface::REPEATABLE_READ); 312 | } catch (Exception $e) { 313 | $this->assertIsArray($e->errorInfo); 314 | [$sqlError, $driverError, $driverMessage] = $e->errorInfo; 315 | 316 | /* Deadlock found when trying to get lock; try restarting transaction */ 317 | if ('40001' === $sqlError && 1213 === $driverError) { 318 | return self::CHILD_EXIT_CODE_DEADLOCK; 319 | } 320 | 321 | $this->log("child 2: ! sql error $sqlError: $driverError: $driverMessage"); 322 | 323 | return 1; 324 | } catch (\Exception|Throwable $e) { 325 | $this->log( 326 | 'child 2: ! exit <<' . $e::class . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" 327 | . $e->getTraceAsString() . '>>', 328 | ); 329 | 330 | return 1; 331 | } finally { 332 | $second->close(); 333 | } 334 | 335 | $this->log('child 2: exit'); 336 | 337 | return 0; 338 | } 339 | 340 | /** 341 | * Set own error handler. 342 | * 343 | * In case of error in child process its execution bubbles up to phpunit to continue all the rest tests. So, all 344 | * the rest tests in this case will run both in the child and parent processes. Such mess must be prevented with 345 | * child's own error handler. 346 | */ 347 | private function setErrorHandler(): void 348 | { 349 | set_error_handler(static function ($errno, $errstr, $errfile, $errline): never { 350 | throw new ErrorException($errstr, $errno, $errno, $errfile, $errline); 351 | }); 352 | } 353 | 354 | /** 355 | * Sets filename for log file shared between children processes. 356 | */ 357 | private function setLogFile(string $filename): void 358 | { 359 | $this->logFile = $filename; 360 | } 361 | 362 | /** 363 | * Deletes shared log file. 364 | * 365 | * Deletes the file {@see logFile} if it exists. 366 | */ 367 | private function deleteLog(): void 368 | { 369 | /** @psalm-suppress RedundantCondition */ 370 | if (is_file($this->logFile)) { 371 | unlink($this->logFile); 372 | } 373 | } 374 | 375 | /** 376 | * Reads shared log content and deletes the log file. 377 | * 378 | * Reads content of log file {@see logFile} and returns it deleting the file. 379 | * 380 | * @return string|null String content of the file {@see logFile}. `false` is returned when file cannot be read. 381 | * `null` is returned when file does not exist or {@see logFile} is not set. 382 | */ 383 | private function getLogContentAndDelete(): ?string 384 | { 385 | if (is_file($this->logFile)) { 386 | $content = file_get_contents($this->logFile); 387 | 388 | unlink($this->logFile); 389 | 390 | return $content; 391 | } 392 | 393 | return null; 394 | } 395 | 396 | /** 397 | * Append message to shared log. 398 | * 399 | * @param string $message Message to append to the log. The message will be prepended with timestamp and appended 400 | * with new line. 401 | */ 402 | private function log(string $message): void 403 | { 404 | $time = microtime(true); 405 | $timeInt = floor($time); 406 | $timeFrac = $time - $timeInt; 407 | $timestamp = date('Y-m-d H:i:s', (int) $timeInt) . '.' . round($timeFrac * 1000); 408 | 409 | file_put_contents($this->logFile, "[$timestamp] $message\n", FILE_APPEND | LOCK_EX); 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /tests/SchemaTest.php: -------------------------------------------------------------------------------- 1 | ') 42 | && !str_contains($serverVersion, 'MariaDB') 43 | ) { 44 | if ($tableName === 'type') { 45 | $columns['int_col']->size(null); 46 | $columns['int_col2']->size(null); 47 | $columns['bigunsigned_col']->size(null); 48 | $columns['tinyint_col']->size(null); 49 | $columns['smallint_col']->size(null); 50 | $columns['mediumint_col']->size(null); 51 | } 52 | 53 | if ($tableName === 'animal') { 54 | $columns['id']->size(null); 55 | } 56 | 57 | if ($tableName === 'T_constraints_1') { 58 | $columns['C_id']->size(null); 59 | $columns['C_not_null']->size(null); 60 | $columns['C_unique']->size(null); 61 | $columns['C_default']->size(null); 62 | } 63 | } 64 | 65 | parent::testColumns($columns, $tableName, $dump); 66 | } 67 | 68 | #[DataProviderExternal(SchemaProvider::class, 'columnsTypeBit')] 69 | public function testColumnWithTypeBit(array $columns): void 70 | { 71 | $this->assertTableColumns($columns, 'type_bit'); 72 | } 73 | 74 | public function testDefaultValueDatetimeColumn(): void 75 | { 76 | $tableName = '{{%datetime_test}}'; 77 | $db = $this->getSharedConnection(); 78 | $serverVersion = TestConnection::getServerVersion(); 79 | 80 | $oldMySQL = !( 81 | version_compare($serverVersion, '8.0.0', '>') 82 | && !str_contains($serverVersion, 'MariaDB') 83 | ); 84 | 85 | $utcTimezone = new DateTimeZone('UTC'); 86 | $dbTimezone = new DateTimeZone($db->getServerInfo()->getTimezone()); 87 | 88 | $columnsData = [ 89 | 'id' => ['int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', ''], 90 | 'd' => ['date DEFAULT \'2011-11-11\'', new DateTimeImmutable('2011-11-11', $utcTimezone)], 91 | 'dt' => ['datetime NOT NULL DEFAULT CURRENT_TIMESTAMP', new Expression('CURRENT_TIMESTAMP')], 92 | 'dt1' => ['datetime DEFAULT \'2011-11-11 00:00:00\'', new DateTimeImmutable('2011-11-11 00:00:00', $utcTimezone)], 93 | 'dt2' => ['datetime DEFAULT CURRENT_TIMESTAMP', new Expression('CURRENT_TIMESTAMP')], 94 | 'ts' => ['timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', new Expression('CURRENT_TIMESTAMP')], 95 | 'ts1' => ['timestamp DEFAULT \'2011-11-11 00:00:00\'', new DateTimeImmutable('2011-11-11 00:00:00', $dbTimezone)], 96 | 'ts2' => ['timestamp DEFAULT CURRENT_TIMESTAMP', new Expression('CURRENT_TIMESTAMP')], 97 | 'simple_col' => ['varchar(40) DEFAULT \'uuid()\'', 'uuid()'], 98 | ]; 99 | if (!$oldMySQL) { 100 | $columnsData['ts4'] = ['date DEFAULT (CURRENT_DATE + INTERVAL 2 YEAR)', new Expression('(curdate() + interval 2 year)')]; 101 | $columnsData['uuid_col'] = ['varchar(40) DEFAULT (uuid())', new Expression('(uuid())')]; 102 | } 103 | 104 | $columns = []; 105 | foreach ($columnsData as $column => $columnData) { 106 | $columns[$column] = $columnData[0]; 107 | } 108 | 109 | if ($db->getTableSchema($tableName, true) !== null) { 110 | $db->createCommand()->dropTable($tableName)->execute(); 111 | } 112 | 113 | $db->createCommand()->createTable($tableName, $columns, 'ENGINE=InnoDB DEFAULT CHARSET=utf8')->execute(); 114 | 115 | $tableSchema = $db->getTableSchema($tableName); 116 | $this->assertNotNull($tableSchema); 117 | 118 | foreach ($tableSchema->getColumns() as $column) { 119 | $columnName = $column->getName(); 120 | $this->assertEquals($columnsData[$columnName][1], $column->getDefaultValue()); 121 | } 122 | 123 | $db->close(); 124 | } 125 | 126 | public function testDefaultValueDatetimeColumnWithMicrosecs(): void 127 | { 128 | $db = $this->getSharedConnection(); 129 | 130 | $command = $db->createCommand(); 131 | $schema = $db->getSchema(); 132 | 133 | $sql = <<setSql($sql)->execute(); 141 | 142 | $schema = $schema->getTableSchema('current_timestamp_test'); 143 | 144 | $this->assertNotNull($schema); 145 | 146 | $dt = $schema->getColumn('dt'); 147 | 148 | $this->assertNotNull($dt); 149 | 150 | $this->assertInstanceOf(Expression::class, $dt->getDefaultValue()); 151 | $this->assertEquals('CURRENT_TIMESTAMP(2)', (string) $dt->getDefaultValue()); 152 | 153 | $ts = $schema->getColumn('ts'); 154 | 155 | $this->assertNotNull($ts); 156 | $this->assertInstanceOf(Expression::class, $ts->getDefaultValue()); 157 | $this->assertEquals('CURRENT_TIMESTAMP(3)', (string) $ts->getDefaultValue()); 158 | 159 | $db->close(); 160 | } 161 | 162 | public function testGetSchemaChecks(): void 163 | { 164 | $this->expectException(NotSupportedException::class); 165 | $this->expectExceptionMessage( 166 | 'Yiisoft\Db\Mysql\Schema::loadTableChecks is not supported by MySQL.', 167 | ); 168 | 169 | parent::testGetSchemaChecks(); 170 | } 171 | 172 | public function testGetSchemaDefaultValues(): void 173 | { 174 | $this->expectException(NotSupportedException::class); 175 | $this->expectExceptionMessage( 176 | 'Yiisoft\Db\Mysql\Schema::loadTableDefaultValues is not supported by MySQL.', 177 | ); 178 | 179 | parent::testGetSchemaDefaultValues(); 180 | } 181 | 182 | public function testGetSchemaNames(): void 183 | { 184 | $db = $this->getSharedConnection(); 185 | 186 | $schema = $db->getSchema(); 187 | 188 | $this->assertSame([TestConnection::databaseName()], $schema->getSchemaNames()); 189 | 190 | $db->close(); 191 | } 192 | 193 | public function testGetTableChecks(): void 194 | { 195 | $this->expectException(NotSupportedException::class); 196 | $this->expectExceptionMessage( 197 | 'Yiisoft\Db\Mysql\Schema::loadTableChecks is not supported by MySQL.', 198 | ); 199 | 200 | parent::testGetTableChecks(); 201 | } 202 | 203 | public function testGetTableNamesWithSchema(): void 204 | { 205 | $db = $this->getSharedConnection(); 206 | $this->loadFixture(); 207 | 208 | $schema = $db->getSchema(); 209 | $tablesNames = $schema->getTableNames(TestConnection::databaseName()); 210 | 211 | $expectedTableNames = [ 212 | 'alpha', 213 | 'animal', 214 | 'animal_view', 215 | 'beta', 216 | 'bit_values', 217 | 'category', 218 | 'comment', 219 | 'composite_fk', 220 | 'constraints', 221 | 'customer', 222 | 'default_pk', 223 | 'department', 224 | 'document', 225 | 'dossier', 226 | 'employee', 227 | 'item', 228 | 'negative_default_values', 229 | 'null_values', 230 | 'order', 231 | 'order_item', 232 | 'order_item_with_null_fk', 233 | 'order_with_null_fk', 234 | 'profile', 235 | 'quoter', 236 | 'storage', 237 | 'T_constraints_1', 238 | 'T_constraints_2', 239 | 'T_constraints_3', 240 | 'T_constraints_4', 241 | 'T_upsert', 242 | 'T_upsert_1', 243 | 'type', 244 | ]; 245 | 246 | foreach ($expectedTableNames as $tableName) { 247 | $this->assertContains($tableName, $tablesNames); 248 | } 249 | 250 | $db->close(); 251 | } 252 | 253 | public function testGetViewNames(): void 254 | { 255 | $db = $this->getSharedConnection(); 256 | $this->loadFixture(); 257 | 258 | $schema = $db->getSchema(); 259 | $views = $schema->getViewNames(); 260 | 261 | $viewExpected = match (str_contains(TestConnection::getServerVersion(), 'MariaDB')) { 262 | true => ['animal_view', 'user'], 263 | default => ['animal_view'], 264 | }; 265 | 266 | $this->assertSame($viewExpected, $views); 267 | 268 | $db->close(); 269 | } 270 | 271 | #[DataProviderExternal(SchemaProvider::class, 'constraints')] 272 | public function testTableSchemaConstraints(string $tableName, string $type, mixed $expected): void 273 | { 274 | parent::testTableSchemaConstraints($tableName, $type, $expected); 275 | } 276 | 277 | #[DataProviderExternal(SchemaProvider::class, 'constraints')] 278 | public function testTableSchemaConstraintsWithPdoLowercase(string $tableName, string $type, mixed $expected): void 279 | { 280 | parent::testTableSchemaConstraintsWithPdoLowercase($tableName, $type, $expected); 281 | } 282 | 283 | #[DataProviderExternal(SchemaProvider::class, 'constraints')] 284 | public function testTableSchemaConstraintsWithPdoUppercase(string $tableName, string $type, mixed $expected): void 285 | { 286 | parent::testTableSchemaConstraintsWithPdoUppercase($tableName, $type, $expected); 287 | } 288 | 289 | #[DataProviderExternal(SchemaProvider::class, 'tableSchemaWithDbSchemes')] 290 | public function testTableSchemaWithDbSchemes( 291 | string $tableName, 292 | string $expectedTableName, 293 | string $expectedSchemaName = '', 294 | ): void { 295 | $db = $this->getSharedConnection(); 296 | 297 | $commandMock = $this->createMock(CommandInterface::class); 298 | $commandMock->method('queryAll')->willReturn([]); 299 | $mockDb = $this->createMock(PdoConnectionInterface::class); 300 | $mockDb->method('getQuoter')->willReturn($db->getQuoter()); 301 | $mockDb 302 | ->method('createCommand') 303 | ->with( 304 | self::callback(static fn($sql) => true), 305 | self::callback( 306 | function ($params) use ($expectedTableName, $expectedSchemaName) { 307 | $this->assertEquals($expectedTableName, $params[':tableName']); 308 | $this->assertEquals($expectedSchemaName, $params[':schemaName']); 309 | 310 | return true; 311 | }, 312 | ), 313 | ) 314 | ->willReturn($commandMock); 315 | 316 | $schema = new Schema($mockDb, TestHelper::createMemorySchemaCache()); 317 | $schema->getTablePrimaryKey($tableName, true); 318 | 319 | $db->close(); 320 | } 321 | 322 | public function testWorkWithCheckConstraint(): void 323 | { 324 | $this->expectException(NotSupportedException::class); 325 | $this->expectExceptionMessage( 326 | 'Yiisoft\Db\Mysql\DDLQueryBuilder::addCheck is not supported by MySQL.', 327 | ); 328 | 329 | parent::testWorkWithCheckConstraint(); 330 | } 331 | 332 | public function testWorkWithDefaultValueConstraint(): void 333 | { 334 | $this->expectException(NotSupportedException::class); 335 | $this->expectExceptionMessage( 336 | 'Yiisoft\Db\Mysql\DDLQueryBuilder::addDefaultValue is not supported by MySQL.', 337 | ); 338 | 339 | parent::testWorkWithDefaultValueConstraint(); 340 | } 341 | 342 | public function testWorkWithPrimaryKeyConstraint(): void 343 | { 344 | $tableName = 'test_table_with'; 345 | $constraintName = 't_constraint'; 346 | $columnName = 't_field'; 347 | 348 | $db = $this->getSharedConnection(); 349 | 350 | $this->createTableForIndexAndConstraintTests($db, $tableName, $columnName); 351 | $db->createCommand()->addPrimaryKey($tableName, $constraintName, $columnName)->execute(); 352 | 353 | $this->assertEquals( 354 | new Index('PRIMARY', [$columnName], true, true), 355 | $db->getSchema()->getTablePrimaryKey($tableName), 356 | ); 357 | 358 | $db->createCommand()->dropPrimaryKey($tableName, $constraintName)->execute(); 359 | 360 | $constraints = $db->getSchema()->getTablePrimaryKey($tableName, true); 361 | 362 | $this->assertNull($constraints); 363 | 364 | $this->dropTableForIndexAndConstraintTests($db, $tableName); 365 | 366 | $db->close(); 367 | } 368 | 369 | public function testTinyInt1() 370 | { 371 | $db = $this->getSharedConnection(); 372 | $this->loadFixture(); 373 | 374 | $tableName = '{{%tinyint}}'; 375 | 376 | if ($db->getTableSchema($tableName)) { 377 | $db->createCommand()->dropTable($tableName)->execute(); 378 | } 379 | 380 | $db->createCommand()->createTable( 381 | $tableName, 382 | [ 383 | 'id' => ColumnBuilder::primaryKey(), 384 | 'bool_col' => ColumnBuilder::boolean(), 385 | 'status' => ColumnBuilder::tinyint(), 386 | ], 387 | )->execute(); 388 | 389 | $status = 2; 390 | $insertedRow = $db->createCommand()->insertReturningPks($tableName, ['status' => $status, 'bool_col' => true]); 391 | $selectedRow = $db->createCommand('SELECT * FROM ' . $tableName . ' WHERE id=:id', ['id' => $insertedRow['id']])->queryOne(); 392 | 393 | $this->assertEquals($status, $selectedRow['status']); 394 | $this->assertEquals(true, $selectedRow['bool_col']); 395 | 396 | $db->close(); 397 | } 398 | 399 | public function testNotConnectionPDO(): void 400 | { 401 | $db = $this->createMock(ConnectionInterface::class); 402 | $schema = new Schema($db, TestHelper::createMemorySchemaCache()); 403 | 404 | $this->expectException(NotSupportedException::class); 405 | $this->expectExceptionMessage('Only PDO connections are supported.'); 406 | 407 | $schema->refresh(); 408 | } 409 | 410 | public function testInsertDefaultValues() 411 | { 412 | $db = $this->getSharedConnection(); 413 | $this->loadFixture(); 414 | 415 | $command = $db->createCommand(); 416 | 417 | $command->insert('negative_default_values', [])->execute(); 418 | 419 | $row = (new Query($db))->from('negative_default_values')->one(); 420 | 421 | $this->assertSame([ 422 | 'tinyint_col' => '-123', 423 | 'smallint_col' => '-123', 424 | 'int_col' => '-123', 425 | 'bigint_col' => '-123', 426 | 'float_col' => '-12345.6789', 427 | 'numeric_col' => '-33.22', 428 | ], $row); 429 | 430 | $db->close(); 431 | } 432 | 433 | #[DataProviderExternal(SchemaProvider::class, 'resultColumns')] 434 | public function testGetResultColumn(?ColumnInterface $expected, array $metadata): void 435 | { 436 | parent::testGetResultColumn($expected, $metadata); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /tests/Support/Fixture/mysql.sql: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the database schema for testing MySQL support of Yii DAO and Active Record. 3 | * The database setup in config.php is required to perform then relevant tests: 4 | */ 5 | 6 | DROP TABLE IF EXISTS `composite_fk` CASCADE; 7 | DROP TABLE IF EXISTS `order_item` CASCADE; 8 | DROP TABLE IF EXISTS `order_item_with_null_fk` CASCADE; 9 | DROP TABLE IF EXISTS `item` CASCADE; 10 | DROP TABLE IF EXISTS `order` CASCADE; 11 | DROP TABLE IF EXISTS `order_with_null_fk` CASCADE; 12 | DROP TABLE IF EXISTS `category` CASCADE; 13 | DROP TABLE IF EXISTS `customer` CASCADE; 14 | DROP TABLE IF EXISTS `profile` CASCADE; 15 | DROP TABLE IF EXISTS `quoter` CASCADE; 16 | DROP TABLE IF EXISTS `null_values` CASCADE; 17 | DROP TABLE IF EXISTS `negative_default_values` CASCADE; 18 | DROP TABLE IF EXISTS `type` CASCADE; 19 | DROP TABLE IF EXISTS `type_bit`; 20 | DROP TABLE IF EXISTS `constraints` CASCADE; 21 | DROP TABLE IF EXISTS `animal` CASCADE; 22 | DROP TABLE IF EXISTS `default_pk` CASCADE; 23 | DROP TABLE IF EXISTS `notauto_pk` CASCADE; 24 | DROP TABLE IF EXISTS `without_pk` CASCADE; 25 | DROP TABLE IF EXISTS `document` CASCADE; 26 | DROP TABLE IF EXISTS `comment` CASCADE; 27 | DROP TABLE IF EXISTS `dossier`; 28 | DROP TABLE IF EXISTS `employee`; 29 | DROP TABLE IF EXISTS `department`; 30 | DROP TABLE IF EXISTS `storage`; 31 | DROP TABLE IF EXISTS `alpha`; 32 | DROP TABLE IF EXISTS `beta`; 33 | DROP VIEW IF EXISTS `animal_view`; 34 | DROP TABLE IF EXISTS `T_constraints_4` CASCADE; 35 | DROP TABLE IF EXISTS `T_constraints_3` CASCADE; 36 | DROP TABLE IF EXISTS `T_constraints_2` CASCADE; 37 | DROP TABLE IF EXISTS `T_constraints_1` CASCADE; 38 | DROP TABLE IF EXISTS `T_upsert` CASCADE; 39 | DROP TABLE IF EXISTS `T_upsert_1`; 40 | DROP TABLE IF EXISTS `json_type` CASCADE; 41 | 42 | CREATE TABLE `constraints` 43 | ( 44 | `id` integer not null, 45 | `field1` varchar(255) 46 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 47 | 48 | 49 | CREATE TABLE `profile` ( 50 | `id` int(11) NOT NULL AUTO_INCREMENT, 51 | `description` varchar(128) NOT NULL, 52 | PRIMARY KEY (`id`) 53 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 54 | 55 | CREATE TABLE `quoter` ( 56 | `id` int(11) NOT NULL AUTO_INCREMENT, 57 | `name` varchar(16) NOT NULL, 58 | `description` varchar(128) NOT NULL, 59 | PRIMARY KEY (`id`) 60 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 61 | 62 | CREATE TABLE `customer` ( 63 | `id` int(11) NOT NULL AUTO_INCREMENT, 64 | `email` varchar(128) NOT NULL, 65 | `name` varchar(128), 66 | `address` text, 67 | `status` int (11) DEFAULT 0, 68 | `profile_id` int(11), 69 | PRIMARY KEY (`id`), 70 | CONSTRAINT `FK_customer_profile_id` FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`) 71 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 72 | 73 | CREATE TABLE `category` ( 74 | `id` int(11) NOT NULL AUTO_INCREMENT, 75 | `name` varchar(128) NOT NULL, 76 | PRIMARY KEY (`id`) 77 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 78 | 79 | CREATE TABLE `item` ( 80 | `id` int(11) NOT NULL AUTO_INCREMENT, 81 | `name` varchar(128) NOT NULL, 82 | `category_id` int(11) NOT NULL, 83 | PRIMARY KEY (`id`), 84 | KEY `FK_item_category_id` (`category_id`), 85 | CONSTRAINT `FK_item_category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE 86 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 87 | 88 | CREATE TABLE `order` ( 89 | `id` int(11) NOT NULL AUTO_INCREMENT, 90 | `customer_id` int(11) NOT NULL, 91 | `created_at` int(11) NOT NULL, 92 | `total` decimal(10,0) NOT NULL, 93 | PRIMARY KEY (`id`), 94 | CONSTRAINT `FK_order_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`) ON DELETE CASCADE 95 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 96 | 97 | CREATE TABLE `order_with_null_fk` ( 98 | `id` int(11) NOT NULL AUTO_INCREMENT, 99 | `customer_id` int(11), 100 | `created_at` int(11) NOT NULL, 101 | `total` decimal(10,0) NOT NULL, 102 | PRIMARY KEY (`id`) 103 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 104 | 105 | CREATE TABLE `order_item` ( 106 | `order_id` int(11) NOT NULL, 107 | `item_id` int(11) NOT NULL, 108 | `quantity` int(11) NOT NULL, 109 | `subtotal` decimal(10,0) NOT NULL, 110 | PRIMARY KEY (`order_id`,`item_id`), 111 | KEY `FK_order_item_item_id` (`item_id`), 112 | CONSTRAINT `FK_order_item_order_id` FOREIGN KEY (`order_id`) REFERENCES `order` (`id`) ON DELETE CASCADE, 113 | CONSTRAINT `FK_order_item_item_id` FOREIGN KEY (`item_id`) REFERENCES `item` (`id`) ON DELETE CASCADE 114 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 115 | 116 | 117 | CREATE TABLE `order_item_with_null_fk` ( 118 | `order_id` int(11), 119 | `item_id` int(11), 120 | `quantity` int(11) NOT NULL, 121 | `subtotal` decimal(10,0) NOT NULL 122 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 123 | 124 | CREATE TABLE `composite_fk` ( 125 | `id` int(11) NOT NULL, 126 | `order_id` int(11) NOT NULL, 127 | `item_id` int(11) NOT NULL, 128 | PRIMARY KEY (`id`), 129 | CONSTRAINT `FK_composite_fk_order_item` FOREIGN KEY (`order_id`,`item_id`) REFERENCES `order_item` (`order_id`,`item_id`) ON DELETE CASCADE 130 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 131 | 132 | CREATE TABLE null_values ( 133 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, 134 | `var1` INT UNSIGNED NULL, 135 | `var2` INT NULL, 136 | `var3` INT DEFAULT NULL, 137 | `stringcol` VARCHAR (32) DEFAULT NULL, 138 | PRIMARY KEY (id) 139 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 140 | 141 | CREATE TABLE `negative_default_values` ( 142 | `tinyint_col` tinyint default '-123', 143 | `smallint_col` smallint default '-123', 144 | `int_col` integer default '-123', 145 | `bigint_col` bigint default '-123', 146 | `float_col` double default '-12345.6789', 147 | `numeric_col` decimal(5,2) default '-33.22' 148 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 149 | 150 | CREATE TABLE `type` ( 151 | `int_col` integer NOT NULL, 152 | `int_col2` integer DEFAULT '1', 153 | `bigunsigned_col` bigint unsigned DEFAULT '12345678901234567890', 154 | `tinyint_col` tinyint(3) DEFAULT '1', 155 | `smallint_col` smallint(1) DEFAULT '1', 156 | `mediumint_col` mediumint, 157 | `char_col` char(100) NOT NULL, 158 | `char_col2` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'something', 159 | `char_col3` text, 160 | `enum_col` enum('a', 'B', 'c,D'), 161 | `float_col` double(4,3) NOT NULL, 162 | `float_col2` double DEFAULT '1.23', 163 | `blob_col` blob, 164 | `numeric_col` decimal(5,2) DEFAULT '33.22', 165 | `timestamp_col` timestamp NOT NULL DEFAULT '2002-01-01 00:00:00', 166 | `timestamp_default` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 167 | `bool_col` bit(1) NOT NULL, 168 | `tiny_col` tinyint(1) DEFAULT '2', 169 | `bit_col` BIT(8) NOT NULL DEFAULT b'10000010', 170 | `tinyblob_col` tinyblob, 171 | `tinytext_col` tinytext, 172 | `mediumblob_col` mediumblob, 173 | `mediumtext_col` mediumtext, 174 | `json_col` json 175 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 176 | 177 | CREATE TABLE `type_bit` ( 178 | `bit_col_1` BIT(1) NOT NULL DEFAULT b'0', 179 | `bit_col_2` BIT(1) DEFAULT b'1', 180 | `bit_col_3` BIT(32) NOT NULL, 181 | `bit_col_4` BIT(32) DEFAULT b'10000010', 182 | `bit_col_5` BIT(64) NOT NULL, 183 | `bit_col_6` BIT(64) DEFAULT b'10000010' 184 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 185 | 186 | CREATE TABLE `animal` ( 187 | `id` INT NOT NULL AUTO_INCREMENT, 188 | `type` VARCHAR(255) NOT NULL, 189 | PRIMARY KEY (id) 190 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 191 | 192 | CREATE TABLE `default_pk` ( 193 | `id` INT NOT NULL DEFAULT 5, 194 | `type` VARCHAR(255) NOT NULL, 195 | PRIMARY KEY (id) 196 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 197 | 198 | CREATE TABLE `notauto_pk` ( 199 | `id_1` INTEGER, 200 | `id_2` DECIMAL(5,2), 201 | `type` VARCHAR(255) NOT NULL, 202 | PRIMARY KEY (`id_1`, `id_2`) 203 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 204 | 205 | CREATE TABLE `without_pk` ( 206 | `email` VARCHAR(126) UNIQUE, 207 | `name` VARCHAR(126), 208 | `address` TEXT, 209 | `status` INT(11) DEFAULT 1 210 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 211 | 212 | CREATE TABLE `document` ( 213 | `id` INT(11) NOT NULL AUTO_INCREMENT, 214 | `title` VARCHAR(255) NOT NULL, 215 | `content` TEXT, 216 | `version` INT(11) NOT NULL DEFAULT 0, 217 | PRIMARY KEY (id) 218 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 219 | 220 | CREATE TABLE `comment` ( 221 | `id` INT(11) NOT NULL AUTO_INCREMENT, 222 | `add_comment` VARCHAR(255) NOT NULL, 223 | `replace_comment` VARCHAR(255) COMMENT 'comment', 224 | `delete_comment` VARCHAR(128) NOT NULL COMMENT 'comment', 225 | PRIMARY KEY (id) 226 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 227 | 228 | CREATE TABLE `department` ( 229 | `id` INT(11) NOT NULL AUTO_INCREMENT, 230 | title VARCHAR(255) NOT NULL, 231 | PRIMARY KEY (`id`) 232 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 233 | 234 | CREATE TABLE `employee` ( 235 | `id` INT(11) NOT NULL, 236 | `department_id` INT(11) NOT NULL, 237 | `first_name` VARCHAR(255) NOT NULL, 238 | `last_name` VARCHAR(255) NOT NULL, 239 | PRIMARY KEY (`id`, `department_id`) 240 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 241 | 242 | CREATE TABLE `dossier` ( 243 | `id` INT(11) NOT NULL AUTO_INCREMENT, 244 | `department_id` INT(11) NOT NULL, 245 | `employee_id` INT(11) NOT NULL, 246 | `summary` VARCHAR(255) NOT NULL, 247 | PRIMARY KEY (`id`) 248 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 249 | 250 | CREATE TABLE `storage` ( 251 | `id` INT(11) NOT NULL AUTO_INCREMENT, 252 | `data` JSON NOT NULL, 253 | PRIMARY KEY (`id`) 254 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 255 | 256 | CREATE TABLE `alpha` ( 257 | `id` INT(11) NOT NULL AUTO_INCREMENT, 258 | `string_identifier` VARCHAR(255) NOT NULL, 259 | PRIMARY KEY (`id`) 260 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 261 | 262 | CREATE TABLE `beta` ( 263 | `id` INT(11) NOT NULL AUTO_INCREMENT, 264 | `alpha_string_identifier` VARCHAR(255) NOT NULL, 265 | PRIMARY KEY (`id`) 266 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 267 | 268 | CREATE TABLE `json_type` ( 269 | `id` INT(11) NOT NULL AUTO_INCREMENT, 270 | `json_col` JSON, 271 | PRIMARY KEY (`id`) 272 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 273 | 274 | CREATE VIEW `animal_view` AS SELECT * FROM `animal`; 275 | 276 | INSERT INTO `animal` (`type`) VALUES ('yiiunit\data\ar\Cat'); 277 | INSERT INTO `animal` (`type`) VALUES ('yiiunit\data\ar\Dog'); 278 | 279 | INSERT INTO `profile` (description) VALUES ('profile customer 1'); 280 | INSERT INTO `profile` (description) VALUES ('profile customer 3'); 281 | 282 | INSERT INTO `customer` (email, name, address, status, profile_id) VALUES ('user1@example.com', 'user1', 'address1', 1, 1); 283 | INSERT INTO `customer` (email, name, address, status) VALUES ('user2@example.com', 'user2', 'address2', 1); 284 | INSERT INTO `customer` (email, name, address, status, profile_id) VALUES ('user3@example.com', 'user3', 'address3', 2, 2); 285 | 286 | INSERT INTO `category` (name) VALUES ('Books'); 287 | INSERT INTO `category` (name) VALUES ('Movies'); 288 | 289 | INSERT INTO `item` (name, category_id) VALUES ('Agile Web Application Development with Yii1.1 and PHP5', 1); 290 | INSERT INTO `item` (name, category_id) VALUES ('Yii 1.1 Application Development Cookbook', 1); 291 | INSERT INTO `item` (name, category_id) VALUES ('Ice Age', 2); 292 | INSERT INTO `item` (name, category_id) VALUES ('Toy Story', 2); 293 | INSERT INTO `item` (name, category_id) VALUES ('Cars', 2); 294 | 295 | INSERT INTO `order` (customer_id, created_at, total) VALUES (1, 1325282384, 110.0); 296 | INSERT INTO `order` (customer_id, created_at, total) VALUES (2, 1325334482, 33.0); 297 | INSERT INTO `order` (customer_id, created_at, total) VALUES (2, 1325502201, 40.0); 298 | 299 | INSERT INTO `order_with_null_fk` (customer_id, created_at, total) VALUES (1, 1325282384, 110.0); 300 | INSERT INTO `order_with_null_fk` (customer_id, created_at, total) VALUES (2, 1325334482, 33.0); 301 | INSERT INTO `order_with_null_fk` (customer_id, created_at, total) VALUES (2, 1325502201, 40.0); 302 | 303 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (1, 1, 1, 30.0); 304 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (1, 2, 2, 40.0); 305 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (2, 4, 1, 10.0); 306 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (2, 5, 1, 15.0); 307 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (2, 3, 1, 8.0); 308 | INSERT INTO `order_item` (order_id, item_id, quantity, subtotal) VALUES (3, 2, 1, 40.0); 309 | 310 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (1, 1, 1, 30.0); 311 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (1, 2, 2, 40.0); 312 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (2, 4, 1, 10.0); 313 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (2, 5, 1, 15.0); 314 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (2, 3, 1, 8.0); 315 | INSERT INTO `order_item_with_null_fk` (order_id, item_id, quantity, subtotal) VALUES (3, 2, 1, 40.0); 316 | 317 | INSERT INTO `document` (title, content, version) VALUES ('Yii 2.0 guide', 'This is Yii 2.0 guide', 0); 318 | 319 | INSERT INTO `department` (id, title) VALUES (1, 'IT'); 320 | INSERT INTO `department` (id, title) VALUES (2, 'accounting'); 321 | 322 | INSERT INTO `employee` (id, department_id, first_name, last_name) VALUES (1, 1, 'John', 'Doe'); 323 | INSERT INTO `employee` (id, department_id, first_name, last_name) VALUES (1, 2, 'Ann', 'Smith'); 324 | INSERT INTO `employee` (id, department_id, first_name, last_name) VALUES (2, 2, 'Will', 'Smith'); 325 | 326 | INSERT INTO `dossier` (id, department_id, employee_id, summary) VALUES (1, 1, 1, 'Excellent employee.'); 327 | INSERT INTO `dossier` (id, department_id, employee_id, summary) VALUES (2, 2, 1, 'Brilliant employee.'); 328 | INSERT INTO `dossier` (id, department_id, employee_id, summary) VALUES (3, 2, 2, 'Good employee.'); 329 | 330 | INSERT INTO `alpha` (id, string_identifier) VALUES (1, '1'); 331 | INSERT INTO `alpha` (id, string_identifier) VALUES (2, '1a'); 332 | INSERT INTO `alpha` (id, string_identifier) VALUES (3, '01'); 333 | INSERT INTO `alpha` (id, string_identifier) VALUES (4, '001'); 334 | INSERT INTO `alpha` (id, string_identifier) VALUES (5, '2'); 335 | INSERT INTO `alpha` (id, string_identifier) VALUES (6, '2b'); 336 | INSERT INTO `alpha` (id, string_identifier) VALUES (7, '02'); 337 | INSERT INTO `alpha` (id, string_identifier) VALUES (8, '002'); 338 | 339 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (1, '1'); 340 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (2, '01'); 341 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (3, '001'); 342 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (4, '001'); 343 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (5, '2'); 344 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (6, '2b'); 345 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (7, '2b'); 346 | INSERT INTO `beta` (id, alpha_string_identifier) VALUES (8, '02'); 347 | 348 | INSERT INTO `json_type` (json_col) VALUES (null); 349 | INSERT INTO `json_type` (json_col) VALUES ('[]'); 350 | INSERT INTO `json_type` (json_col) VALUES ('[1,2,3,null]'); 351 | INSERT INTO `json_type` (json_col) VALUES ('[3,4,5]'); 352 | 353 | /* bit test, see https://github.com/yiisoft/yii2/issues/9006 */ 354 | 355 | DROP TABLE IF EXISTS `bit_values` CASCADE; 356 | 357 | CREATE TABLE `bit_values` ( 358 | `id` INT(11) NOT NULL AUTO_INCREMENT, 359 | `val` bit(1) NOT NULL, 360 | PRIMARY KEY (`id`) 361 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 362 | 363 | INSERT INTO `bit_values` (id, val) VALUES (1, b'0'), (2, b'1'); 364 | 365 | CREATE TABLE `T_constraints_1` 366 | ( 367 | `C_id` INT NOT NULL PRIMARY KEY, 368 | `C_not_null` INT NOT NULL, 369 | `C_check` VARCHAR(255) NULL CHECK (`C_check` <> ''), 370 | `C_unique` INT NOT NULL, 371 | `C_default` INT NOT NULL DEFAULT 0, 372 | CONSTRAINT `CN_unique` UNIQUE (`C_unique`) 373 | ) 374 | ENGINE = 'InnoDB' DEFAULT CHARSET = 'utf8mb4' COLLATE=utf8mb4_general_ci; 375 | 376 | CREATE TABLE `T_constraints_2` 377 | ( 378 | `C_id_1` INT NOT NULL, 379 | `C_id_2` INT NOT NULL, 380 | `C_index_1` INT NULL, 381 | `C_index_2_1` INT NULL, 382 | `C_index_2_2` INT NULL, 383 | CONSTRAINT `CN_constraints_2_multi` UNIQUE (`C_index_2_1`, `C_index_2_2`), 384 | CONSTRAINT `CN_pk` PRIMARY KEY (`C_id_1`, `C_id_2`) 385 | ) 386 | ENGINE = 'InnoDB' DEFAULT CHARSET = 'utf8mb4'; 387 | 388 | CREATE INDEX `CN_constraints_2_single` ON `T_constraints_2` (`C_index_1`); 389 | 390 | CREATE TABLE `T_constraints_3` 391 | ( 392 | `C_id` INT NOT NULL, 393 | `C_fk_id_1` INT NOT NULL, 394 | `C_fk_id_2` INT NOT NULL, 395 | CONSTRAINT `CN_constraints_3` FOREIGN KEY (`C_fk_id_1`, `C_fk_id_2`) REFERENCES `T_constraints_2` (`C_id_1`, `C_id_2`) ON DELETE CASCADE ON UPDATE CASCADE 396 | ) 397 | ENGINE = 'InnoDB' DEFAULT CHARSET = 'utf8mb4'; 398 | 399 | CREATE TABLE `T_constraints_4` 400 | ( 401 | `C_id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 402 | `C_col_1` INT NULL, 403 | `C_col_2` INT NOT NULL, 404 | CONSTRAINT `CN_constraints_4` UNIQUE (`C_col_1`, `C_col_2`) 405 | ) 406 | ENGINE = 'InnoDB' DEFAULT CHARSET = 'utf8mb4'; 407 | 408 | CREATE TABLE `T_upsert` 409 | ( 410 | `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, 411 | `ts` BIGINT NULL, 412 | `email` VARCHAR(128) NOT NULL UNIQUE, 413 | `recovery_email` VARCHAR(128) NULL, 414 | `address` TEXT NULL, 415 | `status` TINYINT NOT NULL DEFAULT 0, 416 | `orders` INT NOT NULL DEFAULT 0, 417 | `profile_id` INT NULL, 418 | UNIQUE (`email`, `recovery_email`) 419 | ) 420 | ENGINE = 'InnoDB' DEFAULT CHARSET = 'utf8mb4'; 421 | 422 | CREATE TABLE `T_upsert_1` ( 423 | `a` int(11) NOT NULL, 424 | PRIMARY KEY (`a`) 425 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 426 | -------------------------------------------------------------------------------- /src/Schema.php: -------------------------------------------------------------------------------- 1 | 68 | */ 69 | final class Schema extends AbstractPdoSchema 70 | { 71 | protected function findConstraints(TableSchemaInterface $table): void 72 | { 73 | $tableName = $this->resolveFullName($table->getName(), $table->getSchemaName()); 74 | 75 | $table->foreignKeys(...$this->getTableMetadata($tableName, SchemaInterface::FOREIGN_KEYS)); 76 | $table->indexes(...$this->getTableMetadata($tableName, SchemaInterface::INDEXES)); 77 | } 78 | 79 | /** 80 | * Collects the metadata of table columns. 81 | * 82 | * @param TableSchemaInterface $table The table metadata. 83 | * 84 | * @return bool Whether the table exists in the database. 85 | */ 86 | protected function findColumns(TableSchemaInterface $table): bool 87 | { 88 | $schemaName = $table->getSchemaName(); 89 | $tableName = $table->getName(); 90 | 91 | $columns = $this->db->createCommand( 92 | << $schemaName ?: null, 110 | ':tableName' => $tableName, 111 | ], 112 | )->queryAll(); 113 | 114 | if (empty($columns)) { 115 | return false; 116 | } 117 | 118 | $jsonColumns = $this->getJsonColumns($table); 119 | $isMariaDb = str_contains($this->db->getServerInfo()->getVersion(), 'MariaDB'); 120 | 121 | foreach ($columns as $info) { 122 | $info = array_change_key_case($info); 123 | 124 | $info['schema'] = $schemaName; 125 | $info['table'] = $tableName; 126 | 127 | if (in_array($info['column_name'], $jsonColumns, true)) { 128 | $info['column_type'] = ColumnType::JSON; 129 | } 130 | 131 | if ($isMariaDb && $info['column_default'] === 'NULL') { 132 | $info['column_default'] = null; 133 | } 134 | 135 | /** @psalm-var ColumnArray $info */ 136 | $column = $this->loadColumn($info); 137 | $table->column($info['column_name'], $column); 138 | 139 | if ($column->isPrimaryKey() && $column->isAutoIncrement()) { 140 | $table->sequenceName(''); 141 | } 142 | } 143 | 144 | return true; 145 | } 146 | 147 | protected function findSchemaNames(): array 148 | { 149 | $sql = <<db->createCommand($sql)->queryColumn(); 155 | } 156 | 157 | protected function findTableComment(TableSchemaInterface $tableSchema): void 158 | { 159 | $sql = <<db->createCommand($sql, [ 168 | ':schemaName' => $tableSchema->getSchemaName() ?: null, 169 | ':tableName' => $tableSchema->getName(), 170 | ])->queryScalar(); 171 | 172 | $tableSchema->comment(is_string($comment) ? $comment : null); 173 | } 174 | 175 | protected function findTableNames(string $schema = ''): array 176 | { 177 | $sql = 'SHOW TABLES'; 178 | 179 | if ($schema !== '') { 180 | $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema); 181 | } 182 | 183 | /** @var string[] */ 184 | return $this->db->createCommand($sql)->queryColumn(); 185 | } 186 | 187 | protected function findViewNames(string $schema = ''): array 188 | { 189 | $sql = match ($schema) { 190 | '' => << <<db->createCommand($sql)->queryColumn(); 200 | } 201 | 202 | /** 203 | * Gets the `CREATE TABLE` SQL string. 204 | * 205 | * @param TableSchemaInterface $table The table metadata. 206 | * 207 | * @return string $sql The result of `SHOW CREATE TABLE`. 208 | */ 209 | protected function getCreateTableSql(TableSchemaInterface $table): string 210 | { 211 | $tableName = $table->getFullName(); 212 | 213 | try { 214 | /** @psalm-var array $row */ 215 | $row = $this->db->createCommand( 216 | 'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName), 217 | )->queryOne(); 218 | 219 | if (isset($row['Create Table'])) { 220 | $sql = $row['Create Table']; 221 | } else { 222 | $row = array_values($row); 223 | $sql = $row[1]; 224 | } 225 | } catch (Exception) { 226 | $sql = ''; 227 | } 228 | 229 | return $sql; 230 | } 231 | 232 | /** 233 | * @psalm-param array{ 234 | * native_type: string, 235 | * pdo_type: int, 236 | * flags: string[], 237 | * table: string, 238 | * name: string, 239 | * len: int, 240 | * precision: int, 241 | * } $metadata 242 | * 243 | * @psalm-suppress MoreSpecificImplementedParamType 244 | */ 245 | protected function loadResultColumn(array $metadata): ?ColumnInterface 246 | { 247 | if (empty($metadata['native_type']) || $metadata['native_type'] === 'NULL') { 248 | return null; 249 | } 250 | 251 | $dbType = match ($metadata['native_type']) { 252 | 'TINY' => 'tinyint', 253 | 'SHORT' => 'smallint', 254 | 'INT24' => 'mediumint', 255 | 'LONG' => 'int', 256 | 'LONGLONG' => $metadata['len'] < 10 ? 'int' : 'bigint', 257 | 'NEWDECIMAL' => 'decimal', 258 | 'STRING' => 'char', 259 | 'VAR_STRING' => 'varchar', 260 | 'BLOB' => match ($metadata['len']) { 261 | 255 => 'tinyblob', 262 | 510, 765, 1020 => 'tinytext', 263 | // 65535 => 'blob', 264 | 131070, 196605, 262140 => 'text', 265 | 16777215 => 'mediumblob', 266 | 33554430, 50331645, 67108860 => 'mediumtext', 267 | 4294967295 => 'longblob', 268 | default => 'blob', 269 | }, 270 | default => strtolower($metadata['native_type']), 271 | }; 272 | 273 | $columnInfo = ['source' => ColumnInfoSource::QUERY_RESULT]; 274 | 275 | if (!empty($metadata['table'])) { 276 | $columnInfo['table'] = $metadata['table']; 277 | $columnInfo['name'] = $metadata['name']; 278 | } elseif (!empty($metadata['name'])) { 279 | $columnInfo['name'] = $metadata['name']; 280 | } 281 | 282 | if (!empty($metadata['len'])) { 283 | $columnInfo['size'] = match ($dbType) { 284 | 'decimal' => $metadata['len'] - ($metadata['precision'] === 0 ? 1 : 2), 285 | 'time', 'datetime', 'timestamp' => $metadata['precision'], 286 | default => $metadata['len'], 287 | }; 288 | } 289 | 290 | match ($dbType) { 291 | 'float', 'double', 'decimal' => $columnInfo['scale'] = $metadata['precision'], 292 | 'bigint' => $metadata['len'] === 20 ? $columnInfo['unsigned'] = true : null, 293 | 'int' => $metadata['len'] === 10 && PHP_INT_SIZE !== 8 ? $columnInfo['unsigned'] = true : null, 294 | 'timestamp' => $columnInfo['dbTimezone'] = $this->db->getServerInfo()->getTimezone(), 295 | default => null, 296 | }; 297 | 298 | $columnInfo['notNull'] = in_array('not_null', $metadata['flags'], true); 299 | 300 | return $this->db->getColumnFactory()->fromDbType($dbType, $columnInfo); 301 | } 302 | 303 | protected function loadTableChecks(string $tableName): array 304 | { 305 | throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.'); 306 | } 307 | 308 | protected function loadTableForeignKeys(string $tableName): array 309 | { 310 | $sql = <<db->getQuoter()->getTableNameParts($tableName); 335 | $foreignKeys = $this->db->createCommand($sql, [ 336 | ':schemaName' => $nameParts['schemaName'] ?? null, 337 | ':tableName' => $nameParts['name'], 338 | ])->queryAll(); 339 | 340 | /** @psalm-var list> $foreignKeys */ 341 | $foreignKeys = array_map(array_change_key_case(...), $foreignKeys); 342 | $foreignKeys = DbArrayHelper::arrange($foreignKeys, ['name']); 343 | 344 | $result = []; 345 | 346 | /** 347 | * @var string $name 348 | * @psalm-var ForeignKeysArray $foreignKey 349 | */ 350 | foreach ($foreignKeys as $name => $foreignKey) { 351 | $result[$name] = new ForeignKey( 352 | $name, 353 | array_column($foreignKey, 'column_name'), 354 | $foreignKey[0]['foreign_table_schema'], 355 | $foreignKey[0]['foreign_table_name'], 356 | array_column($foreignKey, 'foreign_column_name'), 357 | $foreignKey[0]['on_delete'], 358 | $foreignKey[0]['on_update'], 359 | ); 360 | } 361 | 362 | return $result; 363 | } 364 | 365 | /** 366 | * @throws NotSupportedException 367 | */ 368 | protected function loadTableDefaultValues(string $tableName): array 369 | { 370 | throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.'); 371 | } 372 | 373 | protected function loadTableIndexes(string $tableName): array 374 | { 375 | $sql = <<db->getQuoter()->getTableNameParts($tableName); 390 | $indexes = $this->db->createCommand($sql, [ 391 | ':schemaName' => $nameParts['schemaName'] ?? null, 392 | ':tableName' => $nameParts['name'], 393 | ])->queryAll(); 394 | 395 | /** @psalm-var list> $indexes */ 396 | $indexes = array_map(array_change_key_case(...), $indexes); 397 | $indexes = DbArrayHelper::arrange($indexes, ['name']); 398 | $result = []; 399 | 400 | /** 401 | * @var string $name 402 | * @psalm-var list $index 403 | */ 404 | foreach ($indexes as $name => $index) { 405 | $result[$name] = new Index( 406 | $name, 407 | array_column($index, 'column_name'), 408 | (bool) $index[0]['is_unique'], 409 | (bool) $index[0]['is_primary_key'], 410 | ); 411 | } 412 | 413 | return $result; 414 | } 415 | 416 | protected function loadTableSchema(string $name): ?TableSchemaInterface 417 | { 418 | $table = new TableSchema(...$this->db->getQuoter()->getTableNameParts($name)); 419 | $this->resolveTableCreateSql($table); 420 | 421 | if ($this->findColumns($table)) { 422 | $this->findTableComment($table); 423 | $this->findConstraints($table); 424 | 425 | return $table; 426 | } 427 | 428 | return null; 429 | } 430 | 431 | protected function resolveTableCreateSql(TableSchemaInterface $table): void 432 | { 433 | $sql = $this->getCreateTableSql($table); 434 | $table->createSql($sql); 435 | } 436 | 437 | /** 438 | * Loads the column information into a {@see ColumnInterface} object. 439 | * 440 | * @param array $info The column information. 441 | * 442 | * @return ColumnInterface The column object. 443 | * 444 | * @psalm-param ColumnArray $info The column information. 445 | */ 446 | private function loadColumn(array $info): ColumnInterface 447 | { 448 | $extra = trim(str_ireplace('auto_increment', '', $info['extra'], $autoIncrement)); 449 | $columnInfo = [ 450 | 'autoIncrement' => $autoIncrement > 0, 451 | 'characterSet' => $info['character_set_name'], 452 | 'collation' => $info['collation_name'], 453 | 'comment' => $info['column_comment'] === '' ? null : $info['column_comment'], 454 | 'defaultValueRaw' => $info['column_default'], 455 | 'extra' => $extra === '' ? null : $extra, 456 | 'name' => $info['column_name'], 457 | 'notNull' => $info['is_nullable'] !== 'YES', 458 | 'primaryKey' => $info['column_key'] === 'PRI', 459 | 'schema' => $info['schema'], 460 | 'source' => ColumnInfoSource::TABLE_SCHEMA, 461 | 'table' => $info['table'], 462 | 'unique' => $info['column_key'] === 'UNI', 463 | ]; 464 | 465 | if (substr_compare($info['column_type'], 'timestamp', 0, 9, true) === 0) { 466 | $columnInfo['dbTimezone'] = $this->db->getServerInfo()->getTimezone(); 467 | } 468 | 469 | /** @psalm-suppress InvalidArgument */ 470 | $column = $this->db->getColumnFactory()->fromDefinition($info['column_type'], $columnInfo); 471 | 472 | if (str_starts_with($extra, 'DEFAULT_GENERATED')) { 473 | $extra = trim(substr($extra, 18)); 474 | $column->extra($extra === '' ? null : $extra); 475 | } 476 | 477 | return $column; 478 | } 479 | 480 | private function getJsonColumns(TableSchemaInterface $table): array 481 | { 482 | $sql = $this->getCreateTableSql($table); 483 | $result = []; 484 | $regexp = '/json_valid\([`"](.+)[`"]\s*\)/mi'; 485 | 486 | if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER) > 0) { 487 | foreach ($matches as $match) { 488 | $result[] = $match[1]; 489 | } 490 | } 491 | 492 | return $result; 493 | } 494 | } 495 | --------------------------------------------------------------------------------