Passed
Push — master ( ffdfb2...8dcdb3 )
by Sam
13:39
created

Importer::toUpper()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 2
c 0
b 0
f 0
dl 0
loc 5
ccs 1
cts 1
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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 int $lastReview = 0;
32
33
    private array $reviewByNumber = [];
34
35
    private array $countryByName = [];
36
37
    private Connection $connection;
38
39
    private int $updatedUsers = 0;
40
41
    private int $updatedOrganizations = 0;
42
43
    private int $deletedOrganizations = 0;
44
45
    private array $seenEmails = [];
46
47
    private array $seenPatterns = [];
48
49
    private ?int $currentUser;
50
51
    private array $errors = [];
52
53
    private array $usersParams = [];
54
55 17
    private array $organizationsParams = [];
56
57 17
    public function import(string $filename): array
58 17
    {
59 17
        $start = microtime(true);
60 17
        $this->connection = _em()->getConnection();
61 17
        $this->fetchReviews();
62 17
        $this->fetchLastReview();
63 17
        $this->fetchCountries();
64 17
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
65 17
        $this->updatedUsers = 0;
66 17
        $this->updatedOrganizations = 0;
67
        $this->deletedOrganizations = 0;
68 17
        $this->seenEmails = [];
69 1
        $this->seenPatterns = [];
70
71
        if (!file_exists($filename)) {
72 16
            throw new Exception('File not found: ' . $filename);
73 16
        }
74
75
        $file = fopen($filename, 'rb');
76
        if ($file === false) {
77 16
            throw new Exception('Could not read file: ' . $filename);
78
        }
79
80 16
        $this->skipBOM($file);
81 16
82
        try {
83 16
            $this->connection->beginTransaction();
84 16
            $this->read($file);
85 16
86 16
            $this->markToDelete();
87
            $this->updateAllUsers();
88 16
            $this->updateAllOrganizations();
89 11
            $this->deleteOldOrganizations();
90
91
            if ($this->errors) {
92
                throw new Exception(implode(PHP_EOL, $this->errors));
93
            }
94 5
95 5
            // Give user automatic access via organization
96
            /** @var OrganizationRepository $organizationRepository */
97 5
            $organizationRepository = _em()->getRepository(Organization::class);
98 11
            $organizationRepository->applyOrganizationAccesses();
99 11
100
            $this->connection->commit();
101 11
        } catch (Throwable $exception) {
102 5
            $this->connection->rollBack();
103 16
104
            throw $exception;
105
        } finally {
106 5
            fclose($file);
107 5
        }
108
109 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

109
        $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...
110
        $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

110
        $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...
111
112 5
        $time = round(microtime(true) - $start, 1);
113 5
114 5
        return [
115 5
            'updatedUsers' => $this->updatedUsers,
116 5
            'updatedOrganizations' => $this->updatedOrganizations,
117 5
            'deletedOrganizations' => $this->deletedOrganizations,
118 5
            'totalUsers' => $totalUsers,
119
            'totalOrganizations' => $totalOrganizations,
120
            'totalLines' => $this->lineNumber,
121
            'time' => $time,
122 17
        ];
123
    }
124 17
125
    private function fetchReviews(): void
126 17
    {
127 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

127
        $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...
128 17
129
        $this->reviewByNumber = [];
130 17
        foreach ($records as $r) {
131
            $this->reviewByNumber[$r['review_number']] = $r['id'];
132 17
        }
133
    }
134 17
135
    private function fetchLastReview(): void
136 17
    {
137 17
        $records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL AND is_active = 1 ORDER BY review_number DESC limit 1');
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

137
        $records = /** @scrutinizer ignore-deprecated */ $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL AND is_active = 1 ORDER BY review_number DESC limit 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...
138 17
        $this->lastReview = (int) $records[0]['review_number'];
139 17
    }
140
141 17
    private function fetchCountries(): void
142
    {
143
        $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

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

336
        $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...
337
    }
338
339 12
    private function readMembership($membership): string
340
    {
341 12
        if ($membership === '1') {
342 8
            return MembershipType::MEMBER;
343
        }
344
345 6
        return MembershipType::NONE;
346 2
    }
347
348
    private function readSubscriptionType(string $subscriptionType): ?string
349 5
    {
350 4
        if (!$subscriptionType) {
351
            return null;
352
        }
353 2
354 1
        if ($subscriptionType === 'Web') {
355
            return ProductTypeType::DIGITAL;
356
        }
357 1
358
        if ($subscriptionType === 'Papier') {
359 1
            return ProductTypeType::PAPER;
360
        }
361
362 12
        if ($subscriptionType === 'Papier/web') {
363
            return ProductTypeType::BOTH;
364 12
        }
365 12
366 12
        $this->throw('Le subscriptionType est invalide : "' . $subscriptionType . '"');
367 12
368 12
        return null;
369
    }
370 12
371 12
    private function updateUser(...$args): void
372 12
    {
373
        $params = $args;
374 16
        $params[] = false; // web_temporary_access
375
        $params[] = false; // should_delete
376 16
        $params[] = ''; // password
377 4
        $params[] = $this->currentUser;
378
379
        array_push($this->usersParams, ...$params);
380 12
        ++$this->updatedUsers;
381
    }
382
383
    private function updateAllUsers(): void
384
    {
385
        if (!$this->updatedUsers) {
386
            return;
387
        }
388
389
        $placeholders = $this->placeholders($this->updatedUsers, '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())');
390
391
        $sql = 'INSERT INTO user (
392
                            email,
393
                            subscription_type,
394
                            subscription_last_review_id,
395
                            membership,
396
                            first_name,
397
                            last_name,
398
                            street,
399
                            postcode,
400 12
                            locality,
401
                            country_id,
402
                            phone,
403
                            web_temporary_access,
404
                            should_delete,
405
                            password,
406
                            creator_id,
407
                            creation_date
408
                        )
409
                        VALUES ' . $placeholders . '
410
                        ON DUPLICATE KEY UPDATE
411
                            email = VALUES(email),
412
                            subscription_type = VALUES(subscription_type),
413
                            subscription_last_review_id = VALUES(subscription_last_review_id),
414
                            membership = VALUES(membership),
415
                            first_name = VALUES(first_name),
416
                            last_name = VALUES(last_name),
417
                            street = VALUES(street),
418 12
                            postcode = VALUES(postcode),
419 12
                            locality = VALUES(locality),
420
                            country_id = VALUES(country_id),
421 4
                            phone = VALUES(phone),
422
                            web_temporary_access = VALUES(web_temporary_access),
423 4
                            should_delete = VALUES(should_delete),
424 4
                            updater_id = VALUES(creator_id),
425 4
                            update_date = NOW()';
426
427 4
        $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

427
        /** @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...
428
    }
429 4
430 4
    private function updateOrganization(...$args): void
431
    {
432 16
        $params = $args;
433
        $params[] = false; // should_delete
434 16
        $params[] = $this->currentUser;
435 12
436
        array_push($this->organizationsParams, ...$params);
437
438 4
        ++$this->updatedOrganizations;
439
    }
440
441 4
    private function updateAllOrganizations(): void
442
    {
443
        if (!$this->updatedOrganizations) {
444
            return;
445
        }
446
447
        $placeholders = $this->placeholders($this->updatedOrganizations, '(?, ?, ?, ?, NOW())');
448
449 4
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, should_delete, creator_id, creation_date)
450 4
                        VALUES ' . $placeholders . '
451
                        ON DUPLICATE KEY UPDATE
452 16
                        pattern = VALUES(pattern),
453
                        subscription_last_review_id = VALUES(subscription_last_review_id),
454 16
                        updater_id = VALUES(creator_id),
455 16
                        should_delete = VALUES(should_delete),
456 16
                        update_date = NOW()';
457
458
        $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

458
        /** @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...
459
    }
460
461 17
    private function markToDelete(): void
462
    {
463 17
        $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

463
        /** @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...
464
        $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

464
        /** @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...
465 17
    }
466
467
    /**
468 14
     * To upper without any accent
469
     */
470 14
    private function toUpper(string $name): string
471
    {
472
        $withoutAccent = iconv('UTF-8', 'ASCII//TRANSLIT', mb_strtolower($name));
473
474
        return trim(mb_strtoupper($withoutAccent));
475
    }
476
477
    private function placeholders(int $count, string $placeholder): string
478
    {
479
        return implode(',' . PHP_EOL, array_fill(0, $count, $placeholder));
480
    }
481
}
482