Skip to content

Commit 3dd16c8

Browse files
committed
Laravel Scout
1 parent 66d7fe8 commit 3dd16c8

File tree

5 files changed

+461
-0
lines changed

5 files changed

+461
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
use InvalidArgumentException;
7+
use Laravel\Scout\EngineManager;
8+
use MongoDB\Laravel\Scout\AtlasSearchEngine;
9+
10+
use function config;
11+
use function sprintf;
12+
13+
class AtlasSearchScoutServiceProvider extends ServiceProvider
14+
{
15+
public function register()
16+
{
17+
$this->app->extend(EngineManager::class, function (EngineManager $engineManager) {
18+
$engineManager->extend('atlas_search', function ($app) {
19+
$connectionName = config('scout.atlas_search.connection');
20+
$connection = $app->make('db')->connection($connectionName);
21+
22+
if (! $connection instanceof Connection) {
23+
throw new InvalidArgumentException(sprintf('The MongoDB connection for Atlas Search must be a MongoDB connection. Got "%s". Set configuration "scout.atlas_search.connection"', $connection->getDriverName()));
24+
}
25+
26+
return new AtlasSearchEngine($connection);
27+
});
28+
29+
return $engineManager;
30+
});
31+
}
32+
}

src/Scout/AtlasSearchEngine.php

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Scout;
4+
5+
use Illuminate\Database\Eloquent\Collection;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
use Illuminate\Support\LazyCollection;
9+
use Laravel\Scout\Builder;
10+
use Laravel\Scout\Engines\Engine;
11+
use MongoDB\Driver\Exception\ServerException;
12+
use MongoDB\Exception\Exception;
13+
use MongoDB\Laravel\Connection;
14+
15+
use function array_filter;
16+
use function array_flip;
17+
use function array_merge;
18+
use function call_user_func;
19+
use function class_uses_recursive;
20+
use function collect;
21+
use function count;
22+
use function in_array;
23+
use function substr;
24+
25+
final class AtlasSearchEngine extends Engine
26+
{
27+
public function __construct(
28+
private Connection $mongodb,
29+
private bool $softDelete = true,
30+
) {
31+
}
32+
33+
/**
34+
* Update the given model in the index.
35+
*
36+
* @param Collection $models
37+
*
38+
* @return void
39+
*
40+
* @throws Exception
41+
*/
42+
public function update($models)
43+
{
44+
if ($models->isEmpty()) {
45+
return;
46+
}
47+
48+
$collection = $this->mongodb->getCollection($models->first()->indexableAs());
49+
50+
if ($this->usesSoftDelete($models->first()) && $this->softDelete) {
51+
$models->each->pushSoftDeleteMetadata();
52+
}
53+
54+
$bulk = [];
55+
foreach ($models as $model) {
56+
$searchableData = $model->toSearchableArray();
57+
58+
if ($searchableData) {
59+
unset($searchableData['_id']);
60+
61+
$bulk[] = [
62+
'updateOne' => [
63+
['_id' => $model->getScoutKey()],
64+
[
65+
'$set' => array_merge(
66+
$searchableData,
67+
$model->scoutMetadata(),
68+
),
69+
'$setOnInsert' => ['_id' => $model->getScoutKey()],
70+
],
71+
['upsert' => true],
72+
],
73+
];
74+
}
75+
}
76+
77+
if (! empty($bulk)) {
78+
$collection->bulkWrite($bulk);
79+
}
80+
}
81+
82+
/**
83+
* Remove the given model from the index.
84+
*
85+
* @param Collection $models
86+
*
87+
* @return void
88+
*/
89+
public function delete($models)
90+
{
91+
if ($models->isEmpty()) {
92+
return;
93+
}
94+
95+
$collection = $this->mongodb->getCollection($models->first()->indexableAs());
96+
97+
$bulk = [];
98+
foreach ($models as $model) {
99+
$bulk[] = [
100+
'deleteOne' => [
101+
['_id' => $model->getScoutKey()],
102+
],
103+
];
104+
}
105+
106+
if (! empty($bulk)) {
107+
$collection->bulkWrite($bulk);
108+
}
109+
}
110+
111+
/**
112+
* Perform the given search on the engine.
113+
*
114+
* @param Builder $builder
115+
*
116+
* @return mixed
117+
*/
118+
public function search(Builder $builder)
119+
{
120+
return $this->performSearch($builder, array_filter([
121+
'numericFilters' => $this->filters($builder),
122+
'hitsPerPage' => $builder->limit,
123+
]));
124+
}
125+
126+
/**
127+
* Perform the given search on the engine.
128+
*
129+
* @param Builder $builder
130+
* @param int $perPage
131+
* @param int $page
132+
*
133+
* @return mixed
134+
*/
135+
public function paginate(Builder $builder, $perPage, $page)
136+
{
137+
return $this->performSearch($builder, [
138+
'numericFilters' => $this->filters($builder),
139+
'hitsPerPage' => $perPage,
140+
'page' => $page - 1,
141+
]);
142+
}
143+
144+
/**
145+
* Perform the given search on the engine.
146+
*
147+
* @param Builder $builder
148+
* @param array $options
149+
*
150+
* @return mixed
151+
*/
152+
protected function performSearch(Builder $builder, array $options = [])
153+
{
154+
$algolia = $this->algolia->initIndex(
155+
$builder->index ?: $builder->model->searchableAs(),
156+
);
157+
158+
$options = array_merge($builder->options, $options);
159+
160+
if ($builder->callback) {
161+
return call_user_func(
162+
$builder->callback,
163+
$algolia,
164+
$builder->query,
165+
$options,
166+
);
167+
}
168+
169+
return $algolia->search($builder->query, $options);
170+
}
171+
172+
/**
173+
* Get the filter array for the query.
174+
*
175+
* @param Builder $builder
176+
*
177+
* @return array
178+
*/
179+
protected function filters(Builder $builder)
180+
{
181+
$wheres = collect($builder->wheres)->map(function ($value, $key) {
182+
return $key . '=' . $value;
183+
})->values();
184+
185+
return $wheres->merge(collect($builder->whereIns)->map(function ($values, $key) {
186+
if (empty($values)) {
187+
return '0=1';
188+
}
189+
190+
return collect($values)->map(function ($value) use ($key) {
191+
return $key . '=' . $value;
192+
})->all();
193+
})->values())->values()->all();
194+
}
195+
196+
/**
197+
* Pluck and return the primary keys of the given results.
198+
*
199+
* @param mixed $results
200+
*
201+
* @return \Illuminate\Support\Collection
202+
*/
203+
public function mapIds($results)
204+
{
205+
return collect($results)->pluck('_id')->values();
206+
}
207+
208+
/**
209+
* Map the given results to instances of the given model.
210+
*
211+
* @param Builder $builder
212+
* @param mixed $results
213+
* @param Model $model
214+
*
215+
* @return Collection
216+
*/
217+
public function map(Builder $builder, $results, $model)
218+
{
219+
if (count($results) === 0) {
220+
return $model->newCollection();
221+
}
222+
223+
$objectIds = collect($results)->pluck('_id')->values()->all();
224+
225+
$objectIdPositions = array_flip($objectIds);
226+
227+
return $model->getScoutModelsByIds(
228+
$builder,
229+
$objectIds,
230+
)->filter(function ($model) use ($objectIds) {
231+
return in_array($model->getScoutKey(), $objectIds);
232+
})->map(function ($model) use ($results, $objectIdPositions) {
233+
$result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
234+
235+
foreach ($result as $key => $value) {
236+
if ($key !== '_id' && $key[0] ?? '' === '_') {
237+
$model->withScoutMetadata($key, $value);
238+
}
239+
}
240+
241+
return $model;
242+
})->sortBy(function ($model) use ($objectIdPositions) {
243+
return $objectIdPositions[$model->getScoutKey()];
244+
})->values();
245+
}
246+
247+
/**
248+
* Map the given results to instances of the given model via a lazy collection.
249+
*
250+
* @param Builder $builder
251+
* @param mixed $results
252+
* @param Model $model
253+
*
254+
* @return LazyCollection
255+
*/
256+
public function lazyMap(Builder $builder, $results, $model)
257+
{
258+
if (count($results['hits']) === 0) {
259+
return LazyCollection::make($model->newCollection());
260+
}
261+
262+
$objectIds = collect($results['hits'])->pluck('objectID')->values()->all();
263+
$objectIdPositions = array_flip($objectIds);
264+
265+
return $model->queryScoutModelsByIds(
266+
$builder,
267+
$objectIds,
268+
)->cursor()->filter(function ($model) use ($objectIds) {
269+
return in_array($model->getScoutKey(), $objectIds);
270+
})->map(function ($model) use ($results, $objectIdPositions) {
271+
$result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
272+
273+
foreach ($result as $key => $value) {
274+
if (substr($key, 0, 1) === '_') {
275+
$model->withScoutMetadata($key, $value);
276+
}
277+
}
278+
279+
return $model;
280+
})->sortBy(function ($model) use ($objectIdPositions) {
281+
return $objectIdPositions[$model->getScoutKey()];
282+
})->values();
283+
}
284+
285+
/**
286+
* Get the total count from a raw result returned by the engine.
287+
*
288+
* @param mixed $results
289+
*
290+
* @return int
291+
*/
292+
public function getTotalCount($results)
293+
{
294+
return $results['nbHits'];
295+
}
296+
297+
/**
298+
* Flush all of the model's records from the engine.
299+
*
300+
* @param Model $model
301+
*
302+
* @return void
303+
*/
304+
public function flush($model)
305+
{
306+
$this->mongodb->getCollection($model->indexableAs())->deleteMany([]);
307+
}
308+
309+
/**
310+
* Create a search index.
311+
*
312+
* @param string $name
313+
* @param array $options
314+
*
315+
* @return mixed
316+
*
317+
* @throws ServerException
318+
*/
319+
public function createIndex($name, array $options = [])
320+
{
321+
$this->mongodb->getMongoDB()->createCollection($name);
322+
$this->mongodb->getCollection($name)->createSearchIndex([
323+
'name' => 'laravel_scout',
324+
]);
325+
}
326+
327+
/**
328+
* Delete a search index.
329+
*
330+
* @param string $name
331+
*
332+
* @return mixed
333+
*/
334+
public function deleteIndex($name)
335+
{
336+
return $this->mongodb->getCollection($name)->drop();
337+
}
338+
339+
/**
340+
* Determine if the given model uses soft deletes.
341+
*
342+
* @param Model $model
343+
*
344+
* @return bool
345+
*/
346+
protected function usesSoftDelete($model)
347+
{
348+
return in_array(SoftDeletes::class, class_uses_recursive($model));
349+
}
350+
351+
private function getMongoDBCollections(Collection $models): \MongoDB\Laravel\Collection
352+
{
353+
return $this->mongodb->getCollection($models->first()->indexableAs());
354+
}
355+
}

0 commit comments

Comments
 (0)