Skip to content

Commit c9d3cb6

Browse files
authored
feat: Port whereJsonContainsKey methods + CompilesJsonPaths from Laravel (#7699)
1 parent 2e5040a commit c9d3cb6

6 files changed

Lines changed: 226 additions & 27 deletions

File tree

src/Concerns/CompilesJsonPaths.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of Hyperf.
6+
*
7+
* @link https://www.hyperf.io
8+
* @document https://hyperf.wiki
9+
* @contact group@hyperf.io
10+
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
11+
*/
12+
13+
namespace Hyperf\Database\Concerns;
14+
15+
use Hyperf\Stringable\Str;
16+
17+
use function Hyperf\Collection\collect;
18+
19+
trait CompilesJsonPaths
20+
{
21+
/**
22+
* Split the given JSON selector into the field and the optional path and wrap them separately.
23+
*
24+
* @param string $column
25+
* @return array
26+
*/
27+
protected function wrapJsonFieldAndPath($column)
28+
{
29+
$parts = explode('->', $column, 2);
30+
31+
$field = $this->wrap($parts[0]);
32+
33+
$path = count($parts) > 1 ? ', ' . $this->wrapJsonPath($parts[1], '->') : '';
34+
35+
return [$field, $path];
36+
}
37+
38+
/**
39+
* Wrap the given JSON path.
40+
*
41+
* @param string $value
42+
* @param string $delimiter
43+
* @return string
44+
*/
45+
protected function wrapJsonPath($value, $delimiter = '->')
46+
{
47+
$value = preg_replace("/([\\\\]+)?\\'/", "''", $value);
48+
49+
$jsonPath = collect(explode($delimiter, $value))
50+
->map(fn ($segment) => $this->wrapJsonPathSegment($segment))
51+
->join('.');
52+
53+
return "'$" . (str_starts_with($jsonPath, '[') ? '' : '.') . $jsonPath . "'";
54+
}
55+
56+
/**
57+
* Wrap the given JSON path segment.
58+
*
59+
* @param string $segment
60+
* @return string
61+
*/
62+
protected function wrapJsonPathSegment($segment)
63+
{
64+
if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) {
65+
$key = Str::beforeLast($segment, $parts[0]);
66+
67+
if (! empty($key)) {
68+
return '"' . $key . '"' . $parts[0];
69+
}
70+
71+
return $parts[0];
72+
}
73+
74+
return '"' . $segment . '"';
75+
}
76+
}

src/Query/Builder.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,42 @@ public function orWhereJsonDoesntOverlap(string $column, mixed $value): static
15601560
return $this->whereJsonDoesntOverlap($column, $value, 'or');
15611561
}
15621562

1563+
/**
1564+
* Add a clause that determines if a JSON path exists to the query.
1565+
*/
1566+
public function whereJsonContainsKey(string $column, string $boolean = 'and', bool $not = false): static
1567+
{
1568+
$type = 'JsonContainsKey';
1569+
1570+
$this->wheres[] = compact('type', 'column', 'boolean', 'not');
1571+
1572+
return $this;
1573+
}
1574+
1575+
/**
1576+
* Add an "or" clause that determines if a JSON path exists to the query.
1577+
*/
1578+
public function orWhereJsonContainsKey(string $column): static
1579+
{
1580+
return $this->whereJsonContainsKey($column, 'or');
1581+
}
1582+
1583+
/**
1584+
* Add a clause that determines if a JSON path does not exist to the query.
1585+
*/
1586+
public function whereJsonDoesntContainKey(string $column, string $boolean = 'and'): static
1587+
{
1588+
return $this->whereJsonContainsKey($column, $boolean, true);
1589+
}
1590+
1591+
/**
1592+
* Add an "or" clause that determines if a JSON path does not exist to the query.
1593+
*/
1594+
public function orWhereJsonDoesntContainKey(string $column): static
1595+
{
1596+
return $this->whereJsonDoesntContainKey($column, 'or');
1597+
}
1598+
15631599
/**
15641600
* Add an "where Bit Functions and Operators" clause to the query.
15651601
*/

src/Query/Grammars/Grammar.php

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace Hyperf\Database\Query\Grammars;
1414

1515
use Hyperf\Collection\Arr;
16+
use Hyperf\Database\Concerns\CompilesJsonPaths;
1617
use Hyperf\Database\Grammar as BaseGrammar;
1718
use Hyperf\Database\Query\Builder;
1819
use Hyperf\Database\Query\Expression;
@@ -27,6 +28,8 @@
2728

2829
class Grammar extends BaseGrammar
2930
{
31+
use CompilesJsonPaths;
32+
3033
/**
3134
* The grammar specific operators.
3235
*/
@@ -398,6 +401,24 @@ protected function compileJsonOverlaps(string $column, string $value): string
398401
throw new RuntimeException('This database engine does not support JSON overlaps operations.');
399402
}
400403

404+
/**
405+
* Compile a "where JSON contains key" clause.
406+
*/
407+
protected function whereJsonContainsKey(Builder $query, array $where): string
408+
{
409+
$not = $where['not'] ? 'not ' : '';
410+
411+
return $not . $this->compileJsonContainsKey($where['column']);
412+
}
413+
414+
/**
415+
* Compile a "JSON contains key" statement into SQL.
416+
*/
417+
protected function compileJsonContainsKey(string $column): string
418+
{
419+
throw new RuntimeException('This database engine does not support JSON contains key operations.');
420+
}
421+
401422
/**
402423
* Compile the components necessary for a select clause.
403424
*/
@@ -1108,33 +1129,6 @@ protected function wrapJsonSelector($value): string
11081129
throw new RuntimeException('This database engine does not support JSON operations.');
11091130
}
11101131

1111-
/**
1112-
* Split the given JSON selector into the field and the optional path and wrap them separately.
1113-
*
1114-
* @param string $column
1115-
*/
1116-
protected function wrapJsonFieldAndPath($column): array
1117-
{
1118-
$parts = explode('->', $column, 2);
1119-
1120-
$field = $this->wrap($parts[0]);
1121-
1122-
$path = count($parts) > 1 ? ', ' . $this->wrapJsonPath($parts[1], '->') : '';
1123-
1124-
return [$field, $path];
1125-
}
1126-
1127-
/**
1128-
* Wrap the given JSON path.
1129-
*
1130-
* @param string $value
1131-
* @param string $delimiter
1132-
*/
1133-
protected function wrapJsonPath($value, $delimiter = '->'): string
1134-
{
1135-
return '\'$."' . str_replace($delimiter, '"."', $value) . '"\'';
1136-
}
1137-
11381132
/**
11391133
* Determine if the given string is a JSON selector.
11401134
*

src/Query/Grammars/MySqlGrammar.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,16 @@ protected function compileJsonOverlaps(string $column, string $value): string
246246
return 'json_overlaps(' . $field . ', ' . $value . $path . ')';
247247
}
248248

249+
/**
250+
* Compile a "JSON contains key" statement into SQL.
251+
*/
252+
protected function compileJsonContainsKey(string $column): string
253+
{
254+
[$field, $path] = $this->wrapJsonFieldAndPath($column);
255+
256+
return 'ifnull(json_contains_path(' . $field . ', \'one\'' . $path . '), 0)';
257+
}
258+
249259
/**
250260
* Compile a "JSON length" statement into SQL.
251261
*

src/Schema/Grammars/Grammar.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use Doctrine\DBAL\Schema\AbstractSchemaManager as SchemaManager;
1616
use Doctrine\DBAL\Schema\TableDiff;
17+
use Hyperf\Database\Concerns\CompilesJsonPaths;
1718
use Hyperf\Database\Connection;
1819
use Hyperf\Database\Grammar as BaseGrammar;
1920
use Hyperf\Database\Query\Expression;
@@ -25,6 +26,8 @@
2526

2627
abstract class Grammar extends BaseGrammar
2728
{
29+
use CompilesJsonPaths;
30+
2831
/**
2932
* If this Grammar supports schema changes wrapped in a transaction.
3033
*/

tests/QueryBuilderTest.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,29 @@ public function testMySqlUpdateWrappingNestedJson()
21532153
]);
21542154
}
21552155

2156+
public function testMySqlUpdateWrappingJsonPathArrayIndex()
2157+
{
2158+
$grammar = new MySqlGrammar();
2159+
$processor = Mockery::mock(Processor::class);
2160+
2161+
$connection = $this->createMock(ConnectionInterface::class);
2162+
$connection->expects($this->once())
2163+
->method('update')
2164+
->with(
2165+
'update `users` set `options` = json_set(`options`, \'$[1]."2fa"\', false), `meta` = json_set(`meta`, \'$."tags"[0][2]\', ?) where `active` = ?',
2166+
[
2167+
'large',
2168+
1,
2169+
]
2170+
);
2171+
2172+
$builder = new Builder($connection, $grammar, $processor);
2173+
$builder->from('users')->where('active', 1)->update([
2174+
'options->[1]->2fa' => false,
2175+
'meta->tags[0][2]' => 'large',
2176+
]);
2177+
}
2178+
21562179
public function testMySqlUpdateWithJsonPreparesBindingsCorrectly()
21572180
{
21582181
$grammar = new MySqlGrammar();
@@ -2257,6 +2280,29 @@ public function testMySqlWrappingJson()
22572280
$this->assertEquals('select * from `users` where json_unquote(json_extract(`items`, \'$."price"."in_usd"\')) = ? and json_unquote(json_extract(`items`, \'$."age"\')) = ?', $builder->toSql());
22582281
}
22592282

2283+
public function testJsonPathEscaping()
2284+
{
2285+
$expectedWithJsonEscaped = <<<'SQL'
2286+
select json_unquote(json_extract(`json`, '$."''))#"'))
2287+
SQL;
2288+
2289+
$builder = $this->getMySqlBuilder();
2290+
$builder->select("json->'))#");
2291+
$this->assertEquals($expectedWithJsonEscaped, $builder->toSql());
2292+
2293+
$builder = $this->getMySqlBuilder();
2294+
$builder->select("json->\\'))#");
2295+
$this->assertEquals($expectedWithJsonEscaped, $builder->toSql());
2296+
2297+
$builder = $this->getMySqlBuilder();
2298+
$builder->select("json->\\'))#");
2299+
$this->assertEquals($expectedWithJsonEscaped, $builder->toSql());
2300+
2301+
$builder = $this->getMySqlBuilder();
2302+
$builder->select("json->\\\\'))#");
2303+
$this->assertEquals($expectedWithJsonEscaped, $builder->toSql());
2304+
}
2305+
22602306
public function testMySqlSoundsLikeOperator()
22612307
{
22622308
$builder = $this->getMySqlBuilder();
@@ -2971,6 +3017,40 @@ public function testWhereJsonDoesntContainMySql()
29713017
$this->assertEquals([1], $builder->getBindings());
29723018
}
29733019

3020+
public function testWhereJsonContainsKeyMySql()
3021+
{
3022+
$builder = $this->getMySqlBuilder();
3023+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
3024+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`users`.`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
3025+
3026+
$builder = $this->getMySqlBuilder();
3027+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
3028+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."language"."primary"\'), 0)', $builder->toSql());
3029+
3030+
$builder = $this->getMySqlBuilder();
3031+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
3032+
$this->assertSame('select * from `users` where `id` = ? or ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
3033+
3034+
$builder = $this->getMySqlBuilder();
3035+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
3036+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql());
3037+
}
3038+
3039+
public function testWhereJsonDoesntContainKeyMySql()
3040+
{
3041+
$builder = $this->getMySqlBuilder();
3042+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
3043+
$this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
3044+
3045+
$builder = $this->getMySqlBuilder();
3046+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
3047+
$this->assertSame('select * from `users` where `id` = ? or not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
3048+
3049+
$builder = $this->getMySqlBuilder();
3050+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]');
3051+
$this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql());
3052+
}
3053+
29743054
public function testWhereJsonLengthMySql()
29753055
{
29763056
$builder = $this->getMySqlBuilder();

0 commit comments

Comments
 (0)