Sql   F
last analyzed

Complexity

Total Complexity 70

Size/Duplication

Total Lines 408
Duplicated Lines 0 %

Test Coverage

Coverage 85.85%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 70
eloc 175
dl 0
loc 408
rs 2.8
c 2
b 1
f 0
ccs 182
cts 212
cp 0.8585

44 Methods

Rating   Name   Duplication   Size   Complexity  
A escFieldsForInsert() 0 3 1
A escValuesForInsert() 0 6 1
A clauses() 0 13 5
A sqlLimit() 0 6 1
A sqlJoin() 0 8 2
A clauseAndEmail() 0 3 1
A clauseAndPostNotIn() 0 3 1
A sqlOffset() 0 12 2
A clauseJoinRatingField() 0 5 1
A clauseAndTerms() 0 6 2
A clauseAndAssignedTerms() 0 3 1
A clauseAndDate() 0 19 5
A clauseJoinAssignedUsers() 0 6 1
A clauseAndRating() 0 5 2
A isCustomRatingField() 0 3 2
A clauseAndRatingField() 0 4 1
A sqlOrderBy() 0 19 4
A clauseJoinAssignedPostsTypes() 0 9 1
A clauseAndContent() 0 3 1
A clauseJoinStatus() 0 5 1
A joinKeyword() 0 3 2
A clauseJoinOrderBy() 0 5 1
A clauseJoinUserNotIn() 0 6 1
A ratingColumn() 0 3 1
A clauseAndPostIn() 0 3 1
A clauseAndType() 0 3 1
A join() 0 11 2
A normalizeAndClauses() 0 16 4
A clauseIfValueNotEmpty() 0 13 4
A clauseJoinDate() 0 6 1
A sqlWhere() 0 6 1
A clauseJoinAssignedTerms() 0 6 1
A clauseJoinUserIn() 0 6 1
A clauseAndStatus() 0 6 2
A sqlHandle() 0 3 1
A clauseAndAssignedUsers() 0 3 1
A clauseJoinContent() 0 6 1
A clauseAndIpAddress() 0 3 1
A clauseAndAssignedPosts() 0 3 1
A clauseAndUserNotIn() 0 3 1
A clauseJoinAssignedPosts() 0 6 1
A clauseAndUserIn() 0 3 1
A clauseAndAssignedPostsTypes() 0 3 1
A sql() 0 15 2

How to fix   Complexity   

Complex Class

Complex classes like Sql 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.

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 Sql, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace GeminiLabs\SiteReviews\Database;
4
5
use GeminiLabs\SiteReviews\Helper;
6
use GeminiLabs\SiteReviews\Helpers\Arr;
7
use GeminiLabs\SiteReviews\Helpers\Str;
8
9
trait Sql
10
{
11
    /** @var array */
12
    public $args;
13
14
    public \wpdb $db;
15
16 9
    protected array $joinStatements = [
17
        'assigned_posts' => "INNER JOIN table|assigned_posts AS apt ON (apt.rating_id = r.ID)",
18 9
        'assigned_terms' => "INNER JOIN table|assigned_terms AS att ON (att.rating_id = r.ID)",
19 9
        'assigned_users' => "INNER JOIN table|assigned_users AS aut ON (aut.rating_id = r.ID)",
20 9
        'postmeta' => "INNER JOIN table|postmeta AS pm ON (pm.post_id = r.review_id)",
21 9
        'posts' => "INNER JOIN table|posts AS p ON (p.ID = r.review_id)",
22 9
    ];
23
24 9
    public function clauses(string $clause, array $values = []): array
25 9
    {
26
        $prefix = Str::restrictTo('and,join', $clause);
27
        foreach ($this->args as $key => $value) {
28 9
            $method = Helper::buildMethodName('clause', $prefix, $key);
29
            if (!method_exists($this, $method) || Helper::isEmpty($value)) {
30
                continue;
31 24
            }
32
            if ($statement = call_user_func([$this, $method])) {
33 24
                $values[$key] = $statement;
34
            }
35
        }
36 44
        return $values;
37
    }
38 44
39 44
    public function escFieldsForInsert(array $fields): string
40 44
    {
41 44
        return sprintf('(`%s`)', implode('`,`', $fields));
42
    }
43
44
    public function escValuesForInsert(array $values): string
45
    {
46
        $values = array_values(
47 47
            array_map('\GeminiLabs\SiteReviews\Helpers\Cast::toString', array_map('esc_sql', $values))
48
        );
49 47
        return sprintf("('%s')", implode("','", $values));
50
    }
51
52
    /**
53
     * This method allows the following SQL syntax:
54
     *  - ALTER TABLE table|<unprefixed_table_name>
55 47
     *  - FROM table|<unprefixed_table_name>
56 47
     *  - JOIN table|<unprefixed_table_name>
57 47
     *  - TRUNCATE TABLE table|<unprefixed_table_name>
58 47
     *  - UPDATE table|<unprefixed_table_name>
59 47
     *
60 47
     * @param string|int ...$args Additional parameters will be passed to $wpdb->prepare()
61
     */
62 47
    public function sql(string $statement, ...$args): string
63 47
    {
64 47
        $handle = $this->sqlHandle();
65 47
        $statement = preg_replace('/ {12}/', '', $statement);
66 47
        $statement = glsr()->filterString("database/sql/{$handle}", $statement);
67
        $statement = preg_replace_callback('/(ALTER TABLE|FROM|JOIN|TRUNCATE TABLE|UPDATE)(\s+)(table\|)([^\s]+)/',
68
            fn ($m) => $m[1].$m[2].glsr(Tables::class)->table($m[4]),
69 8
            $statement
70
        );
71 8
        if (!empty($args)) {
72 8
            $statement = $this->db->prepare($statement, ...$args);
73 8
        }
74
        glsr()->action("database/sql/{$handle}", $statement);
75
        glsr()->action('database/sql', $statement, $handle);
76 2
        return $statement;
77
    }
78 2
79 2
    public function sqlJoin(array $additional = []): string
80 2
    {
81 2
        $joins = $this->clauses('join');
82
        foreach ($additional as $join) {
83
            array_unshift($joins, ($this->joinStatements[$join] ?? ''));
84 2
        }
85
        $joins = glsr()->filterArrayUnique('query/sql/join', $joins, $this->sqlHandle(), $this);
86 2
        return implode(' ', $joins);
87 2
    }
88 2
89 2
    public function sqlLimit(): string
90 2
    {
91
        $limit = Helper::ifTrue($this->args['per_page'] > 0,
92
            $this->db->prepare('LIMIT %d', $this->args['per_page'])
93 2
        );
94
        return glsr()->filterString('query/sql/limit', $limit, $this->sqlHandle(), $this);
95 2
    }
96 2
97 2
    public function sqlOffset(): string
98 2
    {
99 2
        if ($this->args['per_page'] < 1) {
100 2
            $offsetBy = $this->args['offset'];
101 2
        } else {
102 2
            $page = $this->args['page'] - 1;
103 2
            $offsetBy = ($page * $this->args['per_page']) + $this->args['offset'];
104
        }
105
        $offset = Helper::ifTrue($offsetBy > 0,
106
            $this->db->prepare('OFFSET %d', $offsetBy)
107 2
        );
108 2
        return glsr()->filterString('query/sql/offset', $offset, $this->sqlHandle(), $this);
109
    }
110
111 2
    public function sqlOrderBy(): string
112
    {
113
        $values = [
114 8
            'random' => 'RAND()',
115
        ];
116 8
        $order = $this->args['order'];
117 8
        $orderby = $this->args['orderby'];
118 8
        $orderedby = [];
119 8
        if (Str::startsWith($orderby, ['p.', 'r.'])) {
120
            $orderedby[] = "r.is_pinned {$order}";
121
            $orderedby[] = "{$orderby} {$order}";
122 2
        } elseif (array_key_exists($orderby, $values)) {
123
            $orderedby[] = $values[$orderby];
124 2
        }
125
        $orderedby = glsr()->filterArrayUnique('query/sql/order-by', $orderedby, $this->sqlHandle(), $this);
126
        if (empty($orderedby)) {
127
            return '';
128
        }
129
        return 'ORDER BY '.implode(', ', $orderedby);
130
    }
131
132 2
    public function sqlWhere(): string
133
    {
134 2
        $and = $this->clauses('and');
135
        $and = glsr()->filterArrayUnique('query/sql/and', $and, $this->sqlHandle(), $this);
136
        $and = $this->normalizeAndClauses($and);
137 2
        return trim('WHERE 1=1 '.implode(' ', $and));
138
    }
139 2
140
    protected function clauseAndAssignedPosts(): string
141
    {
142 1
        return $this->clauseIfValueNotEmpty('(apt.post_id IN (%s) AND apt.is_published = 1)', $this->args['assigned_posts']);
143
    }
144 1
145
    protected function clauseAndAssignedPostsTypes(): string
146
    {
147 8
        return $this->clauseIfValueNotEmpty('apt.is_published = 1', $this->args['assigned_posts_types']);
148
    }
149 8
150 8
    protected function clauseAndAssignedTerms(): string
151 8
    {
152 1
        return $this->clauseIfValueNotEmpty('(att.term_id IN (%s))', $this->args['assigned_terms']);
153
    }
154 8
155
    protected function clauseAndAssignedUsers(): string
156
    {
157 8
        return $this->clauseIfValueNotEmpty('(aut.user_id IN (%s))', $this->args['assigned_users']);
158
    }
159
160
    protected function clauseAndContent(): string
161
    {
162 8
        return $this->clauseIfValueNotEmpty('AND p.post_content = %s', $this->args['content']);
163 1
    }
164
165 7
    protected function clauseAndDate(): string
166
    {
167
        $clauses = [];
168 1
        $date = $this->args['date'];
169
        if (!empty($date['after'])) {
170 1
            $clauses[] = $this->db->prepare("(p.post_date >{$date['inclusive']} %s)", $date['after']);
171
        }
172
        if (!empty($date['before'])) {
173 1
            $clauses[] = $this->db->prepare("(p.post_date <{$date['inclusive']} %s)", $date['before']);
174
        }
175 1
        if (!empty($date['year'])) {
176
            $clauses[] = $this->db->prepare('(YEAR(p.post_date) = %d AND MONTH(p.post_date) = %d AND DAYOFMONTH(p.post_date) = %d)',
177
                $date['year'], $date['month'], $date['day']
178
            );
179
        }
180
        if ($clauses = implode(' AND ', $clauses)) {
181
            return sprintf('AND (%s)', $clauses);
182
        }
183
        return '';
184
    }
185
186
    protected function clauseAndEmail(): string
187
    {
188 8
        return $this->clauseIfValueNotEmpty('AND r.email = %s', $this->args['email']);
189
    }
190 8
191 8
    protected function clauseAndIpAddress(): string
192 8
    {
193 8
        return $this->clauseIfValueNotEmpty('AND r.ip_address = %s', $this->args['ip_address']);
194
    }
195
196 8
    protected function clauseAndPostIn(): string
197
    {
198 8
        return $this->clauseIfValueNotEmpty('AND r.review_id IN (%s)', $this->args['post__in']);
199 8
    }
200 8
201
    protected function clauseAndPostNotIn(): string
202
    {
203 44
        return $this->clauseIfValueNotEmpty('AND r.review_id NOT IN (%s)', $this->args['post__not_in']);
204
    }
205 44
206 44
    protected function clauseAndRating(): string
207
    {
208 2
        $column = $this->isCustomRatingField() ? 'pm.meta_value' : 'r.rating';
209
        return (string) Helper::ifTrue($this->args['rating'] > 0,
210
            $this->db->prepare("AND {$column} > %d", --$this->args['rating'])
211 8
        );
212
    }
213 8
214
    protected function clauseAndRatingField(): string
215
    {
216 8
        return (string) Helper::ifTrue($this->isCustomRatingField(),
217
            $this->db->prepare('AND pm.meta_key = %s', sprintf('_custom_%s', $this->args['rating_field']))
218
        );
219 44
    }
220
221 44
    protected function clauseAndStatus(): string
222
    {
223
        if (-1 === $this->args['status']) {
224 1
            return "AND p.post_status IN ('pending','publish')";
225
        }
226 1
        return $this->clauseIfValueNotEmpty('AND r.is_approved = %d', $this->args['status']);
227
    }
228
229
    protected function clauseAndTerms(): string
230
    {
231
        if (-1 !== $this->args['terms']) {
232
            return $this->clauseIfValueNotEmpty('AND r.terms = %d', $this->args['terms']);
233
        }
234
        return '';
235
    }
236
237 45
    protected function clauseAndType(): string
238
    {
239 45
        return $this->clauseIfValueNotEmpty('AND r.type = %s', $this->args['type']);
240 45
    }
241
242 45
    protected function clauseAndUserIn(): string
243 9
    {
244
        return $this->clauseIfValueNotEmpty('AND p.post_author IN (%s)', $this->args['user__in']);
245 44
    }
246 7
247 7
    protected function clauseAndUserNotIn(): string
248
    {
249 44
        return $this->clauseIfValueNotEmpty('AND p.post_author NOT IN (%s)', $this->args['user__not_in']);
250
    }
251
252 3
    /**
253
     * @param array|int|string $value
254 3
     */
255 3
    protected function clauseIfValueNotEmpty(string $clause, $value, bool $prepare = true): string
256 3
    {
257 3
        if (Helper::isEmpty($value)) {
258 3
            return '';
259
        }
260
        if (!$prepare) {
261
            return $clause;
262
        }
263
        if (is_array($value)) {
264
            $value = implode(',', Arr::uniqueInt($value));
265
            return sprintf($clause, $value); // this clause uses IN(%s) so we need to bypass db->prepare
266
        }
267
        return $this->db->prepare($clause, $value);
268
    }
269
270
    protected function clauseJoinAssignedPosts(): string
271
    {
272 3
        return $this->clauseIfValueNotEmpty(
273
            $this->join('assigned_posts', $this->joinKeyword()),
274 3
            $this->args['assigned_posts'],
275 3
            $prepare = false
276 3
        );
277 3
    }
278 3
279
    protected function clauseJoinAssignedPostsTypes(): string
280
    {
281 3
        $clause1 = $this->join('assigned_posts', $this->joinKeyword());
282
        $clause2 = "INNER JOIN table|posts AS pt ON (pt.ID = apt.post_id AND pt.post_type IN ('%s'))";
283 3
        $values = Arr::unique($this->args['assigned_posts_types']);
284 3
        $values = array_map('esc_sql', $values);
285 3
        $values = array_filter($values, 'is_string'); // for phpstan
286 3
        $values = implode("','", $values);
287 3
        return sprintf(sprintf('%s %s', $clause1, $clause2), $values);
288
    }
289
290 1
    protected function clauseJoinAssignedTerms(): string
291
    {
292 1
        return $this->clauseIfValueNotEmpty(
293 1
            $this->join('assigned_terms', $this->joinKeyword()),
294 1
            $this->args['assigned_terms'],
295 1
            $prepare = false
296 1
        );
297
    }
298
299 9
    protected function clauseJoinAssignedUsers(): string
300
    {
301 9
        return $this->clauseIfValueNotEmpty(
302 9
            $this->join('assigned_users', $this->joinKeyword()),
303 9
            $this->args['assigned_users'],
304 9
            $prepare = false
305 9
        );
306
    }
307
308 1
    protected function clauseJoinContent(): string
309
    {
310 1
        return $this->clauseIfValueNotEmpty(
311 1
            $this->join('posts', 'INNER JOIN'),
312 1
            $this->args['content'],
313 1
            $prepare = false
314 1
        );
315
    }
316
317
    protected function clauseJoinDate(): string
318
    {
319
        return $this->clauseIfValueNotEmpty(
320
            $this->join('posts', 'INNER JOIN'),
321
            array_filter($this->args['date']),
322
            $prepare = false
323
        );
324
    }
325
326 3
    protected function clauseJoinUserIn(): string
327
    {
328 3
        return $this->clauseIfValueNotEmpty(
329 3
            $this->join('posts', 'INNER JOIN'),
330 3
            $this->args['user__in'],
331
            $prepare = false
332
        );
333 9
    }
334
335 9
    protected function clauseJoinUserNotIn(): string
336 9
    {
337 9
        return $this->clauseIfValueNotEmpty(
338
            $this->join('posts', 'INNER JOIN'),
339
            $this->args['user__not_in'],
340 9
            $prepare = false
341
        );
342 9
    }
343 9
344 9
    protected function clauseJoinOrderBy(): string
345
    {
346
        return (string) Helper::ifTrue(
347 45
            str_starts_with($this->args['orderby'], 'p.'),
348
            $this->join('posts', 'INNER JOIN')
349 45
        );
350
    }
351
352 7
    protected function clauseJoinRatingField(): string
353
    {
354 7
        return (string) Helper::ifTrue(
355 7
            $this->isCustomRatingField(),
356
            $this->join('postmeta', 'INNER JOIN')
357
        );
358 8
    }
359
360 8
    protected function clauseJoinStatus(): string
361 8
    {
362 8
        return (string) Helper::ifTrue(
363 6
            $this->args['status'] === -1,
364 6
            $this->join('posts', 'INNER JOIN')
365
        );
366
    }
367 8
368 8
    protected function isCustomRatingField(): bool
369 8
    {
370 8
        return 'rating' !== $this->args['rating_field'] && !empty($this->args['rating_field']);
371 6
    }
372
373 8
    protected function join(string $join, string $joinKeyword): string
374
    {
375
        $joinKeywords = [
376 44
            'CROSS JOIN', 'INNER JOIN', 'LEFT JOIN', 'NATURAL JOIN', 'RIGHT JOIN',
377
        ];
378 44
        if (!in_array($joinKeyword, $joinKeywords)) {
379
            $joinKeyword = 'INNER JOIN';
380
        }
381 47
        $statement = $this->joinStatements[$join] ?? '';
382
        $statement = str_replace('INNER JOIN', $joinKeyword, $statement);
383 47
        return $statement;
384
    }
385
386
    protected function joinKeyword(): string
387
    {
388
        return 'loose' === glsr_get_option('reviews.assignment') ? 'LEFT JOIN' : 'INNER JOIN';
389
    }
390
391
    protected function normalizeAndClauses(array $and): array
392
    {
393
        $clauses = [];
394
        foreach ($and as $key => $value) {
395
            if (str_starts_with($key, 'assigned_')) {
396
                $clauses[] = $value;
397
                unset($and[$key]);
398
            }
399
        }
400
        $operator = glsr()->filterString('query/sql/clause/operator', 'OR', $clauses, $this->args);
401
        $operator = strtoupper($operator);
402
        $operator = Helper::ifTrue(in_array($operator, ['AND', 'OR']), $operator, 'OR');
403
        if ($clauses = implode(" {$operator} ", $clauses)) {
404
            $and['assigned'] = "AND ($clauses)";
405
        }
406
        return $and;
407
    }
408
409
    protected function ratingColumn(): string
410
    {
411
        return Helper::ifTrue($this->isCustomRatingField(), 'pm.meta_value', 'r.rating');
412
    }
413
414
    protected function sqlHandle(int $depth = 2): string
415
    {
416
        return Str::dashCase(Arr::get((new \Exception())->getTrace(), $depth.'.function'));
417
    }
418
}
419