Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
64 changes: 64 additions & 0 deletions src/Illuminate/Database/Concerns/CompilesJsonPaths.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Illuminate\Database\Concerns;

use Illuminate\Support\Str;

trait CompilesJsonPaths
{
/**
* Split the given JSON selector into the field and the optional path and wrap them separately.
*
* @param string $column
* @return array
*/
protected function wrapJsonFieldAndPath($column)
{
$parts = explode('->', $column, 2);

$field = $this->wrap($parts[0]);

$path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : '';

return [$field, $path];
}

/**
* Wrap the given JSON path.
*
* @param string $value
* @param string $delimiter
* @return string
*/
protected function wrapJsonPath($value, $delimiter = '->')
{
$value = preg_replace("/([\\\\]+)?\\'/", "''", $value);

$jsonPath = collect(explode($delimiter, $value))
->map(fn ($segment) => $this->wrapJsonPathSegment($segment))
->join('.');

return "'$".(str_starts_with($jsonPath, '[') ? '' : '.').$jsonPath."'";
}

/**
* Wrap the given JSON path segment.
*
* @param string $segment
* @return string
*/
protected function wrapJsonPathSegment($segment)
{
if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) {
$key = Str::beforeLast($segment, $parts[0]);

if (! empty($key)) {
return '"'.$key.'"'.$parts[0];
}

return $parts[0];
}

return '"'.$segment.'"';
}
}
62 changes: 3 additions & 59 deletions src/Illuminate/Database/Query/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

namespace Illuminate\Database\Query\Grammars;

use Illuminate\Database\Concerns\CompilesJsonPaths;
use Illuminate\Database\Grammar as BaseGrammar;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use RuntimeException;

class Grammar extends BaseGrammar
{
use CompilesJsonPaths;

/**
* The grammar specific operators.
*
Expand Down Expand Up @@ -1259,64 +1261,6 @@ protected function wrapJsonBooleanValue($value)
return $value;
}

/**
* Split the given JSON selector into the field and the optional path and wrap them separately.
*
* @param string $column
* @return array
*/
protected function wrapJsonFieldAndPath($column)
{
$parts = explode('->', $column, 2);

$field = $this->wrap($parts[0]);

$path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : '';

return [$field, $path];
}

/**
* Wrap the given JSON path.
*
* @param string $value
* @param string $delimiter
* @return string
*/
protected function wrapJsonPath($value, $delimiter = '->')
{
$value = preg_replace("/([\\\\]+)?\\'/", "''", $value);

$jsonPath = collect(explode($delimiter, $value))
->map(function ($segment) {
return $this->wrapJsonPathSegment($segment);
})
->join('.');

return "'$".(str_starts_with($jsonPath, '[') ? '' : '.').$jsonPath."'";
}

/**
* Wrap the given JSON path segment.
*
* @param string $segment
* @return string
*/
protected function wrapJsonPathSegment($segment)
{
if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) {
$key = Str::beforeLast($segment, $parts[0]);

if (! empty($key)) {
return '"'.$key.'"'.$parts[0];
}

return $parts[0];
}

return '"'.$segment.'"';
}

/**
* Concatenate an array of segments, removing empties.
*
Expand Down
38 changes: 33 additions & 5 deletions src/Illuminate/Database/Query/Grammars/PostgresGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

class PostgresGrammar extends Grammar
{
Expand Down Expand Up @@ -374,7 +375,7 @@ protected function compileJsonUpdateColumn($key, $value)

$field = $this->wrap(array_shift($segments));

$path = '\'{"'.implode('","', $segments).'"}\'';
$path = "'{".implode(',', $this->wrapJsonPathAttributes($segments, '"'))."}'";

return "{$field} = jsonb_set({$field}::jsonb, {$path}, {$this->parameter($value)})";
}
Expand Down Expand Up @@ -623,17 +624,44 @@ protected function wrapJsonBooleanValue($value)
}

/**
* Wrap the attributes of the give JSON path.
* Wrap the attributes of the given JSON path.
*
* @param array $path
* @return array
*/
protected function wrapJsonPathAttributes($path)
{
return array_map(function ($attribute) {
$quote = func_num_args() === 2 ? func_get_arg(1) : "'";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

    protected function wrapJsonPathAttributes($path, $quote = "'")
    {

I forget if Laravel considers protected method argument changes as breaking. I guess userland may extend PostgresGrammar and override this method.


return collect($path)->map(function ($attribute) {
return $this->parseJsonPathArrayKeys($attribute);
})->collapse()->map(function ($attribute) use ($quote) {
return filter_var($attribute, FILTER_VALIDATE_INT) !== false
? $attribute
: "'$attribute'";
}, $path);
: $quote.$attribute.$quote;
})->all();
}

/**
* Parse the given JSON path attribute for array keys.
*
* @param string $attribute
* @return array
*/
protected function parseJsonPathArrayKeys($attribute)
{
if (preg_match('/(\[[^\]]+\])+$/', $attribute, $parts)) {
$key = Str::beforeLast($attribute, $parts[0]);

preg_match_all('/\[([^\]]+)\]/', $parts[0], $keys);

return collect([$key])
->merge($keys[1])
->diff('')
->values()
->all();
}

return [$attribute];
}
}
34 changes: 3 additions & 31 deletions src/Illuminate/Database/Schema/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Doctrine\DBAL\Schema\AbstractSchemaManager as SchemaManager;
use Doctrine\DBAL\Schema\TableDiff;
use Illuminate\Database\Concerns\CompilesJsonPaths;
use Illuminate\Database\Connection;
use Illuminate\Database\Grammar as BaseGrammar;
use Illuminate\Database\Query\Expression;
Expand All @@ -14,6 +15,8 @@

abstract class Grammar extends BaseGrammar
{
use CompilesJsonPaths;

/**
* If this Grammar supports schema changes wrapped in a transaction.
*
Expand Down Expand Up @@ -273,37 +276,6 @@ public function wrapTable($table)
);
}

/**
* Split the given JSON selector into the field and the optional path and wrap them separately.
*
* @param string $column
* @return array
*/
protected function wrapJsonFieldAndPath($column)
{
$parts = explode('->', $column, 2);

$field = $this->wrap($parts[0]);

$path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : '';

return [$field, $path];
}

/**
* Wrap the given JSON path.
*
* @param string $value
* @param string $delimiter
* @return string
*/
protected function wrapJsonPath($value, $delimiter = '->')
{
$value = preg_replace("/([\\\\]+)?\\'/", "''", $value);

return '\'$."'.str_replace($delimiter, '"."', $value).'"\'';
}

/**
* Wrap a value in keyword identifiers.
*
Expand Down
15 changes: 15 additions & 0 deletions tests/Database/DatabaseMySqlSchemaGrammarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,21 @@ public function testCreateTableWithVirtualAsColumn()
$this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\".\"nested\"'))))", $statements[0]);
}

public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey()
{
$blueprint = new Blueprint('users');
$blueprint->create();
$blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]');

$conn = $this->getConnection();
$conn->shouldReceive('getConfig')->andReturn(null);

$statements = $blueprint->toSql($conn, $this->getGrammar());

$this->assertCount(1, $statements);
$this->assertSame("create table `users` (`my_json_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"foo\"[0][1]'))))", $statements[0]);
}

public function testCreateTableWithStoredAsColumn()
{
$conn = $this->getConnection();
Expand Down
30 changes: 30 additions & 0 deletions tests/Database/DatabaseQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3138,6 +3138,21 @@ public function testPostgresUpdateWrappingJsonArray()
]);
}

public function testPostgresUpdateWrappingJsonPathArrayIndex()
{
$builder = $this->getPostgresBuilder();
$builder->getConnection()->shouldReceive('update')
->with('update "users" set "options" = jsonb_set("options"::jsonb, \'{1,"2fa"}\', ?), "meta" = jsonb_set("meta"::jsonb, \'{"tags",0,2}\', ?) where ("options"->1->\'2fa\')::jsonb = \'true\'::jsonb', [
'false',
'"large"',
]);

$builder->from('users')->where('options->[1]->2fa', true)->update([
'options->[1]->2fa' => false,
'meta->tags[0][2]' => 'large',
]);
}

public function testSQLiteUpdateWrappingJsonArray()
{
$builder = $this->getSQLiteBuilder();
Expand Down Expand Up @@ -3173,6 +3188,21 @@ public function testSQLiteUpdateWrappingNestedJsonArray()
]);
}

public function testSQLiteUpdateWrappingJsonPathArrayIndex()
{
$builder = $this->getSQLiteBuilder();
$builder->getConnection()->shouldReceive('update')
->with('update "users" set "options" = json_patch(ifnull("options", json(\'{}\')), json(?)), "meta" = json_patch(ifnull("meta", json(\'{}\')), json(?)) where json_extract("options", \'$[1]."2fa"\') = true', [
'{"[1]":{"2fa":false}}',
'{"tags[0][2]":"large"}',
]);

$builder->from('users')->where('options->[1]->2fa', true)->update([
'options->[1]->2fa' => false,
'meta->tags[0][2]' => 'large',
]);
}

public function testMySqlWrappingJsonWithString()
{
$builder = $this->getMySqlBuilder();
Expand Down
15 changes: 15 additions & 0 deletions tests/Database/DatabaseSQLiteSchemaGrammarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,21 @@ public function testCreateTableWithVirtualAsColumn()
$this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"."nested"\')))', $statements[0]);
}

public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey()
{
$blueprint = new Blueprint('users');
$blueprint->create();
$blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]');

$conn = $this->getConnection();
$conn->shouldReceive('getConfig')->andReturn(null);

$statements = $blueprint->toSql($conn, $this->getGrammar());

$this->assertCount(1, $statements);
$this->assertSame("create table \"users\" (\"my_json_column\" varchar as (json_extract(\"my_json_column\", '$.\"foo\"[0][1]')))", $statements[0]);
}

public function testCreateTableWithStoredAsColumn()
{
$blueprint = new Blueprint('users');
Expand Down
Loading