Passed
Push — master ( a8b983...5b3176 )
by Adrien
10:48
created

Importer::import()   B

Complexity

Conditions 5
Paths 48

Size

Total Lines 57
Code Lines 40

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 38
CRAP Score 5.0004

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 40
nc 48
nop 1
dl 0
loc 57
ccs 38
cts 39
cp 0.9744
crap 5.0004
rs 8.9688
c 2
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 16
    public function import(string $filename): array
50
    {
51 16
        $start = microtime(true);
52 16
        $this->connection = _em()->getConnection();
53 16
        $this->fetchReviews();
54 16
        $this->fetchCountries();
55 16
        $this->currentUser = User::getCurrent() ? User::getCurrent()->getId() : null;
56 16
        $this->updatedUsers = 0;
57 16
        $this->updatedOrganizations = 0;
58 16
        $this->deletedOrganizations = 0;
59 16
        $this->seenEmails = [];
60 16
        $this->seenPatterns = [];
61
62 16
        if (!file_exists($filename)) {
63 1
            throw new Exception('File not found: ' . $filename);
64
        }
65
66 15
        $file = fopen($filename, 'rb');
67 15
        if ($file === false) {
68
            throw new Exception('Could not read file: ' . $filename);
69
        }
70
71 15
        $this->skipBOM($file);
72
73
        try {
74 15
            $this->connection->beginTransaction();
75 15
            $this->markToDelete();
76 15
            $this->read($file);
77 4
            $this->deleteOldOrganizations();
78
79
            // Give user automatic access via organization
80
            /** @var OrganizationRepository $organizationRepository */
81 4
            $organizationRepository = _em()->getRepository(Organization::class);
82 4
            $organizationRepository->applyOrganizationAccesses();
83
84 4
            $this->connection->commit();
85 11
        } catch (Throwable $exception) {
86 11
            $this->connection->rollBack();
87
88 11
            throw $exception;
89 4
        } finally {
90 15
            fclose($file);
91
        }
92
93 4
        $totalUsers = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM user');
94 4
        $totalOrganizations = (int) $this->connection->fetchColumn('SELECT COUNT(*) FROM organization');
95
96 4
        $time = round(microtime(true) - $start, 1);
97
98
        return [
99 4
            'updatedUsers' => $this->updatedUsers,
100 4
            'updatedOrganizations' => $this->updatedOrganizations,
101 4
            'deletedOrganizations' => $this->deletedOrganizations,
102 4
            'totalUsers' => $totalUsers,
103 4
            'totalOrganizations' => $totalOrganizations,
104 4
            'totalLines' => $this->lineNumber,
105 4
            'time' => $time,
106
        ];
107
    }
108
109 16
    private function fetchReviews(): void
110
    {
111 16
        $records = $this->connection->fetchAll('SELECT id, review_number FROM product WHERE review_number IS NOT NULL');
112
113 16
        $this->reviewByNumber = [];
114 16
        foreach ($records as $r) {
115 16
            $this->reviewByNumber[$r['review_number']] = $r['id'];
116
        }
117 16
    }
118
119 16
    private function fetchCountries(): void
120
    {
121 16
        $records = $this->connection->fetchAll('SELECT id, name, UPPER(name) AS upper FROM country');
122
123 16
        $this->countryByName = [];
124 16
        foreach ($records as $r) {
125 16
            $this->countryByName[$r['upper']] = $r;
126
        }
127 16
    }
128
129
    /**
130
     * @param resource $file
131
     */
132 15
    private function skipBOM($file): void
133
    {
134
        // Consume BOM, but if not BOM, rewind to beginning
135 15
        if (fgets($file, 4) !== "\xEF\xBB\xBF") {
136 13
            rewind($file);
137
        }
138 15
    }
139
140
    /**
141
     * @param resource $file
142
     */
143 15
    private function read($file): void
144
    {
145 15
        $this->lineNumber = 0;
146 15
        $expectedColumnCount = 12;
147 15
        while ($line = fgetcsv($file)) {
148 15
            ++$this->lineNumber;
149
150 15
            $actualColumnCount = count($line);
151 15
            if ($actualColumnCount !== $expectedColumnCount) {
152 1
                $this->throw("Doit avoir exactement $expectedColumnCount colonnes, mais en a " . $actualColumnCount);
153
            }
154
155
            // un-escape all fields
156 14
            $line = array_map(fn ($r) => html_entity_decode($r), $line);
157
158
            [
159 14
                $email,
160
                $pattern,
161
                $subscriptionType,
162
                $lastReviewNumber,
163
                $membership,
164
                $firstName,
165
                $lastName,
166
                $street,
167
                // $wtf,
168
                $postcode,
169
                $locality,
170
                $country,
171
                $phone,
172
            ] = $line;
173
174 14
            if (!$email && !$pattern) {
175 1
                $this->throw('Il faut soit un email, soit un pattern, mais aucun existe');
176
            }
177
178 13
            $lastReviewId = $this->readReviewId($lastReviewNumber);
179
180 11
            if ($email) {
181 9
                $this->assertEmail($email);
182 8
                $membership = $this->readMembership($membership);
183 7
                $country = $this->readCountryId($country);
184 6
                $subscriptionType = $this->readSubscriptionType($subscriptionType);
185
186 5
                $this->updateUser(
187 5
                    $email,
188
                    $subscriptionType,
189
                    $lastReviewId,
190
                    $membership,
191
                    $firstName,
192
                    $lastName,
193
                    $street,
194
                    $postcode,
195
                    $locality,
196
                    $country,
197
                    $phone
198
                );
199
            }
200
201 7
            if ($pattern) {
202 4
                $this->assertPattern($pattern);
203
204 3
                $this->updateOrganization(
205 3
                    $pattern,
206
                    $lastReviewId
207
                );
208
            }
209
        }
210 4
    }
211
212 9
    private function assertEmail(string $email): void
213
    {
214 9
        $validator = new EmailAddress();
215 9
        if (!$validator->isValid($email)) {
216 1
            $this->throw("Ce n'est pas une addresse email valide : " . $email);
217
        }
218
219 8
        if (array_key_exists($email, $this->seenEmails)) {
220 1
            $this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]);
221
        }
222
223 8
        $this->seenEmails[$email] = $this->lineNumber;
224 8
    }
225
226 4
    private function assertPattern(string $pattern): void
227
    {
228 4
        if (@preg_match('~' . $pattern . '~', '') === false) {
229 1
            $this->throw("Ce n'est pas une expression régulière valide : " . $pattern);
230
        }
231
232 3
        if (array_key_exists($pattern, $this->seenPatterns)) {
233 1
            $this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]);
234
        }
235
236 3
        $this->seenPatterns[$pattern] = $this->lineNumber;
237 3
    }
238
239 13
    private function readReviewId(string $reviewNumber): ?string
240
    {
241 13
        if (!$reviewNumber) {
242 9
            return null;
243
        }
244
245 5
        if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) {
246 1
            $this->throw('Un numéro de revue doit être entièrement numérique, mais est : ' . $reviewNumber);
247
        }
248
249 4
        $reviewNumberNumeric = (int) $reviewNumber;
250 4
        if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) {
251 1
            $this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber);
252
        }
253
254 3
        return $this->reviewByNumber[$reviewNumberNumeric];
255
    }
256
257 7
    private function readCountryId(string $country): ?string
258
    {
259 7
        if (!$country) {
260 5
            return null;
261
        }
262
263
        // Case insensitive match
264 3
        $upper = trim(mb_strtoupper($country));
265 3
        if (array_key_exists($upper, $this->countryByName)) {
266 2
            return $this->countryByName[$upper]['id'];
267
        }
268
269
        // Suggest our best guess, so user can fix their data without lookup up countries manually
270 1
        $best = 0;
271 1
        $bestGuess = 0;
272 1
        foreach ($this->countryByName as $r) {
273 1
            similar_text($upper, $r['upper'], $percent);
274 1
            if ($percent > $best) {
275 1
                $best = $percent;
276 1
                $bestGuess = $r;
277
            }
278
        }
279
280 1
        $this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?');
281
    }
282
283 11
    private function throw(string $message): void
284
    {
285 11
        throw new Exception('A la ligne ' . $this->lineNumber . ' : ' . $message);
286
    }
287
288 4
    private function deleteOldOrganizations(): void
289
    {
290 4
        $sql = 'DELETE FROM organization WHERE should_delete';
291 4
        $this->deletedOrganizations += $this->connection->executeUpdate($sql);
292 4
    }
293
294 8
    private function readMembership($membership): string
295
    {
296 8
        if ($membership === '' || $membership === 'Non membre') {
297 7
            return MembershipType::NONE;
298
        }
299
300 2
        if ($membership === 'Membre (cotisation payée)') {
301 1
            return MembershipType::PAYED;
302
        }
303
304 2
        if ($membership === 'Membre (cotistaion due)') {
305 1
            return MembershipType::DUE;
306
        }
307
308 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...
309
    }
310
311 6
    private function readSubscriptionType(string $subscriptionType): ?string
312
    {
313 6
        if (!$subscriptionType) {
314 4
            return null;
315
        }
316
317 3
        if ($subscriptionType === 'Web') {
318 2
            return ProductTypeType::DIGITAL;
319
        }
320
321 2
        if ($subscriptionType === 'Papier') {
322 1
            return ProductTypeType::PAPER;
323
        }
324
325 2
        if ($subscriptionType === 'Papier/web') {
326 1
            return ProductTypeType::BOTH;
327
        }
328
329 1
        $this->throw('Le subscriptionType est invalide : ' . $subscriptionType);
330
    }
331
332 5
    private function updateUser(...$args): void
333
    {
334 5
        $sql = 'INSERT INTO user (
335
                            email,
336
                            subscription_type,
337
                            subscription_last_review_id,
338
                            membership,
339
                            first_name,
340
                            last_name,
341
                            street,
342
                            postcode,
343
                            locality,
344
                            country_id,
345
                            phone,
346
                            web_temporary_access,
347
                            should_delete,
348
                            password,
349
                            creator_id,
350
                            creation_date
351
                        )
352
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
353
                        ON DUPLICATE KEY UPDATE
354
                            email = VALUES(email),
355
                            subscription_type = VALUES(subscription_type),
356
                            subscription_last_review_id = VALUES(subscription_last_review_id),
357
                            membership = VALUES(membership),
358
                            first_name = VALUES(first_name),
359
                            last_name = VALUES(last_name),
360
                            street = VALUES(street),
361
                            postcode = VALUES(postcode),
362
                            locality = VALUES(locality),
363
                            country_id = VALUES(country_id),
364
                            phone = VALUES(phone),
365
                            web_temporary_access = VALUES(web_temporary_access),
366
                            should_delete = VALUES(should_delete),
367
                            updater_id = VALUES(creator_id),
368
                            update_date = NOW()';
369
370 5
        $params = $args;
371 5
        $params[] = false; // web_temporary_access
372 5
        $params[] = false; // should_delete
373 5
        $params[] = ''; // password
374 5
        $params[] = $this->currentUser;
375
376 5
        $changed = $this->connection->executeUpdate($sql, $params);
377
378 5
        if ($changed) {
379 5
            ++$this->updatedUsers;
380
        }
381 5
    }
382
383 3
    private function updateOrganization(...$args): void
384
    {
385 3
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, creator_id, creation_date)
386
                        VALUES (?, ?, ?, NOW())
387
                        ON DUPLICATE KEY UPDATE
388
                        pattern = VALUES(pattern),
389
                        subscription_last_review_id = VALUES(subscription_last_review_id),
390
                        updater_id = VALUES(creator_id),
391
                        update_date = NOW()';
392
393 3
        $params = $args;
394 3
        $params[] = $this->currentUser;
395
396 3
        $changed = $this->connection->executeUpdate($sql, $params);
397
398 3
        if ($changed) {
399 3
            ++$this->updatedOrganizations;
400
        }
401 3
    }
402
403 15
    private function markToDelete(): void
404
    {
405 15
        $this->connection->executeUpdate('UPDATE user SET should_delete = 1');
406 15
        $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');
407 15
    }
408
}
409