Skip to content

Commit adc014e

Browse files
committed
Merge remote-tracking branch 'origin/master' into feat/sheet-context
# Conflicts: # README.md # tests/FastExcelTest.php
2 parents b302f22 + 8aed19f commit adc014e

7 files changed

Lines changed: 329 additions & 29 deletions

File tree

README.md

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ Export only some attributes specifying columns names:
6565
});
6666
```
6767

68+
Hide columns from the exported file while still being able to use them inside the
69+
callback, using `hideColumnsPrefixedWith()`. This is handy for callback-only data
70+
(lookups, computed flags, etc.) that should not appear as a column:
71+
72+
```php
73+
(new FastExcel(User::all()))->hideColumnsPrefixedWith()->export('users.csv', function ($user) {
74+
return [
75+
'Name' => $user->name,
76+
'_role' => $user->role, // used for logic below, never written to the file
77+
];
78+
});
79+
```
80+
81+
Columns are hidden from both the header row and every data row. The prefix
82+
defaults to `_`, and any other one can be used — `hideColumnsPrefixedWith('tmp_')`.
83+
Hiding is opt-in: unless you call this method, every column is exported, so
84+
columns that already start with an underscore keep working as before.
85+
6886
Download (from a controller method):
6987

7088
```php
@@ -166,6 +184,12 @@ You can also import a specific sheet by its number:
166184
$users = (new FastExcel)->sheet(3)->import('file.xlsx');
167185
```
168186

187+
`sheet()` also accepts a sheet name, so you can select a sheet without knowing its position:
188+
189+
```php
190+
$users = (new FastExcel)->sheet('Users')->import('file.xlsx');
191+
```
192+
169193
Import multiple sheets with sheets names:
170194

171195
```php
@@ -188,21 +212,46 @@ $sheets = (new FastExcel)
188212
});
189213
```
190214

191-
### Export large collections with chunk
215+
### Export large collections (low memory)
192216

193-
Export rows one by one to avoid `memory_limit` issues [using `yield`](https://www.php.net/manual/en/language.generators.syntax.php):
217+
Passing a materialized collection (`User::all()`, `->get()`, `collect([...])`) loads every
218+
row into memory *before* the export starts, so it grows with the size of the data and a
219+
large enough dataset fails outright with `Allowed memory size of N bytes exhausted`. Feed a
220+
lazy source instead and peak memory stays flat, whatever the row count.
221+
222+
Eloquent's `cursor()` (or `->lazy()`) returns a
223+
[`LazyCollection`](https://laravel.com/docs/collections#lazy-collections), which FastExcel
224+
streams row by row — no wrapper needed:
225+
226+
```php
227+
// Export consumes only a few MB, even with 10M+ rows.
228+
(new FastExcel(User::cursor()))->export('users.xlsx');
229+
```
230+
231+
For any other source, hand `export()` a generator [using `yield`](https://www.php.net/manual/en/language.generators.syntax.php):
194232

195233
```php
196-
function usersGenerator() {
197-
foreach (User::cursor() as $user) {
198-
yield $user;
234+
function rowsGenerator() {
235+
foreach (some_paginated_source() as $row) {
236+
yield $row;
199237
}
200238
}
201239

202-
// Export consumes only a few MB, even with 10M+ rows.
203-
(new FastExcel(usersGenerator()))->export('test.xlsx');
240+
(new FastExcel(rowsGenerator()))->export('test.xlsx');
204241
```
205242

243+
Exporting 1,000,000 rows (4 columns) under a 512 MB `memory_limit`:
244+
245+
| How you export | Peak memory | Result |
246+
| --- | --- | --- |
247+
| `export()` from a materialized collection | > 512 MB | **fails** once it exceeds `memory_limit` |
248+
| `export()` from a cursor / generator (streaming) | ~4 MB | always completes |
249+
250+
Streaming does not change export *speed* (that is dominated by the underlying
251+
[OpenSpout](https://github.com/openspout/openspout) writer) — it is what keeps memory flat
252+
so very large files finish at all. `transpose()` cannot stream, as it must buffer the whole
253+
dataset to pivot rows and columns.
254+
206255
### Import large files (low memory)
207256

208257
`import` returns a Collection containing every row, so memory grows with the size of

bench/bench.php

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
* Reports the median wall-clock time and peak memory for the main
1111
* export/import paths. Memory numbers are stable across runs; time on
1212
* shared CI runners is noisy, so deltas under ~10% should be ignored.
13+
*
14+
* Peak memory is read with memory_get_peak_usage(false) — PHP's own
15+
* allocator, which is released back between cases. The real (true) figure
16+
* reports the process high-water mark from the OS allocator, which never
17+
* shrinks, so a 4 MB streaming case measured after a 30 MB collection case
18+
* would wrongly report ~30 MB. Using the PHP figure keeps every case honest
19+
* in a single process.
1320
*/
1421

1522
use OpenSpout\Common\Entity\Style\Style;
@@ -29,29 +36,48 @@
2936
$dir = sys_get_temp_dir().'/fastexcel-bench-'.getmypid();
3037
mkdir($dir);
3138

39+
$style = (new Style())->setFontBold();
40+
41+
$results = [];
42+
43+
// The collection cases share one materialized collection (15 MB+ at 30k rows).
44+
// It is freed before the streaming cases so their peak memory reflects the
45+
// streaming path alone, not a leftover collection still held in scope.
3246
$collection = collect(array_map(fn ($i) => [
3347
'id' => $i,
3448
'name' => 'name_'.$i,
3549
'email' => 'user'.$i.'@example.com',
3650
'amount' => $i * 1.5,
3751
], range(1, $rows)));
3852

39-
$style = (new Style())->setFontBold();
40-
41-
$results = [
42-
'export_plain' => bench($runs, function () use ($collection, $dir) {
43-
(new FastExcel($collection))->export($dir.'/plain.xlsx');
44-
}),
45-
'export_styled' => bench($runs, function () use ($collection, $dir, $style) {
46-
(new FastExcel($collection))->headerStyle($style)->rowsStyle($style)->export($dir.'/styled.xlsx');
47-
}),
48-
'import' => bench($runs, function () use ($dir) {
49-
(new FastExcel())->import($dir.'/plain.xlsx');
50-
}),
51-
'import_transposed' => bench($runs, function () use ($dir) {
52-
(new FastExcel())->transpose()->import($dir.'/plain.xlsx');
53-
}),
54-
];
53+
$results['export_plain'] = bench($runs, function () use ($collection, $dir) {
54+
(new FastExcel($collection))->export($dir.'/plain.xlsx');
55+
});
56+
$results['export_styled'] = bench($runs, function () use ($collection, $dir, $style) {
57+
(new FastExcel($collection))->headerStyle($style)->rowsStyle($style)->export($dir.'/styled.xlsx');
58+
});
59+
60+
unset($collection);
61+
gc_collect_cycles();
62+
63+
// Streaming export: a generator never materializes the full dataset, so peak
64+
// memory stays flat no matter how many rows are written.
65+
$results['export_generator'] = bench($runs, function () use ($rows, $dir) {
66+
(new FastExcel(rowGenerator($rows)))->export($dir.'/gen.xlsx');
67+
});
68+
69+
// Import cases read the file written by export_plain above.
70+
$results['import'] = bench($runs, function () use ($dir) {
71+
(new FastExcel())->import($dir.'/plain.xlsx');
72+
});
73+
// Streaming import: importLazy() yields rows one at a time instead of
74+
// accumulating them into a Collection.
75+
$results['import_lazy'] = bench($runs, function () use ($dir) {
76+
(new FastExcel())->importLazy($dir.'/plain.xlsx')->each(fn ($row) => $row);
77+
});
78+
$results['import_transposed'] = bench($runs, function () use ($dir) {
79+
(new FastExcel())->transpose()->import($dir.'/plain.xlsx');
80+
});
5581

5682
array_map('unlink', glob($dir.'/*'));
5783
rmdir($dir);
@@ -65,6 +91,22 @@
6591
}
6692
}
6793

94+
/**
95+
* A fresh generator over the benchmark row shape. Generators are single-use,
96+
* so each timed run needs a new one.
97+
*/
98+
function rowGenerator(int $rows): Generator
99+
{
100+
for ($i = 1; $i <= $rows; $i++) {
101+
yield [
102+
'id' => $i,
103+
'name' => 'name_'.$i,
104+
'email' => 'user'.$i.'@example.com',
105+
'amount' => $i * 1.5,
106+
];
107+
}
108+
}
109+
68110
function bench(int $runs, callable $callback): array
69111
{
70112
$times = [];
@@ -78,7 +120,7 @@ function bench(int $runs, callable $callback): array
78120
$start = hrtime(true);
79121
$callback();
80122
$times[] = (hrtime(true) - $start) / 1e9;
81-
$peak = max($peak, memory_get_peak_usage(true));
123+
$peak = max($peak, memory_get_peak_usage(false));
82124
}
83125

84126
sort($times);

src/Exportable.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,43 @@ trait Exportable
3939
/** @var array<string, string> */
4040
private $column_formats = [];
4141

42+
/** @var string|null */
43+
private $hidden_column_prefix = null;
44+
4245
/**
4346
* @param AbstractOptions $options
4447
*
4548
* @return mixed
4649
*/
4750
abstract protected function setOptions(&$options);
4851

52+
/**
53+
* Exclude columns whose key starts with the given prefix from the exported
54+
* file (both the header row and every data row). This is opt-in: without
55+
* calling it, every column is exported, so existing columns that happen to
56+
* start with an underscore keep working as before.
57+
*
58+
* It is meant for callback-only data (lookups, computed flags, etc.) that
59+
* you need inside the export callback but do not want in the output:
60+
*
61+
* (new FastExcel($users))->hideColumnsPrefixedWith()->export('users.xlsx', fn ($user) => [
62+
* 'Name' => $user->name,
63+
* '_role' => $user->role, // used for logic, never written to the file
64+
* ]);
65+
*
66+
* Only string keys are considered, so positional/list rows are never
67+
* affected. Column styles are matched against the remaining (visible)
68+
* columns.
69+
*
70+
* @param string $prefix
71+
*/
72+
public function hideColumnsPrefixedWith(string $prefix = '_'): static
73+
{
74+
$this->hidden_column_prefix = $prefix;
75+
76+
return $this;
77+
}
78+
4979
/** @param Style[] $styles */
5080
public function setHeaderColumnStyles($styles): static
5181
{
@@ -286,6 +316,8 @@ private function writeRowsFromCollection($writer, Collection $collection, ?calla
286316
$values = $values->toArray();
287317
}
288318

319+
$values = $this->removeHiddenColumns($values);
320+
289321
if ($use_styles) {
290322
// Column styles are matched against the value keys; use positional
291323
// keys so numeric style indexes work with associative rows.
@@ -309,6 +341,7 @@ private function writeRowsFromGenerator($writer, Traversable $generator, ?callab
309341

310342
// Prepare row (i.e remove non-string)
311343
$item = $this->transformRow($item);
344+
$item = $this->removeHiddenColumns($item);
312345

313346
// Add header row.
314347
if ($this->with_header && $key === 0) {
@@ -343,10 +376,37 @@ private function writeHeader($writer, $first_row)
343376
return;
344377
}
345378

346-
$keys = array_keys(is_array($first_row) ? $first_row : $first_row->toArray());
379+
$row = is_array($first_row) ? $first_row : $first_row->toArray();
380+
$keys = array_keys($this->removeHiddenColumns($row));
347381
$writer->addRow($this->createRow($keys, $this->header_style, $this->header_column_styles));
348382
}
349383

384+
/**
385+
* Remove "hidden" columns from a row before it is written. Does nothing
386+
* unless hideColumnsPrefixedWith() has been called, so exports are
387+
* unchanged by default. Because the header is derived from the first row's
388+
* keys, hidden columns are dropped from the header automatically as well.
389+
*
390+
* Numeric keys are always kept, so positional/list rows are left untouched.
391+
*
392+
* @param array $row
393+
*
394+
* @return array
395+
*/
396+
private function removeHiddenColumns(array $row): array
397+
{
398+
$prefix = $this->hidden_column_prefix;
399+
if ($prefix === null || $prefix === '') {
400+
return $row;
401+
}
402+
403+
return array_filter(
404+
$row,
405+
static fn ($key) => !is_string($key) || !str_starts_with($key, $prefix),
406+
ARRAY_FILTER_USE_KEY
407+
);
408+
}
409+
350410
/**
351411
* Prepare collection by removing non string if required.
352412
*/

src/Facades/FastExcel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* @method static string export($path, callable $callback = null)
1313
* @method static \Illuminate\Support\Collection importSheets($path, callable $callback = null)
1414
* @method static \Rap2hpoutre\FastExcel\FastExcel limitRows(?int $rows = null)
15+
* @method static \Rap2hpoutre\FastExcel\FastExcel hideColumnsPrefixedWith($prefix = '_')
1516
* @method static \Rap2hpoutre\FastExcel\FastExcel configureCsv($delimiter = ',', $enclosure = '"', $encoding = 'UTF-8', $bom = false)
1617
* @method static \Rap2hpoutre\FastExcel\FastExcel configureReaderUsing(?callable $callback = null)
1718
* @method static \Rap2hpoutre\FastExcel\FastExcel configureWriterUsing(?callable $callback = null)

src/FastExcel.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function data($data)
9191
}
9292

9393
/**
94-
* @param $sheet_number
94+
* @param int|string $sheet_number 1-based index or sheet name
9595
*
9696
* @return $this
9797
*/

src/Importable.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
trait Importable
2020
{
2121
/**
22-
* @var int
22+
* @var int|string
2323
*/
2424
private $sheet_number = 1;
2525

@@ -50,7 +50,7 @@ public function import($path, ?callable $callback = null)
5050
$reader = $this->reader($path);
5151

5252
foreach ($reader->getSheetIterator() as $key => $sheet) {
53-
if ($this->sheet_number == $key) {
53+
if ($this->sheetMatches($key, $sheet)) {
5454
$collection = $this->importSheet($sheet, $callback);
5555
break;
5656
}
@@ -79,7 +79,7 @@ public function importLazy($path, ?callable $callback = null)
7979

8080
try {
8181
foreach ($reader->getSheetIterator() as $key => $sheet) {
82-
if ($this->sheet_number != $key) {
82+
if (!$this->sheetMatches($key, $sheet)) {
8383
continue;
8484
}
8585
if ($this->transpose) {
@@ -96,6 +96,24 @@ public function importLazy($path, ?callable $callback = null)
9696
});
9797
}
9898

99+
/**
100+
* Whether the given sheet is the one selected via sheet().
101+
* Selection accepts a 1-based index (int) or a sheet name (string).
102+
*
103+
* @param int $key
104+
* @param SheetInterface $sheet
105+
*
106+
* @return bool
107+
*/
108+
private function sheetMatches(int $key, SheetInterface $sheet): bool
109+
{
110+
if (is_string($this->sheet_number)) {
111+
return $this->sheet_number === $sheet->getName();
112+
}
113+
114+
return (int) $this->sheet_number === $key;
115+
}
116+
99117
/**
100118
* @param string|\Symfony\Component\HttpFoundation\File\UploadedFile $path
101119
* @param callable|null $callback

0 commit comments

Comments
 (0)