Merge pull request #21936 from JMAConsulting/issue_2912
[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 recognised (camel cased) data type."
338 );
339 }
340
341 // @todo Use exceptions instead of CRM_Core_Error::fatal().
342 if ($abort) {
343 $data = htmlentities($data);
344
345 throw new CRM_Core_Exception("$data is not of the type $type");
346 }
347 return NULL;
348 }
349
350 /**
351 * Verify that a variable is of a given type.
352 *
353 * @param mixed $data
354 * The value to validate.
355 * @param string $type
356 * The type to validate against.
357 * @param bool $abort
358 * If TRUE, the operation will CRM_Core_Error::fatal() on invalid data.
359 * @param string $name
360 * The name of the attribute
361 *
362 * @return mixed
363 * The data, escaped if necessary
364 *
365 * @throws \CRM_Core_Exception
366 */
367 public static function validate($data, $type, $abort = TRUE, $name = 'One of parameters ') {
368
369 $possibleTypes = [
370 'Integer',
371 'Int',
372 'Positive',
373 'CommaSeparatedIntegers',
374 'Boolean',
375 'Float',
376 'Money',
377 'Text',
378 'String',
379 'Link',
380 'Memo',
381 'Date',
382 'Timestamp',
383 'ContactReference',
384 'MysqlColumnNameOrAlias',
385 'MysqlOrderByDirection',
386 'MysqlOrderBy',
387 'ExtensionKey',
388 'Json',
389 'Alphanumeric',
390 'Color',
391 ];
392 if (!in_array($type, $possibleTypes)) {
393 throw new CRM_Core_Exception(ts('Invalid type, must be one of : ' . implode($possibleTypes)));
394 }
395 switch ($type) {
396 case 'Integer':
397 case 'Int':
398 if (CRM_Utils_Rule::integer($data)) {
399 return (int) $data;
400 }
401 break;
402
403 case 'Positive':
404 if (CRM_Utils_Rule::positiveInteger($data)) {
405 return (int) $data;
406 }
407 break;
408
409 case 'Float':
410 case 'Money':
411 if (CRM_Utils_Rule::numeric($data)) {
412 return $data;
413 }
414 break;
415
416 case 'Text':
417 case 'String':
418 case 'Link':
419 case 'Memo':
420 return $data;
421
422 case 'Date':
423 case 'Timestamp':
424 // a null timestamp is valid
425 if (strlen(trim($data)) == 0) {
426 return trim($data);
427 }
428
429 if ((preg_match('/^\d{14}$/', $data) ||
430 preg_match('/^\d{8}$/', $data)
431 ) &&
432 CRM_Utils_Rule::mysqlDate($data)
433 ) {
434 return $data;
435 }
436 break;
437
438 case 'ContactReference':
439 // null is valid
440 if (strlen(trim($data)) == 0) {
441 return trim($data);
442 }
443
444 if (CRM_Utils_Rule::validContact($data)) {
445 return (int) $data;
446 }
447 break;
448
449 case 'MysqlOrderByDirection':
450 if (CRM_Utils_Rule::mysqlOrderByDirection($data)) {
451 return strtolower($data);
452 }
453 break;
454
455 case 'ExtensionKey':
456 if (CRM_Utils_Rule::checkExtensionKeyIsValid($data)) {
457 return $data;
458 }
459 break;
460
461 default:
462 $check = lcfirst($type);
463 if (CRM_Utils_Rule::$check($data)) {
464 return $data;
465 }
466 }
467
468 if ($abort) {
469 $data = htmlentities($data);
470 throw new CRM_Core_Exception("$name (value: $data) is not of the type $type");
471 }
472
473 return NULL;
474 }
475
476 /**
477 * Validate that a value matches a PHP type.
478 *
479 * Note that, at a micro-level, this is probably slower than using real PHP type-checking, but it doesn't seem bad.
480 * (In light benchmarking of ~1000 validations on an i3-10100, there is no obvious effect on the execution-time.)
481 * Should be fast enough for validating business entities.
482 *
483 * Example usage: 'validatePhpType(123, 'int|double');`
484 *
485 * @param mixed $value
486 * @param string|string[] $types
487 * The list of acceptable PHP types and/or classnames.
488 * Either an array or a string (with '|' delimiters).
489 * Note that 'null' is a distinct type.
490 * Ex: 'int'
491 * Ex: 'Countable|null'
492 * Ex: 'string|bool'
493 * Ex: 'string|false'
494 * @param bool $isStrict
495 * If data is likely to come from another text medium, then you may want to
496 * allow (say) numbers and string-like-numbers to be used interchangably.
497 *
498 * With $isStrict=TRUE, the string "123" does not match type "int". The int 456 does not match type "double". etc.
499 *
500 * With $isStrict=FALSE, the string "123" will match types "string", "int", and "double".
501 * @return bool
502 */
503 public static function validatePhpType($value, $types, bool $isStrict = TRUE) {
504 if (is_string($types)) {
505 $types = preg_split('/ *\| */', $types);
506 }
507
508 $checkTypeStrict = function($type, $value) {
509 static $aliases = ['integer' => 'int', 'boolean' => 'bool', 'float' => 'double', 'NULL' => 'null'];
510 switch ($type) {
511 case 'mixed':
512 return TRUE;
513
514 case 'false':
515 case 'FALSE':
516 case 'true':
517 case 'TRUE':
518 $expectBool = mb_strtolower($type) === 'true';
519 return $value === $expectBool;
520 }
521 $realType = gettype($value);
522 if (($aliases[$realType] ?? $realType) === ($aliases[$type] ?? $type)) {
523 return TRUE;
524 }
525 if ($realType === 'object' && $value instanceof $type) {
526 return TRUE;
527 }
528 return FALSE;
529 };
530 $checkTypeRelaxed = function($type, $value) use ($checkTypeStrict) {
531 switch ($type) {
532 case 'string':
533 return is_string($value) || is_int($value) || is_float($value);
534
535 case 'bool':
536 case 'boolean':
537 return is_bool($value) || CRM_Utils_Rule::integer($value);
538
539 case 'int':
540 case' integer':
541 return CRM_Utils_Rule::integer($value);
542
543 case 'float':
544 case 'double':
545 return CRM_Utils_Rule::numeric($value);
546
547 default:
548 return $checkTypeStrict($type, $value);
549 }
550 };
551 $checkType = $isStrict ? $checkTypeStrict : $checkTypeRelaxed;
552
553 foreach ($types as $type) {
554 $isTypedArray = substr($type, -2, 2) === '[]';
555 if (!$isTypedArray && $checkType($type, $value)) {
556 return TRUE;
557 }
558 if ($isTypedArray && is_array($value)) {
559 $baseType = substr($type, 0, -2);
560 foreach ($value as $vItem) {
561 if (!\CRM_Utils_Type::validatePhpType($vItem, [$baseType], $isStrict)) {
562 continue 2;
563 }
564 }
565 return TRUE;
566 }
567 }
568 return FALSE;
569 }
570
571 /**
572 * Preg_replace_callback for mysqlOrderByFieldFunction escape.
573 *
574 * Add backticks around the field name.
575 *
576 * @param string $clause
577 *
578 * @return string
579 */
580 public static function mysqlOrderByFieldFunctionCallback($clause) {
581 return preg_replace('/field\((\w*)/', 'field(`${1}`', $clause);
582 }
583
584 /**
585 * preg_replace_callback for MysqlOrderBy escape.
586 */
587 public static function mysqlOrderByCallback($matches) {
588 $output = '';
589 $matches = str_replace('`', '', $matches);
590
591 // Table name.
592 if (isset($matches[1]) && $matches[1]) {
593 $output .= '`' . $matches[1] . '`.';
594 }
595
596 // Column name.
597 if (isset($matches[2]) && $matches[2]) {
598 $output .= '`' . $matches[2] . '`';
599 }
600
601 // Sort order.
602 if (isset($matches[3]) && $matches[3]) {
603 $output .= ' ' . $matches[3];
604 }
605
606 return $output;
607 }
608
609 /**
610 * Get list of avaliable Data Types for Option Groups
611 *
612 * @return array
613 */
614 public static function dataTypes() {
615 $types = [
616 'Integer',
617 'String',
618 'Date',
619 'Time',
620 'Timestamp',
621 'Money',
622 'Email',
623 ];
624 return array_combine($types, $types);
625 }
626
627 /**
628 * Get all the types that are text-like.
629 *
630 * The returned types would all legitimately be compared to '' by mysql
631 * in a query.
632 *
633 * e.g
634 * WHERE display_name = '' is valid
635 * WHERE id = '' is not and in some mysql configurations and queries
636 * could cause an error.
637 *
638 * @return array
639 */
640 public static function getTextTypes(): array {
641 return [
642 self::T_STRING,
643 self::T_ENUM,
644 self::T_TEXT,
645 self::T_LONGTEXT,
646 self::T_BLOB,
647 self::T_EMAIL,
648 self::T_URL,
649 self::T_MEDIUMBLOB,
650 ];
651 }
652
653 }