Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support json type #263

Merged
merged 7 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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.
Tigrov marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
Original file line number Diff line number Diff line change
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
return Json::decode((string) $value);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or you can replace Json::encode() in other place to json_encode and drop dependency yiisoft/json. It's even better this way.

Copy link
Member Author

@Tigrov Tigrov Aug 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Json::encode() is used according to MySQL and PostgreSQL

https://github.com/yiisoft/db-mysql/blob/master/src/Builder/JsonExpressionBuilder.php#L53
https://github.com/yiisoft/db-pgsql/blob/master/src/Builder/JsonExpressionBuilder.php#L56

Json::encode() Pre-processes the data before sending it to json_encode():
https://github.com/yiisoft/json/blob/master/src/Json.php#L100

Json::decode() is the same as json_decode() but with default arguments:
https://github.com/yiisoft/json/blob/master/src/Json.php#L81

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still good idea to use the helper's function

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

return parent::phpTypecast($value);
}
}
3 changes: 3 additions & 0 deletions src/DQLQueryBuilder.php
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 @@ -349,8 +350,13 @@ protected function findColumns(TableSchemaInterface $table): bool
{
/** @psalm-var PragmaTableInfo $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 @@ -720,4 +726,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
Original file line number Diff line number Diff line change
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 @@ -35,6 +40,8 @@ public function testPhpTypeCast(): void
'blob_col' => "\x10\x11\x12",
'timestamp_col' => '2023-07-11 14:50:23',
'bool_col' => false,
'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 @@ -49,6 +56,8 @@ public function testPhpTypeCast(): void
$blobColPhpType = $tableSchema->getColumn('blob_col')?->phpTypecast($query['blob_col']);
$timestampColPhpType = $tableSchema->getColumn('timestamp_col')?->phpTypecast($query['timestamp_col']);
$boolColPhpType = $tableSchema->getColumn('bool_col')?->phpTypecast($query['bool_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 @@ -57,7 +66,19 @@ public function testPhpTypeCast(): void
$this->assertSame("\x10\x11\x12", $blobColPhpType);
$this->assertSame('2023-07-11 14:50:23', $timestampColPhpType);
$this->assertFalse($boolColPhpType);
$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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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;
}
}
Loading
Loading