diff --git a/src/Extractor/CallableExtractor.php b/src/Extractor/CallableExtractor.php index 236cedd..f3d2188 100644 --- a/src/Extractor/CallableExtractor.php +++ b/src/Extractor/CallableExtractor.php @@ -6,6 +6,9 @@ use BenTools\ETL\EtlState; use Closure; +use EmptyIterator; + +use function is_iterable; final readonly class CallableExtractor implements ExtractorInterface { @@ -16,6 +19,16 @@ public function __construct( public function extract(EtlState $state): iterable { - return ($this->closure)($state); + $extracted = ($this->closure)($state); + + if (null === $extracted) { + return new EmptyIterator(); + } + + if (!is_iterable($extracted)) { + return [$extracted]; + } + + return $extracted; } } diff --git a/src/Extractor/ChainExtractor.php b/src/Extractor/ChainExtractor.php new file mode 100644 index 0000000..6f06d08 --- /dev/null +++ b/src/Extractor/ChainExtractor.php @@ -0,0 +1,42 @@ + $_extractor) { + if (!$_extractor instanceof ExtractorInterface) { + $extractors[$e] = new CallableExtractor($_extractor(...)); + } + } + $this->extractors = $extractors; + } + + public function with(ExtractorInterface|callable $extractor): self + { + return new self(...[...$this->extractors, $extractor]); + } + + public function extract(EtlState $state): iterable + { + foreach ($this->extractors as $extractor) { + foreach ($extractor->extract($state) as $item) { + yield $item; + } + } + } +} diff --git a/src/Internal/EtlBuilderTrait.php b/src/Internal/EtlBuilderTrait.php index b9ac83d..09ba7ff 100644 --- a/src/Internal/EtlBuilderTrait.php +++ b/src/Internal/EtlBuilderTrait.php @@ -6,6 +6,7 @@ use BenTools\ETL\EtlConfiguration; use BenTools\ETL\Extractor\CallableExtractor; +use BenTools\ETL\Extractor\ChainExtractor; use BenTools\ETL\Extractor\ExtractorInterface; use BenTools\ETL\Loader\CallableLoader; use BenTools\ETL\Loader\ChainLoader; @@ -29,13 +30,21 @@ trait EtlBuilderTrait */ use EtlEventListenersTrait; - public function extractFrom(ExtractorInterface|callable $extractor): self + public function extractFrom(ExtractorInterface|callable $extractor, ExtractorInterface|callable ...$extractors): self { - if (!$extractor instanceof ExtractorInterface) { - $extractor = new CallableExtractor($extractor(...)); + $extractors = [$extractor, ...$extractors]; + + foreach ($extractors as $e => $_extractor) { + if (!$_extractor instanceof ExtractorInterface) { + $extractors[$e] = new CallableExtractor($_extractor(...)); + } + } + + if (count($extractors) > 1) { + return $this->cloneWith(['extractor' => new ChainExtractor(...$extractors)]); } - return $this->cloneWith(['extractor' => $extractor]); + return $this->cloneWith(['extractor' => $extractors[0]]); } public function transformWith(TransformerInterface|callable $transformer, TransformerInterface|callable ...$transformers): self diff --git a/tests/Unit/Extractor/CallableExtractorTest.php b/tests/Unit/Extractor/CallableExtractorTest.php index 5a56604..2c4cd0c 100644 --- a/tests/Unit/Extractor/CallableExtractorTest.php +++ b/tests/Unit/Extractor/CallableExtractorTest.php @@ -6,6 +6,9 @@ use BenTools\ETL\EtlState; use BenTools\ETL\Extractor\CallableExtractor; +use EmptyIterator; + +use function expect; it('converts a callable to an extractor', function () { // Given @@ -18,3 +21,26 @@ // Then expect($value)->toBe(['foo', 'bar']); }); + +it('returns an empty iterable when extracted content is null', function () { + // Given + $state = new EtlState(); + $callable = fn () => null; + + // When + $value = (new CallableExtractor($callable))->extract($state); + + // Then + expect($value)->toBeInstanceOf(EmptyIterator::class); +}); +it('returns an iterable of values when extracted content is not iterable', function () { + // Given + $state = new EtlState(); + $callable = fn () => 'foo'; + + // When + $value = (new CallableExtractor($callable))->extract($state); + + // Then + expect($value)->toBe(['foo']); +}); diff --git a/tests/Unit/Extractor/ChainExtractorTest.php b/tests/Unit/Extractor/ChainExtractorTest.php new file mode 100644 index 0000000..2921725 --- /dev/null +++ b/tests/Unit/Extractor/ChainExtractorTest.php @@ -0,0 +1,41 @@ + 'banana', + fn () => yield from ['apple', 'strawberry'], + ))->with(fn () => ['raspberry', 'peach']); + $executor = (new EtlExecutor($extractor)); + + // When + $report = $executor->process(); + + // Then + expect($report->output)->toBe(['banana', 'apple', 'strawberry', 'raspberry', 'peach']); +}); + +it('silently chains extractors', function () { + // Given + $executor = (new EtlExecutor()) + ->extractFrom( + fn () => 'banana', + fn () => yield from ['apple', 'strawberry'], + fn () => ['raspberry', 'peach'] + ); + + // When + $report = $executor->process(); + + // Then + expect($report->output)->toBe(['banana', 'apple', 'strawberry', 'raspberry', 'peach']); +});