Completed
Push — develop ( b53258...702238 )
by Tony
15:27
created

DeviceGroup::convertV1Pattern()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 41
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 41
rs 5.3846
c 0
b 0
f 0
cc 8
eloc 25
nc 8
nop 1
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
223
        // fill in parameters
224
        foreach ((array)$this->params as $value) {
225
            if (!is_numeric($value) && !starts_with($value, "'")) {
226
                $value = "'".$value."'";
227
            }
228
            $sql = preg_replace('/\?/', $value, $sql, 1);
229
        }
230
        return $sql;
231
    }
232
233
234
    // ---- Accessors/Mutators ----
235
236
    /**
237
     * Fetch the device counts for groups
238
     * Use DeviceGroups::with('deviceCountRelation') to eager load
239
     *
240
     * @return int
241
     */
242
    public function getDeviceCountAttribute()
243
    {
244
        // if relation is not loaded already, let's do it first
245
        if (!$this->relationLoaded('deviceCountRelation')) {
246
            $this->load('deviceCountRelation');
247
        }
248
249
        $related = $this->getRelation('deviceCountRelation')->first();
250
251
        // then return the count directly
252
        return ($related) ? (int)$related->count : 0;
253
    }
254
255
    /**
256
     * Custom mutator for params attribute
257
     * Allows already encoded json to pass through
258
     *
259
     * @param array|string $params
260
     */
261
    public function setParamsAttribute($params)
262
    {
263
        if (!Util::isJson($params)) {
0 ignored issues
show
Bug introduced by
It seems like $params defined by parameter $params on line 261 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...
264
            $params = json_encode($params);
265
        }
266
267
        $this->attributes['params'] = $params;
268
    }
269
270
    /**
271
     * Check if the stored pattern is v1
272
     * Convert it to v2 for display
273
     * Currently, it will only be updated in the database if the user saves the rule in the ui
274
     *
275
     * @param $pattern
276
     * @return string
277
     */
278
    public function getPatternAttribute($pattern)
279
    {
280
        // If this is a v1 pattern, convert it to sql
281
        if (starts_with($pattern, '%')) {
282
            return $this->convertV1Pattern($pattern);
283
        }
284
285
        return $pattern;
286
    }
287
288
    /**
289
     * Convert a v1 device group pattern to sql that can be ingested by jQuery-QueryBuilder
290
     *
291
     * @param $pattern
292
     * @return array
0 ignored issues
show
Documentation introduced by
Should the return type not be string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

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