Failed Conditions
Push — master ( 798ab2...6213fb )
by Sam
09:34
created

Importer   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 373
Duplicated Lines 0 %

Test Coverage

Coverage 98.16%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 174
c 5
b 0
f 0
dl 0
loc 373
ccs 160
cts 163
cp 0.9816
rs 8.5599
wmc 48

16 Methods

Rating   Name   Duplication   Size   Complexity  
A fetchCountries() 0 7 2
A fetchReviews() 0 7 2
A skipBOM() 0 5 2
B import() 0 57 5
A updateOrganization() 0 17 2
A updateUser() 0 48 2
A readMembership() 0 7 2
A assertEmail() 0 12 3
B read() 0 65 7
A throw() 0 3 1
A assertPattern() 0 11 3
A readReviewId() 0 16 5
A readCountryId() 0 24 5
A markToDelete() 0 4 1
A deleteOldOrganizations() 0 4 1
A readSubscriptionType() 0 19 5

How to fix   Complexity   

Complex Class

Complex classes like Importer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Importer, and based on these observations, apply Extract Interface, too.

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 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 10
        } catch (Throwable $exception) {
86 10
            $this->connection->rollBack();
87
88 10
            throw $exception;
89 4
        } finally {
90 14
            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 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 12
            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 = 14;
147 14
        while ($line = fgetcsv($file, null, "\t")) {
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
            // un-escape all fields
156 13
            $line = array_map(fn ($r) => html_entity_decode($r), $line);
157
158
            [
159 13
                $email,
160
                $pattern,
161
                $subscriptionType,
162
                $lastReviewNumber,
163
                $ignored,
164
                $firstName,
165
                $lastName,
166
                $street,
167
                $street2,
168
                $postcode,
169
                $locality,
170
                $country,
171
                $phone,
172
                $membership
173
            ] = $line;
174
175 13
            if (!$email && !$pattern) {
176 1
                $this->throw('Il faut soit un email, soit un pattern, mais aucun existe');
177
            }
178
179 12
            $lastReviewId = $this->readReviewId($lastReviewNumber);
180
181 10
            if ($email) {
182 8
                $this->assertEmail($email);
183 7
                $membership = $this->readMembership($membership);
184 7
                $country = $this->readCountryId($country);
185 6
                $subscriptionType = $this->readSubscriptionType($subscriptionType);
186
187 5
                $this->updateUser(
188 5
                    $email,
189
                    $subscriptionType,
190
                    $lastReviewId,
191
                    $membership,
192
                    $firstName,
193
                    $lastName,
194 5
                    trim(implode(' ', [$street, $street2])),
195
                    $postcode,
196
                    $locality,
197
                    $country,
198
                    $phone
199
                );
200
            }
201
202 7
            if ($pattern) {
203 4
                $this->assertPattern($pattern);
204
205 3
                $this->updateOrganization(
206 3
                    $pattern,
207
                    $lastReviewId
208
                );
209
            }
210
        }
211 4
    }
212
213 8
    private function assertEmail(string $email): void
214
    {
215 8
        $validator = new EmailAddress();
216 8
        if (!$validator->isValid($email)) {
217 1
            $this->throw("Ce n'est pas une addresse email valide : " . $email);
218
        }
219
220 7
        if (array_key_exists($email, $this->seenEmails)) {
221 1
            $this->throw('L\'email "' . $email . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenEmails[$email]);
222
        }
223
224 7
        $this->seenEmails[$email] = $this->lineNumber;
225 7
    }
226
227 4
    private function assertPattern(string $pattern): void
228
    {
229 4
        if (@preg_match('~' . $pattern . '~', '') === false) {
230 1
            $this->throw("Ce n'est pas une expression régulière valide : " . $pattern);
231
        }
232
233 3
        if (array_key_exists($pattern, $this->seenPatterns)) {
234 1
            $this->throw('Le pattern "' . $pattern . '" est dupliqué et a déjà été vu à la ligne ' . $this->seenPatterns[$pattern]);
235
        }
236
237 3
        $this->seenPatterns[$pattern] = $this->lineNumber;
238 3
    }
239
240 12
    private function readReviewId(string $reviewNumber): ?string
241
    {
242 12
        if (!$reviewNumber) {
243 8
            return null;
244
        }
245
246 5
        if ($reviewNumber && !preg_match('~^\d+$~', $reviewNumber)) {
247 1
            $this->throw('Un numéro de revue doit être entièrement numérique, mais est : ' . $reviewNumber);
248
        }
249
250 4
        $reviewNumberNumeric = (int) $reviewNumber;
251 4
        if (!array_key_exists($reviewNumberNumeric, $this->reviewByNumber)) {
252 1
            $this->throw('Revue introuvable pour le numéro de revue : ' . $reviewNumber);
253
        }
254
255 3
        return $this->reviewByNumber[$reviewNumberNumeric];
256
    }
257
258 7
    private function readCountryId(string $country): ?string
259
    {
260 7
        if (!$country) {
261 5
            return null;
262
        }
263
264
        // Case insensitive match
265 3
        $upper = trim(mb_strtoupper($country));
266 3
        if (array_key_exists($upper, $this->countryByName)) {
267 2
            return $this->countryByName[$upper]['id'];
268
        }
269
270
        // Suggest our best guess, so user can fix their data without lookup up countries manually
271 1
        $best = 0;
272 1
        $bestGuess = 0;
273 1
        foreach ($this->countryByName as $r) {
274 1
            similar_text($upper, $r['upper'], $percent);
275 1
            if ($percent > $best) {
276 1
                $best = $percent;
277 1
                $bestGuess = $r;
278
            }
279
        }
280
281 1
        $this->throw('Pays "' . $country . '" introuvable. Vouliez-vous dire "' . $bestGuess['name'] . '" ?');
282
    }
283
284 10
    private function throw(string $message): void
285
    {
286 10
        throw new Exception('A la ligne ' . $this->lineNumber . ' : ' . $message);
287
    }
288
289 4
    private function deleteOldOrganizations(): void
290
    {
291 4
        $sql = 'DELETE FROM organization WHERE should_delete';
292 4
        $this->deletedOrganizations += $this->connection->executeUpdate($sql);
293 4
    }
294
295 7
    private function readMembership($membership): string
296
    {
297 7
        if ($membership === '1') {
298 1
            return MembershipType::MEMBER;
299
        }
300
301 7
        return MembershipType::NONE;
302
    }
303
304 6
    private function readSubscriptionType(string $subscriptionType): ?string
305
    {
306 6
        if (!$subscriptionType) {
307 4
            return null;
308
        }
309
310 3
        if ($subscriptionType === 'Web') {
311 2
            return ProductTypeType::DIGITAL;
312
        }
313
314 2
        if ($subscriptionType === 'Papier') {
315 1
            return ProductTypeType::PAPER;
316
        }
317
318 2
        if ($subscriptionType === 'Papier/web') {
319 1
            return ProductTypeType::BOTH;
320
        }
321
322 1
        $this->throw('Le subscriptionType est invalide : ' . $subscriptionType);
323
    }
324
325 5
    private function updateUser(...$args): void
326
    {
327 5
        $sql = 'INSERT INTO user (
328
                            email,
329
                            subscription_type,
330
                            subscription_last_review_id,
331
                            membership,
332
                            first_name,
333
                            last_name,
334
                            street,
335
                            postcode,
336
                            locality,
337
                            country_id,
338
                            phone,
339
                            web_temporary_access,
340
                            should_delete,
341
                            password,
342
                            creator_id,
343
                            creation_date
344
                        )
345
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
346
                        ON DUPLICATE KEY UPDATE
347
                            email = VALUES(email),
348
                            subscription_type = VALUES(subscription_type),
349
                            subscription_last_review_id = VALUES(subscription_last_review_id),
350
                            membership = VALUES(membership),
351
                            first_name = VALUES(first_name),
352
                            last_name = VALUES(last_name),
353
                            street = VALUES(street),
354
                            postcode = VALUES(postcode),
355
                            locality = VALUES(locality),
356
                            country_id = VALUES(country_id),
357
                            phone = VALUES(phone),
358
                            web_temporary_access = VALUES(web_temporary_access),
359
                            should_delete = VALUES(should_delete),
360
                            updater_id = VALUES(creator_id),
361
                            update_date = NOW()';
362
363 5
        $params = $args;
364 5
        $params[] = false; // web_temporary_access
365 5
        $params[] = false; // should_delete
366 5
        $params[] = ''; // password
367 5
        $params[] = $this->currentUser;
368
369 5
        $changed = $this->connection->executeUpdate($sql, $params);
370
371 5
        if ($changed) {
372 5
            ++$this->updatedUsers;
373
        }
374 5
    }
375
376 3
    private function updateOrganization(...$args): void
377
    {
378 3
        $sql = 'INSERT INTO organization (pattern, subscription_last_review_id, creator_id, creation_date)
379
                        VALUES (?, ?, ?, NOW())
380
                        ON DUPLICATE KEY UPDATE
381
                        pattern = VALUES(pattern),
382
                        subscription_last_review_id = VALUES(subscription_last_review_id),
383
                        updater_id = VALUES(creator_id),
384
                        update_date = NOW()';
385
386 3
        $params = $args;
387 3
        $params[] = $this->currentUser;
388
389 3
        $changed = $this->connection->executeUpdate($sql, $params);
390
391 3
        if ($changed) {
392 3
            ++$this->updatedOrganizations;
393
        }
394 3
    }
395
396 14
    private function markToDelete(): void
397
    {
398 14
        $this->connection->executeUpdate('UPDATE user SET should_delete = 1');
399 14
        $this->connection->executeUpdate('UPDATE organization SET should_delete = 1');
400 14
    }
401
}
402