Skip to content

Commit

Permalink
Improve JSONConverter codebase
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Oct 12, 2024
1 parent b4583da commit 7138bca
Showing 1 changed file with 151 additions and 133 deletions.
284 changes: 151 additions & 133 deletions src/JsonConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,31 @@ final class JsonConverter
public readonly int $indentSize;
/** @var Closure(T, array-key): mixed */
public readonly Closure $formatter;
private readonly bool $isPrettyPrint;
private readonly bool $isForceObject;
/** @var non-empty-string */
private readonly string $indentation;
/** @var Closure(string, array-key): string */
private readonly Closure $internalFormatter;
/** @var int<1, max> */
public readonly int $chunkSize;
/** @var non-empty-string */
private readonly string $start;
/** @var non-empty-string */
private readonly string $end;
/** @var non-empty-string */
private readonly string $separator;
/** @var non-empty-string */
private readonly string $emptyIterable;
/** @var non-empty-string */
private readonly string $indentation;
/** @var Closure(array<int, T>): string */
private readonly Closure $bufferEncoder;

public static function create(): self
{
return new self(flags: 0, depth: 512, indentSize: 4, formatter: null, chunkSize: 500);
return new self(flags: 0, depth: 512, indentSize: 4, formatter: fn (mixed $value, int|string $offset) => $value, chunkSize: 500);
}

/**
* @param int<1, max> $depth
* @param int<1, max> $indentSize
*/
private function __construct(int $flags, int $depth, int $indentSize, ?Closure $formatter, int $chunkSize)
private function __construct(int $flags, int $depth, int $indentSize, Closure $formatter, int $chunkSize)
{
json_encode([], $flags & ~JSON_THROW_ON_ERROR, $depth);

Expand All @@ -136,28 +142,102 @@ private function __construct(int $flags, int $depth, int $indentSize, ?Closure $
$this->flags = $flags;
$this->depth = $depth;
$this->indentSize = $indentSize;
$this->indentation = str_repeat(' ', $indentSize);
$this->formatter = $formatter ?? fn (mixed $value) => $value;
$this->isPrettyPrint = ($this->flags & JSON_PRETTY_PRINT) === JSON_PRETTY_PRINT;
$this->isForceObject = ($this->flags & JSON_FORCE_OBJECT) === JSON_FORCE_OBJECT;
$this->internalFormatter = $this->setInternalFormatter();
$this->formatter = $formatter;
$this->chunkSize = $chunkSize;

// Initialize private properties used for conversion.
// To optimize encoding, calls are calculated in
// the constructor to reduce condition during
// json encoding
$this->indentation = str_repeat(' ', $this->indentSize);
$start = '[';
$end = ']';
$separator = ',';
if ($this->useFlags(JSON_FORCE_OBJECT)) {
$start = '{';
$end = '}';
}
$this->emptyIterable = $start.$end;
if ($this->useFlags(JSON_PRETTY_PRINT)) {
$start .= "\n";
$end = "\n".$end;
$separator .= "\n";
}
$this->start = $start;
$this->end = $end;
$this->separator = $separator;
$this->bufferEncoder = $this->setBufferEncoder();
}

/**
* @return Closure(string, array-key): string
* Returns the effective encoder for the iterable structure.
*
* @return Closure(array<int, T>): string
*/
private function setInternalFormatter(): Closure
private function setBufferEncoder(): Closure
{
$callback = match ($this->isForceObject) {
false => fn (string $json, int|string $offset): string => $json,
default => fn (string $json, int|string $offset): string => '"'.json_encode($offset).'":'.$json,
$internalFormatter = match ($this->useFlags(JSON_PRETTY_PRINT)) {
true => $this->prettyPrint(...),
false => fn (string $json): string => $json,
};

return match ($this->isPrettyPrint) {
false => $callback,
default => fn (string $json, int|string $offset): string => $this->prettyPrint($callback($json, $offset)),
$bufferFormatter = match ($this->useFlags(JSON_FORCE_OBJECT)) {
true => function (array $value): array {
$data = [];
foreach ($value as $offset => $item) {
$data[$offset] = ($this->formatter)($item, $offset);
}

return $data;
},
false => function (array $value): array {
$data = [];
foreach ($value as $offset => $item) {
$data[] = ($this->formatter)($item, $offset);
}

return $data;
},
};

return fn (array $buffer) => ($internalFormatter)(substr(
string: json_encode(
value: ($bufferFormatter)($buffer),
flags: ($this->flags & ~JSON_PRETTY_PRINT) | JSON_THROW_ON_ERROR,
depth: $this->depth
),
offset: 1,
length: -1
));
}

/**
* Pretty Print the JSON string without using JSON_PRETTY_PRINT
* The method also allow using an arbitrary length for the indentation.
*/
private function prettyPrint(string $json): string
{
$level = 1;
$inQuotes = false;
$escape = false;
$length = strlen($json);
$str = $this->indentation;
for ($i = 0; $i < $length; $i++) {
$char = $json[$i];
if ('"' === $char && !$escape) {
$inQuotes = !$inQuotes;
}

$escape = '\\' === $char && !$escape;
$str .= $inQuotes ? $char : match ($char) {
'{', '[' => $char."\n".str_repeat($this->indentation, ++$level),
'}', ']' => "\n".str_repeat($this->indentation, --$level).$char,
',' => $char."\n".str_repeat($this->indentation, $level),
':' => $char.' ',
default => $char,
};
}

return $str;
}

/**
Expand Down Expand Up @@ -239,6 +319,9 @@ public function useFlags(int ...$flags): bool
return [] !== $flags;
}

/**
* Sets the encoding flags.
*/
private function setFlags(int $flags): self
{
return match ($flags) {
Expand Down Expand Up @@ -291,9 +374,45 @@ public function chunkSize(int $chunkSize): self
*/
public function formatter(?Closure $formatter): self
{
$formatter ??= fn (mixed $value, int|string $offset) => $value;

return new self($this->flags, $this->depth, $this->indentSize, $formatter, $this->chunkSize);
}

/**
* Sends and makes the JSON structure downloadable via HTTP.
*.
* Returns the number of characters read from the handle and passed through to the output.
*
* @param iterable<T> $records
*
* @throws Exception
* @throws JsonException
*/
public function download(iterable $records, string $filename): int
{
HttpHeaders::forFileDownload($filename, 'application/json');

return $this->save($records, new SplFileObject('php://output', 'w'));
}

/**
* Returns the JSON representation of a tabular data collection.
*
* @param iterable<T> $records
*
* @throws Exception
* @throws JsonException
*/
public function encode(iterable $records): string
{
$stream = Stream::createFromString();
$this->save($records, $stream);
$stream->rewind();

return (string) $stream->getContents();
}

/**
* Store the generated JSON in the destination filepath.
*
Expand Down Expand Up @@ -338,40 +457,6 @@ public function save(iterable $records, mixed $destination, $context = null): in
return $bytes;
}

/**
* Returns the JSON representation of a tabular data collection.
*
* @param iterable<T> $records
*
* @throws Exception
* @throws JsonException
*/
public function encode(iterable $records): string
{
$stream = Stream::createFromString();
$this->save($records, $stream);
$stream->rewind();

return (string) $stream->getContents();
}

/**
* Sends and makes the JSON structure downloadable via HTTP.
*.
* Returns the number of characters read from the handle and passed through to the output.
*
* @param iterable<T> $records
*
* @throws Exception
* @throws JsonException
*/
public function download(iterable $records, string $filename): int
{
HttpHeaders::forFileDownload($filename, 'application/json');

return $this->save($records, new SplFileObject('php://output', 'w'));
}

/**
* Returns an Iterator that you can iterate to generate the actual JSON string representation.
*
Expand All @@ -384,107 +469,40 @@ public function download(iterable $records, string $filename): int
*/
public function convert(iterable $records): Iterator
{
$start = '[';
$end = ']';
if ($this->isForceObject) {
$start = '{';
$end = '}';
}

$records = MapIterator::toIterator($records);
$records->rewind();
if (!$records->valid()) {
yield $start.$end;
yield $this->emptyIterable;

return;
}

$separator = ',';
if ($this->isPrettyPrint) {
$start .= "\n";
$end = "\n".$end;
$separator .= "\n";
}

$offset = 0;
$incr = 0;
$buffer = [];
$current = $records->current();
$records->next();

yield $start;
yield $this->start;

$incr = 0;
$buffer = [];
while ($records->valid()) {
if ($incr === $this->chunkSize) {
yield $this->format($buffer, $offset).$separator;
yield ($this->bufferEncoder)($buffer).$this->separator;

$incr = 0;
$buffer = [];
}
$incr++;
$buffer[] = $current;

$offset++;
++$incr;
$buffer[++$offset] = $current;
$current = $records->current();
$records->next();
}

$last = $this->format($buffer, $offset);
if ('' !== $last) {
yield $last.$separator;
}

yield $this->format([$current], $offset++).$end;
}

/**
* @throws JsonException
*/
private function format(array $value, int $offset): string
{
$data = [];
foreach ($value as $item) {
$data[] = ($this->formatter)($item, $offset);
++$offset;
if ([] !== $buffer) {
yield ($this->bufferEncoder)($buffer).$this->separator;
}

$json = json_encode(
value: $data,
flags: ($this->flags & ~JSON_PRETTY_PRINT) | JSON_THROW_ON_ERROR,
depth: $this->depth
);

return ($this->internalFormatter)(substr($json, 1, -1), $offset);
}

/**
* Pretty Print the JSON string without using JSON_PRETTY_PRINT
* The method also allow using an arbitrary length for the indentation.
*/
private function prettyPrint(string $json): string
{
$level = 1;
$inQuotes = false;
$escape = false;
$length = strlen($json);

$str = $this->indentation;
for ($i = 0; $i < $length; $i++) {
$char = $json[$i];
if ('"' === $char && !$escape) {
$inQuotes = !$inQuotes;
}

$escape = '\\' === $char && !$escape;
$str .= $inQuotes ? $char : match ($char) {
'{', '[' => $char."\n".str_repeat($this->indentation, ++$level),
'}', ']' => "\n".str_repeat($this->indentation, --$level).$char,
',' => $char."\n".str_repeat($this->indentation, $level),
':' => $char.' ',
default => $char,
};
}

return $str;
yield ($this->bufferEncoder)([$offset => $current]).$this->end;
}
}

0 comments on commit 7138bca

Please sign in to comment.