diff --git a/CHANGELOG.md b/CHANGELOG.md
index 357c081f..fdb5f518 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/reference/tasks/json_stream_writer_task.md b/docs/reference/tasks/json_stream_writer_task.md
index af4545e8..21b1150e 100644
--- a/docs/reference/tasks/json_stream_writer_task.md
+++ b/docs/reference/tasks/json_stream_writer_task.md
@@ -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.
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.
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).
`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).
`null` means `JSON_THROW_ON_ERROR`; an empty array means no flag |
@@ -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.
diff --git a/src/Task/File/JsonStream/JsonStreamWriterTask.php b/src/Task/File/JsonStream/JsonStreamWriterTask.php
index c57446b5..4518dc35 100644
--- a/src/Task/File/JsonStream/JsonStreamWriterTask.php
+++ b/src/Task/File/JsonStream/JsonStreamWriterTask.php
@@ -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;
@@ -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'],
);
@@ -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']);
@@ -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']);
}
diff --git a/tests/Task/File/JsonStream/JsonStreamWriterTaskTest.php b/tests/Task/File/JsonStream/JsonStreamWriterTaskTest.php
index 4b4ca94b..b586dd5c 100644
--- a/tests/Task/File/JsonStream/JsonStreamWriterTaskTest.php
+++ b/tests/Task/File/JsonStream/JsonStreamWriterTaskTest.php
@@ -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)]
@@ -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 $options
+ * @param list $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 $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;
}
}