Passed
Push — master ( b8255a...26b0ba )
by Adrien
08:35
created

Importer::skipBOM()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 2
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 5
ccs 3
cts 3
cp 1
crap 2
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 15
    public function import(string $filename): array
50
    {
51 15
        $start = microtime(true);
52 15
        $this->connection = _em()->getConnection();
53 15
        $this->fetchReviews();
54 15
        $this->fetchCountries();
55 15
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
56 15
        $this->updatedUsers = 0;
57 15
        $this->updatedOrganizations = 0;
58 15
        $this->deletedOrganizations = 0;
59 15
        $this->seenEmails = [];
60 15
        $this->seenPatterns = [];
61
62 15
        if (!file_exists($filename)) {
63 1
            throw new Exception('File not found: ' . $filename);
64
        }
65
66 14
        $file = fopen($filename, 'rb');
67 14
        if ($file === false) {
68
            throw new Exception('Could not read file: ' . $filename);
69
        }
70
71 14
        $this->skipBOM($file);
72
73
        try {
74 14
            $this->connection->beginTransaction();
75 14
            $this->markToDelete();
76 14
            $this->read($file);
77 3
            $this->deleteOldOrganizations();
78
79
            // Give user automatic access via organization
80
            /** @var OrganizationRepository $organizationRepository */
81 3
            $organizationRepository = _em()->getRepository(Organization::class);
82 3
            $organizationRepository->applyOrganizationAccesses();
83
84 3
            $this->connection->commit();
85 11
        } catch (Throwable $exception) {
86 11
            $this->connection->rollBack();
87
88 11
            throw $exception;
89 3
        } finally {
90 14
            fclose($file);
91
        }
92
93 3
        $totalUsers = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM user');
94 3
        $totalOrganizations = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM organization');
95
96 3
        $time = round(microtime(true) - $start, 1);
97
98
        return [
99 3
            'updatedUsers' => $this->updatedUsers,
100 3
            'updatedOrganizations' => $this->updatedOrganizations,
101 3
            'deletedOrganizations' => $this->deletedOrganizations,
102 3
            'totalUsers' => $totalUsers,
103 3
            'totalOrganizations' => $totalOrganizations,
104 3
            'totalLines' => $this->lineNumber,
105 3
            'time' => $time,
106
        ];
107
    }
108
109 15
    private function fetchReviews(): void
110
    {
111 15
        $records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL');
112
113 15
        $this->reviewByNumber = [];
114 15
        foreach ($records as $r) {
115 15
            $this->reviewByNumber[$r['review_number']] = $r['id'];
116
        }
117 15
    }
118
119 15
    private function fetchCountries(): void
120
    {
121 15
        $records = $this->connection->fetchAll('SELECT id, name, UPPER(name) AS upper FROM country');
122
123 15
        $this->countryByName = [];
124 15
        foreach ($records as $r) {
125 15
            $this->countryByName[$r['upper']] = $r;
126
        }
127 15
    }
128
129
    /**
130
     * @param resource $file
131
     */
132 14
    private function skipBOM($file): void
133
    {
134
        // Consume BOM, but if not BOM, rewind to beginning
135 14
        if (fgets($file, 4) !== "\xEF\xBB\xBF") {
136 13
            rewind($file);
137
        }
138 14
    }
139
140
    /**
141
     * @param resource $file
142
     */
143 14
    private function read($file): void
144
    {
145 14
        $this->lineNumber = 0;
146 14
        $expectedColumnCount = 12;
147 14
        while ($line = fgetcsv($file)) {
148 14
            ++$this->lineNumber;
149
150 14
            $actualColumnCount = count($line);
151 14
            if ($actualColumnCount !== $expectedColumnCount) {
152 1
                $this->throw("Doit avoir exactement $expectedColumnCount colonnes, mais en a " . $actualColumnCount);
153
            }
154
155
            [
156 13
                $email,
157
                $pattern,
158
                $subscriptionType,
159
                $lastReviewNumber,
160
                $membership,
161
                $firstName,
162
                $lastName,
163
                $street,
164
                // $wtf,
165
                $postcode,
166
                $locality,
167
                $country,
168
                $phone,
169
            ] = $line;
170
171 13
            if (!$email && !$pattern) {
172 1
                $this->throw('Il faut soit un email, soit un pattern, mais aucun existe');
173
            }
174
175 12
            $lastReviewId = $this->readReviewId($lastReviewNumber);
176
177 10
            if ($email) {
178 8
                $this->assertEmail($email);
179 7
                $membership = $this->readMembership($membership);
180 6
                $country = $this->readCountryId($country);
181 5
                $subscriptionType = $this->readSubscriptionType($subscriptionType);
182
183 4
                $this->updateUser(
184 4
                    $email,
185
                    $subscriptionType,
186
                    $lastReviewId,
187
                    $membership,
188
                    $firstName,
189
                    $lastName,
190
                    $street,
191
                    $postcode,
192
                    $locality,
193
                    $country,
194
                    $phone
195
                );
196
            }
197
198 6
            if ($pattern) {
199 4
                $this->assertPattern($pattern);
200
201 3
                $this->updateOrganization(
202 3
                    $pattern,
203
                    $lastReviewId
204
                );
205
            }
206
        }
207 3
    }
208
209 8
    private function assertEmail(string $email): void
210
    {
211 8
        $validator = new EmailAddress();
212 8
        if (!$validator->isValid($email)) {
213 1
            $this->throw("Ce n'est pas une addresse email valide : " . $email);
214
        }
215
216 7
        if (array_key_exists($email, $this->seenEmails)) {
217 1
            $this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]);
218
        }
219
220 7
        $this->seenEmails[$email] = $this->lineNumber;
221 7
    }
222
223 4
    private function assertPattern(string $pattern): void
224
    {
225 4
        if (@preg_match('~' . $pattern . '~', '') === false) {
226 1
            $this->throw("Ce n'est pas une expression régulière valide : " . $pattern);
227
        }
228
229 3
        if (array_key_exists($pattern, $this->seenPatterns)) {
230 1
            $this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]);
231
        }
232
233 3
        $this->seenPatterns[$pattern] = $this->lineNumber;
234 3
    }
235
236 12
    private function readReviewId(string $reviewNumber): ?string
237
    {
238 12
        if (!$reviewNumber) {
239 8
            return null;
240
        }
241
242 5
        if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) {
243 1
            $this->throw('Un numéro de revue doit être entièrement numérique, mais est : ' . $reviewNumber);
244
        }
245
246 4
        $reviewNumberNumeric = (int) $reviewNumber;
247 4
        if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) {
248 1
            $this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber);
249
        }
250
251 3
        return $this->reviewByNumber[$reviewNumberNumeric];
252
    }
253
254 6
    private function readCountryId(string $country): ?string
255
    {
256 6
        if (!$country) {
257 4
            return null;
258
        }
259
260
        // Case insensitive match
261 3
        $upper = trim(mb_strtoupper($country));
262 3
        if (array_key_exists($upper, $this->countryByName)) {
263 2
            return $this->countryByName[$upper]['id'];
264
        }
265
266
        // Suggest our best guess, so user can fix their data without lookup up countries manually
267 1
        $best = 0;
268 1
        $bestGuess = 0;
269 1
        foreach ($this->countryByName as $r) {
270 1
            similar_text($upper, $r['upper'], $percent);
271 1
            if ($percent > $best) {
272 1
                $best = $percent;
273 1
                $bestGuess = $r;
274
            }
275
        }
276
277 1
        $this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?');
278
    }
279
280 11
    private function throw(string $message): void
281
    {
282 11
        throw new Exception('A la ligne ' . $this->lineNumber . ' : ' . $message);
283
    }
284
285 3
    private function deleteOldOrganizations(): void
286
    {
287 3
        $sql = 'DELETE FROM organization WHERE should_delete';
288 3
        $this->deletedOrganizations += $this->connection->executeUpdate($sql);
289 3
    }
290
291 7
    private function readMembership($membership): string
292
    {
293 7
        if ($membership === '' || $membership === 'Non membre') {
294 6
            return MembershipType::NONE;
295
        }
296
297 2
        if ($membership === 'Membre (cotisation pay&#233;e)') {
298 1
            return MembershipType::PAYED;
299
        }
300
301 2
        if ($membership === 'Membre (cotistaion due)') {
302 1
            return MembershipType::DUE;
303
        }
304
305 1
        $this->throw('Le membership aux artisans est invalide : ' . $membership);
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
306
    }
307
308 5
    private function readSubscriptionType(string $subscriptionType): ?string
309
    {
310 5
        if (!$subscriptionType) {
311 3
            return null;
312
        }
313
314 3
        if ($subscriptionType === 'Web') {
315 2
            return ProductTypeType::DIGITAL;
316
        }
317
318 2
        if ($subscriptionType === 'Papier') {
319 1
            return ProductTypeType::PAPER;
320
        }
321
322 1
        if ($subscriptionType === 'Papier&#47;web') {
323
            return ProductTypeType::BOTH;
324
        }
325
326 1
        $this->throw('Le subscriptionType est invalide : ' . $subscriptionType);
327
    }
328
329 4
    private function updateUser(...$args): void
330
    {
331 4
        $sql = 'INSERT INTO user (
332
                            email,
333
                            subscription_type,
334
                            subscription_last_review_id,
335
                            membership,
336
                            first_name,
337
                            last_name,
338
                            street,
339
                            postcode,
340
                            locality,
341
                            country_id,
342
                            phone,
343
                            web_temporary_access,
344
                            should_delete,
345
                            password,
346
                            creator_id,
347
                            creation_date
348
                        )
349
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
350
                        ON DUPLICATE KEY UPDATE
351
                            email = VALUES(email),
352
                            subscription_type = VALUES(subscription_type),
353
                            subscription_last_review_id = VALUES(subscription_last_review_id),
354
                            membership = VALUES(membership),
355
                            first_name = VALUES(first_name),
356
                            last_name = VALUES(last_name),
357
                            street = VALUES(street),
358
                            postcode = VALUES(postcode),
359
                            locality = VALUES(locality),
360
                            country_id = VALUES(country_id),
361
                            phone = VALUES(phone),
362
                            web_temporary_access = VALUES(web_temporary_access),
363
                            should_delete = VALUES(should_delete),
364
                            updater_id = VALUES(creator_id),
365
                            update_date = NOW()';
366
367 4
        $params = $args;
368 4
        $params[] = false; // web_temporary_access
369 4
        $params[] = false; // should_delete
370 4
        $params[] = ''; // password
371 4
        $params[] = $this->currentUser;
372
373 4
        $changed = $this->connection->executeUpdate($sql, $params);
374
375 4
        if ($changed) {
376 4
            ++$this->updatedUsers;
377
        }
378 4
    }
379
380 3
    private function updateOrganization(...$args): void
381
    {
382 3
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, creator_id, creation_date)
383
                        VALUES (?, ?, ?, NOW())
384
                        ON DUPLICATE KEY UPDATE
385
                        pattern = VALUES(pattern),
386
                        subscription_last_review_id = VALUES(subscription_last_review_id),
387
                        updater_id = VALUES(creator_id),
388
                        update_date = NOW()';
389
390 3
        $params = $args;
391 3
        $params[] = $this->currentUser;
392
393 3
        $changed = $this->connection->executeUpdate($sql, $params);
394
395 3
        if ($changed) {
396 3
            ++$this->updatedOrganizations;
397
        }
398 3
    }
399
400 14
    private function markToDelete(): void
401
    {
402 14
        $this->connection->executeUpdate('UPDATE user SET should_delete = 1');
403 14
        $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');
404 14
    }
405
}
406