-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEncoder.php
More file actions
212 lines (188 loc) · 6.32 KB
/
Copy pathEncoder.php
File metadata and controls
212 lines (188 loc) · 6.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<?php
declare(strict_types=1);
namespace Bakame\Aide\NdJson;
use ErrorException;
use Exception;
use Iterator;
use JsonException;
use SplFileInfo;
use SplFileObject;
use function filter_var;
use function header;
use function json_encode;
use function json_last_error;
use function preg_replace_callback;
use function rawurlencode;
use function restore_error_handler;
use function set_error_handler;
use function str_replace;
use function strtolower;
use const E_WARNING;
use const FILTER_FLAG_STRIP_HIGH;
use const FILTER_FLAG_STRIP_LOW;
use const FILTER_UNSAFE_RAW;
use const JSON_ERROR_NONE;
use const JSON_FORCE_OBJECT;
use const JSON_PRETTY_PRINT;
use const JSON_THROW_ON_ERROR;
/**
* @internal Generic NDJSON encoder
*
* @template TKey
* @template TValue
* @template TReturn
*
* @phpstan-import-type FileDescriptor from Decoder
*/
final class Encoder
{
/**
* @param int<0, max> $flags
* @param int<1, max> $depth
* @param int<1, max> $chunkSize
*
* @throws InvalidNdJsonArgument
*/
public function __construct(
public readonly int $flags = 0,
public readonly int $depth = 512,
public readonly int $chunkSize = 1,
) {
json_encode(null, $this->flags & ~JSON_THROW_ON_ERROR);
JSON_ERROR_NONE === json_last_error() || throw new InvalidNdJsonArgument('The flags options are invalid.');
1 <= $this->depth || throw new InvalidNdJsonArgument('The depth option is invalid; it must be greater or equal to 0.');
1 <= $this->chunkSize || throw new InvalidNdJsonArgument('The chunk size option is invalid; it must be greater or equal to 1.');
}
/**
* @param iterable<TValue> $data
*
* @throws InvalidNdJsonArgument|EncodingNdJsonFailed
*
* @return Iterator<string>
*/
public function convert(iterable $data): Iterator
{
$iteration = 0;
$ndjson = '';
$flags = ($this->flags | JSON_THROW_ON_ERROR) & ~(JSON_PRETTY_PRINT | JSON_FORCE_OBJECT);
$asData = false;
foreach ($data as $offset => $row) {
$asData = true;
try {
$ndjson .= json_encode($row, $flags, $this->depth)."\n";
} catch (JsonException $exception) {
throw new EncodingNdJsonFailed(
message: 'Unable to encode data: '.$exception->getMessage(),
value: $row,
offset: $offset,
previous: $exception
);
}
++$iteration;
if ($iteration === $this->chunkSize) {
yield $ndjson;
$ndjson = '';
$iteration = 0;
}
}
if ('' !== $ndjson) {
yield $ndjson;
}
if (false === $asData) {
yield '[]';
}
}
/**
* @param iterable<TValue> $value
* @param FileDescriptor $to
* @param ?resource $context
*
* @throws EncodingNdJsonFailed
* @throws InvalidNdJsonArgument
* @throws StreamException
*/
public function write(iterable $value, mixed $to, $context = null): int
{
$stream = match (true) {
$to instanceof SplFileObject => $to,
$to instanceof SplFileInfo => $to->openFile(mode:'wb', context: $context),
default => Stream::from($to, 'wb', $context),
};
try {
$line = null;
$offset = -1;
$bytes = 0;
set_error_handler(
fn (int $errno, string $errstr, string $errfile, int $errline): bool =>
E_WARNING === $errno
? throw new ErrorException($errstr, 0, $errno, $errfile, $errline)
: false
);
foreach ($this->convert($value) as $offset => $line) { /* @phpstan-ignore-line */
$bytes += $stream->fwrite($line); /* @phpstan-ignore-line */
}
return $bytes;
} catch (Exception $exception) {
if ($exception instanceof EncodingNdJsonFailed) {
throw $exception;
}
throw new EncodingNdJsonFailed(
message: 'Unable to write to the destination path `'.$stream->getPathname().'`.',
value: $line,
offset: $offset,
previous: $exception
);
} finally {
restore_error_handler();
}
}
/**
* @param iterable<TValue> $value
*
* @throws EncodingNdJsonFailed
* @throws InvalidNdJsonArgument
* @throws StreamException
*/
public function encode(iterable $value): string
{
/** @var resource $file */
$file = @fopen('php://temp', 'r+');
$this->write(value: $value, to: $file);
rewind($file);
return (string) stream_get_contents($file);
}
/**
* @param iterable<TValue> $value
*
* @throws EncodingNdJsonFailed
* @throws InvalidNdJsonArgument
* @throws StreamException
*/
public function download(iterable $value, ?string $filename = null): int
{
self::downloadHeaders($filename);
return $this->write($value, new SplFileObject('php://output', 'wb'));
}
private static function downloadHeaders(?string $filename): void
{
if (null === $filename) {
return;
}
(! str_contains($filename, '/') && ! str_contains($filename, '\\')) || throw new InvalidNdJsonArgument('The filename `'.$filename.'` cannot contain the "/" or "\" characters.');
/** @var string $filteredName */
$filteredName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH);
$fallbackName = str_replace('%', '', $filteredName);
$disposition = 'attachment;filename="'.str_replace('"', '\\"', $fallbackName).'"';
if ($filename !== $fallbackName) {
$disposition .= ";filename*=UTF-8''".preg_replace_callback(
'/[%"\x00-\x1F\x7F-\xFF]/',
static fn (array $matches): string => strtolower(rawurlencode($matches[0])),
$filename
);
}
header('content-type: application/x-ndjson; charset=utf-8');
header('content-transfer-encoding: binary');
header('content-description: File Transfer');
header('content-disposition: '.$disposition);
}
}