Passed
Pull Request — master (#104)
by
unknown
12:28
created

QueryDetector   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 218
Duplicated Lines 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
wmc 36
eloc 90
c 7
b 0
f 0
dl 0
loc 218
rs 9.52

13 Methods

Rating   Name   Duplication   Size   Complexity  
A boot() 0 19 3
A getOutputTypes() 0 9 2
A fileIsInExcludedPath() 0 16 3
A applyOutput() 0 4 2
A normalizeFilename() 0 7 2
A getDetectedQueries() 0 22 5
A resetQueries() 0 3 1
A parseTrace() 0 18 5
A __construct() 0 3 1
B logQuery() 0 43 6
A isEnabled() 0 9 2
A output() 0 7 2
A findSource() 0 9 2
1
<?php
2
3
namespace BeyondCode\QueryDetector;
4
5
use Illuminate\Support\Facades\DB;
6
use Illuminate\Support\Arr;
7
use Illuminate\Support\Collection;
8
use Illuminate\Database\Eloquent\Builder;
9
use Symfony\Component\HttpFoundation\Response;
10
use Illuminate\Database\Eloquent\Relations\Relation;
11
use BeyondCode\QueryDetector\Events\QueryDetected;
12
13
class QueryDetector
14
{
15
    /** @var Collection */
16
    private $queries;
17
    /**
18
     * @var bool
19
     */
20
    private $booted = false;
21
22
    private function resetQueries()
23
    {
24
        $this->queries = Collection::make();
25
    }
26
27
    public function __construct()
28
    {
29
        $this->resetQueries();
30
    }
31
32
    public function boot()
33
    {
34
        if ($this->booted) {
35
            $this->resetQueries();
36
            return;
37
        }
38
39
        DB::listen(function ($query) {
40
            $backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 50));
41
42
            $this->logQuery($query, $backtrace);
43
        });
44
45
        foreach ($this->getOutputTypes() as $outputType) {
46
            app()->singleton($outputType);
47
            app($outputType)->boot();
48
        }
49
50
        $this->booted = true;
51
    }
52
53
    public function isEnabled(): bool
54
    {
55
        $configEnabled = value(config('querydetector.enabled'));
56
57
        if ($configEnabled === null) {
58
            $configEnabled = config('app.debug');
59
        }
60
61
        return $configEnabled;
62
    }
63
64
    public function logQuery($query, Collection $backtrace)
65
    {
66
        $modelTrace = $backtrace->first(function ($trace) {
67
            return Arr::get($trace, 'object') instanceof Builder;
68
        });
69
70
        // The query is coming from an Eloquent model
71
        if (!is_null($modelTrace)) {
72
            /*
73
             * Relations get resolved by either calling the "getRelationValue" method on the model,
74
             * or if the class itself is a Relation.
75
             */
76
            $relation = $backtrace->first(function ($trace) {
77
                return Arr::get($trace, 'function') === 'getRelationValue' || Arr::get($trace, 'class') === Relation::class;
78
            });
79
80
            // We try to access a relation
81
            if (is_array($relation) && isset($relation['object'])) {
82
                if ($relation['class'] === Relation::class) {
83
                    $model = get_class($relation['object']->getParent());
84
                    $relationName = get_class($relation['object']->getRelated());
85
                    $relatedModel = $relationName;
86
                } else {
87
                    $model = get_class($relation['object']);
88
                    $relationName = $relation['args'][0];
89
                    $relatedModel = $relationName;
90
                }
91
92
                $sources = $this->findSource($backtrace);
93
94
                $key = md5($query->sql . $model . $relationName . $sources[0]->name . $sources[0]->line);
95
96
                $count = Arr::get($this->queries, $key . '.count', 0);
97
                $time = Arr::get($this->queries, $key . '.time', 0);
98
99
                $this->queries[$key] = [
100
                    'count' => ++$count,
101
                    'time' => $time + $query->time,
102
                    'query' => $query->sql,
103
                    'model' => $model,
104
                    'relatedModel' => $relatedModel,
105
                    'relation' => $relationName,
106
                    'sources' => $sources
107
                ];
108
            }
109
        }
110
    }
111
112
    protected function findSource($stack)
113
    {
114
        $sources = [];
115
116
        foreach ($stack as $index => $trace) {
117
            $sources[] = $this->parseTrace($index, $trace);
118
        }
119
120
        return array_values(array_filter($sources));
121
    }
122
123
    public function parseTrace($index, array $trace)
124
    {
125
        $frame = (object)[
126
            'index' => $index,
127
            'name' => null,
128
            'line' => isset($trace['line']) ? $trace['line'] : '?',
129
        ];
130
131
        if (isset($trace['class']) &&
132
            isset($trace['file']) &&
133
            !$this->fileIsInExcludedPath($trace['file'])
134
        ) {
135
            $frame->name = $this->normalizeFilename($trace['file']);
136
137
            return $frame;
138
        }
139
140
        return false;
141
    }
142
143
    /**
144
     * Check if the given file is to be excluded from analysis
145
     *
146
     * @param string $file
147
     * @return bool
148
     */
149
    protected function fileIsInExcludedPath($file)
150
    {
151
        $excludedPaths = [
152
            '/vendor/laravel/framework/src/Illuminate/Database',
153
            '/vendor/laravel/framework/src/Illuminate/Events',
154
        ];
155
156
        $normalizedPath = str_replace('\\', '/', $file);
157
158
        foreach ($excludedPaths as $excludedPath) {
159
            if (strpos($normalizedPath, $excludedPath) !== false) {
160
                return true;
161
            }
162
        }
163
164
        return false;
165
    }
166
167
    /**
168
     * Shorten the path by removing the relative links and base dir
169
     *
170
     * @param string $path
171
     * @return string
172
     */
173
    protected function normalizeFilename($path): string
174
    {
175
        if (file_exists($path)) {
176
            $path = realpath($path);
177
        }
178
179
        return str_replace(base_path(), '', $path);
180
    }
181
182
    public function getDetectedQueries(): Collection
183
    {
184
        $exceptions = config('querydetector.except', []);
185
186
        $queries = $this->queries
187
            ->values();
188
189
        foreach ($exceptions as $parentModel => $relations) {
190
            foreach ($relations as $relation) {
191
                $queries = $queries->reject(function ($query) use ($relation, $parentModel) {
192
                    return $query['model'] === $parentModel && $query['relatedModel'] === $relation;
193
                });
194
            }
195
        }
196
197
        $queries = $queries->where('count', '>', config('querydetector.threshold', 1))->values();
198
199
        if ($queries->isNotEmpty()) {
200
            event(new QueryDetected($queries));
201
        }
202
203
        return $queries;
204
    }
205
206
    protected function getOutputTypes()
207
    {
208
        $outputTypes = config('querydetector.output');
209
210
        if (!is_array($outputTypes)) {
211
            $outputTypes = [$outputTypes];
212
        }
213
214
        return $outputTypes;
215
    }
216
217
    protected function applyOutput(Response $response)
218
    {
219
        foreach ($this->getOutputTypes() as $type) {
220
            app($type)->output($this->getDetectedQueries(), $response);
0 ignored issues
show
Bug introduced by
The method output() does not exist on Illuminate\Contracts\Foundation\Application. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

220
            app($type)->/** @scrutinizer ignore-call */ output($this->getDetectedQueries(), $response);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
221
        }
222
    }
223
224
    public function output($request, $response)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

224
    public function output(/** @scrutinizer ignore-unused */ $request, $response)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
225
    {
226
        if ($this->getDetectedQueries()->isNotEmpty()) {
227
            $this->applyOutput($response);
228
        }
229
230
        return $response;
231
    }
232
}
233