Matches.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. <?php
  2. namespace Alcaeus\MongoDbAdapter\Tests\Constraint;
  3. use LogicException;
  4. use MongoDB\BSON\Int64;
  5. use MongoDB\BSON\Serializable;
  6. use MongoDB\BSON\Type;
  7. use MongoDB\Model\BSONArray;
  8. use MongoDB\Model\BSONDocument;
  9. use PHPUnit\Framework\Assert;
  10. use PHPUnit\Framework\ExpectationFailedException;
  11. use RuntimeException;
  12. use SebastianBergmann\Comparator\ComparisonFailure;
  13. use SebastianBergmann\Comparator\Factory;
  14. use function array_keys;
  15. use function count;
  16. use function get_class;
  17. use function gettype;
  18. use function is_array;
  19. use function is_float;
  20. use function is_int;
  21. use function is_object;
  22. use function range;
  23. use function sprintf;
  24. use function strpos;
  25. use const PHP_INT_SIZE;
  26. /**
  27. * Constraint that checks if one value matches another.
  28. *
  29. * The expected value is passed in the constructor. Behavior for allowing extra
  30. * keys in root documents and processing operators is also configurable.
  31. */
  32. class Matches extends Constraint
  33. {
  34. use ConstraintTrait;
  35. /** @var mixed */
  36. private $value;
  37. /** @var bool */
  38. private $allowExtraRootKeys;
  39. /** @var bool */
  40. private $allowExtraKeys;
  41. /** @var bool */
  42. private $allowOperators;
  43. /** @var ComparisonFailure|null */
  44. private $lastFailure;
  45. public function __construct($value, $allowExtraRootKeys = true, $allowExtraKeys = false, $allowOperators = true)
  46. {
  47. $this->value = self::prepare($value);
  48. $this->allowExtraRootKeys = $allowExtraRootKeys;
  49. $this->allowExtraKeys = $allowExtraKeys;
  50. $this->allowOperators = $allowOperators;
  51. $this->comparatorFactory = Factory::getInstance();
  52. }
  53. private function doEvaluate($other, $description = '', $returnResult = false)
  54. {
  55. $other = self::prepare($other);
  56. $success = false;
  57. $this->lastFailure = null;
  58. try {
  59. $this->assertMatches($this->value, $other);
  60. $success = true;
  61. } catch (ExpectationFailedException $e) {
  62. /* Rethrow internal assertion failures (e.g. operator type checks,
  63. * EntityMap errors), which are logical errors in the code/test. */
  64. throw $e;
  65. } catch (RuntimeException $e) {
  66. /* This will generally catch internal errors from failAt(), which
  67. * include a key path to pinpoint the failure. */
  68. $this->lastFailure = new ComparisonFailure(
  69. $this->value,
  70. $other,
  71. /* TODO: Improve the exporter to canonicalize documents by
  72. * sorting keys and remove spl_object_hash from output. */
  73. $this->exporter()->export($this->value),
  74. $this->exporter()->export($other),
  75. false,
  76. $e->getMessage()
  77. );
  78. }
  79. if ($returnResult) {
  80. return $success;
  81. }
  82. if (! $success) {
  83. $this->fail($other, $description, $this->lastFailure);
  84. }
  85. }
  86. private function assertEquals($expected, $actual, $keyPath)
  87. {
  88. $expectedType = is_object($expected) ? get_class($expected) : gettype($expected);
  89. $actualType = is_object($actual) ? get_class($actual) : gettype($actual);
  90. /* Early check to work around ObjectComparator printing the entire value
  91. * for a failed type comparison. Avoid doing this if either value is
  92. * numeric to allow for flexible numeric comparisons (e.g. 1 == 1.0). */
  93. if ($expectedType !== $actualType && ! (self::isNumeric($expected) || self::isNumeric($actual))) {
  94. self::failAt(sprintf('%s is not expected type "%s"', $actualType, $expectedType), $keyPath);
  95. }
  96. try {
  97. $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual);
  98. } catch (ComparisonFailure $e) {
  99. /* Disregard other ComparisonFailure fields, as evaluate() only uses
  100. * the message when creating its own ComparisonFailure. */
  101. self::failAt($e->getMessage(), $keyPath);
  102. }
  103. }
  104. private function assertMatches($expected, $actual, $keyPath = '')
  105. {
  106. if ($expected instanceof BSONArray) {
  107. $this->assertMatchesArray($expected, $actual, $keyPath);
  108. return;
  109. }
  110. if ($expected instanceof BSONDocument) {
  111. $this->assertMatchesDocument($expected, $actual, $keyPath);
  112. return;
  113. }
  114. $this->assertEquals($expected, $actual, $keyPath);
  115. }
  116. private function assertMatchesArray(BSONArray $expected, $actual, $keyPath)
  117. {
  118. if (! $actual instanceof BSONArray) {
  119. $actualType = is_object($actual) ? get_class($actual) : gettype($actual);
  120. self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONArray::class), $keyPath);
  121. }
  122. if (count($expected) !== count($actual)) {
  123. self::failAt(sprintf('$actual count is %d, expected %d', count($actual), count($expected)), $keyPath);
  124. }
  125. foreach ($expected as $key => $expectedValue) {
  126. $this->assertMatches(
  127. $expectedValue,
  128. $actual[$key],
  129. (empty($keyPath) ? $key : $keyPath . '.' . $key)
  130. );
  131. }
  132. }
  133. private function assertMatchesDocument(BSONDocument $expected, $actual, $keyPath)
  134. {
  135. if ($this->allowOperators && self::isOperator($expected)) {
  136. $this->assertMatchesOperator($expected, $actual, $keyPath);
  137. return;
  138. }
  139. if (! $actual instanceof BSONDocument) {
  140. $actualType = is_object($actual) ? get_class($actual) : gettype($actual);
  141. self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONDocument::class), $keyPath);
  142. }
  143. foreach ($expected as $key => $expectedValue) {
  144. $actualKeyExists = $actual->offsetExists($key);
  145. if ($this->allowOperators && $expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) {
  146. $operatorName = self::getOperatorName($expectedValue);
  147. if ($operatorName === '$$exists') {
  148. Assert::assertIsBool($expectedValue['$$exists'], '$$exists requires bool');
  149. if ($expectedValue['$$exists'] && ! $actualKeyExists) {
  150. self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath);
  151. }
  152. if (! $expectedValue['$$exists'] && $actualKeyExists) {
  153. self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath);
  154. }
  155. continue;
  156. }
  157. if ($operatorName === '$$unsetOrMatches') {
  158. if (! $actualKeyExists) {
  159. continue;
  160. }
  161. $expectedValue = $expectedValue['$$unsetOrMatches'];
  162. }
  163. }
  164. if (! $actualKeyExists) {
  165. self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath);
  166. }
  167. $this->assertMatches(
  168. $expectedValue,
  169. $actual[$key],
  170. (empty($keyPath) ? $key : $keyPath . '.' . $key)
  171. );
  172. }
  173. // Ignore extra keys in root documents
  174. if ($this->allowExtraKeys || ($this->allowExtraRootKeys && empty($keyPath))) {
  175. return;
  176. }
  177. foreach ($actual as $key => $_) {
  178. if (! $expected->offsetExists($key)) {
  179. self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath);
  180. }
  181. }
  182. }
  183. private function assertMatchesOperator(BSONDocument $operator, $actual, $keyPath)
  184. {
  185. $name = self::getOperatorName($operator);
  186. if ($name === '$$unsetOrMatches') {
  187. /* If the operator is used at the top level, consider null values
  188. * for $actual to be unset. If the operator is nested, this check is
  189. * done later during document iteration. */
  190. if ($keyPath === '' && $actual === null) {
  191. return;
  192. }
  193. $this->assertMatches(
  194. self::prepare($operator['$$unsetOrMatches']),
  195. $actual,
  196. $keyPath
  197. );
  198. return;
  199. }
  200. throw new LogicException('unsupported operator: ' . $name);
  201. }
  202. /** @see ConstraintTrait */
  203. private function doAdditionalFailureDescription($other)
  204. {
  205. if ($this->lastFailure === null) {
  206. return '';
  207. }
  208. return $this->lastFailure->getMessage();
  209. }
  210. /** @see ConstraintTrait */
  211. private function doFailureDescription($other)
  212. {
  213. return 'expected value matches actual value';
  214. }
  215. /** @see ConstraintTrait */
  216. private function doMatches($other)
  217. {
  218. $other = self::prepare($other);
  219. try {
  220. $this->assertMatches($this->value, $other);
  221. } catch (RuntimeException $e) {
  222. return false;
  223. }
  224. return true;
  225. }
  226. /** @see ConstraintTrait */
  227. private function doToString()
  228. {
  229. return 'matches ' . $this->exporter()->export($this->value);
  230. }
  231. private static function failAt(string $message, string $keyPath)
  232. {
  233. $prefix = empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath);
  234. throw new RuntimeException($prefix . $message);
  235. }
  236. private static function getOperatorName(BSONDocument $document)
  237. {
  238. foreach ($document as $key => $_) {
  239. if (strpos((string) $key, '$$') === 0) {
  240. return $key;
  241. }
  242. }
  243. throw new LogicException('should not reach this point');
  244. }
  245. private static function isNumeric($value)
  246. {
  247. return is_int($value) || is_float($value) || $value instanceof Int64;
  248. }
  249. private static function isOperator(BSONDocument $document)
  250. {
  251. if (count($document) !== 1) {
  252. return false;
  253. }
  254. foreach ($document as $key => $_) {
  255. return strpos((string) $key, '$$') === 0;
  256. }
  257. throw new LogicException('should not reach this point');
  258. }
  259. /**
  260. * Prepare a value for comparison.
  261. *
  262. * If the value is an array or object, it will be converted to a BSONArray
  263. * or BSONDocument. If $value is an array and $isRoot is true, it will be
  264. * converted to a BSONDocument; otherwise, it will be converted to a
  265. * BSONArray or BSONDocument based on its keys. Each value within an array
  266. * or document will then be prepared recursively.
  267. *
  268. * @param mixed $bson
  269. * @return mixed
  270. */
  271. private static function prepare($bson)
  272. {
  273. if (! is_array($bson) && ! is_object($bson)) {
  274. return $bson;
  275. }
  276. /* Convert Int64 objects to integers on 64-bit platforms for
  277. * compatibility reasons. */
  278. if ($bson instanceof Int64 && PHP_INT_SIZE != 4) {
  279. return (int) ((string) $bson);
  280. }
  281. /* TODO: Convert Int64 objects to integers on 32-bit platforms if they
  282. * can be expressed as such. This is necessary to handle flexible
  283. * numeric comparisons if the server returns 32-bit value as a 64-bit
  284. * integer (e.g. cursor ID). */
  285. // Serializable can produce an array or object, so recurse on its output
  286. if ($bson instanceof Serializable) {
  287. return self::prepare($bson->bsonSerialize());
  288. }
  289. /* Serializable has already been handled, so any remaining instances of
  290. * Type will not serialize as BSON arrays or objects */
  291. if ($bson instanceof Type) {
  292. return $bson;
  293. }
  294. if (is_array($bson) && self::isArrayEmptyOrIndexed($bson)) {
  295. $bson = new BSONArray($bson);
  296. }
  297. if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) {
  298. /* If $bson is an object, any numeric keys may become inaccessible.
  299. * We can work around this by casting back to an array. */
  300. $bson = new BSONDocument((array) $bson);
  301. }
  302. foreach ($bson as $key => $value) {
  303. if (is_array($value) || is_object($value)) {
  304. $bson[$key] = self::prepare($value);
  305. }
  306. }
  307. return $bson;
  308. }
  309. private static function isArrayEmptyOrIndexed(array $a)
  310. {
  311. if (empty($a)) {
  312. return true;
  313. }
  314. return array_keys($a) === range(0, count($a) - 1);
  315. }
  316. }