Sql::normalizeAndClauses()   A
last analyzed

Complexity

Conditions 4
Paths 6

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

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