Failed Conditions
Push — master ( 7cdeba...2dc91d )
by Adrien
08:27
created

Importer   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 444
Duplicated Lines 0 %

Test Coverage

Coverage 99.49%

Importance

Changes 8
Bugs 0 Features 0
Metric Value
eloc 206
c 8
b 0
f 0
dl 0
loc 444
ccs 195
cts 196
cp 0.9949
rs 6.96
wmc 53

20 Methods

Rating   Name   Duplication   Size   Complexity  
A updateOrganization() 0 9 1
A updateUser() 0 10 1
A fetchCountries() 0 8 2
A fetchReviews() 0 7 2
A readMembership() 0 7 2
A updateAllOrganizations() 0 18 2
A assertEmail() 0 16 3
B import() 0 64 6
B read() 0 72 7
A throw() 0 3 1
A assertPattern() 0 15 3
A readReviewId() 0 20 5
A readCountryId() 0 26 5
A toUpper() 0 5 1
A markToDelete() 0 4 1
A skipBOM() 0 5 2
A deleteOldOrganizations() 0 4 1
A placeholders() 0 3 1
A readSubscriptionType() 0 21 5
A updateAllUsers() 0 45 2

How to fix   Complexity   

Complex Class

Complex classes like Importer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Importer, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Service;
6
7
use Application\DBAL\Types\MembershipType;
8
use Application\DBAL\Types\ProductTypeType;
9
use Application\Model\Organization;
10
use Application\Model\User;
11
use Application\Repository\OrganizationRepository;
12
use Doctrine\DBAL\Connection;
13
use Ecodev\Felix\Api\Exception;
14
use Laminas\Validator\EmailAddress;
15
use Throwable;
16
17
/**
18
 * Service to import users from CSV with maximal performance.
19
 *
20
 * Users are never deleted, even though technically they should be, to limit the loss of
21
 * data in case of human error in the incoming files. Because it could mean losing all historic
22
 * of purchases.
23
 *
24
 * On the other hand, organizations are **always** deleted, because they don't have any related objects,
25
 * and they are not editable (not even visible) in any way in the app.
26
 */
27
class Importer
28
{
29
    private int $lineNumber = 0;
30
31
    private array $reviewByNumber = [];
32
33
    private array $countryByName = [];
34
35
    private Connection $connection;
36
37
    private int $updatedUsers = 0;
38
39
    private int $updatedOrganizations = 0;
40
41
    private int $deletedOrganizations = 0;
42
43
    private array $seenEmails = [];
44
45
    private array $seenPatterns = [];
46
47
    private ?int $currentUser;
48
49
    private array $errors = [];
50
51
    private array $usersParams = [];
52
53
    private array $organizationsParams = [];
54
55 17
    public function import(string $filename): array
56
    {
57 17
        $start = microtime(true);
58 17
        $this->connection = _em()->getConnection();
59 17
        $this->fetchReviews();
60 17
        $this->fetchCountries();
61 17
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
62 17
        $this->updatedUsers = 0;
63 17
        $this->updatedOrganizations = 0;
64 17
        $this->deletedOrganizations = 0;
65 17
        $this->seenEmails = [];
66 17
        $this->seenPatterns = [];
67
68 17
        if (!file_exists($filename)) {
69 1
            throw new Exception('File not found: ' . $filename);
70
        }
71
72 16
        $file = fopen($filename, 'rb');
73 16
        if ($file === false) {
74
            throw new Exception('Could not read file: ' . $filename);
75
        }
76
77 16
        $this->skipBOM($file);
78
79
        try {
80 16
            $this->connection->beginTransaction();
81 16
            $this->read($file);
82
83 16
            $this->markToDelete();
84 16
            $this->updateAllUsers();
85 16
            $this->updateAllOrganizations();
86 16
            $this->deleteOldOrganizations();
87
88 16
            if ($this->errors) {
89 11
                throw new Exception(implode(PHP_EOL, $this->errors));
90
            }
91
92
            // Give user automatic access via organization
93
            /** @var OrganizationRepository $organizationRepository */
94 5
            $organizationRepository = _em()->getRepository(Organization::class);
95 5
            $organizationRepository->applyOrganizationAccesses();
96
97 5
            $this->connection->commit();
98 11
        } catch (Throwable $exception) {
99 11
            $this->connection->rollBack();
100
101 11
            throw $exception;
102 5
        } finally {
103 16
            fclose($file);
104
        }
105
106 5
        $totalUsers = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM user');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::fetchColumn() has been deprecated: Use fetchOne() instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

106
        $totalUsers = (int) /** @scrutinizer ignore-deprecated */ $this->connection->fetchColumn('SELECT COUNT(*) FROM user');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
107 5
        $totalOrganizations = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM organization');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::fetchColumn() has been deprecated: Use fetchOne() instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

107
        $totalOrganizations = (int) /** @scrutinizer ignore-deprecated */ $this->connection->fetchColumn('SELECT COUNT(*) FROM organization');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
108
109 5
        $time = round(microtime(true) - $start, 1);
110
111
        return [
112 5
            'updatedUsers' => $this->updatedUsers,
113 5
            'updatedOrganizations' => $this->updatedOrganizations,
114 5
            'deletedOrganizations' => $this->deletedOrganizations,
115 5
            'totalUsers' => $totalUsers,
116 5
            'totalOrganizations' => $totalOrganizations,
117 5
            'totalLines' => $this->lineNumber,
118 5
            'time' => $time,
119
        ];
120
    }
121
122 17
    private function fetchReviews(): void
123
    {
124 17
        $records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::fetchAll() has been deprecated: Use fetchAllAssociative() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

124
        $records = /** @scrutinizer ignore-deprecated */ $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
125
126 17
        $this->reviewByNumber = [];
127 17
        foreach ($records as $r) {
128 17
            $this->reviewByNumber[$r['review_number']] = $r['id'];
129
        }
130 17
    }
131
132 17
    private function fetchCountries(): void
133
    {
134 17
        $records = $this->connection->fetchAll('SELECT id, LOWER(name) AS name FROM country');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::fetchAll() has been deprecated: Use fetchAllAssociative() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

134
        $records = /** @scrutinizer ignore-deprecated */ $this->connection->fetchAll('SELECT id, LOWER(name) AS name FROM country');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
135
136 17
        $this->countryByName = [];
137 17
        foreach ($records as $r) {
138 17
            $r['name'] = $this->toUpper($r['name']);
139 17
            $this->countryByName[$r['name']] = $r;
140
        }
141 17
    }
142
143
    /**
144
     * @param resource $file
145
     */
146 16
    private function skipBOM($file): void
147
    {
148
        // Consume BOM, but if not BOM, rewind to beginning
149 16
        if (fgets($file, 4) !== "\xEF\xBB\xBF") {
150 14
            rewind($file);
151
        }
152 16
    }
153
154
    /**
155
     * @param resource $file
156
     */
157 16
    private function read($file): void
158
    {
159 16
        $this->lineNumber = 0;
160 16
        $expectedColumnCount = 14;
161 16
        while ($line = fgetcsv($file, 0, "\t")) {
162 16
            ++$this->lineNumber;
163
164 16
            $actualColumnCount = count($line);
165 16
            if ($actualColumnCount !== $expectedColumnCount) {
166 1
                $this->throw("Doit avoir exactement $expectedColumnCount colonnes, mais en a " . $actualColumnCount);
167
168 1
                continue;
169
            }
170
171
            // un-escape all fields
172 15
            $line = array_map(fn ($r) => html_entity_decode($r), $line);
173
174
            [
175 15
                $email,
176
                $pattern,
177
                $subscriptionType,
178
                $lastReviewNumber,
179
                $ignored,
180
                $firstName,
181
                $lastName,
182
                $street,
183
                $street2,
184
                $postcode,
185
                $locality,
186
                $country,
187
                $phone,
188
                $membership,
189
            ] = $line;
190
191 15
            $email = trim($email);
192 15
            $pattern = trim($pattern);
193
194 15
            if (!$email && !$pattern) {
195 1
                $this->throw('Il faut soit un email, soit un pattern, mais aucun existe');
196
197 1
                continue;
198
            }
199
200 14
            $lastReviewId = $this->readReviewId($lastReviewNumber);
201
202 14
            if ($email) {
203 12
                $this->assertEmail($email);
204 12
                $membership = $this->readMembership($membership);
205 12
                $country = $this->readCountryId($country);
206 12
                $subscriptionType = $this->readSubscriptionType($subscriptionType);
207
208 12
                $this->updateUser(
209 12
                    $email,
210
                    $subscriptionType,
211
                    $lastReviewId,
212
                    $membership,
213
                    $firstName,
214
                    $lastName,
215 12
                    trim(implode(' ', [$street, $street2])),
216
                    $postcode,
217
                    $locality,
218
                    $country,
219
                    $phone
220
                );
221
            }
222
223 14
            if ($pattern) {
224 4
                $this->assertPattern($pattern);
225
226 4
                $this->updateOrganization(
227 4
                    $pattern,
228
                    $lastReviewId
229
                );
230
            }
231
        }
232 16
    }
233
234 12
    private function assertEmail(string $email): void
235
    {
236 12
        $validator = new EmailAddress();
237 12
        if (!$validator->isValid($email)) {
238 1
            $this->throw('Ce n\'est pas une addresse email valide : "' . $email . '"');
239
240 1
            return;
241
        }
242
243 11
        if (array_key_exists($email, $this->seenEmails)) {
244 1
            $this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]);
245
246 1
            return;
247
        }
248
249 11
        $this->seenEmails[$email] = $this->lineNumber;
250 11
    }
251
252 4
    private function assertPattern(string $pattern): void
253
    {
254 4
        if (@preg_match('~' . $pattern . '~', '') === false) {
255 1
            $this->throw('Ce n\'est pas une expression régulière valide : "' . $pattern . '"');
256
257 1
            return;
258
        }
259
260 3
        if (array_key_exists($pattern, $this->seenPatterns)) {
261 1
            $this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]);
262
263 1
            return;
264
        }
265
266 3
        $this->seenPatterns[$pattern] = $this->lineNumber;
267 3
    }
268
269 14
    private function readReviewId(string $reviewNumber): ?string
270
    {
271 14
        if (!$reviewNumber) {
272 10
            return null;
273
        }
274
275 6
        if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) {
276 1
            $this->throw('Un numéro de revue doit être entièrement numérique, mais est : "' . $reviewNumber . '"');
277
278 1
            return null;
279
        }
280
281 5
        $reviewNumberNumeric = (int) $reviewNumber;
282 5
        if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) {
283 2
            $this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber);
284
285 2
            return null;
286
        }
287
288 3
        return $this->reviewByNumber[$reviewNumberNumeric];
289
    }
290
291 12
    private function readCountryId(string $country): ?string
292
    {
293 12
        if (!$country) {
294 9
            return null;
295
        }
296
297
        // Case insensitive match
298 5
        $upper = $this->toUpper($country);
299 5
        if (array_key_exists($upper, $this->countryByName)) {
300 3
            return $this->countryByName[$upper]['id'];
301
        }
302
303
        // Suggest our best guess, so user can fix their data without lookup up countries manually
304 2
        $best = 0;
305 2
        $bestGuess = 0;
306 2
        foreach ($this->countryByName as $r) {
307 2
            similar_text($upper, $r['name'], $percent);
308 2
            if ($percent > $best) {
309 2
                $best = $percent;
310 2
                $bestGuess = $r;
311
            }
312
        }
313
314 2
        $this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?');
315
316 2
        return null;
317
    }
318
319 11
    private function throw(string $message): void
320
    {
321 11
        $this->errors[] = ('A la ligne ' . $this->lineNumber . ' : ' . $message);
322 11
    }
323
324 16
    private function deleteOldOrganizations(): void
325
    {
326 16
        $sql = 'DELETE FROM organization WHERE should_delete';
327 16
        $this->deletedOrganizations += $this->connection->executeUpdate($sql);
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::executeUpdate() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

327
        $this->deletedOrganizations += /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate($sql);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
328 16
    }
329
330 12
    private function readMembership($membership): string
331
    {
332 12
        if ($membership === '1') {
333 1
            return MembershipType::MEMBER;
334
        }
335
336 12
        return MembershipType::NONE;
337
    }
338
339 12
    private function readSubscriptionType(string $subscriptionType): ?string
340
    {
341 12
        if (!$subscriptionType) {
342 8
            return null;
343
        }
344
345 6
        if ($subscriptionType === 'Web') {
346 2
            return ProductTypeType::DIGITAL;
347
        }
348
349 5
        if ($subscriptionType === 'Papier') {
350 4
            return ProductTypeType::PAPER;
351
        }
352
353 2
        if ($subscriptionType === 'Papier/web') {
354 1
            return ProductTypeType::BOTH;
355
        }
356
357 1
        $this->throw('Le subscriptionType est invalide : "' . $subscriptionType . '"');
358
359 1
        return null;
360
    }
361
362 12
    private function updateUser(...$args): void
363
    {
364 12
        $params = $args;
365 12
        $params[] = false; // web_temporary_access
366 12
        $params[] = false; // should_delete
367 12
        $params[] = ''; // password
368 12
        $params[] = $this->currentUser;
369
370 12
        array_push($this->usersParams, ...$params);
371 12
        ++$this->updatedUsers;
372 12
    }
373
374 16
    private function updateAllUsers(): void
375
    {
376 16
        if (!$this->updatedUsers) {
377 4
            return;
378
        }
379
380 12
        $placeholders = $this->placeholders($this->updatedUsers, '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())');
381
382
        $sql = 'INSERT INTO user (
383
                            email,
384
                            subscription_type,
385
                            subscription_last_review_id,
386
                            membership,
387
                            first_name,
388
                            last_name,
389
                            street,
390
                            postcode,
391
                            locality,
392
                            country_id,
393
                            phone,
394
                            web_temporary_access,
395
                            should_delete,
396
                            password,
397
                            creator_id,
398
                            creation_date
399
                        )
400 12
                        VALUES ' . $placeholders . '
401
                        ON DUPLICATE KEY UPDATE
402
                            email = VALUES(email),
403
                            subscription_type = VALUES(subscription_type),
404
                            subscription_last_review_id = VALUES(subscription_last_review_id),
405
                            membership = VALUES(membership),
406
                            first_name = VALUES(first_name),
407
                            last_name = VALUES(last_name),
408
                            street = VALUES(street),
409
                            postcode = VALUES(postcode),
410
                            locality = VALUES(locality),
411
                            country_id = VALUES(country_id),
412
                            phone = VALUES(phone),
413
                            web_temporary_access = VALUES(web_temporary_access),
414
                            should_delete = VALUES(should_delete),
415
                            updater_id = VALUES(creator_id),
416
                            update_date = NOW()';
417
418 12
        $this->connection->executeUpdate($sql, $this->usersParams);
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::executeUpdate() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

418
        /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate($sql, $this->usersParams);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
419 12
    }
420
421 4
    private function updateOrganization(...$args): void
422
    {
423 4
        $params = $args;
424 4
        $params[] = false; // should_delete
425 4
        $params[] = $this->currentUser;
426
427 4
        array_push($this->organizationsParams, ...$params);
428
429 4
        ++$this->updatedOrganizations;
430 4
    }
431
432 16
    private function updateAllOrganizations(): void
433
    {
434 16
        if (!$this->updatedOrganizations) {
435 12
            return;
436
        }
437
438 4
        $placeholders = $this->placeholders($this->updatedOrganizations, '(?, ?, ?, ?, NOW())');
439
440
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, should_delete, creator_id, creation_date)
441 4
                        VALUES ' . $placeholders . '
442
                        ON DUPLICATE KEY UPDATE
443
                        pattern = VALUES(pattern),
444
                        subscription_last_review_id = VALUES(subscription_last_review_id),
445
                        updater_id = VALUES(creator_id),
446
                        should_delete = VALUES(should_delete),
447
                        update_date = NOW()';
448
449 4
        $this->connection->executeUpdate($sql, $this->organizationsParams);
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::executeUpdate() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

449
        /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate($sql, $this->organizationsParams);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
450 4
    }
451
452 16
    private function markToDelete(): void
453
    {
454 16
        $this->connection->executeUpdate('UPDATE user SET should_delete = 1');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::executeUpdate() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

454
        /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate('UPDATE user SET should_delete = 1');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
455 16
        $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::executeUpdate() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

455
        /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
456 16
    }
457
458
    /**
459
     * To upper without any accent
460
     */
461 17
    private function toUpper(string $name): string
462
    {
463 17
        $withoutAccent = iconv('UTF-8', 'ASCII//TRANSLIT', mb_strtolower($name));
464
465 17
        return trim(mb_strtoupper($withoutAccent));
466
    }
467
468 14
    private function placeholders(int $count, string $placeholder): string
469
    {
470 14
        return implode(',' . PHP_EOL, array_fill(0, $count, $placeholder));
471
    }
472
}
473