diff --git a/.github/workflows/ci-mariadb.yml b/.github/workflows/ci-mariadb.yml new file mode 100644 index 00000000000..d12d0a47c65 --- /dev/null +++ b/.github/workflows/ci-mariadb.yml @@ -0,0 +1,67 @@ +on: + - pull_request + - push + +name: ci-mariadb + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: PHP ${{ matrix.php }}-mariadb-${{ matrix.mariadb }} + env: + extensions: curl, intl, pdo, pdo_mysql + XDEBUG_MODE: coverage, develop + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + mariadb: + - mariadb:10.4 + - mariadb:latest + + services: + mysql: + image: ${{ matrix.mariadb }} + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: yiitest + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout. + uses: actions/checkout@v4 + + - name: Install PHP with extensions. + uses: shivammathur/setup-php@v2 + with: + coverage: xdebug + extensions: ${{ env.EXTENSIONS }} + ini-values: date.timezone='UTC' + php-version: ${{ matrix.php }} + tools: composer:v2, pecl + + - name: Install dependencies with composer + if: matrix.php != '8.4' + run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Install dependencies with PHP 8.4. + if: matrix.php == '8.4' + run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ignore-platform-reqs --ansi + + - name: Run MariaDB tests with PHPUnit and generate coverage. + run: vendor/bin/phpunit --group mysql --coverage-clover=coverage.xml --colors=always + + - name: Upload coverage to Codecov. + if: matrix.php == '7.4' + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index a8adf0f0143..8ed5a4669fc 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -30,6 +30,7 @@ Yii Framework 2 Change Log - Bug #19817: Add MySQL Query `addCheck()` and `dropCheck()` (@bobonov) - Bug #20165: Adjust pretty name of closures for PHP 8.4 compatibility (@staabm) - Bug #19855: Fixed `yii\validators\FileValidator` to not limit some of its rules only to array attribute (bizley) +- Enh: #20171: Support JSON columns for MariaDB 10.4 or higher (@terabytesoftw) 2.0.49.2 October 12, 2023 ------------------------- diff --git a/framework/db/mysql/JsonExpressionBuilder.php b/framework/db/mysql/JsonExpressionBuilder.php index fe7fc8b7869..cb2b35c83b2 100644 --- a/framework/db/mysql/JsonExpressionBuilder.php +++ b/framework/db/mysql/JsonExpressionBuilder.php @@ -44,6 +44,6 @@ public function build(ExpressionInterface $expression, array &$params = []) $placeholder = static::PARAM_PREFIX . count($params); $params[$placeholder] = Json::encode($value); - return "CAST($placeholder AS JSON)"; + return $placeholder; } } diff --git a/framework/db/mysql/Schema.php b/framework/db/mysql/Schema.php index 7a60c620f21..e9678392f67 100644 --- a/framework/db/mysql/Schema.php +++ b/framework/db/mysql/Schema.php @@ -380,10 +380,19 @@ protected function findColumns($table) } throw $e; } + + + $jsonColumns = $this->getJsonColumns($table); + foreach ($columns as $info) { if ($this->db->slavePdo->getAttribute(\PDO::ATTR_CASE) !== \PDO::CASE_LOWER) { $info = array_change_key_case($info, CASE_LOWER); } + + if (\in_array($info['field'], $jsonColumns, true)) { + $info['type'] = static::TYPE_JSON; + } + $column = $this->loadColumnSchema($info); $table->columns[$column->name] = $column; if ($column->isPrimaryKey) { @@ -641,4 +650,20 @@ private function loadTableConstraints($tableName, $returnType) return $result[$returnType]; } + + private function getJsonColumns(TableSchema $table): array + { + $sql = $this->getCreateTableSql($table); + $result = []; + + $regexp = '/json_valid\([\`"](.+)[\`"]\s*\)/mi'; + + if (\preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $result[] = $match[1]; + } + } + + return $result; + } } diff --git a/framework/web/Controller.php b/framework/web/Controller.php index d1688cfafee..db7b3d81a1a 100644 --- a/framework/web/Controller.php +++ b/framework/web/Controller.php @@ -129,9 +129,8 @@ public function bindActionParams($action, $params) foreach ($method->getParameters() as $param) { $name = $param->getName(); if (array_key_exists($name, $params)) { - $isValid = true; + $isValid = true; $isArray = ($type = $param->getType()) instanceof \ReflectionNamedType && $type->getName() === 'array'; - if ($isArray) { $params[$name] = (array)$params[$name]; } elseif (is_array($params[$name])) { diff --git a/tests/framework/db/mysql/QueryBuilderTest.php b/tests/framework/db/mysql/QueryBuilderTest.php index f0f8cdfb397..7097b8d2b61 100644 --- a/tests/framework/db/mysql/QueryBuilderTest.php +++ b/tests/framework/db/mysql/QueryBuilderTest.php @@ -267,35 +267,35 @@ public function conditionProvider() // json conditions [ ['=', 'jsoncol', new JsonExpression(['lang' => 'uk', 'country' => 'UA'])], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => '{"lang":"uk","country":"UA"}'], + '[[jsoncol]] = :qp0', [':qp0' => '{"lang":"uk","country":"UA"}'], ], [ ['=', 'jsoncol', new JsonExpression([false])], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => '[false]'] + '[[jsoncol]] = :qp0', [':qp0' => '[false]'] ], 'object with type. Type is ignored for MySQL' => [ ['=', 'prices', new JsonExpression(['seeds' => 15, 'apples' => 25], 'jsonb')], - '[[prices]] = CAST(:qp0 AS JSON)', [':qp0' => '{"seeds":15,"apples":25}'], + '[[prices]] = :qp0', [':qp0' => '{"seeds":15,"apples":25}'], ], 'nested json' => [ ['=', 'data', new JsonExpression(['user' => ['login' => 'silverfire', 'password' => 'c4ny0ur34d17?'], 'props' => ['mood' => 'good']])], - '[[data]] = CAST(:qp0 AS JSON)', [':qp0' => '{"user":{"login":"silverfire","password":"c4ny0ur34d17?"},"props":{"mood":"good"}}'] + '[[data]] = :qp0', [':qp0' => '{"user":{"login":"silverfire","password":"c4ny0ur34d17?"},"props":{"mood":"good"}}'] ], 'null value' => [ ['=', 'jsoncol', new JsonExpression(null)], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => 'null'] + '[[jsoncol]] = :qp0', [':qp0' => 'null'] ], 'null as array value' => [ ['=', 'jsoncol', new JsonExpression([null])], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => '[null]'] + '[[jsoncol]] = :qp0', [':qp0' => '[null]'] ], 'null as object value' => [ ['=', 'jsoncol', new JsonExpression(['nil' => null])], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => '{"nil":null}'] + '[[jsoncol]] = :qp0', [':qp0' => '{"nil":null}'] ], 'with object as value' => [ ['=', 'jsoncol', new JsonExpression(new DynamicModel(['a' => 1, 'b' => 2]))], - '[[jsoncol]] = CAST(:qp0 AS JSON)', [':qp0' => '{"a":1,"b":2}'] + '[[jsoncol]] = :qp0', [':qp0' => '{"a":1,"b":2}'] ], 'query' => [ ['=', 'jsoncol', new JsonExpression((new Query())->select('params')->from('user')->where(['id' => 1]))], @@ -307,7 +307,7 @@ public function conditionProvider() ], 'nested and combined json expression' => [ ['=', 'jsoncol', new JsonExpression(new JsonExpression(['a' => 1, 'b' => 2, 'd' => new JsonExpression(['e' => 3])]))], - "[[jsoncol]] = CAST(:qp0 AS JSON)", [':qp0' => '{"a":1,"b":2,"d":{"e":3}}'] + "[[jsoncol]] = :qp0", [':qp0' => '{"a":1,"b":2,"d":{"e":3}}'] ], 'search by property in JSON column (issue #15838)' => [ ['=', new Expression("(jsoncol->>'$.someKey')"), '42'], @@ -328,7 +328,7 @@ public function updateProvider() [ 'id' => 1, ], - $this->replaceQuotes('UPDATE [[profile]] SET [[description]]=CAST(:qp0 AS JSON) WHERE [[id]]=:qp1'), + $this->replaceQuotes('UPDATE [[profile]] SET [[description]]=:qp0 WHERE [[id]]=:qp1'), [ ':qp0' => '{"abc":"def","0":123,"1":null}', ':qp1' => 1, diff --git a/tests/framework/db/mysql/SchemaTest.php b/tests/framework/db/mysql/SchemaTest.php index ba98f45e385..aa2634ff307 100644 --- a/tests/framework/db/mysql/SchemaTest.php +++ b/tests/framework/db/mysql/SchemaTest.php @@ -146,87 +146,108 @@ public function testAlternativeDisplayOfDefaultCurrentTimestampAsNullInMariaDB() public function getExpectedColumns() { - $version = $this->getConnection()->getSchema()->getServerVersion(); + $version = $this->getConnection(false)->getServerVersion(); $columns = array_merge( parent::getExpectedColumns(), [ 'int_col' => [ 'type' => 'integer', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'int' : 'int(11)', + 'dbType' => 'int(11)', 'phpType' => 'integer', 'allowNull' => false, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 11, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 11, + 'size' => 11, + 'precision' => 11, 'scale' => null, 'defaultValue' => null, ], 'int_col2' => [ 'type' => 'integer', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'int' : 'int(11)', + 'dbType' => 'int(11)', 'phpType' => 'integer', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 11, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 11, + 'size' => 11, + 'precision' => 11, 'scale' => null, 'defaultValue' => 1, ], 'int_col3' => [ 'type' => 'integer', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'int unsigned' : 'int(11) unsigned', + 'dbType' => 'int(11) unsigned', 'phpType' => 'integer', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 11, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 11, + 'size' => 11, + 'precision' => 11, 'scale' => null, 'defaultValue' => 1, ], 'tinyint_col' => [ 'type' => 'tinyint', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'tinyint' : 'tinyint(3)', + 'dbType' => 'tinyint(3)', 'phpType' => 'integer', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 3, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 3, + 'size' => 3, + 'precision' => 3, 'scale' => null, 'defaultValue' => 1, ], 'smallint_col' => [ 'type' => 'smallint', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'smallint' : 'smallint(1)', + 'dbType' => 'smallint(1)', 'phpType' => 'integer', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 1, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 1, + 'size' => 1, + 'precision' => 1, 'scale' => null, 'defaultValue' => 1, ], 'bigint_col' => [ 'type' => 'bigint', - 'dbType' => \version_compare($version, '8.0.17', '>') ? 'bigint unsigned' : 'bigint(20) unsigned', + 'dbType' => 'bigint(20) unsigned', 'phpType' => 'string', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, - 'size' => \version_compare($version, '8.0.17', '>') ? null : 20, - 'precision' => \version_compare($version, '8.0.17', '>') ? null : 20, + 'size' => 20, + 'precision' => 20, 'scale' => null, 'defaultValue' => null, ], ] ); - if (version_compare($version, '5.7', '<')) { + if (\version_compare($version, '8.0.17', '>') && \stripos($version, 'MariaDb') === false) { + $columns['int_col']['dbType'] = 'int'; + $columns['int_col']['size'] = null; + $columns['int_col']['precision'] = null; + $columns['int_col2']['dbType'] = 'int'; + $columns['int_col2']['size'] = null; + $columns['int_col2']['precision'] = null; + $columns['int_col3']['dbType'] = 'int unsigned'; + $columns['int_col3']['size'] = null; + $columns['int_col3']['precision'] = null; + $columns['tinyint_col']['dbType'] = 'tinyint'; + $columns['tinyint_col']['size'] = null; + $columns['tinyint_col']['precision'] = null; + $columns['smallint_col']['dbType'] = 'smallint'; + $columns['smallint_col']['size'] = null; + $columns['smallint_col']['precision'] = null; + $columns['bigint_col']['dbType'] = 'bigint unsigned'; + $columns['bigint_col']['size'] = null; + $columns['bigint_col']['precision'] = null; + } + + if (version_compare($version, '5.7', '<') && \stripos($version, 'MariaDb') === false) { $columns['int_col3']['phpType'] = 'string'; $columns['json_col']['type'] = 'text'; $columns['json_col']['dbType'] = 'longtext'; diff --git a/tests/framework/db/mysql/type/JsonTest.php b/tests/framework/db/mysql/type/JsonTest.php new file mode 100644 index 00000000000..b955c7221cd --- /dev/null +++ b/tests/framework/db/mysql/type/JsonTest.php @@ -0,0 +1,85 @@ +getConnection(); + + if ($db->getSchema()->getTableSchema('json') !== null) { + $db->createCommand()->dropTable('json')->execute(); + } + + $command = $db->createCommand(); + $command->createTable('json', ['id' => Schema::TYPE_PK, 'data' => Schema::TYPE_JSON])->execute(); + + $this->assertTrue($db->getTableSchema('json') !== null); + $this->assertSame('data', $db->getTableSchema('json')->getColumn('data')->name); + $this->assertSame('json', $db->getTableSchema('json')->getColumn('data')->type); + } + + public function testInsertAndSelect(): void + { + $db = $this->getConnection(true); + $version = $db->getServerVersion(); + + $command = $db->createCommand(); + $command->insert('storage', ['data' => ['a' => 1, 'b' => 2]])->execute(); + + if (\stripos($version, 'MariaDb') === false) { + $rowExpected = '{"a": 1, "b": 2}'; + } else { + $rowExpected = '{"a":1,"b":2}'; + } + + $this->assertSame( + $rowExpected, + $command->setSql( + <<queryScalar(), + ); + } + + public function testInsertJsonExpressionAndSelect(): void + { + $db = $this->getConnection(true); + $version = $db->getServerVersion(); + + $command = $db->createCommand(); + $command->insert('storage', ['data' => new JsonExpression(['a' => 1, 'b' => 2])])->execute(); + + if (\stripos($version, 'MariaDb') === false) { + $rowExpected = '{"a": 1, "b": 2}'; + } else { + $rowExpected = '{"a":1,"b":2}'; + } + + $this->assertSame( + $rowExpected, + $command->setSql( + <<queryScalar(), + ); + } +}