Skip to content

Commit

Permalink
feat: filter recipe
Browse files Browse the repository at this point in the history
  • Loading branch information
bpolaszek committed Nov 17, 2023
1 parent 9a9f3ef commit 99bbd48
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 3 deletions.
25 changes: 24 additions & 1 deletion doc/recipes.md
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
-------------------------

Expand Down
51 changes: 51 additions & 0 deletions src/Recipe/FilterRecipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace BenTools\ETL\Recipe;

use BenTools\ETL\EtlExecutor;
use BenTools\ETL\EventDispatcher\Event\BeforeLoadEvent;
use BenTools\ETL\EventDispatcher\Event\ExtractEvent;
use Closure;
use InvalidArgumentException;

use function in_array;
use function sprintf;

final class FilterRecipe extends Recipe
{
private const EVENTS_CLASSES = [ExtractEvent::class, BeforeLoadEvent::class];

public function __construct(
private readonly Closure $filter,
private readonly string $eventClass = ExtractEvent::class,
private readonly int $priority = 0,
private readonly FilterRecipeMode $mode = FilterRecipeMode::INCLUDE,
) {
if (!in_array($this->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();
}
}
}
14 changes: 14 additions & 0 deletions src/Recipe/FilterRecipeMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace BenTools\ETL\Recipe;

/**
* @internal
*/
enum FilterRecipeMode
{
case INCLUDE;
case EXCLUDE;
}
17 changes: 15 additions & 2 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

namespace BenTools\ETL;

use BenTools\ETL\EventDispatcher\Event\ExtractEvent;
use BenTools\ETL\Extractor\ChainExtractor;
use BenTools\ETL\Extractor\ExtractorInterface;
use BenTools\ETL\Extractor\STDINExtractor;
use BenTools\ETL\Loader\ChainLoader;
use BenTools\ETL\Loader\LoaderInterface;
use BenTools\ETL\Loader\STDOUTLoader;
use BenTools\ETL\Processor\ReactStreamProcessor;
use BenTools\ETL\Recipe\FilterRecipe;
use BenTools\ETL\Recipe\FilterRecipeMode;
use BenTools\ETL\Recipe\Recipe;
use BenTools\ETL\Transformer\ChainTransformer;
use BenTools\ETL\Transformer\TransformerInterface;
Expand Down Expand Up @@ -59,6 +62,11 @@ function withRecipe(Recipe|callable $recipe): EtlExecutor
return (new EtlExecutor())->withRecipe(...func_get_args());
}

function useReact(): EtlExecutor
{
return withRecipe(new ReactStreamProcessor());
}

function chain(ExtractorInterface|TransformerInterface|LoaderInterface $service,
): ChainExtractor|ChainTransformer|ChainLoader {
return match (true) {
Expand All @@ -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
);
}
67 changes: 67 additions & 0 deletions tests/Unit/Recipe/FilterRecipeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace BenTools\ETL\Tests\Unit\Recipe;

use BenTools\ETL\EventDispatcher\Event\BeforeLoadEvent;
use BenTools\ETL\EventDispatcher\Event\ExtractEvent;
use BenTools\ETL\EventDispatcher\Event\LoadEvent;
use BenTools\ETL\Recipe\FilterRecipe;
use InvalidArgumentException;

use function BenTools\ETL\skipWhen;
use function BenTools\ETL\withRecipe;
use function expect;
use function sprintf;
use function str_contains;
use function strtoupper;

it('filters items (on an exclude-list basis)', function (?string $eventClass, array $expectedResult) {
// Given
$skipItems = ['apple', 'BANANA'];
$executor = withRecipe(
skipWhen(
fn ($item) => !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),
);

0 comments on commit 99bbd48

Please sign in to comment.