Commit | Line | Data |
---|---|---|
e2b5e5b1 | 1 | <?php |
d1d3c04a CW |
2 | /* |
3 | +--------------------------------------------------------------------+ | |
fee14197 | 4 | | CiviCRM version 5 | |
d1d3c04a | 5 | +--------------------------------------------------------------------+ |
6b83d5bd | 6 | | Copyright CiviCRM LLC (c) 2004-2019 | |
d1d3c04a CW |
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 | |
e2b5e5b1 TO |
50 | * - SQL clauses correspond to PHP functions ($select->where("foo_id=123")) |
51 | * - Variable escaping is concise and controllable based on prefixes, eg | |
52 | * - similar to Drupal's t() | |
53 | * - use "@varname" to insert the escaped value | |
54 | * - use "!varname" to insert raw (unescaped) values | |
55 | * - use "#varname" to insert a numerical value (these are validated but not escaped) | |
56 | * - to disable any preprocessing, simply omit the variable list | |
c5458931 | 57 | * - control characters (@!#) are mandatory in expressions but optional in arg-keys |
e2b5e5b1 TO |
58 | * - Variables may be individual values or arrays; arrays are imploded with commas |
59 | * - Conditionals are AND'd; if you need OR's, do it yourself | |
c3a8e3e5 | 60 | * - Use classes/functions with documentation (rather than undocumented array-trees) |
c5458931 TO |
61 | * - For any given string, interpolation is only performed once. After an interpolation, |
62 | * a string may never again be subjected to interpolation. | |
63 | * | |
64 | * The "interpolate-once" principle can be enforced by either interpolating on input | |
65 | * xor output. The notations for input and output interpolation are a bit different, | |
66 | * and they may not be mixed. | |
67 | * | |
68 | * @code | |
69 | * // Interpolate on input. Set params when using them. | |
70 | * $select->where('activity_type_id = #type', array( | |
71 | * 'type' => 234, | |
72 | * )); | |
73 | * | |
74 | * // Interpolate on output. Set params independently. | |
75 | * $select | |
76 | * ->where('activity_type_id = #type') | |
77 | * ->param('type', 234), | |
78 | * @endcode | |
d1d3c04a CW |
79 | * |
80 | * @package CRM | |
6b83d5bd | 81 | * @copyright CiviCRM LLC (c) 2004-2019 |
e2b5e5b1 | 82 | */ |
bfd417c7 | 83 | class CRM_Utils_SQL_Select extends CRM_Utils_SQL_BaseParamQuery { |
c5458931 | 84 | |
c5458931 | 85 | private $insertInto = NULL; |
6dd717a6 | 86 | private $insertVerb = 'INSERT INTO '; |
c5458931 | 87 | private $insertIntoFields = array(); |
e2b5e5b1 TO |
88 | private $selects = array(); |
89 | private $from; | |
90 | private $joins = array(); | |
91 | private $wheres = array(); | |
92 | private $groupBys = array(); | |
93 | private $havings = array(); | |
94 | private $orderBys = array(); | |
c3a8e3e5 TO |
95 | private $limit = NULL; |
96 | private $offset = NULL; | |
f8da3b93 | 97 | private $distinct = NULL; |
c5458931 | 98 | |
e2b5e5b1 | 99 | /** |
fe482240 | 100 | * Create a new SELECT query. |
e2b5e5b1 | 101 | * |
77855840 TO |
102 | * @param string $from |
103 | * Table-name and optional alias. | |
c5458931 TO |
104 | * @param array $options |
105 | * @return CRM_Utils_SQL_Select | |
106 | */ | |
107 | public static function from($from, $options = array()) { | |
108 | return new self($from, $options); | |
109 | } | |
110 | ||
111 | /** | |
112 | * Create a partial SELECT query. | |
113 | * | |
114 | * @param array $options | |
e2b5e5b1 TO |
115 | * @return CRM_Utils_SQL_Select |
116 | */ | |
c5458931 TO |
117 | public static function fragment($options = array()) { |
118 | return new self(NULL, $options); | |
e2b5e5b1 TO |
119 | } |
120 | ||
c3a8e3e5 | 121 | /** |
fe482240 | 122 | * Create a new SELECT query. |
c3a8e3e5 | 123 | * |
77855840 TO |
124 | * @param string $from |
125 | * Table-name and optional alias. | |
c5458931 | 126 | * @param array $options |
c3a8e3e5 | 127 | */ |
c5458931 | 128 | public function __construct($from, $options = array()) { |
e2b5e5b1 | 129 | $this->from = $from; |
c5458931 TO |
130 | $this->mode = isset($options['mode']) ? $options['mode'] : self::INTERPOLATE_AUTO; |
131 | } | |
132 | ||
133 | /** | |
134 | * Make a new copy of this query. | |
135 | * | |
136 | * @return CRM_Utils_SQL_Select | |
137 | */ | |
138 | public function copy() { | |
139 | return clone $this; | |
140 | } | |
141 | ||
142 | /** | |
3d469574 | 143 | * Merge something or other. |
144 | * | |
ec2eca5a | 145 | * @param array|CRM_Utils_SQL_Select $other |
c5458931 TO |
146 | * @param array|NULL $parts |
147 | * ex: 'joins', 'wheres' | |
14069c56 | 148 | * @return CRM_Utils_SQL_Select |
c5458931 TO |
149 | */ |
150 | public function merge($other, $parts = NULL) { | |
151 | if ($other === NULL) { | |
152 | return $this; | |
153 | } | |
154 | ||
ec2eca5a TO |
155 | if (is_array($other)) { |
156 | foreach ($other as $fragment) { | |
157 | $this->merge($fragment, $parts); | |
158 | } | |
159 | return $this; | |
160 | } | |
161 | ||
c5458931 TO |
162 | if ($this->mode === self::INTERPOLATE_AUTO) { |
163 | $this->mode = $other->mode; | |
164 | } | |
165 | elseif ($other->mode === self::INTERPOLATE_AUTO) { | |
166 | // Noop. | |
167 | } | |
168 | elseif ($this->mode !== $other->mode) { | |
169 | // Mixing modes will lead to someone getting an expected substitution. | |
170 | throw new RuntimeException("Cannot merge queries that use different interpolation modes ({$this->mode} vs {$other->mode})."); | |
171 | } | |
172 | ||
173 | $arrayFields = array('insertIntoFields', 'selects', 'joins', 'wheres', 'groupBys', 'havings', 'orderBys', 'params'); | |
174 | foreach ($arrayFields as $f) { | |
175 | if ($parts === NULL || in_array($f, $parts)) { | |
176 | $this->{$f} = array_merge($this->{$f}, $other->{$f}); | |
177 | } | |
178 | } | |
179 | ||
180 | $flatFields = array('insertInto', 'from', 'limit', 'offset'); | |
181 | foreach ($flatFields as $f) { | |
182 | if ($parts === NULL || in_array($f, $parts)) { | |
183 | if ($other->{$f} !== NULL) { | |
184 | $this->{$f} = $other->{$f}; | |
185 | } | |
186 | } | |
187 | } | |
188 | ||
189 | return $this; | |
e2b5e5b1 TO |
190 | } |
191 | ||
c3a8e3e5 | 192 | /** |
fe482240 | 193 | * Add a new JOIN clause. |
c3a8e3e5 | 194 | * |
c5458931 TO |
195 | * Note: To add multiple JOINs at once, use $name===NULL and |
196 | * pass an array of $exprs. | |
197 | * | |
198 | * @param string|NULL $name | |
77855840 | 199 | * The effective alias of the joined table. |
c5458931 | 200 | * @param string|array $exprs |
77855840 | 201 | * The complete join expression (eg "INNER JOIN mytable myalias ON mytable.id = maintable.foo_id"). |
c3a8e3e5 TO |
202 | * @param array|null $args |
203 | * @return CRM_Utils_SQL_Select | |
204 | */ | |
c5458931 TO |
205 | public function join($name, $exprs, $args = NULL) { |
206 | if ($name !== NULL) { | |
207 | $this->joins[$name] = $this->interpolate($exprs, $args); | |
208 | } | |
209 | else { | |
210 | foreach ($exprs as $name => $expr) { | |
211 | $this->joins[$name] = $this->interpolate($expr, $args); | |
212 | } | |
213 | return $this; | |
214 | } | |
e2b5e5b1 TO |
215 | return $this; |
216 | } | |
217 | ||
218 | /** | |
c3a8e3e5 TO |
219 | * Specify the column(s)/value(s) to return by adding to the SELECT clause |
220 | * | |
e2b5e5b1 TO |
221 | * @param string|array $exprs list of SQL expressions |
222 | * @param null|array $args use NULL to disable interpolation; use an array of variables to enable | |
223 | * @return CRM_Utils_SQL_Select | |
224 | */ | |
225 | public function select($exprs, $args = NULL) { | |
226 | $exprs = (array) $exprs; | |
227 | foreach ($exprs as $expr) { | |
6e6685dc | 228 | $this->selects[] = $this->interpolate($expr, $args); |
e2b5e5b1 TO |
229 | } |
230 | return $this; | |
231 | } | |
232 | ||
f8da3b93 | 233 | /** |
234 | * Return only distinct values | |
235 | * | |
236 | * @param bool $isDistinct allow DISTINCT select or not | |
237 | * @return CRM_Utils_SQL_Select | |
238 | */ | |
239 | public function distinct($isDistinct = TRUE) { | |
240 | if ($isDistinct) { | |
241 | $this->distinct = 'DISTINCT '; | |
242 | } | |
243 | return $this; | |
244 | } | |
245 | ||
e2b5e5b1 | 246 | /** |
c3a8e3e5 TO |
247 | * Limit results by adding extra condition(s) to the WHERE clause |
248 | * | |
e2b5e5b1 TO |
249 | * @param string|array $exprs list of SQL expressions |
250 | * @param null|array $args use NULL to disable interpolation; use an array of variables to enable | |
251 | * @return CRM_Utils_SQL_Select | |
252 | */ | |
253 | public function where($exprs, $args = NULL) { | |
254 | $exprs = (array) $exprs; | |
255 | foreach ($exprs as $expr) { | |
fa7c2979 TO |
256 | $evaluatedExpr = $this->interpolate($expr, $args); |
257 | $this->wheres[$evaluatedExpr] = $evaluatedExpr; | |
e2b5e5b1 TO |
258 | } |
259 | return $this; | |
260 | } | |
261 | ||
262 | /** | |
fe482240 | 263 | * Group results by adding extra items to the GROUP BY clause. |
c3a8e3e5 | 264 | * |
e2b5e5b1 TO |
265 | * @param string|array $exprs list of SQL expressions |
266 | * @param null|array $args use NULL to disable interpolation; use an array of variables to enable | |
267 | * @return CRM_Utils_SQL_Select | |
268 | */ | |
269 | public function groupBy($exprs, $args = NULL) { | |
270 | $exprs = (array) $exprs; | |
271 | foreach ($exprs as $expr) { | |
6e6685dc C |
272 | $evaluatedExpr = $this->interpolate($expr, $args); |
273 | $this->groupBys[$evaluatedExpr] = $evaluatedExpr; | |
e2b5e5b1 TO |
274 | } |
275 | return $this; | |
276 | } | |
277 | ||
278 | /** | |
c3a8e3e5 TO |
279 | * Limit results by adding extra condition(s) to the HAVING clause |
280 | * | |
e2b5e5b1 TO |
281 | * @param string|array $exprs list of SQL expressions |
282 | * @param null|array $args use NULL to disable interpolation; use an array of variables to enable | |
283 | * @return CRM_Utils_SQL_Select | |
284 | */ | |
285 | public function having($exprs, $args = NULL) { | |
286 | $exprs = (array) $exprs; | |
287 | foreach ($exprs as $expr) { | |
6e6685dc C |
288 | $evaluatedExpr = $this->interpolate($expr, $args); |
289 | $this->havings[$evaluatedExpr] = $evaluatedExpr; | |
e2b5e5b1 TO |
290 | } |
291 | return $this; | |
292 | } | |
293 | ||
294 | /** | |
fe482240 | 295 | * Sort results by adding extra items to the ORDER BY clause. |
c3a8e3e5 | 296 | * |
e2b5e5b1 TO |
297 | * @param string|array $exprs list of SQL expressions |
298 | * @param null|array $args use NULL to disable interpolation; use an array of variables to enable | |
4c6cc364 CW |
299 | * @param int $weight |
300 | * @return \CRM_Utils_SQL_Select | |
e2b5e5b1 | 301 | */ |
6db70618 TO |
302 | public function orderBy($exprs, $args = NULL, $weight = 0) { |
303 | static $guid = 0; | |
e2b5e5b1 TO |
304 | $exprs = (array) $exprs; |
305 | foreach ($exprs as $expr) { | |
6e6685dc | 306 | $evaluatedExpr = $this->interpolate($expr, $args); |
6db70618 | 307 | $this->orderBys[$evaluatedExpr] = array('value' => $evaluatedExpr, 'weight' => $weight, 'guid' => $guid++); |
e2b5e5b1 TO |
308 | } |
309 | return $this; | |
310 | } | |
311 | ||
c5458931 TO |
312 | /** |
313 | * Set one (or multiple) parameters to interpolate into the query. | |
314 | * | |
315 | * @param array|string $keys | |
316 | * Key name, or an array of key-value pairs. | |
317 | * @param null|mixed $value | |
1f7d270d TO |
318 | * The new value of the parameter. |
319 | * Values may be strings, ints, or arrays thereof -- provided that the | |
320 | * SQL query uses appropriate prefix (e.g. "@", "!", "#"). | |
14069c56 | 321 | * @return \CRM_Utils_SQL_Select |
c5458931 TO |
322 | */ |
323 | public function param($keys, $value = NULL) { | |
bfd417c7 TO |
324 | // Why bother with an override? To provide bett er type-hinting in `@return`. |
325 | return parent::param($keys, $value); | |
c5458931 TO |
326 | } |
327 | ||
c3a8e3e5 | 328 | /** |
fe482240 | 329 | * Set a limit on the number of records to return. |
c3a8e3e5 TO |
330 | * |
331 | * @param int $limit | |
332 | * @param int $offset | |
333 | * @return CRM_Utils_SQL_Select | |
334 | * @throws CRM_Core_Exception | |
335 | */ | |
336 | public function limit($limit, $offset = 0) { | |
337 | if ($limit !== NULL && !is_numeric($limit)) { | |
338 | throw new CRM_Core_Exception("Illegal limit"); | |
339 | } | |
340 | if ($offset !== NULL && !is_numeric($offset)) { | |
341 | throw new CRM_Core_Exception("Illegal offset"); | |
342 | } | |
343 | $this->limit = $limit; | |
344 | $this->offset = $offset; | |
345 | return $this; | |
346 | } | |
347 | ||
c5458931 TO |
348 | /** |
349 | * Insert the results of the SELECT query into another | |
350 | * table. | |
351 | * | |
352 | * @param string $table | |
353 | * The name of the other table (which receives new data). | |
354 | * @param array $fields | |
355 | * The fields to fill in the other table (in order). | |
14069c56 | 356 | * @return CRM_Utils_SQL_Select |
c5458931 TO |
357 | * @see insertIntoField |
358 | */ | |
359 | public function insertInto($table, $fields = array()) { | |
360 | $this->insertInto = $table; | |
361 | $this->insertIntoField($fields); | |
362 | return $this; | |
363 | } | |
364 | ||
6dd717a6 | 365 | /** |
366 | * Wrapper function of insertInto fn but sets insertVerb = "INSERT IGNORE INTO " | |
367 | * | |
368 | * @param string $table | |
369 | * The name of the other table (which receives new data). | |
370 | * @param array $fields | |
371 | * The fields to fill in the other table (in order). | |
372 | * @return CRM_Utils_SQL_Select | |
373 | */ | |
374 | public function insertIgnoreInto($table, $fields = array()) { | |
375 | $this->insertVerb = "INSERT IGNORE INTO "; | |
376 | return $this->insertInto($table, $fields); | |
377 | } | |
378 | ||
379 | /** | |
380 | * Wrapper function of insertInto fn but sets insertVerb = "REPLACE INTO " | |
381 | * | |
382 | * @param string $table | |
383 | * The name of the other table (which receives new data). | |
384 | * @param array $fields | |
385 | * The fields to fill in the other table (in order). | |
386 | */ | |
387 | public function replaceInto($table, $fields = array()) { | |
388 | $this->insertVerb = "REPLACE INTO "; | |
389 | return $this->insertInto($table, $fields); | |
390 | } | |
391 | ||
392 | ||
c5458931 TO |
393 | /** |
394 | * @param array $fields | |
395 | * The fields to fill in the other table (in order). | |
14069c56 | 396 | * @return CRM_Utils_SQL_Select |
c5458931 TO |
397 | */ |
398 | public function insertIntoField($fields) { | |
399 | $fields = (array) $fields; | |
400 | foreach ($fields as $field) { | |
401 | $this->insertIntoFields[] = $field; | |
402 | } | |
403 | return $this; | |
404 | } | |
405 | ||
406 | /** | |
407 | * @param array|NULL $parts | |
408 | * List of fields to check (e.g. 'selects', 'joins'). | |
409 | * Defaults to all. | |
410 | * @return bool | |
411 | */ | |
412 | public function isEmpty($parts = NULL) { | |
413 | $empty = TRUE; | |
414 | $fields = array( | |
415 | 'insertInto', | |
416 | 'insertIntoFields', | |
417 | 'selects', | |
418 | 'from', | |
419 | 'joins', | |
420 | 'wheres', | |
421 | 'groupBys', | |
422 | 'havings', | |
423 | 'orderBys', | |
424 | 'limit', | |
425 | 'offset', | |
426 | ); | |
427 | if ($parts !== NULL) { | |
428 | $fields = array_intersect($fields, $parts); | |
429 | } | |
430 | foreach ($fields as $field) { | |
431 | if (!empty($this->{$field})) { | |
432 | $empty = FALSE; | |
433 | } | |
434 | } | |
435 | return $empty; | |
436 | } | |
437 | ||
e2b5e5b1 | 438 | /** |
a6c01b45 CW |
439 | * @return string |
440 | * SQL statement | |
e2b5e5b1 TO |
441 | */ |
442 | public function toSQL() { | |
c5458931 TO |
443 | $sql = ''; |
444 | if ($this->insertInto) { | |
6dd717a6 | 445 | $sql .= $this->insertVerb . $this->insertInto . ' ('; |
c5458931 TO |
446 | $sql .= implode(', ', $this->insertIntoFields); |
447 | $sql .= ")\n"; | |
448 | } | |
6dd717a6 | 449 | |
e2b5e5b1 | 450 | if ($this->selects) { |
f8da3b93 | 451 | $sql .= 'SELECT ' . $this->distinct . implode(', ', $this->selects) . "\n"; |
e2b5e5b1 TO |
452 | } |
453 | else { | |
c5458931 TO |
454 | $sql .= 'SELECT *' . "\n"; |
455 | } | |
456 | if ($this->from !== NULL) { | |
457 | $sql .= 'FROM ' . $this->from . "\n"; | |
e2b5e5b1 | 458 | } |
e2b5e5b1 TO |
459 | foreach ($this->joins as $join) { |
460 | $sql .= $join . "\n"; | |
461 | } | |
462 | if ($this->wheres) { | |
463 | $sql .= 'WHERE (' . implode(') AND (', $this->wheres) . ")\n"; | |
464 | } | |
465 | if ($this->groupBys) { | |
466 | $sql .= 'GROUP BY ' . implode(', ', $this->groupBys) . "\n"; | |
467 | } | |
468 | if ($this->havings) { | |
469 | $sql .= 'HAVING (' . implode(') AND (', $this->havings) . ")\n"; | |
470 | } | |
471 | if ($this->orderBys) { | |
6db70618 TO |
472 | $orderBys = CRM_Utils_Array::crmArraySortByField($this->orderBys, |
473 | array('weight', 'guid')); | |
4c6cc364 CW |
474 | $orderBys = CRM_Utils_Array::collect('value', $orderBys); |
475 | $sql .= 'ORDER BY ' . implode(', ', $orderBys) . "\n"; | |
e2b5e5b1 | 476 | } |
c3a8e3e5 TO |
477 | if ($this->limit !== NULL) { |
478 | $sql .= 'LIMIT ' . $this->limit . "\n"; | |
479 | if ($this->offset !== NULL) { | |
480 | $sql .= 'OFFSET ' . $this->offset . "\n"; | |
481 | } | |
482 | } | |
c5458931 TO |
483 | if ($this->mode === self::INTERPOLATE_OUTPUT) { |
484 | $sql = $this->interpolate($sql, $this->params, self::INTERPOLATE_OUTPUT); | |
485 | } | |
e2b5e5b1 TO |
486 | return $sql; |
487 | } | |
96025800 | 488 | |
77e74ae1 | 489 | /** |
c4dcc9cf TO |
490 | * Execute the query. |
491 | * | |
492 | * To examine the results, use a function like `fetch()`, `fetchAll()`, | |
493 | * `fetchValue()`, or `fetchMap()`. | |
494 | * | |
495 | * @param string|NULL $daoName | |
496 | * The return object should be an instance of this class. | |
497 | * Ex: 'CRM_Contact_BAO_Contact'. | |
498 | * @param bool $i18nRewrite | |
499 | * If the system has multilingual features, should the field/table | |
500 | * names be rewritten? | |
77e74ae1 | 501 | * @return CRM_Core_DAO |
c4dcc9cf TO |
502 | * @see CRM_Core_DAO::executeQuery |
503 | * @see CRM_Core_I18n_Schema::rewriteQuery | |
77e74ae1 TO |
504 | */ |
505 | public function execute($daoName = NULL, $i18nRewrite = TRUE) { | |
506 | // Don't pass through $params. toSQL() handles interpolation. | |
507 | $params = array(); | |
508 | ||
509 | // Don't pass through $abort, $trapException. Just use straight-up exceptions. | |
510 | $abort = TRUE; | |
511 | $trapException = FALSE; | |
512 | $errorScope = CRM_Core_TemporaryErrorScope::useException(); | |
513 | ||
514 | // Don't pass through freeDAO. You can do it yourself. | |
515 | $freeDAO = FALSE; | |
516 | ||
517 | return CRM_Core_DAO::executeQuery($this->toSQL(), $params, $abort, $daoName, | |
518 | $freeDAO, $i18nRewrite, $trapException); | |
519 | } | |
520 | ||
e2b5e5b1 | 521 | } |