66
77use Doctrine \Persistence \ManagerRegistry ;
88use Psr \EventDispatcher \EventDispatcherInterface ;
9+ use Psr \Log \LoggerInterface ;
10+ use Psr \Log \NullLogger ;
911use Setono \Doctrine \ORMTrait ;
1012use Setono \SyliusMeilisearchPlugin \Engine \SearchEngineInterface ;
1113use Setono \SyliusMeilisearchPlugin \Engine \SearchRequest ;
1416use Setono \SyliusMeilisearchPlugin \Event \Search \SearchResponseParametersCreated ;
1517use Setono \SyliusMeilisearchPlugin \Event \Search \SearchResultReceived ;
1618use Setono \SyliusMeilisearchPlugin \Form \Builder \SearchFormBuilderInterface ;
19+ use Setono \SyliusMeilisearchPlugin \Message \Command \RemoveEntity ;
1720use Setono \SyliusMeilisearchPlugin \Model \IndexableInterface ;
21+ use Sylius \Component \Resource \Model \ToggleableInterface ;
1822use Symfony \Component \HttpFoundation \Request ;
1923use Symfony \Component \HttpFoundation \Response ;
24+ use Symfony \Component \Messenger \MessageBusInterface ;
2025use Twig \Environment ;
2126
2227final class SearchAction
@@ -29,6 +34,8 @@ public function __construct(
2934 private readonly SearchFormBuilderInterface $ searchFormBuilder ,
3035 private readonly SearchEngineInterface $ searchEngine ,
3136 private readonly EventDispatcherInterface $ eventDispatcher ,
37+ private readonly MessageBusInterface $ commandBus ,
38+ private readonly LoggerInterface $ logger = new NullLogger (),
3239 ) {
3340 $ this ->managerRegistry = $ managerRegistry ;
3441 }
@@ -48,18 +55,7 @@ public function __invoke(Request $request): Response
4855 // todo handle this scenario
4956 }
5057
51- $ items = [];
52- /** @var array{entityClass: class-string<IndexableInterface>, entityId: mixed} $hit */
53- foreach ($ searchResult ->hits as $ hit ) {
54- $ item = $ this ->getManager ($ hit ['entityClass ' ])->find ($ hit ['entityClass ' ], $ hit ['entityId ' ]);
55-
56- // todo if the item doesn't exist in the database, we can in fact end up with an empty $items list
57- if (null === $ item ) {
58- continue ;
59- }
60-
61- $ items [] = $ item ;
62- }
58+ $ items = $ this ->hydrateHits ($ searchResult ->hits );
6359
6460 $ searchResponseParametersCreatedEvent = new SearchResponseParametersCreated ('@SetonoSyliusMeilisearchPlugin/search/index.html.twig ' , [
6561 'searchResult ' => $ searchResult ,
@@ -80,4 +76,92 @@ public function __invoke(Request $request): Response
8076
8177 return $ response ;
8278 }
79+
80+ /**
81+ * Hydrates the Meilisearch hits into entities: one findBy() per entity class (instead of one
82+ * find() per hit), reordered to match Meilisearch's ranking. Hits whose entity is missing from
83+ * the database or disabled are dropped and a RemoveEntity is dispatched so the index self-heals.
84+ *
85+ * @param array<int, array> $hits
86+ *
87+ * @return list<IndexableInterface>
88+ */
89+ private function hydrateHits (array $ hits ): array
90+ {
91+ // Group hit ids by entity class, preserving Meilisearch's order
92+ $ idsByClass = [];
93+ foreach ($ hits as $ hit ) {
94+ $ class = $ hit ['entityClass ' ] ?? null ;
95+ if (!is_string ($ class ) || !is_a ($ class , IndexableInterface::class, true )) {
96+ continue ;
97+ }
98+
99+ $ idsByClass [$ class ][] = $ hit ['entityId ' ] ?? null ;
100+ }
101+
102+ // Load each class in a single query, keyed by (string) id
103+ $ loaded = [];
104+ foreach ($ idsByClass as $ class => $ ids ) {
105+ /** @var list<IndexableInterface> $entities */
106+ $ entities = $ this ->getRepository ($ class )->findBy (['id ' => $ ids ]);
107+ foreach ($ entities as $ entity ) {
108+ $ loaded [$ class ][(string ) $ entity ->getId ()] = $ entity ;
109+ }
110+ }
111+
112+ [$ items , $ stale ] = self ::reorderAndFilter ($ hits , $ loaded );
113+
114+ foreach ($ stale as $ hit ) {
115+ try {
116+ $ this ->commandBus ->dispatch (new RemoveEntity ($ hit ['entityClass ' ], $ hit ['entityId ' ]));
117+ } catch (\Throwable $ e ) {
118+ // Self-healing must never break rendering the search page
119+ $ this ->logger ->error ('Failed to dispatch RemoveEntity for a stale search hit: {message} ' , [
120+ 'message ' => $ e ->getMessage (),
121+ 'entity ' => $ hit ['entityClass ' ],
122+ 'exception ' => $ e ,
123+ ]);
124+ }
125+ }
126+
127+ return $ items ;
128+ }
129+
130+ /**
131+ * Reorders the loaded entities to match Meilisearch's hit order and separates out the stale
132+ * hits (missing from the database, or disabled) so the caller can self-heal the index.
133+ *
134+ * @param array<int, array<array-key, mixed>> $hits
135+ * @param array<string, array<array-key, IndexableInterface>> $loaded
136+ *
137+ * @return array{0: list<IndexableInterface>, 1: list<array{entityClass: class-string<IndexableInterface>, entityId: int|string}>}
138+ */
139+ public static function reorderAndFilter (array $ hits , array $ loaded ): array
140+ {
141+ $ items = [];
142+ $ stale = [];
143+
144+ foreach ($ hits as $ hit ) {
145+ $ class = $ hit ['entityClass ' ] ?? null ;
146+ $ id = $ hit ['entityId ' ] ?? null ;
147+
148+ if (!is_string ($ class ) || !is_a ($ class , IndexableInterface::class, true ) || (!is_string ($ id ) && !is_int ($ id ))) {
149+ continue ;
150+ }
151+
152+ $ entity = $ loaded [$ class ][(string ) $ id ] ?? null ;
153+
154+ // A hit whose entity was deleted, or that is now disabled, must not be rendered (the
155+ // index is stale); dispatch a removal so it heals on the next request instead.
156+ if (null === $ entity || ($ entity instanceof ToggleableInterface && !$ entity ->isEnabled ())) {
157+ $ stale [] = ['entityClass ' => $ class , 'entityId ' => $ id ];
158+
159+ continue ;
160+ }
161+
162+ $ items [] = $ entity ;
163+ }
164+
165+ return [$ items , $ stale ];
166+ }
83167}
0 commit comments