Reader::setAdapter()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
nop 1
1
<?php
2
declare(strict_types=1);
3
4
namespace Crossjoin\Browscap\Parser\Sqlite;
5
6
use Crossjoin\Browscap\Exception\ParserConditionNotSatisfiedException;
7
use Crossjoin\Browscap\Exception\ParserRuntimeException;
8
use Crossjoin\Browscap\Exception\UnexpectedValueException;
9
use Crossjoin\Browscap\Parser\ReaderInterface;
10
use Crossjoin\Browscap\Parser\Sqlite\Adapter\AdapterFactory;
11
use Crossjoin\Browscap\Parser\Sqlite\Adapter\AdapterInterface;
12
use Crossjoin\Browscap\PropertyFilter\PropertyFilterTrait;
13
use Crossjoin\Browscap\Source\Ini\GetRegExpForPatternTrait;
14
use Crossjoin\Browscap\Type;
15
16
/**
17
 * Class Reader
18
 *
19
 * @package Crossjoin\Browscap\Parser\Sqlite
20
 * @author Christoph Ziegenberg <[email protected]>
21
 * @link https://github.com/crossjoin/browscap
22
 */
23
class Reader implements ReaderInterface
24
{
25
    use DataDirectoryTrait;
26
    use DataVersionHashTrait;
27
    use PropertyFilterTrait;
28
    use GetRegExpForPatternTrait;
29
    
30
    /**
31
     * @var AdapterInterface
32
     */
33
    protected $adapter;
34
35
    /**
36
     * @var string
37
     */
38
    protected $databaseFile;
39
40
    /**
41
     * @var int
42
     */
43
    protected $browserId;
44
45
    /**
46
     * @var int
47
     */
48
    protected $browserParentId;
49
50
    /**
51
     * @var array
52
     */
53
    protected $browserPatternKeywords;
54
55
    /**
56
     * @var string
57
     */
58
    protected $sqliteVersion;
59
    
60
    /**
61
     * Writer constructor.
62
     *
63
     * @param string $dataDirectory
64
     *
65
     * @throws ParserConditionNotSatisfiedException
66
     */
67
    public function __construct(string $dataDirectory)
68
    {
69
        $this->setDataDirectory($dataDirectory);
70
    }
71
72
    /**
73
     * @inheritdoc
74
     *
75
     * @throws ParserConditionNotSatisfiedException
76
     * @throws ParserRuntimeException
77
     * @throws UnexpectedValueException
78
     */
79 View Code Duplication
    protected function getAdapter() : AdapterInterface
80
    {
81
        if ($this->adapter === null) {
82
            $databaseFile = $this->getDatabasePath();
83
            $adapter = AdapterFactory::getInstance($databaseFile);
84
            $this->setAdapter($adapter);
85
        }
86
87
        return $this->adapter;
88
    }
89
90
    /**
91
     * @return string
92
     *
93
     * @throws ParserRuntimeException
94
     */
95
    protected function getDatabasePath() : string
96
    {
97
        $databasePath = $this->getDataDirectory() . DIRECTORY_SEPARATOR . $this->getDatabaseFileName();
98
99
        if (!$this->isFileReadable($databasePath)) {
100
            if (!file_exists($databasePath)) {
101
                throw new ParserRuntimeException(
102
                    "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...
103
                    1458898365
104
                );
105
            } else {
106
                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...
107
            }
108
        } elseif (!is_file($databasePath)) {
109
            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...
110
        }
111
112
        return $databasePath;
113
    }
114
115
    /**
116
     * @return string
117
     *
118
     * @throws ParserRuntimeException
119
     */
120
    protected function getDatabaseFileName() : string
121
    {
122
        // Get database to use, saved in the link file (as symlinks are not available or only
123
        // with admin permissions on Windows).
124
        $linkFile = $this->getDataDirectory() . DIRECTORY_SEPARATOR . Parser::LINK_FILENAME;
125
        if ($this->isFileReadable($linkFile)) {
126
            return (string)file_get_contents($linkFile);
127
        } elseif (!file_exists($linkFile)) {
128
            throw new ParserRuntimeException(
129
                "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...
130
                1458898368
131
            );
132
        } else {
133
            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...
134
        }
135
    }
136
137
    /**
138
     * @param $file
139
     *
140
     * @return bool
141
     */
142
    protected function isFileReadable(string $file) : bool
143
    {
144
        return is_readable($file);
145
    }
146
147
    /**
148
     * @param AdapterInterface $adapter
149
     */
150
    protected function setAdapter(AdapterInterface $adapter)
151
    {
152
        $this->adapter = $adapter;
153
    }
154
155
    /**
156
     * @inheritdoc
157
     *
158
     * @throws UnexpectedValueException
159
     */
160
    public function isUpdateRequired() : bool
161
    {
162
        try {
163
            $query = 'SELECT data_hash FROM info LIMIT 1';
164
            $dataHash = '';
165
            foreach ($this->getAdapter()->query($query) as $row) {
166
                $dataHash = $row['data_hash'];
167
            }
168
            $return = ($dataHash !== $this->getDataVersionHash());
169
        } catch (ParserConditionNotSatisfiedException $e) {
170
            $return = true;
171
        } catch (ParserRuntimeException $e) {
172
            $return = true;
173
        }
174
175
        return $return;
176
    }
177
178
    /**
179
     * @inheritdoc
180
     *
181
     * @throws ParserConditionNotSatisfiedException
182
     * @throws ParserRuntimeException
183
     * @throws UnexpectedValueException
184
     */
185
    public function getReleaseTime() : int
186
    {
187
        $query = 'SELECT release_time FROM info LIMIT 1';
188
189
        $releaseTime = 0;
190
        foreach ($this->getAdapter()->query($query) as $row) {
191
            $releaseTime = (int)$row['release_time'];
192
        }
193
194
        return $releaseTime;
195
    }
196
197
    /**
198
     * @inheritdoc
199
     *
200
     * @throws ParserConditionNotSatisfiedException
201
     * @throws ParserRuntimeException
202
     * @throws UnexpectedValueException
203
     */
204
    public function getVersion() : int
205
    {
206
        $query = 'SELECT version_id FROM info LIMIT 1';
207
208
        $versionId = 0;
209
        foreach ($this->getAdapter()->query($query) as $row) {
210
            $versionId = (int)$row['version_id'];
211
        }
212
213
        return $versionId;
214
    }
215
216
    /**
217
     * @inheritdoc
218
     *
219
     * @throws ParserConditionNotSatisfiedException
220
     * @throws ParserRuntimeException
221
     * @throws UnexpectedValueException
222
     */
223
    public function getType() : int
224
    {
225
        $query = 'SELECT type_id FROM info LIMIT 1';
226
227
        $typeId = Type::UNKNOWN;
228
        foreach ($this->getAdapter()->query($query) as $row) {
229
            $typeId = (int)$row['type_id'];
230
        }
231
232
        return $typeId;
233
    }
234
235
    /**
236
     * @inheritdoc
237
     *
238
     * @throws ParserConditionNotSatisfiedException
239
     * @throws ParserRuntimeException
240
     * @throws UnexpectedValueException
241
     */
242
    public function getBrowser(string $userAgent) : array
243
    {
244
        // Init variables
245
        $browserId = $this->getBrowserId($userAgent);
246
        $browserParentId = $this->getBrowserParentId($userAgent);
247
248
        // Get all settings for the found browser
249
        //
250
        // It's best to use a recursive query here, but some linux distributions like CentOS and RHEL
251
        // use old sqlite library versions (like 3.6.20 or 3.7.17), so that we have to check the
252
        // version here and fall back to multiple single queries in case of older versions.
253
        if (version_compare($this->getSqliteVersion(), '3.8.3', '>=')) {
254
            $query  = 'SELECT t3.property_key, t4.property_value FROM (';
255
            $query .= 'WITH RECURSIVE browser_recursive(browser_id,browser_parent_id) AS (';
256
            $query .= 'SELECT browser_id, browser_parent_id FROM browser WHERE browser_id = :id';
257
            $query .= ' UNION ALL ';
258
            $query .= 'SELECT browser.browser_id, browser.browser_parent_id FROM browser, browser_recursive ';
259
            $query .= 'WHERE browser_recursive.browser_parent_id IS NOT NULL ';
260
            $query .= 'AND browser.browser_id = browser_recursive.browser_parent_id';
261
            $query .= ') ';
262
            $query .= 'SELECT MAX(t2.browser_id) AS browser_id, t2.property_key_id FROM browser_recursive t1 ';
263
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
264
            $query .= 'GROUP BY t2.property_key_id';
265
            $query .= ') t1 ';
266
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
267
            $query .= 'AND t2.property_key_id = t1.property_key_id ';
268
            $query .= 'JOIN browser_property_key t3 ON t3.property_key_id = t2.property_key_id ';
269
            $query .= 'JOIN browser_property_value t4 ON t4.property_value_id = t2.property_value_id';
270
            $query .= ' UNION ALL ';
271
            $query .= 'SELECT \'browser_name_pattern\' AS property_key, browser_pattern AS property_value ';
272
            $query .= 'FROM browser WHERE browser_id = :id';
273
            $query .= ' UNION ALL ';
274
            $query .= 'SELECT \'Parent\' AS property_key, browser_pattern AS property_value ';
275
            $query .= 'FROM browser WHERE browser_id = :parent';
276
            $statement = $this->getAdapter()->prepare($query);
277
        } else {
278
            $query = 'SELECT browser_id, browser_parent_id FROM browser WHERE browser_id = :id';
279
            $statement = $this->getAdapter()->prepare($query);
280
            $browserIds = [];
281
            $lastBrowserId = $browserId;
282
            while ($lastBrowserId !== null) {
283
                $result = $statement->execute(['id' => $lastBrowserId]);
284
285
                $lastBrowserId = null;
286
                if (count($result) > 0) {
287
                    $browserIds[] = (int)$result[0]['browser_id'];
288
                    if ($result[0]['browser_parent_id'] !== null) {
289
                        $lastBrowserId = (int)$result[0]['browser_parent_id'];
290
                    }
291
                }
292
            }
293
294
            $query  = 'SELECT t3.property_key, t4.property_value FROM (';
295
            $query .= 'SELECT MAX(browser_id) AS browser_id, property_key_id ';
296
            $query .= 'FROM browser_property WHERE browser_id IN (' . implode(', ', $browserIds) . ') ';
297
            $query .= 'GROUP BY property_key_id';
298
            $query .= ') t1 ';
299
            $query .= 'JOIN browser_property t2 ON t2.browser_id = t1.browser_id ';
300
            $query .= 'AND t2.property_key_id = t1.property_key_id ';
301
            $query .= 'JOIN browser_property_key t3 ON t3.property_key_id = t2.property_key_id ';
302
            $query .= 'JOIN browser_property_value t4 ON t4.property_value_id = t2.property_value_id';
303
            $query .= ' UNION ALL ';
304
            $query .= 'SELECT \'browser_name_pattern\' AS property_key, browser_pattern AS property_value ';
305
            $query .= 'FROM browser WHERE browser_id = :id';
306
            $query .= ' UNION ALL ';
307
            $query .= 'SELECT \'Parent\' AS property_key, browser_pattern AS property_value ';
308
            $query .= 'FROM browser WHERE browser_id = :parent';
309
            $statement = $this->getAdapter()->prepare($query);
310
        }
311
312
        $properties = [];
313
        foreach ($statement->execute(['id' => $browserId, 'parent' => $browserParentId]) as $row) {
314
            $properties[$row['property_key']] = $row['property_value'];
315
        }
316
317
        // Set regular expression properties
318
        $propertyFilter = $this->getPropertyFilter();
319
        if (!$propertyFilter->isFiltered('browser_name_regex')) {
320
            $properties['browser_name_regex'] = $this->getRegExpForPattern($properties['browser_name_pattern']);
321
        }
322
        if ($propertyFilter->isFiltered('browser_name_pattern')) {
323
            unset($properties['browser_name_pattern']);
324
        }
325
        if ($propertyFilter->isFiltered('Parent')) {
326
            unset($properties['Parent']);
327
        }
328
329
        // IMPORTANT: Reset browserId and browserParentId for next call
330
        $this->browserId = null;
331
        $this->browserParentId = null;
332
333
        // The settings are in random order, so sort them
334
        return $this->sortProperties($properties);
335
    }
336
337
    /**
338
     * @return string
339
     * @throws \Crossjoin\Browscap\Exception\UnexpectedValueException
340
     * @throws \Crossjoin\Browscap\Exception\ParserConditionNotSatisfiedException
341
     */
342
    protected function getSqliteVersion(): string
343
    {
344
        if ($this->sqliteVersion === null) {
345
346
            $this->sqliteVersion = '0.0.0';
347
348
            // Try to get the version number
349
            $query = 'SELECT sqlite_version() AS version';
350
            try {
351
                $result = $this->getAdapter()->query($query);
352
                if (count($result) > 0) {
353
                    $this->sqliteVersion = $result[0]['version'];
354
                }
355
            } catch (ParserRuntimeException $exception) {
356
                // Use default if version could not be detected
357
            }
358
        }
359
360
        return $this->sqliteVersion;
361
    }
362
363
    /**
364
     * @param string $userAgent
365
     *
366
     * @return int
367
     * @throws ParserConditionNotSatisfiedException
368
     * @throws ParserRuntimeException
369
     * @throws UnexpectedValueException
370
     */
371
    protected function getBrowserId(string $userAgent) : int
372
    {
373
        if ($this->browserId === null) {
374
            $this->findBrowser($userAgent);
375
        }
376
377
        return $this->browserId;
378
    }
379
380
    /**
381
     * @param string $userAgent
382
     *
383
     * @return int
384
     * @throws ParserConditionNotSatisfiedException
385
     * @throws ParserRuntimeException
386
     * @throws UnexpectedValueException
387
     */
388
    protected function getBrowserParentId(string $userAgent) : int
389
    {
390
        if ($this->browserParentId === null) {
391
            $this->findBrowser($userAgent);
392
        }
393
394
        return $this->browserParentId;
395
    }
396
397
    /**
398
     * @param string $userAgent
399
     *
400
     * @throws ParserConditionNotSatisfiedException
401
     * @throws ParserRuntimeException
402
     * @throws UnexpectedValueException
403
     */
404
    protected function findBrowser(string $userAgent)
405
    {
406
        // Check each keyword table for the browser pattern
407
        $this->findBrowserInKeywordTables($userAgent);
408
409
        // If no match found in keyword tables, check the default table
410
        // (this also includes the '*' pattern for the default match).
411
        if ($this->browserId === null) {
412
            $this->findBrowserInDefaultTable($userAgent);
413
        }
414
415
        // Check if data found (the last step should always find the default settings)
416
        if ($this->browserId === null) {
417
            throw new ParserRuntimeException(
418
                "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...
419
            );
420
        }
421
    }
422
423
    /**
424
     * @param string $userAgent
425
     * @throws ParserConditionNotSatisfiedException
426
     * @throws ParserRuntimeException
427
     * @throws UnexpectedValueException
428
     */
429
    protected function findBrowserInKeywordTables(string $userAgent)
430
    {
431
        $query  = 'SELECT browser_id, browser_pattern_length FROM "search_[keyword]" ';
432
        $query .= 'WHERE browser_pattern_length >= :length AND :agent GLOB browser_pattern';
433
434
        $userAgentLowered = strtolower($userAgent);
435
        $maxLength = 0;
436
        $browserId = null;
437
        foreach ($this->getPatternKeywords() as $patternKeyword) {
438
            if (strpos($userAgentLowered, $patternKeyword) !== false) {
439
                $statement = $this->getAdapter()->prepare(str_replace('[keyword]', $patternKeyword, $query));
440
441
                /** @noinspection PdoApiUsageInspection */
442
                foreach ($statement->execute(['length' => $maxLength, 'agent' => $userAgentLowered]) as $row) {
443
                    $tmpLength = (int)$row['browser_pattern_length'];
444
                    $tmpBrowserId = (int)$row['browser_id'];
445
446
                    if ($tmpLength < $maxLength) {
447
                        continue; // @codeCoverageIgnore
448
                    }
449
                    if ($tmpLength === $maxLength && $browserId !== null && $tmpBrowserId < $browserId) {
450
                        continue; // @codeCoverageIgnore
451
                    }
452
453
                    $browserId = (int)$row['browser_id'];
454
                    $maxLength = $tmpLength;
455
                }
456
            }
457
        }
458
459
        if ($browserId !== null) {
460
            $this->browserId = $browserId;
461
462
            $query = 'SELECT browser_parent_id FROM browser WHERE browser_id = :id';
463
            $statement = $this->getAdapter()->prepare($query);
464
            /** @noinspection PdoApiUsageInspection */
465
            foreach ($statement->execute(['id' => $browserId]) as $row) {
466
                $this->browserParentId = (int)$row['browser_parent_id'];
467
            }
468
        }
469
    }
470
471
    /**
472
     * @param string $userAgent
473
     *
474
     * @throws ParserConditionNotSatisfiedException
475
     * @throws ParserRuntimeException
476
     * @throws UnexpectedValueException
477
     */
478
    protected function findBrowserInDefaultTable(string $userAgent)
479
    {
480
        // Build query with default table
481
        $query  = 'SELECT t2.browser_id, t2.browser_parent_id FROM ';
482
        $query .= '(';
483
        $query .= 'SELECT browser_id ';
484
        $query .= 'FROM search ';
485
        $query .= 'WHERE :agent GLOB browser_pattern ';
486
        $query .= 'ORDER BY browser_pattern_length DESC, browser_id ';
487
        $query .= 'LIMIT 1';
488
        $query .= ') t1 ';
489
        $query .= 'JOIN browser t2 ON t2.browser_id = t1.browser_id';
490
        $statement = $this->getAdapter()->prepare($query);
491
492
        /** @noinspection PdoApiUsageInspection */
493
        foreach ($statement->execute(['agent' => strtolower($userAgent)]) as $row) {
494
            $this->browserId = (int)$row['browser_id'];
495
            $this->browserParentId = (int)$row['browser_parent_id'];
496
        }
497
    }
498
499
    /**
500
     * @return array
501
     * @throws ParserConditionNotSatisfiedException
502
     * @throws ParserRuntimeException
503
     * @throws UnexpectedValueException
504
     */
505
    protected function getPatternKeywords() : array
506
    {
507
        if ($this->browserPatternKeywords === null) {
508
            $this->browserPatternKeywords = [];
509
            $query = 'SELECT keyword_value FROM keyword ORDER BY keyword_id';
510
            foreach ($this->getAdapter()->query($query) as $row) {
511
                $this->browserPatternKeywords[] = $row['keyword_value'];
512
            }
513
        }
514
515
        return $this->browserPatternKeywords;
516
    }
517
518
    /**
519
     * @param array $properties
520
     *
521
     * @return array
522
     */
523
    protected function sortProperties(array $properties) : array
524
    {
525
        ksort($properties);
526
527
        return $properties;
528
    }
529
}
530