Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.55% covered (success)
96.55%
56 / 58
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportCsv
96.55% covered (success)
96.55%
56 / 58
60.00% covered (warning)
60.00%
3 / 5
17
0.00% covered (danger)
0.00%
0 / 1
 exportCsv
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 generateCsvContent
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 getNestedValue
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 modifyExportCsvQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeCsvLine
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelFastCrud\Actions;
6
7use BackedEnum;
8use DevToolbelt\Enums\Http\HttpStatusCode;
9use DevToolbelt\LaravelFastCrud\Traits\Limitable;
10use DevToolbelt\LaravelFastCrud\Traits\Pageable;
11use DevToolbelt\LaravelFastCrud\Traits\Searchable;
12use DevToolbelt\LaravelFastCrud\Traits\Sortable;
13use Exception;
14use Illuminate\Database\Eloquent\Builder;
15use Illuminate\Http\Request;
16use Illuminate\Support\Facades\Response;
17use Symfony\Component\HttpFoundation\StreamedResponse;
18
19/**
20 * Provides the CSV export (GET /export-csv) action for CRUD controllers.
21 *
22 * Exports filtered and sorted records to a downloadable CSV file.
23 * Supports the same filter and sort parameters as the Search action.
24 *
25 * Configure the export by setting:
26 * - $csvFileName: The output filename (default: 'export.csv')
27 * - $csvColumns: Column mapping array (key = model path, value = CSV header)
28 *
29 * @method string modelClassName() Returns the Eloquent model class name
30 *
31 * @property array $data Paginated records data (from Pageable trait)
32 *
33 * @example
34 * ```php
35 * class ProductController extends CrudController
36 * {
37 *     protected string $csvFileName = 'products.csv';
38 *     protected array $csvColumns = [
39 *         'name' => 'Product Name',
40 *         'category.name' => 'Category',
41 *         'price' => 'Price',
42 *         'created_at' => 'Created At',
43 *     ];
44 * }
45 * ```
46 */
47trait ExportCsv
48{
49    use Searchable;
50    use Sortable;
51    use Limitable;
52    use Pageable;
53
54    /**
55     * The filename for the exported CSV file.
56     * Will be prefixed with the current timestamp (Y-m-d_H-i-s_).
57     */
58    protected string $csvFileName = 'export.csv';
59
60    /**
61     * Column mapping for CSV export.
62     *
63     * Can be either:
64     * - Associative array: ['model.path' => 'CSV Header'] - maps model attributes to custom headers
65     * - Indexed array: ['column1', 'column2'] - uses column names as headers
66     *
67     * Supports dot notation for nested relationships (e.g., 'category.name', 'user.profile.avatar').
68     *
69     * @var array<string, string>|array<int, string>
70     */
71    protected array $csvColumns = [];
72
73    /**
74     * Exports records to a CSV file download.
75     *
76     * @param Request $request The HTTP request with optional filter and sort parameters
77     * @param string|null $method Model serialization method (default from config or 'toArray')
78     * @return StreamedResponse Streamed CSV file download response
79     *
80     * @throws Exception When an invalid search operator is provided
81     */
82    public function exportCsv(Request $request, ?string $method = null): StreamedResponse
83    {
84        $method = $method ?? config('devToolbelt.fast-crud.export_csv.method', 'toArray');
85        $httpStatus = config('devToolbelt.fast-crud.export_csv.http_status', HttpStatusCode::OK->value);
86        $modelName = $this->modelClassName();
87        $query = $modelName::query();
88        $isAssociative = array_keys($this->csvColumns) !== range(0, count($this->csvColumns) - 1);
89        $columnPaths = $isAssociative ? array_keys($this->csvColumns) : $this->csvColumns;
90
91        $this->modifyExportCsvQuery($query);
92
93        $this->processSearch($query, $request->get('filter', []));
94        $this->processSort($query, $request->input('sort', ''));
95        $this->buildPagination($query, (int) $request->input('perPage', 9_999_999), $method);
96
97        $csvContent = $this->generateCsvContent($columnPaths);
98
99        return Response::stream(function () use ($csvContent): void {
100            echo $csvContent;
101        }, $httpStatus, [
102            'Content-Type' => 'text/csv; charset=UTF-8',
103            'Content-Disposition' => 'attachment; filename="' . date('Y-m-d_H-i-s_') . $this->csvFileName . '"',
104            'Content-Length' => strlen($csvContent),
105            'Content-Transfer-Encoding' => 'binary',
106            'Cache-Control' => 'max-age=0, no-cache, must-revalidate, proxy-revalidate',
107            'Pragma' => 'no-cache',
108            'Expires' => '0',
109            'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT',
110            'X-Content-Type-Options' => 'nosniff',
111            'Accept-Ranges' => 'none',
112        ]);
113    }
114
115    /**
116     * Generates the CSV content as a string.
117     *
118     * @param array<int, string> $columnPaths The column paths to export
119     * @return string The complete CSV content
120     */
121    private function generateCsvContent(array $columnPaths): string
122    {
123        $handle = fopen('php://temp', 'r+');
124
125        if (!empty($this->csvColumns)) {
126            $headers = array_values($this->csvColumns);
127            $this->writeCsvLine($handle, $headers);
128        }
129
130        foreach ($this->data as $row) {
131            $csvRow = [];
132            foreach ($columnPaths as $columnPath) {
133                $value = $this->getNestedValue($row, $columnPath);
134
135                if ($value instanceof BackedEnum) {
136                    $value = $value->value;
137                }
138
139                $csvRow[] = $value;
140            }
141            $this->writeCsvLine($handle, $csvRow);
142        }
143
144        rewind($handle);
145        $content = stream_get_contents($handle);
146        fclose($handle);
147
148        return $content !== false ? $content : '';
149    }
150
151    /**
152     * Gets a nested value from an array using dot notation.
153     *
154     * @param array<string, mixed> $data The data array to search in
155     * @param string $path The dot-notation path (e.g., 'category.name', 'user.profile.avatar')
156     * @return mixed The value at the path, or empty string if not found
157     */
158    private function getNestedValue(array $data, string $path): mixed
159    {
160        $keys = explode('.', $path);
161        $value = $data;
162
163        foreach ($keys as $key) {
164            if (!is_array($value) || !array_key_exists($key, $value)) {
165                return '';
166            }
167            $value = $value[$key];
168        }
169
170        return $value ?? '';
171    }
172
173    /**
174     * Hook to modify the export query before filters and sorting are applied.
175     *
176     * Override this method to add base conditions or eager loading
177     * for the export query.
178     *
179     * @param Builder $query The query builder instance
180     *
181     * @example
182     * ```php
183     * protected function modifyExportCsvQuery(Builder $query): void
184     * {
185     *     $query->with(['category', 'supplier'])
186     *           ->where('is_exportable', true);
187     * }
188     * ```
189     */
190    protected function modifyExportCsvQuery(Builder $query): void
191    {
192    }
193
194    /**
195     * Writes a CSV line with proper escaping for special characters.
196     *
197     * Handles commas and newlines by wrapping in quotes.
198     * Double quotes are replaced with single quotes for cleaner output.
199     *
200     * @param resource $handle The file handle to write to
201     * @param array<int, mixed> $fields The fields to write
202     */
203    private function writeCsvLine($handle, array $fields): void
204    {
205        $line = [];
206
207        foreach ($fields as $field) {
208            $field = (string) $field;
209
210            // Replace double quotes with single quotes for cleaner output
211            $field = str_replace('"', "'", $field);
212
213            if (str_contains($field, ',') || str_contains($field, "\n")) {
214                $line[] = '"' . $field . '"';
215                continue;
216            }
217
218            $line[] = $field;
219        }
220
221        fwrite($handle, implode(',', $line) . "\n");
222    }
223}