Skip to content

Commit 3f35e3f

Browse files
authored
Events (#1)
listen to model events and propagate changes to Elasticsearch
1 parent 16ad9c9 commit 3f35e3f

File tree

13 files changed

+278
-2
lines changed

13 files changed

+278
-2
lines changed

.vscode/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"search.exclude": {
3+
"**/build": true,
4+
"**/coverage": true
5+
}
6+
}

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ return [
4747
'port' => env('ELASTICSEARCH_PORT', '9200'),
4848
],
4949
'indices' => [],
50+
'events' => [
51+
'listen' => true,
52+
],
5053
];
5154

5255
```

config/elastica-bridge.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@
77
],
88
'indices' => [],
99
'connection' => env('ELASTICSEARCH_QUEUE_CONNECTION', config('queue.default')),
10+
'events' => [
11+
'listen' => true,
12+
],
1013
];

psalm-baseline.xml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<files psalm-version="4.7.2@83a0325c0a95c0ab531d6b90c877068b464377b5">
2+
<files psalm-version="4.7.3@38c452ae584467e939d55377aaf83b5a26f19dd1">
33
<file src="src/Commands/IndexCommand.php">
44
<PossiblyInvalidArgument occurrences="1">
55
<code>$this-&gt;argument('index')</code>
@@ -24,4 +24,10 @@
2424
<code>cancelled</code>
2525
</PossiblyNullReference>
2626
</file>
27+
<file src="src/LaravelElasticaBridgeServiceProvider.php">
28+
<InvalidArgument occurrences="1"/>
29+
<UnusedClosureParam occurrences="1">
30+
<code>$event</code>
31+
</UnusedClosureParam>
32+
</file>
2733
</files>

src/Client/ElasticaClient.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
class ElasticaClient
1111
{
1212
protected Client $client;
13+
protected static ?bool $listenToEvents = null;
1314

1415
public function __construct()
1516
{
@@ -28,4 +29,19 @@ public function getIndex(string $name): Index
2829
{
2930
return $this->client->getIndex($name);
3031
}
32+
33+
public function enableEventListener(): void
34+
{
35+
self::$listenToEvents = true;
36+
}
37+
public function disableEventListener(): void
38+
{
39+
self::$listenToEvents = false;
40+
}
41+
public function listensToEvents(): bool
42+
{
43+
return self::$listenToEvents !== null
44+
? self::$listenToEvents
45+
: config('elastica-bridge.events.listen', true);
46+
}
3147
}

src/Index/AbstractIndex.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
namespace Limenet\LaravelElasticaBridge\Index;
66

77
use Elastica\Document;
8+
use Elastica\Exception\NotFoundException;
89
use Elastica\Index;
910
use Elastica\Query;
1011
use Elastica\ResultSet;
1112
use Illuminate\Database\Eloquent\Model;
1213
use Limenet\LaravelElasticaBridge\Client\ElasticaClient;
1314
use Limenet\LaravelElasticaBridge\Exception\Index\BlueGreenIndicesIncorrectlySetupException;
15+
use Limenet\LaravelElasticaBridge\Model\ElasticsearchableInterface;
1416
use RuntimeException;
1517

1618
abstract class AbstractIndex implements IndexInterface
@@ -97,6 +99,15 @@ public function getModelInstance(Document $document): Model
9799
return $modelClass::findOrFail($modelId);
98100
}
99101

102+
public function getDocumentInstance(Model | ElasticsearchableInterface $model): ?Document
103+
{
104+
try {
105+
return $this->getElasticaIndex()->getDocument($model->getElasticsearchId());
106+
} catch (NotFoundException) {
107+
return null;
108+
}
109+
}
110+
100111
final public function hasBlueGreenIndices(): bool
101112
{
102113
return array_reduce(

src/Index/IndexInterface.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace Limenet\LaravelElasticaBridge\Index;
66

7+
use Elastica\Document;
78
use Elastica\Index;
89
use Illuminate\Database\Eloquent\Model;
910
use Limenet\LaravelElasticaBridge\Exception\Index\BlueGreenIndicesIncorrectlySetupException;
11+
use Limenet\LaravelElasticaBridge\Model\ElasticsearchableInterface;
1012

1113
interface IndexInterface
1214
{
@@ -131,4 +133,18 @@ public function getBlueGreenActiveElasticaIndex(): Index;
131133
* @internal
132134
*/
133135
public function getBlueGreenInactiveElasticaIndex(): Index;
136+
137+
/**
138+
* Given an Elastica document, return the corresponding Laravel model.
139+
*/
140+
public function getModelInstance(Document $document): Model;
141+
142+
/**
143+
* Given a Laravel model, return the corresponding Elastica document.
144+
*
145+
* @param Model|ElasticsearchableInterface $model
146+
*
147+
* @return Document
148+
*/
149+
public function getDocumentInstance(Model | ElasticsearchableInterface $model): ?Document;
134150
}

src/LaravelElasticaBridgeServiceProvider.php

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

33
namespace Limenet\LaravelElasticaBridge;
44

5+
use Illuminate\Database\Eloquent\Model;
6+
use function Illuminate\Events\queueable;
7+
use Illuminate\Support\Facades\Event;
58
use Limenet\LaravelElasticaBridge\Client\ElasticaClient;
69
use Limenet\LaravelElasticaBridge\Commands\IndexCommand;
710
use Limenet\LaravelElasticaBridge\Commands\StatusCommand;
811
use Limenet\LaravelElasticaBridge\Repository\IndexRepository;
12+
use Limenet\LaravelElasticaBridge\Services\ModelEvent;
913
use Spatie\LaravelPackageTools\Package;
1014
use Spatie\LaravelPackageTools\PackageServiceProvider;
1115

@@ -27,10 +31,28 @@ public function configurePackage(Package $package): void
2731
public function packageRegistered(): void
2832
{
2933
$this->app->singleton(ElasticaClient::class);
34+
$this->app->bind(ModelEvent::class);
3035
$this->app->tag(config('elastica-bridge.indices'), 'elasticaBridgeIndices');
3136

3237
$this->app->when(IndexRepository::class)
3338
->needs('$indices')
3439
->giveTagged('elasticaBridgeIndices');
3540
}
41+
42+
public function packageBooted(): void
43+
{
44+
foreach (ModelEvent::EVENTS as $name) {
45+
Event::listen(
46+
sprintf('eloquent.%s:*', $name),
47+
queueable(function (string $event, array $models) use ($name) {
48+
if (! resolve(ElasticaClient::class)->listensToEvents()) {
49+
return;
50+
}
51+
52+
$modelEvent = resolve(ModelEvent::class);
53+
collect($models)->each(fn (Model $model) => $modelEvent->handle($name, $model));
54+
})->onConnection(config('elastica-bridge.connection'))
55+
);
56+
}
57+
}
3658
}

src/Services/ModelEvent.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace Limenet\LaravelElasticaBridge\Services;
4+
5+
use Elastica\Exception\NotFoundException;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Limenet\LaravelElasticaBridge\Index\IndexInterface;
8+
use Limenet\LaravelElasticaBridge\Repository\IndexRepository;
9+
10+
class ModelEvent
11+
{
12+
public const EVENT_CREATED = 'created';
13+
public const EVENT_UPDATED = 'updated';
14+
public const EVENT_SAVED = 'saved';
15+
public const EVENT_RESTORED = 'restored';
16+
public const EVENT_DELETED = 'deleted';
17+
public const EVENTS = [self::EVENT_CREATED, self::EVENT_UPDATED, self::EVENT_SAVED, self::EVENT_RESTORED, self::EVENT_DELETED];
18+
19+
public function __construct(protected IndexRepository $indexRepository)
20+
{
21+
}
22+
23+
public function handle(string $event, Model $model): void
24+
{
25+
foreach ($this->matchingIndicesForElement($model) as $index) {
26+
if (! $index->getElasticaIndex()->exists()) {
27+
continue;
28+
}
29+
30+
$shouldBePresent = true;
31+
32+
if (! $model->shouldIndex($index) || $event === self::EVENT_DELETED) {
33+
$shouldBePresent = false;
34+
}
35+
36+
$shouldBePresent ? $this->ensureModelPresentInIndex($index, $model) : $this->ensureModelMissingFromIndex($index, $model);
37+
}
38+
}
39+
40+
protected function ensureModelPresentInIndex(IndexInterface $index, Model $model): void
41+
{
42+
$index->getElasticaIndex()->addDocument($model->toElasticaDocument($index));
43+
}
44+
45+
protected function ensureModelMissingFromIndex(IndexInterface $index, Model $model): void
46+
{
47+
try {
48+
$index->getElasticaIndex()->deleteById($model->getElasticsearchId());
49+
} catch (NotFoundException) {
50+
//
51+
}
52+
}
53+
54+
/**
55+
* @return IndexInterface[]
56+
*/
57+
public function matchingIndicesForElement(Model $model): array
58+
{
59+
return array_filter(
60+
$this->indexRepository->all(),
61+
fn (IndexInterface $index): bool => in_array($model::class, $index->getAllowedDocuments(), true)
62+
);
63+
}
64+
}

tests/App/Elasticsearch/AllIndex.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ public function getName(): string
1515

1616
public function getAllowedDocuments(): array
1717
{
18-
return [Customer::class, Product::class];
18+
return [Customer::class, Order::class, Product::class];
1919
}
2020
}

tests/Feature/EventTest.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace Limenet\LaravelElasticaBridge\Tests\Feature;
4+
5+
use Elastica\Document;
6+
use Limenet\LaravelElasticaBridge\Index\IndexInterface;
7+
use Limenet\LaravelElasticaBridge\LaravelElasticaBridgeFacade;
8+
use Limenet\LaravelElasticaBridge\Tests\App\Models\Customer;
9+
use Limenet\LaravelElasticaBridge\Tests\App\Models\Product;
10+
11+
class EventTest extends TestCase
12+
{
13+
/** @test */
14+
public function event_missing_index()
15+
{
16+
$this->assertFalse($this->productIndex->getElasticaIndex()->exists());
17+
LaravelElasticaBridgeFacade::enableEventListener();
18+
Product::factory()->create();
19+
$this->assertFalse($this->productIndex->getElasticaIndex()->exists());
20+
}
21+
22+
/** @test */
23+
public function event_create_disabled_listener()
24+
{
25+
$this->index($this->productIndex);
26+
LaravelElasticaBridgeFacade::disableEventListener();
27+
$product = Product::factory()->create();
28+
$this->assertNull($this->productIndex->getDocumentInstance($product));
29+
}
30+
31+
/** @test */
32+
public function event_create_enabled_listener()
33+
{
34+
$this->index($this->productIndex);
35+
LaravelElasticaBridgeFacade::enableEventListener();
36+
$product = Product::factory()->create();
37+
$document = $this->productIndex->getDocumentInstance($product);
38+
$this->assertInstanceOf(Document::class, $document);
39+
$this->assertSame($product->id, $document->get(IndexInterface::DOCUMENT_MODEL_ID));
40+
}
41+
42+
/** @test */
43+
public function event_update_disabled_listener()
44+
{
45+
$this->index($this->productIndex);
46+
LaravelElasticaBridgeFacade::disableEventListener();
47+
$product = Product::all()->random();
48+
$oldName = $product->name;
49+
$newName = time();
50+
$product->name = $newName;
51+
$this->assertSame($oldName, $this->productIndex->getDocumentInstance($product)->get('name'));
52+
$this->assertNotSame($oldName, $newName);
53+
$product->save();
54+
$this->assertSame($oldName, $this->productIndex->getDocumentInstance($product)->get('name'));
55+
$this->assertNotSame($newName, $this->productIndex->getDocumentInstance($product)->get('name'));
56+
}
57+
58+
/** @test */
59+
public function event_update_enabled_listener()
60+
{
61+
$this->index($this->productIndex);
62+
LaravelElasticaBridgeFacade::enableEventListener();
63+
$product = Product::all()->random();
64+
$oldName = $product->name;
65+
$newName = time();
66+
$product->name = $newName;
67+
$this->assertSame($oldName, $this->productIndex->getDocumentInstance($product)->get('name'));
68+
$this->assertNotSame($oldName, $newName);
69+
$product->save();
70+
$this->assertSame($newName, $this->productIndex->getDocumentInstance($product)->get('name'));
71+
}
72+
73+
/** @test */
74+
public function event_delete_disabled_listener()
75+
{
76+
$this->index($this->productIndex);
77+
LaravelElasticaBridgeFacade::disableEventListener();
78+
$product = Product::all()->random();
79+
$product->delete();
80+
$this->assertInstanceOf(Document::class, $this->productIndex->getDocumentInstance($product));
81+
}
82+
83+
/** @test */
84+
public function event_delete_enabled_listener()
85+
{
86+
$this->index($this->productIndex);
87+
LaravelElasticaBridgeFacade::enableEventListener();
88+
$product = Product::all()->random();
89+
$product->delete();
90+
$this->assertNull($this->productIndex->getDocumentInstance($product));
91+
}
92+
93+
/** @test */
94+
public function event_delete_model_not_in_index()
95+
{
96+
$this->index($this->customerIndex);
97+
LaravelElasticaBridgeFacade::enableEventListener();
98+
$customer = Customer::findOrFail(1);
99+
$this->assertNull($this->customerIndex->getDocumentInstance($customer));
100+
$customer->delete();
101+
$this->assertNull($this->customerIndex->getDocumentInstance($customer));
102+
}
103+
}

tests/Unit/EventTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Limenet\LaravelElasticaBridge\Tests\Unit;
4+
5+
use Limenet\LaravelElasticaBridge\LaravelElasticaBridgeFacade;
6+
7+
class EventTest extends TestCase
8+
{
9+
/** @test */
10+
public function event_enable()
11+
{
12+
LaravelElasticaBridgeFacade::enableEventListener();
13+
$this->assertTrue(LaravelElasticaBridgeFacade::listensToEvents());
14+
}
15+
/** @test */
16+
public function event_disable()
17+
{
18+
LaravelElasticaBridgeFacade::disableEventListener();
19+
$this->assertFalse(LaravelElasticaBridgeFacade::listensToEvents());
20+
}
21+
}

tests/database/seeders/DatabaseSeeder.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Limenet\LaravelElasticaBridge\Tests\Database\Seeders;
44

55
use Illuminate\Database\Seeder;
6+
use Limenet\LaravelElasticaBridge\LaravelElasticaBridgeFacade;
67
use Limenet\LaravelElasticaBridge\Tests\App\Models\Customer;
78
use Limenet\LaravelElasticaBridge\Tests\App\Models\Product;
89

@@ -13,7 +14,11 @@ class DatabaseSeeder extends Seeder
1314
*/
1415
public function run(): void
1516
{
17+
LaravelElasticaBridgeFacade::disableEventListener();
18+
1619
Customer::factory()->count(50)->create();
1720
Product::factory()->count(50)->create();
21+
22+
LaravelElasticaBridgeFacade::enableEventListener();
1823
}
1924
}

0 commit comments

Comments
 (0)