Test Failed
Push — develop ( 6b8a37...6ff246 )
by Paul
08:31
created

Sql::join()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 11
ccs 7
cts 7
cp 1
rs 10
cc 2
nc 2
nop 2
crap 2
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
        $offsetBy = (($this->args['page'] - 1) * $this->args['per_page']) + $this->args['offset'];
100 2
        $offset = Helper::ifTrue($offsetBy > 0,
101 2
            $this->db->prepare('OFFSET %d', $offsetBy)
102 2
        );
103 2
        return glsr()->filterString('query/sql/offset', $offset, $this->sqlHandle(), $this);
104
    }
105
106
    public function sqlOrderBy(): string
107 2
    {
108 2
        $values = [
109
            'random' => 'RAND()',
110
        ];
111 2
        $order = $this->args['order'];
112
        $orderby = $this->args['orderby'];
113
        $orderedby = [];
114 8
        if (Str::startsWith($orderby, ['p.', 'r.'])) {
115
            $orderedby[] = "r.is_pinned {$order}";
116 8
            $orderedby[] = "{$orderby} {$order}";
117 8
        } elseif (array_key_exists($orderby, $values)) {
118 8
            $orderedby[] = $values[$orderby];
119 8
        }
120
        $orderedby = glsr()->filterArrayUnique('query/sql/order-by', $orderedby, $this->sqlHandle(), $this);
121
        if (empty($orderedby)) {
122 2
            return '';
123
        }
124 2
        return 'ORDER BY '.implode(', ', $orderedby);
125
    }
126
127
    public function sqlWhere(): string
128
    {
129
        $and = $this->clauses('and');
130
        $and = glsr()->filterArrayUnique('query/sql/and', $and, $this->sqlHandle(), $this);
131
        $and = $this->normalizeAndClauses($and);
132 2
        return 'WHERE 1=1 '.implode(' ', $and);
133
    }
134 2
135
    protected function clauseAndAssignedPosts(): string
136
    {
137 2
        return $this->clauseIfValueNotEmpty('(apt.post_id IN (%s) AND apt.is_published = 1)', $this->args['assigned_posts']);
138
    }
139 2
140
    protected function clauseAndAssignedPostsTypes(): string
141
    {
142 1
        return $this->clauseIfValueNotEmpty('apt.is_published = 1', $this->args['assigned_posts_types']);
143
    }
144 1
145
    protected function clauseAndAssignedTerms(): string
146
    {
147 8
        return $this->clauseIfValueNotEmpty('(att.term_id IN (%s))', $this->args['assigned_terms']);
148
    }
149 8
150 8
    protected function clauseAndAssignedUsers(): string
151 8
    {
152 1
        return $this->clauseIfValueNotEmpty('(aut.user_id IN (%s))', $this->args['assigned_users']);
153
    }
154 8
155
    protected function clauseAndContent(): string
156
    {
157 8
        return $this->clauseIfValueNotEmpty('AND p.post_content = %s', $this->args['content']);
158
    }
159
160
    protected function clauseAndDate(): string
161
    {
162 8
        $clauses = [];
163 1
        $date = $this->args['date'];
164
        if (!empty($date['after'])) {
165 7
            $clauses[] = $this->db->prepare("(p.post_date >{$date['inclusive']} %s)", $date['after']);
166
        }
167
        if (!empty($date['before'])) {
168 1
            $clauses[] = $this->db->prepare("(p.post_date <{$date['inclusive']} %s)", $date['before']);
169
        }
170 1
        if (!empty($date['year'])) {
171
            $clauses[] = $this->db->prepare('(YEAR(p.post_date) = %d AND MONTH(p.post_date) = %d AND DAYOFMONTH(p.post_date) = %d)',
172
                $date['year'], $date['month'], $date['day']
173 1
            );
174
        }
175 1
        if ($clauses = implode(' AND ', $clauses)) {
176
            return sprintf('AND (%s)', $clauses);
177
        }
178
        return '';
179
    }
180
181
    protected function clauseAndEmail(): string
182
    {
183
        return $this->clauseIfValueNotEmpty('AND r.email = %s', $this->args['email']);
184
    }
185
186
    protected function clauseAndIpAddress(): string
187
    {
188 8
        return $this->clauseIfValueNotEmpty('AND r.ip_address = %s', $this->args['ip_address']);
189
    }
190 8
191 8
    protected function clauseAndPostIn(): string
192 8
    {
193 8
        return $this->clauseIfValueNotEmpty('AND r.review_id IN (%s)', $this->args['post__in']);
194
    }
195
196 8
    protected function clauseAndPostNotIn(): string
197
    {
198 8
        return $this->clauseIfValueNotEmpty('AND r.review_id NOT IN (%s)', $this->args['post__not_in']);
199 8
    }
200 8
201
    protected function clauseAndRating(): string
202
    {
203 44
        $column = $this->isCustomRatingField() ? 'pm.meta_value' : 'r.rating';
204
        return (string) Helper::ifTrue($this->args['rating'] > 0,
205 44
            $this->db->prepare("AND {$column} > %d", --$this->args['rating'])
206 44
        );
207
    }
208 2
209
    protected function clauseAndRatingField(): string
210
    {
211 8
        return (string) Helper::ifTrue($this->isCustomRatingField(),
212
            $this->db->prepare('AND pm.meta_key = %s', sprintf('_custom_%s', $this->args['rating_field']))
213 8
        );
214
    }
215
216 8
    protected function clauseAndStatus(): string
217
    {
218
        if (-1 === $this->args['status']) {
219 44
            return "AND p.post_status IN ('pending','publish')";
220
        }
221 44
        return $this->clauseIfValueNotEmpty('AND r.is_approved = %d', $this->args['status']);
222
    }
223
224 1
    protected function clauseAndTerms(): string
225
    {
226 1
        if (-1 !== $this->args['terms']) {
227
            return $this->clauseIfValueNotEmpty('AND r.terms = %d', $this->args['terms']);
228
        }
229
        return '';
230
    }
231
232
    protected function clauseAndType(): string
233
    {
234
        return $this->clauseIfValueNotEmpty('AND r.type = %s', $this->args['type']);
235
    }
236
237 45
    protected function clauseAndUserIn(): string
238
    {
239 45
        return $this->clauseIfValueNotEmpty('AND p.post_author IN (%s)', $this->args['user__in']);
240 45
    }
241
242 45
    protected function clauseAndUserNotIn(): string
243 9
    {
244
        return $this->clauseIfValueNotEmpty('AND p.post_author NOT IN (%s)', $this->args['user__not_in']);
245 44
    }
246 7
247 7
    /**
248
     * @param array|int|string $value
249 44
     */
250
    protected function clauseIfValueNotEmpty(string $clause, $value, bool $prepare = true): string
251
    {
252 3
        if (Helper::isEmpty($value)) {
253
            return '';
254 3
        }
255 3
        if (!$prepare) {
256 3
            return $clause;
257 3
        }
258 3
        if (is_array($value)) {
259
            $value = implode(',', Arr::uniqueInt($value));
260
            return sprintf($clause, $value); // this clause uses IN(%s) so we need to bypass db->prepare
261
        }
262
        return $this->db->prepare($clause, $value);
263
    }
264
265
    protected function clauseJoinAssignedPosts(): string
266
    {
267
        return $this->clauseIfValueNotEmpty(
268
            $this->join('assigned_posts', $this->joinKeyword()),
269
            $this->args['assigned_posts'],
270
            $prepare = false
271
        );
272 3
    }
273
274 3
    protected function clauseJoinAssignedPostsTypes(): string
275 3
    {
276 3
        $clause1 = $this->join('assigned_posts', $this->joinKeyword());
277 3
        $clause2 = "INNER JOIN table|posts AS pt ON (pt.ID = apt.post_id AND pt.post_type IN ('%s'))";
278 3
        $values = Arr::unique($this->args['assigned_posts_types']);
279
        $values = array_map('esc_sql', $values);
280
        $values = array_filter($values, 'is_string'); // for phpstan
281 3
        $values = implode("','", $values);
282
        return sprintf(sprintf('%s %s', $clause1, $clause2), $values);
283 3
    }
284 3
285 3
    protected function clauseJoinAssignedTerms(): string
286 3
    {
287 3
        return $this->clauseIfValueNotEmpty(
288
            $this->join('assigned_terms', $this->joinKeyword()),
289
            $this->args['assigned_terms'],
290 1
            $prepare = false
291
        );
292 1
    }
293 1
294 1
    protected function clauseJoinAssignedUsers(): string
295 1
    {
296 1
        return $this->clauseIfValueNotEmpty(
297
            $this->join('assigned_users', $this->joinKeyword()),
298
            $this->args['assigned_users'],
299 9
            $prepare = false
300
        );
301 9
    }
302 9
303 9
    protected function clauseJoinContent(): string
304 9
    {
305 9
        return $this->clauseIfValueNotEmpty(
306
            $this->join('posts', 'INNER JOIN'),
307
            $this->args['content'],
308 1
            $prepare = false
309
        );
310 1
    }
311 1
312 1
    protected function clauseJoinDate(): string
313 1
    {
314 1
        return $this->clauseIfValueNotEmpty(
315
            $this->join('posts', 'INNER JOIN'),
316
            array_filter($this->args['date']),
317
            $prepare = false
318
        );
319
    }
320
321
    protected function clauseJoinUserIn(): string
322
    {
323
        return $this->clauseIfValueNotEmpty(
324
            $this->join('posts', 'INNER JOIN'),
325
            $this->args['user__in'],
326 3
            $prepare = false
327
        );
328 3
    }
329 3
330 3
    protected function clauseJoinUserNotIn(): string
331
    {
332
        return $this->clauseIfValueNotEmpty(
333 9
            $this->join('posts', 'INNER JOIN'),
334
            $this->args['user__not_in'],
335 9
            $prepare = false
336 9
        );
337 9
    }
338
339
    protected function clauseJoinOrderBy(): string
340 9
    {
341
        return (string) Helper::ifTrue(
342 9
            str_starts_with($this->args['orderby'], 'p.'),
343 9
            $this->join('posts', 'INNER JOIN')
344 9
        );
345
    }
346
347 45
    protected function clauseJoinRatingField(): string
348
    {
349 45
        return (string) Helper::ifTrue(
350
            $this->isCustomRatingField(),
351
            $this->join('postmeta', 'INNER JOIN')
352 7
        );
353
    }
354 7
355 7
    protected function clauseJoinStatus(): string
356
    {
357
        return (string) Helper::ifTrue(
358 8
            $this->args['status'] === -1,
359
            $this->join('posts', 'INNER JOIN')
360 8
        );
361 8
    }
362 8
363 6
    protected function isCustomRatingField(): bool
364 6
    {
365
        return 'rating' !== $this->args['rating_field'] && !empty($this->args['rating_field']);
366
    }
367 8
368 8
    protected function join(string $join, string $joinKeyword): string
369 8
    {
370 8
        $joinKeywords = [
371 6
            'CROSS JOIN', 'INNER JOIN', 'LEFT JOIN', 'NATURAL JOIN', 'RIGHT JOIN',
372
        ];
373 8
        if (!in_array($joinKeyword, $joinKeywords)) {
374
            $joinKeyword = 'INNER JOIN';
375
        }
376 44
        $statement = $this->joinStatements[$join] ?? '';
377
        $statement = str_replace('INNER JOIN', $joinKeyword, $statement);
378 44
        return $statement;
379
    }
380
381 47
    protected function joinKeyword(): string
382
    {
383 47
        return 'loose' === glsr_get_option('reviews.assignment') ? 'LEFT JOIN' : 'INNER JOIN';
384
    }
385
386
    protected function normalizeAndClauses(array $and): array
387
    {
388
        $clauses = [];
389
        foreach ($and as $key => $value) {
390
            if (str_starts_with($key, 'assigned_')) {
391
                $clauses[] = $value;
392
                unset($and[$key]);
393
            }
394
        }
395
        $operator = glsr()->filterString('query/sql/clause/operator', 'OR', $clauses, $this->args);
396
        $operator = strtoupper($operator);
397
        $operator = Helper::ifTrue(in_array($operator, ['AND', 'OR']), $operator, 'OR');
398
        if ($clauses = implode(" {$operator} ", $clauses)) {
399
            $and['assigned'] = "AND ($clauses)";
400
        }
401
        return $and;
402
    }
403
404
    protected function ratingColumn(): string
405
    {
406
        return Helper::ifTrue($this->isCustomRatingField(), 'pm.meta_value', 'r.rating');
407
    }
408
409
    protected function sqlHandle(int $depth = 2): string
410
    {
411
        return Str::dashCase(Arr::get((new \Exception())->getTrace(), $depth.'.function'));
412
    }
413
}
414