Skip to content

Commit 383d4c4

Browse files
committed
Improve performance on MySQL 8 with "MEMBER OF()"
1 parent a4b3c79 commit 383d4c4

File tree

11 files changed

+311
-39
lines changed

11 files changed

+311
-39
lines changed

README.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ versions of Laravel.
114114

115115
The package also introduces two new relationship types: `BelongsToJson` and `HasManyJson`
116116

117-
On Laravel 5.6.25+, you can use them to implement many-to-many relationships with JSON arrays.
117+
Use them to implement many-to-many relationships with JSON arrays.
118118

119119
In this example, `User` has a `BelongsToMany` relationship with `Role`. There is no pivot table, but the foreign keys
120120
are stored as an array in a JSON field (`users.options`):
@@ -214,14 +214,53 @@ $user->roles()->toggle([2 => ['active' => true], 3])->save();
214214

215215
#### Query Performance
216216

217+
##### MySQL
218+
219+
On MySQL 8.0.17+, you can improve the query performance
220+
with [multi-valued indexes](https://dev.mysql.com/doc/refman/8.0/en/create-index.html#create-index-multi-valued).
221+
222+
Use this migration when the array is the column itself (e.g. `users.role_ids`):
223+
224+
```php
225+
Schema::create('users', function (Blueprint $table) {
226+
// ...
227+
228+
// Array of IDs
229+
$table->rawIndex('(cast(`role_ids` as unsigned array))', 'users_role_ids_index');
230+
231+
// Array of objects
232+
$table->rawIndex('(cast(`roles`->\'$[*]."role_id"\' as unsigned array))', 'users_roles_index');
233+
});
234+
```
235+
236+
Use this migration when the array is nested inside an object (e.g. `users.options->role_ids`):
237+
238+
```php
239+
Schema::create('users', function (Blueprint $table) {
240+
// ...
241+
242+
// Array of IDs
243+
$table->rawIndex('(cast(`options`->\'$."role_ids"\' as unsigned array))', 'users_role_ids_index');
244+
245+
// Array of objects
246+
$table->rawIndex('(cast(`options`->\'$."roles"[*]."role_id"\' as unsigned array))', 'users_roles_index');
247+
});
248+
```
249+
250+
MySQL is quite picky about the syntax so I recommend that you check once
251+
with [`EXPLAIN`](https://dev.mysql.com/doc/refman/8.0/en/using-explain.html) that the executed relationship queries
252+
actually use the index.
253+
254+
##### PostgreSQL
255+
217256
On PostgreSQL, you can improve the query performance with `jsonb` columns
218257
and [`GIN` indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING).
219258

220259
Use this migration when the array of IDs/objects is the column itself (e.g. `users.role_ids`):
221260

222261
```php
223262
Schema::create('users', function (Blueprint $table) {
224-
$table->bigIncrements('id');
263+
$table->id();
225264
$table->jsonb('role_ids');
226265
$table->index('role_ids')->algorithm('gin');
227266
});
@@ -231,10 +270,9 @@ Use this migration when the array is nested inside an object (e.g. `users.option
231270

232271
```php
233272
Schema::create('users', function (Blueprint $table) {
234-
$table->bigIncrements('id');
273+
$table->id();
235274
$table->jsonb('options');
236-
$table->rawIndex('("options"->\'role_ids\')', 'users_options_index')->algorithm('gin'); // Laravel 7.10.3+
237-
//$table->index([DB::raw('("options"->\'role_ids\')')], 'users_options_index', 'gin'); // Laravel < 7.10.3
275+
$table->rawIndex('("options"->\'role_ids\')', 'users_options_index')->algorithm('gin');
238276
});
239277
```
240278

src/Grammars/JsonGrammar.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Staudenmeir\EloquentJsonRelations\Grammars;
44

5+
use Illuminate\Database\ConnectionInterface;
6+
57
interface JsonGrammar
68
{
79
/**
@@ -28,4 +30,30 @@ public function compileJsonObject($column, $levels);
2830
* @return string
2931
*/
3032
public function compileJsonValueSelect(string $column): string;
33+
34+
/**
35+
* Determine whether the database supports the "member of" operator.
36+
*
37+
* @param \Illuminate\Database\ConnectionInterface $connection
38+
* @return bool
39+
*/
40+
public function supportsMemberOf(ConnectionInterface $connection): bool;
41+
42+
/**
43+
* Compile a "member of" statement into SQL.
44+
*
45+
* @param string $column
46+
* @param string|null $objectKey
47+
* @param mixed $value
48+
* @return string
49+
*/
50+
public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string;
51+
52+
/**
53+
* Prepare the bindings for a "member of" statement.
54+
*
55+
* @param mixed $value
56+
* @return array
57+
*/
58+
public function prepareBindingsForMemberOf(mixed $value): array;
3159
}

src/Grammars/MySqlGrammar.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
namespace Staudenmeir\EloquentJsonRelations\Grammars;
44

5+
use Illuminate\Contracts\Database\Query\Expression;
6+
use Illuminate\Database\ConnectionInterface;
57
use Illuminate\Database\Query\Grammars\MySqlGrammar as Base;
8+
use PDO;
69

710
class MySqlGrammar extends Base implements JsonGrammar
811
{
@@ -41,4 +44,58 @@ public function compileJsonValueSelect(string $column): string
4144
{
4245
return $this->wrap($column);
4346
}
47+
48+
/**
49+
* Determine whether the database supports the "member of" operator.
50+
*
51+
* @param \Illuminate\Database\ConnectionInterface $connection
52+
* @return bool
53+
*/
54+
public function supportsMemberOf(ConnectionInterface $connection): bool
55+
{
56+
if ($connection->isMaria()) {
57+
return false;
58+
}
59+
60+
$version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
61+
62+
return version_compare($version, '8.0.17') >= 0;
63+
}
64+
65+
/**
66+
* Compile a "member of" statement into SQL.
67+
*
68+
* @param string $column
69+
* @param mixed $value
70+
* @return string
71+
*/
72+
public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string
73+
{
74+
$columnWithKey = $objectKey ? $column . (str_contains($column, '->') ? '[*]' : '') . "->$objectKey" : $column;
75+
76+
[$field, $path] = $this->wrapJsonFieldAndPath($columnWithKey);
77+
78+
if ($objectKey && !str_contains($column, '->')) {
79+
$path = ", '$[*]" . substr($path, 4);
80+
}
81+
82+
$sql = $path ? "json_extract($field$path)" : $field;
83+
84+
if ($value instanceof Expression) {
85+
return $value->getValue($this) . " member of($sql)";
86+
}
87+
88+
return "? member of($sql)";
89+
}
90+
91+
/**
92+
* Prepare the bindings for a "member of" statement.
93+
*
94+
* @param mixed $value
95+
* @return array
96+
*/
97+
public function prepareBindingsForMemberOf(mixed $value): array
98+
{
99+
return $value instanceof Expression ? [] : [$value];
100+
}
44101
}

src/Grammars/PostgresGrammar.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace Staudenmeir\EloquentJsonRelations\Grammars;
44

5+
use Illuminate\Database\ConnectionInterface;
56
use Illuminate\Database\Query\Expression;
67
use Illuminate\Database\Query\Grammars\PostgresGrammar as Base;
8+
use RuntimeException;
79

810
class PostgresGrammar extends Base implements JsonGrammar
911
{
@@ -44,4 +46,39 @@ public function compileJsonValueSelect(string $column): string
4446
{
4547
return $this->wrap($column);
4648
}
49+
50+
/**
51+
* Determine whether the database supports the "member of" operator.
52+
*
53+
* @param \Illuminate\Database\ConnectionInterface $connection
54+
* @return bool
55+
*/
56+
public function supportsMemberOf(ConnectionInterface $connection): bool
57+
{
58+
return false;
59+
}
60+
61+
/**
62+
* Compile a "member of" statement into SQL.
63+
*
64+
* @param string $column
65+
* @param string|null $objectKey
66+
* @param mixed $value
67+
* @return string
68+
*/
69+
public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string
70+
{
71+
throw new RuntimeException('This database is not supported.');
72+
}
73+
74+
/**
75+
* Prepare the bindings for a "member of" statement.
76+
*
77+
* @param mixed $value
78+
* @return array
79+
*/
80+
public function prepareBindingsForMemberOf(mixed $value): array
81+
{
82+
throw new RuntimeException('This database is not supported.');
83+
}
4784
}

src/Grammars/SqlServerGrammar.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Staudenmeir\EloquentJsonRelations\Grammars;
44

5+
use Illuminate\Database\ConnectionInterface;
56
use Illuminate\Database\Query\Grammars\SqlServerGrammar as Base;
67
use RuntimeException;
78

@@ -42,4 +43,39 @@ public function compileJsonValueSelect(string $column): string
4243

4344
return "json_query($field$path)";
4445
}
46+
47+
/**
48+
* Determine whether the database supports the "member of" operator.
49+
*
50+
* @param \Illuminate\Database\ConnectionInterface $connection
51+
* @return bool
52+
*/
53+
public function supportsMemberOf(ConnectionInterface $connection): bool
54+
{
55+
return false;
56+
}
57+
58+
/**
59+
* Compile a "member of" statement into SQL.
60+
*
61+
* @param string $column
62+
* @param string|null $objectKey
63+
* @param mixed $value
64+
* @return string
65+
*/
66+
public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string
67+
{
68+
throw new RuntimeException('This database is not supported.');
69+
}
70+
71+
/**
72+
* Prepare the bindings for a "member of" statement.
73+
*
74+
* @param mixed $value
75+
* @return array
76+
*/
77+
public function prepareBindingsForMemberOf(mixed $value): array
78+
{
79+
throw new RuntimeException('This database is not supported.');
80+
}
4581
}

src/Relations/BelongsToJson.php

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,13 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery,
154154

155155
$query->addBinding($bindings);
156156

157-
return $query->select($columns)->whereJsonContains(
157+
$this->whereJsonContainsOrMemberOf(
158+
$query,
158159
$this->getQualifiedPath(),
159160
$query->getQuery()->connection->raw($sql)
160161
);
162+
163+
return $query->select($columns);
161164
}
162165

163166
/**
@@ -178,10 +181,13 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder
178181

179182
$query->addBinding($bindings);
180183

181-
return $query->select($columns)->whereJsonContains(
184+
$this->whereJsonContainsOrMemberOf(
185+
$query,
182186
$this->getQualifiedPath(),
183187
$query->getQuery()->connection->raw($sql)
184188
);
189+
190+
return $query->select($columns);
185191
}
186192

187193
/**
@@ -195,16 +201,25 @@ protected function relationExistenceQueryOwnerKey(Builder $query, string $ownerK
195201
{
196202
$ownerKey = $query->qualifyColumn($ownerKey);
197203

198-
if ($this->key) {
199-
$keys = explode('->', $this->key);
204+
$grammar = $this->getJsonGrammar($query);
205+
$connection = $query->getConnection();
200206

201-
$sql = $this->getJsonGrammar($query)->compileJsonObject($ownerKey, count($keys));
207+
if ($grammar->supportsMemberOf($connection)) {
208+
$sql = $grammar->wrap($ownerKey);
202209

203-
$bindings = $keys;
210+
$bindings = [];
204211
} else {
205-
$sql = $this->getJsonGrammar($query)->compileJsonArray($ownerKey);
212+
if ($this->key) {
213+
$keys = explode('->', $this->key);
206214

207-
$bindings = [];
215+
$sql = $this->getJsonGrammar($query)->compileJsonObject($ownerKey, count($keys));
216+
217+
$bindings = $keys;
218+
} else {
219+
$sql = $this->getJsonGrammar($query)->compileJsonArray($ownerKey);
220+
221+
$bindings = [];
222+
}
208223
}
209224

210225
return [$sql, $bindings];

0 commit comments

Comments
 (0)