Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 19ed55e

Browse files
authoredJan 14, 2025··
PHPORM-28 Add Scout engine to index into MongoDB Search (#3205)
1 parent 697c36f commit 19ed55e

14 files changed

+1555
-8
lines changed
 

‎.github/workflows/build-ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ jobs:
6565
until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do
6666
sleep 1
6767
done
68+
until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.createCollection('connection_test') && db.getCollection('connection_test').createSearchIndex({mappings:{dynamic: true}})"; do
69+
sleep 1
70+
done
6871
6972
- name: "Show MongoDB server status"
7073
run: |

‎composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"require-dev": {
3737
"mongodb/builder": "^0.2",
38+
"laravel/scout": "^11",
3839
"league/flysystem-gridfs": "^3.28",
3940
"league/flysystem-read-only": "^3.0",
4041
"phpunit/phpunit": "^10.3",

‎docker-compose.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
version: '3.5'
2-
31
services:
42
app:
53
tty: true
@@ -16,11 +14,11 @@ services:
1614

1715
mongodb:
1816
container_name: mongodb
19-
image: mongo:latest
17+
image: mongodb/mongodb-atlas-local:latest
2018
ports:
2119
- "27017:27017"
2220
healthcheck:
23-
test: echo 'db.runCommand("ping").ok' | mongosh mongodb:27017 --quiet
21+
test: mongosh --quiet --eval 'db.runCommand("ping").ok'
2422
interval: 10s
2523
timeout: 10s
2624
retries: 5

‎phpstan-baseline.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ parameters:
2424
message: "#^Method Illuminate\\\\Database\\\\Schema\\\\Blueprint\\:\\:create\\(\\) invoked with 1 parameter, 0 required\\.$#"
2525
count: 1
2626
path: src/Schema/Builder.php
27+
28+
-
29+
message: "#^Call to an undefined method Illuminate\\\\Support\\\\HigherOrderCollectionProxy\\<\\(int\\|string\\), Illuminate\\\\Database\\\\Eloquent\\\\Model\\>\\:\\:pushSoftDeleteMetadata\\(\\)\\.$#"
30+
count: 1
31+
path: src/Scout/ScoutEngine.php

‎phpunit.xml.dist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
</testsuite>
1818
</testsuites>
1919
<php>
20-
<env name="MONGODB_URI" value="mongodb://mongodb/"/>
20+
<env name="MONGODB_URI" value="mongodb://mongodb/?directConnection=true"/>
2121
<env name="MONGODB_DATABASE" value="unittest"/>
2222
<env name="SQLITE_DATABASE" value=":memory:"/>
2323
<env name="QUEUE_CONNECTION" value="database"/>

‎src/MongoDBServiceProvider.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77
use Closure;
88
use Illuminate\Cache\CacheManager;
99
use Illuminate\Cache\Repository;
10+
use Illuminate\Container\Container;
1011
use Illuminate\Filesystem\FilesystemAdapter;
1112
use Illuminate\Filesystem\FilesystemManager;
1213
use Illuminate\Foundation\Application;
1314
use Illuminate\Session\SessionManager;
1415
use Illuminate\Support\ServiceProvider;
1516
use InvalidArgumentException;
17+
use Laravel\Scout\EngineManager;
1618
use League\Flysystem\Filesystem;
1719
use League\Flysystem\GridFS\GridFSAdapter;
1820
use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter;
1921
use MongoDB\GridFS\Bucket;
2022
use MongoDB\Laravel\Cache\MongoStore;
2123
use MongoDB\Laravel\Eloquent\Model;
2224
use MongoDB\Laravel\Queue\MongoConnector;
25+
use MongoDB\Laravel\Scout\ScoutEngine;
2326
use RuntimeException;
2427
use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler;
2528

@@ -102,6 +105,7 @@ public function register()
102105
});
103106

104107
$this->registerFlysystemAdapter();
108+
$this->registerScoutEngine();
105109
}
106110

107111
private function registerFlysystemAdapter(): void
@@ -155,4 +159,21 @@ private function registerFlysystemAdapter(): void
155159
});
156160
});
157161
}
162+
163+
private function registerScoutEngine(): void
164+
{
165+
$this->app->resolving(EngineManager::class, function (EngineManager $engineManager) {
166+
$engineManager->extend('mongodb', function (Container $app) {
167+
$connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb');
168+
$connection = $app->get('db')->connection($connectionName);
169+
$softDelete = (bool) $app->get('config')->get('scout.soft_delete', false);
170+
171+
assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName)));
172+
173+
return new ScoutEngine($connection->getMongoDB(), $softDelete);
174+
});
175+
176+
return $engineManager;
177+
});
178+
}
158179
}

‎src/Scout/ScoutEngine.php

Lines changed: 551 additions & 0 deletions
Large diffs are not rendered by default.

‎tests/ModelTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,9 @@ public function testSoftDelete(): void
406406
$this->assertEquals(2, Soft::count());
407407
}
408408

409+
/** @param class-string<Model> $model */
409410
#[DataProvider('provideId')]
410-
public function testPrimaryKey(string $model, $id, $expected, bool $expectedFound): void
411+
public function testPrimaryKey(string $model, mixed $id, mixed $expected, bool $expectedFound): void
411412
{
412413
$model::truncate();
413414
$expectedType = get_debug_type($expected);

‎tests/Models/SchemaVersion.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
namespace MongoDB\Laravel\Tests\Models;
66

77
use MongoDB\Laravel\Eloquent\HasSchemaVersion;
8-
use MongoDB\Laravel\Eloquent\Model as Eloquent;
8+
use MongoDB\Laravel\Eloquent\Model;
99

10-
class SchemaVersion extends Eloquent
10+
class SchemaVersion extends Model
1111
{
1212
use HasSchemaVersion;
1313

‎tests/Scout/Models/ScoutUser.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\Scout\Models;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Schema\Blueprint;
9+
use Illuminate\Database\Schema\SQLiteBuilder;
10+
use Illuminate\Support\Facades\Schema;
11+
use Laravel\Scout\Searchable;
12+
use MongoDB\Laravel\Eloquent\SoftDeletes;
13+
14+
use function assert;
15+
16+
class ScoutUser extends Model
17+
{
18+
use Searchable;
19+
use SoftDeletes;
20+
21+
protected $connection = 'sqlite';
22+
protected $table = 'scout_users';
23+
protected static $unguarded = true;
24+
25+
/**
26+
* Create the SQL table for the model.
27+
*/
28+
public static function executeSchema(): void
29+
{
30+
$schema = Schema::connection('sqlite');
31+
assert($schema instanceof SQLiteBuilder);
32+
33+
$schema->dropIfExists('scout_users');
34+
$schema->create('scout_users', function (Blueprint $table) {
35+
$table->increments('id');
36+
$table->string('name');
37+
$table->string('email')->nullable();
38+
$table->date('email_verified_at')->nullable();
39+
$table->timestamps();
40+
$table->softDeletes();
41+
});
42+
}
43+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\Scout\Models;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use Laravel\Scout\Searchable;
9+
use MongoDB\Laravel\Eloquent\DocumentModel;
10+
11+
class SearchableInSameNamespace extends Model
12+
{
13+
use DocumentModel;
14+
use Searchable;
15+
16+
protected $keyType = 'string';
17+
protected $connection = 'mongodb';
18+
protected $fillable = ['name'];
19+
20+
/**
21+
* Using the same collection as the model collection as Scout index
22+
* is prohibited to prevent erasing the data.
23+
*
24+
* @see Searchable::searchableAs()
25+
*/
26+
public function indexableAs(): string
27+
{
28+
return $this->getTable();
29+
}
30+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Scout\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\SoftDeletes;
7+
use Laravel\Scout\Searchable;
8+
9+
class SearchableModel extends Model
10+
{
11+
use Searchable;
12+
use SoftDeletes;
13+
14+
protected $connection = 'sqlite';
15+
protected $fillable = ['id', 'name', 'date'];
16+
17+
/** @see Searchable::searchableAs() */
18+
public function searchableAs(): string
19+
{
20+
return 'collection_searchable';
21+
}
22+
23+
/** @see Searchable::indexableAs() */
24+
public function indexableAs(): string
25+
{
26+
return 'collection_indexable';
27+
}
28+
29+
/**
30+
* Overriding the `getScoutKey` method to ensure the custom key is used for indexing
31+
* and searching the model.
32+
*
33+
* @see Searchable::getScoutKey()
34+
*/
35+
public function getScoutKey(): string
36+
{
37+
return $this->getAttribute($this->getScoutKeyName()) ?: 'key_' . $this->getKey();
38+
}
39+
40+
/**
41+
* This method must be overridden when the `getScoutKey` method is also overridden,
42+
* to support model serialization for async indexing jobs.
43+
*
44+
* @see Searchable::getScoutKeyName()
45+
*/
46+
public function getScoutKeyName(): string
47+
{
48+
return 'scout_key';
49+
}
50+
}

‎tests/Scout/ScoutEngineTest.php

Lines changed: 582 additions & 0 deletions
Large diffs are not rendered by default.

‎tests/Scout/ScoutIntegrationTest.php

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Scout;
4+
5+
use DateTimeImmutable;
6+
use Illuminate\Support\Facades\DB;
7+
use Illuminate\Support\LazyCollection;
8+
use Laravel\Scout\ScoutServiceProvider;
9+
use LogicException;
10+
use MongoDB\Laravel\Tests\Scout\Models\ScoutUser;
11+
use MongoDB\Laravel\Tests\Scout\Models\SearchableInSameNamespace;
12+
use MongoDB\Laravel\Tests\TestCase;
13+
use Override;
14+
use PHPUnit\Framework\Attributes\Depends;
15+
16+
use function array_merge;
17+
use function count;
18+
use function env;
19+
use function Orchestra\Testbench\artisan;
20+
use function range;
21+
use function sprintf;
22+
use function usleep;
23+
24+
class ScoutIntegrationTest extends TestCase
25+
{
26+
#[Override]
27+
protected function getPackageProviders($app): array
28+
{
29+
return array_merge(parent::getPackageProviders($app), [ScoutServiceProvider::class]);
30+
}
31+
32+
#[Override]
33+
protected function getEnvironmentSetUp($app): void
34+
{
35+
parent::getEnvironmentSetUp($app);
36+
37+
$app['config']->set('scout.driver', 'mongodb');
38+
$app['config']->set('scout.prefix', 'prefix_');
39+
}
40+
41+
public function setUp(): void
42+
{
43+
parent::setUp();
44+
45+
$this->skipIfSearchIndexManagementIsNotSupported();
46+
47+
// Init the SQL database with some objects that will be indexed
48+
// Test data copied from Laravel Scout tests
49+
// https://github.com/laravel/scout/blob/10.x/tests/Integration/SearchableTests.php
50+
ScoutUser::executeSchema();
51+
52+
$collect = LazyCollection::make(function () {
53+
yield ['name' => 'Laravel Framework'];
54+
55+
foreach (range(2, 10) as $key) {
56+
yield ['name' => 'Example ' . $key];
57+
}
58+
59+
yield ['name' => 'Larry Casper', 'email_verified_at' => null];
60+
yield ['name' => 'Reta Larkin'];
61+
62+
foreach (range(13, 19) as $key) {
63+
yield ['name' => 'Example ' . $key];
64+
}
65+
66+
yield ['name' => 'Prof. Larry Prosacco DVM', 'email_verified_at' => null];
67+
68+
foreach (range(21, 38) as $key) {
69+
yield ['name' => 'Example ' . $key, 'email_verified_at' => null];
70+
}
71+
72+
yield ['name' => 'Linkwood Larkin', 'email_verified_at' => null];
73+
yield ['name' => 'Otis Larson MD'];
74+
yield ['name' => 'Gudrun Larkin'];
75+
yield ['name' => 'Dax Larkin'];
76+
yield ['name' => 'Dana Larson Sr.'];
77+
yield ['name' => 'Amos Larson Sr.'];
78+
});
79+
80+
$id = 0;
81+
$date = new DateTimeImmutable('2021-01-01 00:00:00');
82+
foreach ($collect as $data) {
83+
$data = array_merge(['id' => ++$id, 'email_verified_at' => $date], $data);
84+
ScoutUser::create($data)->save();
85+
}
86+
87+
self::assertSame(44, ScoutUser::count());
88+
}
89+
90+
/** This test create the search index for tests performing search */
91+
public function testItCanCreateTheCollection()
92+
{
93+
$collection = DB::connection('mongodb')->getCollection('prefix_scout_users');
94+
$collection->drop();
95+
96+
// Recreate the indexes using the artisan commands
97+
// Ensure they return a success exit code (0)
98+
self::assertSame(0, artisan($this, 'scout:delete-index', ['name' => ScoutUser::class]));
99+
self::assertSame(0, artisan($this, 'scout:index', ['name' => ScoutUser::class]));
100+
self::assertSame(0, artisan($this, 'scout:import', ['model' => ScoutUser::class]));
101+
102+
self::assertSame(44, $collection->countDocuments());
103+
104+
$searchIndexes = $collection->listSearchIndexes(['name' => 'scout']);
105+
self::assertCount(1, $searchIndexes);
106+
107+
// Wait for all documents to be indexed asynchronously
108+
$i = 100;
109+
while (true) {
110+
$indexedDocuments = $collection->aggregate([
111+
['$search' => ['index' => 'scout', 'exists' => ['path' => 'name']]],
112+
])->toArray();
113+
114+
if (count($indexedDocuments) >= 44) {
115+
break;
116+
}
117+
118+
if ($i-- === 0) {
119+
self::fail('Documents not indexed');
120+
}
121+
122+
usleep(100_000);
123+
}
124+
125+
self::assertCount(44, $indexedDocuments);
126+
}
127+
128+
#[Depends('testItCanCreateTheCollection')]
129+
public function testItCanUseBasicSearch()
130+
{
131+
// All the search queries use "sort" option to ensure the results are deterministic
132+
$results = ScoutUser::search('lar')->take(10)->orderBy('id')->get();
133+
134+
self::assertSame([
135+
1 => 'Laravel Framework',
136+
11 => 'Larry Casper',
137+
12 => 'Reta Larkin',
138+
20 => 'Prof. Larry Prosacco DVM',
139+
39 => 'Linkwood Larkin',
140+
40 => 'Otis Larson MD',
141+
41 => 'Gudrun Larkin',
142+
42 => 'Dax Larkin',
143+
43 => 'Dana Larson Sr.',
144+
44 => 'Amos Larson Sr.',
145+
], $results->pluck('name', 'id')->all());
146+
}
147+
148+
#[Depends('testItCanCreateTheCollection')]
149+
public function testItCanUseBasicSearchCursor()
150+
{
151+
// All the search queries use "sort" option to ensure the results are deterministic
152+
$results = ScoutUser::search('lar')->take(10)->orderBy('id')->cursor();
153+
154+
self::assertSame([
155+
1 => 'Laravel Framework',
156+
11 => 'Larry Casper',
157+
12 => 'Reta Larkin',
158+
20 => 'Prof. Larry Prosacco DVM',
159+
39 => 'Linkwood Larkin',
160+
40 => 'Otis Larson MD',
161+
41 => 'Gudrun Larkin',
162+
42 => 'Dax Larkin',
163+
43 => 'Dana Larson Sr.',
164+
44 => 'Amos Larson Sr.',
165+
], $results->pluck('name', 'id')->all());
166+
}
167+
168+
#[Depends('testItCanCreateTheCollection')]
169+
public function testItCanUseBasicSearchWithQueryCallback()
170+
{
171+
$results = ScoutUser::search('lar')->take(10)->orderBy('id')->query(function ($query) {
172+
return $query->whereNotNull('email_verified_at');
173+
})->get();
174+
175+
self::assertSame([
176+
1 => 'Laravel Framework',
177+
12 => 'Reta Larkin',
178+
40 => 'Otis Larson MD',
179+
41 => 'Gudrun Larkin',
180+
42 => 'Dax Larkin',
181+
43 => 'Dana Larson Sr.',
182+
44 => 'Amos Larson Sr.',
183+
], $results->pluck('name', 'id')->all());
184+
}
185+
186+
#[Depends('testItCanCreateTheCollection')]
187+
public function testItCanUseBasicSearchToFetchKeys()
188+
{
189+
$results = ScoutUser::search('lar')->orderBy('id')->take(10)->keys();
190+
191+
self::assertSame([1, 11, 12, 20, 39, 40, 41, 42, 43, 44], $results->all());
192+
}
193+
194+
#[Depends('testItCanCreateTheCollection')]
195+
public function testItCanUseBasicSearchWithQueryCallbackToFetchKeys()
196+
{
197+
$results = ScoutUser::search('lar')->take(10)->orderBy('id', 'desc')->query(function ($query) {
198+
return $query->whereNotNull('email_verified_at');
199+
})->keys();
200+
201+
self::assertSame([44, 43, 42, 41, 40, 39, 20, 12, 11, 1], $results->all());
202+
}
203+
204+
#[Depends('testItCanCreateTheCollection')]
205+
public function testItCanUsePaginatedSearch()
206+
{
207+
$page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 1);
208+
$page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 2);
209+
210+
self::assertSame([
211+
1 => 'Laravel Framework',
212+
11 => 'Larry Casper',
213+
12 => 'Reta Larkin',
214+
20 => 'Prof. Larry Prosacco DVM',
215+
39 => 'Linkwood Larkin',
216+
], $page1->pluck('name', 'id')->all());
217+
218+
self::assertSame([
219+
40 => 'Otis Larson MD',
220+
41 => 'Gudrun Larkin',
221+
42 => 'Dax Larkin',
222+
43 => 'Dana Larson Sr.',
223+
44 => 'Amos Larson Sr.',
224+
], $page2->pluck('name', 'id')->all());
225+
}
226+
227+
#[Depends('testItCanCreateTheCollection')]
228+
public function testItCanUsePaginatedSearchWithQueryCallback()
229+
{
230+
$queryCallback = function ($query) {
231+
return $query->whereNotNull('email_verified_at');
232+
};
233+
234+
$page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 1);
235+
$page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 2);
236+
237+
self::assertSame([
238+
1 => 'Laravel Framework',
239+
12 => 'Reta Larkin',
240+
], $page1->pluck('name', 'id')->all());
241+
242+
self::assertSame([
243+
40 => 'Otis Larson MD',
244+
41 => 'Gudrun Larkin',
245+
42 => 'Dax Larkin',
246+
43 => 'Dana Larson Sr.',
247+
44 => 'Amos Larson Sr.',
248+
], $page2->pluck('name', 'id')->all());
249+
}
250+
251+
public function testItCannotIndexInTheSameNamespace()
252+
{
253+
self::expectException(LogicException::class);
254+
self::expectExceptionMessage(sprintf(
255+
'The MongoDB Scout collection "%s.searchable_in_same_namespaces" must use a different collection from the collection name of the model "%s". Set the "scout.prefix" configuration or use a distinct MongoDB database',
256+
env('MONGODB_DATABASE', 'unittest'),
257+
SearchableInSameNamespace::class,
258+
),);
259+
260+
SearchableInSameNamespace::create(['name' => 'test']);
261+
}
262+
}

0 commit comments

Comments
 (0)
Please sign in to comment.