├── 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 |
4 |
5 |
6 |
7 |
8 |
Yii Database MySQL/MariaDB Driver
9 |
10 |
11 |
12 | [](https://packagist.org/packages/yiisoft/db-mysql)
13 | [](https://packagist.org/packages/yiisoft/db-mysql)
14 | [](https://github.com/yiisoft/db-mysql/actions/workflows/build.yml)
15 | [](https://codecov.io/gh/yiisoft/db-mysql)
16 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/db-mysql/master)
17 | [](https://github.com/yiisoft/db-mysql/actions?query=workflow%3A%22static+analysis%22)
18 | [](https://shepherd.dev/github/yiisoft/db-mysql)
19 | [](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 | [](https://opencollective.com/yiisoft)
67 |
68 | ## Follow updates
69 |
70 | [](https://www.yiiframework.com/)
71 | [](https://twitter.com/yiiframework)
72 | [](https://t.me/yii3en)
73 | [](https://www.facebook.com/groups/yiitalk)
74 | [](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 |
--------------------------------------------------------------------------------