Merge pull request #7880 from dgbauleo/crm-17498
[civicrm-core.git] / CRM / Utils / SQL / Select.php
CommitLineData
e2b5e5b1 1<?php
d1d3c04a
CW
2/*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.7 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2015 |
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 */
e2b5e5b1
TO
27
28/**
29 * Dear God Why Do I Have To Write This (Dumb SQL Builder)
30 *
31 * Usage:
c5458931
TO
32 * @code
33 * $select = CRM_Utils_SQL_Select::from('civicrm_activity act')
e2b5e5b1 34 * ->join('absence', 'inner join civicrm_activity absence on absence.id = act.source_record_id')
c5458931
TO
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)'))
e2b5e5b1 39 * ->where('!column = @value', array(
c5458931
TO
40 * 'column' => $customField->column_name,
41 * 'value' => $form['foo']
e2b5e5b1
TO
42 * ))
43 * echo $select->toSQL();
c5458931 44 * @endcode
e2b5e5b1
TO
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
c5458931 58 * - control characters (@!#) are mandatory in expressions but optional in arg-keys
e2b5e5b1
TO
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
c3a8e3e5 61 * - Use classes/functions with documentation (rather than undocumented array-trees)
c5458931
TO
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
d1d3c04a
CW
80 *
81 * @package CRM
82 * @copyright CiviCRM LLC (c) 2004-2015
e2b5e5b1 83 */
c5458931
TO
84class 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();
e2b5e5b1
TO
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();
c3a8e3e5
TO
125 private $limit = NULL;
126 private $offset = NULL;
c5458931
TO
127 private $params = array();
128
129 // Public to work-around PHP 5.3 limit.
130 public $strict = NULL;
e2b5e5b1
TO
131
132 /**
fe482240 133 * Create a new SELECT query.
e2b5e5b1 134 *
77855840
TO
135 * @param string $from
136 * Table-name and optional alias.
c5458931
TO
137 * @param array $options
138 * @return CRM_Utils_SQL_Select
139 */
140 public static function from($from, $options = array()) {
141 return new self($from, $options);
142 }
143
144 /**
145 * Create a partial SELECT query.
146 *
147 * @param array $options
e2b5e5b1
TO
148 * @return CRM_Utils_SQL_Select
149 */
c5458931
TO
150 public static function fragment($options = array()) {
151 return new self(NULL, $options);
e2b5e5b1
TO
152 }
153
c3a8e3e5 154 /**
fe482240 155 * Create a new SELECT query.
c3a8e3e5 156 *
77855840
TO
157 * @param string $from
158 * Table-name and optional alias.
c5458931 159 * @param array $options
c3a8e3e5 160 */
c5458931 161 public function __construct($from, $options = array()) {
e2b5e5b1 162 $this->from = $from;
c5458931
TO
163 $this->mode = isset($options['mode']) ? $options['mode'] : self::INTERPOLATE_AUTO;
164 }
165
166 /**
167 * Make a new copy of this query.
168 *
169 * @return CRM_Utils_SQL_Select
170 */
171 public function copy() {
172 return clone $this;
173 }
174
175 /**
3d469574 176 * Merge something or other.
177 *
c5458931
TO
178 * @param CRM_Utils_SQL_Select $other
179 * @param array|NULL $parts
180 * ex: 'joins', 'wheres'
181 * @return $this
182 */
183 public function merge($other, $parts = NULL) {
184 if ($other === NULL) {
185 return $this;
186 }
187
188 if ($this->mode === self::INTERPOLATE_AUTO) {
189 $this->mode = $other->mode;
190 }
191 elseif ($other->mode === self::INTERPOLATE_AUTO) {
192 // Noop.
193 }
194 elseif ($this->mode !== $other->mode) {
195 // Mixing modes will lead to someone getting an expected substitution.
196 throw new RuntimeException("Cannot merge queries that use different interpolation modes ({$this->mode} vs {$other->mode}).");
197 }
198
199 $arrayFields = array('insertIntoFields', 'selects', 'joins', 'wheres', 'groupBys', 'havings', 'orderBys', 'params');
200 foreach ($arrayFields as $f) {
201 if ($parts === NULL || in_array($f, $parts)) {
202 $this->{$f} = array_merge($this->{$f}, $other->{$f});
203 }
204 }
205
206 $flatFields = array('insertInto', 'from', 'limit', 'offset');
207 foreach ($flatFields as $f) {
208 if ($parts === NULL || in_array($f, $parts)) {
209 if ($other->{$f} !== NULL) {
210 $this->{$f} = $other->{$f};
211 }
212 }
213 }
214
215 return $this;
e2b5e5b1
TO
216 }
217
c3a8e3e5 218 /**
fe482240 219 * Add a new JOIN clause.
c3a8e3e5 220 *
c5458931
TO
221 * Note: To add multiple JOINs at once, use $name===NULL and
222 * pass an array of $exprs.
223 *
224 * @param string|NULL $name
77855840 225 * The effective alias of the joined table.
c5458931 226 * @param string|array $exprs
77855840 227 * The complete join expression (eg "INNER JOIN mytable myalias ON mytable.id = maintable.foo_id").
c3a8e3e5
TO
228 * @param array|null $args
229 * @return CRM_Utils_SQL_Select
230 */
c5458931
TO
231 public function join($name, $exprs, $args = NULL) {
232 if ($name !== NULL) {
233 $this->joins[$name] = $this->interpolate($exprs, $args);
234 }
235 else {
236 foreach ($exprs as $name => $expr) {
237 $this->joins[$name] = $this->interpolate($expr, $args);
238 }
239 return $this;
240 }
e2b5e5b1
TO
241 return $this;
242 }
243
244 /**
c3a8e3e5
TO
245 * Specify the column(s)/value(s) to return by adding to the SELECT clause
246 *
e2b5e5b1
TO
247 * @param string|array $exprs list of SQL expressions
248 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
249 * @return CRM_Utils_SQL_Select
250 */
251 public function select($exprs, $args = NULL) {
252 $exprs = (array) $exprs;
253 foreach ($exprs as $expr) {
6e6685dc 254 $this->selects[] = $this->interpolate($expr, $args);
e2b5e5b1
TO
255 }
256 return $this;
257 }
258
259 /**
c3a8e3e5
TO
260 * Limit results by adding extra condition(s) to the WHERE clause
261 *
e2b5e5b1
TO
262 * @param string|array $exprs list of SQL expressions
263 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
264 * @return CRM_Utils_SQL_Select
265 */
266 public function where($exprs, $args = NULL) {
267 $exprs = (array) $exprs;
268 foreach ($exprs as $expr) {
fa7c2979
TO
269 $evaluatedExpr = $this->interpolate($expr, $args);
270 $this->wheres[$evaluatedExpr] = $evaluatedExpr;
e2b5e5b1
TO
271 }
272 return $this;
273 }
274
275 /**
fe482240 276 * Group results by adding extra items to the GROUP BY clause.
c3a8e3e5 277 *
e2b5e5b1
TO
278 * @param string|array $exprs list of SQL expressions
279 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
280 * @return CRM_Utils_SQL_Select
281 */
282 public function groupBy($exprs, $args = NULL) {
283 $exprs = (array) $exprs;
284 foreach ($exprs as $expr) {
6e6685dc
C
285 $evaluatedExpr = $this->interpolate($expr, $args);
286 $this->groupBys[$evaluatedExpr] = $evaluatedExpr;
e2b5e5b1
TO
287 }
288 return $this;
289 }
290
291 /**
c3a8e3e5
TO
292 * Limit results by adding extra condition(s) to the HAVING clause
293 *
e2b5e5b1
TO
294 * @param string|array $exprs list of SQL expressions
295 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
296 * @return CRM_Utils_SQL_Select
297 */
298 public function having($exprs, $args = NULL) {
299 $exprs = (array) $exprs;
300 foreach ($exprs as $expr) {
6e6685dc
C
301 $evaluatedExpr = $this->interpolate($expr, $args);
302 $this->havings[$evaluatedExpr] = $evaluatedExpr;
e2b5e5b1
TO
303 }
304 return $this;
305 }
306
307 /**
fe482240 308 * Sort results by adding extra items to the ORDER BY clause.
c3a8e3e5 309 *
e2b5e5b1
TO
310 * @param string|array $exprs list of SQL expressions
311 * @param null|array $args use NULL to disable interpolation; use an array of variables to enable
312 * @return CRM_Utils_SQL_Select
313 */
314 public function orderBy($exprs, $args = NULL) {
315 $exprs = (array) $exprs;
316 foreach ($exprs as $expr) {
6e6685dc
C
317 $evaluatedExpr = $this->interpolate($expr, $args);
318 $this->orderBys[$evaluatedExpr] = $evaluatedExpr;
e2b5e5b1
TO
319 }
320 return $this;
321 }
322
c5458931
TO
323 /**
324 * Set one (or multiple) parameters to interpolate into the query.
325 *
326 * @param array|string $keys
327 * Key name, or an array of key-value pairs.
328 * @param null|mixed $value
329 * @return $this
330 */
331 public function param($keys, $value = NULL) {
332 if ($this->mode === self::INTERPOLATE_AUTO) {
333 $this->mode = self::INTERPOLATE_OUTPUT;
334 }
335 elseif ($this->mode !== self::INTERPOLATE_OUTPUT) {
336 throw new RuntimeException("Select::param() only makes sense when interpolating on output.");
337 }
338
339 if (is_array($keys)) {
340 foreach ($keys as $k => $v) {
341 $this->params[$k] = $v;
342 }
343 }
344 else {
345 $this->params[$keys] = $value;
346 }
347 return $this;
348 }
349
c3a8e3e5 350 /**
fe482240 351 * Set a limit on the number of records to return.
c3a8e3e5
TO
352 *
353 * @param int $limit
354 * @param int $offset
355 * @return CRM_Utils_SQL_Select
356 * @throws CRM_Core_Exception
357 */
358 public function limit($limit, $offset = 0) {
359 if ($limit !== NULL && !is_numeric($limit)) {
360 throw new CRM_Core_Exception("Illegal limit");
361 }
362 if ($offset !== NULL && !is_numeric($offset)) {
363 throw new CRM_Core_Exception("Illegal offset");
364 }
365 $this->limit = $limit;
366 $this->offset = $offset;
367 return $this;
368 }
369
c5458931
TO
370 /**
371 * Insert the results of the SELECT query into another
372 * table.
373 *
374 * @param string $table
375 * The name of the other table (which receives new data).
376 * @param array $fields
377 * The fields to fill in the other table (in order).
378 * @return $this
379 * @see insertIntoField
380 */
381 public function insertInto($table, $fields = array()) {
382 $this->insertInto = $table;
383 $this->insertIntoField($fields);
384 return $this;
385 }
386
387 /**
388 * @param array $fields
389 * The fields to fill in the other table (in order).
390 * @return $this
391 */
392 public function insertIntoField($fields) {
393 $fields = (array) $fields;
394 foreach ($fields as $field) {
395 $this->insertIntoFields[] = $field;
396 }
397 return $this;
398 }
399
400 /**
401 * @param array|NULL $parts
402 * List of fields to check (e.g. 'selects', 'joins').
403 * Defaults to all.
404 * @return bool
405 */
406 public function isEmpty($parts = NULL) {
407 $empty = TRUE;
408 $fields = array(
409 'insertInto',
410 'insertIntoFields',
411 'selects',
412 'from',
413 'joins',
414 'wheres',
415 'groupBys',
416 'havings',
417 'orderBys',
418 'limit',
419 'offset',
420 );
421 if ($parts !== NULL) {
422 $fields = array_intersect($fields, $parts);
423 }
424 foreach ($fields as $field) {
425 if (!empty($this->{$field})) {
426 $empty = FALSE;
427 }
428 }
429 return $empty;
430 }
431
432 /**
433 * Enable (or disable) strict mode.
434 *
435 * In strict mode, unknown variables will generate exceptions.
436 *
437 * @param bool $strict
438 * @return $this
439 */
440 public function strict($strict = TRUE) {
441 $this->strict = $strict;
442 return $this;
443 }
444
e2b5e5b1
TO
445 /**
446 * Given a string like "field_name = @value", replace "@value" with an escaped SQL string
447 *
acb1052e 448 * @param $expr SQL expression
e2b5e5b1
TO
449 * @param null|array $args a list of values to insert into the SQL expression; keys are prefix-coded:
450 * prefix '@' => escape SQL
451 * prefix '#' => literal number, skip escaping but do validation
452 * prefix '!' => literal, skip escaping and validation
453 * if a value is an array, then it will be imploded
454 *
455 * PHP NULL's will be treated as SQL NULL's. The PHP string "null" will be treated as a string.
456 *
ad37ac8e 457 * @param string $activeMode
458 *
a6c01b45 459 * @return string
e2b5e5b1 460 */
c5458931 461 public function interpolate($expr, $args, $activeMode = self::INTERPOLATE_INPUT) {
e2b5e5b1
TO
462 if ($args === NULL) {
463 return $expr;
464 }
465 else {
c5458931
TO
466 if ($this->mode === self::INTERPOLATE_AUTO) {
467 $this->mode = $activeMode;
468 }
469 elseif ($activeMode !== $this->mode) {
470 throw new RuntimeException("Cannot mix interpolation modes.");
471 }
472
473 $select = $this;
474 return preg_replace_callback('/([#!@])([a-zA-Z0-9_]+)/', function($m) use ($select, $args) {
475 if (isset($args[$m[2]])) {
476 $values = $args[$m[2]];
e2b5e5b1 477 }
c5458931
TO
478 elseif (isset($args[$m[1] . $m[2]])) {
479 // Backward compat. Keys in $args look like "#myNumber" or "@myString".
480 $values = $args[$m[1] . $m[2]];
e2b5e5b1 481 }
c5458931
TO
482 elseif ($select->strict) {
483 throw new CRM_Core_Exception('Cannot build query. Variable "' . $m[1] . $m[2] . '" is unknown.');
e2b5e5b1
TO
484 }
485 else {
c5458931
TO
486 // Unrecognized variables are ignored. Mitigate risk of accidents.
487 return $m[0];
e2b5e5b1 488 }
c5458931
TO
489 $values = is_array($values) ? $values : array($values);
490 switch ($m[1]) {
491 case '@':
492 $parts = array_map(array($select, 'escapeString'), $values);
493 return implode(', ', $parts);
494
577a782f 495 // TODO: ensure all uses of this un-escaped literal are safe
c5458931
TO
496 case '!':
497 return implode(', ', $values);
498
499 case '#':
500 foreach ($values as $valueKey => $value) {
501 if ($value === NULL) {
502 $values[$valueKey] = 'NULL';
503 }
504 elseif (!is_numeric($value)) {
b98af2ca
TO
505 //throw new API_Exception("Failed encoding non-numeric value" . var_export(array($m[0] => $values), TRUE));
506 throw new CRM_Core_Exception("Failed encoding non-numeric value (" . $m[0] . ")");
c5458931
TO
507 }
508 }
509 return implode(', ', $values);
510
511 default:
512 throw new CRM_Core_Exception("Unrecognized prefix");
513 }
514 }, $expr);
e2b5e5b1
TO
515 }
516 }
517
c3a8e3e5
TO
518 /**
519 * @param string|NULL $value
a6c01b45
CW
520 * @return string
521 * SQL expression, e.g. "it\'s great" (with-quotes) or NULL (without-quotes)
c3a8e3e5 522 */
c5458931 523 public function escapeString($value) {
e2b5e5b1
TO
524 return $value === NULL ? 'NULL' : '"' . CRM_Core_DAO::escapeString($value) . '"';
525 }
526
527 /**
a6c01b45
CW
528 * @return string
529 * SQL statement
e2b5e5b1
TO
530 */
531 public function toSQL() {
c5458931
TO
532 $sql = '';
533 if ($this->insertInto) {
534 $sql .= 'INSERT INTO ' . $this->insertInto . ' (';
535 $sql .= implode(', ', $this->insertIntoFields);
536 $sql .= ")\n";
537 }
e2b5e5b1 538 if ($this->selects) {
c5458931 539 $sql .= 'SELECT ' . implode(', ', $this->selects) . "\n";
e2b5e5b1
TO
540 }
541 else {
c5458931
TO
542 $sql .= 'SELECT *' . "\n";
543 }
544 if ($this->from !== NULL) {
545 $sql .= 'FROM ' . $this->from . "\n";
e2b5e5b1 546 }
e2b5e5b1
TO
547 foreach ($this->joins as $join) {
548 $sql .= $join . "\n";
549 }
550 if ($this->wheres) {
551 $sql .= 'WHERE (' . implode(') AND (', $this->wheres) . ")\n";
552 }
553 if ($this->groupBys) {
554 $sql .= 'GROUP BY ' . implode(', ', $this->groupBys) . "\n";
555 }
556 if ($this->havings) {
557 $sql .= 'HAVING (' . implode(') AND (', $this->havings) . ")\n";
558 }
559 if ($this->orderBys) {
560 $sql .= 'ORDER BY ' . implode(', ', $this->orderBys) . "\n";
561 }
c3a8e3e5
TO
562 if ($this->limit !== NULL) {
563 $sql .= 'LIMIT ' . $this->limit . "\n";
564 if ($this->offset !== NULL) {
565 $sql .= 'OFFSET ' . $this->offset . "\n";
566 }
567 }
c5458931
TO
568 if ($this->mode === self::INTERPOLATE_OUTPUT) {
569 $sql = $this->interpolate($sql, $this->params, self::INTERPOLATE_OUTPUT);
570 }
e2b5e5b1
TO
571 return $sql;
572 }
96025800 573
c5458931
TO
574 public function offsetExists($offset) {
575 return isset($this->params[$offset]);
576 }
577
578 public function offsetGet($offset) {
579 return $this->params[$offset];
580 }
581
582 public function offsetSet($offset, $value) {
583 $this->param($offset, $value);
584 }
585
586 public function offsetUnset($offset) {
587 unset($this->params[$offset]);
588 }
589
e2b5e5b1 590}