diff --git a/src/Illuminate/Database/Concerns/CompilesJsonPaths.php b/src/Illuminate/Database/Concerns/CompilesJsonPaths.php new file mode 100644 index 000000000000..cd520e788502 --- /dev/null +++ b/src/Illuminate/Database/Concerns/CompilesJsonPaths.php @@ -0,0 +1,64 @@ +', $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.'"'; + } +} diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index 6543200e4bea..fb5894459b78 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -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. * @@ -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. * diff --git a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php index 6683e14452be..20090e822cdb 100755 --- a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php @@ -4,6 +4,7 @@ use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; +use Illuminate\Support\Str; class PostgresGrammar extends Grammar { @@ -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)})"; } @@ -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) : "'"; + + 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]; } } diff --git a/src/Illuminate/Database/Schema/Grammars/Grammar.php b/src/Illuminate/Database/Schema/Grammars/Grammar.php index 947283cb25b7..d446dd7dfbc7 100755 --- a/src/Illuminate/Database/Schema/Grammars/Grammar.php +++ b/src/Illuminate/Database/Schema/Grammars/Grammar.php @@ -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; @@ -14,6 +15,8 @@ abstract class Grammar extends BaseGrammar { + use CompilesJsonPaths; + /** * If this Grammar supports schema changes wrapped in a transaction. * @@ -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. * diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index bbe696fdb21d..61ac2224caf8 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -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(); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index eb773fea38f0..4ff656c351ed 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -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(); @@ -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(); diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index a9ae6b80ab1c..3546ddc9db57 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -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'); diff --git a/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php new file mode 100644 index 000000000000..96822f0e6bf2 --- /dev/null +++ b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php @@ -0,0 +1,93 @@ +json('json_col')->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('json_table'); + } + + /** + * @dataProvider jsonWhereNullDataProvider + */ + public function testJsonWhereNull($expected, $key, array $value = ['value' => 123]) + { + DB::table('json_table')->insert(['json_col' => json_encode($value)]); + + $this->assertSame($expected, DB::table('json_table')->whereNull("json_col->$key")->exists()); + } + + /** + * @dataProvider jsonWhereNullDataProvider + */ + public function testJsonWhereNotNull($expected, $key, array $value = ['value' => 123]) + { + DB::table('json_table')->insert(['json_col' => json_encode($value)]); + + $this->assertSame(! $expected, DB::table('json_table')->whereNotNull("json_col->$key")->exists()); + } + + public function jsonWhereNullDataProvider() + { + return [ + 'key not exists' => [true, 'invalid'], + 'key exists and null' => [true, 'value', ['value' => null]], + 'key exists and "null"' => [false, 'value', ['value' => 'null']], + 'key exists and not null' => [false, 'value', ['value' => false]], + 'nested key not exists' => [true, 'nested->invalid'], + 'nested key exists and null' => [true, 'nested->value', ['nested' => ['value' => null]]], + 'nested key exists and "null"' => [false, 'nested->value', ['nested' => ['value' => 'null']]], + 'nested key exists and not null' => [false, 'nested->value', ['nested' => ['value' => false]]], + 'array index not exists' => [true, '[0]', [1 => 'invalid']], + 'array index exists and null' => [true, '[0]', [null]], + 'array index exists and "null"' => [false, '[0]', ['null']], + 'array index exists and not null' => [false, '[0]', [false]], + 'multiple array index not exists' => [true, '[0][0]', [1 => [1 => 'invalid']]], + 'multiple array index exists and null' => [true, '[0][0]', [[null]]], + 'multiple array index exists and "null"' => [false, '[0][0]', [['null']]], + 'multiple array index exists and not null' => [false, '[0][0]', [[false]]], + 'nested array index not exists' => [true, 'nested[0]', ['nested' => [1 => 'nested->invalid']]], + 'nested array index exists and null' => [true, 'nested->value[1]', ['nested' => ['value' => [0, null]]]], + 'nested array index exists and "null"' => [false, 'nested->value[1]', ['nested' => ['value' => [0, 'null']]]], + 'nested array index exists and not null' => [false, 'nested->value[1]', ['nested' => ['value' => [0, false]]]], + ]; + } + + public function testJsonPathUpdate() + { + DB::table('json_table')->insert([ + ['json_col' => '{"foo":["bar"]}'], + ['json_col' => '{"foo":["baz"]}'], + ['json_col' => '{"foo":[["array"]]}'], + ]); + + $updatedCount = DB::table('json_table')->where('json_col->foo[0]', 'baz')->update([ + 'json_col->foo[0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + + $updatedCount = DB::table('json_table')->where('json_col->foo[0][0]', 'array')->update([ + 'json_col->foo[0][0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + } +}