Passed
Push — master ( 722c45...cdf064 )
by Marcel
11:35 queued 15s
created

QueryDetector::resetQueries()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
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
                if (empty($sources)) {
95
                    return;
96
                }
97
98
                $key = md5($query->sql . $model . $relationName . $sources[0]->name . $sources[0]->line);
99
100
                $count = Arr::get($this->queries, $key . '.count', 0);
101
                $time = Arr::get($this->queries, $key . '.time', 0);
102
103
                $this->queries[$key] = [
104
                    'count' => ++$count,
105
                    'time' => $time + $query->time,
106
                    'query' => $query->sql,
107
                    'model' => $model,
108
                    'relatedModel' => $relatedModel,
109
                    'relation' => $relationName,
110
                    'sources' => $sources
111
                ];
112
            }
113
        }
114
    }
115
116
    protected function findSource($stack)
117
    {
118
        $sources = [];
119
120
        foreach ($stack as $index => $trace) {
121
            $sources[] = $this->parseTrace($index, $trace);
122
        }
123
124
        return array_values(array_filter($sources));
125
    }
126
127
    public function parseTrace($index, array $trace)
128
    {
129
        $frame = (object)[
130
            'index' => $index,
131
            'name' => null,
132
            'line' => isset($trace['line']) ? $trace['line'] : '?',
133
        ];
134
135
        if (isset($trace['class']) &&
136
            isset($trace['file']) &&
137
            !$this->fileIsInExcludedPath($trace['file'])
138
        ) {
139
            $frame->name = $this->normalizeFilename($trace['file']);
140
141
            return $frame;
142
        }
143
144
        return false;
145
    }
146
147
    /**
148
     * Check if the given file is to be excluded from analysis
149
     *
150
     * @param string $file
151
     * @return bool
152
     */
153
    protected function fileIsInExcludedPath($file)
154
    {
155
        $excludedPaths = [
156
            '/vendor/laravel/framework/src/Illuminate/Database',
157
            '/vendor/laravel/framework/src/Illuminate/Events',
158
        ];
159
160
        $normalizedPath = str_replace('\\', '/', $file);
161
162
        foreach ($excludedPaths as $excludedPath) {
163
            if (strpos($normalizedPath, $excludedPath) !== false) {
164
                return true;
165
            }
166
        }
167
168
        return false;
169
    }
170
171
    /**
172
     * Shorten the path by removing the relative links and base dir
173
     *
174
     * @param string $path
175
     * @return string
176
     */
177
    protected function normalizeFilename($path): string
178
    {
179
        if (file_exists($path)) {
180
            $path = realpath($path);
181
        }
182
183
        return str_replace(base_path(), '', $path);
184
    }
185
186
    public function getDetectedQueries(): Collection
187
    {
188
        $exceptions = config('querydetector.except', []);
189
190
        $queries = $this->queries
191
            ->values();
192
193
        foreach ($exceptions as $parentModel => $relations) {
194
            foreach ($relations as $relation) {
195
                $queries = $queries->reject(function ($query) use ($relation, $parentModel) {
196
                    return $query['model'] === $parentModel && $query['relatedModel'] === $relation;
197
                });
198
            }
199
        }
200
201
        $queries = $queries->where('count', '>', config('querydetector.threshold', 1))->values();
202
203
        if ($queries->isNotEmpty()) {
204
            event(new QueryDetected($queries));
205
        }
206
207
        return $queries;
208
    }
209
210
    protected function getOutputTypes()
211
    {
212
        $outputTypes = config('querydetector.output');
213
214
        if (!is_array($outputTypes)) {
215
            $outputTypes = [$outputTypes];
216
        }
217
218
        return $outputTypes;
219
    }
220
221
    protected function applyOutput(Response $response)
222
    {
223
        foreach ($this->getOutputTypes() as $type) {
224
            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

224
            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...
225
        }
226
    }
227
228
    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

228
    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...
229
    {
230
        if ($this->getDetectedQueries()->isNotEmpty()) {
231
            $this->applyOutput($response);
232
        }
233
234
        return $response;
235
    }
236
}
237