@@ -61,23 +61,23 @@ make composer-lowest
6161### ElasticSearch Index Management
6262``` bash
6363# Create an index (requires entity mapping class)
64- php bin/console elastic:create-index < index-name>
64+ php bin/console spameri: elastic:create-index < index-name>
6565
6666# Delete an index
67- php bin/console elastic:delete-index < index-name>
67+ php bin/console spameri: elastic:delete-index < index-name>
6868
6969# Initialize all indexes from configuration
70- php bin/console elastic:initialize-indexes
70+ php bin/console spameri: elastic:initialize-index
7171
7272# Dump index data to file
73- php bin/console elastic:dump-index < index-name>
73+ php bin/console spameri: elastic:dump-index < index-name>
7474
75- # Load dumped data back
76- php bin/console elastic:load-dump < file-path>
75+ # Load dumped data back (with optional step size for bulk operations)
76+ php bin/console spameri: elastic:load-dump < file-path> [--step = 500]
7777
7878# Add/remove aliases
79- php bin/console elastic:add-alias < index-name> < alias>
80- php bin/console elastic:remove-alias < index-name> < alias>
79+ php bin/console spameri: elastic:add-alias < index-name> < alias>
80+ php bin/console spameri: elastic:remove-alias < index-name> < alias>
8181```
8282
8383## Architecture
@@ -114,8 +114,12 @@ php bin/console elastic:remove-alias <index-name> <alias>
114114** Event System** (` src/EventManager.php ` , ` src/EventManager/ ` )
115115- Events: ` PRE_PERSIST ` , ` POST_PERSIST ` , ` POST_CREATE ` , ` POST_UPDATE ` , ` PRE_DELETE ` , ` POST_DELETE `
116116- Listeners implement ` ListenerInterface ` with ` getEvent() ` , ` getEntityClass() ` , ` handle() ` methods
117- - Auto-discovered from DI container
118- - Both global event manager and per-entity ` dispatchEvents() ` support
117+ - Auto-discovered from DI container via ` initListeners() `
118+ - Events dispatch to both exact entity class matches AND parent classes
119+ - Recursive event propagation through entity trees via ` DispatchEvents `
120+ - ` ChangeSet ` integration determines POST_CREATE vs POST_UPDATE
121+ - Nested entity event propagation (fires for EntityInterface properties)
122+ - Collection item event propagation (fires for items in EntityCollectionInterface)
119123
120124** Import System** (` src/Import/ ` )
121125- ` Run ` and ` SimpleRun ` - orchestrate data imports with progress tracking
@@ -185,8 +189,9 @@ spameriElasticSearch:
185189 port: 9200
186190 debug: true # Enables Tracy debug bar panel
187191 version: 8 # ElasticSearch version
188- entities:
189- - App\Model\Entity\MyEntity
192+
193+ services:
194+ - App\Model\Settings\MyEntityMapping # IndexConfigInterface implementation
190195```
191196
192197## Code Style
@@ -215,6 +220,287 @@ spameriElasticSearch:
215220
216221** Value objects** - prefer value objects implementing ` ValueInterface ` for entity properties to encapsulate validation logic.
217222
223+ ## Event System Deep Dive
224+
225+ ### Event Lifecycle
226+
227+ ** Persist Flow (EntityManager::persist):**
228+ 1 . ` PRE_PERSIST ` - Before any changes
229+ - Dispatched on main entity
230+ - Dispatched recursively on all nested entities/collections via ` DispatchEvents `
231+ 2 . Entity saved to ElasticSearch via ` Insert `
232+ 3 . ` POST_PERSIST ` - After save
233+ - Dispatched on main entity
234+ - Dispatched recursively on all nested entities/collections
235+ 4 . ` POST_CREATE ` or ` POST_UPDATE ` - Based on ChangeSet
236+ - If ` ChangeSet::isExisting($entity) ` is false → ` POST_CREATE `
237+ - If ` ChangeSet::isExisting($entity) ` is true → ` POST_UPDATE `
238+ - Dispatched on main entity first
239+ - Dispatched recursively (POST_UPDATE only) on nested entities
240+ - POST_CREATE uses ChangeSet filtering during recursion
241+ 5 . Final recursive dispatch of POST_CREATE and POST_PERSIST
242+
243+ ** Delete Flow (EntityManager::remove):**
244+ 1 . ` PRE_DELETE ` - Before deletion
245+ - Dispatched on main entity
246+ - Dispatched recursively on all nested entities/collections
247+ 2 . Entity deleted from ElasticSearch via ` Delete `
248+ 3 . ` POST_DELETE ` - After deletion
249+ - Dispatched on main entity
250+ - Dispatched recursively on all nested entities/collections
251+
252+ ### ChangeSet System
253+
254+ ** Purpose:** Tracks whether entities are new or existing to determine correct lifecycle events.
255+
256+ ** Key Methods:**
257+ - ` markExisting($entity) ` - Marks entity as loaded from database (called by EntityFactory)
258+ - ` isExisting($entity) ` - Returns true if entity was previously marked
259+
260+ ** How It Works:**
261+ - Uses ` spl_object_hash() ` for entity identity tracking
262+ - Stored as: ` $created[$className][$objectHash] = true `
263+ - Works with any object, not just ` ElasticEntityInterface `
264+
265+ ** Integration:**
266+ ``` php
267+ // EntityManager::persist()
268+ if ($this->changeSet->isExisting($entity) === false) {
269+ // Fire POST_CREATE
270+ } else {
271+ // Fire POST_UPDATE
272+ }
273+ ```
274+
275+ ### DispatchEvents - Recursive Event Propagation
276+
277+ ** Purpose:** Walks entity tree and fires events on nested entities and collections.
278+
279+ ** Entry Point:**
280+ ``` php
281+ $this->dispatchEvents->execute($entity, EventManager::PRE_PERSIST);
282+ ```
283+
284+ ** Propagation Flow:**
285+ 1 . Calls ` iterateVariables() ` with entity's properties
286+ 2 . For each property:
287+ - If ` EntityInterface ` → dispatch event + recurse into its properties
288+ - If ` EntityCollectionInterface ` → iterate items, dispatch + recurse for each
289+ - Otherwise → skip (scalars, value objects, etc.)
290+ 3 . Recursion continues through entire entity tree
291+
292+ ** Special POST_CREATE Handling:**
293+ - POST_CREATE events are conditionally dispatched based on ` ChangeSet `
294+ - Only fires if ` ChangeSet::isExisting($property) ` returns false
295+ - Prevents firing CREATE events for existing nested entities loaded from database
296+
297+ ** Example Tree:**
298+ ```
299+ Video (ElasticEntity)
300+ ├── Technical (EntityInterface) ← Events fired here
301+ │ ├── Resolution (scalar) ← No events
302+ │ └── Codec (scalar) ← No events
303+ ├── Seasons (EntityCollection) ← Events fired on each item
304+ │ ├── Season 1 (EntityInterface) ← Events fired here
305+ │ │ └── Episodes (EntityCollection) ← Events fired on each
306+ │ └── Season 2 (EntityInterface) ← Events fired here
307+ └── Cast (ElasticEntityCollection) ← Events fired on each item
308+ ├── Person 1 (ElasticEntity) ← Events fired here
309+ └── Person 2 (ElasticEntity) ← Events fired here
310+ ```
311+
312+ All entities in this tree receive appropriate lifecycle events.
313+
314+ ### Listener Auto-Discovery
315+
316+ ** How It Works:**
317+ 1 . On first event dispatch, ` EventManager::initListeners() ` is called
318+ 2 . Searches DI container for all services implementing ` ListenerInterface `
319+ 3 . For each listener:
320+ - Calls ` getEvent() ` - which event to listen for
321+ - Calls ` getEntityClass() ` - which entity classes (returns array)
322+ - Registers listener for each class/event combination
323+
324+ ** Inheritance Matching:**
325+ Events dispatch to listeners registered for:
326+ - The exact entity class
327+ - ANY parent class or interface
328+
329+ Example:
330+ ``` php
331+ class VideoListener implements ListenerInterface {
332+ public function getEntityClass(): array {
333+ return [AbstractElasticEntity::class]; // Matches ALL entities
334+ }
335+ }
336+
337+ class SpecificVideoListener implements ListenerInterface {
338+ public function getEntityClass(): array {
339+ return [Video::class]; // Only Video entities
340+ }
341+ }
342+ ```
343+
344+ ### Creating Event Listeners
345+
346+ ** 1. Implement ListenerInterface:**
347+ ``` php
348+ namespace App\EventListener;
349+
350+ class VideoPersistedListener implements \Spameri\Elastic\EventManager\ListenerInterface
351+ {
352+ public function __construct(
353+ private \Psr\Log\LoggerInterface $logger
354+ ) {}
355+
356+ public function handle(object|null $entity, object|null $parent): void
357+ {
358+ if ($entity instanceof \App\Entity\Video) {
359+ $this->logger->info('Video persisted', [
360+ 'id' => $entity->id()->value(),
361+ 'title' => $entity->title(),
362+ ]);
363+ }
364+ }
365+
366+ public function getEntityClass(): array
367+ {
368+ return [\App\Entity\Video::class];
369+ }
370+
371+ public function getEvent(): string
372+ {
373+ return \Spameri\Elastic\EventManager::POST_PERSIST;
374+ }
375+ }
376+ ```
377+
378+ ** 2. Register in DI:**
379+ ``` neon
380+ services:
381+ - App\EventListener\VideoPersistedListener
382+ ```
383+
384+ ** That's it!** No manual registration needed - auto-discovered on first event.
385+
386+ ### Parent Entity Access
387+
388+ Listeners receive both the entity and its parent:
389+
390+ ``` php
391+ public function handle(object|null $entity, object|null $parent): void
392+ {
393+ // $entity = the entity the event fired for
394+ // $parent = the entity containing this one (for nested entities)
395+
396+ if ($entity instanceof Season && $parent instanceof Video) {
397+ // Season was saved as part of Video
398+ }
399+ }
400+ ```
401+
402+ ### Common Patterns
403+
404+ ** 1. Invalidate Cache After Update:**
405+ ``` php
406+ class InvalidateCacheListener implements ListenerInterface
407+ {
408+ public function __construct(private CacheInterface $cache) {}
409+
410+ public function handle(object|null $entity, object|null $parent): void
411+ {
412+ if ($entity instanceof Video) {
413+ $this->cache->delete('video.' . $entity->id()->value());
414+ }
415+ }
416+
417+ public function getEntityClass(): array
418+ {
419+ return [Video::class];
420+ }
421+
422+ public function getEvent(): string
423+ {
424+ return EventManager::POST_UPDATE;
425+ }
426+ }
427+ ```
428+
429+ ** 2. Send Notification After Creation:**
430+ ``` php
431+ class NotifyOnCreateListener implements ListenerInterface
432+ {
433+ public function __construct(private NotificationService $notifications) {}
434+
435+ public function handle(object|null $entity, object|null $parent): void
436+ {
437+ if ($entity instanceof Video) {
438+ $this->notifications->send(
439+ 'New video created: ' . $entity->title()
440+ );
441+ }
442+ }
443+
444+ public function getEntityClass(): array
445+ {
446+ return [Video::class];
447+ }
448+
449+ public function getEvent(): string
450+ {
451+ return EventManager::POST_CREATE; // Only on creation, not updates
452+ }
453+ }
454+ ```
455+
456+ ** 3. Update Related Data Before Persist:**
457+ ``` php
458+ class UpdateTimestampListener implements ListenerInterface
459+ {
460+ public function handle(object|null $entity, object|null $parent): void
461+ {
462+ if ($entity instanceof Video) {
463+ $entity->setModifiedAt(new \DateTime());
464+ }
465+ }
466+
467+ public function getEntityClass(): array
468+ {
469+ return [Video::class];
470+ }
471+
472+ public function getEvent(): string
473+ {
474+ return EventManager::PRE_PERSIST; // Before save
475+ }
476+ }
477+ ```
478+
479+ ** 4. Listen to All Entities (Global Listener):**
480+ ``` php
481+ class GlobalAuditListener implements ListenerInterface
482+ {
483+ public function handle(object|null $entity, object|null $parent): void
484+ {
485+ // Log ALL entity persists
486+ if ($entity !== null) {
487+ $this->auditLog->record($entity::class, 'persisted');
488+ }
489+ }
490+
491+ public function getEntityClass(): array
492+ {
493+ // Empty string or base class matches all
494+ return [AbstractElasticEntity::class];
495+ }
496+
497+ public function getEvent(): string
498+ {
499+ return EventManager::POST_PERSIST;
500+ }
501+ }
502+ ```
503+
218504## Testing Notes
219505
220506- Tests require running ElasticSearch instance (configured via ` SpameriTests\Elastic\Config ` )
0 commit comments