From 99bbd48d97c6954e60580c0747dbae6df9520f18 Mon Sep 17 00:00:00 2001 From: Beno!t POLASZEK Date: Fri, 17 Nov 2023 15:05:57 +0100 Subject: [PATCH] feat: filter recipe --- doc/recipes.md | 25 +++++++++- src/Recipe/FilterRecipe.php | 51 ++++++++++++++++++++ src/Recipe/FilterRecipeMode.php | 14 ++++++ src/functions.php | 17 ++++++- tests/Unit/Recipe/FilterRecipeTest.php | 67 ++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/Recipe/FilterRecipe.php create mode 100644 src/Recipe/FilterRecipeMode.php create mode 100644 tests/Unit/Recipe/FilterRecipeTest.php diff --git a/doc/recipes.md b/doc/recipes.md index aea0ded..d88aac9 100644 --- a/doc/recipes.md +++ b/doc/recipes.md @@ -1,7 +1,11 @@ # Recipes Recipes are pre-configured setups for `EtlExecutor`, facilitating reusable ETL configurations. -For instance, `LoggerRecipe` enables logging for all ETL events. + +LoggerRecipe +------------ + +The `LoggerRecipe` enables logging for all ETL events. ```php use BenTools\ETL\EtlExecutor; @@ -15,6 +19,25 @@ $etl = (new EtlExecutor()) This will basically listen to all events and fire log entries. +FilterRecipe +------------ + +The `FilterRecipe` gives you syntactic sugar for skipping items. + +```php +use BenTools\ETL\EtlExecutor; +use BenTools\ETL\Recipe\LoggerRecipe; +use Monolog\Logger; + +use function BenTools\ETL\skipWhen; + +$logger = new Logger(); +$etl = (new EtlExecutor())->withRecipe(skipWhen(fn ($item) => 'apple' === $item)); +$report = $etl->process(['banana', 'apple', 'pinapple']); + +var_dump($report->output); // ['banana', 'pineapple'] +``` + Creating your own recipes ------------------------- diff --git a/src/Recipe/FilterRecipe.php b/src/Recipe/FilterRecipe.php new file mode 100644 index 0000000..43ac895 --- /dev/null +++ b/src/Recipe/FilterRecipe.php @@ -0,0 +1,51 @@ +eventClass, self::EVENTS_CLASSES)) { + throw new InvalidArgumentException(sprintf('Can only filter on ExtractEvent / LoadEvent, not %s', $this->eventClass)); + } + } + + public function decorate(EtlExecutor $executor): EtlExecutor + { + return match ($this->eventClass) { + ExtractEvent::class => $executor->onExtract($this(...), $this->priority), + BeforeLoadEvent::class => $executor->onBeforeLoad($this(...), $this->priority), + default => $executor, + }; + } + + public function __invoke(ExtractEvent|BeforeLoadEvent $event): void + { + $matchFilter = !($this->filter)($event->item, $event->state); + if (FilterRecipeMode::EXCLUDE === $this->mode) { + $matchFilter = !$matchFilter; + } + + if ($matchFilter) { + $event->state->skip(); + } + } +} diff --git a/src/Recipe/FilterRecipeMode.php b/src/Recipe/FilterRecipeMode.php new file mode 100644 index 0000000..20a88ef --- /dev/null +++ b/src/Recipe/FilterRecipeMode.php @@ -0,0 +1,14 @@ +withRecipe(...func_get_args()); } +function useReact(): EtlExecutor +{ + return withRecipe(new ReactStreamProcessor()); +} + function chain(ExtractorInterface|TransformerInterface|LoaderInterface $service, ): ChainExtractor|ChainTransformer|ChainLoader { return match (true) { @@ -78,7 +86,12 @@ function stdOut(): STDOUTLoader return new STDOUTLoader(); } -function useReact(): EtlExecutor +function skipWhen(callable $filter, ?string $eventClass = ExtractEvent::class, int $priority = 0): Recipe { - return withRecipe(new ReactStreamProcessor()); + return new FilterRecipe( + $filter(...), + $eventClass ?? ExtractEvent::class, + $priority, + FilterRecipeMode::EXCLUDE + ); } diff --git a/tests/Unit/Recipe/FilterRecipeTest.php b/tests/Unit/Recipe/FilterRecipeTest.php new file mode 100644 index 0000000..927b3b1 --- /dev/null +++ b/tests/Unit/Recipe/FilterRecipeTest.php @@ -0,0 +1,67 @@ + !in_array($item, $skipItems, true), + $eventClass, + ), + ) + ->transformWith(fn ($item) => strtoupper($item)); + + // When + $report = $executor->process(['banana', 'apple', 'strawberry', 'BANANA', 'APPLE', 'STRAWBERRY']); + + // Then + expect($report->output)->toBe($expectedResult); +})->with(function () { + yield [null, ['APPLE', 'BANANA']]; + yield [ExtractEvent::class, ['APPLE', 'BANANA']]; + yield [BeforeLoadEvent::class, ['BANANA', 'BANANA']]; +}); + +it('filters items (on an allow-list basis)', function (?string $eventClass, array $expectedResult) { + // Given + $executor = withRecipe( + new FilterRecipe( + fn (string $item) => str_contains($item, 'b') || str_contains($item, 'B'), + ), + ) + ->transformWith(fn ($item) => strtoupper($item)); + + // When + $report = $executor->process(['banana', 'apple', 'strawberry', 'BANANA', 'APPLE', 'STRAWBERRY']); + + // Then + expect($report->output)->toBe($expectedResult); +})->with(function () { + yield [null, ['BANANA', 'STRAWBERRY', 'BANANA', 'STRAWBERRY']]; + yield [ExtractEvent::class, ['BANANA', 'STRAWBERRY', 'BANANA', 'STRAWBERRY']]; + yield [BeforeLoadEvent::class, ['BANANA', 'STRAWBERRY', 'BANANA', 'STRAWBERRY']]; +}); + +it('does not accept other types of events', function () { + new FilterRecipe(fn () => '', LoadEvent::class); +})->throws( + InvalidArgumentException::class, + sprintf('Can only filter on ExtractEvent / LoadEvent, not %s', LoadEvent::class), +);