Merge pull request #22321 from colemanw/searchAdminPagerConfigFix
[civicrm-core.git] / CRM / Utils / Type.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17 class CRM_Utils_Type {
18 const
19 T_INT = 1,
20 T_STRING = 2,
21 T_ENUM = 2,
22 T_DATE = 4,
23 T_TIME = 8,
24 T_BOOLEAN = 16,
25 T_TEXT = 32,
26 T_LONGTEXT = 32,
27 T_BLOB = 64,
28 T_TIMESTAMP = 256,
29 T_FLOAT = 512,
30 T_MONEY = 1024,
31 T_EMAIL = 2048,
32 T_URL = 4096,
33 T_CCNUM = 8192,
34 T_MEDIUMBLOB = 16384;
35
36 // @TODO What's the point of these constants? Backwards compatibility?
37 //
38 // These are used for field size (<input type=text size=2>), but redundant TWO=2
39 // usages are rare and should be eliminated. See CRM-18810.
40 const
41 TWO = 2,
42 FOUR = 4,
43 SIX = 6,
44 EIGHT = 8,
45 TWELVE = 12,
46 SIXTEEN = 16,
47 TWENTY = 20,
48 MEDIUM = 20,
49 THIRTY = 30,
50 BIG = 30,
51 FORTYFIVE = 45,
52 HUGE = 45;
53
54 /**
55 * Maximum size of a MySQL BLOB or TEXT column in bytes.
56 */
57 const BLOB_SIZE = 65535;
58
59 /**
60 * Maximum value of a MySQL signed INT column.
61 */
62 const INT_MAX = 2147483647;
63
64 /**
65 * Gets the string representation for a data type.
66 *
67 * @param int $type
68 * Integer number identifying the data type.
69 *
70 * @return string
71 * String identifying the data type, e.g. 'Int' or 'String'.
72 */
73 public static function typeToString($type) {
74 // @todo Use constants in the case statements, e.g. "case T_INT:".
75 // @todo return directly, instead of assigning a value.
76 // @todo Use a lookup array, as a property or as a local variable.
77 switch ($type) {
78 case 1:
79 $string = 'Int';
80 break;
81
82 case 2:
83 $string = 'String';
84 break;
85
86 case 3:
87 $string = 'Enum';
88 break;
89
90 case 4:
91 $string = 'Date';
92 break;
93
94 case 8:
95 $string = 'Time';
96 break;
97
98 case 16:
99 $string = 'Boolean';
100 break;
101
102 case 32:
103 $string = 'Text';
104 break;
105
106 case 64:
107 $string = 'Blob';
108 break;
109
110 // CRM-10404
111 case 12:
112 case 256:
113 $string = 'Timestamp';
114 break;
115
116 case 512:
117 $string = 'Float';
118 break;
119
120 case 1024:
121 $string = 'Money';
122 break;
123
124 case 2048:
125 $string = 'Date';
126 break;
127
128 case 4096:
129 $string = 'Email';
130 break;
131
132 case 16384:
133 $string = 'Mediumblob';
134 break;
135 }
136
137 return (isset($string)) ? $string : "";
138 }
139
140 /**
141 * @return array
142 * An array of type in the form 'type name' => 'int representing type'
143 */
144 public static function getValidTypes() {
145 return [
146 'Int' => self::T_INT,
147 'String' => self::T_STRING,
148 'Enum' => self::T_ENUM,
149 'Date' => self::T_DATE,
150 'Time' => self::T_TIME,
151 'Boolean' => self::T_BOOLEAN,
152 'Text' => self::T_TEXT,
153 'Blob' => self::T_BLOB,
154 'Timestamp' => self::T_TIMESTAMP,
155 'Float' => self::T_FLOAT,
156 'Money' => self::T_MONEY,
157 'Email' => self::T_EMAIL,
158 'Mediumblob' => self::T_MEDIUMBLOB,
159 ];
160 }
161
162 /**
163 * Get the data_type for the field.
164 *
165 * @param array $fieldMetadata
166 * Metadata about the field.
167 *
168 * @return string
169 */
170 public static function getDataTypeFromFieldMetadata($fieldMetadata) {
171 if (isset($fieldMetadata['data_type'])) {
172 return $fieldMetadata['data_type'];
173 }
174 if (empty($fieldMetadata['type'])) {
175 // I would prefer to throw an e-notice but there is some,
176 // probably unnecessary logic, that only retrieves activity fields
177 // if they are 'in the profile' and probably they are not 'in'
178 // until they are added - which might lead to ? who knows!
179 return '';
180 }
181 return self::typeToString($fieldMetadata['type']);
182 }
183
184 /**
185 * Helper function to call escape on arrays.
186 *
187 * @see escape
188 */
189 public static function escapeAll($data, $type, $abort = TRUE) {
190 foreach ($data as $key => $value) {
191 $data[$key] = CRM_Utils_Type::escape($value, $type, $abort);
192 }
193 return $data;
194 }
195
196 /**
197 * Helper function to call validate on arrays
198 *
199 * @param mixed $data
200 * @param string $type
201 *
202 * @return mixed
203 *
204 * @throws \CRM_Core_Exception
205 *
206 * @see validate
207 */
208 public static function validateAll($data, $type) {
209 foreach ($data as $key => $value) {
210 $data[$key] = CRM_Utils_Type::validate($value, $type);
211 }
212 return $data;
213 }
214
215 /**
216 * Verify that a variable is of a given type, and apply a bit of processing.
217 *
218 * @param mixed $data
219 * The value to be verified/escaped.
220 * @param string $type
221 * The type to verify against.
222 * @param bool $abort
223 * If TRUE, the operation will throw an CRM_Core_Exception on invalid data.
224 *
225 * @return mixed
226 * The data, escaped if necessary.
227 * @throws CRM_Core_Exception
228 */
229 public static function escape($data, $type, $abort = TRUE) {
230 switch ($type) {
231 case 'Integer':
232 case 'Int':
233 case 'Positive':
234 case 'Float':
235 case 'Money':
236 case 'Date':
237 case 'Timestamp':
238 case 'ContactReference':
239 case 'MysqlOrderByDirection':
240 $validatedData = self::validate($data, $type, $abort);
241 if (isset($validatedData)) {
242 return $validatedData;
243 }
244 break;
245
246 // CRM-8925 for custom fields of this type
247 case 'Country':
248 case 'StateProvince':
249 // Handle multivalued data in delimited or array format
250 if (is_array($data) || (strpos($data, CRM_Core_DAO::VALUE_SEPARATOR) !== FALSE)) {
251 $valid = TRUE;
252 foreach (CRM_Utils_Array::explodePadded($data) as $item) {
253 if (!CRM_Utils_Rule::positiveInteger($item)) {
254 $valid = FALSE;
255 }
256 }
257 if ($valid) {
258 return $data;
259 }
260 }
261 elseif (CRM_Utils_Rule::positiveInteger($data)) {
262 return (int) $data;
263 }
264 break;
265
266 case 'File':
267 if (CRM_Utils_Rule::positiveInteger($data)) {
268 return (int) $data;
269 }
270 break;
271
272 case 'Link':
273 if (CRM_Utils_Rule::url($data = trim($data))) {
274 return $data;
275 }
276 break;
277
278 case 'Boolean':
279 if (CRM_Utils_Rule::boolean($data)) {
280 return $data;
281 }
282 break;
283
284 case 'String':
285 case 'Memo':
286 case 'Text':
287 return CRM_Core_DAO::escapeString(self::validate($data, $type, $abort));
288
289 case 'MysqlColumnNameOrAlias':
290 if (CRM_Utils_Rule::mysqlColumnNameOrAlias($data)) {
291 $data = str_replace('`', '', $data);
292 $parts = explode('.', $data);
293 $data = '`' . implode('`.`', $parts) . '`';
294
295 return $data;
296 }
297 break;
298
299 case 'MysqlOrderBy':
300 if (CRM_Utils_Rule::mysqlOrderBy($data)) {
301 $parts = explode(',', $data);
302
303 // The field() syntax is tricky here because it uses commas & when
304 // we separate by them we break it up. But we want to keep the clauses in order.
305 // so we just clumsily re-assemble it. Test cover exists.
306 $fieldClauseStart = NULL;
307 foreach ($parts as $index => &$part) {
308 if (substr($part, 0, 6) === 'field(') {
309 // Looking to escape a string like 'field(contribution_status_id,3,4,5) asc'
310 // to 'field(`contribution_status_id`,3,4,5) asc'
311 $fieldClauseStart = $index;
312 continue;
313 }
314 if ($fieldClauseStart !== NULL) {
315 // this is part of the list of field options. Concatenate it back on.
316 $parts[$fieldClauseStart] .= ',' . $part;
317 unset($parts[$index]);
318 if (!strstr($parts[$fieldClauseStart], ')')) {
319 // we have not reached the end of the list.
320 continue;
321 }
322 // We have the last piece of the field() clause, time to escape it.
323 $parts[$fieldClauseStart] = self::mysqlOrderByFieldFunctionCallback($parts[$fieldClauseStart]);
324 $fieldClauseStart = NULL;
325 continue;
326
327 }
328 // Normal clause.
329 $part = preg_replace_callback('/^(?:(?:((?:`[\w-]{1,64}`|[\w-]{1,64}))(?:\.))?(`[\w-]{1,64}`|[\w-]{1,64})(?: (asc|desc))?)$/i', ['CRM_Utils_Type', 'mysqlOrderByCallback'], trim($part));
330 }
331 return implode(', ', $parts);
332 }
333 break;
334
335 default:
336 throw new CRM_Core_Exception(
337 $type . " is not a recognized (camel cased) data type."
338 );
339 }
340
341 if ($abort) {
342 $data = htmlentities($data);
343
344 throw new CRM_Core_Exception("$data is not of the type $type");
345 }
346 return NULL;
347 }
348
349 /**
350 * Verify that a variable is of a given type.
351 *
352 * @param mixed $data
353 * The value to validate.
354 * @param string $type
355 * The type to validate against.
356 * @param bool $abort
357 * If TRUE, the operation will CRM_Core_Error::fatal() on invalid data.
358 * @param string $name
359 * The name of the attribute
360 *
361 * @return mixed
362 * The data, escaped if necessary
363 *
364 * @throws \CRM_Core_Exception
365 */
366 public static function validate($data, $type, $abort = TRUE, $name = 'One of parameters ') {
367
368 $possibleTypes = [
369 'Integer',
370 'Int',
371 'Positive',
372 'CommaSeparatedIntegers',
373 'Boolean',
374 'Float',
375 'Money',
376 'Text',
377 'String',
378 'Link',
379 'Memo',
380 'Date',
381 'Timestamp',
382 'ContactReference',
383 'MysqlColumnNameOrAlias',
384 'MysqlOrderByDirection',
385 'MysqlOrderBy',
386 'ExtensionKey',
387 'Json',
388 'Alphanumeric',
389 'Color',
390 ];
391 if (!in_array($type, $possibleTypes)) {
392 throw new CRM_Core_Exception(ts('Invalid type, must be one of : ' . implode($possibleTypes)));
393 }
394 switch ($type) {
395 case 'Integer':
396 case 'Int':
397 if (CRM_Utils_Rule::integer($data)) {
398 return (int) $data;
399 }
400 break;
401
402 case 'Positive':
403 if (CRM_Utils_Rule::positiveInteger($data)) {
404 return (int) $data;
405 }
406 break;
407
408 case 'Float':
409 case 'Money':
410 if (CRM_Utils_Rule::numeric($data)) {
411 return $data;
412 }
413 break;
414
415 case 'Text':
416 case 'String':
417 case 'Link':
418 case 'Memo':
419 return $data;
420
421 case 'Date':
422 case 'Timestamp':
423 // a null timestamp is valid
424 if (strlen(trim($data)) == 0) {
425 return trim($data);
426 }
427
428 if ((preg_match('/^\d{14}$/', $data) ||
429 preg_match('/^\d{8}$/', $data)
430 ) &&
431 CRM_Utils_Rule::mysqlDate($data)
432 ) {
433 return $data;
434 }
435 break;
436
437 case 'ContactReference':
438 // null is valid
439 if (strlen(trim($data)) == 0) {
440 return trim($data);
441 }
442
443 if (CRM_Utils_Rule::validContact($data)) {
444 return (int) $data;
445 }
446 break;
447
448 case 'MysqlOrderByDirection':
449 if (CRM_Utils_Rule::mysqlOrderByDirection($data)) {
450 return strtolower($data);
451 }
452 break;
453
454 case 'ExtensionKey':
455 if (CRM_Utils_Rule::checkExtensionKeyIsValid($data)) {
456 return $data;
457 }
458 break;
459
460 default:
461 $check = lcfirst($type);
462 if (CRM_Utils_Rule::$check($data)) {
463 return $data;
464 }
465 }
466
467 if ($abort) {
468 $data = htmlentities($data);
469 throw new CRM_Core_Exception("$name (value: $data) is not of the type $type");
470 }
471
472 return NULL;
473 }
474
475 /**
476 * Validate that a value matches a PHP type.
477 *
478 * Note that, at a micro-level, this is probably slower than using real PHP type-checking, but it doesn't seem bad.
479 * (In light benchmarking of ~1000 validations on an i3-10100, there is no obvious effect on the execution-time.)
480 * Should be fast enough for validating business entities.
481 *
482 * Example usage: 'validatePhpType(123, 'int|double');`
483 *
484 * @param mixed $value
485 * @param string|string[] $types
486 * The list of acceptable PHP types and/or classnames.
487 * Either an array or a string (with '|' delimiters).
488 * Note that 'null' is a distinct type.
489 * Ex: 'int'
490 * Ex: 'Countable|null'
491 * Ex: 'string|bool'
492 * Ex: 'string|false'
493 * @param bool $isStrict
494 * If data is likely to come from another text medium, then you may want to
495 * allow (say) numbers and string-like-numbers to be used interchangably.
496 *
497 * With $isStrict=TRUE, the string "123" does not match type "int". The int 456 does not match type "double". etc.
498 *
499 * With $isStrict=FALSE, the string "123" will match types "string", "int", and "double".
500 * @return bool
501 */
502 public static function validatePhpType($value, $types, bool $isStrict = TRUE) {
503 if (is_string($types)) {
504 $types = preg_split('/ *\| */', $types);
505 }
506
507 $checkTypeStrict = function($type, $value) {
508 static $aliases = ['integer' => 'int', 'boolean' => 'bool', 'float' => 'double', 'NULL' => 'null'];
509 switch ($type) {
510 case 'mixed':
511 return TRUE;
512
513 case 'false':
514 case 'FALSE':
515 case 'true':
516 case 'TRUE':
517 $expectBool = mb_strtolower($type) === 'true';
518 return $value === $expectBool;
519 }
520 $realType = gettype($value);
521 if (($aliases[$realType] ?? $realType) === ($aliases[$type] ?? $type)) {
522 return TRUE;
523 }
524 if ($realType === 'object' && $value instanceof $type) {
525 return TRUE;
526 }
527 return FALSE;
528 };
529 $checkTypeRelaxed = function($type, $value) use ($checkTypeStrict) {
530 switch ($type) {
531 case 'string':
532 return is_string($value) || is_int($value) || is_float($value);
533
534 case 'bool':
535 case 'boolean':
536 return is_bool($value) || CRM_Utils_Rule::integer($value);
537
538 case 'int':
539 case' integer':
540 return CRM_Utils_Rule::integer($value);
541
542 case 'float':
543 case 'double':
544 return CRM_Utils_Rule::numeric($value);
545
546 default:
547 return $checkTypeStrict($type, $value);
548 }
549 };
550 $checkType = $isStrict ? $checkTypeStrict : $checkTypeRelaxed;
551
552 foreach ($types as $type) {
553 $isTypedArray = substr($type, -2, 2) === '[]';
554 if (!$isTypedArray && $checkType($type, $value)) {
555 return TRUE;
556 }
557 if ($isTypedArray && is_array($value)) {
558 $baseType = substr($type, 0, -2);
559 foreach ($value as $vItem) {
560 if (!\CRM_Utils_Type::validatePhpType($vItem, [$baseType], $isStrict)) {
561 continue 2;
562 }
563 }
564 return TRUE;
565 }
566 }
567 return FALSE;
568 }
569
570 /**
571 * Preg_replace_callback for mysqlOrderByFieldFunction escape.
572 *
573 * Add backticks around the field name.
574 *
575 * @param string $clause
576 *
577 * @return string
578 */
579 public static function mysqlOrderByFieldFunctionCallback($clause) {
580 return preg_replace('/field\((\w*)/', 'field(`${1}`', $clause);
581 }
582
583 /**
584 * preg_replace_callback for MysqlOrderBy escape.
585 */
586 public static function mysqlOrderByCallback($matches) {
587 $output = '';
588 $matches = str_replace('`', '', $matches);
589
590 // Table name.
591 if (isset($matches[1]) && $matches[1]) {
592 $output .= '`' . $matches[1] . '`.';
593 }
594
595 // Column name.
596 if (isset($matches[2]) && $matches[2]) {
597 $output .= '`' . $matches[2] . '`';
598 }
599
600 // Sort order.
601 if (isset($matches[3]) && $matches[3]) {
602 $output .= ' ' . $matches[3];
603 }
604
605 return $output;
606 }
607
608 /**
609 * Get list of available Data Types for Option Groups
610 *
611 * @return array
612 */
613 public static function dataTypes() {
614 $types = [
615 'Integer',
616 'String',
617 'Date',
618 'Time',
619 'Timestamp',
620 'Money',
621 'Email',
622 ];
623 return array_combine($types, $types);
624 }
625
626 /**
627 * Get all the types that are text-like.
628 *
629 * The returned types would all legitimately be compared to '' by mysql
630 * in a query.
631 *
632 * e.g
633 * WHERE display_name = '' is valid
634 * WHERE id = '' is not and in some mysql configurations and queries
635 * could cause an error.
636 *
637 * @return array
638 */
639 public static function getTextTypes(): array {
640 return [
641 self::T_STRING,
642 self::T_ENUM,
643 self::T_TEXT,
644 self::T_LONGTEXT,
645 self::T_BLOB,
646 self::T_EMAIL,
647 self::T_URL,
648 self::T_MEDIUMBLOB,
649 ];
650 }
651
652 }