Passed
Push — master ( abcdc1...a80a3a )
by Sam
06:58
created

Importer::readReviewId()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 10
dl 0
loc 20
ccs 11
cts 11
cp 1
rs 9.2222
c 1
b 0
f 0
cc 6
nc 5
nop 1
crap 6
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
    private array $organizationsParams = [];
56
57 17
    public function import(string $filename): array
58
    {
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 17
        $this->deletedOrganizations = 0;
68 17
        $this->seenEmails = [];
69 17
        $this->seenPatterns = [];
70
71 17
        if (!file_exists($filename)) {
72 1
            throw new Exception('File not found: ' . $filename);
73
        }
74
75 16
        $file = fopen($filename, 'rb');
76 16
        if ($file === false) {
77
            throw new Exception('Could not read file: ' . $filename);
78
        }
79
80 16
        $this->skipBOM($file);
81
82
        try {
83 16
            $this->connection->beginTransaction();
84 16
            $this->read($file);
85
86 16
            $this->markToDelete();
87 16
            $this->updateAllUsers();
88 16
            $this->updateAllOrganizations();
89 16
            $this->deleteOldOrganizations();
90
91 16
            if ($this->errors) {
92 11
                throw new Exception(implode(PHP_EOL, $this->errors));
93
            }
94
95
            // Give user automatic access via organization
96
            /** @var OrganizationRepository $organizationRepository */
97 5
            $organizationRepository = _em()->getRepository(Organization::class);
98 5
            $organizationRepository->applyOrganizationAccesses();
99
100 5
            $this->connection->commit();
101 11
        } catch (Throwable $exception) {
102 11
            $this->connection->rollBack();
103
104 11
            throw $exception;
105 5
        } finally {
106 16
            fclose($file);
107
        }
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 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

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
114
        return [
115 5
            'updatedUsers' => $this->updatedUsers,
116 5
            'updatedOrganizations' => $this->updatedOrganizations,
117 5
            'deletedOrganizations' => $this->deletedOrganizations,
118 5
            'totalUsers' => $totalUsers,
119 5
            'totalOrganizations' => $totalOrganizations,
120 5
            'totalLines' => $this->lineNumber,
121 5
            'time' => $time,
122
        ];
123
    }
124
125 17
    private function fetchReviews(): void
126
    {
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
129 17
        $this->reviewByNumber = [];
130 17
        foreach ($records as $r) {
131 17
            $this->reviewByNumber[$r['review_number']] = $r['id'];
132
        }
133 17
    }
134
135 17
    private function fetchLastReview(): void
136
    {
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 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

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

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

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

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 4
    }
460
461 16
    private function markToDelete(): void
462
    {
463 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

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 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

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