Merge pull request #10674 from herbdool/crm20764
[civicrm-core.git] / CRM / Utils / SQL / Select.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.7 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2017 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26 */
27
28 /**
29 * Dear God Why Do I Have To Write This (Dumb SQL Builder)
30 *
31 * Usage:
32 * @code
33 * $select = CRM_Utils_SQL_Select::from('civicrm_activity act')
34 * ->join('absence', 'inner join civicrm_activity absence on absence.id = act.source_record_id')
35 * ->where('activity_type_id = #type', array('type' => 234))
36 * ->where('status_id IN (#statuses)', array('statuses' => array(1,2,3))
37 * ->where('subject like @subj', array('subj' => '%hello%'))
38 * ->where('!dynamicColumn = 1', array('dynamicColumn' => 'coalesce(is_active,0)'))
39 * ->where('!column = @value', array(
40 * 'column' => $customField->column_name,
41 * 'value' => $form['foo']
42 * ))
43 * echo $select->toSQL();
44 * @endcode
45 *
46 * Design principles:
47 * - Portable
48 * - No knowledge of the underlying SQL API (except for escaping -- CRM_Core_DAO::escapeString)
49 * - No knowledge of the underlying data model
50 * - Single file
51 * - SQL clauses correspond to PHP functions ($select->where("foo_id=123"))
52 * - Variable escaping is concise and controllable based on prefixes, eg
53 * - similar to Drupal's t()
54 * - use "@varname" to insert the escaped value
55 * - use "!varname" to insert raw (unescaped) values
56 * - use "#varname" to insert a numerical value (these are validated but not escaped)
57 * - to disable any preprocessing, simply omit the variable list
58 * - control characters (@!#) are mandatory in expressions but optional in arg-keys
59 * - Variables may be individual values or arrays; arrays are imploded with commas
60 * - Conditionals are AND'd; if you need OR's, do it yourself
61 * - Use classes/functions with documentation (rather than undocumented array-trees)
62 * - For any given string, interpolation is only performed once. After an interpolation,
63 * a string may never again be subjected to interpolation.
64 *
65 * The "interpolate-once" principle can be enforced by either interpolating on input
66 * xor output. The notations for input and output interpolation are a bit different,
67 * and they may not be mixed.
68 *
69 * @code
70 * // Interpolate on input. Set params when using them.
71 * $select->where('activity_type_id = #type', array(
72 * 'type' => 234,
73 * ));
74 *
75 * // Interpolate on output. Set params independently.
76 * $select
77 * ->where('activity_type_id = #type')
78 * ->param('type', 234),
79 * @endcode
80 *
81 * @package CRM
82 * @copyright CiviCRM LLC (c) 2004-2017
83 */
84 class CRM_Utils_SQL_Select implements ArrayAccess {
85
86 /**
87 * Interpolate values as soon as they are passed in (where(), join(), etc).
88 *
89 * Default.
90 *
91 * Pro: Every clause has its own unique namespace for parameters.
92 * Con: Probably slower.
93 * Advice: Use this when aggregating SQL fragments from agents who
94 * maintained by different parties.
95 */
96 const INTERPOLATE_INPUT = 'in';
97
98 /**
99 * Interpolate values when rendering SQL output (toSQL()).
100 *
101 * Pro: Probably faster.
102 * Con: Must maintain an aggregated list of all parameters.
103 * Advice: Use this when you have control over the entire query.
104 */
105 const INTERPOLATE_OUTPUT = 'out';
106
107 /**
108 * Determine mode automatically. When the first attempt is made
109 * to use input-interpolation (eg `where(..., array(...))`) or
110 * output-interpolation (eg `param(...)`), the mode will be
111 * set. Subsequent calls will be validated using the same mode.
112 */
113 const INTERPOLATE_AUTO = 'auto';
114
115 private $mode = NULL;
116 private $insertInto = NULL;
117 private $insertIntoFields = array();
118 private $selects = array();
119 private $from;
120 private $joins = array();
121 private $wheres = array();
122 private $groupBys = array();
123 private $havings = array();
124 private $orderBys = array();
125 private $limit = NULL;
126 private $offset = NULL;
127 private $params = array();
128 private $distinct = NULL;
129
130 // Public to work-around PHP 5.3 limit.
131 public $strict = NULL;
132
133 /**
134 * Create a new SELECT query.
135 *
136 * @param string $from
137 * Table-name and optional alias.
138 * @param array $options
139 * @return CRM_Utils_SQL_Select
140 */
141 public static function from($from, $options = array()) {
142 return new self($from, $options);
143 }
144
145 /**
146 * Create a partial SELECT query.
147 *
148 * @param array $options
149 * @return CRM_Utils_SQL_Select
150 */
151 public static function fragment($options = array()) {
152 return new self(NULL, $options);
153 }
154
155 /**
156 * Create a new SELECT query.
157 *
158 * @param string $from
159 * Table-name and optional alias.
160 * @param array $options
161 */
162 public function __construct($from, $options = array()) {
163 $this->from = $from;
164 $this->mode = isset($options['mode']) ? $options['mode'] : self::INTERPOLATE_AUTO;
165 }
166
167 /**
168 * Make a new copy of this query.
169 *
170 * @return CRM_Utils_SQL_Select
171 */
172 public function copy() {
173 return clone $this;
174 }
175
176 /**
177 * Merge something or other.
178 *
179 * @param CRM_Utils_SQL_Select $other
180 * @param array|NULL $parts
181 * ex: 'joins', 'wheres'
182 * @return CRM_Utils_SQL_Select
183 */
184 public function merge($other, $parts = NULL) {
185 if ($other === NULL) {
186 return $this;
187 }
188
189 if ($this->mode === self::INTERPOLATE_AUTO) {
190 $this->mode = $other->mode;
191 }
192 elseif ($other->mode === self::INTERPOLATE_AUTO) {
193 // Noop.
194 }
195 elseif ($this->mode !== $other->mode) {
196 // Mixing modes will lead to someone getting an expected substitution.
197 throw new RuntimeException("Cannot merge queries that use different interpolation modes ({$this->mode} vs {$other->mode}).");
198 }
199
200 $arrayFields = array('insertIntoFields', 'selects', 'joins', 'wheres', 'groupBys', 'havings', 'orderBys', 'params');
201 foreach ($arrayFields as $f) {
202 if ($parts === NULL || in_array($f, $parts)) {
203 $this->{$f} = array_merge($this->{$f}, $other->{$f});
204 }
205 }
206
207 $flatFields = array('insertInto', 'from', 'limit', 'offset');
208 foreach ($flatFields as $f) {
209 if ($parts === NULL || in_array($f, $parts)) {
210 if ($other->{$f} !== NULL) {
211 $this->{$f} = $other->{$f};
212 }
213 }
214 }
215
216 return $this;
217 }
218
219 /**
220 * Add a new JOIN clause.
221 *
222 * Note: To add multiple JOINs at once, use $name===NULL and
223 * pass an array of $exprs.
224 *
225 * @param string|NULL $name
226 * The effective alias of the joined table.
227 * @param string|array $exprs
228 * The complete join expression (eg "INNER JOIN mytable myalias ON mytable.id = maintable.foo_id").
229 * @param array|null $args
230 * @return CRM_Utils_SQL_Select
231 */
232 public function join($name, $exprs, $args = NULL) {
233 if ($name !== NULL) {
234 $this->joins[$name] = $this->interpolate($exprs, $args);
235 }
236 else {
237 foreach ($exprs as $name => $expr) {
238 $this->joins[$name] = $this->interpolate($expr, $args);
239 }
240 return $this;
241 }
242 return $this;
243 }
244
245 /**
246 * Specify the column(s)/value(s) to return by adding to the SELECT clause
247 *
248 * @param string|array $exprs list of SQL expressions
249 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
250 * @return CRM_Utils_SQL_Select
251 */
252 public function select($exprs, $args = NULL) {
253 $exprs = (array) $exprs;
254 foreach ($exprs as $expr) {
255 $this->selects[] = $this->interpolate($expr, $args);
256 }
257 return $this;
258 }
259
260 /**
261 * Return only distinct values
262 *
263 * @param bool $isDistinct allow DISTINCT select or not
264 * @return CRM_Utils_SQL_Select
265 */
266 public function distinct($isDistinct = TRUE) {
267 if ($isDistinct) {
268 $this->distinct = 'DISTINCT ';
269 }
270 return $this;
271 }
272
273 /**
274 * Limit results by adding extra condition(s) to the WHERE clause
275 *
276 * @param string|array $exprs list of SQL expressions
277 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
278 * @return CRM_Utils_SQL_Select
279 */
280 public function where($exprs, $args = NULL) {
281 $exprs = (array) $exprs;
282 foreach ($exprs as $expr) {
283 $evaluatedExpr = $this->interpolate($expr, $args);
284 $this->wheres[$evaluatedExpr] = $evaluatedExpr;
285 }
286 return $this;
287 }
288
289 /**
290 * Group results by adding extra items to the GROUP BY clause.
291 *
292 * @param string|array $exprs list of SQL expressions
293 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
294 * @return CRM_Utils_SQL_Select
295 */
296 public function groupBy($exprs, $args = NULL) {
297 $exprs = (array) $exprs;
298 foreach ($exprs as $expr) {
299 $evaluatedExpr = $this->interpolate($expr, $args);
300 $this->groupBys[$evaluatedExpr] = $evaluatedExpr;
301 }
302 return $this;
303 }
304
305 /**
306 * Limit results by adding extra condition(s) to the HAVING clause
307 *
308 * @param string|array $exprs list of SQL expressions
309 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
310 * @return CRM_Utils_SQL_Select
311 */
312 public function having($exprs, $args = NULL) {
313 $exprs = (array) $exprs;
314 foreach ($exprs as $expr) {
315 $evaluatedExpr = $this->interpolate($expr, $args);
316 $this->havings[$evaluatedExpr] = $evaluatedExpr;
317 }
318 return $this;
319 }
320
321 /**
322 * Sort results by adding extra items to the ORDER BY clause.
323 *
324 * @param string|array $exprs list of SQL expressions
325 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
326 * @param int $weight
327 * @return \CRM_Utils_SQL_Select
328 */
329 public function orderBy($exprs, $args = NULL, $weight = 0) {
330 static $guid = 0;
331 $exprs = (array) $exprs;
332 foreach ($exprs as $expr) {
333 $evaluatedExpr = $this->interpolate($expr, $args);
334 $this->orderBys[$evaluatedExpr] = array('value' => $evaluatedExpr, 'weight' => $weight, 'guid' => $guid++);
335 }
336 return $this;
337 }
338
339 /**
340 * Set one (or multiple) parameters to interpolate into the query.
341 *
342 * @param array|string $keys
343 * Key name, or an array of key-value pairs.
344 * @param null|mixed $value
345 * The new value of the parameter.
346 * Values may be strings, ints, or arrays thereof -- provided that the
347 * SQL query uses appropriate prefix (e.g. "@", "!", "#").
348 * @return \CRM_Utils_SQL_Select
349 */
350 public function param($keys, $value = NULL) {
351 if ($this->mode === self::INTERPOLATE_AUTO) {
352 $this->mode = self::INTERPOLATE_OUTPUT;
353 }
354 elseif ($this->mode !== self::INTERPOLATE_OUTPUT) {
355 throw new RuntimeException("Select::param() only makes sense when interpolating on output.");
356 }
357
358 if (is_array($keys)) {
359 foreach ($keys as $k => $v) {
360 $this->params[$k] = $v;
361 }
362 }
363 else {
364 $this->params[$keys] = $value;
365 }
366 return $this;
367 }
368
369 /**
370 * Set a limit on the number of records to return.
371 *
372 * @param int $limit
373 * @param int $offset
374 * @return CRM_Utils_SQL_Select
375 * @throws CRM_Core_Exception
376 */
377 public function limit($limit, $offset = 0) {
378 if ($limit !== NULL && !is_numeric($limit)) {
379 throw new CRM_Core_Exception("Illegal limit");
380 }
381 if ($offset !== NULL && !is_numeric($offset)) {
382 throw new CRM_Core_Exception("Illegal offset");
383 }
384 $this->limit = $limit;
385 $this->offset = $offset;
386 return $this;
387 }
388
389 /**
390 * Insert the results of the SELECT query into another
391 * table.
392 *
393 * @param string $table
394 * The name of the other table (which receives new data).
395 * @param array $fields
396 * The fields to fill in the other table (in order).
397 * @return CRM_Utils_SQL_Select
398 * @see insertIntoField
399 */
400 public function insertInto($table, $fields = array()) {
401 $this->insertInto = $table;
402 $this->insertIntoField($fields);
403 return $this;
404 }
405
406 /**
407 * @param array $fields
408 * The fields to fill in the other table (in order).
409 * @return CRM_Utils_SQL_Select
410 */
411 public function insertIntoField($fields) {
412 $fields = (array) $fields;
413 foreach ($fields as $field) {
414 $this->insertIntoFields[] = $field;
415 }
416 return $this;
417 }
418
419 /**
420 * @param array|NULL $parts
421 * List of fields to check (e.g. 'selects', 'joins').
422 * Defaults to all.
423 * @return bool
424 */
425 public function isEmpty($parts = NULL) {
426 $empty = TRUE;
427 $fields = array(
428 'insertInto',
429 'insertIntoFields',
430 'selects',
431 'from',
432 'joins',
433 'wheres',
434 'groupBys',
435 'havings',
436 'orderBys',
437 'limit',
438 'offset',
439 );
440 if ($parts !== NULL) {
441 $fields = array_intersect($fields, $parts);
442 }
443 foreach ($fields as $field) {
444 if (!empty($this->{$field})) {
445 $empty = FALSE;
446 }
447 }
448 return $empty;
449 }
450
451 /**
452 * Enable (or disable) strict mode.
453 *
454 * In strict mode, unknown variables will generate exceptions.
455 *
456 * @param bool $strict
457 * @return CRM_Utils_SQL_Select
458 */
459 public function strict($strict = TRUE) {
460 $this->strict = $strict;
461 return $this;
462 }
463
464 /**
465 * Given a string like "field_name = @value", replace "@value" with an escaped SQL string
466 *
467 * @param string $expr SQL expression
468 * @param null|array $args a list of values to insert into the SQL expression; keys are prefix-coded:
469 * prefix '@' => escape SQL
470 * prefix '#' => literal number, skip escaping but do validation
471 * prefix '!' => literal, skip escaping and validation
472 * if a value is an array, then it will be imploded
473 *
474 * PHP NULL's will be treated as SQL NULL's. The PHP string "null" will be treated as a string.
475 *
476 * @param string $activeMode
477 *
478 * @return string
479 */
480 public function interpolate($expr, $args, $activeMode = self::INTERPOLATE_INPUT) {
481 if ($args === NULL) {
482 return $expr;
483 }
484 else {
485 if ($this->mode === self::INTERPOLATE_AUTO) {
486 $this->mode = $activeMode;
487 }
488 elseif ($activeMode !== $this->mode) {
489 throw new RuntimeException("Cannot mix interpolation modes.");
490 }
491
492 $select = $this;
493 return preg_replace_callback('/([#!@])([a-zA-Z0-9_]+)/', function($m) use ($select, $args) {
494 if (isset($args[$m[2]])) {
495 $values = $args[$m[2]];
496 }
497 elseif (isset($args[$m[1] . $m[2]])) {
498 // Backward compat. Keys in $args look like "#myNumber" or "@myString".
499 $values = $args[$m[1] . $m[2]];
500 }
501 elseif ($select->strict) {
502 throw new CRM_Core_Exception('Cannot build query. Variable "' . $m[1] . $m[2] . '" is unknown.');
503 }
504 else {
505 // Unrecognized variables are ignored. Mitigate risk of accidents.
506 return $m[0];
507 }
508 $values = is_array($values) ? $values : array($values);
509 switch ($m[1]) {
510 case '@':
511 $parts = array_map(array($select, 'escapeString'), $values);
512 return implode(', ', $parts);
513
514 // TODO: ensure all uses of this un-escaped literal are safe
515 case '!':
516 return implode(', ', $values);
517
518 case '#':
519 foreach ($values as $valueKey => $value) {
520 if ($value === NULL) {
521 $values[$valueKey] = 'NULL';
522 }
523 elseif (!is_numeric($value)) {
524 //throw new API_Exception("Failed encoding non-numeric value" . var_export(array($m[0] => $values), TRUE));
525 throw new CRM_Core_Exception("Failed encoding non-numeric value (" . $m[0] . ")");
526 }
527 }
528 return implode(', ', $values);
529
530 default:
531 throw new CRM_Core_Exception("Unrecognized prefix");
532 }
533 }, $expr);
534 }
535 }
536
537 /**
538 * @param string|NULL $value
539 * @return string
540 * SQL expression, e.g. "it\'s great" (with-quotes) or NULL (without-quotes)
541 */
542 public function escapeString($value) {
543 return $value === NULL ? 'NULL' : '"' . CRM_Core_DAO::escapeString($value) . '"';
544 }
545
546 /**
547 * @return string
548 * SQL statement
549 */
550 public function toSQL() {
551 $sql = '';
552 if ($this->insertInto) {
553 $sql .= 'INSERT INTO ' . $this->insertInto . ' (';
554 $sql .= implode(', ', $this->insertIntoFields);
555 $sql .= ")\n";
556 }
557 if ($this->selects) {
558 $sql .= 'SELECT ' . $this->distinct . implode(', ', $this->selects) . "\n";
559 }
560 else {
561 $sql .= 'SELECT *' . "\n";
562 }
563 if ($this->from !== NULL) {
564 $sql .= 'FROM ' . $this->from . "\n";
565 }
566 foreach ($this->joins as $join) {
567 $sql .= $join . "\n";
568 }
569 if ($this->wheres) {
570 $sql .= 'WHERE (' . implode(') AND (', $this->wheres) . ")\n";
571 }
572 if ($this->groupBys) {
573 $sql .= 'GROUP BY ' . implode(', ', $this->groupBys) . "\n";
574 }
575 if ($this->havings) {
576 $sql .= 'HAVING (' . implode(') AND (', $this->havings) . ")\n";
577 }
578 if ($this->orderBys) {
579 $orderBys = CRM_Utils_Array::crmArraySortByField($this->orderBys,
580 array('weight', 'guid'));
581 $orderBys = CRM_Utils_Array::collect('value', $orderBys);
582 $sql .= 'ORDER BY ' . implode(', ', $orderBys) . "\n";
583 }
584 if ($this->limit !== NULL) {
585 $sql .= 'LIMIT ' . $this->limit . "\n";
586 if ($this->offset !== NULL) {
587 $sql .= 'OFFSET ' . $this->offset . "\n";
588 }
589 }
590 if ($this->mode === self::INTERPOLATE_OUTPUT) {
591 $sql = $this->interpolate($sql, $this->params, self::INTERPOLATE_OUTPUT);
592 }
593 return $sql;
594 }
595
596 /**
597 * Execute the query.
598 *
599 * To examine the results, use a function like `fetch()`, `fetchAll()`,
600 * `fetchValue()`, or `fetchMap()`.
601 *
602 * @param string|NULL $daoName
603 * The return object should be an instance of this class.
604 * Ex: 'CRM_Contact_BAO_Contact'.
605 * @param bool $i18nRewrite
606 * If the system has multilingual features, should the field/table
607 * names be rewritten?
608 * @return CRM_Core_DAO
609 * @see CRM_Core_DAO::executeQuery
610 * @see CRM_Core_I18n_Schema::rewriteQuery
611 */
612 public function execute($daoName = NULL, $i18nRewrite = TRUE) {
613 // Don't pass through $params. toSQL() handles interpolation.
614 $params = array();
615
616 // Don't pass through $abort, $trapException. Just use straight-up exceptions.
617 $abort = TRUE;
618 $trapException = FALSE;
619 $errorScope = CRM_Core_TemporaryErrorScope::useException();
620
621 // Don't pass through freeDAO. You can do it yourself.
622 $freeDAO = FALSE;
623
624 return CRM_Core_DAO::executeQuery($this->toSQL(), $params, $abort, $daoName,
625 $freeDAO, $i18nRewrite, $trapException);
626 }
627
628 /**
629 * Has an offset been set.
630 *
631 * @param string $offset
632 *
633 * @return bool
634 */
635 public function offsetExists($offset) {
636 return isset($this->params[$offset]);
637 }
638
639 /**
640 * Get the value of a SQL parameter.
641 *
642 * @code
643 * $select['cid'] = 123;
644 * $select->where('contact.id = #cid');
645 * echo $select['cid'];
646 * @endCode
647 *
648 * @param string $offset
649 * @return mixed
650 * @see param()
651 * @see ArrayAccess::offsetGet
652 */
653 public function offsetGet($offset) {
654 return $this->params[$offset];
655 }
656
657 /**
658 * Set the value of a SQL parameter.
659 *
660 * @code
661 * $select['cid'] = 123;
662 * $select->where('contact.id = #cid');
663 * echo $select['cid'];
664 * @endCode
665 *
666 * @param string $offset
667 * @param mixed $value
668 * The new value of the parameter.
669 * Values may be strings, ints, or arrays thereof -- provided that the
670 * SQL query uses appropriate prefix (e.g. "@", "!", "#").
671 * @see param()
672 * @see ArrayAccess::offsetSet
673 */
674 public function offsetSet($offset, $value) {
675 $this->param($offset, $value);
676 }
677
678 /**
679 * Unset the value of a SQL parameter.
680 *
681 * @param string $offset
682 * @see param()
683 * @see ArrayAccess::offsetUnset
684 */
685 public function offsetUnset($offset) {
686 unset($this->params[$offset]);
687 }
688
689 }