Completed
Push — device-groups ( f4e0ce...4e4afd )
by Tony
02:57
created

DeviceGroup::getPatternSqlAttribute()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 13
rs 9.2
cc 4
eloc 7
nc 3
nop 0
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
    // ---- Accessors/Mutators ----
234
235
    /**
236
     * Fetch the device counts for groups
237
     * Use DeviceGroups::with('deviceCountRelation') to eager load
238
     *
239
     * @return int
240
     */
241
    public function getDeviceCountAttribute()
242
    {
243
        // if relation is not loaded already, let's do it first
244
        if (!$this->relationLoaded('deviceCountRelation')) {
245
            $this->load('deviceCountRelation');
246
        }
247
248
        $related = $this->getRelation('deviceCountRelation')->first();
249
250
        // then return the count directly
251
        return ($related) ? (int)$related->count : 0;
252
    }
253
254
    /**
255
     * Custom mutator for params attribute
256
     * Allows already encoded json to pass through
257
     *
258
     * @param array|string $params
259
     */
260
    public function setParamsAttribute($params)
261
    {
262
        if (!Util::isJson($params)) {
0 ignored issues
show
Bug introduced by
It seems like $params defined by parameter $params on line 260 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...
263
            $params = json_encode($params);
264
        }
265
266
        $this->attributes['params'] = $params;
267
    }
268
269
    /**
270
     * Check if the stored pattern is v1
271
     * Convert it to v2 for display
272
     * Currently, it will only be updated in the database if the user saves the rule in the ui
273
     *
274
     * @param $pattern
275
     * @return string
276
     */
277
    public function getPatternAttribute($pattern)
278
    {
279
        // If this is a v1 pattern, convert it to sql
280
        if (starts_with($pattern, '%')) {
281
            return $this->convertV1Pattern($pattern);
282
        }
283
284
        return $pattern;
285
    }
286
287
    /**
288
     * Convert a v1 device group pattern to sql that can be ingested by jQuery-QueryBuilder
289
     *
290
     * @param $pattern
291
     * @return array
292
     */
293
    private function convertV1Pattern($pattern)
294
    {
295
        $pattern = rtrim($pattern, ' &&');
296
        $pattern = rtrim($pattern, ' ||');
297
298
        $ops = ['=', '!=', '<', '<=', '>', '>='];
299
        $parts = str_getcsv($pattern, ' '); // tokenize the pattern, respecting quoted parts
300
        $out = "";
301
302
        $count = count($parts);
303
        for ($i = 0; $i < $count; $i++) {
304
            $cur = $parts[$i];
305
306
            if (starts_with($cur, '%')) {
307
                // table and column or macro
308
                $out .= substr($cur, 1).' ';
309
            } elseif (substr($cur, -1) == '~') {
310
                // like operator
311
                $content = $parts[++$i]; // grab the content so we can format it
312
313
                if (starts_with($cur, '!')) {
314
                    // prepend NOT
315
                    $out .= 'NOT ';
316
                }
317
318
                $out .= "LIKE('".$this->convertRegexToLike($content)."') ";
319
320
            } elseif ($cur == '&&') {
321
                $out .= 'AND ';
322
            } elseif ($cur == '||') {
323
                $out .= 'OR ';
324
            } elseif (in_array($cur, $ops)) {
325
                // pass-through operators
326
                $out .= $cur.' ';
327
            } else {
328
                // user supplied input
329
                $out .= "'".trim($cur, '"\'')."' "; // TODO: remove trim, only needed with invalid input
330
            }
331
        }
332
        return rtrim($out);
333
    }
334
335
    // ---- Define Relationships ----
336
337
    /**
338
     * Convert sql regex to like, many common uses can be converted
339
     * Should only be used to convert v1 patterns
340
     *
341
     * @param $pattern
342
     * @return string
343
     */
344
    private function convertRegexToLike($pattern)
345
    {
346
        $startAnchor = starts_with($pattern, '^');
347
        $endAnchor = ends_with($pattern, '$');
348
349
        $pattern = trim($pattern, '^$');
350
351
        $wildcards = ['@', '.*'];
352
        if (str_contains($pattern, $wildcards)) {
353
            // contains wildcard
354
            $pattern = str_replace($wildcards, '%', $pattern);
355
        }
356
357
        // add ends appropriately
358
        if ($startAnchor && !$endAnchor) {
359
            $pattern .= '%';
360
        } elseif (!$startAnchor && $endAnchor) {
361
            $pattern = '%'.$pattern;
362
        }
363
364
        // if there are no wildcards, assume substring
365
        if (!str_contains($pattern, '%')) {
366
            $pattern = '%'.$pattern.'%';
367
        }
368
369
        return $pattern;
370
    }
371
372
    /**
373
     * Relationship allows us to eager load device counts
374
     * DeviceGroups::with('deviceCountRelation')
375
     *
376
     * @return mixed
377
     */
378
    public function deviceCountRelation()
379
    {
380
        return $this->devices()->selectRaw('`device_group_device`.`device_group_id`, count(*) as count')->groupBy('pivot_device_group_id');
381
    }
382
}
383