From 5825897a107767d591413d4121261a01e25f5518 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Mon, 12 Oct 2026 11:12:49 +0200 Subject: [PATCH] feat(task) #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. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + .../tasks/json_stream_writer_task.md | 4 +- .../File/JsonStream/JsonStreamWriterTask.php | 15 ++- .../JsonStream/JsonStreamWriterTaskTest.php | 105 ++++++++++++++++-- 4 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d744af..b19d2364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Latest * [#143](https://github.com/cleverage/process-bundle/issues/143) Improve PHPStan level from 6 to 7, and fix the level 8 errors that do not require a signature change (the remaining ones, due to nullable return types such as `AbstractConfigurableTask::getOptions(): ?array`, will be fixed in v6.0). Default to a `ContextualOptionResolver` in ProcessState and to a property accessor in ConditionTrait when none is set. * [#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. +* [#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; } }