CsvSeeder   A
last analyzed

Complexity

Total Complexity 39

Size/Duplication

Total Lines 301
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 39
eloc 92
c 4
b 0
f 0
dl 0
loc 301
rs 9.28

8 Methods

Rating   Name   Duplication   Size   Complexity  
C seedFromCSV() 0 63 12
A stripUtf8Bom() 0 6 1
B readRow() 0 26 9
A run() 0 13 4
A openCSV() 0 16 4
A createMappingFromRow() 0 15 4
A insert() 0 10 2
A removeUnusedHashColumns() 0 11 3
1
<?php
2
3
namespace Flynsarmy\CsvSeeder;
4
5
use App;
6
use DB;
7
use Exception;
8
use Log;
9
use Carbon\Carbon;
10
use Illuminate\Database\Seeder;
11
use Illuminate\Database\Schema;
12
use Illuminate\Support\Facades\Hash;
13
14
/**
15
 * Taken from http://laravelsnippets.com/snippets/seeding-database-with-csv-files-cleanly
16
 * and modified to include insert chunking
17
 */
18
class CsvSeeder extends Seeder
19
{
20
    /**
21
     * DB table name
22
     *
23
     * @var string
24
     */
25
    public string $table = '';
26
27
    /**
28
     * CSV filename
29
     *
30
     * @var string
31
     */
32
    public string $filename = '';
33
34
    /**
35
     * DB connection to use. Leave empty for default connection
36
     */
37
    public string $connection = '';
38
39
    /**
40
     * DB fields to be hashed before import, For example a password field.
41
     */
42
    public array $hashable = ['password'];
43
44
    /**
45
     * An SQL INSERT query will execute every time this number of rows
46
     * are read from the CSV. Without this, large INSERTS will silently
47
     * fail.
48
     */
49
    public int $insert_chunk_size = 50;
50
51
    /**
52
     * CSV delimiter (defaults to ,)
53
     */
54
    public string $csv_delimiter = ',';
55
56
    /**
57
     * Number of rows to skip at the start of the CSV
58
     */
59
    public int $offset_rows = 0;
60
61
    /**
62
     * Can be used to tell the import to trim any leading or trailing white space from the column;
63
     */
64
    public bool $should_trim = false;
65
66
    /**
67
     * Add created_at and updated_at to rows
68
     */
69
    public bool $timestamps = false;
70
    /**
71
     * created_at and updated_at values to be added to each row. Only used if
72
     * $this->timestamps is true
73
     */
74
    public string $created_at = '';
75
    public string $updated_at = '';
76
77
    /**
78
     * The mapping of CSV to DB column. If not specified manually, the first
79
     * row (after offset_rows) of your CSV will be read as your DB columns.
80
     *
81
     * Mappings take the form of csvColNumber => dbColName.
82
     *
83
     * IE to read the first, third and fourth columns of your CSV only, use:
84
     * array(
85
     *   0 => id,
86
     *   2 => name,
87
     *   3 => description,
88
     * )
89
     */
90
    public array $mapping = [];
91
92
93
    /**
94
     * Run DB seed
95
     */
96
    public function run()
97
    {
98
        // Cache created_at and updated_at if we need to
99
        if ($this->timestamps) {
100
            if (!$this->created_at) {
101
                $this->created_at = Carbon::now()->toIso8601String();
102
            }
103
            if (!$this->updated_at) {
104
                $this->updated_at = Carbon::now()->toIso8601String();
105
            }
106
        }
107
108
        $this->seedFromCSV($this->filename, $this->csv_delimiter);
109
    }
110
111
    /**
112
     * Strip UTF-8 BOM characters from the start of a string
113
     *
114
     * @param  string $text
115
     * @return string       String with BOM stripped
116
     */
117
    public function stripUtf8Bom(string $text): string
118
    {
119
        $bom = pack('H*', 'EFBBBF');
120
        $text = preg_replace("/^$bom/", '', $text);
121
122
        return $text;
123
    }
124
125
    /**
126
     * Opens a CSV file and returns it as a resource
127
     *
128
     * @param string $filename
129
     * @return FALSE|resource
130
     */
131
    public function openCSV(string $filename)
132
    {
133
        if (!file_exists($filename) || !is_readable($filename)) {
134
            Log::error("CSV insert failed: CSV " . $filename . " does not exist or is not readable.");
135
            return false;
136
        }
137
138
        // check if file is gzipped
139
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
140
        $file_mime_type = finfo_file($finfo, $filename);
141
        finfo_close($finfo);
142
        $gzipped = strcmp($file_mime_type, "application/x-gzip") == 0;
143
144
        $handle = $gzipped ? gzopen($filename, 'r') : fopen($filename, 'r');
145
146
        return $handle;
147
    }
148
149
    /**
150
     * Reads all rows of a given CSV and imports the data.
151
     *
152
     * @param string $filename
153
     * @param string $deliminator
154
     * @return bool  Whether or not the import completed successfully.
155
     * @throws Exception
156
     */
157
    public function seedFromCSV(string $filename, string $deliminator = ","): bool
158
    {
159
        $handle = $this->openCSV($filename);
160
161
        // CSV doesn't exist or couldn't be read from.
162
        if ($handle === false) {
163
            throw new Exception("CSV insert failed: CSV " . $filename . " does not exist or is not readable.");
164
        }
165
166
        $success = true;
167
        $row_count = 0;
168
        $data = [];
169
        $mapping = $this->mapping ?: [];
170
        $offset = $this->offset_rows;
171
172
        if ($mapping) {
173
            $this->hashable = $this->removeUnusedHashColumns($mapping);
174
        }
175
176
        while (($row = fgetcsv($handle, 0, $deliminator)) !== false) {
177
            // Offset the specified number of rows
178
179
            while ($offset-- > 0) {
180
                continue 2;
181
            }
182
183
            // No mapping specified - the first row will be used as the mapping
184
            // ie it's a CSV title row. This row won't be inserted into the DB.
185
            if (!$mapping) {
186
                $mapping = $this->createMappingFromRow($row);
187
                $this->hashable = $this->removeUnusedHashColumns($mapping);
188
                continue;
189
            }
190
191
            $row = $this->readRow($row, $mapping);
192
193
            // insert only non-empty rows from the csv file
194
            if (empty($row)) {
195
                continue;
196
            }
197
198
            $data[$row_count] = $row;
199
200
            // Chunk size reached, insert
201
            if (++$row_count == $this->insert_chunk_size) {
202
                $success = $success && $this->insert($data);
203
                $row_count = 0;
204
                // clear the data array explicitly when it was inserted so
205
                // that nothing is left, otherwise a leftover scenario can
206
                // cause duplicate inserts
207
                $data = [];
208
            }
209
        }
210
211
        // Insert any leftover rows
212
        //check if the data array explicitly if there are any values left to be inserted, if insert them
213
        if (count($data)) {
214
            $success = $success && $this->insert($data);
215
        }
216
217
        fclose($handle);
218
219
        return $success;
220
    }
221
222
    /**
223
     * Creates a CSV->DB column mapping from the given CSV row.
224
     *
225
     * @param array $row  List of DB columns to insert into
226
     * @return array  List of DB fields to insert into
227
     */
228
    public function createMappingFromRow(array $row): array
229
    {
230
        $mapping = $row;
231
        $mapping[0] = $this->stripUtf8Bom($mapping[0]);
232
233
        // skip csv columns that don't exist in the database
234
        foreach ($mapping as $index => $fieldname) {
235
            if (!DB::connection($this->connection)->getSchemaBuilder()->hasColumn($this->table, $fieldname)) {
236
                if (isset($mapping[$index])) {
237
                    unset($mapping[$index]);
238
                }
239
            }
240
        }
241
242
        return $mapping;
243
    }
244
245
    /**
246
     * Removes fields from the hashable array that don't exist in our mapping.
247
     *
248
     * This function acts as a performance enhancement - we don't want
249
     * to search for hashable columns on every row imported when we already
250
     * know they don't exist.
251
     *
252
     * @param array $mapping
253
     * @return array
254
     */
255
    public function removeUnusedHashColumns(array $mapping)
256
    {
257
        $hashables = $this->hashable;
258
259
        foreach ($hashables as $key => $field) {
260
            if (!in_array($field, $mapping)) {
261
                unset($hashables[$key]);
262
            }
263
        }
264
265
        return $hashables;
266
    }
267
268
    /**
269
     * Read a CSV row into a DB insertable array
270
     *
271
     * @param array $row        A row of data to read
272
     * @param array $mapping    Array of csvCol => dbCol
273
     * @return array
274
     */
275
    public function readRow(array $row, array $mapping): array
276
    {
277
        $row_values = [];
278
279
        foreach ($mapping as $csvCol => $dbCol) {
280
            if (!isset($row[$csvCol]) || $row[$csvCol] === '') {
281
                $row_values[$dbCol] = null;
282
            } else {
283
                $row_values[$dbCol] = $this->should_trim ? trim($row[$csvCol]) : $row[$csvCol];
284
            }
285
        }
286
287
        if (!empty($this->hashable)) {
288
            foreach ($this->hashable as $columnToHash) {
289
                if (isset($row_values[$columnToHash])) {
290
                    $row_values[$columnToHash] = Hash::make($row_values[$columnToHash]);
291
                }
292
            }
293
        }
294
295
        if ($this->timestamps) {
296
            $row_values['created_at'] = $this->created_at;
297
            $row_values['updated_at'] = $this->updated_at;
298
        }
299
300
        return $row_values;
301
    }
302
303
    /**
304
     * Seed a given set of data to the DB
305
     *
306
     * @param array $seedData
307
     * @return bool   TRUE on success else FALSE
308
     */
309
    public function insert(array $seedData): bool
310
    {
311
        try {
312
            DB::connection($this->connection)->table($this->table)->insert($seedData);
313
        } catch (\Exception $e) {
314
            Log::error("CSV insert failed: " . $e->getMessage() . " - CSV " . $this->filename);
315
            return false;
316
        }
317
318
        return true;
319
    }
320
}
321