Skip to content

Commit 756097c

Browse files
derekmdamir9480taylorotwell
authored
[9.x] Add query builder method whereJsonContainsKey() (#41802)
* Add query builder method whereJsonContainsKey() Allow filtering by JSONB documents that contain a given object string key or an array integer key. * formatting Co-authored-by: amir <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent 0f3c06b commit 756097c

File tree

12 files changed

+512
-0
lines changed

12 files changed

+512
-0
lines changed

src/Illuminate/Database/Query/Builder.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,6 +1776,57 @@ public function orWhereJsonDoesntContain($column, $value)
17761776
return $this->whereJsonDoesntContain($column, $value, 'or');
17771777
}
17781778

1779+
/**
1780+
* Add a clause that determines if a JSON path exists to the query.
1781+
*
1782+
* @param string $column
1783+
* @param string $boolean
1784+
* @param bool $not
1785+
* @return $this
1786+
*/
1787+
public function whereJsonContainsKey($column, $boolean = 'and', $not = false)
1788+
{
1789+
$type = 'JsonContainsKey';
1790+
1791+
$this->wheres[] = compact('type', 'column', 'boolean', 'not');
1792+
1793+
return $this;
1794+
}
1795+
1796+
/**
1797+
* Add an "or" clause that determines if a JSON path exists to the query.
1798+
*
1799+
* @param string $column
1800+
* @return $this
1801+
*/
1802+
public function orWhereJsonContainsKey($column)
1803+
{
1804+
return $this->whereJsonContainsKey($column, 'or');
1805+
}
1806+
1807+
/**
1808+
* Add a clause that determines if a JSON path does not exist to the query.
1809+
*
1810+
* @param string $column
1811+
* @param string $boolean
1812+
* @return $this
1813+
*/
1814+
public function whereJsonDoesntContainKey($column, $boolean = 'and')
1815+
{
1816+
return $this->whereJsonContainsKey($column, $boolean, true);
1817+
}
1818+
1819+
/**
1820+
* Add an "or" clause that determines if a JSON path does not exist to the query.
1821+
*
1822+
* @param string $column
1823+
* @return $this
1824+
*/
1825+
public function orWhereJsonDoesntContainKey($column)
1826+
{
1827+
return $this->whereJsonDoesntContainKey($column, 'or');
1828+
}
1829+
17791830
/**
17801831
* Add a "where JSON length" clause to the query.
17811832
*

src/Illuminate/Database/Query/Grammars/Grammar.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,35 @@ public function prepareBindingForJsonContains($binding)
622622
return json_encode($binding);
623623
}
624624

625+
/**
626+
* Compile a "where JSON contains key" clause.
627+
*
628+
* @param \Illuminate\Database\Query\Builder $query
629+
* @param array $where
630+
* @return string
631+
*/
632+
protected function whereJsonContainsKey(Builder $query, $where)
633+
{
634+
$not = $where['not'] ? 'not ' : '';
635+
636+
return $not.$this->compileJsonContainsKey(
637+
$where['column']
638+
);
639+
}
640+
641+
/**
642+
* Compile a "JSON contains key" statement into SQL.
643+
*
644+
* @param string $column
645+
* @return string
646+
*
647+
* @throws \RuntimeException
648+
*/
649+
protected function compileJsonContainsKey($column)
650+
{
651+
throw new RuntimeException('This database engine does not support JSON contains key operations.');
652+
}
653+
625654
/**
626655
* Compile a "where JSON length" clause.
627656
*

src/Illuminate/Database/Query/Grammars/MySqlGrammar.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ protected function compileJsonContains($column, $value)
100100
return 'json_contains('.$field.', '.$value.$path.')';
101101
}
102102

103+
/**
104+
* Compile a "JSON contains key" statement into SQL.
105+
*
106+
* @param string $column
107+
* @return string
108+
*/
109+
protected function compileJsonContainsKey($column)
110+
{
111+
[$field, $path] = $this->wrapJsonFieldAndPath($column);
112+
113+
return 'ifnull(json_contains_path('.$field.', \'one\''.$path.'), 0)';
114+
}
115+
103116
/**
104117
* Compile a "JSON length" statement into SQL.
105118
*

src/Illuminate/Database/Query/Grammars/PostgresGrammar.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,40 @@ protected function compileJsonContains($column, $value)
216216
return '('.$column.')::jsonb @> '.$value;
217217
}
218218

219+
/**
220+
* Compile a "JSON contains key" statement into SQL.
221+
*
222+
* @param string $column
223+
* @return string
224+
*/
225+
protected function compileJsonContainsKey($column)
226+
{
227+
$segments = explode('->', $column);
228+
229+
$lastSegment = array_pop($segments);
230+
231+
if (filter_var($lastSegment, FILTER_VALIDATE_INT) !== false) {
232+
$i = $lastSegment;
233+
} elseif (preg_match('/\[(-?[0-9]+)\]$/', $lastSegment, $matches)) {
234+
$segments[] = Str::beforeLast($lastSegment, $matches[0]);
235+
236+
$i = $matches[1];
237+
}
238+
239+
$column = str_replace('->>', '->', $this->wrap(implode('->', $segments)));
240+
241+
if (isset($i)) {
242+
return vsprintf('case when %s then %s else false end', [
243+
'jsonb_typeof(('.$column.")::jsonb) = 'array'",
244+
'jsonb_array_length(('.$column.')::jsonb) >= '.($i < 0 ? abs($i) : $i + 1),
245+
]);
246+
}
247+
248+
$key = "'".str_replace("'", "''", $lastSegment)."'";
249+
250+
return 'coalesce(('.$column.')::jsonb ?? '.$key.', false)';
251+
}
252+
219253
/**
220254
* Compile a "JSON length" statement into SQL.
221255
*

src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ protected function compileJsonLength($column, $operator, $value)
132132
return 'json_array_length('.$field.$path.') '.$operator.' '.$value;
133133
}
134134

135+
/**
136+
* Compile a "JSON contains key" statement into SQL.
137+
*
138+
* @param string $column
139+
* @return string
140+
*/
141+
protected function compileJsonContainsKey($column)
142+
{
143+
[$field, $path] = $this->wrapJsonFieldAndPath($column);
144+
145+
return 'json_type('.$field.$path.') is not null';
146+
}
147+
135148
/**
136149
* Compile an update statement into SQL.
137150
*

src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,31 @@ public function prepareBindingForJsonContains($binding)
165165
return is_bool($binding) ? json_encode($binding) : $binding;
166166
}
167167

168+
/**
169+
* Compile a "JSON contains key" statement into SQL.
170+
*
171+
* @param string $column
172+
* @return string
173+
*/
174+
protected function compileJsonContainsKey($column)
175+
{
176+
$segments = explode('->', $column);
177+
178+
$lastSegment = array_pop($segments);
179+
180+
if (preg_match('/\[([0-9]+)\]$/', $lastSegment, $matches)) {
181+
$segments[] = Str::beforeLast($lastSegment, $matches[0]);
182+
183+
$key = $matches[1];
184+
} else {
185+
$key = "'".str_replace("'", "''", $lastSegment)."'";
186+
}
187+
188+
[$field, $path] = $this->wrapJsonFieldAndPath(implode('->', $segments));
189+
190+
return $key.' in (select [key] from openjson('.$field.$path.'))';
191+
}
192+
168193
/**
169194
* Compile a "JSON length" statement into SQL.
170195
*

tests/Database/DatabaseQueryBuilderTest.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4432,6 +4432,150 @@ public function testWhereJsonDoesntContainSqlServer()
44324432
$this->assertEquals([1], $builder->getBindings());
44334433
}
44344434

4435+
public function testWhereJsonContainsKeyMySql()
4436+
{
4437+
$builder = $this->getMySqlBuilder();
4438+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
4439+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`users`.`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
4440+
4441+
$builder = $this->getMySqlBuilder();
4442+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
4443+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."language"."primary"\'), 0)', $builder->toSql());
4444+
4445+
$builder = $this->getMySqlBuilder();
4446+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
4447+
$this->assertSame('select * from `users` where `id` = ? or ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
4448+
4449+
$builder = $this->getMySqlBuilder();
4450+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
4451+
$this->assertSame('select * from `users` where ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql());
4452+
}
4453+
4454+
public function testWhereJsonContainsKeyPostgres()
4455+
{
4456+
$builder = $this->getPostgresBuilder();
4457+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
4458+
$this->assertSame('select * from "users" where coalesce(("users"."options")::jsonb ?? \'languages\', false)', $builder->toSql());
4459+
4460+
$builder = $this->getPostgresBuilder();
4461+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
4462+
$this->assertSame('select * from "users" where coalesce(("options"->\'language\')::jsonb ?? \'primary\', false)', $builder->toSql());
4463+
4464+
$builder = $this->getPostgresBuilder();
4465+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
4466+
$this->assertSame('select * from "users" where "id" = ? or coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
4467+
4468+
$builder = $this->getPostgresBuilder();
4469+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
4470+
$this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql());
4471+
4472+
$builder = $this->getPostgresBuilder();
4473+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[-1]');
4474+
$this->assertSame('select * from "users" where case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql());
4475+
}
4476+
4477+
public function testWhereJsonContainsKeySqlite()
4478+
{
4479+
$builder = $this->getSQLiteBuilder();
4480+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
4481+
$this->assertSame('select * from "users" where json_type("users"."options", \'$."languages"\') is not null', $builder->toSql());
4482+
4483+
$builder = $this->getSQLiteBuilder();
4484+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
4485+
$this->assertSame('select * from "users" where json_type("options", \'$."language"."primary"\') is not null', $builder->toSql());
4486+
4487+
$builder = $this->getSQLiteBuilder();
4488+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
4489+
$this->assertSame('select * from "users" where "id" = ? or json_type("options", \'$."languages"\') is not null', $builder->toSql());
4490+
4491+
$builder = $this->getSQLiteBuilder();
4492+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
4493+
$this->assertSame('select * from "users" where json_type("options", \'$."languages"[0][1]\') is not null', $builder->toSql());
4494+
}
4495+
4496+
public function testWhereJsonContainsKeySqlServer()
4497+
{
4498+
$builder = $this->getSqlServerBuilder();
4499+
$builder->select('*')->from('users')->whereJsonContainsKey('users.options->languages');
4500+
$this->assertSame('select * from [users] where \'languages\' in (select [key] from openjson([users].[options]))', $builder->toSql());
4501+
4502+
$builder = $this->getSqlServerBuilder();
4503+
$builder->select('*')->from('users')->whereJsonContainsKey('options->language->primary');
4504+
$this->assertSame('select * from [users] where \'primary\' in (select [key] from openjson([options], \'$."language"\'))', $builder->toSql());
4505+
4506+
$builder = $this->getSqlServerBuilder();
4507+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonContainsKey('options->languages');
4508+
$this->assertSame('select * from [users] where [id] = ? or \'languages\' in (select [key] from openjson([options]))', $builder->toSql());
4509+
4510+
$builder = $this->getSqlServerBuilder();
4511+
$builder->select('*')->from('users')->whereJsonContainsKey('options->languages[0][1]');
4512+
$this->assertSame('select * from [users] where 1 in (select [key] from openjson([options], \'$."languages"[0]\'))', $builder->toSql());
4513+
}
4514+
4515+
public function testWhereJsonDoesntContainKeyMySql()
4516+
{
4517+
$builder = $this->getMySqlBuilder();
4518+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
4519+
$this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
4520+
4521+
$builder = $this->getMySqlBuilder();
4522+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
4523+
$this->assertSame('select * from `users` where `id` = ? or not ifnull(json_contains_path(`options`, \'one\', \'$."languages"\'), 0)', $builder->toSql());
4524+
4525+
$builder = $this->getMySqlBuilder();
4526+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]');
4527+
$this->assertSame('select * from `users` where not ifnull(json_contains_path(`options`, \'one\', \'$."languages"[0][1]\'), 0)', $builder->toSql());
4528+
}
4529+
4530+
public function testWhereJsonDoesntContainKeyPostgres()
4531+
{
4532+
$builder = $this->getPostgresBuilder();
4533+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
4534+
$this->assertSame('select * from "users" where not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
4535+
4536+
$builder = $this->getPostgresBuilder();
4537+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
4538+
$this->assertSame('select * from "users" where "id" = ? or not coalesce(("options")::jsonb ?? \'languages\', false)', $builder->toSql());
4539+
4540+
$builder = $this->getPostgresBuilder();
4541+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[0][1]');
4542+
$this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\'->0)::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\'->0)::jsonb) >= 2 else false end', $builder->toSql());
4543+
4544+
$builder = $this->getPostgresBuilder();
4545+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages[-1]');
4546+
$this->assertSame('select * from "users" where not case when jsonb_typeof(("options"->\'languages\')::jsonb) = \'array\' then jsonb_array_length(("options"->\'languages\')::jsonb) >= 1 else false end', $builder->toSql());
4547+
}
4548+
4549+
public function testWhereJsonDoesntContainKeySqlite()
4550+
{
4551+
$builder = $this->getSQLiteBuilder();
4552+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
4553+
$this->assertSame('select * from "users" where not json_type("options", \'$."languages"\') is not null', $builder->toSql());
4554+
4555+
$builder = $this->getSQLiteBuilder();
4556+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
4557+
$this->assertSame('select * from "users" where "id" = ? or not json_type("options", \'$."languages"\') is not null', $builder->toSql());
4558+
4559+
$builder = $this->getSQLiteBuilder();
4560+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages[0][1]');
4561+
$this->assertSame('select * from "users" where "id" = ? or not json_type("options", \'$."languages"[0][1]\') is not null', $builder->toSql());
4562+
}
4563+
4564+
public function testWhereJsonDoesntContainKeySqlServer()
4565+
{
4566+
$builder = $this->getSqlServerBuilder();
4567+
$builder->select('*')->from('users')->whereJsonDoesntContainKey('options->languages');
4568+
$this->assertSame('select * from [users] where not \'languages\' in (select [key] from openjson([options]))', $builder->toSql());
4569+
4570+
$builder = $this->getSqlServerBuilder();
4571+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages');
4572+
$this->assertSame('select * from [users] where [id] = ? or not \'languages\' in (select [key] from openjson([options]))', $builder->toSql());
4573+
4574+
$builder = $this->getSqlServerBuilder();
4575+
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntContainKey('options->languages[0][1]');
4576+
$this->assertSame('select * from [users] where [id] = ? or not 1 in (select [key] from openjson([options], \'$."languages"[0]\'))', $builder->toSql());
4577+
}
4578+
44354579
public function testWhereJsonLengthMySql()
44364580
{
44374581
$builder = $this->getMySqlBuilder();

tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,34 @@ public function testJsonPathUpdate()
121121
]);
122122
$this->assertSame(1, $updatedCount);
123123
}
124+
125+
/**
126+
* @dataProvider jsonContainsKeyDataProvider
127+
*/
128+
public function testWhereJsonContainsKey($count, $column)
129+
{
130+
DB::table(self::TABLE)->insert([
131+
['json_col' => '{"foo":{"bar":["baz"]}}'],
132+
['json_col' => '{"foo":{"bar":false}}'],
133+
['json_col' => '{"foo":{}}'],
134+
['json_col' => '{"foo":[{"bar":"bar"},{"baz":"baz"}]}'],
135+
['json_col' => '{"bar":null}'],
136+
]);
137+
138+
$this->assertSame($count, DB::table(self::TABLE)->whereJsonContainsKey($column)->count());
139+
}
140+
141+
public function jsonContainsKeyDataProvider()
142+
{
143+
return [
144+
'string key' => [4, 'json_col->foo'],
145+
'nested key exists' => [2, 'json_col->foo->bar'],
146+
'string key missing' => [0, 'json_col->none'],
147+
'integer key with arrow ' => [0, 'json_col->foo->bar->0'],
148+
'integer key with braces' => [2, 'json_col->foo->bar[0]'],
149+
'integer key missing' => [0, 'json_col->foo->bar[1]'],
150+
'mixed keys' => [1, 'json_col->foo[1]->baz'],
151+
'null value' => [1, 'json_col->bar'],
152+
];
153+
}
124154
}

0 commit comments

Comments
 (0)