From 55438cdf9962448a16659d4ba35b51411418a6c6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 21 Jul 2021 02:23:29 -0700 Subject: [PATCH] CRM_Utils_Type::validatePhpType - Helper to validate PHP type expressions --- CRM/Utils/Type.php | 95 ++++++++++++++++++++++++ tests/phpunit/CRM/Utils/TypeTest.php | 104 +++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/CRM/Utils/Type.php b/CRM/Utils/Type.php index 01d833e72b..c53db4eda8 100644 --- a/CRM/Utils/Type.php +++ b/CRM/Utils/Type.php @@ -473,6 +473,101 @@ class CRM_Utils_Type { return NULL; } + /** + * Validate that a value matches a PHP type. + * + * Note that, at a micro-level, this is probably slower than using real PHP type-checking, but it doesn't seem bad. + * (In light benchmarking of ~1000 validations on an i3-10100, there is no obvious effect on the execution-time.) + * Should be fast enough for validating business entities. + * + * Example usage: 'validatePhpType(123, 'int|double');` + * + * @param mixed $value + * @param string|string[] $types + * The list of acceptable PHP types and/or classnames. + * Either an array or a string (with '|' delimiters). + * Note that 'null' is a distinct type. + * Ex: 'int' + * Ex: 'Countable|null' + * Ex: 'string|bool' + * Ex: 'string|false' + * @param bool $isStrict + * If data is likely to come from another text medium, then you may want to + * allow (say) numbers and string-like-numbers to be used interchangably. + * + * With $isStrict=TRUE, the string "123" does not match type "int". The int 456 does not match type "double". etc. + * + * With $isStrict=FALSE, the string "123" will match types "string", "int", and "double". + * @return bool + */ + public static function validatePhpType($value, $types, bool $isStrict = TRUE) { + if (is_string($types)) { + $types = preg_split('/ *\| */', $types); + } + + $checkTypeStrict = function($type, $value) { + static $aliases = ['integer' => 'int', 'boolean' => 'bool', 'float' => 'double', 'NULL' => 'null']; + switch ($type) { + case 'mixed': + return TRUE; + + case 'false': + case 'FALSE': + case 'true': + case 'TRUE': + $expectBool = mb_strtolower($type) === 'true'; + return $value === $expectBool; + } + $realType = gettype($value); + if (($aliases[$realType] ?? $realType) === ($aliases[$type] ?? $type)) { + return TRUE; + } + if ($realType === 'object' && $value instanceof $type) { + return TRUE; + } + return FALSE; + }; + $checkTypeRelaxed = function($type, $value) use ($checkTypeStrict) { + switch ($type) { + case 'string': + return is_string($value) || is_int($value) || is_float($value); + + case 'bool': + case 'boolean': + return is_bool($value) || CRM_Utils_Rule::integer($value); + + case 'int': + case' integer': + return CRM_Utils_Rule::integer($value); + + case 'float': + case 'double': + return CRM_Utils_Rule::numeric($value); + + default: + return $checkTypeStrict($type, $value); + } + }; + $checkType = $isStrict ? $checkTypeStrict : $checkTypeRelaxed; + + foreach ($types as $type) { + $isTypedArray = substr($type, -2, 2) === '[]'; + if (!$isTypedArray && $checkType($type, $value)) { + return TRUE; + } + if ($isTypedArray && is_array($value)) { + $baseType = substr($type, 0, -2); + foreach ($value as $vItem) { + if (!\CRM_Utils_Type::validatePhpType($vItem, [$baseType], $isStrict)) { + continue 2; + } + } + return TRUE; + } + } + return FALSE; + } + /** * Preg_replace_callback for mysqlOrderByFieldFunction escape. * diff --git a/tests/phpunit/CRM/Utils/TypeTest.php b/tests/phpunit/CRM/Utils/TypeTest.php index 4a95156cff..0b536df982 100644 --- a/tests/phpunit/CRM/Utils/TypeTest.php +++ b/tests/phpunit/CRM/Utils/TypeTest.php @@ -8,6 +8,11 @@ */ class CRM_Utils_TypeTest extends CiviUnitTestCase { + public function setUp(): void { + parent::setUp(); + $this->useTransaction(); + } + /** * @dataProvider validateDataProvider * @param $inputData @@ -135,4 +140,103 @@ class CRM_Utils_TypeTest extends CiviUnitTestCase { ]; } + public function getPhpTypeExamples() { + $es = []; + $es['int_ok'] = [['int'], 1, 'strictly']; + $es['int_lax'] = [['int'], '1', 'lackadaisically']; + $es['int_badstr'] = [['int'], 'one', 'never']; + + $es['float_ok'] = ['float', 1.2, 'strictly']; + $es['float_lax_int'] = ['float', 123, 'lackadaisically']; + $es['float_lax_str'] = ['float', '1.2', 'lackadaisically']; + $es['float_badstr'] = ['float', 'one point two', 'never']; + + $es['double_ok'] = ['double', 1.2, 'strictly']; + $es['double_lax'] = ['double', '1.2', 'lackadaisically']; + $es['double_badstr'] = [['double'], 'one point two', 'never']; + + $es['bool_ok'] = ['bool', TRUE, 'strictly']; + $es['bool_lax_int'] = ['bool', 0, 'lackadaisically']; + $es['bool_lax_strint'] = ['bool', '1', 'lackadaisically']; + $es['bool_bad_null'] = ['bool', NULL, 'never']; + $es['bool_bad_empty'] = ['bool', '', 'never']; + $es['bool_bad_str'] = ['bool', '1.2', 'never']; + + $es['string_ok'] = [['string'], 'one', 'strictly']; + $es['string_ok'] = [['string'], 123, 'lackadaisically']; + $es['string_badarr'] = [['string'], ['a', 'b', 'c'], 'never']; + $es['string_badobj'] = [['string'], new \stdClass(), 'never']; + + $es['array_ok'] = ['array', [1, 2, 3], 'strictly']; + $es['array_null_req'] = ['array', NULL, 'never']; + + $es['int[]_ok'] = [['int[]'], [1, 2, 3], 'strictly']; + $es['int[]_lax'] = [['int[]'], [1, '22', 3], 'lackadaisically']; + $es['int[]_obj'] = [['int[]'], [1, 2, new \stdClass()], 'never']; + $es['int[]_single'] = [['int[]'], 1, 'never']; + $es['int[]_null_req'] = [['int[]'], NULL, 'never']; + $es['string[]_ok'] = [['string[]'], ['a', 'b', 'c'], 'strictly']; + $es['string[]_obj'] = [['string[]'], ['a', 'b', new \stdClass()], 'never']; + $es['string[]_single'] = [['string[]'], 'a', 'never']; + $es['string[]_null_opt'] = [['string[]'], NULL, 'never']; + + $es['int|null_1'] = ['int|NULL', 1, 'strictly']; + $es['int|null_null'] = ['int|NULL', NULL, 'strictly']; + + $es['int[]|null_ok'] = ['int[]|NULL', [1, 2, 3], 'strictly']; + $es['int[]|null_single'] = ['int[]|NULL', 1, 'never']; + $es['int[]|null_badstr'] = ['int[]|NULL', 'abc', 'never']; + + $es['array|null_ok'] = ['array|NULL', [1, 2, 3], 'strictly']; + $es['array|null_null'] = ['array|NULL', NULL, 'strictly']; + + $es['DateTimeZone|DateTime_ok_date'] = ['DateTimeZone|DateTime', new \DateTimeZone('UTC'), 'strictly']; + $es['DateTimeZone|DateTime_ok_datetime'] = ['Date|DateTime', new \DateTime(), 'strictly']; + $es['DateTimeZone|DateTime_bad_arr'] = ['DateTimeZone|DateTime', [], 'never']; + $es['DateTimeZone|DateTime_bad_obj'] = ['DateTimeZone|DateTime', new \stdClass(), 'never']; + + $es['Throwable_ok'] = ['Throwable', new \Exception('Somethingsomething'), 'strictly']; + $es['Throwable|NotReallyAClass_ok'] = ['Throwable|NotReallyAClass', new \Exception('Somethingsomething'), 'strictly']; + $es['Throwable|NotReallyAClass_bad'] = ['Throwable|NotReallyAClass', 2, 'never']; + + $es['string|false_ok_str'] = ['string|false', 'one', 'strictly']; + $es['string|false_ok_false'] = ['string|false', FALSE, 'strictly']; + $es['string|false_bad_true'] = ['string|false', TRUE, 'never']; + $es['string|false_lax_0'] = ['string|false', 0, 'lackadaisically' /* via string */]; + $es['string|TRUE_ok_true'] = ['string|TRUE', TRUE, 'strictly']; + + return $es; + } + + public function testValidatePhpType() { + + // This test runs much faster as one test-func rather than data-provider func. + foreach ($this->getPhpTypeExamples() as $exampleId => $example) { + [$types, $value, $expectMatches] = $example; + + $strictMatch = CRM_Utils_Type::validatePhpType($value, $types, TRUE); + $relaxedMatch = CRM_Utils_Type::validatePhpType($value, $types, FALSE); + + switch ($expectMatches) { + case 'strictly': + $this->assertEquals(TRUE, $strictMatch, sprintf('(%s) Expect value %s to strictly match type %s', $exampleId, json_encode($value), json_encode($types))); + $this->assertEquals(TRUE, $relaxedMatch, sprintf('(%s) Expect value %s to laxly match type %s', $exampleId, json_encode($value), json_encode($types))); + break; + + case 'lackadaisically': + $this->assertEquals(FALSE, $strictMatch, sprintf('(%s) Expect value %s to strictly NOT match type %s', $exampleId, json_encode($value), json_encode($types))); + $this->assertEquals(TRUE, $relaxedMatch, sprintf('(%s) Expect value %s to laxly match type %s', $exampleId, json_encode($value), json_encode($types))); + break; + + case 'never': + $this->assertEquals(FALSE, $strictMatch, sprintf('(%s) Expect value %s to strictly NOT match type %s', $exampleId, json_encode($value), json_encode($types))); + $this->assertEquals(FALSE, $relaxedMatch, sprintf('(%s) Expect value %s to laxly NOT match type %s', $exampleId, json_encode($value), json_encode($types))); + break; + + default: + throw new \RuntimeException("Unrecognized option: $expectMatches"); + } + } + } + } -- 2.25.1