DeviceGroup::convertRegexToLike()   C
last analyzed

Complexity

Conditions 7
Paths 12

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 14
nc 12
nop 1
dl 0
loc 27
ccs 12
cts 12
cp 1
crap 7
rs 6.7272
c 0
b 0
f 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
 * @property-read mixed $pattern_sql
49
 * @property-read mixed $device_count
50
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereParams($value)
51
 */
52
class DeviceGroup extends Model
53
{
54
    /**
55
     * Indicates if the model should be timestamped.
56
     *
57
     * @var bool
58
     */
59
    public $timestamps = false;
60
    /**
61
     * The table associated with the model.
62
     *
63
     * @var string
64
     */
65
    protected $table = 'device_groups';
66
    /**
67
     * The primary key column name.
68
     *
69
     * @var string
70
     */
71
    protected $primaryKey = 'id';
72
    /**
73
     * Virtual attributes
74
     *
75
     * @var string
76
     */
77
    protected $appends = ['patternSql', 'deviceCount'];
78
79
    /**
80
     * The attributes that can be mass assigned.
81
     *
82
     * @var array
83
     */
84
    protected $fillable = ['name', 'desc', 'pattern', 'params'];
85
86
    /**
87
     * The attributes that should be casted to native types.
88
     *
89
     * @var array
90
     */
91
    protected $casts = ['params' => 'array'];
92
93
    // ---- Helper Functions ----
94
95
96 1
    public function updateRelations()
97
    {
98
        // we need an id to add relationships
99 1
        if (is_null($this->id)) {
100
            $this->save();
101
        }
102
103 1
        $device_ids = $this->getDeviceIdsRaw();
104
105
        // update the relationships (deletes and adds as needed)
106 1
        $this->devices()->sync($device_ids);
107 1
    }
108
109
    /**
110
     * Get an array of the device ids from this group by re-querying the database with
111
     * either the specified pattern or the saved pattern of this group
112
     *
113
     * @param string $statement Optional, will use the pattern from this group if not specified
114
     * @param array $params array of paremeters
115
     * @return array
116
     */
117 1
    public function getDeviceIdsRaw($statement = null, $params = null)
118
    {
119 1
        if (is_null($statement)) {
120 1
            $statement = $this->pattern;
121
        }
122
123 1
        if (is_null($params)) {
124 1
            if (empty($this->params)) {
125
                if (!starts_with($statement, '%')) {
126
                    // can't build sql
127
                    return [];
128
                }
129
            } else {
130 1
                $params = $this->params;
131
            }
132
        }
133
134 1
        $statement = $this->applyGroupMacros($statement);
135 1
        $tables = $this->getTablesFromPattern($statement);
0 ignored issues
show
Bug introduced by
It seems like $statement defined by $this->applyGroupMacros($statement) on line 134 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...
136
137 1
        $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...
138 1
        if (count($tables) == 1) {
139 1
            $query = DB::table($tables[0])->select('device_id')->distinct();
140
        } else {
141 1
            $query = DB::table('devices')->select('devices.device_id')->distinct();
142
143 1
            foreach ($tables as $table) {
144
                // skip devices table, we used that as the base.
145 1
                if ($table == 'devices') {
146 1
                    continue;
147
                }
148
149 1
                $query = $query->join($table, 'devices.device_id', '=', $table.'.device_id');
150
            }
151
        }
152
153
        // match the device ids
154 1
        if (is_null($params)) {
155
            return $query->whereRaw($statement)->pluck('device_id')->toArray();
156
        } else {
157 1
            return $query->whereRaw($statement, $params)->pluck('device_id')->toArray();
158
        }
159
    }
160
161
    /**
162
     * Process Macros
163
     *
164
     * @param string $pattern Rule to process
165
     * @param int $x Recursion-Anchor, do not pass
166
     * @return string|boolean
167
     */
168 2
    public static function applyGroupMacros($pattern, $x = 1)
169
    {
170 2
        if (!str_contains($pattern, 'macros.')) {
171 1
            return $pattern;
172
        }
173
174 1
        foreach (Settings::get('alert.macros.group', []) as $macro => $value) {
175 1
            $value = str_replace(['%', '&&', '||'], ['', 'AND', 'OR'], $value);  // this might need something more complex
176 1
            if (!str_contains($macro, ' ')) {
177 1
                $pattern = str_replace('macros.'.$macro, '('.$value.')', $pattern);
178
            }
179
        }
180
181 1
        if (str_contains($pattern, 'macros.')) {
182
            if (++$x < 30) {
183
                $pattern = self::applyGroupMacros($pattern, $x);
184
            } else {
185
                return false;
186
            }
187
        }
188 1
        return $pattern;
189
    }
190
191
    /**
192
     * Extract an array of tables in a pattern
193
     *
194
     * @param string $pattern
195
     * @return array
196
     */
197 1
    private function getTablesFromPattern($pattern)
198
    {
199 1
        preg_match_all('/[A-Za-z_]+(?=\.[A-Za-z_]+ )/', $pattern, $tables);
200 1
        if (is_null($tables)) {
201
            return [];
202
        }
203 1
        return array_keys(array_flip($tables[0])); // unique tables only
204
    }
205
206
    /**
207
     * Convert a v1 device group pattern to sql that can be ingested by jQuery-QueryBuilder
208
     *
209
     * @param $pattern
210
     * @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...
211
     */
212 1
    private function convertV1Pattern($pattern)
213
    {
214 1
        $pattern = rtrim($pattern, ' &&');
215 1
        $pattern = rtrim($pattern, ' ||');
216
217 1
        $ops = ['=', '!=', '<', '<=', '>', '>='];
218 1
        $parts = str_getcsv($pattern, ' '); // tokenize the pattern, respecting quoted parts
219 1
        $out = "";
220
221 1
        $count = count($parts);
222 1
        for ($i = 0; $i < $count; $i++) {
223 1
            $cur = $parts[$i];
224
225 1
            if (starts_with($cur, '%')) {
226
                // table and column or macro
227 1
                $out .= substr($cur, 1).' ';
228 1
            } elseif (substr($cur, -1) == '~') {
229
                // like operator
230 1
                $content = $parts[++$i]; // grab the content so we can format it
231
232 1
                if (starts_with($cur, '!')) {
233
                    // prepend NOT
234 1
                    $out .= 'NOT ';
235
                }
236
237 1
                $out .= "LIKE('".$this->convertRegexToLike($content)."') ";
238 1
            } elseif ($cur == '&&') {
239 1
                $out .= 'AND ';
240 1
            } elseif ($cur == '||') {
241 1
                $out .= 'OR ';
242 1
            } elseif (in_array($cur, $ops)) {
243
                // pass-through operators
244 1
                $out .= $cur.' ';
245
            } else {
246
                // user supplied input
247 1
                $out .= "'".trim($cur, '"\'')."' "; // TODO: remove trim, only needed with invalid input
248
            }
249
        }
250 1
        return rtrim($out);
251
    }
252
253
    /**
254
     * Convert sql regex to like, many common uses can be converted
255
     * Should only be used to convert v1 patterns
256
     *
257
     * @param $pattern
258
     * @return string
259
     */
260 1
    private function convertRegexToLike($pattern)
261
    {
262 1
        $startAnchor = starts_with($pattern, '^');
263 1
        $endAnchor = ends_with($pattern, '$');
264
265 1
        $pattern = trim($pattern, '^$');
266
267 1
        $wildcards = ['@', '.*'];
268 1
        if (str_contains($pattern, $wildcards)) {
269
            // contains wildcard
270 1
            $pattern = str_replace($wildcards, '%', $pattern);
271
        }
272
273
        // add ends appropriately
274 1
        if ($startAnchor && !$endAnchor) {
275
            $pattern .= '%';
276 1
        } elseif (!$startAnchor && $endAnchor) {
277
            $pattern = '%'.$pattern;
278
        }
279
280
        // if there are no wildcards, assume substring
281 1
        if (!str_contains($pattern, '%')) {
282 1
            $pattern = '%'.$pattern.'%';
283
        }
284
285 1
        return $pattern;
286
    }
287
288
    // ---- Accessors/Mutators ----
289
290
    /**
291
     * Returns an sql formatted string
292
     * Mostly, this is for ingestion by JQuery-QueryBuilder
293
     *
294
     * @return string
295
     */
296
    public function getPatternSqlAttribute()
297
    {
298
        $sql = $this->pattern;
299
300
        // fill in parameters
301
        foreach ((array)$this->params as $value) {
302
            if (!is_numeric($value) && !starts_with($value, "'")) {
303
                $value = "'".$value."'";
304
            }
305
            $sql = preg_replace('/\?/', $value, $sql, 1);
306
        }
307
        return $sql;
308
    }
309
310
    /**
311
     * Fetch the device counts for groups
312
     * Use DeviceGroups::with('deviceCountRelation') to eager load
313
     *
314
     * @return int
315
     */
316
    public function getDeviceCountAttribute()
317
    {
318
        // if relation is not loaded already, let's do it first
319
        if (!$this->relationLoaded('deviceCountRelation')) {
320
            $this->load('deviceCountRelation');
321
        }
322
323
        $related = $this->getRelation('deviceCountRelation')->first();
324
325
        // then return the count directly
326
        return ($related) ? (int)$related->count : 0;
327
    }
328
329
    /**
330
     * Custom mutator for params attribute
331
     * Allows already encoded json to pass through
332
     *
333
     * @param array|string $params
334
     */
335 1
    public function setParamsAttribute($params)
336
    {
337 1
        if (!Util::isJson($params)) {
0 ignored issues
show
Bug introduced by
It seems like $params defined by parameter $params on line 335 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...
338 1
            $params = json_encode($params);
339
        }
340
341 1
        $this->attributes['params'] = $params;
342 1
    }
343
344
    /**
345
     * Check if the stored pattern is v1
346
     * Convert it to v2 for display
347
     * Currently, it will only be updated in the database if the user saves the rule in the ui
348
     *
349
     * @param $pattern
350
     * @return string
351
     */
352 2
    public function getPatternAttribute($pattern)
353
    {
354
        // If this is a v1 pattern, convert it to sql
355 2
        if (starts_with($pattern, '%')) {
356 1
            return $this->convertV1Pattern($pattern);
357
        }
358
359 1
        return $pattern;
360
    }
361
362
363
    // ---- Define Relationships ----
364
365
    /**
366
     * Relationship allows us to eager load device counts
367
     * DeviceGroups::with('deviceCountRelation')
368
     *
369
     * @return mixed
370
     */
371
    public function deviceCountRelation()
372
    {
373
        // FIXME this query doesn't work in strict mode
374
        return $this->devices()->selectRaw('`device_group_device`.`device_group_id`, count(*) as count')->groupBy('device_group_device.device_group_id');
375
    }
376
377
    /**
378
     * Relationship to App\Models\Device
379
     *
380
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
381
     */
382 1
    public function devices()
383
    {
384 1
        return $this->belongsToMany('App\Models\Device', 'device_group_device', 'device_group_id', 'device_id');
385
    }
386
}
387