Skip to content

Commit d728dd2

Browse files
authored
Merge pull request #11 from petrknap/internal-refactoring-2
Extracted pipeline logic from `Filter`
2 parents 8d935a0 + 2b73a19 commit d728dd2

File tree

7 files changed

+411
-209
lines changed

7 files changed

+411
-209
lines changed

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ allowing for easy chaining and execution of executable filters.
88
namespace PetrKnap\ExternalFilter;
99

1010
# echo "H4sIAAAAAAAAA0tJLEkEAGPz860EAAAA" | base64 --decode | gzip --decompress
11-
echo (
12-
new Filter('base64', ['--decode'])
13-
)->pipe(
14-
new Filter('gzip', ['--decompress'])
15-
)->filter('H4sIAAAAAAAAA0tJLEkEAGPz860EAAAA');
11+
echo Filter::new('base64', ['--decode'])
12+
->pipe(Filter::new('gzip', ['--decompress']))
13+
->filter('H4sIAAAAAAAAA0tJLEkEAGPz860EAAAA');
1614
```
1715

1816
If you want to process external data, redirect output or get errors, you can use input, output or error streams.
@@ -22,8 +20,8 @@ namespace PetrKnap\ExternalFilter;
2220

2321
$errorStream = fopen('php://memory', 'w+');
2422

25-
(new Filter('php'))->filter(
26-
'<?php fwrite(fopen("php://stderr", "w"), "error");',
23+
Filter::new('php')->filter(
24+
'<?php fputs(fopen("php://stderr", "w"), "error");',
2725
error: $errorStream,
2826
);
2927

src/Filter.php

Lines changed: 88 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,98 +4,124 @@
44

55
namespace PetrKnap\ExternalFilter;
66

7-
use InvalidArgumentException;
87
use Symfony\Component\Process\Exception\ProcessFailedException;
98
use Symfony\Component\Process\Process;
109

11-
final class Filter
10+
/**
11+
* @todo make it abstract and remove extends
12+
*/
13+
final class Filter extends PipelinableFilter
1214
{
13-
private self|null $previous = null;
14-
1515
/**
16+
* @deprecated use {@see self::new()}
17+
*
1618
* @param non-empty-string $command
17-
* @param array<non-empty-string> $options
19+
* @param array<non-empty-string>|null $options
1820
*/
1921
public function __construct(
2022
private readonly string $command,
21-
private readonly array $options = [],
23+
private readonly array|null $options = null,
2224
) {
2325
}
2426

2527
/**
26-
* @param string|resource $input
27-
* @param resource|null $output
28-
* @param resource|null $error
29-
*
30-
* @return ($output is null ? string : null)
31-
*
32-
* @throws Exception\FilterException
28+
* @param non-empty-string $command
29+
* @param array<non-empty-string>|null $options
30+
*/
31+
public static function new(string $command, array|null $options = null): PipelinableFilter
32+
{
33+
return new Filter($command, $options);
34+
}
35+
36+
/**
37+
* @todo move it to {@see ExternalFilter}
3338
*/
3439
public function filter(mixed $input, mixed $output = null, mixed $error = null): string|null
3540
{
36-
if (!is_string($input) && !is_resource($input)) {
37-
throw new class ('$input must be string|resource') extends InvalidArgumentException implements Exception\FilterException {
38-
};
39-
}
40-
if ($output !== null && !is_resource($output)) {
41-
throw new class ('$output must be resource|null') extends InvalidArgumentException implements Exception\FilterException {
42-
};
43-
}
44-
if ($error !== null && !is_resource($error)) {
45-
throw new class ('$error must be resource|null') extends InvalidArgumentException implements Exception\FilterException {
46-
};
41+
self::checkFilterArguments(
42+
input: $input,
43+
output: $output,
44+
error: $error,
45+
);
46+
47+
$headlessPipeline = self::transformPipeline($this->buildPipeline());
48+
$pipelineHead = array_pop($headlessPipeline);
49+
foreach ($headlessPipeline as $filter) {
50+
$filter->setInput($input);
51+
$filter->start(self::buildProcessCallback(
52+
output: null,
53+
error: $error,
54+
));
55+
$input = $filter;
4756
}
57+
$process = $pipelineHead;
58+
59+
$process->setInput($input);
60+
$process->run(self::buildProcessCallback(
61+
output: $output,
62+
error: $error,
63+
));
64+
self::checkFinishedProcess($process);
65+
66+
return $output === null ? $process->getOutput() : null;
67+
}
68+
69+
/**
70+
* @todo move it to {@see ExternalFilter}
71+
*/
72+
protected function clone(): static
73+
{
74+
return new self($this->command, $this->options);
75+
}
4876

49-
$process = $this->startFilter($input, static function (string $type, string $data) use ($output, $error): void {
77+
/**
78+
* @todo move it to {@see ExternalFilter}
79+
*
80+
* @param resource|null $output
81+
* @param resource|null $error
82+
*/
83+
private static function buildProcessCallback(mixed $output, mixed $error): callable
84+
{
85+
return static function (string $type, string $data) use ($output, $error): void {
5086
/** @var Process::OUT|Process::ERR $type */
5187
match ($type) {
52-
Process::OUT => $output === null or fwrite($output, $data),
53-
Process::ERR => $error === null or fwrite($error, $data),
54-
};
55-
});
56-
$process->wait();
57-
if (!$process->isSuccessful()) {
58-
throw new class ($process) extends ProcessFailedException implements Exception\FilterException {
88+
Process::OUT => $output === null or fputs($output, $data),
89+
Process::ERR => $error === null or fputs($error, $data),
5990
};
60-
}
61-
return $output === null ? $process->getOutput() : null;
91+
};
6292
}
6393

64-
public function pipe(self $to): self
94+
/**
95+
* @todo move it to {@see ExternalFilter}
96+
*/
97+
private static function checkFinishedProcess(Process $finishedProcess): void
6598
{
66-
$reversedPipeline = [];
67-
$head = $to;
68-
while ($head !== null) {
69-
$reversedPipeline[] = $head;
70-
$head = $head->previous;
71-
}
72-
73-
$base = $this;
74-
foreach (array_reverse($reversedPipeline) as $next) {
75-
$next = new self($next->command, $next->options);
76-
$next->previous = $base;
77-
$base = $next;
99+
if (!$finishedProcess->isSuccessful()) {
100+
throw new class ($finishedProcess) extends ProcessFailedException implements Exception\FilterException {
101+
};
78102
}
79-
80-
return $base;
81103
}
82104

83105
/**
84-
* @param string|resource $input
106+
* @todo move it to {@see ExternalFilter}
107+
*
108+
* @param non-empty-array<PipelinableFilter> $pipeline
109+
*
110+
* @return non-empty-array<Process>
85111
*/
86-
private function startFilter(mixed $input, callable|null $callback): Process
112+
private static function transformPipeline(array $pipeline): array
87113
{
88-
if ($this->previous !== null) {
89-
$input = $this->previous->startFilter($input, null);
114+
$transformedPipeline = [];
115+
foreach ($pipeline as $filter) {
116+
if ($filter instanceof self) {
117+
$transformedPipeline[] = new Process([
118+
$filter->command,
119+
...($filter->options ?? []),
120+
]);
121+
} else {
122+
throw new \BadMethodCallException('$pipeline contains unsupported filter');
123+
}
90124
}
91-
92-
$process = new Process([
93-
$this->command,
94-
...$this->options,
95-
]);
96-
$process->setInput($input);
97-
$process->start($callback);
98-
99-
return $process;
125+
return $transformedPipeline;
100126
}
101127
}

src/PipelinableFilter.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PetrKnap\ExternalFilter;
6+
7+
use InvalidArgumentException;
8+
9+
/**
10+
* @todo extend {@see Filter}
11+
*
12+
* @internal shared logic
13+
*/
14+
abstract class PipelinableFilter
15+
{
16+
private self|null $previous = null;
17+
18+
/**
19+
* @todo move it to {@see Filter}
20+
*
21+
* @param string|resource $input
22+
* @param resource|null $output
23+
* @param resource|null $error
24+
*
25+
* @return ($output is null ? string : null)
26+
*
27+
* @throws Exception\FilterException
28+
*/
29+
abstract public function filter(mixed $input, mixed $output = null, mixed $error = null): string|null;
30+
31+
public function pipe(self $to): self
32+
{
33+
$head = $this;
34+
foreach ($to->buildPipeline() as $filter) {
35+
$filter = $filter->clone();
36+
$filter->previous = $head;
37+
$head = $filter;
38+
}
39+
40+
return $head;
41+
}
42+
43+
/**
44+
* @return non-empty-array<self>
45+
*/
46+
protected function buildPipeline(): array
47+
{
48+
$reversedPipeline = [];
49+
$filter = $this;
50+
while ($filter !== null) {
51+
$reversedPipeline[] = $filter;
52+
$filter = $filter->previous;
53+
}
54+
return array_reverse($reversedPipeline);
55+
}
56+
57+
/**
58+
* @todo move it to {@see Filter}
59+
*/
60+
61+
protected static function checkFilterArguments(mixed $input, mixed $output, mixed $error): void
62+
{
63+
if (!is_string($input) && !is_resource($input)) {
64+
throw new class ('$input must be string|resource') extends InvalidArgumentException implements Exception\FilterException {
65+
};
66+
}
67+
if ($output !== null && !is_resource($output)) {
68+
throw new class ('$output must be resource|null') extends InvalidArgumentException implements Exception\FilterException {
69+
};
70+
}
71+
if ($error !== null && !is_resource($error)) {
72+
throw new class ('$error must be resource|null') extends InvalidArgumentException implements Exception\FilterException {
73+
};
74+
}
75+
}
76+
77+
abstract protected function clone(): static;
78+
}

tests/ExternalFilterTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PetrKnap\ExternalFilter;
6+
7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
9+
final class ExternalFilterTest extends PipelinableFilterTestCase
10+
{
11+
#[DataProvider('dataFilterThrowsWhenProcessFailed')]
12+
public function testFilterThrowsWhenProcessFailed(string $command, array $options, string $input): void
13+
{
14+
self::expectException(Exception\FilterException::class);
15+
16+
(new Filter($command, $options))->filter($input);
17+
}
18+
19+
public static function dataFilterThrowsWhenProcessFailed(): array
20+
{
21+
return [
22+
'unknown command' => ['unknown', [], ''],
23+
'unknown option' => ['php', ['--unknown'], ''],
24+
'wrong data' => ['php', [], '<?php wrong data'],
25+
];
26+
}
27+
28+
protected static function createPhpFilter(): PipelinableFilter
29+
{
30+
return new Filter('php');
31+
}
32+
33+
protected static function createPassTroughFilter(): PipelinableFilter
34+
{
35+
return new Filter('cat');
36+
}
37+
}

0 commit comments

Comments
 (0)