Skip to content

Commit 9cfd520

Browse files
authored
Merge pull request #147 from FriendsOfCake/fix-5x-pr146-cs-stan
Fix CsvView state reset and 5.x cs-stan setup
1 parent 0c4d733 commit 9cfd520

5 files changed

Lines changed: 153 additions & 39 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ to set how null values should be displayed in the CSV.
180180

181181
`null` defaults to `''`.
182182

183+
Extract paths now consistently resolve through `Hash::get()`, so missing keys
184+
become `null`. If an extract path resolves to an array or object that cannot be
185+
stringified, use a callable extractor to flatten it before rendering.
186+
183187
#### Automatic view class switching
184188

185189
You can use the controller's content negotiation feature to automatically have
@@ -328,4 +332,3 @@ $view->set(compact('data'));
328332
// And Save the file
329333
file_put_contents('/full/path/to/file.csv', $view->render());
330334
```
331-

phpcs.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<?xml version="1.0"?>
22
<ruleset name="CsvView">
3-
<config name="installed_paths" value="../../cakephp/cakephp-codesniffer" />
43
<arg value="s"/>
54

65
<file>src/</file>

phpstan.neon

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,3 @@ parameters:
44
- src/
55
bootstrapFiles:
66
- tests/bootstrap.php
7-
ignoreErrors:
8-
- identifier: missingType.generics

src/View/CsvView.php

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Cake\Datasource\EntityInterface;
88
use Cake\Utility\Hash;
99
use Cake\View\SerializedView;
10+
use Stringable;
1011

1112
/**
1213
* A view class that is used for CSV responses.
@@ -77,11 +78,18 @@ class CsvView extends SerializedView
7778
protected string $subDir = 'csv';
7879

7980
/**
80-
* Whether or not to reset static variables in use
81+
* Aggregated CSV output for the current serialization pass.
8182
*
82-
* @var bool
83+
* @var string
84+
*/
85+
protected string $csv = '';
86+
87+
/**
88+
* Temp stream used by fputcsv() to generate a single row.
89+
*
90+
* @var resource|null
8391
*/
84-
protected bool $_resetStaticVariables = false;
92+
protected $fp = null;
8593

8694
/**
8795
* Iconv extension.
@@ -201,16 +209,43 @@ public static function contentType(): string
201209
*/
202210
protected function _serialize(array|string $serialize): string
203211
{
212+
$this->resetState();
213+
204214
$this->_renderRow($this->getConfig('header'));
205215
$this->_renderContent();
206216
$this->_renderRow($this->getConfig('footer'));
207-
$content = $this->_renderRow();
208-
$this->_resetStaticVariables = true;
209-
$this->_renderRow();
217+
$content = $this->csv;
218+
219+
$this->resetState();
210220

211221
return $content;
212222
}
213223

224+
/**
225+
* Reset accumulated state so the same view instance can render multiple
226+
* times in a single request (queue worker, multi-file export, etc.).
227+
*/
228+
protected function resetState(): void
229+
{
230+
$this->csv = '';
231+
$this->isFirstBom = true;
232+
if (is_resource($this->fp)) {
233+
fclose($this->fp);
234+
}
235+
$this->fp = null;
236+
}
237+
238+
/**
239+
* @inheritDoc
240+
*/
241+
public function __destruct()
242+
{
243+
if (is_resource($this->fp)) {
244+
fclose($this->fp);
245+
$this->fp = null;
246+
}
247+
}
248+
214249
/**
215250
* Renders the body of the data to the csv
216251
*
@@ -245,24 +280,35 @@ protected function _renderContent(): void
245280
foreach ($extract as $formatter) {
246281
if (!is_string($formatter) && is_callable($formatter)) {
247282
$value = $formatter($_data);
283+
$pathForError = '<callable>';
248284
} else {
249285
$path = $formatter;
250286
$format = null;
251287
if (is_array($formatter)) {
252288
[$path, $format] = $formatter;
253289
}
290+
$pathForError = (string)$path;
254291

255-
if (!str_contains($path, '.')) {
256-
$value = $_data[$path];
257-
} else {
258-
$value = Hash::get($_data, $path);
259-
}
292+
$value = Hash::get($_data, $path);
260293

261-
if ($format) {
294+
if ($format !== null) {
262295
$value = sprintf($format, $value);
263296
}
264297
}
265298

299+
if (
300+
$value !== null
301+
&& !is_scalar($value)
302+
&& !($value instanceof Stringable)
303+
) {
304+
throw new CakeException(sprintf(
305+
'Extract path `%s` resolved to a non-scalar `%s`. '
306+
. 'Use a callable formatter to flatten it, or adjust the extract path.',
307+
$pathForError,
308+
get_debug_type($value),
309+
));
310+
}
311+
266312
$values[] = $value;
267313
}
268314
$this->_renderRow($values);
@@ -273,54 +319,44 @@ protected function _renderContent(): void
273319
/**
274320
* Aggregates the rows into a single csv
275321
*
276-
* @param array<string>|null $row Row data
322+
* @param array<scalar|\Stringable|null>|null $row Row data
277323
* @return string CSV with all data to date
278324
*/
279325
protected function _renderRow(?array $row = null): string
280326
{
281-
static $csv = '';
327+
$this->csv .= (string)$this->_generateRow($row);
282328

283-
if ($this->_resetStaticVariables) {
284-
$csv = '';
285-
$this->_resetStaticVariables = false;
286-
287-
return '';
288-
}
289-
290-
$csv .= (string)$this->_generateRow($row);
291-
292-
return $csv;
329+
return $this->csv;
293330
}
294331

295332
/**
296333
* Generates a single row in a csv from an array of
297334
* data by writing the array to a temporary file and
298335
* returning its contents
299336
*
300-
* @param array<string|null>|null $row Row data
337+
* @param array<scalar|\Stringable|null>|null $row Row data
301338
* @return string|false String with the row in csv-syntax, false on fputscv failure
302339
*/
303340
protected function _generateRow(?array $row = null): string|false
304341
{
305-
static $fp = false;
306-
307342
if (!$row) {
308343
return '';
309344
}
310345

311-
if ($fp === false) {
346+
if ($this->fp === null) {
312347
$stream = 'php://temp';
313348
$fp = fopen($stream, 'r+');
314349
if ($fp === false) {
315350
throw new CakeException(sprintf('Cannot open stream `%s`', $stream));
316351
}
352+
$this->fp = $fp;
317353

318354
$setSeparator = $this->getConfig('setSeparator');
319355
if ($setSeparator) {
320-
fwrite($fp, 'sep=' . $setSeparator . "\n");
356+
fwrite($this->fp, 'sep=' . $setSeparator . "\n");
321357
}
322358
} else {
323-
ftruncate($fp, 0);
359+
ftruncate($this->fp, 0);
324360
}
325361

326362
$null = $this->getConfig('null');
@@ -340,21 +376,21 @@ protected function _generateRow(?array $row = null): string|false
340376
/** @phpstan-ignore-next-line */
341377
$row = str_replace(["\r\n", "\n", "\r"], $newline, $row);
342378
if ($enclosure === '') {
343-
// fputcsv does not supports empty enclosure
344-
if (fputs($fp, implode($delimiter, $row) . "\n") === false) {
379+
// fputcsv does not support empty enclosure
380+
if (fputs($this->fp, implode($delimiter, $row) . "\n") === false) {
345381
return false;
346382
}
347383
} else {
348-
if (fputcsv($fp, $row, $delimiter, $enclosure, $escape) === false) {
384+
if (fputcsv($this->fp, $row, $delimiter, $enclosure, $escape) === false) {
349385
return false;
350386
}
351387
}
352388

353-
rewind($fp);
389+
rewind($this->fp);
354390
unset($row);
355391

356392
$csv = '';
357-
while (($buffer = fgets($fp, 4096)) !== false) {
393+
while (($buffer = fgets($this->fp, 4096)) !== false) {
358394
$csv .= $buffer;
359395
}
360396

tests/TestCase/View/CsvViewTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
namespace CsvView\Test\TestCase\View;
55

6+
use Cake\Core\Exception\CakeException;
67
use Cake\Http\Response;
78
use Cake\Http\ServerRequest as Request;
89
use Cake\I18n\DateTime;
@@ -519,4 +520,81 @@ public function testInvalidViewVarThrowsException()
519520
$this->view->setConfig('serialize', 'data');
520521
$this->view->render();
521522
}
523+
524+
/**
525+
* Rendering the same instance twice must produce clean output both times
526+
* (no stale BOM state, no leftover writer state).
527+
*
528+
* @return void
529+
*/
530+
public function testRenderTwiceWithSameInstance()
531+
{
532+
$data = [['a', 'b'], ['c', 'd']];
533+
$this->view->set(['data' => $data])
534+
->setConfig(['serialize' => 'data', 'bom' => true, 'csvEncoding' => 'UTF-8']);
535+
536+
$bom = chr(0xEF) . chr(0xBB) . chr(0xBF);
537+
$expected = $bom . 'a,b' . PHP_EOL . 'c,d' . PHP_EOL;
538+
539+
$this->assertSame($expected, $this->view->render());
540+
$this->assertSame($expected, $this->view->render());
541+
}
542+
543+
/**
544+
* Simple (non-dotted) extract paths must fall through Hash::get() so a
545+
* missing key resolves to null instead of triggering an undefined-key
546+
* warning.
547+
*
548+
* @return void
549+
*/
550+
public function testRenderViaExtractMissingSimpleKey()
551+
{
552+
$data = [
553+
['name' => 'alice', 'email' => 'a@example.com'],
554+
['name' => 'bob'], // missing 'email'
555+
];
556+
$this->view->set(['users' => $data])
557+
->setConfig([
558+
'serialize' => 'users',
559+
'extract' => ['name', 'email'],
560+
]);
561+
562+
$expected = 'alice,a@example.com' . PHP_EOL . 'bob,' . PHP_EOL;
563+
$this->assertSame($expected, $this->view->render());
564+
}
565+
566+
/**
567+
* An extract path that resolves to an array (e.g. a hasMany association)
568+
* must throw a clear exception instead of silently producing "Array to
569+
* string conversion" notices and corrupted CSV. Regression for #131.
570+
*
571+
* @return void
572+
*/
573+
public function testRenderViaExtractArrayValueThrows()
574+
{
575+
$data = [
576+
[
577+
'id' => 1,
578+
'tags' => [['name' => 'php'], ['name' => 'cakephp']],
579+
],
580+
];
581+
$this->view->set(['rows' => $data])
582+
->setConfig([
583+
'serialize' => 'rows',
584+
'extract' => ['id', 'tags'],
585+
]);
586+
587+
try {
588+
$this->view->render();
589+
$this->fail('Expected exception for array-valued extract path.');
590+
} catch (Exception $e) {
591+
// SerializedView wraps our CakeException in SerializationFailureException.
592+
$previous = $e->getPrevious() ?? $e;
593+
$this->assertInstanceOf(CakeException::class, $previous);
594+
$this->assertStringContainsString(
595+
'Extract path `tags` resolved to a non-scalar `array`',
596+
$previous->getMessage(),
597+
);
598+
}
599+
}
522600
}

0 commit comments

Comments
 (0)