Completed
Push — device-groups ( bb073d...f4e0ce )
by Tony
06:39
created

DeviceGroup   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 337
Duplicated Lines 0 %

Coupling/Cohesion

Components 3
Dependencies 3

Importance

Changes 0
Metric Value
wmc 48
c 0
b 0
f 0
lcom 3
cbo 3
dl 0
loc 337
rs 8.4864

12 Methods

Rating   Name   Duplication   Size   Complexity  
A updateRelations() 0 12 2
D getDeviceIdsRaw() 0 43 9
B applyGroupMacros() 0 22 6
A getTablesFromPattern() 0 8 2
A devices() 0 4 1
B getPatternSqlAttribute() 0 16 5
A getDeviceCountAttribute() 0 12 3
A setParamsAttribute() 0 8 2
A getPatternAttribute() 0 9 2
C convertV1Pattern() 0 41 8
C convertRegexToLike() 0 27 7
A deviceCountRelation() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like DeviceGroup often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DeviceGroup, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * DeviceGroup.php
4
 *
5
 * Dynamic groups of devices
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
 *
20
 * @package    LibreNMS
21
 * @link       http://librenms.org
22
 * @copyright  2016 Tony Murray
23
 * @author     Tony Murray <[email protected]>
24
 */
25
26
namespace App\Models;
27
28
use App\Util;
29
use DB;
30
use Illuminate\Database\Eloquent\Model;
31
use Settings;
32
33
/**
34
 * App\Models\DeviceGroup
35
 *
36
 * @property integer $id
37
 * @property string $name
38
 * @property string $desc
39
 * @property string $pattern
40
 * @property array $params
41
 * @property string $patternSql
42
 * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Device[] $devices
43
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereId($value)
44
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereName($value)
45
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereDesc($value)
46
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup wherePattern($value)
47
 * @mixin \Eloquent
48
 */
49
class DeviceGroup extends Model
50
{
51
    /**
52
     * Indicates if the model should be timestamped.
53
     *
54
     * @var bool
55
     */
56
    public $timestamps = false;
57
    /**
58
     * The table associated with the model.
59
     *
60
     * @var string
61
     */
62
    protected $table = 'device_groups';
63
    /**
64
     * The primary key column name.
65
     *
66
     * @var string
67
     */
68
    protected $primaryKey = 'id';
69
    /**
70
     * Virtual attributes
71
     *
72
     * @var string
73
     */
74
    protected $appends = ['patternSql', 'deviceCount'];
75
76
    /**
77
     * The attributes that can be mass assigned.
78
     *
79
     * @var array
80
     */
81
    protected $fillable = ['name', 'desc', 'pattern', 'params'];
82
83
    /**
84
     * The attributes that should be casted to native types.
85
     *
86
     * @var array
87
     */
88
    protected $casts = ['params' => 'array'];
89
90
    // ---- Helper Functions ----
91
92
93
    public function updateRelations()
94
    {
95
        // we need an id to add relationships
96
        if (is_null($this->id)) {
97
            $this->save();
98
        }
99
100
        $device_ids = $this->getDeviceIdsRaw();
101
102
        // update the relationships (deletes and adds as needed)
103
        $this->devices()->sync($device_ids);
104
    }
105
106
    /**
107
     * Get an array of the device ids from this group by re-querying the database with
108
     * either the specified pattern or the saved pattern of this group
109
     *
110
     * @param string $statement Optional, will use the pattern from this group if not specified
111
     * @param array $params array of paremeters
112
     * @return array
113
     */
114
    public function getDeviceIdsRaw($statement = null, $params = null)
115
    {
116
        if (is_null($statement)) {
117
            $statement = $this->pattern;
118
        }
119
120
        if (is_null($params)) {
121
            if (empty($this->params)) {
122
                if (!starts_with($statement, '%')) {
123
                    // can't build sql
124
                    return [];
125
                }
126
            } else {
127
                $params = $this->params;
128
            }
129
        }
130
131
        $statement = $this->applyGroupMacros($statement);
132
        $tables = $this->getTablesFromPattern($statement);
0 ignored issues
show
Bug introduced by
It seems like $statement defined by $this->applyGroupMacros($statement) on line 131 can also be of type boolean; however, App\Models\DeviceGroup::getTablesFromPattern() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
133
134
        $query = null;
0 ignored issues
show
Unused Code introduced by
$query is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
135
        if (count($tables) == 1) {
136
            $query = DB::table($tables[0])->select('device_id')->distinct();
137
        } else {
138
            $query = DB::table('devices')->select('devices.device_id')->distinct();
139
140
            foreach ($tables as $table) {
141
                // skip devices table, we used that as the base.
142
                if ($table == 'devices') {
143
                    continue;
144
                }
145
146
                $query = $query->join($table, 'devices.device_id', '=', $table.'.device_id');
147
            }
148
        }
149
150
        // match the device ids
151
        if (is_null($params)) {
152
            return $query->whereRaw($statement)->pluck('device_id');
153
        } else {
154
            return $query->whereRaw($statement, $params)->pluck('device_id');
155
        }
156
    }
157
158
    /**
159
     * Process Macros
160
     *
161
     * @param string $pattern Rule to process
162
     * @param int $x Recursion-Anchor, do not pass
163
     * @return string|boolean
164
     */
165
    public static function applyGroupMacros($pattern, $x = 1)
166
    {
167
        if (!str_contains($pattern, 'macros.')) {
168
            return $pattern;
169
        }
170
171
        foreach (Settings::get('alert.macros.group', []) as $macro => $value) {
172
            $value = str_replace(['%', '&&', '||'], ['', 'AND', 'OR'], $value);  // this might need something more complex
173
            if (!str_contains($macro, ' ')) {
174
                $pattern = str_replace('macros.'.$macro, '('.$value.')', $pattern);
175
            }
176
        }
177
178
        if (str_contains($pattern, 'macros.')) {
179
            if (++$x < 30) {
180
                $pattern = self::applyGroupMacros($pattern, $x);
181
            } else {
182
                return false;
183
            }
184
        }
185
        return $pattern;
186
    }
187
188
    /**
189
     * Extract an array of tables in a pattern
190
     *
191
     * @param string $pattern
192
     * @return array
193
     */
194
    private function getTablesFromPattern($pattern)
195
    {
196
        preg_match_all('/[A-Za-z_]+(?=\.[A-Za-z_]+ )/', $pattern, $tables);
197
        if (is_null($tables)) {
198
            return [];
199
        }
200
        return array_keys(array_flip($tables[0])); // unique tables only
201
    }
202
203
    /**
204
     * Relationship to App\Models\Device
205
     *
206
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
207
     */
208
    public function devices()
209
    {
210
        return $this->belongsToMany('App\Models\Device', 'device_group_device', 'device_group_id', 'device_id');
211
    }
212
213
    /**
214
     * Returns an sql formatted string
215
     * Mostly, this is for ingestion by JQuery-QueryBuilder
216
     *
217
     * @return string
218
     */
219
    public function getPatternSqlAttribute()
220
    {
221
        $sql = $this->pattern;
222
        if (empty($this->params)) {
223
            return $sql;
224
        }
225
226
        // fill in parameters
227
        foreach ((array)$this->params as $value) {
228
            if (!is_numeric($value) && !starts_with($value, "'")) {
229
                $value = "'".$value."'";
230
            }
231
            $sql = preg_replace('/\?/', $value, $sql, 1);
232
        }
233
        return $sql;
234
    }
235
236
    // ---- Accessors/Mutators ----
237
238
    /**
239
     * Fetch the device counts for groups
240
     * Use DeviceGroups::with('deviceCountRelation') to eager load
241
     *
242
     * @return int
243
     */
244
    public function getDeviceCountAttribute()
245
    {
246
        // if relation is not loaded already, let's do it first
247
        if (!$this->relationLoaded('deviceCountRelation')) {
248
            $this->load('deviceCountRelation');
249
        }
250
251
        $related = $this->getRelation('deviceCountRelation')->first();
252
253
        // then return the count directly
254
        return ($related) ? (int)$related->count : 0;
255
    }
256
257
    /**
258
     * Custom mutator for params attribute
259
     * Allows already encoded json to pass through
260
     *
261
     * @param array|string $params
262
     */
263
    public function setParamsAttribute($params)
264
    {
265
        if (!Util::isJson($params)) {
0 ignored issues
show
Bug introduced by
It seems like $params defined by parameter $params on line 263 can also be of type array; however, App\Util::isJson() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
266
            $params = json_encode($params);
267
        }
268
269
        $this->attributes['params'] = $params;
270
    }
271
272
    /**
273
     * Check if the stored pattern is v1
274
     * Convert it to v2 for display
275
     * Currently, it will only be updated in the database if the user saves the rule in the ui
276
     *
277
     * @param $pattern
278
     * @return string
279
     */
280
    public function getPatternAttribute($pattern)
281
    {
282
        // If this is a v1 pattern, convert it to sql
283
        if (starts_with($pattern, '%')) {
284
            return $this->convertV1Pattern($pattern);
285
        }
286
287
        return $pattern;
288
    }
289
290
    /**
291
     * Convert a v1 device group pattern to sql that can be ingested by jQuery-QueryBuilder
292
     *
293
     * @param $pattern
294
     * @return array
295
     */
296
    private function convertV1Pattern($pattern)
297
    {
298
        $pattern = rtrim($pattern, ' &&');
299
        $pattern = rtrim($pattern, ' ||');
300
301
        $ops = ['=', '!=', '<', '<=', '>', '>='];
302
        $parts = str_getcsv($pattern, ' '); // tokenize the pattern, respecting quoted parts
303
        $out = "";
304
305
        $count = count($parts);
306
        for ($i = 0; $i < $count; $i++) {
307
            $cur = $parts[$i];
308
309
            if (starts_with($cur, '%')) {
310
                // table and column or macro
311
                $out .= substr($cur, 1).' ';
312
            } elseif (substr($cur, -1) == '~') {
313
                // like operator
314
                $content = $parts[++$i]; // grab the content so we can format it
315
316
                if (starts_with($cur, '!')) {
317
                    // prepend NOT
318
                    $out .= 'NOT ';
319
                }
320
321
                $out .= "LIKE('".$this->convertRegexToLike($content)."') ";
322
323
            } elseif ($cur == '&&') {
324
                $out .= 'AND ';
325
            } elseif ($cur == '||') {
326
                $out .= 'OR ';
327
            } elseif (in_array($cur, $ops)) {
328
                // pass-through operators
329
                $out .= $cur.' ';
330
            } else {
331
                // user supplied input
332
                $out .= "'".trim($cur, '"\'')."' "; // TODO: remove trim, only needed with invalid input
333
            }
334
        }
335
        return rtrim($out);
336
    }
337
338
    // ---- Define Relationships ----
339
340
    /**
341
     * Convert sql regex to like, many common uses can be converted
342
     * Should only be used to convert v1 patterns
343
     *
344
     * @param $pattern
345
     * @return string
346
     */
347
    private function convertRegexToLike($pattern)
348
    {
349
        $startAnchor = starts_with($pattern, '^');
350
        $endAnchor = ends_with($pattern, '$');
351
352
        $pattern = trim($pattern, '^$');
353
354
        $wildcards = ['@', '.*'];
355
        if (str_contains($pattern, $wildcards)) {
356
            // contains wildcard
357
            $pattern = str_replace($wildcards, '%', $pattern);
358
        }
359
360
        // add ends appropriately
361
        if ($startAnchor && !$endAnchor) {
362
            $pattern .= '%';
363
        } elseif (!$startAnchor && $endAnchor) {
364
            $pattern = '%'.$pattern;
365
        }
366
367
        // if there are no wildcards, assume substring
368
        if (!str_contains($pattern, '%')) {
369
            $pattern = '%'.$pattern.'%';
370
        }
371
372
        return $pattern;
373
    }
374
375
    /**
376
     * Relationship allows us to eager load device counts
377
     * DeviceGroups::with('deviceCountRelation')
378
     *
379
     * @return mixed
380
     */
381
    public function deviceCountRelation()
382
    {
383
        return $this->devices()->selectRaw('`device_group_device`.`device_group_id`, count(*) as count')->groupBy('pivot_device_group_id');
384
    }
385
}
386