Passed
Push — master ( aafc79...755445 )
by Adrien
11:37
created

Importer::markToDelete()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
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 17
    public function import(string $filename): array
52
    {
53 17
        $start = microtime(true);
54 17
        $this->connection = _em()->getConnection();
55 17
        $this->fetchReviews();
56 17
        $this->fetchCountries();
57 17
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
58 17
        $this->updatedUsers = 0;
59 17
        $this->updatedOrganizations = 0;
60 17
        $this->deletedOrganizations = 0;
61 17
        $this->seenEmails = [];
62 17
        $this->seenPatterns = [];
63
64 17
        if (!file_exists($filename)) {
65 1
            throw new Exception('File not found: ' . $filename);
66
        }
67
68 16
        $file = fopen($filename, 'rb');
69 16
        if ($file === false) {
70
            throw new Exception('Could not read file: ' . $filename);
71
        }
72
73 16
        $this->skipBOM($file);
74
75
        try {
76 16
            $this->connection->beginTransaction();
77 16
            $this->markToDelete();
78 16
            $this->read($file);
79 16
            $this->deleteOldOrganizations();
80
81 16
            if ($this->errors) {
82 11
                throw new Exception(implode(PHP_EOL, $this->errors));
83
            }
84
85
            // Give user automatic access via organization
86
            /** @var OrganizationRepository $organizationRepository */
87 5
            $organizationRepository = _em()->getRepository(Organization::class);
88 5
            $organizationRepository->applyOrganizationAccesses();
89
90 5
            $this->connection->commit();
91 11
        } catch (Throwable $exception) {
92 11
            $this->connection->rollBack();
93
94 11
            throw $exception;
95 5
        } finally {
96 16
            fclose($file);
97
        }
98
99 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

99
        $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...
100 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

100
        $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...
101
102 5
        $time = round(microtime(true) - $start, 1);
103
104
        return [
105 5
            'updatedUsers' => $this->updatedUsers,
106 5
            'updatedOrganizations' => $this->updatedOrganizations,
107 5
            'deletedOrganizations' => $this->deletedOrganizations,
108 5
            'totalUsers' => $totalUsers,
109 5
            'totalOrganizations' => $totalOrganizations,
110 5
            'totalLines' => $this->lineNumber,
111 5
            'time' => $time,
112
        ];
113
    }
114
115 17
    private function fetchReviews(): void
116
    {
117 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

117
        $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...
118
119 17
        $this->reviewByNumber = [];
120 17
        foreach ($records as $r) {
121 17
            $this->reviewByNumber[$r['review_number']] = $r['id'];
122
        }
123 17
    }
124
125 17
    private function fetchCountries(): void
126
    {
127 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

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

317
        $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...
318 16
    }
319
320 12
    private function readMembership($membership): string
321
    {
322 12
        if ($membership === '1') {
323 1
            return MembershipType::MEMBER;
324
        }
325
326 12
        return MembershipType::NONE;
327
    }
328
329 12
    private function readSubscriptionType(string $subscriptionType): ?string
330
    {
331 12
        if (!$subscriptionType) {
332 8
            return null;
333
        }
334
335 6
        if ($subscriptionType === 'Web') {
336 2
            return ProductTypeType::DIGITAL;
337
        }
338
339 5
        if ($subscriptionType === 'Papier') {
340 4
            return ProductTypeType::PAPER;
341
        }
342
343 2
        if ($subscriptionType === 'Papier/web') {
344 1
            return ProductTypeType::BOTH;
345
        }
346
347 1
        $this->throw('Le subscriptionType est invalide : "' . $subscriptionType . '"');
348
349 1
        return null;
350
    }
351
352 12
    private function updateUser(...$args): void
353
    {
354 12
        $sql = 'INSERT INTO user (
355
                            email,
356
                            subscription_type,
357
                            subscription_last_review_id,
358
                            membership,
359
                            first_name,
360
                            last_name,
361
                            street,
362
                            postcode,
363
                            locality,
364
                            country_id,
365
                            phone,
366
                            web_temporary_access,
367
                            should_delete,
368
                            password,
369
                            creator_id,
370
                            creation_date
371
                        )
372
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
373
                        ON DUPLICATE KEY UPDATE
374
                            email = VALUES(email),
375
                            subscription_type = VALUES(subscription_type),
376
                            subscription_last_review_id = VALUES(subscription_last_review_id),
377
                            membership = VALUES(membership),
378
                            first_name = VALUES(first_name),
379
                            last_name = VALUES(last_name),
380
                            street = VALUES(street),
381
                            postcode = VALUES(postcode),
382
                            locality = VALUES(locality),
383
                            country_id = VALUES(country_id),
384
                            phone = VALUES(phone),
385
                            web_temporary_access = VALUES(web_temporary_access),
386
                            should_delete = VALUES(should_delete),
387
                            updater_id = VALUES(creator_id),
388
                            update_date = NOW()';
389
390 12
        $params = $args;
391 12
        $params[] = false; // web_temporary_access
392 12
        $params[] = false; // should_delete
393 12
        $params[] = ''; // password
394 12
        $params[] = $this->currentUser;
395
396 12
        $changed = $this->connection->executeUpdate($sql, $params);
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

396
        $changed = /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate($sql, $params);

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...
397
398 12
        if ($changed) {
399 12
            ++$this->updatedUsers;
400
        }
401 12
    }
402
403 4
    private function updateOrganization(...$args): void
404
    {
405 4
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, creator_id, creation_date)
406
                        VALUES (?, ?, ?, NOW())
407
                        ON DUPLICATE KEY UPDATE
408
                        pattern = VALUES(pattern),
409
                        subscription_last_review_id = VALUES(subscription_last_review_id),
410
                        updater_id = VALUES(creator_id),
411
                        update_date = NOW()';
412
413 4
        $params = $args;
414 4
        $params[] = $this->currentUser;
415
416 4
        $changed = $this->connection->executeUpdate($sql, $params);
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

416
        $changed = /** @scrutinizer ignore-deprecated */ $this->connection->executeUpdate($sql, $params);

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...
417
418 4
        if ($changed) {
419 4
            ++$this->updatedOrganizations;
420
        }
421 4
    }
422
423 16
    private function markToDelete(): void
424
    {
425 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

425
        /** @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...
426 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

426
        /** @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...
427 16
    }
428
429
    /**
430
     * To upper without any accent
431
     */
432 17
    private function toUpper(string $name): string
433
    {
434 17
        $withoutAccent = iconv('UTF-8', 'ASCII//TRANSLIT', mb_strtolower($name));
435
436 17
        return trim(mb_strtoupper($withoutAccent));
437
    }
438
}
439