Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Latest
* [#94](https://github.com/cleverage/process-bundle/issues/94) TransformerTrait: the `transformers` option (TransformerTask, MappingTransformer, ArrayMapTransformer, CachedTransformer, RulesTransformer, generic transformers) also accepts a list, whose items are a transformer code without options (`- trim`) or a single `code: options` map (`- callback: {...}`), to chain the same transformer without `#` suffix. The map syntax is still supported. Update documentation, add tests.
* [#117](https://github.com/cleverage/process-bundle/issues/117) MappingTransformer: writing a target property that is a nested path (e.g. `address.city`) on an array destination, which silently created an `address.city` literal key, is deprecated and will throw an `\UnexpectedValueException` in v6.0. Use `[address.city]` for a literal key or `[address][city]` for a nested array; a simple property name (e.g. `name`) is still added as a key. Update documentation, add tests.
* [#258](https://github.com/cleverage/process-bundle/issues/258) Add XmlStreamReaderTask: iterable task reading an XML file element by element with `\XMLReader` (constant memory), outputting each element named `node_name` as a `\DOMElement` in its own `\DOMDocument`, ready for XpathEvaluatorTransformer. The file path is set in the `file_path` option or given as input. Add documentation and tests.
* [#259](https://github.com/cleverage/process-bundle/issues/259) JsonStreamWriterTask: add a `mode` option (default `wb`, e.g. `ab` to append to an existing JSON Lines file), and release the file on `finalize()`. Update documentation, add tests.

## Fixes
* [#143](https://github.com/cleverage/process-bundle/issues/143) Fix InputIteratorTask: an `\IteratorAggregate` input whose `getIterator()` does not return an `\Iterator` (e.g. another `\IteratorAggregate`) is iterated instead of failing with a `TypeError`. Update documentation, add tests.
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/tasks/json_stream_writer_task.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Options

| Code | Type | Required | Default | Description |
|-------------------------|---------------|:--------:|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `file_path` | `string` | **X** | | Path of the file to write to, opened in `wb` mode (overwritten); missing parent directories are created.<br/>Placeholders `{date}`, `{date_time}`, `{timestamp}` and `{unique_token}` are replaced in the path |
| `file_path` | `string` | **X** | | Path of the file to write to; missing parent directories are created.<br/>Placeholders `{date}`, `{date_time}`, `{timestamp}` and `{unique_token}` are replaced in the path |
| `mode` | `string` | | `wb` | File open mode (see [fopen mode parameter](https://www.php.net/manual/en/function.fopen.php)): `wb` overwrites the file, `ab` appends to it |
| `spl_file_object_flags` | `array\|null` | | `null` | List of `SplFileObject` flags, summed and passed to [SplFileObject::setFlags](https://www.php.net/manual/en/splfileobject.setflags.php).<br/>`null` means `DROP_NEW_LINE`, `READ_AHEAD` and `SKIP_EMPTY`; an empty array means no flag |
| `json_flags` | `array\|null` | | `null` | List of JSON flags, summed and passed to [json_encode](https://www.php.net/manual/en/function.json-encode.php).<br/>`null` means `JSON_THROW_ON_ERROR`; an empty array means no flag |

Expand Down Expand Up @@ -62,3 +63,4 @@ Notes
* The parent directory of the file is created (recursively) if it does not exist.
* Using `JSON_PRETTY_PRINT` produces multi-line documents: the file can no longer be read by
[JsonStreamReaderTask](json_stream_reader_task.md).
* The file is released at the end of the process.
15 changes: 13 additions & 2 deletions src/Task/File/JsonStream/JsonStreamWriterTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
use CleverAge\ProcessBundle\Filesystem\JsonStreamFile;
use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\BlockingTaskInterface;
use CleverAge\ProcessBundle\Model\FinalizableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

class JsonStreamWriterTask extends AbstractConfigurableTask implements BlockingTaskInterface
class JsonStreamWriterTask extends AbstractConfigurableTask implements BlockingTaskInterface, FinalizableTaskInterface
{
protected ?JsonStreamFile $file = null;

Expand All @@ -30,7 +31,7 @@ public function execute(ProcessState $state): void
$options = $this->getOptions($state);
$this->file = new JsonStreamFile(
$options['file_path'],
'wb',
$options['mode'],
$options['spl_file_object_flags'],
$options['json_flags'],
);
Expand All @@ -49,6 +50,14 @@ public function proceed(ProcessState $state): void
$state->setOutput($options['file_path']);
}

/**
* Release the file at the end of the process, so that the task can write it again on its next execution.
*/
public function finalize(ProcessState $state): void
{
$this->file = null;
}

protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['file_path']);
Expand All @@ -66,9 +75,11 @@ protected function configureOptions(OptionsResolver $resolver): void
)
);
$resolver->setDefaults([
'mode' => 'wb',
'spl_file_object_flags' => null,
'json_flags' => null,
]);
$resolver->setAllowedTypes('mode', ['string']);
$resolver->setAllowedTypes('spl_file_object_flags', ['array', 'null']);
$resolver->setAllowedTypes('json_flags', ['array', 'null']);
}
Expand Down
105 changes: 94 additions & 11 deletions tests/Task/File/JsonStream/JsonStreamWriterTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
use CleverAge\ProcessBundle\Model\ProcessState;
use CleverAge\ProcessBundle\Task\File\JsonStream\JsonStreamWriterTask;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;

#[\PHPUnit\Framework\Attributes\CoversClass(JsonStreamWriterTask::class)]
#[\PHPUnit\Framework\Attributes\UsesClass(JsonStreamFile::class)]
Expand All @@ -42,30 +44,111 @@ protected function setUp(): void

protected function tearDown(): void
{
@unlink($this->tmpDir.'/sub/out.jsonl');
@rmdir($this->tmpDir.'/sub');
@rmdir($this->tmpDir);
(new Filesystem())->remove($this->tmpDir);
}

public function testWritesInMissingParentDirectory(): void
{
$filePath = $this->tmpDir.'/sub/out.jsonl';

$processConfiguration = new ProcessConfiguration('test', []);
$state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration));
$state->setContextualOptionResolver(new ContextualOptionResolver());
$state->setContext([]);
$state->setTaskConfiguration(new TaskConfiguration('write', JsonStreamWriterTask::class, ['file_path' => $filePath]));
$state = $this->write(['file_path' => $filePath], [['a' => 1], ['b' => 2]]);

self::assertSame($filePath, $state->getOutput());
self::assertSame('{"a":1}'.\PHP_EOL.'{"b":2}'.\PHP_EOL, file_get_contents($filePath));
}

public function testExistingFileIsOverwrittenByDefault(): void
{
$filePath = $this->tmpDir.'/out.jsonl';
(new Filesystem())->dumpFile($filePath, '{"old":true}'.\PHP_EOL);

$this->write(['file_path' => $filePath], [['a' => 1]]);

self::assertSame('{"a":1}'.\PHP_EOL, file_get_contents($filePath));
}

public function testModeIsUsedToOpenTheFile(): void
{
$filePath = $this->tmpDir.'/out.jsonl';
(new Filesystem())->dumpFile($filePath, '{"old":true}'.\PHP_EOL);

$this->write(['file_path' => $filePath, 'mode' => 'ab'], [['a' => 1]]);

self::assertSame('{"old":true}'.\PHP_EOL.'{"a":1}'.\PHP_EOL, file_get_contents($filePath));
}

public function testModeMustBeAString(): void
{
$this->expectException(InvalidOptionsException::class);

$this->write(['file_path' => $this->tmpDir.'/out.jsonl', 'mode' => true], []);
}

public function testPlaceholdersAreReplacedInTheWrittenAndOutputPath(): void
{
$state = $this->write(['file_path' => $this->tmpDir.'/out_{date}_{unique_token}.jsonl'], [['a' => 1], ['b' => 2]]);

$output = $state->getOutput();
self::assertIsString($output);
self::assertStringStartsWith($this->tmpDir.'/out_'.date('Ymd').'_', $output);
self::assertStringNotContainsString('{', $output);
self::assertSame('{"a":1}'.\PHP_EOL.'{"b":2}'.\PHP_EOL, file_get_contents($output));
}

public function testNonArrayInputThrows(): void
{
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Input value is not an array');

$this->write(['file_path' => $this->tmpDir.'/out.jsonl'], ['not an array']);
}

public function testFinalizeReleasesTheFile(): void
{
$filePath = $this->tmpDir.'/out.jsonl';
$task = new JsonStreamWriterTask();
$state = $this->createState(['file_path' => $filePath]);
$task->initialize($state);
$state->setInput(['a' => 1]);
$task->execute($state);

$task->finalize($state);
$state->setInput(['b' => 2]);
$task->execute($state);

// The file is opened again in "wb" mode on the following execution
self::assertSame('{"b":2}'.\PHP_EOL, file_get_contents($filePath));
}

/**
* @param array<string, mixed> $options
* @param list<mixed> $inputs
*/
private function write(array $options, array $inputs): ProcessState
{
$task = new JsonStreamWriterTask();
$state = $this->createState($options);
$task->initialize($state);
foreach ([['a' => 1], ['b' => 2]] as $input) {
foreach ($inputs as $input) {
$state->setInput($input);
$task->execute($state);
}
$task->proceed($state);

self::assertSame($filePath, $state->getOutput());
self::assertSame('{"a":1}'.\PHP_EOL.'{"b":2}'.\PHP_EOL, file_get_contents($filePath));
return $state;
}

/**
* @param array<string, mixed> $options
*/
private function createState(array $options): ProcessState
{
$processConfiguration = new ProcessConfiguration('test', []);
$state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration));
$state->setContextualOptionResolver(new ContextualOptionResolver());
$state->setContext([]);
$state->setTaskConfiguration(new TaskConfiguration('write', JsonStreamWriterTask::class, $options));

return $state;
}
}
Loading