Completed
Push — develop ( 16e50c...73e4c3 )
by Timothy
05:53
created

QueryBuilder   D

Complexity

Total Complexity 114

Size/Duplication

Total Lines 1278
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 1278
rs 4.4102
c 1
b 0
f 0
wmc 114
lcom 1
cbo 4

59 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 1
A __destruct() 0 4 1
A __call() 0 9 2
B select() 0 34 5
A selectMax() 0 6 1
A selectMin() 0 6 1
A selectAvg() 0 6 1
A selectSum() 0 6 1
A distinct() 0 6 1
A explain() 0 5 1
A from() 0 15 1
A like() 0 4 1
A orLike() 0 4 1
A notLike() 0 4 1
A orNotLike() 0 4 1
A having() 0 4 1
A orHaving() 0 4 1
A where() 0 4 1
A orWhere() 0 4 1
A whereIn() 0 4 1
A orWhereIn() 0 4 1
A whereNotIn() 0 4 1
A orWhereNotIn() 0 4 1
B set() 0 31 2
A join() 0 16 1
A groupBy() 0 18 2
B orderBy() 0 31 4
A limit() 0 7 1
A groupStart() 0 8 2
A notGroupStart() 0 8 2
A orGroupStart() 0 6 1
A orNotGroupStart() 0 6 1
A groupEnd() 0 6 1
A get() 0 16 3
A getWhere() 0 8 1
A countAll() 0 6 1
A countAllResults() 0 13 2
A insert() 0 9 2
A insertBatch() 0 9 2
A update() 0 9 2
A updateBatch() 0 9 2
A replace() 0 9 2
A delete() 0 10 2
A getCompiledSelect() 0 10 2
A getCompiledInsert() 0 4 1
A getCompiledUpdate() 0 4 1
A getCompiledDelete() 0 4 1
A resetQuery() 0 5 1
A _select() 0 13 2
A _getCompile() 0 12 2
B _like() 0 28 4
B _having() 0 27 4
A _where() 0 22 3
B _whereString() 0 37 6
A _whereIn() 0 13 2
B _run() 0 32 5
B _appendQuery() 0 28 4
B _compileType() 0 45 6
B _compile() 0 46 6

How to fix   Complexity   

Complex Class

Complex classes like QueryBuilder 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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

1
<?php declare(strict_types=1);
2
/**
3
 * Query
4
 *
5
 * SQL Query Builder / Database Abstraction Layer
6
 *
7
 * PHP version 7.1
8
 *
9
 * @package     Query
10
 * @author      Timothy J. Warren <[email protected]>
11
 * @copyright   2012 - 2018 Timothy J. Warren
12
 * @license     http://www.opensource.org/licenses/mit-license.html  MIT License
13
 * @link        https://git.timshomepage.net/aviat4ion/Query
14
 */
15
namespace Query;
16
17
use BadMethodCallException;
18
use PDOStatement;
19
use Query\Drivers\DriverInterface;
20
21
/**
22
 * Convenience class for creating sql queries
23
 */
24
class QueryBuilder implements QueryBuilderInterface {
25
26
	/**
27
	 * Convenience property for connection management
28
	 * @var string
29
	 */
30
	public $connName = '';
31
32
	/**
33
	 * List of queries executed
34
	 * @var array
35
	 */
36
	public $queries = [
37
		'total_time' => 0
38
	];
39
40
	/**
41
	 * Whether to do only an explain on the query
42
	 * @var boolean
43
	 */
44
	protected $explain = FALSE;
45
46
	/**
47
	 * The current database driver
48
	 * @var DriverInterface
49
	 */
50
	public $driver;
51
52
	/**
53
	 * Query parser class instance
54
	 * @var QueryParser
55
	 */
56
	protected $parser;
57
58
	/**
59
	 * Query Builder state
60
	 * @var State
61
	 */
62
	protected $state;
63
64
	// --------------------------------------------------------------------------
65
	// ! Methods
66
	// --------------------------------------------------------------------------
67
68
	/**
69
	 * Constructor
70
	 *
71
	 * @param DriverInterface $driver
72
	 * @param QueryParser $parser
73
	 */
74
	public function __construct(DriverInterface $driver, QueryParser $parser)
75
	{
76
		// Inject driver and parser
77
		$this->driver = $driver;
78
		$this->parser = $parser;
79
80
		// Create new State object
81
		$this->state = new State();
82
	}
83
84
	/**
85
	 * Destructor
86
	 * @codeCoverageIgnore
87
	 */
88
	public function __destruct()
89
	{
90
		$this->driver = NULL;
91
	}
92
93
	/**
94
	 * Calls a function further down the inheritance chain
95
	 *
96
	 * @param string $name
97
	 * @param array $params
98
	 * @return mixed
99
	 * @throws BadMethodCallException
100
	 */
101
	public function __call(string $name, array $params)
102
	{
103
		if (method_exists($this->driver, $name))
104
		{
105
			return \call_user_func_array([$this->driver, $name], $params);
106
		}
107
108
		throw new BadMethodCallException('Method does not exist');
109
	}
110
111
	// --------------------------------------------------------------------------
112
	// ! Select Queries
113
	// --------------------------------------------------------------------------
114
115
	/**
116
	 * Specifies rows to select in a query
117
	 *
118
	 * @param string $fields
119
	 * @return QueryBuilderInterface
120
	 */
121
	public function select(string $fields): QueryBuilderInterface
122
	{
123
		// Split fields by comma
124
		$fieldsArray = explode(',', $fields);
125
		$fieldsArray = array_map('mb_trim', $fieldsArray);
126
127
		// Split on 'As'
128
		foreach ($fieldsArray as $key => $field)
129
		{
130
			if (stripos($field, 'as') !== FALSE)
131
			{
132
				$fieldsArray[$key] = preg_split('` as `i', $field);
133
				$fieldsArray[$key] = array_map('mb_trim', $fieldsArray[$key]);
134
			}
135
		}
136
137
		// Quote the identifiers
138
		$safeArray = $this->driver->quoteIdent($fieldsArray);
139
140
		unset($fieldsArray);
141
142
		// Join the strings back together
143
		for($i = 0, $c = count($safeArray); $i < $c; $i++)
144
		{
145
			if (\is_array($safeArray[$i]))
146
			{
147
				$safeArray[$i] = implode(' AS ', $safeArray[$i]);
148
			}
149
		}
150
151
		$this->state->appendSelectString(implode(', ', $safeArray));
152
153
		return $this;
154
	}
155
156
	/**
157
	 * Selects the maximum value of a field from a query
158
	 *
159
	 * @param string $field
160
	 * @param string|bool $as
161
	 * @return QueryBuilderInterface
162
	 */
163
	public function selectMax(string $field, $as=FALSE): QueryBuilderInterface
164
	{
165
		// Create the select string
166
		$this->state->appendSelectString(' MAX'.$this->_select($field, $as));
167
		return $this;
168
	}
169
170
	/**
171
	 * Selects the minimum value of a field from a query
172
	 *
173
	 * @param string $field
174
	 * @param string|bool $as
175
	 * @return QueryBuilderInterface
176
	 */
177
	public function selectMin(string $field, $as=FALSE): QueryBuilderInterface
178
	{
179
		// Create the select string
180
		$this->state->appendSelectString(' MIN'.$this->_select($field, $as));
181
		return $this;
182
	}
183
184
	/**
185
	 * Selects the average value of a field from a query
186
	 *
187
	 * @param string $field
188
	 * @param string|bool $as
189
	 * @return QueryBuilderInterface
190
	 */
191
	public function selectAvg(string $field, $as=FALSE): QueryBuilderInterface
192
	{
193
		// Create the select string
194
		$this->state->appendSelectString(' AVG'.$this->_select($field, $as));
195
		return $this;
196
	}
197
198
	/**
199
	 * Selects the sum of a field from a query
200
	 *
201
	 * @param string $field
202
	 * @param string|bool $as
203
	 * @return QueryBuilderInterface
204
	 */
205
	public function selectSum(string $field, $as=FALSE): QueryBuilderInterface
206
	{
207
		// Create the select string
208
		$this->state->appendSelectString(' SUM'.$this->_select($field, $as));
209
		return $this;
210
	}
211
212
	/**
213
	 * Adds the 'distinct' keyword to a query
214
	 *
215
	 * @return QueryBuilderInterface
216
	 */
217
	public function distinct(): QueryBuilderInterface
218
	{
219
		// Prepend the keyword to the select string
220
		$this->state->setSelectString(' DISTINCT' . $this->state->getSelectString());
221
		return $this;
222
	}
223
224
	/**
225
	 * Tell the database to give you the query plan instead of result set
226
	 *
227
	 * @return QueryBuilderInterface
228
	 */
229
	public function explain(): QueryBuilderInterface
230
	{
231
		$this->explain = TRUE;
232
		return $this;
233
	}
234
235
	/**
236
	 * Specify the database table to select from
237
	 *
238
	 * @param string $tblname
239
	 * @return QueryBuilderInterface
240
	 */
241
	public function from(string $tblname): QueryBuilderInterface
242
	{
243
		// Split identifiers on spaces
244
		$identArray = explode(' ', \mb_trim($tblname));
245
		$identArray = array_map('\\mb_trim', $identArray);
246
247
		// Quote the identifiers
248
		$identArray[0] = $this->driver->quoteTable($identArray[0]);
249
		$identArray = $this->driver->quoteIdent($identArray);
250
251
		// Paste it back together
252
		$this->state->setFromString(implode(' ', $identArray));
253
254
		return $this;
255
	}
256
257
	// --------------------------------------------------------------------------
258
	// ! 'Like' methods
259
	// --------------------------------------------------------------------------
260
261
	/**
262
	 * Creates a Like clause in the sql statement
263
	 *
264
	 * @param string $field
265
	 * @param mixed $val
266
	 * @param string $pos
267
	 * @return QueryBuilderInterface
268
	 */
269
	public function like(string $field, $val, string $pos='both'): QueryBuilderInterface
270
	{
271
		return $this->_like($field, $val, $pos);
272
	}
273
274
	/**
275
	 * Generates an OR Like clause
276
	 *
277
	 * @param string $field
278
	 * @param mixed $val
279
	 * @param string $pos
280
	 * @return QueryBuilderInterface
281
	 */
282
	public function orLike(string $field, $val, string $pos='both'): QueryBuilderInterface
283
	{
284
		return $this->_like($field, $val, $pos, 'LIKE', 'OR');
285
	}
286
287
	/**
288
	 * Generates a NOT LIKE clause
289
	 *
290
	 * @param string $field
291
	 * @param mixed $val
292
	 * @param string $pos
293
	 * @return QueryBuilderInterface
294
	 */
295
	public function notLike(string $field, $val, string $pos='both'): QueryBuilderInterface
296
	{
297
		return $this->_like($field, $val, $pos, 'NOT LIKE');
298
	}
299
300
	/**
301
	 * Generates a OR NOT LIKE clause
302
	 *
303
	 * @param string $field
304
	 * @param mixed $val
305
	 * @param string $pos
306
	 * @return QueryBuilderInterface
307
	 */
308
	public function orNotLike(string $field, $val, string $pos='both'): QueryBuilderInterface
309
	{
310
		return $this->_like($field, $val, $pos, 'NOT LIKE', 'OR');
311
	}
312
313
	// --------------------------------------------------------------------------
314
	// ! Having methods
315
	// --------------------------------------------------------------------------
316
317
	/**
318
	 * Generates a 'Having' clause
319
	 *
320
	 * @param mixed $key
321
	 * @param mixed $val
322
	 * @return QueryBuilderInterface
323
	 */
324
	public function having($key, $val=[]): QueryBuilderInterface
325
	{
326
		return $this->_having($key, $val);
327
	}
328
329
	/**
330
	 * Generates a 'Having' clause prefixed with 'OR'
331
	 *
332
	 * @param mixed $key
333
	 * @param mixed $val
334
	 * @return QueryBuilderInterface
335
	 */
336
	public function orHaving($key, $val=[]): QueryBuilderInterface
337
	{
338
		return $this->_having($key, $val, 'OR');
339
	}
340
341
	// --------------------------------------------------------------------------
342
	// ! 'Where' methods
343
	// --------------------------------------------------------------------------
344
345
	/**
346
	 * Specify condition(s) in the where clause of a query
347
	 * Note: this function works with key / value, or a
348
	 * passed array with key / value pairs
349
	 *
350
	 * @param mixed $key
351
	 * @param mixed $val
352
	 * @param mixed $escape
353
	 * @return QueryBuilderInterface
354
	 */
355
	public function where($key, $val=[], $escape=NULL): QueryBuilderInterface
356
	{
357
		return $this->_whereString($key, $val);
358
	}
359
360
	/**
361
	 * Where clause prefixed with "OR"
362
	 *
363
	 * @param string $key
364
	 * @param mixed $val
365
	 * @return QueryBuilderInterface
366
	 */
367
	public function orWhere($key, $val=[]): QueryBuilderInterface
368
	{
369
		return $this->_whereString($key, $val, 'OR');
370
	}
371
372
	/**
373
	 * Where clause with 'IN' statement
374
	 *
375
	 * @param mixed $field
376
	 * @param mixed $val
377
	 * @return QueryBuilderInterface
378
	 */
379
	public function whereIn($field, $val=[]): QueryBuilderInterface
380
	{
381
		return $this->_whereIn($field, $val);
382
	}
383
384
	/**
385
	 * Where in statement prefixed with "or"
386
	 *
387
	 * @param string $field
388
	 * @param mixed $val
389
	 * @return QueryBuilderInterface
390
	 */
391
	public function orWhereIn($field, $val=[]): QueryBuilderInterface
392
	{
393
		return $this->_whereIn($field, $val, 'IN', 'OR');
394
	}
395
396
	/**
397
	 * WHERE NOT IN (FOO) clause
398
	 *
399
	 * @param string $field
400
	 * @param mixed $val
401
	 * @return QueryBuilderInterface
402
	 */
403
	public function whereNotIn($field, $val=[]): QueryBuilderInterface
404
	{
405
		return $this->_whereIn($field, $val, 'NOT IN');
406
	}
407
408
	/**
409
	 * OR WHERE NOT IN (FOO) clause
410
	 *
411
	 * @param string $field
412
	 * @param mixed $val
413
	 * @return QueryBuilderInterface
414
	 */
415
	public function orWhereNotIn($field, $val=[]): QueryBuilderInterface
416
	{
417
		return $this->_whereIn($field, $val, 'NOT IN', 'OR');
418
	}
419
420
	// --------------------------------------------------------------------------
421
	// ! Other Query Modifier methods
422
	// --------------------------------------------------------------------------
423
424
	/**
425
	 * Sets values for inserts / updates / deletes
426
	 *
427
	 * @param mixed $key
428
	 * @param mixed $val
429
	 * @return QueryBuilderInterface
430
	 */
431
	public function set($key, $val = NULL): QueryBuilderInterface
432
	{
433
		if (is_scalar($key))
434
		{
435
			$pairs = [$key => $val];
436
		}
437
		else
438
		{
439
			$pairs = $key;
440
		}
441
442
		$keys = array_keys($pairs);
443
		$values = array_values($pairs);
444
445
		$this->state->appendSetArrayKeys($keys);
446
		$this->state->appendValues($values);
447
448
		// Use the keys of the array to make the insert/update string
449
		// Escape the field names
450
		$this->state->setSetArrayKeys(
451
			array_map([$this->driver, '_quote'], $this->state->getSetArrayKeys())
452
		);
453
454
		// Generate the "set" string
455
		$setString = implode('=?,', $this->state->getSetArrayKeys());
456
		$setString .= '=?';
457
458
		$this->state->setSetString($setString);
459
460
		return $this;
461
	}
462
463
	/**
464
	 * Creates a join phrase in a compiled query
465
	 *
466
	 * @param string $table
467
	 * @param string $condition
468
	 * @param string $type
469
	 * @return QueryBuilderInterface
470
	 */
471
	public function join(string $table, string $condition, string $type=''): QueryBuilderInterface
472
	{
473
		// Prefix and quote table name
474
		$table = explode(' ', mb_trim($table));
475
		$table[0] = $this->driver->quoteTable($table[0]);
476
		$table = $this->driver->quoteIdent($table);
477
		$table = implode(' ', $table);
478
479
		// Parse out the join condition
480
		$parsedCondition = $this->parser->compileJoin($condition);
481
		$condition = $table . ' ON ' . $parsedCondition;
482
483
		$this->state->appendMap("\n" . strtoupper($type) . ' JOIN ', $condition, 'join');
484
485
		return $this;
486
	}
487
488
	/**
489
	 * Group the results by the selected field(s)
490
	 *
491
	 * @param mixed $field
492
	 * @return QueryBuilderInterface
493
	 */
494
	public function groupBy($field): QueryBuilderInterface
495
	{
496
		if ( ! is_scalar($field))
497
		{
498
			$newGroupArray = array_map([$this->driver, 'quoteIdent'], $field);
499
			$this->state->setGroupArray(
500
				array_merge($this->state->getGroupArray(), $newGroupArray)
501
			);
502
		}
503
		else
504
		{
505
			$this->state->appendGroupArray($this->driver->quoteIdent($field));
0 ignored issues
show
Bug introduced by
It seems like $this->driver->quoteIdent($field) targeting Query\Drivers\DriverInterface::quoteIdent() can also be of type array; however, Query\State::appendGroupArray() does only seem to accept string, maybe add an additional type check?

This check looks at variables that 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...
506
		}
507
508
		$this->state->setGroupString(' GROUP BY ' . implode(',', $this->state->getGroupArray()));
509
510
		return $this;
511
	}
512
513
	/**
514
	 * Order the results by the selected field(s)
515
	 *
516
	 * @param string $field
517
	 * @param string $type
518
	 * @return QueryBuilderInterface
519
	 */
520
	public function orderBy(string $field, string $type=''): QueryBuilderInterface
521
	{
522
		// When ordering by random, do an ascending order if the driver
523
		// doesn't support random ordering
524
		if (stripos($type, 'rand') !== FALSE)
525
		{
526
			$rand = $this->driver->getSql()->random();
527
			$type = $rand ?? 'ASC';
528
		}
529
530
		// Set fields for later manipulation
531
		$field = $this->driver->quoteIdent($field);
532
		$this->state->setOrderArray($field, $type);
0 ignored issues
show
Bug introduced by
It seems like $field defined by $this->driver->quoteIdent($field) on line 531 can also be of type array; however, Query\State::setOrderArray() 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...
533
534
		$orderClauses = [];
535
536
		// Flatten key/val pairs into an array of space-separated pairs
537
		foreach($this->state->getOrderArray() as $k => $v)
538
		{
539
			$orderClauses[] = $k . ' ' . strtoupper($v);
540
		}
541
542
		// Set the final string
543
		$orderString =  ! isset($rand)
544
			? "\nORDER BY ".implode(', ', $orderClauses)
545
			: "\nORDER BY".$rand;
546
547
		$this->state->setOrderString($orderString);
548
549
		return $this;
550
	}
551
552
	/**
553
	 * Set a limit on the current sql statement
554
	 *
555
	 * @param int $limit
556
	 * @param int|bool $offset
557
	 * @return QueryBuilderInterface
558
	 */
559
	public function limit(int $limit, $offset=FALSE): QueryBuilderInterface
560
	{
561
		$this->state->setLimit($limit);
562
		$this->state->setOffset($offset);
0 ignored issues
show
Documentation introduced by
$offset is of type integer|boolean, but the function expects a string|false.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
563
564
		return $this;
565
	}
566
567
	// --------------------------------------------------------------------------
568
	// ! Query Grouping Methods
569
	// --------------------------------------------------------------------------
570
571
	/**
572
	 * Adds a paren to the current query for query grouping
573
	 *
574
	 * @return QueryBuilderInterface
575
	 */
576
	public function groupStart(): QueryBuilderInterface
577
	{
578
		$conj = empty($this->state->getQueryMap()) ? ' WHERE ' : ' ';
579
580
		$this->state->appendMap($conj, '(', 'group_start');
581
582
		return $this;
583
	}
584
585
	/**
586
	 * Adds a paren to the current query for query grouping,
587
	 * prefixed with 'NOT'
588
	 *
589
	 * @return QueryBuilderInterface
590
	 */
591
	public function notGroupStart(): QueryBuilderInterface
592
	{
593
		$conj = empty($this->state->getQueryMap()) ? ' WHERE ' : ' AND ';
594
595
		$this->state->appendMap($conj, ' NOT (', 'group_start');
596
597
		return $this;
598
	}
599
600
	/**
601
	 * Adds a paren to the current query for query grouping,
602
	 * prefixed with 'OR'
603
	 *
604
	 * @return QueryBuilderInterface
605
	 */
606
	public function orGroupStart(): QueryBuilderInterface
607
	{
608
		$this->state->appendMap('', ' OR (', 'group_start');
609
610
		return $this;
611
	}
612
613
	/**
614
	 * Adds a paren to the current query for query grouping,
615
	 * prefixed with 'OR NOT'
616
	 *
617
	 * @return QueryBuilderInterface
618
	 */
619
	public function orNotGroupStart(): QueryBuilderInterface
620
	{
621
		$this->state->appendMap('', ' OR NOT (', 'group_start');
622
623
		return $this;
624
	}
625
626
	/**
627
	 * Ends a query group
628
	 *
629
	 * @return QueryBuilderInterface
630
	 */
631
	public function groupEnd(): QueryBuilderInterface
632
	{
633
		$this->state->appendMap('', ')', 'group_end');
634
635
		return $this;
636
	}
637
638
	// --------------------------------------------------------------------------
639
	// ! Query execution methods
640
	// --------------------------------------------------------------------------
641
642
	/**
643
	 * Select and retrieve all records from the current table, and/or
644
	 * execute current compiled query
645
	 *
646
	 * @param string $table
647
	 * @param int|bool $limit
648
	 * @param int|bool $offset
649
	 * @return PDOStatement
650
	 */
651
	public function get(string $table='', $limit=FALSE, $offset=FALSE): ?PDOStatement
652
	{
653
		// Set the table
654
		if ( ! empty($table))
655
		{
656
			$this->from($table);
657
		}
658
659
		// Set the limit, if it exists
660
		if (\is_int($limit))
661
		{
662
			$this->limit($limit, $offset);
663
		}
664
665
		return $this->_run('get', $table);
666
	}
667
668
	/**
669
	 * Convenience method for get() with a where clause
670
	 *
671
	 * @param string $table
672
	 * @param mixed $where
673
	 * @param int|bool $limit
674
	 * @param int|bool $offset
675
	 * @return PDOStatement
676
	 */
677
	public function getWhere(string $table, $where=[], $limit=FALSE, $offset=FALSE): ?PDOStatement
678
	{
679
		// Create the where clause
680
		$this->where($where);
681
682
		// Return the result
683
		return $this->get($table, $limit, $offset);
684
	}
685
686
	/**
687
	 * Retrieve the number of rows in the selected table
688
	 *
689
	 * @param string $table
690
	 * @return int
691
	 */
692
	public function countAll(string $table): int
693
	{
694
		$sql = 'SELECT * FROM '.$this->driver->quoteTable($table);
695
		$res = $this->driver->query($sql);
696
		return (int) count($res->fetchAll());
697
	}
698
699
	/**
700
	 * Retrieve the number of results for the generated query - used
701
	 * in place of the get() method
702
	 *
703
	 * @param string $table
704
	 * @param boolean $reset
705
	 * @return int
706
	 */
707
	public function countAllResults(string $table='', bool $reset = TRUE): int
708
	{
709
		// Set the table
710
		if ( ! empty($table))
711
		{
712
			$this->from($table);
713
		}
714
715
		$result = $this->_run('get', $table, NULL, NULL, $reset);
716
		$rows = $result->fetchAll();
717
718
		return (int) count($rows);
719
	}
720
721
	/**
722
	 * Creates an insert clause, and executes it
723
	 *
724
	 * @param string $table
725
	 * @param mixed $data
726
	 * @return PDOStatement
727
	 */
728
	public function insert(string $table, $data=[]): ?PDOStatement
729
	{
730
		if ( ! empty($data))
731
		{
732
			$this->set($data);
733
		}
734
735
		return $this->_run('insert', $table);
736
	}
737
738
	/**
739
	 * Creates and executes a batch insertion query
740
	 *
741
	 * @param string $table
742
	 * @param array $data
743
	 * @return PDOStatement
744
	 */
745
	public function insertBatch(string $table, $data=[]): ?PDOStatement
746
	{
747
		// Get the generated values and sql string
748
		[$sql, $data] = $this->driver->insertBatch($table, $data);
0 ignored issues
show
Bug introduced by
The variable $sql does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
749
750
		return $sql !== NULL
751
			? $this->_run('', $table, $sql, $data)
752
			: NULL;
753
	}
754
755
	/**
756
	 * Creates an update clause, and executes it
757
	 *
758
	 * @param string $table
759
	 * @param mixed $data
760
	 * @return PDOStatement
761
	 */
762
	public function update(string $table, $data=[]): PDOStatement
763
	{
764
		if ( ! empty($data))
765
		{
766
			$this->set($data);
767
		}
768
769
		return $this->_run('update', $table);
770
	}
771
772
	/**
773
	 * Creates a batch update, and executes it.
774
	 * Returns the number of affected rows
775
	 *
776
	 * @param string $table
777
	 * @param array|object $data
778
	 * @param string $where
779
	 * @return PDOStatement|null
780
	 */
781
	public function updateBatch(string $table, $data, $where): ?PDOStatement
782
	{
783
		// Get the generated values and sql string
784
		list($sql, $data) = $this->driver->updateBatch($table, $data, $where);
785
786
		return $sql !== NULL
787
			? $this->_run('', $table, $sql, $data)
788
			: NULL;
789
	}
790
791
	/**
792
	 * Insertion with automatic overwrite, rather than attempted duplication
793
	 *
794
	 * @param string $table
795
	 * @param array $data
796
	 * @return \PDOStatement|null
797
	 */
798
	public function replace(string $table, $data=[]): ?PDOStatement
799
	{
800
		if ( ! empty($data))
801
		{
802
			$this->set($data);
803
		}
804
805
		return $this->_run('replace', $table);
806
	}
807
808
	/**
809
	 * Deletes data from a table
810
	 *
811
	 * @param string $table
812
	 * @param mixed $where
813
	 * @return PDOStatement
814
	 */
815
	public function delete(string $table, $where=''): ?PDOStatement
816
	{
817
		// Set the where clause
818
		if ( ! empty($where))
819
		{
820
			$this->where($where);
821
		}
822
823
		return $this->_run('delete', $table);
824
	}
825
826
	// --------------------------------------------------------------------------
827
	// ! SQL Returning Methods
828
	// --------------------------------------------------------------------------
829
830
	/**
831
	 * Returns the generated 'select' sql query
832
	 *
833
	 * @param string $table
834
	 * @param bool $reset
835
	 * @return string
836
	 */
837
	public function getCompiledSelect(string $table='', bool $reset=TRUE): string
838
	{
839
		// Set the table
840
		if ( ! empty($table))
841
		{
842
			$this->from($table);
843
		}
844
845
		return $this->_getCompile('select', $table, $reset);
846
	}
847
848
	/**
849
	 * Returns the generated 'insert' sql query
850
	 *
851
	 * @param string $table
852
	 * @param bool $reset
853
	 * @return string
854
	 */
855
	public function getCompiledInsert(string $table, bool $reset=TRUE): string
856
	{
857
		return $this->_getCompile('insert', $table, $reset);
858
	}
859
860
	/**
861
	 * Returns the generated 'update' sql query
862
	 *
863
	 * @param string $table
864
	 * @param bool $reset
865
	 * @return string
866
	 */
867
	public function getCompiledUpdate(string $table='', bool $reset=TRUE): string
868
	{
869
		return $this->_getCompile('update', $table, $reset);
870
	}
871
872
	/**
873
	 * Returns the generated 'delete' sql query
874
	 *
875
	 * @param string $table
876
	 * @param bool $reset
877
	 * @return string
878
	 */
879
	public function getCompiledDelete(string $table='', bool $reset=TRUE): string
880
	{
881
		return $this->_getCompile('delete', $table, $reset);
882
	}
883
884
	// --------------------------------------------------------------------------
885
	// ! Miscellaneous Methods
886
	// --------------------------------------------------------------------------
887
888
	/**
889
	 * Clear out the class variables, so the next query can be run
890
	 *
891
	 * @return void
892
	 */
893
	public function resetQuery(): void
894
	{
895
		$this->state = new State();
896
		$this->explain = FALSE;
897
	}
898
899
	/**
900
	 * Method to simplify select_ methods
901
	 *
902
	 * @param string $field
903
	 * @param string|bool $as
904
	 * @return string
905
	 */
906
	protected function _select(string $field, $as = FALSE): string
907
	{
908
		// Escape the identifiers
909
		$field = $this->driver->quoteIdent($field);
910
911
		if ( ! \is_string($as))
912
		{
913
			return $field;
914
		}
915
916
		$as = $this->driver->quoteIdent($as);
917
		return "({$field}) AS {$as} ";
918
	}
919
920
	/**
921
	 * Helper function for returning sql strings
922
	 *
923
	 * @param string $type
924
	 * @param string $table
925
	 * @param bool $reset
926
	 * @return string
927
	 */
928
	protected function _getCompile(string $type, string $table, bool $reset): string
929
	{
930
		$sql = $this->_compile($type, $table);
931
932
		// Reset the query builder for the next query
933
		if ($reset)
934
		{
935
			$this->resetQuery();
936
		}
937
938
		return $sql;
939
	}
940
941
	/**
942
	 * Simplify 'like' methods
943
	 *
944
	 * @param string $field
945
	 * @param mixed $val
946
	 * @param string $pos
947
	 * @param string $like
948
	 * @param string $conj
949
	 * @return self
950
	 */
951
	protected function _like(string $field, $val, string $pos, string $like='LIKE', string $conj='AND'): self
952
	{
953
		$field = $this->driver->quoteIdent($field);
954
955
		// Add the like string into the order map
956
		$like = $field. " {$like} ?";
957
958
		if ($pos === 'before')
959
		{
960
			$val = "%{$val}";
961
		}
962
		elseif ($pos === 'after')
963
		{
964
			$val = "{$val}%";
965
		}
966
		else
967
		{
968
			$val = "%{$val}%";
969
		}
970
971
		$conj = empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} ";
972
		$this->state->appendMap($conj, $like, 'like');
973
974
		// Add to the values array
975
		$this->state->appendWhereValues($val);
976
977
		return $this;
978
	}
979
980
	/**
981
	 * Simplify building having clauses
982
	 *
983
	 * @param mixed $key
984
	 * @param mixed $values
985
	 * @param string $conj
986
	 * @return self
987
	 */
988
	protected function _having($key, $values=[], string $conj='AND'): self
989
	{
990
		$where = $this->_where($key, $values);
991
992
		// Create key/value placeholders
993
		foreach($where as $f => $val)
994
		{
995
			// Split each key by spaces, in case there
996
			// is an operator such as >, <, !=, etc.
997
			$fArray = explode(' ', trim($f));
998
999
			$item = $this->driver->quoteIdent($fArray[0]);
1000
1001
			// Simple key value, or an operator
1002
			$item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?";
1003
1004
			// Put in the having map
1005
			$this->state->appendHavingMap([
1006
				'conjunction' => empty($this->state->getHavingMap())
1007
					? ' HAVING '
1008
					: " {$conj} ",
1009
				'string' => $item
1010
			]);
1011
		}
1012
1013
		return $this;
1014
	}
1015
1016
	/**
1017
	 * Do all the redundant stuff for where/having type methods
1018
	 *
1019
	 * @param mixed $key
1020
	 * @param mixed $val
1021
	 * @return array
1022
	 */
1023
	protected function _where($key, $val=[]): array
1024
	{
1025
		$where = [];
1026
		$pairs = [];
1027
1028
		if (is_scalar($key))
1029
		{
1030
			$pairs[$key] = $val;
1031
		}
1032
		else
1033
		{
1034
			$pairs = $key;
1035
		}
1036
1037
		foreach($pairs as $k => $v)
0 ignored issues
show
Bug introduced by
The expression $pairs of type object|array|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
1038
		{
1039
			$where[$k] = $v;
1040
			$this->state->appendWhereValues($v);
1041
		}
1042
1043
		return $where;
1044
	}
1045
1046
	/**
1047
	 * Simplify generating where string
1048
	 *
1049
	 * @param mixed $key
1050
	 * @param mixed $values
1051
	 * @param string $defaultConj
1052
	 * @return self
1053
	 */
1054
	protected function _whereString($key, $values=[], string $defaultConj='AND'): self
1055
	{
1056
		// Create key/value placeholders
1057
		foreach($this->_where($key, $values) as $f => $val)
1058
		{
1059
			$queryMap = $this->state->getQueryMap();
1060
1061
			// Split each key by spaces, in case there
1062
			// is an operator such as >, <, !=, etc.
1063
			$fArray = explode(' ', trim($f));
1064
1065
			$item = $this->driver->quoteIdent($fArray[0]);
1066
1067
			// Simple key value, or an operator
1068
			$item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?";
1069
			$lastItem = end($queryMap);
1070
1071
			// Determine the correct conjunction
1072
			$conjunctionList = array_column($queryMap, 'conjunction');
1073
			if (empty($queryMap) || ( ! \regexInArray($conjunctionList, "/^ ?\n?WHERE/i")))
1074
			{
1075
				$conj = "\nWHERE ";
1076
			}
1077
			elseif ($lastItem['type'] === 'group_start')
1078
			{
1079
				$conj = '';
1080
			}
1081
			else
1082
			{
1083
				$conj = " {$defaultConj} ";
1084
			}
1085
1086
			$this->state->appendMap($conj, $item, 'where');
1087
		}
1088
1089
		return $this;
1090
	}
1091
1092
	/**
1093
	 * Simplify where_in methods
1094
	 *
1095
	 * @param mixed $key
1096
	 * @param mixed $val
1097
	 * @param string $in - The (not) in fragment
1098
	 * @param string $conj - The where in conjunction
1099
	 * @return self
1100
	 */
1101
	protected function _whereIn($key, $val=[], string $in='IN', string $conj='AND'): self
1102
	{
1103
		$key = $this->driver->quoteIdent($key);
1104
		$params = array_fill(0, count($val), '?');
1105
		$this->state->appendWhereValues($val);
1106
1107
		$conjunction =  empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} ";
1108
		$str = $key . " {$in} (".implode(',', $params).') ';
1109
1110
		$this->state->appendMap($conjunction, $str, 'where_in');
1111
1112
		return $this;
1113
	}
1114
1115
	/**
1116
	 * Executes the compiled query
1117
	 *
1118
	 * @param string $type
1119
	 * @param string $table
1120
	 * @param string $sql
1121
	 * @param array|null $vals
1122
	 * @param boolean $reset
1123
	 * @return PDOStatement
1124
	 */
1125
	protected function _run(string $type, string $table, string $sql=NULL, array $vals=NULL, bool $reset=TRUE): PDOStatement
1126
	{
1127
		if ($sql === NULL)
1128
		{
1129
			$sql = $this->_compile($type, $table);
1130
		}
1131
1132
		if ($vals === NULL)
1133
		{
1134
			$vals = array_merge($this->state->getValues(), $this->state->getWhereValues());
1135
		}
1136
1137
		$startTime = microtime(TRUE);
1138
1139
		$res = empty($vals)
1140
			? $this->driver->query($sql)
1141
			: $this->driver->prepareExecute($sql, $vals);
1142
1143
		$endTime = microtime(TRUE);
1144
		$totalTime = number_format($endTime - $startTime, 5);
1145
1146
		// Add this query to the list of executed queries
1147
		$this->_appendQuery($vals, $sql, (int) $totalTime);
1148
1149
		// Reset class state for next query
1150
		if ($reset)
1151
		{
1152
			$this->resetQuery();
1153
		}
1154
1155
		return $res;
1156
	}
1157
1158
	/**
1159
	 * Convert the prepared statement into readable sql
1160
	 *
1161
	 * @param array $vals
1162
	 * @param string $sql
1163
	 * @param int $totalTime
1164
	 * @return void
1165
	 */
1166
	protected function _appendQuery(array $vals = NULL, string $sql, int $totalTime)
1167
	{
1168
		$evals = \is_array($vals) ? $vals : [];
1169
		$esql = str_replace('?', "%s", $sql);
1170
1171
		// Quote string values
1172
		foreach($evals as &$v)
1173
		{
1174
			$v = ( ! is_numeric($v))
1175
				? htmlentities($this->driver->quote($v), ENT_NOQUOTES, 'utf-8')
1176
				: $v;
1177
		}
1178
1179
		// Add the query onto the array of values to pass
1180
		// as arguments to sprintf
1181
		array_unshift($evals, $esql);
1182
1183
		// Add the interpreted query to the list of executed queries
1184
		$this->queries[] = [
1185
			'time' => $totalTime,
1186
			'sql' => sprintf(...$evals)
1187
		];
1188
1189
		$this->queries['total_time'] += $totalTime;
1190
1191
		// Set the last query to get rowcounts properly
1192
		$this->driver->setLastQuery($sql);
1193
	}
1194
1195
	/**
1196
	 * Sub-method for generating sql strings
1197
	 *
1198
	 * @param string $type
1199
	 * @param string $table
1200
	 * @return string
1201
	 */
1202
	protected function _compileType(string $type='', string $table=''): string
1203
	{
1204
		$setArrayKeys = $this->state->getSetArrayKeys();
1205
		switch($type)
1206
		{
1207
			case 'insert':
1208
				$paramCount = count($setArrayKeys);
1209
				$params = array_fill(0, $paramCount, '?');
1210
				$sql = "INSERT INTO {$table} ("
1211
					. implode(',', $setArrayKeys)
1212
					. ")\nVALUES (".implode(',', $params).')';
1213
				break;
1214
1215
			case 'update':
1216
				$setString = $this->state->getSetString();
1217
				$sql = "UPDATE {$table}\nSET {$setString}";
1218
				break;
1219
1220
			case 'replace':
1221
				// @TODO implement
1222
				$sql = '';
1223
				break;
1224
1225
			case 'delete':
1226
				$sql = "DELETE FROM {$table}";
1227
				break;
1228
1229
			// Get queries
1230
			default:
1231
				$fromString = $this->state->getFromString();
1232
				$selectString = $this->state->getSelectString();
1233
1234
				$sql = "SELECT * \nFROM {$fromString}";
1235
1236
				// Set the select string
1237
				if ( ! empty($selectString))
1238
				{
1239
					// Replace the star with the selected fields
1240
					$sql = str_replace('*', $selectString, $sql);
1241
				}
1242
				break;
1243
		}
1244
1245
		return $sql;
1246
	}
1247
1248
	/**
1249
	 * String together the sql statements for sending to the db
1250
	 *
1251
	 * @param string $type
1252
	 * @param string $table
1253
	 * @return string
1254
	 */
1255
	protected function _compile(string $type='', string $table=''): string
1256
	{
1257
		// Get the base clause for the query
1258
		$sql = $this->_compileType($type, $this->driver->quoteTable($table));
0 ignored issues
show
Bug introduced by
It seems like $this->driver->quoteTable($table) targeting Query\Drivers\DriverInterface::quoteTable() can also be of type array; however, Query\QueryBuilder::_compileType() does only seem to accept string, maybe add an additional type check?

This check looks at variables that 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...
1259
1260
		$clauses = [
1261
			'queryMap',
1262
			'groupString',
1263
			'orderString',
1264
			'havingMap',
1265
		];
1266
1267
		// Set each type of subclause
1268
		foreach($clauses as $clause)
1269
		{
1270
			$func = 'get' . ucFirst($clause);
1271
			$param = $this->state->$func();
1272
			if (\is_array($param))
1273
			{
1274
				foreach($param as $q)
1275
				{
1276
					$sql .= $q['conjunction'] . $q['string'];
1277
				}
1278
			}
1279
			else
1280
			{
1281
				$sql .= $param;
1282
			}
1283
		}
1284
1285
		// Set the limit via the class variables
1286
		$limit = $this->state->getLimit();
1287
		if (is_numeric($limit))
1288
		{
1289
			$sql = $this->driver->getSql()->limit($sql, $limit, $this->state->getOffset());
0 ignored issues
show
Bug introduced by
It seems like $this->state->getOffset() targeting Query\State::getOffset() can also be of type string; however, Query\Drivers\SQLInterface::limit() does only seem to accept integer|boolean, maybe add an additional type check?

This check looks at variables that 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...
1290
		}
1291
1292
		// See if the query plan, rather than the
1293
		// query data should be returned
1294
		if ($this->explain === TRUE)
1295
		{
1296
			$sql = $this->driver->getSql()->explain($sql);
1297
		}
1298
1299
		return $sql;
1300
	}
1301
}
1302