Skip to content

PHPLIB-715: Use flexible numeric comparisons in DocumentMatchConstraint #958

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 12 additions & 29 deletions tests/SpecTests/DocumentsMatchConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@
use function array_values;
use function get_class;
use function get_debug_type;
use function in_array;
use function is_array;
use function is_float;
use function is_int;
use function is_object;
use function is_scalar;
use function method_exists;
use function sprintf;

Expand All @@ -59,9 +59,6 @@ class DocumentsMatchConstraint extends Constraint
/** @var boolean */
private $ignoreExtraKeysInEmbedded = false;

/** @var array */
private $placeholders = [];

/**
* TODO: This is not currently used, but was preserved from the design of
* TestCase::assertMatchesDocument(), which would sort keys and then compare
Expand All @@ -88,14 +85,12 @@ class DocumentsMatchConstraint extends Constraint
* @param array|object $value
* @param boolean $ignoreExtraKeysInRoot If true, ignore extra keys within the root document
* @param boolean $ignoreExtraKeysInEmbedded If true, ignore extra keys within embedded documents
* @param array $placeholders Placeholders for any value
*/
public function __construct($value, bool $ignoreExtraKeysInRoot = false, bool $ignoreExtraKeysInEmbedded = false, array $placeholders = [])
public function __construct($value, bool $ignoreExtraKeysInRoot = false, bool $ignoreExtraKeysInEmbedded = false)
{
$this->value = $this->prepareBSON($value, true, $this->sortKeys);
$this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot;
$this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded;
$this->placeholders = $placeholders;
$this->comparatorFactory = Factory::getInstance();
}

Expand Down Expand Up @@ -303,10 +298,6 @@ private function assertEquals(ArrayObject $expected, ArrayObject $actual, bool $
throw new RuntimeException(sprintf('$actual is missing key: "%s"', $keyPrefix . $key));
}

if (in_array($expectedValue, $this->placeholders, true)) {
continue;
}

$actualValue = $actual[$key];

if ($expectedValue instanceof BSONDocument && isset($expectedValue['$$type'])) {
Expand All @@ -322,26 +313,13 @@ private function assertEquals(ArrayObject $expected, ArrayObject $actual, bool $
continue;
}

if (is_scalar($expectedValue) && is_scalar($actualValue)) {
if ($expectedValue !== $actualValue) {
throw new ComparisonFailure(
$expectedValue,
$actualValue,
'',
'',
false,
sprintf('Field path "%s": %s', $keyPrefix . $key, 'Failed asserting that two values are equal.')
);
}

continue;
}

$expectedType = get_debug_type($expectedValue);
$actualType = get_debug_type($actualValue);

// Workaround for ObjectComparator printing the whole actual object
if ($expectedType !== $actualType) {
/* Early check to work around ObjectComparator printing the entire value
* for a failed type comparison. Avoid doing this if either value is
* numeric to allow for flexible numeric comparisons (e.g. 1 == 1.0). */
if ($expectedType !== $actualType && ! (self::isNumeric($expectedValue) || self::isNumeric($actualValue))) {
throw new ComparisonFailure(
$expectedValue,
$actualValue,
Expand Down Expand Up @@ -421,6 +399,11 @@ private function doToString()
return 'matches ' . $this->exporter()->export($this->value);
}

private static function isNumeric($value): bool
{
return is_int($value) || is_float($value) || $value instanceof Int64;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareBSON has logic to "Convert Int64 objects to integers on 64-bit platforms for compatibility reasons." Was that sufficient to allow flexible comparisons of Int64 objects with integers and floats? Mainly asking because I don't believe we have a test for this.

Int64 is conditionally used in DocumentsMatchConstraintTest only when the platform is 32-bit.

This probably makes no difference, but the fact that you reference the class here made me ask.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shamelessly copied the logic from the unified spec tests where this was used for flexible numeric comparisons.

}

/**
* Prepare a BSON document or array for comparison.
*
Expand Down
23 changes: 11 additions & 12 deletions tests/SpecTests/DocumentsMatchConstraintTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ public function testIgnoreExtraKeysInRoot(): void
$this->assertResult(false, $c, [1, ['a' => 1, 'b' => 2]], 'Extra keys in embedded are not permitted');
}

public function testFlexibleNumericComparison(): void
{
$c = new DocumentsMatchConstraint(['x' => 1, 'y' => 1.0]);
$this->assertResult(true, $c, ['x' => 1.0, 'y' => 1.0], 'Float instead of expected int matches');
$this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'Int instead of expected float matches');
$this->assertResult(false, $c, ['x' => 'foo', 'y' => 1.0], 'Different type does not match');
}

public function testIgnoreExtraKeysInEmbedded(): void
{
$c = new DocumentsMatchConstraint(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true);
Expand All @@ -64,15 +72,6 @@ public function testIgnoreExtraKeysInEmbedded(): void
$this->assertResult(false, $c, [1, ['a' => 2]], 'Keys must have the correct value');
}

public function testPlaceholders(): void
{
$c = new DocumentsMatchConstraint(['x' => '42', 'y' => 42, 'z' => ['a' => 24]], false, false, [24, 42]);

$this->assertResult(true, $c, ['x' => '42', 'y' => 'foo', 'z' => ['a' => 1]], 'Placeholders accept any value');
$this->assertResult(false, $c, ['x' => 42, 'y' => 'foo', 'z' => ['a' => 1]], 'Placeholder type must match');
$this->assertResult(true, $c, ['x' => '42', 'y' => 42, 'z' => ['a' => 24]], 'Exact match');
}

/**
* @dataProvider provideBSONTypes
*/
Expand Down Expand Up @@ -146,13 +145,13 @@ public function errorMessageProvider()
['foo' => ['foo' => 'bar', 'bar' => 'baz']],
],
'Scalar value not equal' => [
'Field path "foo": Failed asserting that two values are equal.',
'Field path "foo": Failed asserting that two strings are equal.',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error messages change due to us no longer using a direct comparison for scalar values, and instead leaving those checks to PHPUnit comparators.

new DocumentsMatchConstraint(['foo' => 'bar']),
['foo' => 'baz'],
],
'Scalar type mismatch' => [
'Field path "foo": Failed asserting that two values are equal.',
new DocumentsMatchConstraint(['foo' => 42]),
'Field path "foo": \'42\' is not instance of expected type "bool".',
new DocumentsMatchConstraint(['foo' => true]),
['foo' => '42'],
],
'Type mismatch' => [
Expand Down
7 changes: 6 additions & 1 deletion tests/SpecTests/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use MongoDB\Driver\Server;
use MongoDB\Driver\Session;
use MongoDB\GridFS\Bucket;
use MongoDB\MapReduceResult;
use MongoDB\Model\IndexInfo;
use MongoDB\Operation\FindOneAndReplace;
use MongoDB\Operation\FindOneAndUpdate;
Expand Down Expand Up @@ -206,6 +207,10 @@ public function assert(FunctionalTestCase $test, Context $context, bool $bubbleE
* is not used (e.g. Command Monitoring spec). */
if ($result instanceof Cursor) {
$result = $result->toArray();
} elseif ($result instanceof MapReduceResult) {
/* For mapReduce operations, we ignore the mapReduce metadata
* and only return the result iterator for evaluation. */
$result = iterator_to_array($result->getIterator());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this change necessary? The only other change was ASSERT_SAME_DOCUMENTS to ASSERT_DOCUMENTS_MATCH. IIRC, the result assertion logic iterates the $result variable here and metadata is never considered.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't. We could update the logic to call iterator_to_array on any IteratorAggregate, but assertDocumentsMatch compared the MapReduceResult instance to the expected object, causing a test failure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResultExpectation::assert() had entirely different handling for those cases. ASSERT_SAME_DOCUMENTS invoked TestCase::assertSameDocuments(), which unpacks the Traversable with iterator_to_array(). ASSERT_DOCUMENTS_MATCH used MongoDB\Tests\SpecTests\FunctionalTestCase::assertDocumentsMatch.

This is fine. No sense adding significant refactoring for an outdated code path.

}
} catch (Exception $e) {
$exception = $e;
Expand Down Expand Up @@ -789,7 +794,7 @@ private function getResultAssertionTypeForCollection()
return ResultExpectation::ASSERT_SAME_DOCUMENTS;

case 'mapReduce':
return ResultExpectation::ASSERT_SAME_DOCUMENTS;
return ResultExpectation::ASSERT_DOCUMENTS_MATCH;

case 'replaceOne':
case 'updateMany':
Expand Down
2 changes: 1 addition & 1 deletion tests/SpecTests/RetryableReadsSpecTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class RetryableReadsSpecTest extends FunctionalTestCase
];

/** @var array */
private static $incompleteTests = ['mapReduce: MapReduce succeeds with retry on' => 'PHPLIB-715'];
private static $incompleteTests = [];

/**
* Assert that the expected and actual command documents match.
Expand Down
8 changes: 8 additions & 0 deletions tests/UnifiedSpecTests/Constraint/MatchesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ public function testMatchesDocument(): void
$this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant');
}

public function testFlexibleNumericComparison(): void
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added these for the sake of completeness (and because I wanted to make sure that this works in the unified test runner).

{
$c = new Matches(['x' => 1, 'y' => 1.0]);
$this->assertResult(true, $c, ['x' => 1.0, 'y' => 1.0], 'Float instead of expected int matches');
$this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'Int instead of expected float matches');
$this->assertResult(false, $c, ['x' => 'foo', 'y' => 1.0], 'Different type does not match');
}

public function testDoNotAllowExtraRootKeys(): void
{
$c = new Matches(['x' => 1], null, false);
Expand Down