Skip to content

Commit 0b53c08

Browse files
committed
bump version and setup dev docker
1 parent 20abf2a commit 0b53c08

5 files changed

Lines changed: 357 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 298 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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`)

Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
ARG PHP_VERSION=8.2
2+
FROM php:${PHP_VERSION}-cli
3+
4+
RUN apt-get update && apt-get install -y \
5+
curl \
6+
git \
7+
unzip \
8+
libcurl4-openssl-dev \
9+
&& docker-php-ext-install curl iconv \
10+
&& pecl install xdebug \
11+
&& docker-php-ext-enable xdebug \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
15+
16+
WORKDIR /app
17+
18+
# Xdebug config for coverage
19+
RUN echo "xdebug.mode=coverage,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
20+
&& echo "xdebug.start_with_request=trigger" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"ext-json": "*",
2929
"ext-curl": "*",
3030
"spameri/elastic-query": "dev-master",
31-
"elasticsearch/elasticsearch": "^8.17.0",
31+
"elasticsearch/elasticsearch": "^9.0.0",
3232
"guzzlehttp/guzzle": "^7.9",
3333
"nette/di": "^3.1.10",
3434
"nette/utils": "^4.0.0|^3.2.5",
@@ -40,7 +40,7 @@
4040
"require-dev": {
4141
"slevomat/coding-standard": "^8.15.0",
4242
"spameri/dependency-mocker": "^1.3",
43-
"nette/tester": "^2.4",
43+
"nette/tester": "^v2.5.7",
4444
"phpstan/phpstan": "^1.11.3",
4545
"php-coveralls/php-coveralls": "^2.1",
4646
"nette/bootstrap": "^3.1",

0 commit comments

Comments
 (0)