Skip to content

Commit 2d123e1

Browse files
committed
Improve NDJson encoding feature
1 parent fa4ea5b commit 2d123e1

File tree

4 files changed

+79
-21
lines changed

4 files changed

+79
-21
lines changed

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ All Notable changes to `Csv` will be documented in this file
66

77
### Added
88

9-
- None
9+
- `JsonFormat::NdJsonHeader` and `JsonFormat::NdJsonHeaderLess`
10+
- `JsonConverter` conversion methods accept an optional `$header` parameter to handle the new JSON formats.
1011

1112
### Deprecated
1213

1314
- None
1415

1516
### Fixed
1617

17-
- Adding an internal `Warning` class to fix warnings triggering in the codebaase.
18+
- Adding an internal `Warning` class to fix warnings triggering in the codebase.
1819
- Fix `chunkSize` usage when NDJson is generated by the `JsonConverter` class.
20+
- NdJson/Jsonlines content-type is fixed to `application/x-ndjson`.
1921

2022
### Remove
2123

docs/9.0/converter/json.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,14 +317,16 @@ This property always contains one of the `JsonFormat` enum values:
317317

318318
- `JsonFormat::Standard`— produces a single JSON array containing all records.
319319
- `JsonFormat::NdJson` — produces one JSON object per line.
320+
- `JsonFormat::NdJsonHeader` — produces one JSON list per line with the first line representing the file header
321+
- `JsonFormat::NdJsonHeaderLess` — produces one JSON list per line without any header
322+
323+
<p class="message-notice"><code>JsonFormat::NdJsonHeader</code> and <code>JsonFormat::NdJsonHeaderLess</code> are added
324+
in version <code>9.26</code>.</p>
320325

321326
<p class="message-warning">The converter does not restrict JSON encoding options when using
322327
<code>JsonFormat::NdJson</code>. For example, enabling <code>JSON_PRETTY_PRINT</code>
323328
will still work, but it will technically generate a non-compliant JSON Lines output.</p>
324329

325-
<p class="message-warning">When <code>JsonFormat::NdJson</code> is selected,
326-
the <code>chunksize</code> feature is disabled.</p>
327-
328330
## Download
329331

330332
<p class="message-warning">If you are using the package inside a framework please use the framework recommended way instead of the describe mechanism hereafter.</p>

src/JsonConverter.php

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Closure;
1818
use Deprecated;
1919
use Exception;
20+
use Generator;
2021
use InvalidArgumentException;
2122
use Iterator;
2223
use JsonException;
@@ -26,8 +27,12 @@
2627
use TypeError;
2728

2829
use function array_filter;
30+
use function array_map;
2931
use function array_reduce;
32+
use function array_values;
3033
use function get_defined_constants;
34+
use function implode;
35+
use function in_array;
3136
use function is_bool;
3237
use function is_resource;
3338
use function is_string;
@@ -154,7 +159,7 @@ public function __construct(
154159
$end = ']';
155160
$separator = ',';
156161
$chunkFormatter = fn (array $value): array => $value;
157-
if (JsonFormat::NdJson !== $this->format) {
162+
if (JsonFormat::Standard === $this->format) {
158163
$chunkFormatter = array_values(...);
159164
}
160165

@@ -165,18 +170,18 @@ public function __construct(
165170
$chunkFormatter = fn (array $value): array => $value;
166171
}
167172

168-
if (JsonFormat::NdJson === $this->format) {
173+
if (JsonFormat::Standard !== $this->format) {
169174
$start = '';
170175
$end = "\n";
171176
$separator = "\n";
172177
}
173178

174-
$this->emptyIterable = JsonFormat::NdJson === $this->format ? '' : $start.$end;
179+
$this->emptyIterable = JsonFormat::Standard !== $this->format ? '' : $start.$end;
175180
if ($this->usePrettyPrint()) {
176181
$start .= "\n";
177182
$end = "\n".$end;
178183
$separator .= "\n";
179-
if (JsonFormat::NdJson === $this->format) {
184+
if (JsonFormat::Standard !== $this->format) {
180185
$start = '';
181186
$end = "\n";
182187
$separator = "\n";
@@ -189,9 +194,10 @@ public function __construct(
189194
$this->start = $start;
190195
$this->end = $end;
191196
$this->separator = $separator;
192-
$this->jsonEncodeChunk = (JsonFormat::NdJson === $this->format)
193-
? fn (array $chunk): string => implode($this->separator, array_map(fn ($value) => json_encode(($chunkFormatter)($value), $flags, $this->depth), $chunk))
194-
: fn (array $chunk): string => ($prettyPrintFormatter)(substr(json_encode(($chunkFormatter)($chunk), $flags, $this->depth), /* @phpstan-ignore-line */ 1, -1));
197+
$this->jsonEncodeChunk = match ($this->format) {
198+
JsonFormat::Standard => fn (array $chunk): string => ($prettyPrintFormatter)(substr(json_encode(($chunkFormatter)($chunk), $flags, $this->depth), /* @phpstan-ignore-line */ 1, -1)),
199+
default => fn (array $chunk): string => implode($this->separator, array_map(fn ($value) => json_encode(($chunkFormatter)($value), $flags, $this->depth), $chunk)),
200+
};
195201
}
196202

197203
/**
@@ -391,32 +397,38 @@ public function when(callable|bool $condition, callable $onSuccess, ?callable $o
391397
* Returns the number of characters read from the handle and passed through to the output.
392398
*
393399
* @param iterable<T> $records
400+
* @param array<string> $header
394401
*
395402
* @throws Exception
396403
* @throws JsonException
397404
*/
398-
public function download(iterable $records, ?string $filename = null): int
405+
public function download(iterable|TabularDataProvider $records, ?string $filename = null, array $header = []): int
399406
{
400407
if (null !== $filename) {
401-
$mimetype = JsonFormat::NdJson === $this->format ? 'application/jsonl' : 'application/json';
408+
$mimetype = JsonFormat::Standard === $this->format ? 'application/json' : 'application/x-ndjson';
402409
HttpHeaders::forFileDownload($filename, $mimetype.'; charset=utf-8');
403410
}
404411

405-
return $this->save($records, new SplFileObject('php://output', 'wb'));
412+
return $this->save(
413+
records: $records,
414+
destination: new SplFileObject('php://output', 'wb'),
415+
header: $header,
416+
);
406417
}
407418

408419
/**
409420
* Returns the JSON representation of a tabular data collection.
410421
*
411422
* @param iterable<T> $records
423+
* @param array<string> $header
412424
*
413425
* @throws Exception
414426
* @throws JsonException
415427
*/
416-
public function encode(iterable $records): string
428+
public function encode(iterable $records, array $header = []): string
417429
{
418430
$stream = Stream::createFromString();
419-
$this->save($records, $stream);
431+
$this->save(records: $records, destination: $stream, header: $header);
420432
$stream->rewind();
421433

422434
return (string) $stream->getContents();
@@ -434,13 +446,14 @@ public function encode(iterable $records): string
434446
* @param iterable<T> $records
435447
* @param SplFileInfo|SplFileObject|Stream|resource|string $destination
436448
* @param resource|null $context
449+
* @param array<string> $header
437450
*
438451
* @throws JsonException
439452
* @throws RuntimeException
440453
* @throws TypeError
441454
* @throws UnavailableStream
442455
*/
443-
public function save(iterable $records, mixed $destination, $context = null): int
456+
public function save(iterable|TabularDataProvider $records, mixed $destination, $context = null, array $header = []): int
444457
{
445458
$stream = match (true) {
446459
$destination instanceof Stream,
@@ -452,7 +465,7 @@ public function save(iterable $records, mixed $destination, $context = null): in
452465
};
453466
$bytes = 0;
454467
$writtenBytes = 0;
455-
foreach ($this->convert($records) as $line) {
468+
foreach ($this->convert($records, $header) as $line) {
456469
/** @var int|false $writtenBytes */
457470
$writtenBytes = Warning::cloak($stream->fwrite(...), $line);
458471
if (false === $writtenBytes) {
@@ -470,19 +483,36 @@ public function save(iterable $records, mixed $destination, $context = null): in
470483
* Returns an Iterator that you can iterate to generate the actual JSON string representation.
471484
*
472485
* @param iterable<T> $records
486+
* @param array<string> $header
473487
*
474488
* @throws JsonException
475489
* @throws Exception
476490
*
477491
* @return Iterator<string>
478492
*/
479-
public function convert(iterable $records): Iterator
493+
public function convert(iterable|TabularDataProvider $records, array $header = []): Iterator
480494
{
495+
if ($records instanceof TabularDataProvider) {
496+
$tabularData = $records->getTabularData();
497+
$records = $tabularData->getRecords();
498+
if ([] === $header) {
499+
$header = $tabularData->getHeader();
500+
}
501+
}
502+
481503
$iterator = match ($this->formatter) {
482504
null => MapIterator::toIterator($records),
483505
default => MapIterator::fromIterable($records, $this->formatter)
484506
};
485507

508+
if (in_array($this->format, [JsonFormat::NdJsonHeader, JsonFormat::NdJsonHeaderLess], true)) {
509+
if ($records instanceof TabularData && [] === $header) {
510+
$header = $records->getHeader();
511+
}
512+
513+
$iterator = self::getList($iterator, $header, $this->format)();
514+
}
515+
486516
$iterator->rewind();
487517
if (!$iterator->valid()) {
488518
yield $this->emptyIterable;
@@ -496,7 +526,7 @@ public function convert(iterable $records): Iterator
496526
$current = $iterator->current();
497527
$iterator->next();
498528

499-
if (JsonFormat::NdJson !== $this->format) {
529+
if (JsonFormat::Standard === $this->format) {
500530
yield $this->start;
501531
}
502532

@@ -522,6 +552,28 @@ public function convert(iterable $records): Iterator
522552
yield ($this->jsonEncodeChunk)([$offset => $current]).$this->end;
523553
}
524554

555+
/**
556+
* @param array<string> $header
557+
*
558+
* @throws InvalidArgument
559+
*
560+
* @return Closure(): Generator
561+
*/
562+
private static function getList(Iterator $data, array $header, JsonFormat $format): Closure
563+
{
564+
if (JsonFormat::NdJsonHeaderLess === $format) {
565+
return fn () => yield from new MapIterator($data, fn (array $record): array => array_values($record));
566+
}
567+
568+
[] !== $header || throw new InvalidArgument('the tabular data header is empty.');
569+
570+
return function () use ($header, $data) {
571+
yield $header;
572+
573+
yield from new MapIterator($data, fn (array $record): array => array_values($record));
574+
};
575+
}
576+
525577
/**
526578
* DEPRECATION WARNING! This method will be removed in the next major point release.
527579
*

src/JsonFormat.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ enum JsonFormat
1717
{
1818
case Standard;
1919
case NdJson;
20+
case NdJsonHeader;
21+
case NdJsonHeaderLess;
2022
}

0 commit comments

Comments
 (0)