Completed
Pull Request — develop (#143)
by Tony
04:52
created

DeviceGroup::convertV1Pattern()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 41
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
dl 0
loc 41
rs 5.3846
c 3
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 DB;
29
use Illuminate\Database\Eloquent\Model;
30
use Settings;
31
32
/**
33
 * App\Models\DeviceGroup
34
 *
35
 * @property integer $id
36
 * @property string $name
37
 * @property string $desc
38
 * @property string $pattern
39
 * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Device[] $devices
40
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereId($value)
41
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereName($value)
42
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup whereDesc($value)
43
 * @method static \Illuminate\Database\Query\Builder|\App\Models\DeviceGroup wherePattern($value)
44
 * @mixin \Eloquent
45
 */
46
class DeviceGroup extends Model
47
{
48
    /**
49
     * Indicates if the model should be timestamped.
50
     *
51
     * @var bool
52
     */
53
    public $timestamps = false;
54
    /**
55
     * The table associated with the model.
56
     *
57
     * @var string
58
     */
59
    protected $table = 'device_groups';
60
    /**
61
     * The primary key column name.
62
     *
63
     * @var string
64
     */
65
    protected $primaryKey = 'id';
66
    /**
67
     * Virtual attributes
68
     *
69
     * @var string
70
     */
71
    protected $appends = ['deviceCount'];
72
73
    /**
74
     * The attributes that can be mass assigned.
75
     *
76
     * @var array
77
     */
78
    protected $fillable = ['name', 'desc', 'pattern'];
79
80
    /**
81
     * Get an array of the device ids from this group by re-querying the database with
82
     * either the specified pattern or the saved pattern of this group
83
     *
84
     * @param null $pattern Optional, will use the pattern from this group if not specified
85
     * @return array
86
     */
87
    public function getDeviceIdsRaw($pattern = null)
88
    {
89
        if (is_null($pattern)) {
90
            $pattern = $this->pattern;
91
        }
92
93
        $pattern = $this->applyGroupMacros($pattern);
94
95
        $tables = $this->getTablesFromPattern($pattern);
0 ignored issues
show
Bug introduced by
It seems like $pattern defined by $this->applyGroupMacros($pattern) on line 93 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...
96
97
        $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...
98
        if (count($tables) == 1) {
99
            $query = DB::table($tables[0])->select('device_id')->distinct();
100
        } else {
101
            $query = DB::table('devices')->select('devices.device_id')->distinct();
102
103
            foreach ($tables as $table) {
104
                // skip devices table, we used that as the base.
105
                if ($table == 'devices') {
106
                    continue;
107
                }
108
109
                $query = $query->join($table, 'devices.device_id', '=', $table.'.device_id');
110
            }
111
        }
112
113
        // match the device ids
114
        return $query->whereRaw($pattern)->pluck('device_id');
115
    }
116
117
    /**
118
     * Convert a v1 device group pattern to sql that can be ingested by jQuery-QueryBuilder
119
     *
120
     * @param $pattern
121
     * @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...
122
     */
123
    private function convertV1Pattern($pattern)
124
    {
125
        $pattern = rtrim($pattern, ' &&');
126
        $pattern = rtrim($pattern, ' ||');
127
128
        $ops = ['=', '!=', '<', '<=', '>', '>='];
129
        $parts = str_getcsv($pattern, ' '); // tokenize the pattern, respecting quoted parts
130
        $out = "";
131
132
        $count = count($parts);
133
        for ($i = 0; $i < $count; $i++) {
134
            $cur = $parts[$i];
135
136
            if (starts_with($cur, '%')) {
137
                // table and column or macro
138
                $out .= substr($cur, 1).' ';
139
            } elseif (substr($cur, -1) == '~') {
140
                // like operator
141
                $content = $parts[++$i]; // grab the content so we can format it
142
143
                if (starts_with($cur, '!')) {
144
                    // prepend NOT
145
                    $out .= 'NOT ';
146
                }
147
148
                $out .= "LIKE('".$this->convertRegexToLike($content)."') ";
149
150
            } elseif ($cur == '&&') {
151
                $out .= 'AND ';
152
            } elseif ($cur == '||') {
153
                $out .= 'OR ';
154
            } elseif (in_array($cur, $ops)) {
155
                // pass-through operators
156
                $out .= $cur.' ';
157
            } else {
158
                // user supplied input
159
                $out .= "'".trim($cur, '"\'')."' "; // TODO: remove trim, only needed with invalid input
160
            }
161
        }
162
        return rtrim($out);
163
    }
164
165
    /**
166
     * Convert sql regex to like, many common uses can be converted
167
     * Should only be used to convert v1 patterns
168
     *
169
     * @param $pattern
170
     * @return string
171
     */
172
    private function convertRegexToLike($pattern)
173
    {
174
        $startAnchor = starts_with($pattern, '^');
175
        $endAnchor = ends_with($pattern, '$');
176
177
        $pattern = trim($pattern, '^$');
178
179
        $wildcards = ['@', '.*'];
180
        if (str_contains($pattern, $wildcards)) {
181
            // contains wildcard
182
            $pattern = str_replace($wildcards, '%', $pattern);
183
        }
184
185
        // add ends appropriately
186
        if ($startAnchor && !$endAnchor) {
187
            $pattern .= '%';
188
        } elseif (!$startAnchor && $endAnchor) {
189
            $pattern = '%'.$pattern;
190
        }
191
192
        // if there are no wildcards, assume substring
193
        if (!str_contains($pattern, '%')) {
194
            $pattern = '%'.$pattern.'%';
195
        }
196
197
        return $pattern;
198
    }
199
200
    /**
201
     * Process Macros
202
     *
203
     * @param string $pattern Rule to process
204
     * @param int $x Recursion-Anchor, do not pass
205
     * @return string|boolean
206
     */
207
    public static function applyGroupMacros($pattern, $x = 1)
208
    {
209
        if (!str_contains($pattern, 'macros.')) {
210
            return $pattern;
211
        }
212
213
        foreach (Settings::get('alert.macros.group', []) as $macro => $value) {
214
            $value = str_replace(['%', '&&', '||'], ['', 'AND', 'OR'], $value);  // this might need something more complex
215
            if (!str_contains($macro, ' ')) {
216
                $pattern = str_replace('macros.'.$macro, '('.$value.')', $pattern);
217
            }
218
        }
219
220
        if (str_contains($pattern, 'macros.')) {
221
            if (++$x < 30) {
222
                $pattern = self::applyGroupMacros($pattern, $x);
223
            } else {
224
                return false;
225
            }
226
        }
227
        return $pattern;
228
    }
229
230
    /**
231
     * Extract an array of tables in a pattern
232
     *
233
     * @param string $pattern
234
     * @return array
235
     */
236
    private function getTablesFromPattern($pattern)
237
    {
238
        preg_match_all('/[A-Za-z_]+(?=\.[A-Za-z_]+ )/', $pattern, $tables);
239
        if (is_null($tables)) {
240
            return [];
241
        }
242
        return array_keys(array_flip($tables[0])); // unique tables only
243
    }
244
245
    // ---- Accessors/Mutators ----
246
247
    /**
248
     * Fetch the device counts for groups
249
     * Use DeviceGroups::with('deviceCountRelation') to eager load
250
     *
251
     * @return int
252
     */
253
    public function getDeviceCountAttribute()
254
    {
255
        // if relation is not loaded already, let's do it first
256
        if (!$this->relationLoaded('deviceCountRelation')) {
257
            $this->load('deviceCountRelation');
258
        }
259
260
        $related = $this->getRelation('deviceCountRelation')->first();
261
262
        // then return the count directly
263
        return ($related) ? (int)$related->count : 0;
264
    }
265
266
    /**
267
     * Set the pattern attribute
268
     * Update the relationships when set
269
     *
270
     * @param $pattern
271
     */
272
    public function setPatternAttribute($pattern)
273
    {
274
        $this->attributes['pattern'] = $pattern;
275
276
        // we need an id to add relationships
277
        if (is_null($this->id)) {
278
            $this->save();
279
        }
280
281
        // update the relationships (deletes and adds as needed)
282
        $this->devices()->sync($this->getDeviceIdsRaw($pattern));
283
    }
284
285
    /**
286
     * Check if the stored pattern is v1
287
     * Convert it to v2 for display
288
     * Currently, it will only be updated in the database if the user saves the rule in the ui
289
     *
290
     * @param $pattern
291
     * @return string
292
     */
293
    public function getPatternAttribute($pattern)
294
    {
295
        // If this is a v1 pattern, convert it to sql
296
        if (starts_with($pattern, '%')) {
297
            return $this->convertV1Pattern($pattern);
298
        }
299
300
        return $pattern;
301
    }
302
303
    // ---- Define Relationships ----
304
305
    /**
306
     * Relationship to App\Models\Device
307
     *
308
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
309
     */
310
    public function devices()
311
    {
312
        return $this->belongsToMany('App\Models\Device', 'device_group_device', 'device_group_id', 'device_id');
313
    }
314
315
    /**
316
     * Relationship allows us to eager load device counts
317
     * DeviceGroups::with('deviceCountRelation')
318
     *
319
     * @return mixed
320
     */
321
    public function deviceCountRelation()
322
    {
323
        return $this->devices()->selectRaw('`device_group_device`.`device_group_id`, count(*) as count')->groupBy('pivot_device_group_id');
324
    }
325
}
326