Skip to content

Commit

Permalink
Fix #263: Support json type
Browse files Browse the repository at this point in the history
  • Loading branch information
Tigrov committed Aug 26, 2023
1 parent fd9a542 commit 1b375d2
Show file tree
Hide file tree
Showing 14 changed files with 347 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

## 1.0.2 under development

- Enh #263: Support json type (@Tigrov)
- Bug #268: Fix foreign keys: support multiple foreign keys referencing to one table and possible null columns for reference (@Tigrov)

## 1.0.1 July 24, 2023
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -24,7 +24,8 @@
"php": "^8.0",
"ext-mbstring": "*",
"ext-pdo": "*",
"yiisoft/db": "^1.0"
"yiisoft/db": "^1.0",
"yiisoft/json": "^1.0"
},
"require-dev": {
"ext-json": "*",
Expand Down
55 changes: 55 additions & 0 deletions src/Builder/JsonExpressionBuilder.php
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Sqlite\Builder;

use JsonException;
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Json\Json;

/**
* Builds expressions for {@see `Yiisoft\Db\Expression\JsonExpression`} for SQLite.
*/
final class JsonExpressionBuilder implements ExpressionBuilderInterface
{
public function __construct(private QueryBuilderInterface $queryBuilder)
{
}

/**
* The method builds the raw SQL from the `$expression` that won't be additionally escaped or quoted.
*
* @param JsonExpression $expression The expression to build.
* @param array $params The binding parameters.
*
* @throws Exception
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws JsonException
* @throws NotSupportedException
*
* @return string The raw SQL that won't be additionally escaped or quoted.
*/
public function build(ExpressionInterface $expression, array &$params = []): string
{
/** @psalm-var mixed $value */
$value = $expression->getValue();

if ($value instanceof QueryInterface) {
[$sql, $params] = $this->queryBuilder->build($value, $params);

return "($sql)";
}

return $this->queryBuilder->bindParam(Json::encode($value), $params);
}
}
50 changes: 50 additions & 0 deletions src/ColumnSchema.php
Expand Up @@ -4,7 +4,13 @@

namespace Yiisoft\Db\Sqlite;

use JsonException;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Schema\AbstractColumnSchema;
use Yiisoft\Db\Schema\SchemaInterface;

use function json_decode;

/**
* Represents the metadata of a column in a database table for SQLite Server.
Expand Down Expand Up @@ -32,4 +38,48 @@
*/
final class ColumnSchema extends AbstractColumnSchema
{
/**
* Converts a value from its PHP representation to a database-specific representation.
*
* If the value is null or an {@see Expression}, it won't be converted.
*
* @param mixed $value The value to be converted.
*
* @return mixed The converted value.
*/
public function dbTypecast(mixed $value): mixed
{
if ($value === null || $value instanceof ExpressionInterface) {
return $value;
}

if ($this->getType() === SchemaInterface::TYPE_JSON) {
return new JsonExpression($value, $this->getDbType());
}

return parent::dbTypecast($value);
}

/**
* Converts the input value according to {@see phpType} after retrieval from the database.
*
* If the value is null or an {@see Expression}, it won't be converted.
*
* @param mixed $value The value to be converted.
*
* @throws JsonException
* @return mixed The converted value.
*/
public function phpTypecast(mixed $value): mixed
{
if ($value === null) {
return null;
}

if ($this->getType() === SchemaInterface::TYPE_JSON) {
return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
}

return parent::phpTypecast($value);
}
}
3 changes: 3 additions & 0 deletions src/DQLQueryBuilder.php
Expand Up @@ -6,12 +6,14 @@

use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Query\Query;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\AbstractDQLQueryBuilder;
use Yiisoft\Db\QueryBuilder\Condition\InCondition;
use Yiisoft\Db\QueryBuilder\Condition\LikeCondition;
use Yiisoft\Db\Sqlite\Builder\InConditionBuilder;
use Yiisoft\Db\Sqlite\Builder\JsonExpressionBuilder;
use Yiisoft\Db\Sqlite\Builder\LikeConditionBuilder;

use function array_filter;
Expand Down Expand Up @@ -135,6 +137,7 @@ protected function defaultExpressionBuilders(): array
return array_merge(parent::defaultExpressionBuilders(), [
LikeCondition::class => LikeConditionBuilder::class,
InCondition::class => InConditionBuilder::class,
JsonExpression::class => JsonExpressionBuilder::class,
]);
}
}
1 change: 1 addition & 0 deletions src/QueryBuilder.php
Expand Up @@ -42,6 +42,7 @@ final class QueryBuilder extends AbstractQueryBuilder
SchemaInterface::TYPE_MONEY => 'decimal(19,4)',
SchemaInterface::TYPE_UUID => 'blob(16)',
SchemaInterface::TYPE_UUID_PK => 'blob(16) PRIMARY KEY',
SchemaInterface::TYPE_JSON => 'json',
];

public function __construct(QuoterInterface $quoter, SchemaInterface $schema)
Expand Down
27 changes: 27 additions & 0 deletions src/Schema.php
Expand Up @@ -105,6 +105,7 @@ final class Schema extends AbstractPdoSchema
'time' => self::TYPE_TIME,
'timestamp' => self::TYPE_TIMESTAMP,
'enum' => self::TYPE_STRING,
'json' => self::TYPE_JSON,
];

public function createColumn(string $type, array|int|string $length = null): ColumnInterface
Expand Down Expand Up @@ -364,8 +365,13 @@ protected function findColumns(TableSchemaInterface $table): bool
{
/** @psalm-var ColumnInfo[] $columns */
$columns = $this->getPragmaTableInfo($table->getName());
$jsonColumns = $this->getJsonColumns($table);

foreach ($columns as $info) {
if (in_array($info['name'], $jsonColumns, true)) {
$info['type'] = self::TYPE_JSON;
}

$column = $this->loadColumnSchema($info);
$table->column($column->getName(), $column);

Expand Down Expand Up @@ -734,4 +740,25 @@ protected function getCacheTag(): string
{
return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
}

/**
* @throws Throwable
*/
private function getJsonColumns(TableSchemaInterface $table): array
{
$result = [];
/** @psalm-var CheckConstraint[] $checks */
$checks = $this->getTableChecks((string) $table->getFullName());
$regexp = '/\bjson_valid\(\s*["`\[]?(.+?)["`\]]?\s*\)/i';

foreach ($checks as $check) {
if (preg_match_all($regexp, $check->getExpression(), $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$result[] = $match[1];
}
}
}

return $result;
}
}
21 changes: 21 additions & 0 deletions tests/ColumnSchemaTest.php
Expand Up @@ -4,7 +4,12 @@

namespace Yiisoft\Db\Sqlite\Tests;

use PDO;
use PHPUnit\Framework\TestCase;
use Yiisoft\Db\Command\Param;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Sqlite\ColumnSchema;
use Yiisoft\Db\Schema\SchemaInterface;
use Yiisoft\Db\Sqlite\Tests\Support\TestTrait;
use Yiisoft\Db\Query\Query;

Expand Down Expand Up @@ -36,6 +41,8 @@ public function testPhpTypeCast(): void
'timestamp_col' => '2023-07-11 14:50:23',
'bool_col' => false,
'bit_col' => 0b0110_0110, // 102
'json_col' => [['a' => 1, 'b' => null, 'c' => [1, 3, 5]]],
'json_text_col' => (new Query($db))->select(new Param('[1,2,3,"string",null]', PDO::PARAM_STR)),
]
);
$command->execute();
Expand All @@ -51,6 +58,8 @@ public function testPhpTypeCast(): void
$timestampColPhpType = $tableSchema->getColumn('timestamp_col')?->phpTypecast($query['timestamp_col']);
$boolColPhpType = $tableSchema->getColumn('bool_col')?->phpTypecast($query['bool_col']);
$bitColPhpType = $tableSchema->getColumn('bit_col')?->phpTypecast($query['bit_col']);
$jsonColPhpType = $tableSchema->getColumn('json_col')?->phpTypecast($query['json_col']);
$jsonTextColPhpType = $tableSchema->getColumn('json_text_col')?->phpTypecast($query['json_text_col']);

$this->assertSame(1, $intColPhpType);
$this->assertSame(str_repeat('x', 100), $charColPhpType);
Expand All @@ -60,7 +69,19 @@ public function testPhpTypeCast(): void
$this->assertSame('2023-07-11 14:50:23', $timestampColPhpType);
$this->assertFalse($boolColPhpType);
$this->assertSame(0b0110_0110, $bitColPhpType);
$this->assertSame([['a' => 1, 'b' => null, 'c' => [1, 3, 5]]], $jsonColPhpType);
$this->assertSame([1, 2, 3, 'string', null], $jsonTextColPhpType);

$db->close();
}

public function testTypeCastJson(): void
{
$columnSchema = new ColumnSchema('json_col');
$columnSchema->dbType(SchemaInterface::TYPE_JSON);
$columnSchema->type(SchemaInterface::TYPE_JSON);

$this->assertSame(['a' => 1], $columnSchema->phpTypeCast('{"a":1}'));
$this->assertEquals(new JsonExpression(['a' => 1], SchemaInterface::TYPE_JSON), $columnSchema->dbTypeCast(['a' => 1]));
}
}
35 changes: 35 additions & 0 deletions tests/CommandTest.php
Expand Up @@ -8,6 +8,7 @@
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Schema\SchemaInterface;
use Yiisoft\Db\Sqlite\Tests\Support\TestTrait;
use Yiisoft\Db\Tests\Common\CommonCommandTest;
Expand Down Expand Up @@ -495,4 +496,38 @@ public function testShowDatabases(): void
$this->assertSame('sqlite::memory:', $db->getDriver()->getDsn());
$this->assertSame(['main'], $command->showDatabases());
}

public function testJsonTable(): void
{
$db = $this->getConnection();
$command = $db->createCommand();

if ($db->getTableSchema('json_table', true) !== null) {
$command->dropTable('json_table')->execute();
}

$command->createTable('json_table', [
'id' => SchemaInterface::TYPE_PK,
'json_col' => SchemaInterface::TYPE_JSON,
])->execute();

$command->insert('json_table', ['id' => 1, 'json_col' => ['a' => 1, 'b' => 2]])->execute();
$command->insert('json_table', ['id' => 2, 'json_col' => new JsonExpression(['c' => 3, 'd' => 4])])->execute();

$tableSchema = $db->getTableSchema('json_table', true);
$this->assertNotNull($tableSchema);
$this->assertSame('json_col', $tableSchema->getColumn('json_col')->getName());
$this->assertSame('json', $tableSchema->getColumn('json_col')->getType());
$this->assertSame('json', $tableSchema->getColumn('json_col')->getDbType());

$this->assertSame(
'{"a":1,"b":2}',
$command->setSql('SELECT `json_col` FROM `json_table` WHERE `id`=1')->queryScalar(),
);

$this->assertSame(
'{"c":3,"d":4}',
$command->setSql('SELECT `json_col` FROM `json_table` WHERE `id`=2')->queryScalar(),
);
}
}
33 changes: 33 additions & 0 deletions tests/Provider/CommandProvider.php
Expand Up @@ -4,11 +4,44 @@

namespace Yiisoft\Db\Sqlite\Tests\Provider;

use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Sqlite\Tests\Support\TestTrait;

final class CommandProvider extends \Yiisoft\Db\Tests\Provider\CommandProvider
{
use TestTrait;

protected static string $driverName = 'sqlite';

public static function batchInsert(): array
{
$batchInsert = parent::batchInsert();

$batchInsert['batchInsert binds json params'] = [
'{{%type}}',
['int_col', 'char_col', 'float_col', 'bool_col', 'json_col'],
[
[1, 'a', 0.0, true, ['a' => 1, 'b' => true, 'c' => [1, 2, 3]]],
[2, 'b', -1.0, false, new JsonExpression(['d' => 'e', 'f' => false, 'g' => [4, 5, null]])],
],
'expected' => 'INSERT INTO `type` (`int_col`, `char_col`, `float_col`, `bool_col`, `json_col`) '
. 'VALUES (:qp0, :qp1, :qp2, :qp3, :qp4), (:qp5, :qp6, :qp7, :qp8, :qp9)',
'expectedParams' => [
':qp0' => 1,
':qp1' => 'a',
':qp2' => 0.0,
':qp3' => true,
':qp4' => '{"a":1,"b":true,"c":[1,2,3]}',

':qp5' => 2,
':qp6' => 'b',
':qp7' => -1.0,
':qp8' => false,
':qp9' => '{"d":"e","f":false,"g":[4,5,null]}',
],
2,
];

return $batchInsert;
}
}

0 comments on commit 1b375d2

Please sign in to comment.