Completed
Push — master ( 488797...ae2a4f )
by Marcel
10s
created

QueryDetector::getOutputTypes()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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

Loading history...
206
    {
207
        if ($this->getDetectedQueries()->isNotEmpty()) {
208
            $this->applyOutput($response);
209
        }
210
211
        return $response;
212
    }
213
}
214