Completed
Push — 2.x ( a34872...c77264 )
by Delete
03:15
created

Reader::isUpdateRequired()   A

Complexity

Conditions 4
Paths 12

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 17
rs 9.2
cc 4
eloc 12
nc 12
nop 0
1
<?php
2
namespace Crossjoin\Browscap\Parser\Sqlite;
3
4
use Crossjoin\Browscap\Exception\InvalidArgumentException;
5
use Crossjoin\Browscap\Exception\ParserConditionNotSatisfiedException;
6
use Crossjoin\Browscap\Exception\ParserRuntimeException;
7
use Crossjoin\Browscap\Exception\UnexpectedValueException;
8
use Crossjoin\Browscap\Parser\ReaderInterface;
9
use Crossjoin\Browscap\Parser\Sqlite\Adapter\AdapterFactory;
10
use Crossjoin\Browscap\Parser\Sqlite\Adapter\AdapterInterface;
11
use Crossjoin\Browscap\PropertyFilter\PropertyFilterTrait;
12
use Crossjoin\Browscap\Source\Ini\GetRegExpForPatternTrait;
13
use Crossjoin\Browscap\Type;
14
15
/**
16
 * Class Reader
17
 *
18
 * @package Crossjoin\Browscap\Parser\Sqlite
19
 * @author Christoph Ziegenberg <[email protected]>
20
 * @link https://github.com/crossjoin/browscap
21
 */
22
class Reader implements ReaderInterface
23
{
24
    use DataDirectoryTrait;
25
    use DataVersionHashTrait;
26
    use PropertyFilterTrait;
27
    use GetRegExpForPatternTrait;
28
    
29
    /**
30
     * @var AdapterInterface
31
     */
32
    protected $adapter;
33
34
    /**
35
     * @var string
36
     */
37
    protected $databaseFile;
38
39
    /**
40
     * @var int
41
     */
42
    protected $browserId;
43
44
    /**
45
     * @var int
46
     */
47
    protected $browserParentId;
48
49
    /**
50
     * @var array
51
     */
52
    protected $browserPatternKeywords;
53
54
    /**
55
     * @var string
56
     */
57
    protected $sqliteVersion;
58
    
59
    /**
60
     * Writer constructor.
61
     *
62
     * @param string $dataDirectory
63
     *
64
     * @throws InvalidArgumentException
65
     * @throws ParserConditionNotSatisfiedException
66
     */
67
    public function __construct($dataDirectory)
68
    {
69
        if (!is_string($dataDirectory)) {
70
            throw new InvalidArgumentException(
71
                "Invalid type '" . gettype($dataDirectory) . "' for argument 'dataDirectory'."
72
            );
73
        }
74
75
        $this->setDataDirectory($dataDirectory);
76
    }
77
78
    /**
79
     * @inheritdoc
80
     *
81
     * @throws InvalidArgumentException
82
     * @throws ParserConditionNotSatisfiedException
83
     * @throws ParserRuntimeException
84
     * @throws UnexpectedValueException
85
     */
86 View Code Duplication
    protected function getAdapter()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
87
    {
88
        if ($this->adapter === null) {
89
            $databaseFile = $this->getDatabasePath();
90
            $adapter = AdapterFactory::getInstance($databaseFile);
91
            $this->setAdapter($adapter);
92
        }
93
94
        return $this->adapter;
95
    }
96
97
    /**
98
     * @return string
99
     *
100
     * @throws InvalidArgumentException
101
     * @throws ParserRuntimeException
102
     */
103
    protected function getDatabasePath()
104
    {
105
        $databasePath = $this->getDataDirectory() . DIRECTORY_SEPARATOR . $this->getDatabaseFileName();
106
107
        if (!$this->isFileReadable($databasePath)) {
108
            if (!file_exists($databasePath)) {
109
                throw new ParserRuntimeException(
110
                    "Linked database file '$databasePath' not found. Parser data need to be generated.",
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $databasePath instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
111
                    1458898365
112
                );
113
            } else {
114
                throw new ParserRuntimeException("Linked database file '$databasePath' is not readable.", 1458898366);
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $databasePath instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
115
            }
116
        } elseif (!is_file($databasePath)) {
117
            throw new ParserRuntimeException("Invalid database file name '$databasePath' in link file.", 1458898367);
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $databasePath instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
118
        }
119
120
        return $databasePath;
121
    }
122
123
    /**
124
     * @return string
125
     *
126
     * @throws InvalidArgumentException
127
     * @throws ParserRuntimeException
128
     */
129
    protected function getDatabaseFileName()
130
    {
131
        // Get database to use, saved in the link file (as symlinks are not available or only
132
        // with admin permissions on Windows).
133
        $linkFile = $this->getDataDirectory() . DIRECTORY_SEPARATOR . Parser::LINK_FILENAME;
134
        if ($this->isFileReadable($linkFile)) {
135
            return (string)file_get_contents($linkFile);
136
        } elseif (!file_exists($linkFile)) {
137
            throw new ParserRuntimeException(
138
                "Database link file '$linkFile' not found. Parser data need to be generated.",
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $linkFile instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
139
                1458898368
140
            );
141
        } else {
142
            throw new ParserRuntimeException("Database link file '$linkFile' is not readable.", 1458898369);
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $linkFile instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
143
        }
144
    }
145
146
    /**
147
     * @param $file
148
     *
149
     * @return bool
150
     * @throws InvalidArgumentException
151
     */
152 View Code Duplication
    protected function isFileReadable($file)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
153
    {
154
        if (!is_string($file)) {
155
            throw new InvalidArgumentException(
156
                "Invalid type '" . gettype($file) . "' for argument 'file'."
157
            );
158
        }
159
        
160
        return is_readable($file);
161
    }
162
163
    /**
164
     * @param AdapterInterface $adapter
165
     */
166
    protected function setAdapter(AdapterInterface $adapter)
167
    {
168
        $this->adapter = $adapter;
169
    }
170
171
    /**
172
     * @inheritdoc
173
     *
174
     * @throws InvalidArgumentException
175
     * @throws UnexpectedValueException
176
     */
177
    public function isUpdateRequired()
178
    {
179
        try {
180
            $query = 'SELECT data_hash FROM info LIMIT 1';
181
            $dataHash = '';
182
            foreach ($this->getAdapter()->query($query) as $row) {
183
                $dataHash = $row['data_hash'];
184
            }
185
            $return = ($dataHash !== $this->getDataVersionHash());
186
        } catch (ParserConditionNotSatisfiedException $e) {
187
            $return = true;
188
        } catch (ParserRuntimeException $e) {
189
            $return = true;
190
        }
191
192
        return $return;
193
    }
194
195
    /**
196
     * @inheritdoc
197
     *
198
     * @throws InvalidArgumentException
199
     * @throws ParserConditionNotSatisfiedException
200
     * @throws ParserRuntimeException
201
     * @throws UnexpectedValueException
202
     */
203
    public function getReleaseTime()
204
    {
205
        $query = 'SELECT release_time FROM info LIMIT 1';
206
207
        $releaseTime = 0;
208
        foreach ($this->getAdapter()->query($query) as $row) {
209
            $releaseTime = (int)$row['release_time'];
210
        }
211
212
        return $releaseTime;
213
    }
214
215
    /**
216
     * @inheritdoc
217
     *
218
     * @throws InvalidArgumentException
219
     * @throws ParserConditionNotSatisfiedException
220
     * @throws ParserRuntimeException
221
     * @throws UnexpectedValueException
222
     */
223
    public function getVersion()
224
    {
225
        $query = 'SELECT version_id FROM info LIMIT 1';
226
227
        $versionId = 0;
228
        foreach ($this->getAdapter()->query($query) as $row) {
229
            $versionId = (int)$row['version_id'];
230
        }
231
232
        return $versionId;
233
    }
234
235
    /**
236
     * @inheritdoc
237
     *
238
     * @throws InvalidArgumentException
239
     * @throws ParserConditionNotSatisfiedException
240
     * @throws ParserRuntimeException
241
     * @throws UnexpectedValueException
242
     */
243
    public function getType()
244
    {
245
        $query = 'SELECT type_id FROM info LIMIT 1';
246
247
        $typeId = Type::UNKNOWN;
248
        foreach ($this->getAdapter()->query($query) as $row) {
249
            $typeId = (int)$row['type_id'];
250
        }
251
252
        return $typeId;
253
    }
254
255
    /**
256
     * @inheritdoc
257
     *
258
     * @throws InvalidArgumentException
259
     * @throws ParserConditionNotSatisfiedException
260
     * @throws ParserRuntimeException
261
     * @throws UnexpectedValueException
262
     */
263
    public function getBrowser($userAgent)
264
    {
265
        if (!is_string($userAgent)) {
266
            throw new InvalidArgumentException(
267
                "Invalid type '" . gettype($userAgent) . "' for argument 'userAgent'."
268
            );
269
        }
270
271
        // Init variables
272
        $browserId = $this->getBrowserId($userAgent);
273
        $browserParentId = $this->getBrowserParentId($userAgent);
274
275
        // Get all settings for the found browser
276
        //
277
        // It's best to use a recursive query here, but some linux distributions like CentOS and RHEL
278
        // use old sqlite library versions (like 3.6.20 or 3.7.17), so that we have to check the
279
        // version here and fall back to multiple single queries in case of older versions.
280
        if (version_compare($this->getSqliteVersion(), '3.8.3', '>=')) {
281
            $query  = 'SELECT t3.property_key, t4.property_value FROM (';
282
            $query .= 'WITH RECURSIVE browser_recursive(browser_id,browser_parent_id) AS (';
283
            $query .= 'SELECT browser_id, browser_parent_id FROM browser WHERE browser_id = :id';
284
            $query .= ' UNION ALL ';
285
            $query .= 'SELECT browser.browser_id, browser.browser_parent_id FROM browser, browser_recursive ';
286
            $query .= 'WHERE browser_recursive.browser_parent_id IS NOT NULL ';
287
            $query .= 'AND browser.browser_id = browser_recursive.browser_parent_id';
288
            $query .= ') ';
289
            $query .= 'SELECT MAX(t2.browser_id) AS browser_id, t2.property_key_id FROM browser_recursive t1 ';
290
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
291
            $query .= 'GROUP BY t2.property_key_id';
292
            $query .= ') t1 ';
293
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
294
            $query .= 'AND t2.property_key_id = t1.property_key_id ';
295
            $query .= 'JOIN browser_property_key t3 ON t3.property_key_id = t2.property_key_id ';
296
            $query .= 'JOIN browser_property_value t4 ON t4.property_value_id = t2.property_value_id';
297
            $query .= ' UNION ALL ';
298
            $query .= 'SELECT \'browser_name_pattern\' AS property_key, browser_pattern AS property_value ';
299
            $query .= 'FROM browser WHERE browser_id = :id';
300
            $query .= ' UNION ALL ';
301
            $query .= 'SELECT \'Parent\' AS property_key, browser_pattern AS property_value ';
302
            $query .= 'FROM browser WHERE browser_id = :parent';
303
            $statement = $this->getAdapter()->prepare($query);
304
        } else {
305
            $query = 'SELECT browser_id, browser_parent_id FROM browser WHERE browser_id = :id';
306
            $statement = $this->getAdapter()->prepare($query);
307
            $browserIds = [];
308
            $lastBrowserId = $browserId;
309
            while ($lastBrowserId !== null) {
310
                $result = $statement->execute(['id' => $lastBrowserId]);
311
312
                $lastBrowserId = null;
313
                if (count($result) > 0) {
314
                    $browserIds[] = (int)$result[0]['browser_id'];
315
                    if ($result[0]['browser_parent_id'] !== null) {
316
                        $lastBrowserId = (int)$result[0]['browser_parent_id'];
317
                    }
318
                }
319
            }
320
321
            $query  = 'SELECT t3.property_key, t4.property_value FROM (';
322
            $query .= 'SELECT MAX(browser_id) AS browser_id, property_key_id ';
323
            $query .= 'FROM browser_property WHERE browser_id IN (' . implode(', ', $browserIds) . ') ';
324
            $query .= 'GROUP BY property_key_id';
325
            $query .= ') t1 ';
326
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
327
            $query .= 'AND t2.property_key_id = t1.property_key_id ';
328
            $query .= 'JOIN browser_property_key t3 ON t3.property_key_id = t2.property_key_id ';
329
            $query .= 'JOIN browser_property_value t4 ON t4.property_value_id = t2.property_value_id';
330
            $query .= ' UNION ALL ';
331
            $query .= 'SELECT \'browser_name_pattern\' AS property_key, browser_pattern AS property_value ';
332
            $query .= 'FROM browser WHERE browser_id = :id';
333
            $query .= ' UNION ALL ';
334
            $query .= 'SELECT \'Parent\' AS property_key, browser_pattern AS property_value ';
335
            $query .= 'FROM browser WHERE browser_id = :parent';
336
            $statement = $this->getAdapter()->prepare($query);
337
        }
338
339
        $properties = [];
340
        foreach ($statement->execute(['id' => $browserId, 'parent' => $browserParentId]) as $row) {
341
            $properties[$row['property_key']] = $row['property_value'];
342
        }
343
344
        // Set regular expression properties
345
        $propertyFilter = $this->getPropertyFilter();
346
        if (!$propertyFilter->isFiltered('browser_name_regex')) {
347
            $properties['browser_name_regex'] = $this->getRegExpForPattern($properties['browser_name_pattern']);
348
        }
349
        if ($propertyFilter->isFiltered('browser_name_pattern')) {
350
            unset($properties['browser_name_pattern']);
351
        }
352
        if ($propertyFilter->isFiltered('Parent')) {
353
            unset($properties['Parent']);
354
        }
355
356
        // IMPORTANT: Reset browserId and browserParentId for next call
357
        $this->browserId = null;
358
        $this->browserParentId = null;
359
360
        // The settings are in random order, so sort them
361
        return $this->sortProperties($properties);
362
    }
363
364
    /**
365
     * @return string
366
     * @throws \Crossjoin\Browscap\Exception\UnexpectedValueException
367
     * @throws \Crossjoin\Browscap\Exception\ParserConditionNotSatisfiedException
368
     */
369
    protected function getSqliteVersion()
370
    {
371
        if ($this->sqliteVersion === null) {
372
373
            $this->sqliteVersion = '0.0.0';
374
375
            // Try to get the version number
376
            $query = 'SELECT sqlite_version() AS version';
377
            try {
378
                $result = $this->getAdapter()->query($query);
379
                if (count($result) > 0) {
380
                    $this->sqliteVersion = $result[0]['version'];
381
                }
382
            } catch (ParserRuntimeException $exception) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
383
            }
384
        }
385
386
        return $this->sqliteVersion;
387
    }
388
389
    /**
390
     * @param string $userAgent
391
     *
392
     * @return int
393
     * @throws InvalidArgumentException
394
     * @throws ParserConditionNotSatisfiedException
395
     * @throws ParserRuntimeException
396
     * @throws UnexpectedValueException
397
     */
398
    protected function getBrowserId($userAgent)
399
    {
400
        if ($this->browserId === null) {
401
            $this->findBrowser($userAgent);
402
        }
403
404
        return $this->browserId;
405
    }
406
407
    /**
408
     * @param string $userAgent
409
     *
410
     * @return int
411
     * @throws InvalidArgumentException
412
     * @throws ParserConditionNotSatisfiedException
413
     * @throws ParserRuntimeException
414
     * @throws UnexpectedValueException
415
     */
416
    protected function getBrowserParentId($userAgent)
417
    {
418
        if ($this->browserParentId === null) {
419
            $this->findBrowser($userAgent);
420
        }
421
422
        return $this->browserParentId;
423
    }
424
425
    /**
426
     * @param string $userAgent
427
     *
428
     * @throws InvalidArgumentException
429
     * @throws ParserConditionNotSatisfiedException
430
     * @throws ParserRuntimeException
431
     * @throws UnexpectedValueException
432
     */
433
    protected function findBrowser($userAgent)
434
    {
435
        if (!is_string($userAgent)) {
436
            throw new InvalidArgumentException(
437
                "Invalid type '" . gettype($userAgent) . "' for argument 'userAgent'."
438
            );
439
        }
440
441
        // Check each keyword table for the browser pattern
442
        $this->findBrowserInKeywordTables($userAgent);
443
444
        // If no match found in keyword tables, check the default table
445
        // (this also includes the '*' pattern for the default match).
446
        if ($this->browserId === null) {
447
            $this->findBrowserInDefaultTable($userAgent);
448
        }
449
450
        // Check if data found (the last step should always find the default settings)
451
        if ($this->browserId === null) {
452
            throw new ParserRuntimeException(
453
                "No result found for user agent '$userAgent'. There seems to be something wrong with the data."
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $userAgent instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
454
            );
455
        }
456
    }
457
458
    /**
459
     * @param string $userAgent
460
     * @throws InvalidArgumentException
461
     * @throws ParserConditionNotSatisfiedException
462
     * @throws ParserRuntimeException
463
     * @throws UnexpectedValueException
464
     */
465
    protected function findBrowserInKeywordTables($userAgent)
466
    {
467
        if (!is_string($userAgent)) {
468
            throw new InvalidArgumentException(
469
                "Invalid type '" . gettype($userAgent) . "' for argument 'userAgent'."
470
            );
471
        }
472
        
473
        $query  = 'SELECT browser_id, browser_pattern_length FROM "search_[keyword]" ';
474
        $query .= 'WHERE browser_pattern_length >= :length AND :agent GLOB browser_pattern';
475
476
        $userAgentLowered = strtolower($userAgent);
477
        $maxLength = 0;
478
        $browserId = null;
479
        foreach ($this->getPatternKeywords() as $patternKeyword) {
480
            if (strpos($userAgentLowered, $patternKeyword) !== false) {
481
                $statement = $this->getAdapter()->prepare(str_replace('[keyword]', $patternKeyword, $query));
482
483
                /** @noinspection PdoApiUsageInspection */
484
                foreach ($statement->execute(['length' => $maxLength, 'agent' => $userAgentLowered]) as $row) {
485
                    $tmpLength = (int)$row['browser_pattern_length'];
486
                    $tmpBrowserId = (int)$row['browser_id'];
487
488
                    if ($tmpLength < $maxLength) {
489
                        continue; // @codeCoverageIgnore
490
                    }
491
                    if ($tmpLength === $maxLength && $browserId !== null && $tmpBrowserId < $browserId) {
492
                        continue; // @codeCoverageIgnore
493
                    }
494
495
                    $browserId = (int)$row['browser_id'];
496
                    $maxLength = $tmpLength;
497
                }
498
            }
499
        }
500
501
        if ($browserId !== null) {
502
            $this->browserId = $browserId;
503
504
            $query = 'SELECT browser_parent_id FROM browser WHERE browser_id = :id';
505
            $statement = $this->getAdapter()->prepare($query);
506
            /** @noinspection PdoApiUsageInspection */
507
            foreach ($statement->execute(['id' => $browserId]) as $row) {
508
                $this->browserParentId = (int)$row['browser_parent_id'];
509
            }
510
        }
511
    }
512
513
    /**
514
     * @param string $userAgent
515
     *
516
     * @throws InvalidArgumentException
517
     * @throws ParserConditionNotSatisfiedException
518
     * @throws ParserRuntimeException
519
     * @throws UnexpectedValueException
520
     */
521
    protected function findBrowserInDefaultTable($userAgent)
522
    {
523
        if (!is_string($userAgent)) {
524
            throw new InvalidArgumentException(
525
                "Invalid type '" . gettype($userAgent) . "' for argument 'userAgent'."
526
            );
527
        }
528
        
529
        // Build query with default table
530
        $query  = 'SELECT t2.browser_id, t2.browser_parent_id FROM ';
531
        $query .= '(SELECT MIN(browser_id) AS browser_id FROM search WHERE :agent GLOB browser_pattern) t1 ';
532
        $query .= 'JOIN browser t2 ON t2.browser_id = t1.browser_id';
533
        $statement = $this->getAdapter()->prepare($query);
534
535
        /** @noinspection PdoApiUsageInspection */
536
        foreach ($statement->execute(['agent' => strtolower($userAgent)]) as $row) {
537
            $this->browserId = (int)$row['browser_id'];
538
            $this->browserParentId = (int)$row['browser_parent_id'];
539
        }
540
    }
541
542
    /**
543
     * @return array
544
     * @throws InvalidArgumentException
545
     * @throws ParserConditionNotSatisfiedException
546
     * @throws ParserRuntimeException
547
     * @throws UnexpectedValueException
548
     */
549
    protected function getPatternKeywords()
550
    {
551
        if ($this->browserPatternKeywords === null) {
552
            $this->browserPatternKeywords = [];
553
            $query = 'SELECT keyword_value FROM keyword ORDER BY keyword_id';
554
            foreach ($this->getAdapter()->query($query) as $row) {
555
                $this->browserPatternKeywords[] = $row['keyword_value'];
556
            }
557
        }
558
559
        return $this->browserPatternKeywords;
560
    }
561
562
    /**
563
     * @param array $properties
564
     *
565
     * @return array
566
     */
567
    protected function sortProperties(array $properties)
568
    {
569
        ksort($properties);
570
571
        return $properties;
572
    }
573
}
574