From 735f2564acbb027b2622a9da544173a0157e4ef8 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 8 Sep 2020 22:33:58 -0400 Subject: [PATCH 01/41] Reduce isShardedCluster to a one-liner --- tests/FunctionalTestCase.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 778a259e6..03d389677 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -308,11 +308,7 @@ protected function isReplicaSet() protected function isShardedCluster() { - if ($this->getPrimaryServer()->getType() == Server::TYPE_MONGOS) { - return true; - } - - return false; + return $this->getPrimaryServer()->getType() == Server::TYPE_MONGOS; } protected function isShardedClusterUsingReplicasets() From 1845c2e01c2922c9f6a1c9d6224c846f482798a9 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 8 Sep 2020 22:34:57 -0400 Subject: [PATCH 02/41] wip --- .../DocumentsMatchConstraint.php | 458 ++++++++++++++++++ .../DocumentsMatchConstraintTest.php | 168 +++++++ tests/UnifiedSpecTests/EntityMap.php | 88 ++++ tests/UnifiedSpecTests/FunctionalTestCase.php | 230 +++++++++ tests/UnifiedSpecTests/RunOnRequirement.php | 102 ++++ tests/UnifiedSpecTests/UnifiedSpecTest.php | 246 ++++++++++ tests/UnifiedSpecTests/example-insertOne.json | 100 ++++ tests/UnifiedSpecTests/example-insertOne.yml | 53 ++ 8 files changed, 1445 insertions(+) create mode 100644 tests/UnifiedSpecTests/DocumentsMatchConstraint.php create mode 100644 tests/UnifiedSpecTests/DocumentsMatchConstraintTest.php create mode 100644 tests/UnifiedSpecTests/EntityMap.php create mode 100644 tests/UnifiedSpecTests/FunctionalTestCase.php create mode 100644 tests/UnifiedSpecTests/RunOnRequirement.php create mode 100644 tests/UnifiedSpecTests/UnifiedSpecTest.php create mode 100644 tests/UnifiedSpecTests/example-insertOne.json create mode 100644 tests/UnifiedSpecTests/example-insertOne.yml diff --git a/tests/UnifiedSpecTests/DocumentsMatchConstraint.php b/tests/UnifiedSpecTests/DocumentsMatchConstraint.php new file mode 100644 index 000000000..26556491a --- /dev/null +++ b/tests/UnifiedSpecTests/DocumentsMatchConstraint.php @@ -0,0 +1,458 @@ +value = $this->prepareBSON($value, true, $this->sortKeys); + $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot; + $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded; + $this->placeholders = $placeholders; + $this->comparatorFactory = Factory::getInstance(); + } + + public function evaluate($other, $description = '', $returnResult = false) + { + /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be + * able to skip preparation, convert both documents to extended JSON, + * and compare strings. + * + * If ignoreExtraKeys is false and sortKeys is true, we still be able to + * compare JSON strings but will still require preparation to sort keys + * in all documents and sub-documents. */ + $other = $this->prepareBSON($other, true, $this->sortKeys); + + $success = false; + $this->lastFailure = null; + + try { + $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); + $success = true; + } catch (RuntimeException $e) { + $this->lastFailure = new ComparisonFailure( + $this->value, + $other, + $this->exporter()->export($this->value), + $this->exporter()->export($other), + false, + $e->getMessage() + ); + } + + if ($returnResult) { + return $success; + } + + if (! $success) { + $this->fail($other, $description, $this->lastFailure); + } + } + + /** + * @param string $expectedType + * @param mixed $actualValue + */ + private function assertBSONType($expectedType, $actualValue) + { + switch ($expectedType) { + case 'double': + (new IsType('float'))->evaluate($actualValue); + + return; + case 'string': + (new IsType('string'))->evaluate($actualValue); + + return; + case 'object': + $constraints = [ + new IsType('object'), + new LogicalNot(new IsInstanceOf(BSONArray::class)), + ]; + + // LogicalAnd::fromConstraints was introduced in PHPUnit 6.5.0. + // This check can be removed when the PHPUnit dependency is bumped to that version + if (method_exists(LogicalAnd::class, 'fromConstraints')) { + $constraint = LogicalAnd::fromConstraints(...$constraints); + } else { + $constraint = new LogicalAnd(); + $constraint->setConstraints($constraints); + } + + $constraint->evaluate($actualValue); + + return; + case 'array': + $constraints = [ + new IsType('array'), + new IsInstanceOf(BSONArray::class), + ]; + + // LogicalOr::fromConstraints was introduced in PHPUnit 6.5.0. + // This check can be removed when the PHPUnit dependency is bumped to that version + if (method_exists(LogicalOr::class, 'fromConstraints')) { + $constraint = LogicalOr::fromConstraints(...$constraints); + } else { + $constraint = new LogicalOr(); + $constraint->setConstraints($constraints); + } + + $constraint->evaluate($actualValue); + + return; + case 'binData': + (new IsInstanceOf(BinaryInterface::class))->evaluate($actualValue); + + return; + case 'undefined': + (new IsInstanceOf(Undefined::class))->evaluate($actualValue); + + return; + case 'objectId': + (new IsInstanceOf(ObjectId::class))->evaluate($actualValue); + + return; + case 'boolean': + (new IsType('bool'))->evaluate($actualValue); + + return; + case 'date': + (new IsInstanceOf(UTCDateTime::class))->evaluate($actualValue); + + return; + case 'null': + (new IsNull())->evaluate($actualValue); + + return; + case 'regex': + (new IsInstanceOf(Regex::class))->evaluate($actualValue); + + return; + case 'dbPointer': + (new IsInstanceOf(DBPointer::class))->evaluate($actualValue); + + return; + case 'javascript': + (new IsInstanceOf(Javascript::class))->evaluate($actualValue); + + return; + case 'symbol': + (new IsInstanceOf(Symbol::class))->evaluate($actualValue); + + return; + case 'int': + (new IsType('int'))->evaluate($actualValue); + + return; + case 'timestamp': + (new IsInstanceOf(Timestamp::class))->evaluate($actualValue); + + return; + case 'long': + if (PHP_INT_SIZE == 4) { + (new IsInstanceOf(Int64::class))->evaluate($actualValue); + } else { + (new IsType('int'))->evaluate($actualValue); + } + + return; + case 'decimal': + (new IsInstanceOf(Decimal128::class))->evaluate($actualValue); + + return; + case 'minKey': + (new IsInstanceOf(MinKey::class))->evaluate($actualValue); + + return; + case 'maxKey': + (new IsInstanceOf(MaxKey::class))->evaluate($actualValue); + + return; + } + } + + /** + * Compares two documents recursively. + * + * @param ArrayObject $expected + * @param ArrayObject $actual + * @param boolean $ignoreExtraKeys + * @param string $keyPrefix + * @throws RuntimeException if the documents do not match + */ + private function assertEquals(ArrayObject $expected, ArrayObject $actual, $ignoreExtraKeys, $keyPrefix = '') + { + if (get_class($expected) !== get_class($actual)) { + throw new RuntimeException(sprintf( + '%s is not instance of expected class "%s"', + $this->exporter()->shortenedExport($actual), + get_class($expected) + )); + } + + foreach ($expected as $key => $expectedValue) { + $actualHasKey = $actual->offsetExists($key); + + if (! $actualHasKey) { + 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'])) { + $this->assertBSONType($expectedValue['$$type'], $actualValue); + continue; + } + + if (($expectedValue instanceof BSONArray && $actualValue instanceof BSONArray) || + ($expectedValue instanceof BSONDocument && $actualValue instanceof BSONDocument)) { + $this->assertEquals($expectedValue, $actualValue, $this->ignoreExtraKeysInEmbedded, $keyPrefix . $key . '.'); + 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 = is_object($expectedValue) ? get_class($expectedValue) : gettype($expectedValue); + $actualType = is_object($expectedValue) ? get_class($actualValue) : gettype($actualValue); + + // Workaround for ObjectComparator printing the whole actual object + if ($expectedType !== $actualType) { + throw new ComparisonFailure( + $expectedValue, + $actualValue, + '', + '', + false, + sprintf( + 'Field path "%s": %s is not instance of expected type "%s".', + $keyPrefix . $key, + $this->exporter()->shortenedExport($actualValue), + $expectedType + ) + ); + } + + try { + $this->comparatorFactory->getComparatorFor($expectedValue, $actualValue)->assertEquals($expectedValue, $actualValue); + } catch (ComparisonFailure $failure) { + throw new ComparisonFailure( + $expectedValue, + $actualValue, + '', + '', + false, + sprintf('Field path "%s": %s', $keyPrefix . $key, $failure->getMessage()) + ); + } + } + + if ($ignoreExtraKeys) { + return; + } + + foreach ($actual as $key => $value) { + if (! $expected->offsetExists($key)) { + throw new RuntimeException(sprintf('$actual has extra key: "%s"', $keyPrefix . $key)); + } + } + } + + private function doAdditionalFailureDescription($other) + { + if ($this->lastFailure === null) { + return ''; + } + + return $this->lastFailure->getMessage(); + } + + private function doFailureDescription($other) + { + return 'two BSON objects are equal'; + } + + private function doMatches($other) + { + /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be + * able to skip preparation, convert both documents to extended JSON, + * and compare strings. + * + * If ignoreExtraKeys is false and sortKeys is true, we still be able to + * compare JSON strings but will still require preparation to sort keys + * in all documents and sub-documents. */ + $other = $this->prepareBSON($other, true, $this->sortKeys); + + try { + $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); + } catch (RuntimeException $e) { + return false; + } + + return true; + } + + private function doToString() + { + return 'matches ' . $this->exporter()->export($this->value); + } + + /** + * Prepare a BSON document or array for comparison. + * + * The argument will be converted to a BSONArray or BSONDocument based on + * its type and keys. Keys within documents will optionally be sorted. Each + * value within the array or document will then be prepared recursively. + * + * @param array|object $bson + * @param boolean $isRoot If true, ensure an array value is converted to a document + * @param boolean $sortKeys + * @return BSONDocument|BSONArray + * @throws InvalidArgumentException if $bson is not an array or object + */ + private function prepareBSON($bson, $isRoot, $sortKeys = false) + { + if (! is_array($bson) && ! is_object($bson)) { + throw new InvalidArgumentException('$bson is not an array or object'); + } + + if ($isRoot && is_array($bson)) { + $bson = (object) $bson; + } + + if ($bson instanceof BSONArray || (is_array($bson) && $bson === array_values($bson))) { + if (! $bson instanceof BSONArray) { + $bson = new BSONArray($bson); + } + } else { + if (! $bson instanceof BSONDocument) { + $bson = new BSONDocument((array) $bson); + } + + if ($sortKeys) { + $bson->ksort(); + } + } + + foreach ($bson as $key => $value) { + if ($value instanceof BSONArray || (is_array($value) && $value === array_values($value))) { + $bson[$key] = $this->prepareBSON($value, false, $sortKeys); + continue; + } + + if ($value instanceof BSONDocument || $value instanceof stdClass || is_array($value)) { + $bson[$key] = $this->prepareBSON($value, false, $sortKeys); + continue; + } + + /* Convert Int64 objects to integers on 64-bit platforms for + * compatibility reasons. */ + if ($value instanceof Int64 && PHP_INT_SIZE != 4) { + $bson[$key] = (int) ((string) $value); + } + } + + return $bson; + } +} diff --git a/tests/UnifiedSpecTests/DocumentsMatchConstraintTest.php b/tests/UnifiedSpecTests/DocumentsMatchConstraintTest.php new file mode 100644 index 000000000..1d4abddfe --- /dev/null +++ b/tests/UnifiedSpecTests/DocumentsMatchConstraintTest.php @@ -0,0 +1,168 @@ + 1, 'y' => ['a' => 1, 'b' => 2]], true, false); + + $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are permitted'); + $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are not permitted'); + $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); + + // Arrays are always interpreted as root documents + $c = new DocumentsMatchConstraint([1, ['a' => 1]], true, false); + + $this->assertResult(false, $c, [1, 2], 'Incorrect value'); + $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); + $this->assertResult(true, $c, [1, ['a' => 1], 3], 'Extra keys in root are permitted'); + $this->assertResult(false, $c, [1, ['a' => 1, 'b' => 2]], 'Extra keys in embedded are not permitted'); + } + + public function testIgnoreExtraKeysInEmbedded() + { + $c = new DocumentsMatchConstraint(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); + + $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); + $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 3]], 'Incorrect value'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); + $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are not permitted'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are permitted'); + $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded Key order is not significant'); + + // Arrays are always interpreted as root documents + $c = new DocumentsMatchConstraint([1, ['a' => 1]], false, true); + + $this->assertResult(false, $c, [1, 2], 'Incorrect value'); + $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); + $this->assertResult(false, $c, [1, ['a' => 1], 3], 'Extra keys in root are not permitted'); + $this->assertResult(true, $c, [1, ['a' => 1, 'b' => 2]], 'Extra keys in embedded are permitted'); + $this->assertResult(false, $c, [1, ['a' => 2]], 'Keys must have the correct value'); + } + + public function testPlaceholders() + { + $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 + */ + public function testBSONTypeAssertions($type, $value) + { + $constraint = new DocumentsMatchConstraint(['x' => ['$$type' => $type]]); + + $this->assertResult(true, $constraint, ['x' => $value], 'Type matches'); + } + + public function provideBSONTypes() + { + $undefined = toPHP(fromJSON('{ "undefined": {"$undefined": true} }')); + $symbol = toPHP(fromJSON('{ "symbol": {"$symbol": "test"} }')); + $dbPointer = toPHP(fromJSON('{ "dbPointer": {"$dbPointer": {"$ref": "phongo.test", "$id" : { "$oid" : "5a2e78accd485d55b405ac12" } }} }')); + + return [ + 'double' => ['double', 1.4], + 'string' => ['string', 'foo'], + 'object' => ['object', new BSONDocument()], + 'array' => ['array', ['foo']], + 'binData' => ['binData', new Binary('', 0)], + 'undefined' => ['undefined', $undefined->undefined], + 'objectId' => ['objectId', new ObjectId()], + 'boolean' => ['boolean', true], + 'date' => ['date', new UTCDateTime()], + 'null' => ['null', null], + 'regex' => ['regex', new Regex('.*')], + 'dbPointer' => ['dbPointer', $dbPointer->dbPointer], + 'javascript' => ['javascript', new Javascript('foo = 1;')], + 'symbol' => ['symbol', $symbol->symbol], + 'int' => ['int', 1], + 'timestamp' => ['timestamp', new Timestamp(0, 0)], + 'long' => ['long', PHP_INT_SIZE == 4 ? unserialize('C:18:"MongoDB\BSON\Int64":38:{a:1:{s:7:"integer";s:10:"4294967296";}}') : 4294967296], + 'decimal' => ['decimal', new Decimal128('18446744073709551616')], + 'minKey' => ['minKey', new MinKey()], + 'maxKey' => ['maxKey', new MaxKey()], + ]; + } + + /** + * @dataProvider errorMessageProvider + */ + public function testErrorMessages($expectedMessagePart, DocumentsMatchConstraint $constraint, $actualValue) + { + try { + $constraint->evaluate($actualValue); + $this->fail('Expected a comparison failure'); + } catch (ExpectationFailedException $failure) { + $this->assertStringContainsString('Failed asserting that two BSON objects are equal.', $failure->getMessage()); + $this->assertStringContainsString($expectedMessagePart, $failure->getMessage()); + } + } + + public function errorMessageProvider() + { + return [ + 'Root type mismatch' => [ + 'MongoDB\Model\BSONArray Object (...) is not instance of expected class "MongoDB\Model\BSONDocument"', + new DocumentsMatchConstraint(['foo' => 'bar']), + new BSONArray(['foo' => 'bar']), + ], + 'Missing key' => [ + '$actual is missing key: "foo.bar"', + new DocumentsMatchConstraint(['foo' => ['bar' => 'baz']]), + ['foo' => ['foo' => 'bar']], + ], + 'Extra key' => [ + '$actual has extra key: "foo.foo"', + new DocumentsMatchConstraint(['foo' => ['bar' => 'baz']]), + ['foo' => ['foo' => 'bar', 'bar' => 'baz']], + ], + 'Scalar value not equal' => [ + 'Field path "foo": Failed asserting that two values are equal.', + new DocumentsMatchConstraint(['foo' => 'bar']), + ['foo' => 'baz'], + ], + 'Scalar type mismatch' => [ + 'Field path "foo": Failed asserting that two values are equal.', + new DocumentsMatchConstraint(['foo' => 42]), + ['foo' => '42'], + ], + 'Type mismatch' => [ + 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected type "MongoDB\Model\BSONArray".', + new DocumentsMatchConstraint(['foo' => ['bar']]), + ['foo' => (object) ['bar']], + ], + ]; + } + + private function assertResult($expectedResult, DocumentsMatchConstraint $constraint, $value, $message) + { + $this->assertSame($expectedResult, $constraint->evaluate($value, '', true), $message); + } +} diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php new file mode 100644 index 000000000..c57d6909a --- /dev/null +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -0,0 +1,88 @@ +container); + } + + /** + * Return an entity from the map. + * + * @see http://php.net/arrayaccess.offsetget + * @param mixed $key + * @return mixed + * @throws InvalidArgumentException if the key is not a string + * @throws OutOfBoundsException if the entity is not defined + */ + public function offsetGet($key) + { + if (! is_string($key)) { + throw new InvalidArgumentException('Key is not a string'); + } + + if (! $this->offsetExists($key)) { + throw new OutOfBoundsException(sprintf('No entity is defined for "%s"', $key)); + } + + return $this->container[$key]; + } + + /** + * Assigns an entity to the map. + * + * @see http://php.net/arrayaccess.offsetset + * @param mixed $key + * @param mixed $value + * @throws InvalidArgumentException if the key is not a string + * @throws OutOfBoundsException if the entity is already defined + */ + public function offsetSet($key, $value) + { + if (! is_string($key)) { + throw new InvalidArgumentException('Key is not a string'); + } + + if ($this->offsetExists($key)) { + throw new OutOfBoundsException('Entity already exists for key "%s" and cannot be replaced'); + } + + $this->container[$key] = $value; + } + + /** + * Not supported. + * + * @see http://php.net/arrayaccess.offsetunset + * @param mixed $key + * @throws BadMethodCallException + */ + public function offsetUnset($key) + { + throw new BadMethodCallException('Entities cannot be removed from the map'); + } +} diff --git a/tests/UnifiedSpecTests/FunctionalTestCase.php b/tests/UnifiedSpecTests/FunctionalTestCase.php new file mode 100644 index 000000000..9778fe334 --- /dev/null +++ b/tests/UnifiedSpecTests/FunctionalTestCase.php @@ -0,0 +1,230 @@ +context = null; + } + + private function doTearDown() + { + $this->context = null; + + parent::tearDown(); + } + + /** + * Assert that the expected and actual command documents match. + * + * Note: Spec tests that do not assert command started events may throw an + * exception in lieu of implementing this method. + * + * @param stdClass $expectedCommand Expected command document + * @param stdClass $actualCommand Actual command document + */ + public static function assertCommandMatches(stdClass $expected, stdClass $actual) + { + throw new LogicException(sprintf('%s does not assert CommandStartedEvents', static::class)); + } + + /** + * Assert that the expected and actual command reply documents match. + * + * Note: Spec tests that do not assert command started events may throw an + * exception in lieu of implementing this method. + * + * @param stdClass $expected Expected command reply document + * @param stdClass $actual Actual command reply document + */ + public static function assertCommandReplyMatches(stdClass $expected, stdClass $actual) + { + throw new LogicException(sprintf('%s does not assert CommandSucceededEvents', static::class)); + } + + /** + * Asserts that two given documents match. + * + * Extra keys in the actual value's document(s) will be ignored. + * + * @param array|object $expectedDocument + * @param array|object $actualDocument + * @param string $message + */ + protected static function assertDocumentsMatch($expectedDocument, $actualDocument, $message = '') + { + $constraint = new DocumentsMatchConstraint($expectedDocument, true, true); + + static::assertThat($actualDocument, $constraint, $message); + } + + /** + * Assert data within the outcome collection. + * + * @param array $expectedDocuments + * @param int $resultExpectation + */ + protected function assertOutcomeCollectionData(array $expectedDocuments, $resultExpectation = ResultExpectation::ASSERT_SAME_DOCUMENT) + { + $outcomeCollection = $this->getOutcomeCollection($this->getContext()->outcomeReadOptions); + + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($expectedDocuments)); + $mi->attachIterator(new IteratorIterator($outcomeCollection->find([], ['sort' => ['_id' => 1]]))); + + foreach ($mi as $documents) { + list($expectedDocument, $actualDocument) = $documents; + $this->assertNotNull($expectedDocument); + $this->assertNotNull($actualDocument); + + switch ($resultExpectation) { + case ResultExpectation::ASSERT_SAME_DOCUMENT: + $this->assertSameDocument($expectedDocument, $actualDocument); + break; + + case ResultExpectation::ASSERT_DOCUMENTS_MATCH: + $this->assertDocumentsMatch($expectedDocument, $actualDocument); + break; + + default: + $this->fail(sprintf('Invalid result expectation "%d" for %s', $resultExpectation, __METHOD__)); + } + } + } + + /** + * Decode a JSON spec test. + * + * This decodes the file through the driver's extended JSON parser to ensure + * proper handling of special types. + * + * @param string $json + * @return array + */ + protected function decodeJson($json) + { + return toPHP(fromJSON($json)); + } + + /** + * Return the test context. + * + * @return Context + * @throws LogicException if the context has not been set + */ + protected function getContext() + { + if (! $this->context instanceof Context) { + throw new LogicException('Context has not been set'); + } + + return $this->context; + } + + /** + * Set the test context. + * + * @param Context $context + */ + protected function setContext(Context $context) + { + $this->context = $context; + } + + /** + * Drop the test and outcome collections by dropping them. + */ + protected function dropTestAndOutcomeCollections() + { + $context = $this->getContext(); + + if ($context->databaseName === 'admin') { + return; + } + + if ($context->bucketName !== null) { + $bucket = $context->getGridFSBucket($context->defaultWriteOptions); + $bucket->drop(); + } + + $collection = null; + if ($context->collectionName !== null) { + $collection = $context->getCollection($context->defaultWriteOptions); + $collection->drop(); + } + + if ($context->outcomeCollectionName !== null) { + $outcomeCollection = $this->getOutcomeCollection($context->defaultWriteOptions); + + // Avoid redundant drop if the test and outcome collections are the same + if ($collection === null || $outcomeCollection->getNamespace() !== $collection->getNamespace()) { + $outcomeCollection->drop(); + } + } + } + + /** + * Insert data fixtures into the test collection. + * + * @param array $documents + * @param string|null $collectionName + */ + protected function insertDataFixtures(array $documents, $collectionName = null) + { + if (empty($documents)) { + return; + } + + $context = $this->getContext(); + $collection = $collectionName ? $context->selectCollection($context->databaseName, $collectionName) : $context->getCollection(); + + $collection->insertMany($documents, $context->defaultWriteOptions); + + return; + } + + private function getOutcomeCollection(array $collectionOptions = []) + { + $context = $this->getContext(); + + // Outcome collection need not use the client under test + return new Collection($this->manager, $context->databaseName, $context->outcomeCollectionName, $collectionOptions); + } +} diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php new file mode 100644 index 000000000..e3f64ab8d --- /dev/null +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -0,0 +1,102 @@ +bsonUnserialize((array) $data); + } + + /** + * @see https://www.php.net/manual/en/mongodb-bson-unserializable.bsonunserialize.php + */ + public function bsonUnserialize(array $data) + { + $this->minServerVersion = $data['minServerVersion'] ?? null; + $this->maxServerVersion = $data['maxServerVersion'] ?? null; + $this->topologies = $data['topologies'] ?? null; + + $this->validate(); + } + + /** + * Checks if the requirements are satisfied. + * + * @param string $serverVersion + * @param string $topology + * @return boolean + */ + public function isSatisfied(string $serverVersion, string $topology) + { + if (isset($this->minServerVersion) && version_compare($serverVersion, $this->minServerVersion, '<')) { + return false; + } + + if (isset($this->maxServerVersion) && version_compare($serverVersion, $this->maxServerVersion, '>')) { + return false; + } + + if (isset($this->topologies)) { + if (in_array($topology, $this->topologies)) { + return true; + } + + /* Ensure "sharded-replicaset" is also accepted for topologies that + * only include "sharded" (agnostic about the shard topology) */ + if ($topology === self::TOPOLOGY_SHARDED_REPLICASET && in_array(self::TOPOLOGY_SHARDED, $this->topologies)) { + return true; + } + + return false; + } + + return true; + } + + /** + * @throws UnexpectedValueException if a property is invalid + */ + private function validate() + { + if (isset($this->minServerVersion) && ! is_string($this->minServerVersion)) { + throw new UnexpectedValueException('minServerVersion is not a string'); + } + + if (isset($this->maxServerVersion) && ! is_string($this->maxServerVersion)) { + throw new UnexpectedValueException('maxServerVersion is not a string'); + } + + if (! isset($this->topologies)) { + return; + } + + if (! is_array($this->topologies)) { + throw new UnexpectedValueException('topologies is not an array'); + } + + foreach ($this->topologies as $topology) { + if (! is_string($topology)) { + throw new UnexpectedValueException('topologies is not an array of strings'); + } + } + } +} diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php new file mode 100644 index 000000000..b328c4011 --- /dev/null +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -0,0 +1,246 @@ +hasFailed()) { + self::killAllSessions(); + } + + parent::tearDown(); + } + + /** + * Execute an individual test case from the specification. + * + * @dataProvider provideTests + * @param stdClass $test Individual "tests[]" document + * @param array $runOn Top-level "runOn" array with server requirements + * @param array $data Top-level "data" array to initialize collection + * @param string $databaseName Name of database under test + * @param string $collectionName Name of collection under test + */ + public function testCase(stdClass $test, $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) + { + if (! $this->isSchemaVersionSupported($schemaVersion)) { + $this->markTestIncomplete(sprintf('Test format schema version "%s" is not supported', $schemaVersion)); + } + + if (isset($runOnRequirements)) { + $this->checkRunOnRequirements($runOnRequirements); + } + + if (isset($test->skipReason)) { + $this->markTestSkipped($test->skipReason); + } + + if (isset($test->runOnRequirements)) { + $this->checkRunOnRequirements($runOnRequirements); + } + + if (isset($initialData)) { + // TODO + } + } + + public function provideTests() + { + $testArgs = []; + + foreach (glob(__DIR__ . '/*.json') as $filename) { + /* Decode the file through the driver's extended JSON parser to + * ensure proper handling of special types. */ + $json = toPHP( + fromJSON(file_get_contents($filename)), + /* + ['fieldPaths' => [ + 'runOnRequirements.$' => RunOnRequirement::class, + 'tests.$.runOnRequirements.$' => RunOnRequirement::class, + ]]*/ + ); + + $description = $json->description; + $schemaVersion = $json->schemaVersion; + $runOnRequirements = $json->runOnRequirements ?? null; + $createEntities = $json->createEntities ?? null; + $initialData = $json->initialData ?? null; + + foreach ($json->tests as $test) { + $name = $description . ': ' . $test->description; + $testArgs[$name] = [$test, $schemaVersion, $runOnRequirements, $createEntities, $initialData]; + } + } + + return $testArgs; + } + + /** + * Checks server version and topology requirements. + * + * @param array $runOnRequirements + * @throws SkippedTest unless one or more runOnRequirements are met + */ + private function checkRunOnRequirements(array $runOnRequirements) + { + $this->assertNotEmpty($runOnRequirements); + + $serverVersion = $this->getCachedServerVersion(); + $topology = $this->getCachedTopology(); + + foreach ($runOnRequirements as $data) { + // $this->assertInstanceOf(RunOnRequirement::class, $runOnRequirement); + $runOnRequirement = new RunOnRequirement($data); + if ($runOnRequirement->isSatisfied($serverVersion, $topology)) { + return; + } + } + + $this->markTestSkipped(sprintf('Server version "%s" and topology "%s" do not meet test requirements', $serverVersion, $topology)); + } + + /** + * Return the server version (cached for subsequent calls). + * + * @return string + */ + private function getCachedServerVersion() + { + static $cachedServerVersion; + + if (isset($cachedServerVersion)) { + return $cachedServerVersion; + } + + $cachedServerVersion = $this->getServerVersion(); + + return $cachedServerVersion; + } + + /** + * Return the topology type (cached for subsequent calls). + * + * @return string + * @throws UnexpectedValueException if topology is neither single nor RS nor sharded + */ + private function getCachedTopology() + { + static $cachedTopology = null; + + if (isset($cachedTopology)) { + return $cachedTopology; + } + + switch ($this->getPrimaryServer()->getType()) { + case Server::TYPE_STANDALONE: + $cachedTopology = RunOnRequirement::TOPOLOGY_SINGLE; + break; + + case Server::TYPE_RS_PRIMARY: + $cachedTopology = RunOnRequirement::TOPOLOGY_REPLICASET; + break; + + case Server::TYPE_MONGOS: + $cachedTopology = $this->isShardedClusterUsingReplicasets() + ? RunOnRequirement::TOPOLOGY_SHARDED_REPLICASET + : RunOnRequirement::TOPOLOGY_SHARDED; + break; + + default: + throw new UnexpectedValueException('Toplogy is neither single nor RS nor sharded'); + } + + return $cachedTopology; + } + + /** + * Checks is a test format schema version is supported. + * + * @param string $schemaVersion + * @return boolean + */ + private function isSchemaVersionSupported($schemaVersion) + { + if (version_compare($schemaVersion, '1.0', '>=') && version_compare($schemaVersion, '1.1', '<')) { + return true; + } + + return false; + } + + /** + * Kill all sessions on the cluster. + * + * This will clean up any open transactions that may remain from a + * previously failed test. For sharded clusters, this command will be run + * on all mongos nodes. + */ + private static function killAllSessions() + { + $manager = new Manager(static::getUri()); + $primary = $manager->selectServer(new ReadPreference('primary')); + + $servers = $primary->getType() === Server::TYPE_MONGOS + ? $manager->getServers() + : [$primary]; + + foreach ($servers as $server) { + try { + // Skip servers that do not support sessions + if (! isset($server->getInfo()['logicalSessionTimeoutMinutes'])) { + continue; + } + $server->executeCommand('admin', new Command(['killAllSessions' => []])); + } catch (ServerException $e) { + // Interrupted error is safe to ignore (see: SERVER-38335) + if ($e->getCode() != self::ERROR_CODE_INTERRUPTED) { + throw $e; + } + } + } + } +} diff --git a/tests/UnifiedSpecTests/example-insertOne.json b/tests/UnifiedSpecTests/example-insertOne.json new file mode 100644 index 000000000..68f2ccf8d --- /dev/null +++ b/tests/UnifiedSpecTests/example-insertOne.json @@ -0,0 +1,100 @@ +{ + "description": "example-insertOne", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "2.6" + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "test" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "test", + "documents": [ + { + "_id": 1 + } + ] + } + ], + "tests": [ + { + "description": "insertOne", + "operations": [ + { + "object": "collection0", + "name": "insertOne", + "arguments": { + "document": { + "_id": 2 + } + }, + "expectedResult": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + ], + "expectedEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "commandName": "insert", + "databaseName": "test", + "command": { + "insert": "coll", + "documents": [ + { + "_id": 2 + } + ] + } + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "test", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/example-insertOne.yml b/tests/UnifiedSpecTests/example-insertOne.yml new file mode 100644 index 000000000..e00408008 --- /dev/null +++ b/tests/UnifiedSpecTests/example-insertOne.yml @@ -0,0 +1,53 @@ +description: "example-insertOne" + +schemaVersion: "1.0" + +runOnRequirements: + - minServerVersion: "2.6" + +createEntities: + - client: + id: &client0 client0 + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name test + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll + +initialData: + - collectionName: *collection0Name + databaseName: *database0Name + documents: + - { _id: 1 } + +tests: + - description: "insertOne" + operations: + - + object: *collection0 + name: insertOne + arguments: + document: { _id: 2 } + expectedResult: + insertedId: { $$unsetOrMatches: 2 } + expectedEvents: + - client: *client0 + events: + - commandStartedEvent: + commandName: insert + databaseName: *database0Name + command: + insert: *collection0Name + documents: + - { _id: 2 } + outcome: + - collectionName: *collection0Name + databaseName: *database0Name + documents: + - { _id: 1 } + - { _id: 2 } From 37da353e542926746f51dc3ca3f58ca50fb29d03 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 11 Sep 2020 22:47:59 -0400 Subject: [PATCH 03/41] wip --- tests/UnifiedSpecTests/CollectionData.php | 88 +++++ .../DocumentsMatch.php} | 4 +- .../DocumentsMatchTest.php} | 32 +- tests/UnifiedSpecTests/Context.php | 306 ++++++++++++++++++ tests/UnifiedSpecTests/EntityMap.php | 8 +- tests/UnifiedSpecTests/EventObserver.php | 102 ++++++ tests/UnifiedSpecTests/FunctionalTestCase.php | 230 ------------- tests/UnifiedSpecTests/RunOnRequirement.php | 64 ++-- tests/UnifiedSpecTests/UnifiedSpecTest.php | 65 ++-- tests/bootstrap.php | 4 + 10 files changed, 584 insertions(+), 319 deletions(-) create mode 100644 tests/UnifiedSpecTests/CollectionData.php rename tests/UnifiedSpecTests/{DocumentsMatchConstraint.php => Constraint/DocumentsMatch.php} (99%) rename tests/UnifiedSpecTests/{DocumentsMatchConstraintTest.php => Constraint/DocumentsMatchTest.php} (85%) create mode 100644 tests/UnifiedSpecTests/Context.php create mode 100644 tests/UnifiedSpecTests/EventObserver.php delete mode 100644 tests/UnifiedSpecTests/FunctionalTestCase.php diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php new file mode 100644 index 000000000..4d7ab2ba7 --- /dev/null +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -0,0 +1,88 @@ +collectionName); + $this->collectionName = $o->collectionName; + + assertIsString($o->databaseName); + $this->databaseName = $o->databaseName; + + assertIsArray($o->documents); + assertContainsOnly('object', $o->documents); + $this->documents = $o->documents; + } + + /** + * Prepare collection state for "initialData". + * + * @param Client $client + */ + public function prepare(Client $client) + { + $database = $client->selectDatabase( + $this->databaseName, + ['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)] + ); + + $database->dropCollection($this->collectionName); + + if (empty($this->documents)) { + $database->createCollection($this->collectionName); + return; + } + + $collection = $database->selectCollection($this->collectionName); + $collection->insertMany($this->documents); + } + + /** + * Assert collection contents for "outcome". + * + * @param Client $client + */ + public function assertOutcome(Client $client) + { + $collection = $client->selectCollection( + $this->databaseName, + $this->collectionName, + [ + 'readConcern' => new ReadConcern(ReadConcern::LOCAL), + 'readPreference' => new ReadPreference(ReadPreference::PRIMARY), + ] + ); + + $cursor = $collection->find([], ['sort' => ['_id' => 1]]); + + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($this->documents)); + $mi->attachIterator(new IteratorIterator($cursor)); + + foreach ($mi as $i => $documents) { + list($expectedDocument, $actualDocument) = $documents; + assertNotNull($expectedDocument); + assertNotNull($actualDocument); + + $constraint = new DocumentsMatch($expectedDocument, false, false); + assertThat($actualDocument, $constraint, sprintf('documents[%d] match', $i)); + } + } +} diff --git a/tests/UnifiedSpecTests/DocumentsMatchConstraint.php b/tests/UnifiedSpecTests/Constraint/DocumentsMatch.php similarity index 99% rename from tests/UnifiedSpecTests/DocumentsMatchConstraint.php rename to tests/UnifiedSpecTests/Constraint/DocumentsMatch.php index 26556491a..8b17daac4 100644 --- a/tests/UnifiedSpecTests/DocumentsMatchConstraint.php +++ b/tests/UnifiedSpecTests/Constraint/DocumentsMatch.php @@ -1,6 +1,6 @@ 1, 'y' => ['a' => 1, 'b' => 2]], true, false); + $c = new DocumentsMatch(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], true, false); $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); @@ -33,7 +33,7 @@ public function testIgnoreExtraKeysInRoot() $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); // Arrays are always interpreted as root documents - $c = new DocumentsMatchConstraint([1, ['a' => 1]], true, false); + $c = new DocumentsMatch([1, ['a' => 1]], true, false); $this->assertResult(false, $c, [1, 2], 'Incorrect value'); $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); @@ -43,7 +43,7 @@ public function testIgnoreExtraKeysInRoot() public function testIgnoreExtraKeysInEmbedded() { - $c = new DocumentsMatchConstraint(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); + $c = new DocumentsMatch(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 3]], 'Incorrect value'); @@ -53,7 +53,7 @@ public function testIgnoreExtraKeysInEmbedded() $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded Key order is not significant'); // Arrays are always interpreted as root documents - $c = new DocumentsMatchConstraint([1, ['a' => 1]], false, true); + $c = new DocumentsMatch([1, ['a' => 1]], false, true); $this->assertResult(false, $c, [1, 2], 'Incorrect value'); $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); @@ -64,7 +64,7 @@ public function testIgnoreExtraKeysInEmbedded() public function testPlaceholders() { - $c = new DocumentsMatchConstraint(['x' => '42', 'y' => 42, 'z' => ['a' => 24]], false, false, [24, 42]); + $c = new DocumentsMatch(['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'); @@ -76,7 +76,7 @@ public function testPlaceholders() */ public function testBSONTypeAssertions($type, $value) { - $constraint = new DocumentsMatchConstraint(['x' => ['$$type' => $type]]); + $constraint = new DocumentsMatch(['x' => ['$$type' => $type]]); $this->assertResult(true, $constraint, ['x' => $value], 'Type matches'); } @@ -114,7 +114,7 @@ public function provideBSONTypes() /** * @dataProvider errorMessageProvider */ - public function testErrorMessages($expectedMessagePart, DocumentsMatchConstraint $constraint, $actualValue) + public function testErrorMessages($expectedMessagePart, DocumentsMatch $constraint, $actualValue) { try { $constraint->evaluate($actualValue); @@ -130,38 +130,38 @@ public function errorMessageProvider() return [ 'Root type mismatch' => [ 'MongoDB\Model\BSONArray Object (...) is not instance of expected class "MongoDB\Model\BSONDocument"', - new DocumentsMatchConstraint(['foo' => 'bar']), + new DocumentsMatch(['foo' => 'bar']), new BSONArray(['foo' => 'bar']), ], 'Missing key' => [ '$actual is missing key: "foo.bar"', - new DocumentsMatchConstraint(['foo' => ['bar' => 'baz']]), + new DocumentsMatch(['foo' => ['bar' => 'baz']]), ['foo' => ['foo' => 'bar']], ], 'Extra key' => [ '$actual has extra key: "foo.foo"', - new DocumentsMatchConstraint(['foo' => ['bar' => 'baz']]), + new DocumentsMatch(['foo' => ['bar' => 'baz']]), ['foo' => ['foo' => 'bar', 'bar' => 'baz']], ], 'Scalar value not equal' => [ 'Field path "foo": Failed asserting that two values are equal.', - new DocumentsMatchConstraint(['foo' => 'bar']), + new DocumentsMatch(['foo' => 'bar']), ['foo' => 'baz'], ], 'Scalar type mismatch' => [ 'Field path "foo": Failed asserting that two values are equal.', - new DocumentsMatchConstraint(['foo' => 42]), + new DocumentsMatch(['foo' => 42]), ['foo' => '42'], ], 'Type mismatch' => [ 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected type "MongoDB\Model\BSONArray".', - new DocumentsMatchConstraint(['foo' => ['bar']]), + new DocumentsMatch(['foo' => ['bar']]), ['foo' => (object) ['bar']], ], ]; } - private function assertResult($expectedResult, DocumentsMatchConstraint $constraint, $value, $message) + private function assertResult($expectedResult, DocumentsMatch $constraint, $value, $message) { $this->assertSame($expectedResult, $constraint->evaluate($value, '', true), $message); } diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php new file mode 100644 index 000000000..96782c831 --- /dev/null +++ b/tests/UnifiedSpecTests/Context.php @@ -0,0 +1,306 @@ +entityMap = new EntityMap; + $this->uri = $uri; + } + + /** + * Create entities for "createEntities". + * + * @param array $createEntities + */ + public function createEntities(array $entities) + { + foreach ($entities as $entity) { + assertIsObject($entity); + $entity = (array) $entity; + assertCount(1, $entity); + + $type = key($entity); + $def = current($entity); + assertIsObject($def); + + $id = $def->id ?? null; + assertIsString($id); + assertArrayNotHasKey($id, $this->entityMap); + + switch ($type) { + case 'client': + $this->entityMap[$id] = $this->createClient($def); + + if (isset($def->observeEvents)) { + $this->eventObserversByClient[$id] = $this->createEventObserver($def); + } + break; + + case 'database': + $this->entityMap[$id] = $this->createDatabase($def); + break; + + case 'collection': + $this->entityMap[$id] = $this->createCollection($def); + break; + + default: + throw new LogicException('Unsupported entity type: ' . $type); + } + } + } + + public function startEventObservers() + { + foreach ($this->eventObserversByClient as $eventObserver) { + $eventObserver->start(); + } + } + + public function stopEventObservers() + { + foreach ($this->eventObserversByClient as $eventObserver) { + $eventObserver->stop(); + } + } + + private function createClient(stdClass $o): Client + { + $uri = $this->uri; + + if (isset($o->useMultipleMongoses)) { + assertIsBool($o->useMultipleMongoses); + + if ($o->useMultipleMongoses) { + self::requireMultipleMongoses($uri); + } else { + $uri = self::removeMultipleMongoses($uri); + } + } + + $uriOptions = []; + + if (isset($o->uriOptions)) { + assertIsObject($o->uriOptions); + $uriOptions = (array) $o->uriOptions; + } + + return new Client($uri, $uriOptions); + } + + private function createEventObserver(stdClass $o): EventObserver + { + $observeEvents = $o->observeEvents ?? null; + $ignoreCommands = $o->ignoreCommandMonitoringEvents ?? []; + + assertIsArray($observeEvents); + assertIsArray($ignoreCommands); + + return new EventObserver($observeEvents, $ignoreCommands); + } + + private function createCollection(stdClass $o): Collection + { + $collectionName = $o->collectionName ?? null; + $database = $o->database ?? null; + + assertIsString($collectionName); + assertIsString($database); + assertArrayHasKey($database, $this->entityMap); + + $database = $this->entityMap[$o->database]; + assertInstanceOf(Database::class, $database); + + $options = isset($o->collectionOptions) ? self::prepareCollectionOrDatabaseOptions($o->collectionOptions) : []; + + return $database->selectCollection($o->collectionName, $options); + } + + private function createDatabase(stdClass $o): Database + { + assertObjectHasAttribute('databaseName', $o); + assertIsString($o->databaseName); + + assertObjectHasAttribute('client', $o); + assertIsString($o->client); + assertArrayHasKey($o->client, $this->entityMap); + + $client = $this->entityMap[$o->client]; + assertInstanceOf(Client::class, $client); + + $options = isset($o->databaseOptions) ? self::prepareCollectionOrDatabaseOptions($o->databaseOptions) : []; + + return $client->selectDatabase($o->databaseName, $options); + } + + private static function prepareCollectionOrDatabaseOptions(stdClass $o): array + { + $options = []; + + if (isset($o->readConcern)) { + assertIsObject($o->readConcern); + $options['readConcern'] = self::prepareReadConcern($o->readConcern); + } + + if (isset($o->readPreference)) { + assertIsObject($o->readPreference); + $options['readPreference'] = self::prepareReadPreference($o->readPreference); + } + + if (isset($o->writeConcern)) { + assertIsObject($o->writeConcern); + $options['writeConcern'] = self::prepareWriteConcern($o->writeConcern); + } + + return $options; + } + + private static function createReadConcern(stdClass $o): ReadConcern + { + $level = $o->level ?? null; + assertIsString($level); + + return new ReadConcern($level); + } + + private static function createReadPreference(stdClass $o): ReadPreference + { + $mode = $o->mode ?? null; + $tagSets = $o->tagSets ?? null; + $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; + $hedge = $o->hedge ?? null; + + assertIsString($mode); + + if (isset($tagSets)) { + assertIsArray($tagSets); + assertContains('object', $tagSets); + } + + $options = []; + + if (isset($maxStalenessSeconds)) { + assertIsInt($maxStalenessSeconds); + $options['maxStalenessSeconds'] = $maxStalenessSeconds; + } + + if (isset($hedge)) { + assertIsObject($hedge); + $options['hedge'] = $hedge; + } + + return new ReadPreference($mode, $tagSets, $options); + } + + private static function createWriteConcern(stdClass $o): WriteConcern + { + $w = $o->w ?? -2 /* MONGOC_WRITE_CONCERN_W_DEFAULT */; + $wtimeoutMS = $o->wtimeoutMS ?? 0; + $journal = $o->journal ?? null; + + assertThat($w, logicalOr(new IsType('int'), new IsType('string'))); + assertIsInt($wtimeoutMS); + + $args = [$w, $wtimeoutMS]; + + if (isset($journal)) { + assertIsBool($journal); + $args[] = $journal; + } + + return new WriteConcern(...$args); + } + + /** + * Removes mongos hosts beyond the first if the URI refers to a sharded + * cluster. Otherwise, the URI is returned as-is. + */ + private static function removeMultipleMongoses(string $uri): string + { + assertStringStartsWith('mongodb://', $uri); + + $manager = new Manager($uri); + + // Nothing to do if the URI does not refer to a sharded cluster + if ($manager->selectServer(new ReadPreference(ReadPreference::PRIMARY))->getType() !== Server::TYPE_MONGOS) { + return $uri; + } + + $parts = parse_url($uri); + + assertIsArray($parts); + + $hosts = explode(',', $parts['host']); + + // Nothing to do if the URI already has a single mongos host + if (count($hosts) === 1) { + return $uri; + } + + // Re-append port to last host + if (isset($parts['port'])) { + $hosts[count($hosts) - 1] .= ':' . $parts['port']; + } + + $singleHost = $hosts[0]; + $multipleHosts = implode(',', $hosts); + + $pos = strpos($uri, $multipleHosts); + + assertNotFalse($pos); + + return substr_replace($uri, $singleHost, $pos, strlen($multipleHosts)); + } + + /** + * Requires multiple mongos hosts if the URI refers to a sharded cluster. + */ + private static function requireMultipleMongoses(string $uri) + { + assertStringStartsWith('mongodb://', $uri); + + $manager = new Manager($uri); + + // Nothing to do if the URI does not refer to a sharded cluster + if ($manager->selectServer(new ReadPreference(ReadPreference::PRIMARY))->getType() !== Server::TYPE_MONGOS) { + return; + } + + assertStringContains(',', parse_url($uri, PHP_URL_HOST)); + } +} diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index c57d6909a..2209e8b8b 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -11,7 +11,7 @@ class EntityMap implements ArrayAccess { - private $container = []; + private $map = []; /** * Check whether an entity exists in the map. @@ -27,7 +27,7 @@ public function offsetExists($key) throw new InvalidArgumentException('Key is not a string'); } - return array_key_exists($key, $this->container); + return array_key_exists($key, $this->map); } /** @@ -49,7 +49,7 @@ public function offsetGet($key) throw new OutOfBoundsException(sprintf('No entity is defined for "%s"', $key)); } - return $this->container[$key]; + return $this->map[$key]; } /** @@ -71,7 +71,7 @@ public function offsetSet($key, $value) throw new OutOfBoundsException('Entity already exists for key "%s" and cannot be replaced'); } - $this->container[$key] = $value; + $this->map[$key] = $value; } /** diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php new file mode 100644 index 000000000..2640a959a --- /dev/null +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -0,0 +1,102 @@ + CommandStartedEvent::class, + 'commandSucceededEvent' => CommandSucceededEvent::class, + 'commandFailedEvent' => CommandFailedEvent::class, + ]; + + public function __construct(array $observeEvents, array $ignoreCommands) + { + assertNotEmpty($observeEvents); + + foreach ($observeEvents as $event) { + assertIsString($event); + assertArrayHasKey($event, self::$commandMonitoringEvents); + $this->observeEvents[self::$commandMonitoringEvents[$event]] = 1; + } + + foreach ($ignoreCommands as $command) { + assertIsString($command); + $this->ignoreCommands[$command] = 1; + } + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandfailed.php + */ + public function commandFailed(CommandFailedEvent $event) + { + if (! isset($this->observeEvents[CommandFailedEvent::class])) { + return; + } + + if (isset($this->ignoreCommands[$event->getCommandName()])) { + return; + } + + $this->actualEvents[] = $event; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandstarted.php + */ + public function commandStarted(CommandStartedEvent $event) + { + if (! isset($this->observeEvents[CommandStartedEvent::class])) { + return; + } + + if (isset($this->ignoreCommands[$event->getCommandName()])) { + return; + } + + $this->actualEvents[] = $event; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + if (! isset($this->observeEvents[CommandSucceededEvent::class])) { + return; + } + + if (isset($this->ignoreCommands[$event->getCommandName()])) { + return; + } + + $this->actualEvents[] = $event; + } + + public function getActualEvents() + { + return $this->actualEvents; + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } +} diff --git a/tests/UnifiedSpecTests/FunctionalTestCase.php b/tests/UnifiedSpecTests/FunctionalTestCase.php deleted file mode 100644 index 9778fe334..000000000 --- a/tests/UnifiedSpecTests/FunctionalTestCase.php +++ /dev/null @@ -1,230 +0,0 @@ -context = null; - } - - private function doTearDown() - { - $this->context = null; - - parent::tearDown(); - } - - /** - * Assert that the expected and actual command documents match. - * - * Note: Spec tests that do not assert command started events may throw an - * exception in lieu of implementing this method. - * - * @param stdClass $expectedCommand Expected command document - * @param stdClass $actualCommand Actual command document - */ - public static function assertCommandMatches(stdClass $expected, stdClass $actual) - { - throw new LogicException(sprintf('%s does not assert CommandStartedEvents', static::class)); - } - - /** - * Assert that the expected and actual command reply documents match. - * - * Note: Spec tests that do not assert command started events may throw an - * exception in lieu of implementing this method. - * - * @param stdClass $expected Expected command reply document - * @param stdClass $actual Actual command reply document - */ - public static function assertCommandReplyMatches(stdClass $expected, stdClass $actual) - { - throw new LogicException(sprintf('%s does not assert CommandSucceededEvents', static::class)); - } - - /** - * Asserts that two given documents match. - * - * Extra keys in the actual value's document(s) will be ignored. - * - * @param array|object $expectedDocument - * @param array|object $actualDocument - * @param string $message - */ - protected static function assertDocumentsMatch($expectedDocument, $actualDocument, $message = '') - { - $constraint = new DocumentsMatchConstraint($expectedDocument, true, true); - - static::assertThat($actualDocument, $constraint, $message); - } - - /** - * Assert data within the outcome collection. - * - * @param array $expectedDocuments - * @param int $resultExpectation - */ - protected function assertOutcomeCollectionData(array $expectedDocuments, $resultExpectation = ResultExpectation::ASSERT_SAME_DOCUMENT) - { - $outcomeCollection = $this->getOutcomeCollection($this->getContext()->outcomeReadOptions); - - $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); - $mi->attachIterator(new ArrayIterator($expectedDocuments)); - $mi->attachIterator(new IteratorIterator($outcomeCollection->find([], ['sort' => ['_id' => 1]]))); - - foreach ($mi as $documents) { - list($expectedDocument, $actualDocument) = $documents; - $this->assertNotNull($expectedDocument); - $this->assertNotNull($actualDocument); - - switch ($resultExpectation) { - case ResultExpectation::ASSERT_SAME_DOCUMENT: - $this->assertSameDocument($expectedDocument, $actualDocument); - break; - - case ResultExpectation::ASSERT_DOCUMENTS_MATCH: - $this->assertDocumentsMatch($expectedDocument, $actualDocument); - break; - - default: - $this->fail(sprintf('Invalid result expectation "%d" for %s', $resultExpectation, __METHOD__)); - } - } - } - - /** - * Decode a JSON spec test. - * - * This decodes the file through the driver's extended JSON parser to ensure - * proper handling of special types. - * - * @param string $json - * @return array - */ - protected function decodeJson($json) - { - return toPHP(fromJSON($json)); - } - - /** - * Return the test context. - * - * @return Context - * @throws LogicException if the context has not been set - */ - protected function getContext() - { - if (! $this->context instanceof Context) { - throw new LogicException('Context has not been set'); - } - - return $this->context; - } - - /** - * Set the test context. - * - * @param Context $context - */ - protected function setContext(Context $context) - { - $this->context = $context; - } - - /** - * Drop the test and outcome collections by dropping them. - */ - protected function dropTestAndOutcomeCollections() - { - $context = $this->getContext(); - - if ($context->databaseName === 'admin') { - return; - } - - if ($context->bucketName !== null) { - $bucket = $context->getGridFSBucket($context->defaultWriteOptions); - $bucket->drop(); - } - - $collection = null; - if ($context->collectionName !== null) { - $collection = $context->getCollection($context->defaultWriteOptions); - $collection->drop(); - } - - if ($context->outcomeCollectionName !== null) { - $outcomeCollection = $this->getOutcomeCollection($context->defaultWriteOptions); - - // Avoid redundant drop if the test and outcome collections are the same - if ($collection === null || $outcomeCollection->getNamespace() !== $collection->getNamespace()) { - $outcomeCollection->drop(); - } - } - } - - /** - * Insert data fixtures into the test collection. - * - * @param array $documents - * @param string|null $collectionName - */ - protected function insertDataFixtures(array $documents, $collectionName = null) - { - if (empty($documents)) { - return; - } - - $context = $this->getContext(); - $collection = $collectionName ? $context->selectCollection($context->databaseName, $collectionName) : $context->getCollection(); - - $collection->insertMany($documents, $context->defaultWriteOptions); - - return; - } - - private function getOutcomeCollection(array $collectionOptions = []) - { - $context = $this->getContext(); - - // Outcome collection need not use the client under test - return new Collection($this->manager, $context->databaseName, $context->outcomeCollectionName, $collectionOptions); - } -} diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index e3f64ab8d..9985675a3 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -2,7 +2,6 @@ namespace MongoDB\Tests\UnifiedSpecTests; -use MongoDB\BSON\Unserializable; use stdClass; use UnexpectedValueException; use function in_array; @@ -10,32 +9,43 @@ use function is_string; use function version_compare; -class RunOnRequirement implements Unserializable +class RunOnRequirement { const TOPOLOGY_SINGLE = 'single'; const TOPOLOGY_REPLICASET = 'replicaset'; const TOPOLOGY_SHARDED = 'sharded'; const TOPOLOGY_SHARDED_REPLICASET = 'sharded-replicaset'; + const VERSION_PATTERN = '/^[0-9]+(\\.[0-9]+){1,2}$/'; + private $minServerVersion; private $maxServerVersion; private $topologies; - public function __construct(stdClass $data) + public function __construct(stdClass $o) { - $this->bsonUnserialize((array) $data); + if (isset($o->minServerVersion)) { + assertIsString($o->minServerVersion); + assertRegExp(self::VERSION_PATTERN, $o->minServerVersion); + $this->minServerVersion = $o->minServerVersion; + } + + if (isset($o->maxServerVersion)) { + assertIsString($o->maxServerVersion); + assertRegExp(self::VERSION_PATTERN, $o->maxServerVersion); + $this->maxServerVersion = $o->maxServerVersion; + } + + if (isset($o->topologies)) { + assertIsArray($o->topologies); + assertContainsOnly('string', $o->topologies); + $this->topologies = $o->topologies; + } } - /** - * @see https://www.php.net/manual/en/mongodb-bson-unserializable.bsonunserialize.php - */ - public function bsonUnserialize(array $data) + public static function fromObject(stdClass $o): self { - $this->minServerVersion = $data['minServerVersion'] ?? null; - $this->maxServerVersion = $data['maxServerVersion'] ?? null; - $this->topologies = $data['topologies'] ?? null; - - $this->validate(); + } /** @@ -71,32 +81,4 @@ public function isSatisfied(string $serverVersion, string $topology) return true; } - - /** - * @throws UnexpectedValueException if a property is invalid - */ - private function validate() - { - if (isset($this->minServerVersion) && ! is_string($this->minServerVersion)) { - throw new UnexpectedValueException('minServerVersion is not a string'); - } - - if (isset($this->maxServerVersion) && ! is_string($this->maxServerVersion)) { - throw new UnexpectedValueException('maxServerVersion is not a string'); - } - - if (! isset($this->topologies)) { - return; - } - - if (! is_array($this->topologies)) { - throw new UnexpectedValueException('topologies is not an array'); - } - - foreach ($this->topologies as $topology) { - if (! is_string($topology)) { - throw new UnexpectedValueException('topologies is not an array of strings'); - } - } - } } diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index b328c4011..a321d8aba 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -2,10 +2,12 @@ namespace MongoDB\Tests\UnifiedSpecTests; +use MongoDB\Client; use MongoDB\Driver\Command; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Tests\FunctionalTestCase; use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; use stdClass; @@ -27,17 +29,21 @@ class UnifiedSpecTest extends FunctionalTestCase { use SetUpTearDownTrait; - const ERROR_CODE_INTERRUPTED = 11601; + const SERVER_ERROR_INTERRUPTED = 11601; const TOPOLOGY_SINGLE = 'single'; const TOPOLOGY_REPLICASET = 'replicaset'; const TOPOLOGY_SHARDED = 'sharded'; const TOPOLOGY_SHARDED_REPLICASET = 'sharded-replicaset'; + /** @var MongoDB\Client */ + private static $internalClient; + private static function doSetUpBeforeClass() { parent::setUpBeforeClass(); + self::$internalClient = new Client(static::getUri()); self::killAllSessions(); } @@ -59,13 +65,13 @@ private function doTearDown() * Execute an individual test case from the specification. * * @dataProvider provideTests - * @param stdClass $test Individual "tests[]" document - * @param array $runOn Top-level "runOn" array with server requirements - * @param array $data Top-level "data" array to initialize collection - * @param string $databaseName Name of database under test - * @param string $collectionName Name of collection under test + * @param stdClass $test Individual object in "tests[]" + * @param string $schemaVersion Top-level "schemaVersion" + * @param array $runOnRequirements Top-level "runOnRequirements" + * @param array $createEntities Top-level "createEntities" + * @param array $initialData Top-level "initialData" */ - public function testCase(stdClass $test, $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) + public function testCase(stdClass $test, string $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) { if (! $this->isSchemaVersionSupported($schemaVersion)) { $this->markTestIncomplete(sprintf('Test format schema version "%s" is not supported', $schemaVersion)); @@ -84,8 +90,16 @@ public function testCase(stdClass $test, $schemaVersion, array $runOnRequirement } if (isset($initialData)) { - // TODO + $this->prepareInitialData($initialData); + } + + $context = new Context(static::getUri()); + + if (isset($createEntities)) { + $context->createEntities($createEntities); } + + } public function provideTests() @@ -95,14 +109,7 @@ public function provideTests() foreach (glob(__DIR__ . '/*.json') as $filename) { /* Decode the file through the driver's extended JSON parser to * ensure proper handling of special types. */ - $json = toPHP( - fromJSON(file_get_contents($filename)), - /* - ['fieldPaths' => [ - 'runOnRequirements.$' => RunOnRequirement::class, - 'tests.$.runOnRequirements.$' => RunOnRequirement::class, - ]]*/ - ); + $json = toPHP(fromJSON(file_get_contents($filename))); $description = $json->description; $schemaVersion = $json->schemaVersion; @@ -132,9 +139,8 @@ private function checkRunOnRequirements(array $runOnRequirements) $serverVersion = $this->getCachedServerVersion(); $topology = $this->getCachedTopology(); - foreach ($runOnRequirements as $data) { - // $this->assertInstanceOf(RunOnRequirement::class, $runOnRequirement); - $runOnRequirement = new RunOnRequirement($data); + foreach ($runOnRequirements as $o) { + $runOnRequirement = new RunOnRequirement($o); if ($runOnRequirement->isSatisfied($serverVersion, $topology)) { return; } @@ -221,12 +227,9 @@ private function isSchemaVersionSupported($schemaVersion) */ private static function killAllSessions() { - $manager = new Manager(static::getUri()); - $primary = $manager->selectServer(new ReadPreference('primary')); - - $servers = $primary->getType() === Server::TYPE_MONGOS - ? $manager->getServers() - : [$primary]; + $manager = self::$internalClient->getManager(); + $primary = $manager->selectServer(new ReadPreference(ReadPreference::PRIMARY)); + $servers = $primary->getType() === Server::TYPE_MONGOS ? $manager->getServers() : [$primary]; foreach ($servers as $server) { try { @@ -237,10 +240,20 @@ private static function killAllSessions() $server->executeCommand('admin', new Command(['killAllSessions' => []])); } catch (ServerException $e) { // Interrupted error is safe to ignore (see: SERVER-38335) - if ($e->getCode() != self::ERROR_CODE_INTERRUPTED) { + if ($e->getCode() != self::SERVER_ERROR_INTERRUPTED) { throw $e; } } } } + + private function prepareInitialData(array $initialData) + { + $this->assertNotEmpty($initialData); + + foreach ($initialData as $data) { + $collectionData = new CollectionData($data); + $collectionData->prepare(self::$internalClient); + } + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 31649c704..878df52bb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,6 +10,10 @@ throw new Exception('Can\'t find autoload.php. Did you install dependencies with Composer?'); } +/* Manually include assertion functions for PHPUnit 8.x and earlier. + * See: https://github.com/sebastianbergmann/phpunit/issues/3746 */ +require_once(__DIR__ . '/../vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'); + if (! class_exists(PHPUnit\Framework\Error\Warning::class)) { class_alias(PHPUnit_Framework_Error_Warning::class, PHPUnit\Framework\Error\Warning::class); } From 28c2ba5d15143e30b1010831eedfffc7ef22094d Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 22 Sep 2020 09:25:25 -0400 Subject: [PATCH 04/41] wip --- composer.json | 2 +- .../Constraint/DocumentsMatch.php | 458 -------------- .../Constraint/IsBsonType.php | 160 +++++ tests/UnifiedSpecTests/Constraint/Match.php | 302 +++++++++ .../{DocumentsMatchTest.php => MatchTest.php} | 30 +- tests/UnifiedSpecTests/Context.php | 148 +++-- tests/UnifiedSpecTests/EntityMap.php | 62 +- tests/UnifiedSpecTests/EventObserver.php | 18 + tests/UnifiedSpecTests/ExpectedError.php | 166 +++++ tests/UnifiedSpecTests/ExpectedResult.php | 287 +++++++++ tests/UnifiedSpecTests/Operation.php | 586 ++++++++++++++++++ tests/UnifiedSpecTests/RunOnRequirement.php | 5 - tests/UnifiedSpecTests/UnifiedSpecTest.php | 31 +- tests/UnifiedSpecTests/example-insertOne.json | 4 +- tests/UnifiedSpecTests/example-insertOne.yml | 4 +- 15 files changed, 1696 insertions(+), 567 deletions(-) delete mode 100644 tests/UnifiedSpecTests/Constraint/DocumentsMatch.php create mode 100644 tests/UnifiedSpecTests/Constraint/IsBsonType.php create mode 100644 tests/UnifiedSpecTests/Constraint/Match.php rename tests/UnifiedSpecTests/Constraint/{DocumentsMatchTest.php => MatchTest.php} (86%) create mode 100644 tests/UnifiedSpecTests/ExpectedError.php create mode 100644 tests/UnifiedSpecTests/ExpectedResult.php create mode 100644 tests/UnifiedSpecTests/Operation.php diff --git a/composer.json b/composer.json index 8be1e8616..e9b22faa0 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "jean85/pretty-package-versions": "^1.2" }, "require-dev": { - "phpunit/phpunit": "^6.4", + "phpunit/phpunit": "^6.5", "sebastian/comparator": "^2.0 || ^3.0", "squizlabs/php_codesniffer": "^3.5, <3.5.5", "symfony/phpunit-bridge": "^4.4@dev" diff --git a/tests/UnifiedSpecTests/Constraint/DocumentsMatch.php b/tests/UnifiedSpecTests/Constraint/DocumentsMatch.php deleted file mode 100644 index 8b17daac4..000000000 --- a/tests/UnifiedSpecTests/Constraint/DocumentsMatch.php +++ /dev/null @@ -1,458 +0,0 @@ -value = $this->prepareBSON($value, true, $this->sortKeys); - $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot; - $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded; - $this->placeholders = $placeholders; - $this->comparatorFactory = Factory::getInstance(); - } - - public function evaluate($other, $description = '', $returnResult = false) - { - /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be - * able to skip preparation, convert both documents to extended JSON, - * and compare strings. - * - * If ignoreExtraKeys is false and sortKeys is true, we still be able to - * compare JSON strings but will still require preparation to sort keys - * in all documents and sub-documents. */ - $other = $this->prepareBSON($other, true, $this->sortKeys); - - $success = false; - $this->lastFailure = null; - - try { - $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); - $success = true; - } catch (RuntimeException $e) { - $this->lastFailure = new ComparisonFailure( - $this->value, - $other, - $this->exporter()->export($this->value), - $this->exporter()->export($other), - false, - $e->getMessage() - ); - } - - if ($returnResult) { - return $success; - } - - if (! $success) { - $this->fail($other, $description, $this->lastFailure); - } - } - - /** - * @param string $expectedType - * @param mixed $actualValue - */ - private function assertBSONType($expectedType, $actualValue) - { - switch ($expectedType) { - case 'double': - (new IsType('float'))->evaluate($actualValue); - - return; - case 'string': - (new IsType('string'))->evaluate($actualValue); - - return; - case 'object': - $constraints = [ - new IsType('object'), - new LogicalNot(new IsInstanceOf(BSONArray::class)), - ]; - - // LogicalAnd::fromConstraints was introduced in PHPUnit 6.5.0. - // This check can be removed when the PHPUnit dependency is bumped to that version - if (method_exists(LogicalAnd::class, 'fromConstraints')) { - $constraint = LogicalAnd::fromConstraints(...$constraints); - } else { - $constraint = new LogicalAnd(); - $constraint->setConstraints($constraints); - } - - $constraint->evaluate($actualValue); - - return; - case 'array': - $constraints = [ - new IsType('array'), - new IsInstanceOf(BSONArray::class), - ]; - - // LogicalOr::fromConstraints was introduced in PHPUnit 6.5.0. - // This check can be removed when the PHPUnit dependency is bumped to that version - if (method_exists(LogicalOr::class, 'fromConstraints')) { - $constraint = LogicalOr::fromConstraints(...$constraints); - } else { - $constraint = new LogicalOr(); - $constraint->setConstraints($constraints); - } - - $constraint->evaluate($actualValue); - - return; - case 'binData': - (new IsInstanceOf(BinaryInterface::class))->evaluate($actualValue); - - return; - case 'undefined': - (new IsInstanceOf(Undefined::class))->evaluate($actualValue); - - return; - case 'objectId': - (new IsInstanceOf(ObjectId::class))->evaluate($actualValue); - - return; - case 'boolean': - (new IsType('bool'))->evaluate($actualValue); - - return; - case 'date': - (new IsInstanceOf(UTCDateTime::class))->evaluate($actualValue); - - return; - case 'null': - (new IsNull())->evaluate($actualValue); - - return; - case 'regex': - (new IsInstanceOf(Regex::class))->evaluate($actualValue); - - return; - case 'dbPointer': - (new IsInstanceOf(DBPointer::class))->evaluate($actualValue); - - return; - case 'javascript': - (new IsInstanceOf(Javascript::class))->evaluate($actualValue); - - return; - case 'symbol': - (new IsInstanceOf(Symbol::class))->evaluate($actualValue); - - return; - case 'int': - (new IsType('int'))->evaluate($actualValue); - - return; - case 'timestamp': - (new IsInstanceOf(Timestamp::class))->evaluate($actualValue); - - return; - case 'long': - if (PHP_INT_SIZE == 4) { - (new IsInstanceOf(Int64::class))->evaluate($actualValue); - } else { - (new IsType('int'))->evaluate($actualValue); - } - - return; - case 'decimal': - (new IsInstanceOf(Decimal128::class))->evaluate($actualValue); - - return; - case 'minKey': - (new IsInstanceOf(MinKey::class))->evaluate($actualValue); - - return; - case 'maxKey': - (new IsInstanceOf(MaxKey::class))->evaluate($actualValue); - - return; - } - } - - /** - * Compares two documents recursively. - * - * @param ArrayObject $expected - * @param ArrayObject $actual - * @param boolean $ignoreExtraKeys - * @param string $keyPrefix - * @throws RuntimeException if the documents do not match - */ - private function assertEquals(ArrayObject $expected, ArrayObject $actual, $ignoreExtraKeys, $keyPrefix = '') - { - if (get_class($expected) !== get_class($actual)) { - throw new RuntimeException(sprintf( - '%s is not instance of expected class "%s"', - $this->exporter()->shortenedExport($actual), - get_class($expected) - )); - } - - foreach ($expected as $key => $expectedValue) { - $actualHasKey = $actual->offsetExists($key); - - if (! $actualHasKey) { - 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'])) { - $this->assertBSONType($expectedValue['$$type'], $actualValue); - continue; - } - - if (($expectedValue instanceof BSONArray && $actualValue instanceof BSONArray) || - ($expectedValue instanceof BSONDocument && $actualValue instanceof BSONDocument)) { - $this->assertEquals($expectedValue, $actualValue, $this->ignoreExtraKeysInEmbedded, $keyPrefix . $key . '.'); - 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 = is_object($expectedValue) ? get_class($expectedValue) : gettype($expectedValue); - $actualType = is_object($expectedValue) ? get_class($actualValue) : gettype($actualValue); - - // Workaround for ObjectComparator printing the whole actual object - if ($expectedType !== $actualType) { - throw new ComparisonFailure( - $expectedValue, - $actualValue, - '', - '', - false, - sprintf( - 'Field path "%s": %s is not instance of expected type "%s".', - $keyPrefix . $key, - $this->exporter()->shortenedExport($actualValue), - $expectedType - ) - ); - } - - try { - $this->comparatorFactory->getComparatorFor($expectedValue, $actualValue)->assertEquals($expectedValue, $actualValue); - } catch (ComparisonFailure $failure) { - throw new ComparisonFailure( - $expectedValue, - $actualValue, - '', - '', - false, - sprintf('Field path "%s": %s', $keyPrefix . $key, $failure->getMessage()) - ); - } - } - - if ($ignoreExtraKeys) { - return; - } - - foreach ($actual as $key => $value) { - if (! $expected->offsetExists($key)) { - throw new RuntimeException(sprintf('$actual has extra key: "%s"', $keyPrefix . $key)); - } - } - } - - private function doAdditionalFailureDescription($other) - { - if ($this->lastFailure === null) { - return ''; - } - - return $this->lastFailure->getMessage(); - } - - private function doFailureDescription($other) - { - return 'two BSON objects are equal'; - } - - private function doMatches($other) - { - /* TODO: If ignoreExtraKeys and sortKeys are both false, then we may be - * able to skip preparation, convert both documents to extended JSON, - * and compare strings. - * - * If ignoreExtraKeys is false and sortKeys is true, we still be able to - * compare JSON strings but will still require preparation to sort keys - * in all documents and sub-documents. */ - $other = $this->prepareBSON($other, true, $this->sortKeys); - - try { - $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); - } catch (RuntimeException $e) { - return false; - } - - return true; - } - - private function doToString() - { - return 'matches ' . $this->exporter()->export($this->value); - } - - /** - * Prepare a BSON document or array for comparison. - * - * The argument will be converted to a BSONArray or BSONDocument based on - * its type and keys. Keys within documents will optionally be sorted. Each - * value within the array or document will then be prepared recursively. - * - * @param array|object $bson - * @param boolean $isRoot If true, ensure an array value is converted to a document - * @param boolean $sortKeys - * @return BSONDocument|BSONArray - * @throws InvalidArgumentException if $bson is not an array or object - */ - private function prepareBSON($bson, $isRoot, $sortKeys = false) - { - if (! is_array($bson) && ! is_object($bson)) { - throw new InvalidArgumentException('$bson is not an array or object'); - } - - if ($isRoot && is_array($bson)) { - $bson = (object) $bson; - } - - if ($bson instanceof BSONArray || (is_array($bson) && $bson === array_values($bson))) { - if (! $bson instanceof BSONArray) { - $bson = new BSONArray($bson); - } - } else { - if (! $bson instanceof BSONDocument) { - $bson = new BSONDocument((array) $bson); - } - - if ($sortKeys) { - $bson->ksort(); - } - } - - foreach ($bson as $key => $value) { - if ($value instanceof BSONArray || (is_array($value) && $value === array_values($value))) { - $bson[$key] = $this->prepareBSON($value, false, $sortKeys); - continue; - } - - if ($value instanceof BSONDocument || $value instanceof stdClass || is_array($value)) { - $bson[$key] = $this->prepareBSON($value, false, $sortKeys); - continue; - } - - /* Convert Int64 objects to integers on 64-bit platforms for - * compatibility reasons. */ - if ($value instanceof Int64 && PHP_INT_SIZE != 4) { - $bson[$key] = (int) ((string) $value); - } - } - - return $bson; - } -} diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php new file mode 100644 index 000000000..ba7ab0380 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -0,0 +1,160 @@ + 1, + 'string' => 1, + 'object' => 1, + 'array' => 1, + 'binData' => 1, + 'undefined' => 1, + 'objectId' => 1, + 'bool' => 1, + 'date' => 1, + 'null' => 1, + 'regex' => 1, + 'dbPointer' => 1, + 'javascript' => 1, + 'symbol' => 1, + 'javascriptWithScope' => 1, + 'int' => 1, + 'timestamp' => 1, + 'long' => 1, + 'decimal' => 1, + 'minKey' => 1, + 'maxKey' => 1, + ]; + + private $type; + + public function __construct(string $type) + { + if (! isset(self::$knownTypes[$type])) { + throw new RuntimeException(sprintf('Type specified for %s <%s> is not a valid type', self::class, $type)); + } + + $this->type = $type; + } + + private function doMatches($other): bool + { + switch ($this->type) { + case 'double': + return is_float($other); + + case 'string': + return is_string($other); + + case 'object': + return is_object($other) && (! $other instanceof BSONArray); + + case 'array': + return self::isArray($other); + + case 'binData': + return $other instanceof BinaryInterface; + + case 'undefined': + return $other instanceof Undefined; + + case 'objectId': + return $other instanceof ObjectIdInterface; + + case 'bool': + return is_bool($other); + + case 'date': + return $other instanceof UTCDateTimeInterface; + + case 'null': + return $other === null; + + case 'regex': + return $other instanceof RegexInterface; + + case 'dbPointer': + return $other instanceof DBPointer; + + case 'javascript': + return $other instanceof JavascriptInterface && $other->getScope() === null; + + case 'symbol': + return $other instanceof Symbol; + + case 'javascriptWithScope': + return $other instanceof JavascriptInterface && $other->getScope() !== null; + + case 'int': + return is_int($other); + + case 'timestamp': + return $other instanceof TimestampInterface; + + case 'long': + if (PHP_INT_SIZE == 4) { + return $other instanceof Int64; + } + + return is_int($other); + + case 'decimal': + return $other instanceof Decimal128Interface; + + case 'minKey': + return $other instanceof MinKeyInterface; + + case 'maxKey': + return $other instanceof MaxKeyInterface; + + default: + // This should already have been caught in the constructor + throw new LogicException('Unsupported type: ' . $this->type); + } + } + + private function doToString(): string + { + return sprintf('is of BSON type "%s"', $this->type); + } + + private static function isArray($other): bool + { + if ($other instanceof BSONArray) { + return true; + } + + if (! is_array($other)) { + return false; + } + + if (empty($other)) { + return true; + } + + return array_keys($other) === range(0, count($other) - 1); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/Match.php b/tests/UnifiedSpecTests/Constraint/Match.php new file mode 100644 index 000000000..a6241b204 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/Match.php @@ -0,0 +1,302 @@ +value = self::prepare($value, true); + $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot; + $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded; + $this->comparatorFactory = Factory::getInstance(); + } + + public function evaluate($other, $description = '', $returnResult = false) + { + $other = self::prepare($other, true); + $success = false; + $this->lastFailure = null; + + try { + $this->assertMatches($this->value, $other, $this->ignoreExtraKeysInRoot); + $success = true; + } catch (RuntimeException $e) { + $this->lastFailure = new ComparisonFailure( + $this->value, + $other, + $this->exporter()->export($this->value), + $this->exporter()->export($other), + false, + $e->getMessage() + ); + } + + if ($returnResult) { + return $success; + } + + if (! $success) { + $this->fail($other, $description, $this->lastFailure); + } + } + + private function assertEquals($expected, $actual, string $keyPath) + { + $expectedType = is_object($expected) ? get_class($expected) : gettype($expected); + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + + // Workaround for ObjectComparator printing the whole actual object + if ($expectedType !== $actualType) { + throw new ComparisonFailure( + $expected, + $actual, + '', + '', + false, + sprintf( + '%s%s is not instance of expected type "%s".', + empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath), + $this->exporter()->shortenedExport($actual), + $expectedType + ) + ); + } + + try { + $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual); + } catch (ComparisonFailure $failure) { + throw new ComparisonFailure( + $expected, + $actual, + '', + '', + false, + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)) . $failure->getMessage() + ); + } + } + + /** + * Compares two BSON values recursively. + * + * @param mixed $expected + * @param mixed $actual + * @param bool $ignoreExtraKeys + * @param string $keyPath + * @throws RuntimeException if the documents do not match + */ + private function assertMatches($expected, $actual, bool $ignoreExtraKeys, $keyPath = '') + { + if (! $expected instanceof BSONDocument && ! $expected instanceof BSONArray) { + $this->assertEquals($expected, $actual, $keyPath); + } + + if ($expected instanceof BSONArray) { + + } + + if ($expected instanceof BSONDocument) { + if (self::isSpecialOperator($expected)) { + + } + } + + + if (get_class($expected) !== get_class($actual)) { + throw new RuntimeException(sprintf( + '%s is not instance of expected class "%s"', + $this->exporter()->shortenedExport($actual), + get_class($expected) + )); + } + + foreach ($expected as $key => $expectedValue) { + $actualHasKey = $actual->offsetExists($key); + + if (! $actualHasKey) { + 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'])) { + $this->assertBSONType($expectedValue['$$type'], $actualValue); + continue; + } + + if (($expectedValue instanceof BSONArray && $actualValue instanceof BSONArray) || + ($expectedValue instanceof BSONDocument && $actualValue instanceof BSONDocument)) { + $this->assertEquals($expectedValue, $actualValue, $this->ignoreExtraKeysInEmbedded, $keyPrefix . $key . '.'); + 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; + } + } + + if ($ignoreExtraKeys) { + return; + } + + foreach ($actual as $key => $value) { + if (! $expected->offsetExists($key)) { + throw new RuntimeException(sprintf('$actual has extra key: "%s"', $keyPrefix . $key)); + } + } + } + + private function doAdditionalFailureDescription($other) + { + if ($this->lastFailure === null) { + return ''; + } + + return $this->lastFailure->getMessage(); + } + + private function doFailureDescription($other) + { + return 'expected value matches actual value'; + } + + private function doMatches($other) + { + $other = self::prepare($other, true); + + try { + $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); + } catch (RuntimeException $e) { + return false; + } + + return true; + } + + private function doToString() + { + return 'matches ' . $this->exporter()->export($this->value); + } + + private static function isSpecialOperator(BSONDocument $document): bool + { + foreach ($document as $key => $_) { + return strpos((string) $key, '$$') === 0; + } + + return false; + } + + /** + * Prepare a value for comparison. + * + * If the value is an array or object, it will be converted to a BSONArray + * or BSONDocument. If $value is an array and $isRoot is true, it will be + * converted to a BSONDocument; otherwise, it will be converted to a + * BSONArray or BSONDocument based on its keys. Each value within an array + * or document will then be prepared recursively. + * + * @param mixed $bson + * @param boolean $isRoot If true, convert an array to a BSONDocument + */ + private static function prepare($bson, bool $isRoot) + { + if (! is_array($bson) && ! is_object($bson)) { + return $bson; + } + + /* Convert Int64 objects to integers on 64-bit platforms for + * compatibility reasons. */ + if ($bson instanceof Int64 && PHP_INT_SIZE != 4) { + return (int) ((string) $bson); + } + + // TODO: ignore Serializable if needed + if ($bson instanceof Type) { + return $bson; + } + + if ($isRoot && is_array($bson)) { + $bson = new BSONDocument($bson); + } + + if (is_array($bson) && $bson === array_values($bson)) { + $bson = new BSONArray($bson); + } + + if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) { + // TODO: determine if (array) cast is needed + $bson = new BSONDocument($bson); + } + + foreach ($bson as $key => $value) { + if (is_array($value) || is_object($value)) { + $bson[$key] = self::prepare($value, false); + } + } + + return $bson; + } +} diff --git a/tests/UnifiedSpecTests/Constraint/DocumentsMatchTest.php b/tests/UnifiedSpecTests/Constraint/MatchTest.php similarity index 86% rename from tests/UnifiedSpecTests/Constraint/DocumentsMatchTest.php rename to tests/UnifiedSpecTests/Constraint/MatchTest.php index 32225156e..d0a7e3c80 100644 --- a/tests/UnifiedSpecTests/Constraint/DocumentsMatchTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchTest.php @@ -20,11 +20,11 @@ use function unserialize; use const PHP_INT_SIZE; -class DocumentsMatchTest extends TestCase +class MatchTest extends TestCase { public function testIgnoreExtraKeysInRoot() { - $c = new DocumentsMatch(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], true, false); + $c = new Match(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], true, false); $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); @@ -33,7 +33,7 @@ public function testIgnoreExtraKeysInRoot() $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); // Arrays are always interpreted as root documents - $c = new DocumentsMatch([1, ['a' => 1]], true, false); + $c = new Match([1, ['a' => 1]], true, false); $this->assertResult(false, $c, [1, 2], 'Incorrect value'); $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); @@ -43,7 +43,7 @@ public function testIgnoreExtraKeysInRoot() public function testIgnoreExtraKeysInEmbedded() { - $c = new DocumentsMatch(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); + $c = new Match(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 3]], 'Incorrect value'); @@ -53,7 +53,7 @@ public function testIgnoreExtraKeysInEmbedded() $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded Key order is not significant'); // Arrays are always interpreted as root documents - $c = new DocumentsMatch([1, ['a' => 1]], false, true); + $c = new Match([1, ['a' => 1]], false, true); $this->assertResult(false, $c, [1, 2], 'Incorrect value'); $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); @@ -64,7 +64,7 @@ public function testIgnoreExtraKeysInEmbedded() public function testPlaceholders() { - $c = new DocumentsMatch(['x' => '42', 'y' => 42, 'z' => ['a' => 24]], false, false, [24, 42]); + $c = new Match(['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'); @@ -76,7 +76,7 @@ public function testPlaceholders() */ public function testBSONTypeAssertions($type, $value) { - $constraint = new DocumentsMatch(['x' => ['$$type' => $type]]); + $constraint = new Match(['x' => ['$$type' => $type]]); $this->assertResult(true, $constraint, ['x' => $value], 'Type matches'); } @@ -114,7 +114,7 @@ public function provideBSONTypes() /** * @dataProvider errorMessageProvider */ - public function testErrorMessages($expectedMessagePart, DocumentsMatch $constraint, $actualValue) + public function testErrorMessages($expectedMessagePart, Match $constraint, $actualValue) { try { $constraint->evaluate($actualValue); @@ -130,38 +130,38 @@ public function errorMessageProvider() return [ 'Root type mismatch' => [ 'MongoDB\Model\BSONArray Object (...) is not instance of expected class "MongoDB\Model\BSONDocument"', - new DocumentsMatch(['foo' => 'bar']), + new Match(['foo' => 'bar']), new BSONArray(['foo' => 'bar']), ], 'Missing key' => [ '$actual is missing key: "foo.bar"', - new DocumentsMatch(['foo' => ['bar' => 'baz']]), + new Match(['foo' => ['bar' => 'baz']]), ['foo' => ['foo' => 'bar']], ], 'Extra key' => [ '$actual has extra key: "foo.foo"', - new DocumentsMatch(['foo' => ['bar' => 'baz']]), + new Match(['foo' => ['bar' => 'baz']]), ['foo' => ['foo' => 'bar', 'bar' => 'baz']], ], 'Scalar value not equal' => [ 'Field path "foo": Failed asserting that two values are equal.', - new DocumentsMatch(['foo' => 'bar']), + new Match(['foo' => 'bar']), ['foo' => 'baz'], ], 'Scalar type mismatch' => [ 'Field path "foo": Failed asserting that two values are equal.', - new DocumentsMatch(['foo' => 42]), + new Match(['foo' => 42]), ['foo' => '42'], ], 'Type mismatch' => [ 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected type "MongoDB\Model\BSONArray".', - new DocumentsMatch(['foo' => ['bar']]), + new Match(['foo' => ['bar']]), ['foo' => (object) ['bar']], ], ]; } - private function assertResult($expectedResult, DocumentsMatch $constraint, $value, $message) + private function assertResult($expectedResult, Match $constraint, $value, $message) { $this->assertSame($expectedResult, $constraint->evaluate($value, '', true), $message); } diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 96782c831..8715cbc85 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -33,12 +33,16 @@ final class Context /** @var EventObserver[] */ private $eventObserversByClient = []; + /** @var Client */ + private $internalClient; + /** @var string */ private $uri; - public function __construct(string $uri) + public function __construct(Client $internalClient, string $uri) { $this->entityMap = new EntityMap; + $this->internalClient = $internalClient; $this->uri = $uri; } @@ -65,10 +69,6 @@ public function createEntities(array $entities) switch ($type) { case 'client': $this->entityMap[$id] = $this->createClient($def); - - if (isset($def->observeEvents)) { - $this->eventObserversByClient[$id] = $this->createEventObserver($def); - } break; case 'database': @@ -85,6 +85,44 @@ public function createEntities(array $entities) } } + public function getEntityMap(): EntityMap + { + return $this->entityMap; + } + + public function getInternalClient(): Client + { + return $this->internalClient; + } + + public static function prepareOperationArguments(array $args): array + { + if (array_key_exists('readConcern', $args)) { + assertIsObject($args['readConcern']); + $args['readConcern'] = self::prepareReadConcern($args['readConcern']); + } + + if (array_key_exists('readPreference', $args)) { + assertIsObject($args['readPreference']); + $args['readPreference'] = self::prepareReadPreference($args['readPreference']); + } + + if (array_key_exists('session', $args)) { + assertIsString($args['session']); + assertArrayHasKey($args['session'], $this->entityMap); + $session = $this->entityMap[$args['session']]; + assertInstanceOf(Session::class, $session); + $args['session'] = $session; + } + + if (array_key_exists('writeConcern', $args)) { + assertIsObject($args['writeConcern']); + $args['writeConcern'] = self::prepareWriteConcern($args['writeConcern']); + } + + return $args; + } + public function startEventObservers() { foreach ($this->eventObserversByClient as $eventObserver) { @@ -99,14 +137,27 @@ public function stopEventObservers() } } + private static function assertHasOnlyKeys($arrayOrObject, array $keys) + { + assertThat($arrayOrObject, logicalOr(IsType('array'), IsType('object'))); + $diff = array_diff_key((array) $arrayOrObject, array_fill_keys($keys, 1)); + assertEmpty($diff, 'Unsupported keys: ' . implode(',', array_keys($diff))); + } + private function createClient(stdClass $o): Client { + self::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); + + $useMultipleMongoses = $o->useMultipleMongoses ?? null; + $observeEvents = $o->observeEvents ?? null; + $ignoreCommandMonitoringEvents = $o->ignoreCommandMonitoringEvents ?? []; + $uri = $this->uri; - if (isset($o->useMultipleMongoses)) { - assertIsBool($o->useMultipleMongoses); + if (isset($useMultipleMongoses)) { + assertIsBool($useMultipleMongoses); - if ($o->useMultipleMongoses) { + if ($useMultipleMongoses) { self::requireMultipleMongoses($uri); } else { $uri = self::removeMultipleMongoses($uri); @@ -117,25 +168,26 @@ private function createClient(stdClass $o): Client if (isset($o->uriOptions)) { assertIsObject($o->uriOptions); + /* TODO: If readPreferenceTags is set, assert it is an array of + * strings and convert to an array of documents expected by the + * PHP driver. */ $uriOptions = (array) $o->uriOptions; } - return new Client($uri, $uriOptions); - } + if (isset($observeEvents)) { + assertIsArray($observeEvents); + assertIsArray($ignoreCommandMonitoringEvents); - private function createEventObserver(stdClass $o): EventObserver - { - $observeEvents = $o->observeEvents ?? null; - $ignoreCommands = $o->ignoreCommandMonitoringEvents ?? []; - - assertIsArray($observeEvents); - assertIsArray($ignoreCommands); + $this->eventObserversByClient[$o->id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents); + } - return new EventObserver($observeEvents, $ignoreCommands); + return new Client($uri, $uriOptions); } private function createCollection(stdClass $o): Collection { + self::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); + $collectionName = $o->collectionName ?? null; $database = $o->database ?? null; @@ -143,48 +195,60 @@ private function createCollection(stdClass $o): Collection assertIsString($database); assertArrayHasKey($database, $this->entityMap); - $database = $this->entityMap[$o->database]; + $database = $this->entityMap[$database]; assertInstanceOf(Database::class, $database); - $options = isset($o->collectionOptions) ? self::prepareCollectionOrDatabaseOptions($o->collectionOptions) : []; + $options = []; + + if (isset($o->collectionOptions)) { + assertIsObject($o->collectionOptions); + $options = self::prepareCollectionOrDatabaseOptions((array) $o->collectionOptions); + } return $database->selectCollection($o->collectionName, $options); } private function createDatabase(stdClass $o): Database { - assertObjectHasAttribute('databaseName', $o); - assertIsString($o->databaseName); + self::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); + + $databaseName = $o->databaseName ?? null; + $client = $o->client ?? null; - assertObjectHasAttribute('client', $o); - assertIsString($o->client); - assertArrayHasKey($o->client, $this->entityMap); + assertIsString($databaseName); + assertIsString($client); + assertArrayHasKey($client, $this->entityMap); - $client = $this->entityMap[$o->client]; + $client = $this->entityMap[$client]; assertInstanceOf(Client::class, $client); - $options = isset($o->databaseOptions) ? self::prepareCollectionOrDatabaseOptions($o->databaseOptions) : []; + $options = []; + + if (isset($o->databaseOptions)) { + assertIsObject($o->databaseOptions); + $options = self::prepareCollectionOrDatabaseOptions((array) $o->databaseOptions); + } - return $client->selectDatabase($o->databaseName, $options); + return $client->selectDatabase($databaseName, $options); } - private static function prepareCollectionOrDatabaseOptions(stdClass $o): array + private static function prepareCollectionOrDatabaseOptions(array $options): array { - $options = []; + self::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); - if (isset($o->readConcern)) { - assertIsObject($o->readConcern); - $options['readConcern'] = self::prepareReadConcern($o->readConcern); + if (array_key_exists('readConcern', $options)) { + assertIsObject($options['readConcern']); + $options['readConcern'] = self::createReadConcern($options['readConcern']); } - if (isset($o->readPreference)) { - assertIsObject($o->readPreference); - $options['readPreference'] = self::prepareReadPreference($o->readPreference); + if (array_key_exists('readPreference', $options)) { + assertIsObject($options['readPreference']); + $options['readPreference'] = self::createReadPreference($options['readPreference']); } - if (isset($o->writeConcern)) { - assertIsObject($o->writeConcern); - $options['writeConcern'] = self::prepareWriteConcern($o->writeConcern); + if (array_key_exists('writeConcern', $options)) { + assertIsObject($options['writeConcern']); + $options['writeConcern'] = self::createWriteConcern($options['writeConcern']); } return $options; @@ -192,6 +256,8 @@ private static function prepareCollectionOrDatabaseOptions(stdClass $o): array private static function createReadConcern(stdClass $o): ReadConcern { + self::assertHasOnlyKeys($o, ['level']); + $level = $o->level ?? null; assertIsString($level); @@ -200,6 +266,8 @@ private static function createReadConcern(stdClass $o): ReadConcern private static function createReadPreference(stdClass $o): ReadPreference { + self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); + $mode = $o->mode ?? null; $tagSets = $o->tagSets ?? null; $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; @@ -229,6 +297,8 @@ private static function createReadPreference(stdClass $o): ReadPreference private static function createWriteConcern(stdClass $o): WriteConcern { + self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); + $w = $o->w ?? -2 /* MONGOC_WRITE_CONCERN_W_DEFAULT */; $wtimeoutMS = $o->wtimeoutMS ?? 0; $journal = $o->journal ?? null; diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 2209e8b8b..e57e233f0 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -2,87 +2,63 @@ namespace MongoDB\Tests\UnifiedSpecTests; +use MongoDB\Driver\Session; +use PHPUnit\Framework\Assert; use ArrayAccess; -use BadMethodCallException; -use InvalidArgumentException; -use OutOfBoundsException; -use function is_string; use function sprintf; class EntityMap implements ArrayAccess { private $map = []; + public function __destruct() + { + /* TODO: Determine if this is actually necessary. References to session + * entities should not persist between tests. */ + foreach ($this->map as $entity) { + if ($entity instanceof Session) { + $entity->endSession(); + } + } + } + /** - * Check whether an entity exists in the map. - * * @see http://php.net/arrayaccess.offsetexists - * @param mixed $key - * @return boolean - * @throws InvalidArgumentException if the key is not a string */ public function offsetExists($key) { - if (! is_string($key)) { - throw new InvalidArgumentException('Key is not a string'); - } + assertIsString($key); return array_key_exists($key, $this->map); } /** - * Return an entity from the map. - * * @see http://php.net/arrayaccess.offsetget - * @param mixed $key - * @return mixed - * @throws InvalidArgumentException if the key is not a string - * @throws OutOfBoundsException if the entity is not defined */ public function offsetGet($key) { - if (! is_string($key)) { - throw new InvalidArgumentException('Key is not a string'); - } - - if (! $this->offsetExists($key)) { - throw new OutOfBoundsException(sprintf('No entity is defined for "%s"', $key)); - } + assertIsString($key); + assertArrayHasKey($key, $this->map, sprintf('No entity is defined for "%s"', $key)); return $this->map[$key]; } /** - * Assigns an entity to the map. - * * @see http://php.net/arrayaccess.offsetset - * @param mixed $key - * @param mixed $value - * @throws InvalidArgumentException if the key is not a string - * @throws OutOfBoundsException if the entity is already defined */ public function offsetSet($key, $value) { - if (! is_string($key)) { - throw new InvalidArgumentException('Key is not a string'); - } - - if ($this->offsetExists($key)) { - throw new OutOfBoundsException('Entity already exists for key "%s" and cannot be replaced'); - } + assertIsString($key); + assertArrayNotHasKey($key, $this->map, sprintf('Entity already exists for key "%s" and cannot be replaced', $key)); $this->map[$key] = $value; } /** - * Not supported. - * * @see http://php.net/arrayaccess.offsetunset - * @param mixed $key - * @throws BadMethodCallException */ public function offsetUnset($key) { - throw new BadMethodCallException('Entities cannot be removed from the map'); + Assert::fail('Entities cannot be removed from the map'); } } diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 2640a959a..3ffd10f78 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -11,6 +11,22 @@ class EventObserver implements CommandSubscriber { + private static $defaultIgnoreCommands = [ + // failPoint and targetedFailPoint operations + 'configureFailPoint', + // See: https://github.com/mongodb/specifications/blob/master/source/command-monitoring/command-monitoring.rst#security + 'authenticate', + 'saslStart', + 'saslContinue', + 'getnonce', + 'createUser', + 'updateUser', + 'copydbgetnonce', + 'copydbsaslstart', + 'copydb', + 'isMaster', + ]; + private $actualEvents = []; private $ignoreCommands = []; private $observeEvents = []; @@ -31,6 +47,8 @@ public function __construct(array $observeEvents, array $ignoreCommands) $this->observeEvents[self::$commandMonitoringEvents[$event]] = 1; } + $this->ignoreCommands = array_fill_keys(self::$defaultIgnoreCommands, 1); + foreach ($ignoreCommands as $command) { assertIsString($command); $this->ignoreCommands[$command] = 1; diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php new file mode 100644 index 000000000..f1d18ff6e --- /dev/null +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -0,0 +1,166 @@ + 11601, + 'MaxTimeMSExpired' => 50, + 'NoSuchTransaction' => 251, + 'OperationNotSupportedInTransaction' => 263, + 'WriteConflict' => 112, + ]; + + private $isError = true; + private $isClientError; + private $messageContains; + private $code; + private $codeName; + private $includedLabels = []; + private $excludedLabels = []; + private $expectResult; + + private function __construct(stdClass $o = null) + { + if (isset($o->isError)) { + assertTrue($o->isError); + } + + if (isset($o->isClientError)) { + assertIsBool($o->isClientError); + $this->isClientError = $o->isClientError; + } + + if (isset($o->errorContains)) { + assertIsString($o->errorContains); + $this->messageContains = $o->errorContains; + } + + if (isset($o->errorCode)) { + assertIsInt($o->errorCode); + $this->code = $o->errorCode; + } + + if (isset($o->errorCodeName)) { + assertIsString($o->errorCodeName); + $this->codeName = $o->errorCodeName; + } + + if (isset($o->errorLabelsContain)) { + assertIsArray($o->errorLabelsContain); + assertContainsOnly('string', $o->errorLabelsContain); + $o->includedLabels = $o->errorLabelsContain; + } + + if (isset($o->errorLabelsOmit)) { + assertIsArray($o->errorLabelsOmit); + assertContainsOnly('string', $o->errorLabelsOmit); + $o->excludedLabels = $o->errorLabelsOmit; + } + + if (isset($o->expectResult)) { + $o->expectResult = new ExpectedResult($o->expectResult); + } + } + + public static function fromOperation(stdClass $o): self + { + if (! isset($o->expectError)) { + $expectedError = new self; + $expectedError->isError = false; + + return $expectedError; + } + + $expectedError = new self($o->expectError); + + if (isset($o->expectError->expectResult)) { + $o->expectResult = ExpectedResult::fromOperation($o); + } + } + + /** + * Assert the outcome of an operation. + * + * @param Throwable|null $e Exception (if any) from executing an operation + */ + public function assert(Throwable $e = null) + { + if (! $this->isError) { + assertNull($e, sprintf("Operation threw unexpected %s: %s\n%s", get_class($e), $e->getMessage(), $e->getTraceAsString())); + return; + } + + $assertNotNull($e); + + if (isset($this->messageContains)) { + assertStringContainsStringIgnoringCase($this->messageContains, $e->getMessage()); + } + + if (isset($this->code)) { + assertInstanceOf(ServerException::class, $e); + assertSame($this->code, $e->getCode()); + } + + if (isset($this->codeName)) { + assertInstanceOf(ServerException::class, $e); + $this->assertCodeName($e); + } + + if (! empty($this->excludedLabels) || ! empty($this->includedLabels)) { + assertInstanceOf(RuntimeException::class, $e); + + foreach ($this->excludedLabels as $label) { + assertFalse($e->hasErrorLabel($label), 'Exception should not have error label: ' . $label); + } + + foreach ($this->includedLabels as $label) { + assertTrue($e->hasErrorLabel($label), 'Exception should have error label: ' . $label); + } + } + + if (isset($this->expectResult)) { + assertInstanceOf(BulkWriteException::class, $e); + $this->expectResult->assert($e->getWriteResult()); + } + } + + private function assertCodeName(ServerException $e) + { + /* BulkWriteException and ExecutionTimeoutException do not expose + * codeName. Work around this by translating it to a numeric code. + * + * TODO: Remove this once PHPC-1386 is resolved. */ + if ($e instanceof BulkWriteException || $e instanceof ExecutionTimeoutException) { + assertArrayHasKey($this->codeName, self::$codeNameMap); + assertSame(self::$codeNameMap[$this->codeName], $e->getCode()); + + return; + } + + assertInstanceOf(CommandException::class, $e); + $result = $e->getResultDocument(); + + if (isset($result->writeConcernError)) { + assertObjectHasAttribute('codeName', $result->writeConcernError); + assertSame($this->codeName, $result->writeConcernError->codeName); + + return; + } + + assertObjectHasAttribute('codeName', $result); + assertSame($this->codeName, $result->codeName); + } +} diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php new file mode 100644 index 000000000..a31ff3f18 --- /dev/null +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -0,0 +1,287 @@ +assertionType = $assertionType; + $this->expectedValue = $expectedValue; + } + + /** + * Assert that the result expectation matches the actual outcome. + * + * @param FunctionalTestCase $test Test instance for performing assertions + * @param mixed $result Result (if any) from the actual outcome + * @throws LogicException if the assertion type is unsupported + */ + public function assert(FunctionalTestCase $test, $actual) + { + $expected = $this->expectedValue; + + switch ($this->assertionType) { + case self::ASSERT_BULKWRITE: + /* If the bulk write was successful, the actual value should be + * a BulkWriteResult; otherwise, expect a WriteResult extracted + * from the BulkWriteException. */ + $test->assertThat($actual, $test->logicalOr( + $test->isInstanceOf(BulkWriteResult::class), + $test->isInstanceOf(WriteResult::class) + )); + + if (! $actual->isAcknowledged()) { + break; + } + + if (isset($expected->deletedCount)) { + $test->assertSame($expected->deletedCount, $actual->getDeletedCount()); + } + + if (isset($expected->insertedCount)) { + $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); + } + + // insertedIds are not available after BulkWriteException (see: PHPLIB-428) + if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) { + $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds()); + } + + if (isset($expected->matchedCount)) { + $test->assertSame($expected->matchedCount, $actual->getMatchedCount()); + } + + if (isset($expected->modifiedCount)) { + $test->assertSame($expected->modifiedCount, $actual->getModifiedCount()); + } + + if (isset($expected->upsertedCount)) { + $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount()); + } + + if (isset($expected->upsertedIds)) { + $test->assertSameDocument($expected->upsertedIds, $actual->getUpsertedIds()); + } + break; + + case self::ASSERT_CALLABLE: + call_user_func($this->assertionCallable, $expected, $actual); + break; + + case self::ASSERT_DELETE: + $test->assertInstanceOf(DeleteResult::class, $actual); + + if (isset($expected->deletedCount)) { + $test->assertSame($expected->deletedCount, $actual->getDeletedCount()); + } + break; + + case self::ASSERT_INSERTMANY: + /* If the bulk insert was successful, the actual value should be + * a InsertManyResult; otherwise, expect a WriteResult extracted + * from the BulkWriteException. */ + $test->assertThat($actual, $test->logicalOr( + $test->isInstanceOf(InsertManyResult::class), + $test->isInstanceOf(WriteResult::class) + )); + + if (isset($expected->insertedCount)) { + $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); + } + + // insertedIds are not available after BulkWriteException (see: PHPLIB-428) + if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) { + $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds()); + } + break; + + case self::ASSERT_INSERTONE: + $test->assertThat($actual, $test->logicalOr( + $test->isInstanceOf(InsertOneResult::class), + $test->isInstanceOf(WriteResult::class) + )); + + if (isset($expected->insertedCount)) { + $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); + } + + if (property_exists($expected, 'insertedId')) { + $test->assertSameDocument( + ['insertedId' => $expected->insertedId], + ['insertedId' => $actual->getInsertedId()] + ); + } + break; + + case self::ASSERT_MATCHES_DOCUMENT: + $test->assertIsObject($expected); + $test->assertThat($actual, $test->logicalOr( + $test->isType('array'), + $test->isType('object') + )); + $test->assertMatchesDocument($expected, $actual); + break; + + case self::ASSERT_NOTHING: + break; + + case self::ASSERT_NULL: + $test->assertNull($actual); + break; + + case self::ASSERT_SAME: + $test->assertSame($expected, $actual); + break; + + case self::ASSERT_SAME_DOCUMENT: + $test->assertIsObject($expected); + $test->assertThat($actual, $test->logicalOr( + $test->isType('array'), + $test->isType('object') + )); + $test->assertSameDocument($expected, $actual); + break; + + case self::ASSERT_SAME_DOCUMENTS: + $test->assertSameDocuments($expected, $actual); + break; + + case self::ASSERT_DOCUMENTS_MATCH: + $test->assertDocumentsMatch($expected, $actual); + break; + + case self::ASSERT_UPDATE: + $test->assertInstanceOf(UpdateResult::class, $actual); + + if (isset($expected->matchedCount)) { + $test->assertSame($expected->matchedCount, $actual->getMatchedCount()); + } + + if (isset($expected->modifiedCount)) { + $test->assertSame($expected->modifiedCount, $actual->getModifiedCount()); + } + + if (isset($expected->upsertedCount)) { + $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount()); + } + + if (property_exists($expected, 'upsertedId')) { + $test->assertSameDocument( + ['upsertedId' => $expected->upsertedId], + ['upsertedId' => $actual->getUpsertedId()] + ); + } + break; + + default: + throw new LogicException('Unsupported assertion type: ' . $this->assertionType); + } + } + + public function isExpected() + { + return $this->assertionType !== self::ASSERT_NOTHING; + } + + private static function isArrayOfObjects($array) + { + if (! is_array($array)) { + return false; + } + + foreach ($array as $object) { + if (! is_object($object)) { + return false; + } + } + + return true; + } + + /** + * Determines whether the result is actually an error expectation. + * + * @see https://github.com/mongodb/specifications/blob/master/source/transactions/tests/README.rst#test-format + * @param mixed $result + * @return boolean + */ + private static function isErrorResult($result) + { + if (! is_object($result)) { + return false; + } + + $keys = ['errorContains', 'errorCodeName', 'errorLabelsContain', 'errorLabelsOmit']; + + foreach ($keys as $key) { + if (isset($result->{$key})) { + return true; + } + } + + return false; + } +} diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php new file mode 100644 index 000000000..934adc228 --- /dev/null +++ b/tests/UnifiedSpecTests/Operation.php @@ -0,0 +1,586 @@ +name); + $this->name = $o->name; + + assertIsString($o->object); + $this->object = $o->object; + + if (isset($o->arguments)) { + assertIsObject($o->arguments); + $this->arguments = (array) $o->arguments; + } + + // expectError is mutually exclusive with expectResult and saveResultAsEntity + assertThat($o, logicalXor( + objectHasAttribute('expectError'), + logicalOr(objectHasAttribute('expectResult'), objectHasAttribute('saveResultAsEntity')) + )); + + $o->expectError = ExpectedError::fromOperation($o); + + if (isset($o->expectResult)) { + $o->expectResult = ExpectedResult::fromOperation($o); + } + + if (isset($o->saveResultAsEntity)) { + assertIsString($o->saveResultAsEntity); + $this->saveResultAsEntity = $o->saveResultAsEntity; + } + } + + /** + * Execute the operation and assert its outcome. + */ + public function assert(Context $context, bool $rethrowExceptions = false) + { + $throwable = null; + $result = null; + + try { + $result = $this->execute($context); + + /* Eagerly iterate the results of a cursor. This both allows an + * exception to be thrown sooner and ensures that any expected + * getMore command(s) can be observed even if a ResultExpectation + * is not used (e.g. Command Monitoring spec). */ + if ($result instanceof Cursor) { + $result = $result->toArray(); + } + } catch (Throwable $e) { + $error = $e; + } + + $this->expectError->assert($throwable); + $this->expectResult->assert($result); + + // Rethrowing is primarily used for withTransaction callbacks + if ($error && $rethrowExceptions) { + throw $error; + } + } + + /** + * Executes the operation with a given context. + * + * @param Context $context + * @return mixed + * @throws LogicException if the entity type or operation is unsupported + */ + private function execute(Context $context) + { + if ($this->object = self::OBJECT_TEST_RUNNER) { + return $this->executeForTestRunner($context); + } + + $entityMap = $context->getEntityMap(); + + assertArrayHasKey($this->object, $entityMap); + $object = $entityMap[$this->object]; + assertIsObject($object); + + switch (get_class($object)) { + case Client::class: + return $this->executeForClient($object, $context); + + case Database::class: + return $this->executeForDatabase($object, $context); + + case Collection::class: + return $this->executeForCollection($object, $context); + + default: + Assert::fail('Unsupported entity type: ' . get_class($object)); + } + } + + private function executeForClient(Client $client, Context $context) + { + $args = Context::prepareOperationArguments($this->arguments); + + switch ($this->name) { + case 'listDatabaseNames': + return iterator_to_array($client->listDatabaseNames($args)); + + case 'listDatabases': + return $client->listDatabases($args); + + case 'watch': + return $client->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + + default: + Assert::fail('Unsupported client operation: ' . $this->name); + } + } + + private function executeForCollection(Collection $collection, Context $context) + { + $args = Context::prepareOperationArguments($this->arguments); + + switch ($this->name) { + case 'aggregate': + return $collection->aggregate( + $args['pipeline'], + array_diff_key($args, ['pipeline' => 1]) + ); + + case 'bulkWrite': + // Merge nested and top-level options (see: SPEC-1158) + $options = isset($args['options']) ? (array) $args['options'] : []; + $options += array_diff_key($args, ['requests' => 1]); + + return $collection->bulkWrite( + // TODO: Check if self can be used with a private static function + array_map([$this, 'prepareBulkWriteRequest'], $args['requests']), + $options + ); + + case 'createIndex': + return $collection->createIndex( + $args['keys'], + array_diff_key($args, ['keys' => 1]) + ); + + case 'dropIndex': + return $collection->dropIndex( + $args['name'], + array_diff_key($args, ['name' => 1]) + ); + + case 'count': + case 'countDocuments': + case 'find': + return $collection->{$this->name}( + $args['filter'] ?? [], + array_diff_key($args, ['filter' => 1]) + ); + + case 'estimatedDocumentCount': + return $collection->estimatedDocumentCount($args); + + case 'deleteMany': + case 'deleteOne': + case 'findOneAndDelete': + return $collection->{$this->name}( + $args['filter'], + array_diff_key($args, ['filter' => 1]) + ); + + case 'distinct': + return $collection->distinct( + $args['fieldName'], + $args['filter'] ?? [], + array_diff_key($args, ['fieldName' => 1, 'filter' => 1]) + ); + + case 'drop': + return $collection->drop($args); + + case 'findOne': + return $collection->findOne($args['filter'], array_diff_key($args, ['filter' => 1])); + + case 'findOneAndReplace': + if (isset($args['returnDocument'])) { + $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) + ? FindOneAndReplace::RETURN_DOCUMENT_AFTER + : FindOneAndReplace::RETURN_DOCUMENT_BEFORE; + } + // Fall through + + case 'replaceOne': + return $collection->{$this->name}( + $args['filter'], + $args['replacement'], + array_diff_key($args, ['filter' => 1, 'replacement' => 1]) + ); + + case 'findOneAndUpdate': + if (isset($args['returnDocument'])) { + $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) + ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER + : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE; + } + // Fall through + + case 'updateMany': + case 'updateOne': + return $collection->{$this->name}( + $args['filter'], + $args['update'], + array_diff_key($args, ['filter' => 1, 'update' => 1]) + ); + + case 'insertMany': + // Merge nested and top-level options (see: SPEC-1158) + $options = isset($args['options']) ? (array) $args['options'] : []; + $options += array_diff_key($args, ['documents' => 1]); + + return $collection->insertMany( + $args['documents'], + $options + ); + + case 'insertOne': + return $collection->insertOne( + $args['document'], + array_diff_key($args, ['document' => 1]) + ); + + case 'listIndexes': + return $collection->listIndexes($args); + + case 'mapReduce': + return $collection->mapReduce( + $args['map'], + $args['reduce'], + $args['out'], + array_diff_key($args, ['map' => 1, 'reduce' => 1, 'out' => 1]) + ); + + case 'watch': + return $collection->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + + default: + Assert::fail('Unsupported collection operation: ' . $this->name); + } + } + + private function executeForDatabase(Database $database, Context $context) + { + $args = Context::prepareOperationArguments($this->arguments); + + switch ($this->name) { + case 'aggregate': + return $database->aggregate( + $args['pipeline'], + array_diff_key($args, ['pipeline' => 1]) + ); + + case 'createCollection': + return $database->createCollection( + $args['collection'], + array_diff_key($args, ['collection' => 1]) + ); + + case 'dropCollection': + return $database->dropCollection( + $args['collection'], + array_diff_key($args, ['collection' => 1]) + ); + + case 'listCollectionNames': + return iterator_to_array($database->listCollectionNames($args)); + + case 'listCollections': + return $database->listCollections($args); + + case 'runCommand': + return $database->command( + $args['command'], + array_diff_key($args, ['command' => 1]) + )->toArray()[0]; + + case 'watch': + return $database->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + + default: + Assert::fail('Unsupported database operation: ' . $this->name); + } + } + + private function executeForTestRunner(FunctionalTestCase $test, Context $context) + { + $args = Context::prepareOperationArguments($this->arguments); + + switch ($this->name) { + case 'assertCollectionExists': + $databaseName = $args['database']; + $collectionName = $args['collection']; + + $test->assertContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + + return null; + case 'assertCollectionNotExists': + $databaseName = $args['database']; + $collectionName = $args['collection']; + + $test->assertNotContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + + return null; + case 'assertIndexExists': + $databaseName = $args['database']; + $collectionName = $args['collection']; + $indexName = $args['index']; + + $test->assertContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + + return null; + case 'assertIndexNotExists': + $databaseName = $args['database']; + $collectionName = $args['collection']; + $indexName = $args['index']; + + $test->assertNotContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + + return null; + case 'assertSessionPinned': + $test->assertInstanceOf(Session::class, $args['session']); + $test->assertInstanceOf(Server::class, $args['session']->getServer()); + + return null; + case 'assertSessionTransactionState': + $test->assertInstanceOf(Session::class, $args['session']); + /* PHPC currently does not expose the exact session state, but + * instead exposes a bool to let us know whether a transaction + * is currently in progress. This code may fail down the line + * and should be adjusted once PHPC-1438 is implemented. */ + $test->assertSame($this->arguments['state'], $args['session']->getTransactionState()); + + return null; + case 'assertSessionUnpinned': + $test->assertInstanceOf(Session::class, $args['session']); + $test->assertNull($args['session']->getServer()); + + return null; + case 'targetedFailPoint': + $test->assertInstanceOf(Session::class, $args['session']); + $test->configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); + + return null; + default: + throw new LogicException('Unsupported test runner operation: ' . $this->name); + } + } + + /** + * @param string $databaseName + * @param string $collectionName + * + * @return array + */ + private function getIndexNames(Context $context, $databaseName, $collectionName) + { + return array_map( + function (IndexInfo $indexInfo) { + return $indexInfo->getName(); + }, + iterator_to_array($context->selectCollection($databaseName, $collectionName)->listIndexes()) + ); + } + + /** + * @throws LogicException if the operation object is unsupported + */ + private function getResultAssertionType() + { + switch ($this->object) { + case self::OBJECT_CLIENT: + return $this->getResultAssertionTypeForClient(); + case self::OBJECT_COLLECTION: + return $this->getResultAssertionTypeForCollection(); + case self::OBJECT_DATABASE: + return $this->getResultAssertionTypeForDatabase(); + case self::OBJECT_GRIDFS_BUCKET: + return ResultExpectation::ASSERT_SAME; + case self::OBJECT_SESSION0: + case self::OBJECT_SESSION1: + case self::OBJECT_TEST_RUNNER: + return ResultExpectation::ASSERT_NOTHING; + default: + throw new LogicException('Unsupported object: ' . $this->object); + } + } + + /** + * @throws LogicException if the collection operation is unsupported + */ + private function getResultAssertionTypeForClient() + { + switch ($this->name) { + case 'listDatabaseNames': + return ResultExpectation::ASSERT_SAME; + case 'listDatabases': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'watch': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + default: + throw new LogicException('Unsupported client operation: ' . $this->name); + } + } + + /** + * @throws LogicException if the collection operation is unsupported + */ + private function getResultAssertionTypeForCollection() + { + switch ($this->name) { + case 'aggregate': + /* Returning a cursor for the $out collection is optional per + * the CRUD specification and is not implemented in the library + * since we have no concept of lazy cursors. Rely on examining + * the output collection rather than the operation result. */ + if (is_last_pipeline_operator_write($this->arguments['pipeline'])) { + return ResultExpectation::ASSERT_NOTHING; + } + + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'bulkWrite': + return ResultExpectation::ASSERT_BULKWRITE; + case 'count': + case 'countDocuments': + return ResultExpectation::ASSERT_SAME; + case 'createIndex': + case 'dropIndex': + return ResultExpectation::ASSERT_MATCHES_DOCUMENT; + case 'distinct': + case 'estimatedDocumentCount': + return ResultExpectation::ASSERT_SAME; + case 'deleteMany': + case 'deleteOne': + return ResultExpectation::ASSERT_DELETE; + case 'drop': + return ResultExpectation::ASSERT_NOTHING; + case 'findOne': + case 'findOneAndDelete': + case 'findOneAndReplace': + case 'findOneAndUpdate': + return ResultExpectation::ASSERT_SAME_DOCUMENT; + case 'find': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'insertMany': + return ResultExpectation::ASSERT_INSERTMANY; + case 'insertOne': + return ResultExpectation::ASSERT_INSERTONE; + case 'listIndexes': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'mapReduce': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'replaceOne': + case 'updateMany': + case 'updateOne': + return ResultExpectation::ASSERT_UPDATE; + case 'watch': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + default: + throw new LogicException('Unsupported collection operation: ' . $this->name); + } + } + + /** + * @throws LogicException if the database operation is unsupported + */ + private function getResultAssertionTypeForDatabase() + { + switch ($this->name) { + case 'aggregate': + case 'listCollections': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + case 'listCollectionNames': + return ResultExpectation::ASSERT_SAME; + case 'createCollection': + case 'dropCollection': + case 'runCommand': + return ResultExpectation::ASSERT_MATCHES_DOCUMENT; + case 'watch': + return ResultExpectation::ASSERT_SAME_DOCUMENTS; + default: + throw new LogicException('Unsupported database operation: ' . $this->name); + } + } + + /** + * Prepares a request element for a bulkWrite operation. + * + * @param stdClass $request + * @return array + * @throws LogicException if the bulk write request is unsupported + */ + private function prepareBulkWriteRequest(stdClass $request) + { + $args = (array) $request->arguments; + + switch ($request->name) { + case 'deleteMany': + case 'deleteOne': + return [ + $request->name => [ + $args['filter'], + array_diff_key($args, ['filter' => 1]), + ], + ]; + case 'insertOne': + return [ 'insertOne' => [ $args['document'] ]]; + case 'replaceOne': + return [ + 'replaceOne' => [ + $args['filter'], + $args['replacement'], + array_diff_key($args, ['filter' => 1, 'replacement' => 1]), + ], + ]; + case 'updateMany': + case 'updateOne': + return [ + $request->name => [ + $args['filter'], + $args['update'], + array_diff_key($args, ['filter' => 1, 'update' => 1]), + ], + ]; + default: + throw new LogicException('Unsupported bulk write request: ' . $request->name); + } + } +} diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index 9985675a3..d58319330 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -43,11 +43,6 @@ public function __construct(stdClass $o) } } - public static function fromObject(stdClass $o): self - { - - } - /** * Checks if the requirements are satisfied. * diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index a321d8aba..7c47bff05 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -93,13 +93,30 @@ public function testCase(stdClass $test, string $schemaVersion, array $runOnRequ $this->prepareInitialData($initialData); } - $context = new Context(static::getUri()); + $context = new Context(self::$internalClient, static::getUri()); if (isset($createEntities)) { $context->createEntities($createEntities); } - + // TODO handle distinct commands in sharded transactions + + if (isset($test->expectedEvents)) { + $context->startEventObservers(); + } + + foreach ($test->operations as $o) { + $operation = new Operation($o); + $operation->assert($context); + } + + if (isset($test->expectedEvents)) { + $context->stopEventObservers(); + } + + if (isset($test->outcome)) { + $this->assertOutcome($test->outcome); + } } public function provideTests() @@ -247,6 +264,16 @@ private static function killAllSessions() } } + private function assertOutcome(array $outcome) + { + $this->assertNotEmpty($outcome); + + foreach ($outcome as $data) { + $collectionData = new CollectionData($data); + $collectionData->assertOutcome(self::$internalClient); + } + } + private function prepareInitialData(array $initialData) { $this->assertNotEmpty($initialData); diff --git a/tests/UnifiedSpecTests/example-insertOne.json b/tests/UnifiedSpecTests/example-insertOne.json index 68f2ccf8d..be41f9eac 100644 --- a/tests/UnifiedSpecTests/example-insertOne.json +++ b/tests/UnifiedSpecTests/example-insertOne.json @@ -53,14 +53,14 @@ "_id": 2 } }, - "expectedResult": { + "expectResult": { "insertedId": { "$$unsetOrMatches": 2 } } } ], - "expectedEvents": [ + "expectEvents": [ { "client": "client0", "events": [ diff --git a/tests/UnifiedSpecTests/example-insertOne.yml b/tests/UnifiedSpecTests/example-insertOne.yml index e00408008..22b27bb2f 100644 --- a/tests/UnifiedSpecTests/example-insertOne.yml +++ b/tests/UnifiedSpecTests/example-insertOne.yml @@ -33,9 +33,9 @@ tests: name: insertOne arguments: document: { _id: 2 } - expectedResult: + expectResult: insertedId: { $$unsetOrMatches: 2 } - expectedEvents: + expectEvents: - client: *client0 events: - commandStartedEvent: From a589d5f8e45deac3737dfc809ed453dc8c585e11 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 22 Sep 2020 22:38:20 -0400 Subject: [PATCH 05/41] wip --- .../Constraint/IsBsonType.php | 42 +-- .../Constraint/IsBsonTypeTest.php | 113 +++++++ tests/UnifiedSpecTests/Constraint/Match.php | 280 ++++++++++++++---- .../UnifiedSpecTests/Constraint/MatchTest.php | 88 ++---- 4 files changed, 382 insertions(+), 141 deletions(-) create mode 100644 tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php index ba7ab0380..9073ecbde 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonType.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -2,6 +2,7 @@ namespace MongoDB\Tests\UnifiedSpecTests\Constraint; +use LogicException; use MongoDB\BSON\BinaryInterface; use MongoDB\BSON\DBPointer; use MongoDB\BSON\Decimal128Interface; @@ -17,9 +18,19 @@ use MongoDB\BSON\UTCDateTimeInterface; use MongoDB\Model\BSONArray; use PHPUnit\Framework\Constraint\Constraint; -use Symfony\Bridge\PhpUnit\ConstraintTrait; use RuntimeException; -use LogicException; +use Symfony\Bridge\PhpUnit\ConstraintTrait; +use function array_keys; +use function count; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_object; +use function is_string; +use function range; +use function sprintf; +use const PHP_INT_SIZE; final class IsBsonType extends Constraint { @@ -60,88 +71,67 @@ public function __construct(string $type) $this->type = $type; } - private function doMatches($other): bool + private function doMatches($other) : bool { switch ($this->type) { case 'double': return is_float($other); - case 'string': return is_string($other); - case 'object': return is_object($other) && (! $other instanceof BSONArray); - case 'array': return self::isArray($other); - case 'binData': return $other instanceof BinaryInterface; - case 'undefined': return $other instanceof Undefined; - case 'objectId': return $other instanceof ObjectIdInterface; - case 'bool': return is_bool($other); - case 'date': return $other instanceof UTCDateTimeInterface; - case 'null': return $other === null; - case 'regex': return $other instanceof RegexInterface; - case 'dbPointer': return $other instanceof DBPointer; - case 'javascript': return $other instanceof JavascriptInterface && $other->getScope() === null; - case 'symbol': return $other instanceof Symbol; - case 'javascriptWithScope': return $other instanceof JavascriptInterface && $other->getScope() !== null; - case 'int': return is_int($other); - case 'timestamp': return $other instanceof TimestampInterface; - case 'long': if (PHP_INT_SIZE == 4) { return $other instanceof Int64; } return is_int($other); - case 'decimal': return $other instanceof Decimal128Interface; - case 'minKey': return $other instanceof MinKeyInterface; - case 'maxKey': return $other instanceof MaxKeyInterface; - default: // This should already have been caught in the constructor throw new LogicException('Unsupported type: ' . $this->type); } } - private function doToString(): string + private function doToString() : string { return sprintf('is of BSON type "%s"', $this->type); } - private static function isArray($other): bool + private static function isArray($other) : bool { if ($other instanceof BSONArray) { return true; diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php new file mode 100644 index 000000000..7812c8b9d --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php @@ -0,0 +1,113 @@ +assertTrue($c->evaluate($value, '', true)); + } + + public function provideTypes() + { + $undefined = toPHP(fromJSON('{ "undefined": {"$undefined": true} }')); + $symbol = toPHP(fromJSON('{ "symbol": {"$symbol": "test"} }')); + $dbPointer = toPHP(fromJSON('{ "dbPointer": {"$dbPointer": {"$ref": "phongo.test", "$id" : { "$oid" : "5a2e78accd485d55b405ac12" } }} }')); + + return [ + 'double' => ['double', 1.4], + 'string' => ['string', 'foo'], + 'object(stdClass)' => ['object', new stdClass()], + 'object(BSONDocument)' => ['object', new BSONDocument()], + 'array(array)' => ['array', ['foo']], + 'array(BSONArray)' => ['array', new BSONArray()], + 'binData' => ['binData', new Binary('', 0)], + 'undefined' => ['undefined', $undefined->undefined], + 'objectId' => ['objectId', new ObjectId()], + 'bool' => ['bool', true], + 'date' => ['date', new UTCDateTime()], + 'null' => ['null', null], + 'regex' => ['regex', new Regex('.*')], + 'dbPointer' => ['dbPointer', $dbPointer->dbPointer], + 'javascript' => ['javascript', new Javascript('foo = 1;')], + 'symbol' => ['symbol', $symbol->symbol], + 'javascriptWithScope' => ['javascriptWithScope', new Javascript('foo = 1;', ['x' => 1])], + 'int' => ['int', 1], + 'timestamp' => ['timestamp', new Timestamp(0, 0)], + 'long' => ['long', PHP_INT_SIZE == 4 ? unserialize('C:18:"MongoDB\BSON\Int64":38:{a:1:{s:7:"integer";s:10:"4294967296";}}') : 4294967296], + 'decimal' => ['decimal', new Decimal128('18446744073709551616')], + 'minKey' => ['minKey', new MinKey()], + 'maxKey' => ['maxKey', new MaxKey()], + ]; + } + + public function testErrorMessage() + { + $c = new IsBsonType('string'); + + try { + $c->evaluate(1); + $this->fail('Expected a comparison failure'); + } catch (ExpectationFailedException $e) { + $this->assertStringMatchesFormat('Failed asserting that %s is of BSON type "string".', $e->getMessage()); + } + } + + public function testTypeArray() + { + $c = new IsBsonType('array'); + + $this->assertFalse($c->evaluate(1, '', true)); + $this->assertFalse($c->evaluate(['x' => 1], '', true)); + $this->assertFalse($c->evaluate([0 => 'a', 2 => 'c'], '', true)); + } + + public function testTypeObject() + { + $c = new IsBsonType('object'); + + $this->assertFalse($c->evaluate(1, '', true)); + $this->assertFalse($c->evaluate(new BSONArray(), '', true)); + } + + public function testTypeJavascript() + { + $c = new IsBsonType('javascript'); + + $this->assertFalse($c->evaluate(1, '', true)); + $this->assertFalse($c->evaluate(new Javascript('foo = 1;', ['x' => 1]), '', true)); + } + + public function testTypeJavascriptWithScope() + { + $c = new IsBsonType('javascriptWithScope'); + + $this->assertFalse($c->evaluate(1, '', true)); + $this->assertFalse($c->evaluate(new Javascript('foo = 1;'), '', true)); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/Match.php b/tests/UnifiedSpecTests/Constraint/Match.php index a6241b204..4f8a16b06 100644 --- a/tests/UnifiedSpecTests/Constraint/Match.php +++ b/tests/UnifiedSpecTests/Constraint/Match.php @@ -2,31 +2,32 @@ namespace MongoDB\Tests\UnifiedSpecTests\Constraint; -use ArrayObject; -use InvalidArgumentException; +use LogicException; +use MongoDB\BSON\Type; use MongoDB\Model\BSONArray; use MongoDB\Model\BSONDocument; +use MongoDB\Tests\UnifiedSpecTests\EntityMap; use PHPUnit\Framework\Constraint\Constraint; -use PHPUnit\Framework\Constraint\IsInstanceOf; -use PHPUnit\Framework\Constraint\IsNull; -use PHPUnit\Framework\Constraint\IsType; -use PHPUnit\Framework\Constraint\LogicalAnd; -use PHPUnit\Framework\Constraint\LogicalNot; use PHPUnit\Framework\Constraint\LogicalOr; use RuntimeException; use SebastianBergmann\Comparator\ComparisonFailure; use SebastianBergmann\Comparator\Factory; -use stdClass; use Symfony\Bridge\PhpUnit\ConstraintTrait; use function array_values; +use function count; use function get_class; +use function get_resource_type; use function gettype; -use function in_array; +use function hex2bin; +use function implode; use function is_array; +use function is_bool; use function is_object; -use function is_scalar; -use function method_exists; +use function is_resource; +use function is_string; use function sprintf; +use function stream_get_contents; +use function strpos; use const PHP_INT_SIZE; /** @@ -38,6 +39,9 @@ class Match extends Constraint { use ConstraintTrait; + /** @var EntityMap */ + private $entityMap; + /** @var boolean */ private $ignoreExtraKeysInRoot = false; @@ -50,9 +54,10 @@ class Match extends Constraint /** @var ComparisonFailure|null */ private $lastFailure; - public function __construct($value, $ignoreExtraKeysInRoot = false, $ignoreExtraKeysInEmbedded = false) + public function __construct($value, bool $ignoreExtraKeysInRoot = false, bool $ignoreExtraKeysInEmbedded = false, EntityMap $entityMap = null) { $this->value = self::prepare($value, true); + $this->entityMap = $entityMap ?? new EntityMap(); $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot; $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded; $this->comparatorFactory = Factory::getInstance(); @@ -136,76 +141,224 @@ private function assertMatches($expected, $actual, bool $ignoreExtraKeys, $keyPa { if (! $expected instanceof BSONDocument && ! $expected instanceof BSONArray) { $this->assertEquals($expected, $actual, $keyPath); + + return; } if ($expected instanceof BSONArray) { + if (! $actual instanceof BSONArray) { + throw new RuntimeException(sprintf( + '%s%s is not instance of expected class "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + BSONArray::class + )); + } + if (count($expected) !== count($actual)) { + throw new RuntimeException(sprintf( + '%s%s has %d elements instead of %d expected', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + count($actual), + count($expected) + )); + } + + foreach ($expected as $key => $expectedValue) { + $this->assertMatches( + $expectedValue, + $actual[$key], + $this->ignoreExtraKeysInEmbedded, + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); + } + + return; } if ($expected instanceof BSONDocument) { if (self::isSpecialOperator($expected)) { + $operator = self::getSpecialOperator($expected); - } - } + // TODO: Validate structure of operators + if ($operator === '$$type') { + $types = is_string($expected['$$type']) ? [$expected['$$type']] : $expected['$$type']; + $constraints = []; + foreach ($types as $type) { + $constraints[] = new IsBsonType($type); + } - if (get_class($expected) !== get_class($actual)) { - throw new RuntimeException(sprintf( - '%s is not instance of expected class "%s"', - $this->exporter()->shortenedExport($actual), - get_class($expected) - )); - } + $constraint = LogicalOr::fromConstraints(...$constraints); + + if (! $constraint->evaluate($actual, '', true)) { + throw new RuntimeException(sprintf( + '%s%s is not an expected type: %s', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + implode(', ', $types) + )); + } + + return; + } + + if ($operator === '$$matchesEntity') { + $entityMap = $this->getEntityMap(); + + $this->assertMatches( + $entityMap[$expected['$$matchesEntity']], + $actual, + $ignoreExtraKeys, + $keyPath + ); + + return; + } - foreach ($expected as $key => $expectedValue) { - $actualHasKey = $actual->offsetExists($key); + if ($operator === '$$matchesHexBytes') { + if (! is_resource($actual) || get_resource_type($actual) != "stream") { + throw new RuntimeException(sprintf( + '%s%s is not a stream', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + )); + } + + if (stream_get_contents($actual, -1, 0) !== hex2bin($expected['$$matchesHexBytes'])) { + throw new RuntimeException(sprintf( + '%s%s does not match expected hex bytes: %s', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + $expected['$$matchesHexBytes'] + )); + } + + return; + } + + if ($operator === '$$unsetOrMatches') { + /* If the operator is used at the top level, consider null + * values for $actual to be unset. If the operator is nested + * this check is done later document iteration. */ + if ($keyPath === '' && $actual === null) { + return; + } + + $this->assertMatches( + $expected['$$unsetOrMatches'], + $actual, + $ignoreExtraKeys, + $keyPath + ); - if (! $actualHasKey) { - throw new RuntimeException(sprintf('$actual is missing key: "%s"', $keyPrefix . $key)); + return; + } + + if ($operator === '$$sessionLsid') { + $entityMap = $this->getEntityMap(); + $session = $entityMap['$$sessionLsid']; + + if (! $session instanceof Session) { + throw new RuntimeException(sprintf( + '%sentity "%s" is not a session', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $entityMap['$$sessionLsid'], + )); + } + + $this->assertMatches( + $this->prepare($session->getLogicalSessionId(), true), + $actual, + false, /* LSID document should match exactly */ + $keyPath + ); + } + + throw new LogicException('unsupported operator: ' . $operator); } - if (in_array($expectedValue, $this->placeholders, true)) { - continue; + if (! $actual instanceof BSONDocument) { + throw new RuntimeException(sprintf( + '%s%s is not instance of expected class "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + BSONDocument::class + )); } - $actualValue = $actual[$key]; + foreach ($expected as $key => $expectedValue) { + $actualKeyExists = $actual->offsetExists($key); + + if ($expectedValue instanceof BSONDocument && self::isSpecialOperator($expectedValue)) { + $operator = self::getSpecialOperator($expectedValue); + + if ($operator === '$$exists') { + if (! is_bool($expectedValue['$$exists'])) { + throw new RuntimeException('$$exists is malformed'); + } + + if ($expectedValue['$$exists'] && ! $actualKeyExists) { + throw new RuntimeException(sprintf( + '%s%s does not have expected key "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + $key + )); + } + + if (! $expectedValue['$$exists'] && $actualKeyExists) { + throw new RuntimeException(sprintf( + '%s%s has unexpected key "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $this->exporter()->shortenedExport($actual), + $key + )); + } + + continue; + } + + if ($operator === '$$unsetOrMatches' && ! $actualKeyExists) { + continue; + } + } + + if (! $actualKeyExists) { + throw new RuntimeException(sprintf( + '%s$actual does not have expected key "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $key + )); + } - if ($expectedValue instanceof BSONDocument && isset($expectedValue['$$type'])) { - $this->assertBSONType($expectedValue['$$type'], $actualValue); - continue; + $this->assertMatches( + $expectedValue, + $actual[$key], + $this->ignoreExtraKeysInEmbedded, + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); } - if (($expectedValue instanceof BSONArray && $actualValue instanceof BSONArray) || - ($expectedValue instanceof BSONDocument && $actualValue instanceof BSONDocument)) { - $this->assertEquals($expectedValue, $actualValue, $this->ignoreExtraKeysInEmbedded, $keyPrefix . $key . '.'); - continue; + if ($ignoreExtraKeys) { + return; } - 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.') - ); + foreach ($actual as $key => $_) { + if (! $expected->offsetExists($key)) { + throw new RuntimeException(sprintf( + '%s$actual has extra key "%s"', + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), + $key + )); } - - continue; } - } - if ($ignoreExtraKeys) { return; } - foreach ($actual as $key => $value) { - if (! $expected->offsetExists($key)) { - throw new RuntimeException(sprintf('$actual has extra key: "%s"', $keyPrefix . $key)); - } - } + throw new LogicException('should not reach this point'); } private function doAdditionalFailureDescription($other) @@ -240,13 +393,28 @@ private function doToString() return 'matches ' . $this->exporter()->export($this->value); } - private static function isSpecialOperator(BSONDocument $document): bool + private static function getSpecialOperator(BSONDocument $document) : string + { + foreach ($document as $key => $_) { + if (strpos((string) $key, '$$') === 0) { + return $key; + } + } + + throw new LogicException('should not reach this point'); + } + + private static function isSpecialOperator(BSONDocument $document) : bool { + if (count($document) !== 1) { + return false; + } + foreach ($document as $key => $_) { return strpos((string) $key, '$$') === 0; } - return false; + throw new LogicException('should not reach this point'); } /** diff --git a/tests/UnifiedSpecTests/Constraint/MatchTest.php b/tests/UnifiedSpecTests/Constraint/MatchTest.php index d0a7e3c80..0c26e2dd6 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchTest.php @@ -2,23 +2,9 @@ namespace MongoDB\Tests\UnifiedSpecTests\Constraint; -use MongoDB\BSON\Binary; -use MongoDB\BSON\Decimal128; -use MongoDB\BSON\Javascript; -use MongoDB\BSON\MaxKey; -use MongoDB\BSON\MinKey; -use MongoDB\BSON\ObjectId; -use MongoDB\BSON\Regex; -use MongoDB\BSON\Timestamp; -use MongoDB\BSON\UTCDateTime; use MongoDB\Model\BSONArray; -use MongoDB\Model\BSONDocument; use MongoDB\Tests\TestCase; use PHPUnit\Framework\ExpectationFailedException; -use function MongoDB\BSON\fromJSON; -use function MongoDB\BSON\toPHP; -use function unserialize; -use const PHP_INT_SIZE; class MatchTest extends TestCase { @@ -62,53 +48,37 @@ public function testIgnoreExtraKeysInEmbedded() $this->assertResult(false, $c, [1, ['a' => 2]], 'Keys must have the correct value'); } - public function testPlaceholders() + public function testSpecialOperatorExists() { - $c = new Match(['x' => '42', 'y' => 42, 'z' => ['a' => 24]], false, false, [24, 42]); + $c = new Match(['x' => ['$$exists' => true]]); + $this->assertResult(true, $c, ['x' => '1'], 'top-level $$exists:true and field exists'); + $this->assertResult(false, $c, [], 'top-level $$exists:true and field missing'); - $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'); - } + $c = new Match(['x' => ['$$exists' => false]]); + $this->assertResult(false, $c, ['x' => '1'], 'top-level $$exists:false and field exists'); + $this->assertResult(true, $c, [], 'top-level $$exists:false and field missing'); - /** - * @dataProvider provideBSONTypes - */ - public function testBSONTypeAssertions($type, $value) - { - $constraint = new Match(['x' => ['$$type' => $type]]); + $c = new Match(['x' => ['y' => ['$$exists' => true]]]); + $this->assertResult(true, $c, ['x' => ['y' => '1']], 'nested $$exists:true and field exists'); + $this->assertResult(false, $c, ['x' => (object) []], 'nested $$exists:true and field missing'); - $this->assertResult(true, $constraint, ['x' => $value], 'Type matches'); + $c = new Match(['x' => ['y' => ['$$exists' => false]]]); + $this->assertResult(false, $c, ['x' => ['y' => 1]], 'nested $$exists:false and field exists'); + $this->assertResult(true, $c, ['x' => (object) []], 'nested $$exists:false and field missing'); } - public function provideBSONTypes() + public function testSpecialOperatorType() { - $undefined = toPHP(fromJSON('{ "undefined": {"$undefined": true} }')); - $symbol = toPHP(fromJSON('{ "symbol": {"$symbol": "test"} }')); - $dbPointer = toPHP(fromJSON('{ "dbPointer": {"$dbPointer": {"$ref": "phongo.test", "$id" : { "$oid" : "5a2e78accd485d55b405ac12" } }} }')); + $c = new Match(['x' => ['$$type' => 'string']]); - return [ - 'double' => ['double', 1.4], - 'string' => ['string', 'foo'], - 'object' => ['object', new BSONDocument()], - 'array' => ['array', ['foo']], - 'binData' => ['binData', new Binary('', 0)], - 'undefined' => ['undefined', $undefined->undefined], - 'objectId' => ['objectId', new ObjectId()], - 'boolean' => ['boolean', true], - 'date' => ['date', new UTCDateTime()], - 'null' => ['null', null], - 'regex' => ['regex', new Regex('.*')], - 'dbPointer' => ['dbPointer', $dbPointer->dbPointer], - 'javascript' => ['javascript', new Javascript('foo = 1;')], - 'symbol' => ['symbol', $symbol->symbol], - 'int' => ['int', 1], - 'timestamp' => ['timestamp', new Timestamp(0, 0)], - 'long' => ['long', PHP_INT_SIZE == 4 ? unserialize('C:18:"MongoDB\BSON\Int64":38:{a:1:{s:7:"integer";s:10:"4294967296";}}') : 4294967296], - 'decimal' => ['decimal', new Decimal128('18446744073709551616')], - 'minKey' => ['minKey', new MinKey()], - 'maxKey' => ['maxKey', new MaxKey()], - ]; + $this->assertResult(true, $c, ['x' => 'foo'], '$$type:string matches string'); + $this->assertResult(false, $c, ['x' => 1], '$$type:string does not match int'); + + $c = new Match(['x' => ['$$type' => ['string', 'bool']]]); + + $this->assertResult(true, $c, ['x' => 'foo'], '$$type:[string,bool] matches string'); + $this->assertResult(true, $c, ['x' => true], '$$type:[string,bool] matches bool'); + $this->assertResult(false, $c, ['x' => 1], '$$type:[string,bool] does not match int'); } /** @@ -120,7 +90,7 @@ public function testErrorMessages($expectedMessagePart, Match $constraint, $actu $constraint->evaluate($actualValue); $this->fail('Expected a comparison failure'); } catch (ExpectationFailedException $failure) { - $this->assertStringContainsString('Failed asserting that two BSON objects are equal.', $failure->getMessage()); + $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $failure->getMessage()); $this->assertStringContainsString($expectedMessagePart, $failure->getMessage()); } } @@ -134,27 +104,27 @@ public function errorMessageProvider() new BSONArray(['foo' => 'bar']), ], 'Missing key' => [ - '$actual is missing key: "foo.bar"', + 'Field path "foo": $actual does not have expected key "bar"', new Match(['foo' => ['bar' => 'baz']]), ['foo' => ['foo' => 'bar']], ], 'Extra key' => [ - '$actual has extra key: "foo.foo"', + 'Field path "foo": $actual has extra key "foo"', new Match(['foo' => ['bar' => 'baz']]), ['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.', new Match(['foo' => 'bar']), ['foo' => 'baz'], ], 'Scalar type mismatch' => [ - 'Field path "foo": Failed asserting that two values are equal.', + 'Field path "foo": \'42\' is not instance of expected type "integer".', new Match(['foo' => 42]), ['foo' => '42'], ], 'Type mismatch' => [ - 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected type "MongoDB\Model\BSONArray".', + 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected class "MongoDB\Model\BSONArray"', new Match(['foo' => ['bar']]), ['foo' => (object) ['bar']], ], From 236a03154c9a454c7e6f51fd29bb79269302da17 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 28 Sep 2020 20:40:41 +0800 Subject: [PATCH 06/41] Clean up constraints and add test coverage --- .../Constraint/IsBsonType.php | 45 +- .../Constraint/IsBsonTypeTest.php | 62 ++- tests/UnifiedSpecTests/Constraint/Match.php | 470 ------------------ .../UnifiedSpecTests/Constraint/MatchTest.php | 138 ----- tests/UnifiedSpecTests/Constraint/Matches.php | 422 ++++++++++++++++ .../Constraint/MatchesTest.php | 257 ++++++++++ 6 files changed, 773 insertions(+), 621 deletions(-) delete mode 100644 tests/UnifiedSpecTests/Constraint/Match.php delete mode 100644 tests/UnifiedSpecTests/Constraint/MatchTest.php create mode 100644 tests/UnifiedSpecTests/Constraint/Matches.php create mode 100644 tests/UnifiedSpecTests/Constraint/MatchesTest.php diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php index 9073ecbde..c28c7cd4d 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonType.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -12,11 +12,14 @@ use MongoDB\BSON\MinKeyInterface; use MongoDB\BSON\ObjectIdInterface; use MongoDB\BSON\RegexInterface; +use MongoDB\BSON\Serializable; use MongoDB\BSON\Symbol; use MongoDB\BSON\TimestampInterface; +use MongoDB\BSON\Type; use MongoDB\BSON\Undefined; use MongoDB\BSON\UTCDateTimeInterface; use MongoDB\Model\BSONArray; +use MongoDB\Model\BSONDocument; use PHPUnit\Framework\Constraint\Constraint; use RuntimeException; use Symfony\Bridge\PhpUnit\ConstraintTrait; @@ -79,7 +82,7 @@ private function doMatches($other) : bool case 'string': return is_string($other); case 'object': - return is_object($other) && (! $other instanceof BSONArray); + return self::isObject($other); case 'array': return self::isArray($other); case 'binData': @@ -137,14 +140,50 @@ private static function isArray($other) : bool return true; } + // Serializable can produce an array or object, so recurse on its output + if ($other instanceof Serializable) { + return self::isArray($other->bsonSerialize()); + } + if (! is_array($other)) { return false; } - if (empty($other)) { + // Empty and indexed arrays serialize as BSON arrays + return self::isArrayEmptyOrIndexed($other); + } + + private static function isObject($other) : bool + { + if ($other instanceof BSONDocument) { + return true; + } + + // Serializable can produce an array or object, so recurse on its output + if ($other instanceof Serializable) { + return self::isObject($other->bsonSerialize()); + } + + // Non-empty, associative arrays serialize as BSON objects + if (is_array($other)) { + return ! self::isArrayEmptyOrIndexed($other); + } + + if (! is_object($other)) { + return false; + } + + /* Serializable has already been handled, so any remaining instances of + * Type will not serialize as BSON objects */ + return ! $other instanceof Type; + } + + private static function isArrayEmptyOrIndexed(array $a) : bool + { + if (empty($a)) { return true; } - return array_keys($other) === range(0, count($other) - 1); + return array_keys($a) === range(0, count($a) - 1); } } diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php index 7812c8b9d..15062f3d9 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php @@ -9,6 +9,7 @@ use MongoDB\BSON\MinKey; use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; +use MongoDB\BSON\Serializable; use MongoDB\BSON\Timestamp; use MongoDB\BSON\UTCDateTime; use MongoDB\Model\BSONArray; @@ -42,9 +43,11 @@ public function provideTypes() return [ 'double' => ['double', 1.4], 'string' => ['string', 'foo'], + // Note: additional tests in testTypeObject 'object(stdClass)' => ['object', new stdClass()], 'object(BSONDocument)' => ['object', new BSONDocument()], - 'array(array)' => ['array', ['foo']], + // Note: additional tests tests in testTypeArray + 'array(indexed array)' => ['array', ['foo']], 'array(BSONArray)' => ['array', new BSONArray()], 'binData' => ['binData', new Binary('', 0)], 'undefined' => ['undefined', $undefined->undefined], @@ -82,32 +85,71 @@ public function testTypeArray() { $c = new IsBsonType('array'); - $this->assertFalse($c->evaluate(1, '', true)); - $this->assertFalse($c->evaluate(['x' => 1], '', true)); - $this->assertFalse($c->evaluate([0 => 'a', 2 => 'c'], '', true)); + $this->assertResult(true, $c, [], 'empty array is array'); + $this->assertResult(true, $c, ['foo'], 'indexed array is array'); + $this->assertResult(true, $c, new BSONArray(), 'BSONArray is array'); + $this->assertResult(true, $c, new SerializableArray(), 'SerializableArray is array'); + + $this->assertResult(false, $c, 1, 'integer is not array'); + $this->assertResult(false, $c, ['x' => 1], 'associative array is not array'); + $this->assertResult(false, $c, new BSONDocument(), 'BSONDocument is not array'); + $this->assertResult(false, $c, new SerializableObject(), 'SerializableObject is not array'); } public function testTypeObject() { $c = new IsBsonType('object'); - $this->assertFalse($c->evaluate(1, '', true)); - $this->assertFalse($c->evaluate(new BSONArray(), '', true)); + $this->assertResult(true, $c, new stdClass(), 'stdClass is object'); + $this->assertResult(true, $c, new BSONDocument(), 'BSONDocument is object'); + $this->assertResult(true, $c, ['x' => 1], 'associative array is object'); + $this->assertResult(true, $c, new SerializableObject(), 'SerializableObject is object'); + + $this->assertResult(false, $c, 1, 'integer is not object'); + $this->assertResult(false, $c, [], 'empty array is not object'); + $this->assertResult(false, $c, ['foo'], 'indexed array is not object'); + $this->assertResult(false, $c, new BSONArray(), 'BSONArray is not object'); + $this->assertResult(false, $c, new SerializableArray(), 'SerializableArray is not object'); + $this->assertResult(false, $c, new ObjectId(), 'Type other than Serializable is not object'); } public function testTypeJavascript() { $c = new IsBsonType('javascript'); - $this->assertFalse($c->evaluate(1, '', true)); - $this->assertFalse($c->evaluate(new Javascript('foo = 1;', ['x' => 1]), '', true)); + $this->assertResult(false, $c, 1, 'integer is not javascript'); + $this->assertResult(false, $c, new Javascript('foo = 1;', ['x' => 1]), 'javascriptWithScope is not javascript'); } public function testTypeJavascriptWithScope() { $c = new IsBsonType('javascriptWithScope'); - $this->assertFalse($c->evaluate(1, '', true)); - $this->assertFalse($c->evaluate(new Javascript('foo = 1;'), '', true)); + $this->assertResult(false, $c, 1, 'integer is not javascriptWithScope'); + $this->assertResult(false, $c, new Javascript('foo = 1;'), 'javascript is not javascriptWithScope'); + } + + private function assertResult($expected, IsBsonType $constraint, $value, $message) + { + $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); + } +} + +// phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses +// phpcs:disable Squiz.Classes.ClassFileName.NoMatch +class SerializableArray implements Serializable +{ + public function bsonSerialize() + { + return ['foo']; + } +} + +class SerializableObject implements Serializable +{ + public function bsonSerialize() + { + return ['x' => 1]; } } +// phpcs:enable diff --git a/tests/UnifiedSpecTests/Constraint/Match.php b/tests/UnifiedSpecTests/Constraint/Match.php deleted file mode 100644 index 4f8a16b06..000000000 --- a/tests/UnifiedSpecTests/Constraint/Match.php +++ /dev/null @@ -1,470 +0,0 @@ -value = self::prepare($value, true); - $this->entityMap = $entityMap ?? new EntityMap(); - $this->ignoreExtraKeysInRoot = $ignoreExtraKeysInRoot; - $this->ignoreExtraKeysInEmbedded = $ignoreExtraKeysInEmbedded; - $this->comparatorFactory = Factory::getInstance(); - } - - public function evaluate($other, $description = '', $returnResult = false) - { - $other = self::prepare($other, true); - $success = false; - $this->lastFailure = null; - - try { - $this->assertMatches($this->value, $other, $this->ignoreExtraKeysInRoot); - $success = true; - } catch (RuntimeException $e) { - $this->lastFailure = new ComparisonFailure( - $this->value, - $other, - $this->exporter()->export($this->value), - $this->exporter()->export($other), - false, - $e->getMessage() - ); - } - - if ($returnResult) { - return $success; - } - - if (! $success) { - $this->fail($other, $description, $this->lastFailure); - } - } - - private function assertEquals($expected, $actual, string $keyPath) - { - $expectedType = is_object($expected) ? get_class($expected) : gettype($expected); - $actualType = is_object($actual) ? get_class($actual) : gettype($actual); - - // Workaround for ObjectComparator printing the whole actual object - if ($expectedType !== $actualType) { - throw new ComparisonFailure( - $expected, - $actual, - '', - '', - false, - sprintf( - '%s%s is not instance of expected type "%s".', - empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath), - $this->exporter()->shortenedExport($actual), - $expectedType - ) - ); - } - - try { - $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual); - } catch (ComparisonFailure $failure) { - throw new ComparisonFailure( - $expected, - $actual, - '', - '', - false, - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)) . $failure->getMessage() - ); - } - } - - /** - * Compares two BSON values recursively. - * - * @param mixed $expected - * @param mixed $actual - * @param bool $ignoreExtraKeys - * @param string $keyPath - * @throws RuntimeException if the documents do not match - */ - private function assertMatches($expected, $actual, bool $ignoreExtraKeys, $keyPath = '') - { - if (! $expected instanceof BSONDocument && ! $expected instanceof BSONArray) { - $this->assertEquals($expected, $actual, $keyPath); - - return; - } - - if ($expected instanceof BSONArray) { - if (! $actual instanceof BSONArray) { - throw new RuntimeException(sprintf( - '%s%s is not instance of expected class "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - BSONArray::class - )); - } - - if (count($expected) !== count($actual)) { - throw new RuntimeException(sprintf( - '%s%s has %d elements instead of %d expected', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - count($actual), - count($expected) - )); - } - - foreach ($expected as $key => $expectedValue) { - $this->assertMatches( - $expectedValue, - $actual[$key], - $this->ignoreExtraKeysInEmbedded, - (empty($keyPath) ? $key : $keyPath . '.' . $key) - ); - } - - return; - } - - if ($expected instanceof BSONDocument) { - if (self::isSpecialOperator($expected)) { - $operator = self::getSpecialOperator($expected); - - // TODO: Validate structure of operators - if ($operator === '$$type') { - $types = is_string($expected['$$type']) ? [$expected['$$type']] : $expected['$$type']; - $constraints = []; - - foreach ($types as $type) { - $constraints[] = new IsBsonType($type); - } - - $constraint = LogicalOr::fromConstraints(...$constraints); - - if (! $constraint->evaluate($actual, '', true)) { - throw new RuntimeException(sprintf( - '%s%s is not an expected type: %s', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - implode(', ', $types) - )); - } - - return; - } - - if ($operator === '$$matchesEntity') { - $entityMap = $this->getEntityMap(); - - $this->assertMatches( - $entityMap[$expected['$$matchesEntity']], - $actual, - $ignoreExtraKeys, - $keyPath - ); - - return; - } - - if ($operator === '$$matchesHexBytes') { - if (! is_resource($actual) || get_resource_type($actual) != "stream") { - throw new RuntimeException(sprintf( - '%s%s is not a stream', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - )); - } - - if (stream_get_contents($actual, -1, 0) !== hex2bin($expected['$$matchesHexBytes'])) { - throw new RuntimeException(sprintf( - '%s%s does not match expected hex bytes: %s', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - $expected['$$matchesHexBytes'] - )); - } - - return; - } - - if ($operator === '$$unsetOrMatches') { - /* If the operator is used at the top level, consider null - * values for $actual to be unset. If the operator is nested - * this check is done later document iteration. */ - if ($keyPath === '' && $actual === null) { - return; - } - - $this->assertMatches( - $expected['$$unsetOrMatches'], - $actual, - $ignoreExtraKeys, - $keyPath - ); - - return; - } - - if ($operator === '$$sessionLsid') { - $entityMap = $this->getEntityMap(); - $session = $entityMap['$$sessionLsid']; - - if (! $session instanceof Session) { - throw new RuntimeException(sprintf( - '%sentity "%s" is not a session', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $entityMap['$$sessionLsid'], - )); - } - - $this->assertMatches( - $this->prepare($session->getLogicalSessionId(), true), - $actual, - false, /* LSID document should match exactly */ - $keyPath - ); - } - - throw new LogicException('unsupported operator: ' . $operator); - } - - if (! $actual instanceof BSONDocument) { - throw new RuntimeException(sprintf( - '%s%s is not instance of expected class "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - BSONDocument::class - )); - } - - foreach ($expected as $key => $expectedValue) { - $actualKeyExists = $actual->offsetExists($key); - - if ($expectedValue instanceof BSONDocument && self::isSpecialOperator($expectedValue)) { - $operator = self::getSpecialOperator($expectedValue); - - if ($operator === '$$exists') { - if (! is_bool($expectedValue['$$exists'])) { - throw new RuntimeException('$$exists is malformed'); - } - - if ($expectedValue['$$exists'] && ! $actualKeyExists) { - throw new RuntimeException(sprintf( - '%s%s does not have expected key "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - $key - )); - } - - if (! $expectedValue['$$exists'] && $actualKeyExists) { - throw new RuntimeException(sprintf( - '%s%s has unexpected key "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $this->exporter()->shortenedExport($actual), - $key - )); - } - - continue; - } - - if ($operator === '$$unsetOrMatches' && ! $actualKeyExists) { - continue; - } - } - - if (! $actualKeyExists) { - throw new RuntimeException(sprintf( - '%s$actual does not have expected key "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $key - )); - } - - $this->assertMatches( - $expectedValue, - $actual[$key], - $this->ignoreExtraKeysInEmbedded, - (empty($keyPath) ? $key : $keyPath . '.' . $key) - ); - } - - if ($ignoreExtraKeys) { - return; - } - - foreach ($actual as $key => $_) { - if (! $expected->offsetExists($key)) { - throw new RuntimeException(sprintf( - '%s$actual has extra key "%s"', - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)), - $key - )); - } - } - - return; - } - - throw new LogicException('should not reach this point'); - } - - private function doAdditionalFailureDescription($other) - { - if ($this->lastFailure === null) { - return ''; - } - - return $this->lastFailure->getMessage(); - } - - private function doFailureDescription($other) - { - return 'expected value matches actual value'; - } - - private function doMatches($other) - { - $other = self::prepare($other, true); - - try { - $this->assertEquals($this->value, $other, $this->ignoreExtraKeysInRoot); - } catch (RuntimeException $e) { - return false; - } - - return true; - } - - private function doToString() - { - return 'matches ' . $this->exporter()->export($this->value); - } - - private static function getSpecialOperator(BSONDocument $document) : string - { - foreach ($document as $key => $_) { - if (strpos((string) $key, '$$') === 0) { - return $key; - } - } - - throw new LogicException('should not reach this point'); - } - - private static function isSpecialOperator(BSONDocument $document) : bool - { - if (count($document) !== 1) { - return false; - } - - foreach ($document as $key => $_) { - return strpos((string) $key, '$$') === 0; - } - - throw new LogicException('should not reach this point'); - } - - /** - * Prepare a value for comparison. - * - * If the value is an array or object, it will be converted to a BSONArray - * or BSONDocument. If $value is an array and $isRoot is true, it will be - * converted to a BSONDocument; otherwise, it will be converted to a - * BSONArray or BSONDocument based on its keys. Each value within an array - * or document will then be prepared recursively. - * - * @param mixed $bson - * @param boolean $isRoot If true, convert an array to a BSONDocument - */ - private static function prepare($bson, bool $isRoot) - { - if (! is_array($bson) && ! is_object($bson)) { - return $bson; - } - - /* Convert Int64 objects to integers on 64-bit platforms for - * compatibility reasons. */ - if ($bson instanceof Int64 && PHP_INT_SIZE != 4) { - return (int) ((string) $bson); - } - - // TODO: ignore Serializable if needed - if ($bson instanceof Type) { - return $bson; - } - - if ($isRoot && is_array($bson)) { - $bson = new BSONDocument($bson); - } - - if (is_array($bson) && $bson === array_values($bson)) { - $bson = new BSONArray($bson); - } - - if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) { - // TODO: determine if (array) cast is needed - $bson = new BSONDocument($bson); - } - - foreach ($bson as $key => $value) { - if (is_array($value) || is_object($value)) { - $bson[$key] = self::prepare($value, false); - } - } - - return $bson; - } -} diff --git a/tests/UnifiedSpecTests/Constraint/MatchTest.php b/tests/UnifiedSpecTests/Constraint/MatchTest.php deleted file mode 100644 index 0c26e2dd6..000000000 --- a/tests/UnifiedSpecTests/Constraint/MatchTest.php +++ /dev/null @@ -1,138 +0,0 @@ - 1, 'y' => ['a' => 1, 'b' => 2]], true, false); - - $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); - $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); - $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are permitted'); - $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are not permitted'); - $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); - - // Arrays are always interpreted as root documents - $c = new Match([1, ['a' => 1]], true, false); - - $this->assertResult(false, $c, [1, 2], 'Incorrect value'); - $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); - $this->assertResult(true, $c, [1, ['a' => 1], 3], 'Extra keys in root are permitted'); - $this->assertResult(false, $c, [1, ['a' => 1, 'b' => 2]], 'Extra keys in embedded are not permitted'); - } - - public function testIgnoreExtraKeysInEmbedded() - { - $c = new Match(['x' => 1, 'y' => ['a' => 1, 'b' => 2]], false, true); - - $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); - $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 3]], 'Incorrect value'); - $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); - $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are not permitted'); - $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are permitted'); - $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded Key order is not significant'); - - // Arrays are always interpreted as root documents - $c = new Match([1, ['a' => 1]], false, true); - - $this->assertResult(false, $c, [1, 2], 'Incorrect value'); - $this->assertResult(true, $c, [1, ['a' => 1]], 'Exact match'); - $this->assertResult(false, $c, [1, ['a' => 1], 3], 'Extra keys in root are not permitted'); - $this->assertResult(true, $c, [1, ['a' => 1, 'b' => 2]], 'Extra keys in embedded are permitted'); - $this->assertResult(false, $c, [1, ['a' => 2]], 'Keys must have the correct value'); - } - - public function testSpecialOperatorExists() - { - $c = new Match(['x' => ['$$exists' => true]]); - $this->assertResult(true, $c, ['x' => '1'], 'top-level $$exists:true and field exists'); - $this->assertResult(false, $c, [], 'top-level $$exists:true and field missing'); - - $c = new Match(['x' => ['$$exists' => false]]); - $this->assertResult(false, $c, ['x' => '1'], 'top-level $$exists:false and field exists'); - $this->assertResult(true, $c, [], 'top-level $$exists:false and field missing'); - - $c = new Match(['x' => ['y' => ['$$exists' => true]]]); - $this->assertResult(true, $c, ['x' => ['y' => '1']], 'nested $$exists:true and field exists'); - $this->assertResult(false, $c, ['x' => (object) []], 'nested $$exists:true and field missing'); - - $c = new Match(['x' => ['y' => ['$$exists' => false]]]); - $this->assertResult(false, $c, ['x' => ['y' => 1]], 'nested $$exists:false and field exists'); - $this->assertResult(true, $c, ['x' => (object) []], 'nested $$exists:false and field missing'); - } - - public function testSpecialOperatorType() - { - $c = new Match(['x' => ['$$type' => 'string']]); - - $this->assertResult(true, $c, ['x' => 'foo'], '$$type:string matches string'); - $this->assertResult(false, $c, ['x' => 1], '$$type:string does not match int'); - - $c = new Match(['x' => ['$$type' => ['string', 'bool']]]); - - $this->assertResult(true, $c, ['x' => 'foo'], '$$type:[string,bool] matches string'); - $this->assertResult(true, $c, ['x' => true], '$$type:[string,bool] matches bool'); - $this->assertResult(false, $c, ['x' => 1], '$$type:[string,bool] does not match int'); - } - - /** - * @dataProvider errorMessageProvider - */ - public function testErrorMessages($expectedMessagePart, Match $constraint, $actualValue) - { - try { - $constraint->evaluate($actualValue); - $this->fail('Expected a comparison failure'); - } catch (ExpectationFailedException $failure) { - $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $failure->getMessage()); - $this->assertStringContainsString($expectedMessagePart, $failure->getMessage()); - } - } - - public function errorMessageProvider() - { - return [ - 'Root type mismatch' => [ - 'MongoDB\Model\BSONArray Object (...) is not instance of expected class "MongoDB\Model\BSONDocument"', - new Match(['foo' => 'bar']), - new BSONArray(['foo' => 'bar']), - ], - 'Missing key' => [ - 'Field path "foo": $actual does not have expected key "bar"', - new Match(['foo' => ['bar' => 'baz']]), - ['foo' => ['foo' => 'bar']], - ], - 'Extra key' => [ - 'Field path "foo": $actual has extra key "foo"', - new Match(['foo' => ['bar' => 'baz']]), - ['foo' => ['foo' => 'bar', 'bar' => 'baz']], - ], - 'Scalar value not equal' => [ - 'Field path "foo": Failed asserting that two strings are equal.', - new Match(['foo' => 'bar']), - ['foo' => 'baz'], - ], - 'Scalar type mismatch' => [ - 'Field path "foo": \'42\' is not instance of expected type "integer".', - new Match(['foo' => 42]), - ['foo' => '42'], - ], - 'Type mismatch' => [ - 'Field path "foo": MongoDB\Model\BSONDocument Object (...) is not instance of expected class "MongoDB\Model\BSONArray"', - new Match(['foo' => ['bar']]), - ['foo' => (object) ['bar']], - ], - ]; - } - - private function assertResult($expectedResult, Match $constraint, $value, $message) - { - $this->assertSame($expectedResult, $constraint->evaluate($value, '', true), $message); - } -} diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php new file mode 100644 index 000000000..29d908b08 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -0,0 +1,422 @@ +value = self::prepare($value); + $this->entityMap = $entityMap ?? new EntityMap(); + $this->comparatorFactory = Factory::getInstance(); + } + + public function evaluate($other, $description = '', $returnResult = false) + { + $other = self::prepare($other); + $success = false; + $this->lastFailure = null; + + try { + $this->assertMatches($this->value, $other); + $success = true; + } catch (RuntimeException $e) { + $this->lastFailure = new ComparisonFailure( + $this->value, + $other, + $this->exporter()->export($this->value), + $this->exporter()->export($other), + false, + $e->getMessage() + ); + } + + if ($returnResult) { + return $success; + } + + if (! $success) { + $this->fail($other, $description, $this->lastFailure); + } + } + + private function assertEquals($expected, $actual, string $keyPath) + { + $expectedType = is_object($expected) ? get_class($expected) : gettype($expected); + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + + // Workaround for ObjectComparator printing the whole actual object + if ($expectedType !== $actualType) { + self::failAt(sprintf('%s is not expected type "%s"', $actualType, $expectedType), $keyPath); + } + + try { + $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual); + } catch (ComparisonFailure $failure) { + throw new ComparisonFailure( + $expected, + $actual, + // No diff is required + '', + '', + false, + (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)) . $failure->getMessage() + ); + } + } + + private function assertMatches($expected, $actual, string $keyPath = '') + { + if ($expected instanceof BSONArray) { + $this->assertMatchesArray($expected, $actual, $keyPath); + + return; + } + + if ($expected instanceof BSONDocument) { + $this->assertMatchesDocument($expected, $actual, $keyPath); + + return; + } + + $this->assertEquals($expected, $actual, $keyPath); + } + + private function assertMatchesArray(BSONArray $expected, $actual, string $keyPath) + { + if (! $actual instanceof BSONArray) { + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONArray::class), $keyPath); + } + + if (count($expected) !== count($actual)) { + self::failAt(sprintf('$actual count is %d, expected %d', count($actual), count($expected)), $keyPath); + } + + foreach ($expected as $key => $expectedValue) { + $this->assertMatches( + $expectedValue, + $actual[$key], + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); + } + } + + private function assertMatchesDocument(BSONDocument $expected, $actual, string $keyPath) + { + if (self::isOperator($expected)) { + $this->assertMatchesOperator($expected, $actual, $keyPath); + + return; + } + + if (! $actual instanceof BSONDocument) { + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONDocument::class), $keyPath); + } + + foreach ($expected as $key => $expectedValue) { + $actualKeyExists = $actual->offsetExists($key); + + if ($expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) { + $operatorName = self::getOperatorName($expectedValue); + + // TODO: Validate structure of operators + if ($operatorName === '$$exists') { + if ($expectedValue['$$exists'] && ! $actualKeyExists) { + self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath); + } + + if (! $expectedValue['$$exists'] && $actualKeyExists) { + self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath); + } + + continue; + } + + if ($operatorName === '$$unsetOrMatches') { + if (! $actualKeyExists) { + continue; + } + + $expectedValue = $expectedValue['$$unsetOrMatches']; + } + } + + if (! $actualKeyExists) { + self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath); + } + + $this->assertMatches( + $expectedValue, + $actual[$key], + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); + } + + // Ignore extra fields in root documents + if (empty($keyPath)) { + return; + } + + foreach ($actual as $key => $_) { + if (! $expected->offsetExists($key)) { + self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath); + } + } + } + + private function assertMatchesOperator(BSONDocument $operator, $actual, string $keyPath) + { + $name = self::getOperatorName($operator); + + // TODO: Validate structure of operators + if ($name === '$$type') { + $types = is_string($operator['$$type']) ? [$operator['$$type']] : $operator['$$type']; + $constraints = []; + + foreach ($types as $type) { + $constraints[] = new IsBsonType($type); + } + + $constraint = LogicalOr::fromConstraints(...$constraints); + + if (! $constraint->evaluate($actual, '', true)) { + self::failAt(sprintf('%s is not an expected BSON type: %s', $this->exporter()->shortenedExport($actual), implode(', ', $types)), $keyPath); + } + + return; + } + + if ($name === '$$matchesEntity') { + $this->assertMatches( + $this->prepare($this->entityMap[$operator['$$matchesEntity']]), + $actual, + $keyPath + ); + + return; + } + + if ($name === '$$matchesHexBytes') { + if (! is_resource($actual) || get_resource_type($actual) != "stream") { + self::failAt(sprintf('%s is not a stream', $this->exporter()->shortenedExport($actual)), $keyPath); + } + + if (stream_get_contents($actual, -1, 0) !== hex2bin($operator['$$matchesHexBytes'])) { + self::failAt(sprintf('%s does not match expected hex bytes: %s', $this->exporter()->shortenedExport($actual), $operator['$$matchesHexBytes']), $keyPath); + } + + return; + } + + if ($name === '$$unsetOrMatches') { + /* If the operator is used at the top level, consider null + * values for $actual to be unset. If the operator is nested + * this check is done later document iteration. */ + if ($keyPath === '' && $actual === null) { + return; + } + + $this->assertMatches( + self::prepare($operator['$$unsetOrMatches']), + $actual, + $keyPath + ); + + return; + } + + if ($name === '$$sessionLsid') { + $session = $this->entityMap[$operator['$$sessionLsid']]; + + if (! $session instanceof Session) { + self::failAt(sprintf('entity "%s" is not a session', $operator['$$sessionLsid']), $keyPath); + } + + $this->assertEquals( + self::prepare($session->getLogicalSessionId()), + $actual, + $keyPath + ); + + return; + } + + throw new LogicException('unsupported operator: ' . $operator); + } + + /** @see ConstraintTrait */ + private function doAdditionalFailureDescription($other) + { + if ($this->lastFailure === null) { + return ''; + } + + return $this->lastFailure->getMessage(); + } + + /** @see ConstraintTrait */ + private function doFailureDescription($other) + { + return 'expected value matches actual value'; + } + + /** @see ConstraintTrait */ + private function doMatches($other) + { + $other = self::prepare($other); + + try { + $this->assertMatches($this->value, $other); + } catch (RuntimeException $e) { + return false; + } + + return true; + } + + /** @see ConstraintTrait */ + private function doToString() + { + return 'matches ' . $this->exporter()->export($this->value); + } + + private static function failAt(string $message, string $keyPath) + { + $prefix = empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath); + + throw new RuntimeException($prefix . $message); + } + + private static function getOperatorName(BSONDocument $document) : string + { + foreach ($document as $key => $_) { + if (strpos((string) $key, '$$') === 0) { + return $key; + } + } + + throw new LogicException('should not reach this point'); + } + + private static function isOperator(BSONDocument $document) : bool + { + if (count($document) !== 1) { + return false; + } + + foreach ($document as $key => $_) { + return strpos((string) $key, '$$') === 0; + } + + throw new LogicException('should not reach this point'); + } + + /** + * Prepare a value for comparison. + * + * If the value is an array or object, it will be converted to a BSONArray + * or BSONDocument. If $value is an array and $isRoot is true, it will be + * converted to a BSONDocument; otherwise, it will be converted to a + * BSONArray or BSONDocument based on its keys. Each value within an array + * or document will then be prepared recursively. + * + * @param mixed $bson + * @return mixed + */ + private static function prepare($bson) + { + if (! is_array($bson) && ! is_object($bson)) { + return $bson; + } + + /* Convert Int64 objects to integers on 64-bit platforms for + * compatibility reasons. */ + if ($bson instanceof Int64 && PHP_INT_SIZE != 4) { + return (int) ((string) $bson); + } + + // Serializable can produce an array or object, so recurse on its output + if ($bson instanceof Serializable) { + return self::prepare($bson->bsonSerialize()); + } + + /* Serializable has already been handled, so any remaining instances of + * Type will not serialize as BSON arrays or objects */ + if ($bson instanceof Type) { + return $bson; + } + + if (is_array($bson) && self::isArrayEmptyOrIndexed($bson)) { + $bson = new BSONArray($bson); + } + + if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) { + $bson = new BSONDocument($bson); + } + + foreach ($bson as $key => $value) { + if (is_array($value) || is_object($value)) { + $bson[$key] = self::prepare($value); + } + } + + return $bson; + } + + private static function isArrayEmptyOrIndexed(array $a) : bool + { + if (empty($a)) { + return true; + } + + return array_keys($a) === range(0, count($a) - 1); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php new file mode 100644 index 000000000..a3bda024c --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -0,0 +1,257 @@ + 1, 'y' => ['a' => 1, 'b' => 2]]); + + $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are permitted'); + $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are not permitted'); + $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); + } + + public function testOperatorExists() + { + $c = new Matches(['x' => ['$$exists' => true]]); + $this->assertResult(true, $c, ['x' => '1'], 'root-level key exists'); + $this->assertResult(false, $c, new stdClass(), 'root-level key missing'); + $this->assertResult(true, $c, ['x' => '1', 'y' => 1], 'root-level key exists (extra key)'); + $this->assertResult(false, $c, ['y' => 1], 'root-level key missing (extra key)'); + + $c = new Matches(['x' => ['$$exists' => false]]); + $this->assertResult(false, $c, ['x' => '1'], 'root-level key exists'); + $this->assertResult(true, $c, new stdClass(), 'root-level key missing'); + $this->assertResult(false, $c, ['x' => '1', 'y' => 1], 'root-level key exists (extra key)'); + $this->assertResult(true, $c, ['y' => 1], 'root-level key missing (extra key)'); + + $c = new Matches(['x' => ['y' => ['$$exists' => true]]]); + $this->assertResult(true, $c, ['x' => ['y' => '1']], 'embedded key exists'); + $this->assertResult(false, $c, ['x' => new stdClass()], 'embedded key missing'); + + $c = new Matches(['x' => ['y' => ['$$exists' => false]]]); + $this->assertResult(false, $c, ['x' => ['y' => 1]], 'embedded key exists'); + $this->assertResult(true, $c, ['x' => new stdClass()], 'embedded key missing'); + } + + public function testOperatorType() + { + $c = new Matches(['x' => ['$$type' => 'string']]); + + $this->assertResult(true, $c, ['x' => 'foo'], 'string matches string type'); + $this->assertResult(false, $c, ['x' => 1], 'integer does not match string type'); + + $c = new Matches(['x' => ['$$type' => ['string', 'bool']]]); + + $this->assertResult(true, $c, ['x' => 'foo'], 'string matches [string,bool] type'); + $this->assertResult(true, $c, ['x' => true], 'bool matches [string,bool] type'); + $this->assertResult(false, $c, ['x' => 1], 'integer does not match [string,bool] type'); + } + + public function testOperatorMatchesEntity() + { + $entityMap = new EntityMap(); + $entityMap['integer'] = 1; + $entityMap['object'] = ['y' => 1]; + + $c = new Matches(['x' => ['$$matchesEntity' => 'integer']], $entityMap); + + $this->assertResult(true, $c, ['x' => 1], 'value matches integer entity (embedded)'); + $this->assertResult(false, $c, ['x' => 2], 'value does not match integer entity (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1]], 'value does not match integer entity (embedded)'); + + $c = new Matches(['x' => ['$$matchesEntity' => 'object']], $entityMap); + + $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches object entity (embedded)'); + $this->assertResult(false, $c, ['x' => 1], 'value does not match object entity (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (embedded)'); + + $c = new Matches(['$$matchesEntity' => 'object'], $entityMap); + + $this->assertResult(true, $c, ['y' => 1], 'value matches object entity (root-level)'); + $this->assertResult(true, $c, ['x' => 2, 'y' => 1], 'value matches object entity (root-level)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (root-level)'); + + $c = new Matches(['$$matchesEntity' => 'undefined'], $entityMap); + + $this->assertResult(false, $c, 'undefined', 'value does not match undefined entity (root-level)'); + + $c = new Matches(['x' => ['$$matchesEntity' => 'undefined']], $entityMap); + + $this->assertResult(false, $c, ['x' => 'undefined'], 'value does not match undefined entity (embedded)'); + } + + public function testOperatorMatchesHexBytes() + { + $stream1 = fopen('php://temp', 'w+b'); + fwrite($stream1, hex2bin('DEADBEEF')); + rewind($stream1); + + $stream2 = fopen('php://temp', 'w+b'); + fwrite($stream2, hex2bin('90ABCDEF')); + rewind($stream2); + + $c = new Matches(['$$matchesHexBytes' => 'DEADBEEF']); + + $this->assertResult(true, $c, $stream1, 'value matches hex bytes (root-level)'); + $this->assertResult(false, $c, $stream2, 'value does not match hex bytes (root-level)'); + $this->assertResult(false, $c, 1, 'value is not a stream'); + + $c = new Matches(['x' => ['$$matchesHexBytes' => '90ABCDEF']]); + + $this->assertResult(true, $c, ['x' => $stream2], 'value matches hex bytes (embedded)'); + $this->assertResult(false, $c, ['x' => $stream1], 'value does not match hex bytes (embedded)'); + $this->assertResult(false, $c, ['x' => 1], 'value is not a stream'); + } + + public function testOperatorUnsetOrMatches() + { + $c = new Matches(['$$unsetOrMatches' => ['x' => 1]]); + + $this->assertResult(true, $c, null, 'null value is considered unset (root-level)'); + $this->assertResult(true, $c, ['x' => 1], 'value matches (root-level)'); + $this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'value matches (root-level)'); + $this->assertResult(false, $c, ['x' => 2], 'value does not match (root-level)'); + + $c = new Matches(['x' => ['$$unsetOrMatches' => ['y' => 1]]]); + + $this->assertResult(true, $c, new stdClass(), 'missing value is considered unset (embedded)'); + $this->assertResult(false, $c, ['x' => null], 'null value is not considered unset (embedded)'); + $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match (embedded)'); + } + + public function testOperatorSessionLsid() + { + if (version_compare($this->getFeatureCompatibilityVersion(), '3.6', '<')) { + $this->markTestSkipped('startSession() is only supported on FCV 3.6 or higher'); + } + + $session = $this->manager->startSession(); + + $entityMap = new EntityMap(); + $entityMap['session'] = $session; + + $lsidWithWrongId = ['id' => new Binary('0123456789ABCDEF', Binary::TYPE_UUID)]; + $lsidWithExtraField = (array) $session->getLogicalSessionId() + ['y' => 1]; + + $c = new Matches(['$$sessionLsid' => 'session'], $entityMap); + + $this->assertResult(true, $c, $session->getLogicalSessionId(), 'session LSID matches (root-level)'); + $this->assertResult(false, $c, $lsidWithWrongId, 'session LSID does not match (root-level)'); + $this->assertResult(false, $c, $lsidWithExtraField, 'session LSID does not match (root-level)'); + $this->assertResult(false, $c, 1, 'session LSID does not match (root-level)'); + + $c = new Matches(['x' => ['$$sessionLsid' => 'session']], $entityMap); + + $this->assertResult(true, $c, ['x' => $session->getLogicalSessionId()], 'session LSID matches (embedded)'); + $this->assertResult(false, $c, ['x' => $lsidWithWrongId], 'session LSID does not match (embedded)'); + $this->assertResult(false, $c, ['x' => $lsidWithExtraField], 'session LSID does not match (embedded)'); + $this->assertResult(false, $c, ['x' => 1], 'session LSID does not match (embedded)'); + } + + /** + * @dataProvider errorMessageProvider + */ + public function testErrorMessages($expectedMessagePart, Matches $constraint, $actualValue) + { + try { + $constraint->evaluate($actualValue); + $this->fail('Expected a comparison failure'); + } catch (ExpectationFailedException $failure) { + $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $failure->getMessage()); + $this->assertStringContainsString($expectedMessagePart, $failure->getMessage()); + } + } + + public function errorMessageProvider() + { + return [ + 'assertEquals: type check (root-level)' => [ + 'string is not expected type "integer"', + new Matches(1), + '1', + ], + 'assertEquals: type check (embedded)' => [ + 'Field path "x": string is not expected type "integer"', + new Matches(['x' => 1]), + ['x' => '1'], + ], + 'assertEquals: comparison failure (root-level)' => [ + 'Failed asserting that two strings are equal.', + new Matches('foo'), + 'bar', + ], + 'assertEquals: comparison failure (embedded)' => [ + 'Field path "x": Failed asserting that two strings are equal.', + new Matches(['x' => 'foo']), + ['x' => 'bar'], + ], + 'assertMatchesArray: type check (root-level)' => [ + 'MongoDB\Model\BSONDocument is not instance of expected class "MongoDB\Model\BSONArray"', + new Matches([1, 2, 3]), + ['x' => 1], + ], + 'assertMatchesArray: type check (embedded)' => [ + 'Field path "x": integer is not instance of expected class "MongoDB\Model\BSONArray"', + new Matches(['x' => [1, 2, 3]]), + ['x' => 1], + ], + 'assertMatchesArray: count check (root-level)' => [ + '$actual count is 2, expected 3', + new Matches(['x' => [1, 2, 3]]), + ['x' => [1, 2]], + ], + 'assertMatchesArray: count check (embedded)' => [ + 'Field path "x": $actual count is 2, expected 3', + new Matches(['x' => [1, 2, 3]]), + ['x' => [1, 2]], + ], + 'assertMatchesDocument: type check (root-level)' => [ + 'integer is not instance of expected class "MongoDB\Model\BSONDocument"', + new Matches(['x' => 1]), + 1, + ], + 'assertMatchesDocument: type check (embedded)' => [ + 'Field path "x": integer is not instance of expected class "MongoDB\Model\BSONDocument"', + new Matches(['x' => ['y' => 1]]), + ['x' => 1], + ], + 'assertMatchesDocument: expected key missing (root-level)' => [ + '$actual does not have expected key "x"', + new Matches(['x' => 1]), + new stdClass(), + ], + 'assertMatchesDocument: expected key missing (embedded)' => [ + 'Field path "x": $actual does not have expected key "y"', + new Matches(['x' => ['y' => 1]]), + ['x' => new stdClass()], + ], + 'assertMatchesDocument: unexpected key present (embedded)' => [ + 'Field path "x": $actual has unexpected key "y', + new Matches(['x' => new stdClass()]), + ['x' => ['y' => 1]], + ], + ]; + } + + private function assertResult($expected, Matches $constraint, $value, $message) + { + $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); + } +} From 78839886e1fa70b6c9c6700ffbc401d2db7a1471 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 28 Sep 2020 21:00:09 +0800 Subject: [PATCH 07/41] Code fixes and fall back to assertInternalType for PHPUnit 6.x --- tests/UnifiedSpecTests/CollectionData.php | 6 +-- tests/UnifiedSpecTests/Context.php | 56 ++++++++++----------- tests/UnifiedSpecTests/EntityMap.php | 6 +-- tests/UnifiedSpecTests/EventObserver.php | 4 +- tests/UnifiedSpecTests/ExpectedError.php | 20 +++++--- tests/UnifiedSpecTests/ExpectedResult.php | 14 ++++-- tests/UnifiedSpecTests/Operation.php | 18 +++---- tests/UnifiedSpecTests/RunOnRequirement.php | 6 +-- 8 files changed, 71 insertions(+), 59 deletions(-) diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php index 4d7ab2ba7..e8ada0df0 100644 --- a/tests/UnifiedSpecTests/CollectionData.php +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -20,13 +20,13 @@ class CollectionData public function __construct(stdClass $o) { - assertIsString($o->collectionName); + assertInternalType('string', $o->collectionName); $this->collectionName = $o->collectionName; - assertIsString($o->databaseName); + assertInternalType('string', $o->databaseName); $this->databaseName = $o->databaseName; - assertIsArray($o->documents); + assertInternalType('array', $o->documents); assertContainsOnly('object', $o->documents); $this->documents = $o->documents; } diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 8715cbc85..8fc6c328d 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -54,16 +54,16 @@ public function __construct(Client $internalClient, string $uri) public function createEntities(array $entities) { foreach ($entities as $entity) { - assertIsObject($entity); + assertInternalType('object', $entity); $entity = (array) $entity; assertCount(1, $entity); $type = key($entity); $def = current($entity); - assertIsObject($def); + assertInternalType('object', $def); $id = $def->id ?? null; - assertIsString($id); + assertInternalType('string', $id); assertArrayNotHasKey($id, $this->entityMap); switch ($type) { @@ -98,17 +98,17 @@ public function getInternalClient(): Client public static function prepareOperationArguments(array $args): array { if (array_key_exists('readConcern', $args)) { - assertIsObject($args['readConcern']); + assertInternalType('object', $args['readConcern']); $args['readConcern'] = self::prepareReadConcern($args['readConcern']); } if (array_key_exists('readPreference', $args)) { - assertIsObject($args['readPreference']); + assertInternalType('object', $args['readPreference']); $args['readPreference'] = self::prepareReadPreference($args['readPreference']); } if (array_key_exists('session', $args)) { - assertIsString($args['session']); + assertInternalType('string', $args['session']); assertArrayHasKey($args['session'], $this->entityMap); $session = $this->entityMap[$args['session']]; assertInstanceOf(Session::class, $session); @@ -116,7 +116,7 @@ public static function prepareOperationArguments(array $args): array } if (array_key_exists('writeConcern', $args)) { - assertIsObject($args['writeConcern']); + assertInternalType('object', $args['writeConcern']); $args['writeConcern'] = self::prepareWriteConcern($args['writeConcern']); } @@ -155,7 +155,7 @@ private function createClient(stdClass $o): Client $uri = $this->uri; if (isset($useMultipleMongoses)) { - assertIsBool($useMultipleMongoses); + assertInternalType('bool', $useMultipleMongoses); if ($useMultipleMongoses) { self::requireMultipleMongoses($uri); @@ -167,7 +167,7 @@ private function createClient(stdClass $o): Client $uriOptions = []; if (isset($o->uriOptions)) { - assertIsObject($o->uriOptions); + assertInternalType('object', $o->uriOptions); /* TODO: If readPreferenceTags is set, assert it is an array of * strings and convert to an array of documents expected by the * PHP driver. */ @@ -175,8 +175,8 @@ private function createClient(stdClass $o): Client } if (isset($observeEvents)) { - assertIsArray($observeEvents); - assertIsArray($ignoreCommandMonitoringEvents); + assertInternalType('array', $observeEvents); + assertInternalType('array', $ignoreCommandMonitoringEvents); $this->eventObserversByClient[$o->id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents); } @@ -191,8 +191,8 @@ private function createCollection(stdClass $o): Collection $collectionName = $o->collectionName ?? null; $database = $o->database ?? null; - assertIsString($collectionName); - assertIsString($database); + assertInternalType('string', $collectionName); + assertInternalType('string', $database); assertArrayHasKey($database, $this->entityMap); $database = $this->entityMap[$database]; @@ -201,7 +201,7 @@ private function createCollection(stdClass $o): Collection $options = []; if (isset($o->collectionOptions)) { - assertIsObject($o->collectionOptions); + assertInternalType('object', $o->collectionOptions); $options = self::prepareCollectionOrDatabaseOptions((array) $o->collectionOptions); } @@ -215,8 +215,8 @@ private function createDatabase(stdClass $o): Database $databaseName = $o->databaseName ?? null; $client = $o->client ?? null; - assertIsString($databaseName); - assertIsString($client); + assertInternalType('string', $databaseName); + assertInternalType('string', $client); assertArrayHasKey($client, $this->entityMap); $client = $this->entityMap[$client]; @@ -225,7 +225,7 @@ private function createDatabase(stdClass $o): Database $options = []; if (isset($o->databaseOptions)) { - assertIsObject($o->databaseOptions); + assertInternalType('object', $o->databaseOptions); $options = self::prepareCollectionOrDatabaseOptions((array) $o->databaseOptions); } @@ -237,17 +237,17 @@ private static function prepareCollectionOrDatabaseOptions(array $options): arra self::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); if (array_key_exists('readConcern', $options)) { - assertIsObject($options['readConcern']); + assertInternalType('object', $options['readConcern']); $options['readConcern'] = self::createReadConcern($options['readConcern']); } if (array_key_exists('readPreference', $options)) { - assertIsObject($options['readPreference']); + assertInternalType('object', $options['readPreference']); $options['readPreference'] = self::createReadPreference($options['readPreference']); } if (array_key_exists('writeConcern', $options)) { - assertIsObject($options['writeConcern']); + assertInternalType('object', $options['writeConcern']); $options['writeConcern'] = self::createWriteConcern($options['writeConcern']); } @@ -259,7 +259,7 @@ private static function createReadConcern(stdClass $o): ReadConcern self::assertHasOnlyKeys($o, ['level']); $level = $o->level ?? null; - assertIsString($level); + assertInternalType('string', $level); return new ReadConcern($level); } @@ -273,22 +273,22 @@ private static function createReadPreference(stdClass $o): ReadPreference $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; $hedge = $o->hedge ?? null; - assertIsString($mode); + assertInternalType('string', $mode); if (isset($tagSets)) { - assertIsArray($tagSets); + assertInternalType('array', $tagSets); assertContains('object', $tagSets); } $options = []; if (isset($maxStalenessSeconds)) { - assertIsInt($maxStalenessSeconds); + assertInternalType('int', $maxStalenessSeconds); $options['maxStalenessSeconds'] = $maxStalenessSeconds; } if (isset($hedge)) { - assertIsObject($hedge); + assertInternalType('object', $hedge); $options['hedge'] = $hedge; } @@ -304,12 +304,12 @@ private static function createWriteConcern(stdClass $o): WriteConcern $journal = $o->journal ?? null; assertThat($w, logicalOr(new IsType('int'), new IsType('string'))); - assertIsInt($wtimeoutMS); + assertInternalType('int', $wtimeoutMS); $args = [$w, $wtimeoutMS]; if (isset($journal)) { - assertIsBool($journal); + assertInternalType('bool', $journal); $args[] = $journal; } @@ -333,7 +333,7 @@ private static function removeMultipleMongoses(string $uri): string $parts = parse_url($uri); - assertIsArray($parts); + assertInternalType('array', $parts); $hosts = explode(',', $parts['host']); diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index e57e233f0..b013957bb 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -27,7 +27,7 @@ public function __destruct() */ public function offsetExists($key) { - assertIsString($key); + assertInternalType('string', $key); return array_key_exists($key, $this->map); } @@ -37,7 +37,7 @@ public function offsetExists($key) */ public function offsetGet($key) { - assertIsString($key); + assertInternalType('string', $key); assertArrayHasKey($key, $this->map, sprintf('No entity is defined for "%s"', $key)); return $this->map[$key]; @@ -48,7 +48,7 @@ public function offsetGet($key) */ public function offsetSet($key, $value) { - assertIsString($key); + assertInternalType('string', $key); assertArrayNotHasKey($key, $this->map, sprintf('Entity already exists for key "%s" and cannot be replaced', $key)); $this->map[$key] = $value; diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 3ffd10f78..dbaace951 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -42,7 +42,7 @@ public function __construct(array $observeEvents, array $ignoreCommands) assertNotEmpty($observeEvents); foreach ($observeEvents as $event) { - assertIsString($event); + assertInternalType('string', $event); assertArrayHasKey($event, self::$commandMonitoringEvents); $this->observeEvents[self::$commandMonitoringEvents[$event]] = 1; } @@ -50,7 +50,7 @@ public function __construct(array $observeEvents, array $ignoreCommands) $this->ignoreCommands = array_fill_keys(self::$defaultIgnoreCommands, 1); foreach ($ignoreCommands as $command) { - assertIsString($command); + assertInternalType('string', $command); $this->ignoreCommands[$command] = 1; } } diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index f1d18ff6e..5223e26e6 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -39,33 +39,33 @@ private function __construct(stdClass $o = null) } if (isset($o->isClientError)) { - assertIsBool($o->isClientError); + assertInternalType('bool', $o->isClientError); $this->isClientError = $o->isClientError; } if (isset($o->errorContains)) { - assertIsString($o->errorContains); + assertInternalType('string', $o->errorContains); $this->messageContains = $o->errorContains; } if (isset($o->errorCode)) { - assertIsInt($o->errorCode); + assertInternalType('int', $o->errorCode); $this->code = $o->errorCode; } if (isset($o->errorCodeName)) { - assertIsString($o->errorCodeName); + assertInternalType('string', $o->errorCodeName); $this->codeName = $o->errorCodeName; } if (isset($o->errorLabelsContain)) { - assertIsArray($o->errorLabelsContain); + assertInternalType('array', $o->errorLabelsContain); assertContainsOnly('string', $o->errorLabelsContain); $o->includedLabels = $o->errorLabelsContain; } if (isset($o->errorLabelsOmit)) { - assertIsArray($o->errorLabelsOmit); + assertInternalType('array', $o->errorLabelsOmit); assertContainsOnly('string', $o->errorLabelsOmit); $o->excludedLabels = $o->errorLabelsOmit; } @@ -98,12 +98,16 @@ public static function fromOperation(stdClass $o): self */ public function assert(Throwable $e = null) { + if (! $this->isError && $e !== null) { + Assert::fail(sprintf("Operation threw unexpected %s: %s\n%s", get_class($e), $e->getMessage(), $e->getTraceAsString())); + } + if (! $this->isError) { - assertNull($e, sprintf("Operation threw unexpected %s: %s\n%s", get_class($e), $e->getMessage(), $e->getTraceAsString())); + assertNull($e); return; } - $assertNotNull($e); + assertNotNull($e); if (isset($this->messageContains)) { assertStringContainsStringIgnoringCase($this->messageContains, $e->getMessage()); diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php index a31ff3f18..53856ac69 100644 --- a/tests/UnifiedSpecTests/ExpectedResult.php +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -72,6 +72,14 @@ private function __construct($assertionType, $expectedValue) $this->expectedValue = $expectedValue; } + public static function fromOperation(stdClass $o): self + { + // TODO: Infer assertion type from operation + $expectedResult = new self(self::ASSERT_NOTHING, $o->expectResult); + + return $expectedResult; + } + /** * Assert that the result expectation matches the actual outcome. * @@ -79,7 +87,7 @@ private function __construct($assertionType, $expectedValue) * @param mixed $result Result (if any) from the actual outcome * @throws LogicException if the assertion type is unsupported */ - public function assert(FunctionalTestCase $test, $actual) + public function assert($actual) { $expected = $this->expectedValue; @@ -177,7 +185,7 @@ public function assert(FunctionalTestCase $test, $actual) break; case self::ASSERT_MATCHES_DOCUMENT: - $test->assertIsObject($expected); + $test->assertInternalType('object', $expected); $test->assertThat($actual, $test->logicalOr( $test->isType('array'), $test->isType('object') @@ -197,7 +205,7 @@ public function assert(FunctionalTestCase $test, $actual) break; case self::ASSERT_SAME_DOCUMENT: - $test->assertIsObject($expected); + $test->assertInternalType('object', $expected); $test->assertThat($actual, $test->logicalOr( $test->isType('array'), $test->isType('object') diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 934adc228..1af6ded06 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -43,14 +43,14 @@ final class Operation public function __construct(stdClass $o) { - assertIsString($o->name); + assertInternalType('string', $o->name); $this->name = $o->name; - assertIsString($o->object); + assertInternalType('string', $o->object); $this->object = $o->object; if (isset($o->arguments)) { - assertIsObject($o->arguments); + assertInternalType('object', $o->arguments); $this->arguments = (array) $o->arguments; } @@ -60,14 +60,14 @@ public function __construct(stdClass $o) logicalOr(objectHasAttribute('expectResult'), objectHasAttribute('saveResultAsEntity')) )); - $o->expectError = ExpectedError::fromOperation($o); + $this->expectError = ExpectedError::fromOperation($o); if (isset($o->expectResult)) { - $o->expectResult = ExpectedResult::fromOperation($o); + $this->expectResult = ExpectedResult::fromOperation($o); } if (isset($o->saveResultAsEntity)) { - assertIsString($o->saveResultAsEntity); + assertInternalType('string', $o->saveResultAsEntity); $this->saveResultAsEntity = $o->saveResultAsEntity; } } @@ -112,7 +112,7 @@ public function assert(Context $context, bool $rethrowExceptions = false) */ private function execute(Context $context) { - if ($this->object = self::OBJECT_TEST_RUNNER) { + if ($this->object == self::OBJECT_TEST_RUNNER) { return $this->executeForTestRunner($context); } @@ -120,7 +120,7 @@ private function execute(Context $context) assertArrayHasKey($this->object, $entityMap); $object = $entityMap[$this->object]; - assertIsObject($object); + assertInternalType('object', $object); switch (get_class($object)) { case Client::class: @@ -340,7 +340,7 @@ private function executeForDatabase(Database $database, Context $context) } } - private function executeForTestRunner(FunctionalTestCase $test, Context $context) + private function executeForTestRunner(Context $context) { $args = Context::prepareOperationArguments($this->arguments); diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index d58319330..c36f5aea5 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -25,19 +25,19 @@ class RunOnRequirement public function __construct(stdClass $o) { if (isset($o->minServerVersion)) { - assertIsString($o->minServerVersion); + assertInternalType('string', $o->minServerVersion); assertRegExp(self::VERSION_PATTERN, $o->minServerVersion); $this->minServerVersion = $o->minServerVersion; } if (isset($o->maxServerVersion)) { - assertIsString($o->maxServerVersion); + assertInternalType('string', $o->maxServerVersion); assertRegExp(self::VERSION_PATTERN, $o->maxServerVersion); $this->maxServerVersion = $o->maxServerVersion; } if (isset($o->topologies)) { - assertIsArray($o->topologies); + assertInternalType('array', $o->topologies); assertContainsOnly('string', $o->topologies); $this->topologies = $o->topologies; } From e8c609f2a4c459b5be0b246b09057ef024f592a2 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 28 Sep 2020 21:09:11 +0800 Subject: [PATCH 08/41] phpcs fixes --- tests/UnifiedSpecTests/CollectionData.php | 11 +++- .../Constraint/IsBsonType.php | 2 + .../Constraint/MatchesTest.php | 2 +- tests/UnifiedSpecTests/Context.php | 41 +++++++------ tests/UnifiedSpecTests/EntityMap.php | 4 +- tests/UnifiedSpecTests/EventObserver.php | 8 +++ tests/UnifiedSpecTests/ExpectedError.php | 33 ++++++++--- tests/UnifiedSpecTests/ExpectedResult.php | 9 +-- tests/UnifiedSpecTests/Operation.php | 58 +++++-------------- tests/UnifiedSpecTests/RunOnRequirement.php | 8 ++- tests/UnifiedSpecTests/UnifiedSpecTest.php | 11 ++-- 11 files changed, 101 insertions(+), 86 deletions(-) diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php index e8ada0df0..fab75861c 100644 --- a/tests/UnifiedSpecTests/CollectionData.php +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -2,20 +2,26 @@ namespace MongoDB\Tests\UnifiedSpecTests; +use ArrayIterator; +use IteratorIterator; use MongoDB\Client; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; use MongoDB\Tests\UnifiedSpecTests\Constraint\DocumentsMatch; -use ArrayIterator; -use IteratorIterator; use MultipleIterator; use stdClass; +use function sprintf; class CollectionData { + /** @var string */ private $collectionName; + + /** @var string */ private $databaseName; + + /** @var array */ private $documents; public function __construct(stdClass $o) @@ -47,6 +53,7 @@ public function prepare(Client $client) if (empty($this->documents)) { $database->createCollection($this->collectionName); + return; } diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php index c28c7cd4d..b746411d4 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonType.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -39,6 +39,7 @@ final class IsBsonType extends Constraint { use ConstraintTrait; + /** @var array */ private static $knownTypes = [ 'double' => 1, 'string' => 1, @@ -63,6 +64,7 @@ final class IsBsonType extends Constraint 'maxKey' => 1, ]; + /** @var string */ private $type; public function __construct(string $type) diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index a3bda024c..5f5693d34 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -3,7 +3,6 @@ namespace MongoDB\Tests\UnifiedSpecTests\Constraint; use MongoDB\BSON\Binary; -use MongoDB\Client; use MongoDB\Tests\FunctionalTestCase; use MongoDB\Tests\UnifiedSpecTests\EntityMap; use PHPUnit\Framework\ExpectationFailedException; @@ -12,6 +11,7 @@ use function fwrite; use function hex2bin; use function rewind; +use function version_compare; class MatchesTest extends FunctionalTestCase { diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 8fc6c328d..171b66f72 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -12,11 +12,18 @@ use MongoDB\Driver\WriteConcern; use stdClass; use function array_diff_key; +use function array_fill_keys; +use function array_key_exists; use function array_keys; -use function getenv; +use function count; +use function current; +use function explode; use function implode; -use function mt_rand; -use function uniqid; +use function key; +use function parse_url; +use function strlen; +use function strpos; +use function substr_replace; use const PHP_URL_HOST; /** @@ -41,7 +48,7 @@ final class Context public function __construct(Client $internalClient, string $uri) { - $this->entityMap = new EntityMap; + $this->entityMap = new EntityMap(); $this->internalClient = $internalClient; $this->uri = $uri; } @@ -85,17 +92,17 @@ public function createEntities(array $entities) } } - public function getEntityMap(): EntityMap + public function getEntityMap() : EntityMap { return $this->entityMap; } - - public function getInternalClient(): Client + + public function getInternalClient() : Client { return $this->internalClient; } - public static function prepareOperationArguments(array $args): array + public function prepareOperationArguments(array $args) : array { if (array_key_exists('readConcern', $args)) { assertInternalType('object', $args['readConcern']); @@ -144,7 +151,7 @@ private static function assertHasOnlyKeys($arrayOrObject, array $keys) assertEmpty($diff, 'Unsupported keys: ' . implode(',', array_keys($diff))); } - private function createClient(stdClass $o): Client + private function createClient(stdClass $o) : Client { self::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); @@ -184,7 +191,7 @@ private function createClient(stdClass $o): Client return new Client($uri, $uriOptions); } - private function createCollection(stdClass $o): Collection + private function createCollection(stdClass $o) : Collection { self::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); @@ -208,7 +215,7 @@ private function createCollection(stdClass $o): Collection return $database->selectCollection($o->collectionName, $options); } - private function createDatabase(stdClass $o): Database + private function createDatabase(stdClass $o) : Database { self::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); @@ -232,7 +239,7 @@ private function createDatabase(stdClass $o): Database return $client->selectDatabase($databaseName, $options); } - private static function prepareCollectionOrDatabaseOptions(array $options): array + private static function prepareCollectionOrDatabaseOptions(array $options) : array { self::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); @@ -254,7 +261,7 @@ private static function prepareCollectionOrDatabaseOptions(array $options): arra return $options; } - private static function createReadConcern(stdClass $o): ReadConcern + private static function createReadConcern(stdClass $o) : ReadConcern { self::assertHasOnlyKeys($o, ['level']); @@ -264,7 +271,7 @@ private static function createReadConcern(stdClass $o): ReadConcern return new ReadConcern($level); } - private static function createReadPreference(stdClass $o): ReadPreference + private static function createReadPreference(stdClass $o) : ReadPreference { self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); @@ -295,11 +302,11 @@ private static function createReadPreference(stdClass $o): ReadPreference return new ReadPreference($mode, $tagSets, $options); } - private static function createWriteConcern(stdClass $o): WriteConcern + private static function createWriteConcern(stdClass $o) : WriteConcern { self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); - $w = $o->w ?? -2 /* MONGOC_WRITE_CONCERN_W_DEFAULT */; + $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ $wtimeoutMS = $o->wtimeoutMS ?? 0; $journal = $o->journal ?? null; @@ -320,7 +327,7 @@ private static function createWriteConcern(stdClass $o): WriteConcern * Removes mongos hosts beyond the first if the URI refers to a sharded * cluster. Otherwise, the URI is returned as-is. */ - private static function removeMultipleMongoses(string $uri): string + private static function removeMultipleMongoses(string $uri) : string { assertStringStartsWith('mongodb://', $uri); diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index b013957bb..7b56372ac 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -2,13 +2,15 @@ namespace MongoDB\Tests\UnifiedSpecTests; +use ArrayAccess; use MongoDB\Driver\Session; use PHPUnit\Framework\Assert; -use ArrayAccess; +use function array_key_exists; use function sprintf; class EntityMap implements ArrayAccess { + /** @var array */ private $map = []; public function __destruct() diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index dbaace951..79926bdac 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -6,11 +6,13 @@ use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use function array_fill_keys; use function MongoDB\Driver\Monitoring\addSubscriber; use function MongoDB\Driver\Monitoring\removeSubscriber; class EventObserver implements CommandSubscriber { + /** @var array */ private static $defaultIgnoreCommands = [ // failPoint and targetedFailPoint operations 'configureFailPoint', @@ -27,10 +29,16 @@ class EventObserver implements CommandSubscriber 'isMaster', ]; + /** @var array */ private $actualEvents = []; + + /** @var array */ private $ignoreCommands = []; + + /** @var array */ private $observeEvents = []; + /** @var array */ private static $commandMonitoringEvents = [ 'commandStartedEvent' => CommandStartedEvent::class, 'commandSucceededEvent' => CommandSucceededEvent::class, diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index 5223e26e6..35745e274 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -9,11 +9,14 @@ use MongoDB\Driver\Exception\ServerException; use stdClass; use Throwable; +use function get_class; +use function sprintf; final class ExpectedError { /** * @see https://github.com/mongodb/mongo/blob/master/src/mongo/base/error_codes.err + * @var array */ private static $codeNameMap = [ 'Interrupted' => 11601, @@ -23,14 +26,29 @@ final class ExpectedError 'WriteConflict' => 112, ]; + /** @var bool */ private $isError = true; + + /** @var bool */ private $isClientError; + + /** @var string */ private $messageContains; + + /** @var int */ private $code; + + /** @var string */ private $codeName; + + /** @var array */ private $includedLabels = []; + + /** @var array */ private $excludedLabels = []; - private $expectResult; + + /** @var ExpectedResult */ + private $expectedResult; private function __construct(stdClass $o = null) { @@ -70,15 +88,15 @@ private function __construct(stdClass $o = null) $o->excludedLabels = $o->errorLabelsOmit; } - if (isset($o->expectResult)) { - $o->expectResult = new ExpectedResult($o->expectResult); + if (isset($o->expectedResult)) { + $o->expectedResult = new ExpectedResult($o->expectResult); } } - public static function fromOperation(stdClass $o): self + public static function fromOperation(stdClass $o) : self { if (! isset($o->expectError)) { - $expectedError = new self; + $expectedError = new self(); $expectedError->isError = false; return $expectedError; @@ -104,6 +122,7 @@ public function assert(Throwable $e = null) if (! $this->isError) { assertNull($e); + return; } @@ -135,9 +154,9 @@ public function assert(Throwable $e = null) } } - if (isset($this->expectResult)) { + if (isset($this->expectedResult)) { assertInstanceOf(BulkWriteException::class, $e); - $this->expectResult->assert($e->getWriteResult()); + $this->expectedResult->assert($e->getWriteResult()); } } diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php index 53856ac69..924954c4e 100644 --- a/tests/UnifiedSpecTests/ExpectedResult.php +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -72,19 +72,16 @@ private function __construct($assertionType, $expectedValue) $this->expectedValue = $expectedValue; } - public static function fromOperation(stdClass $o): self + public static function fromOperation(stdClass $o) : self { // TODO: Infer assertion type from operation - $expectedResult = new self(self::ASSERT_NOTHING, $o->expectResult); - - return $expectedResult; + return new self(self::ASSERT_NOTHING, $o->expectResult); } /** * Assert that the result expectation matches the actual outcome. * - * @param FunctionalTestCase $test Test instance for performing assertions - * @param mixed $result Result (if any) from the actual outcome + * @param mixed $actual Result (if any) from the actual outcome * @throws LogicException if the assertion type is unsupported */ public function assert($actual) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 1af6ded06..d1b9eaa1c 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -7,24 +7,17 @@ use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\Cursor; -use MongoDB\Driver\Exception\BulkWriteException; -use MongoDB\Driver\Exception\Exception; use MongoDB\Driver\Server; use MongoDB\Driver\Session; -use MongoDB\Driver\WriteConcern; -use MongoDB\GridFS\Bucket; use MongoDB\Model\IndexInfo; use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; use stdClass; use function array_diff_key; use function array_map; -use function fclose; -use function fopen; +use function get_class; use function iterator_to_array; use function MongoDB\is_last_pipeline_operator_write; -use function MongoDB\with_transaction; -use function stream_get_contents; use function strtolower; /** @@ -34,11 +27,22 @@ final class Operation { const OBJECT_TEST_RUNNER = 'testRunner'; + /** @var string */ private $name; + + /** @var string */ private $object; + + /** @var array */ private $arguments = []; + + /** @var ExpectedError */ private $expectedError; + + /** @var ExpectedResult */ private $expectedResult; + + /** @var bool */ private $saveResultAsEntity; public function __construct(stdClass $o) @@ -125,13 +129,10 @@ private function execute(Context $context) switch (get_class($object)) { case Client::class: return $this->executeForClient($object, $context); - case Database::class: return $this->executeForDatabase($object, $context); - case Collection::class: return $this->executeForCollection($object, $context); - default: Assert::fail('Unsupported entity type: ' . get_class($object)); } @@ -139,21 +140,18 @@ private function execute(Context $context) private function executeForClient(Client $client, Context $context) { - $args = Context::prepareOperationArguments($this->arguments); + $args = $context->prepareOperationArguments($this->arguments); switch ($this->name) { case 'listDatabaseNames': return iterator_to_array($client->listDatabaseNames($args)); - case 'listDatabases': return $client->listDatabases($args); - case 'watch': return $client->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); - default: Assert::fail('Unsupported client operation: ' . $this->name); } @@ -161,7 +159,7 @@ private function executeForClient(Client $client, Context $context) private function executeForCollection(Collection $collection, Context $context) { - $args = Context::prepareOperationArguments($this->arguments); + $args = $context->prepareOperationArguments($this->arguments); switch ($this->name) { case 'aggregate': @@ -169,7 +167,6 @@ private function executeForCollection(Collection $collection, Context $context) $args['pipeline'], array_diff_key($args, ['pipeline' => 1]) ); - case 'bulkWrite': // Merge nested and top-level options (see: SPEC-1158) $options = isset($args['options']) ? (array) $args['options'] : []; @@ -180,19 +177,16 @@ private function executeForCollection(Collection $collection, Context $context) array_map([$this, 'prepareBulkWriteRequest'], $args['requests']), $options ); - case 'createIndex': return $collection->createIndex( $args['keys'], array_diff_key($args, ['keys' => 1]) ); - case 'dropIndex': return $collection->dropIndex( $args['name'], array_diff_key($args, ['name' => 1]) ); - case 'count': case 'countDocuments': case 'find': @@ -200,10 +194,8 @@ private function executeForCollection(Collection $collection, Context $context) $args['filter'] ?? [], array_diff_key($args, ['filter' => 1]) ); - case 'estimatedDocumentCount': return $collection->estimatedDocumentCount($args); - case 'deleteMany': case 'deleteOne': case 'findOneAndDelete': @@ -211,20 +203,16 @@ private function executeForCollection(Collection $collection, Context $context) $args['filter'], array_diff_key($args, ['filter' => 1]) ); - case 'distinct': return $collection->distinct( $args['fieldName'], $args['filter'] ?? [], array_diff_key($args, ['fieldName' => 1, 'filter' => 1]) ); - case 'drop': return $collection->drop($args); - case 'findOne': return $collection->findOne($args['filter'], array_diff_key($args, ['filter' => 1])); - case 'findOneAndReplace': if (isset($args['returnDocument'])) { $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) @@ -239,7 +227,6 @@ private function executeForCollection(Collection $collection, Context $context) $args['replacement'], array_diff_key($args, ['filter' => 1, 'replacement' => 1]) ); - case 'findOneAndUpdate': if (isset($args['returnDocument'])) { $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) @@ -255,7 +242,6 @@ private function executeForCollection(Collection $collection, Context $context) $args['update'], array_diff_key($args, ['filter' => 1, 'update' => 1]) ); - case 'insertMany': // Merge nested and top-level options (see: SPEC-1158) $options = isset($args['options']) ? (array) $args['options'] : []; @@ -265,16 +251,13 @@ private function executeForCollection(Collection $collection, Context $context) $args['documents'], $options ); - case 'insertOne': return $collection->insertOne( $args['document'], array_diff_key($args, ['document' => 1]) ); - case 'listIndexes': return $collection->listIndexes($args); - case 'mapReduce': return $collection->mapReduce( $args['map'], @@ -282,13 +265,11 @@ private function executeForCollection(Collection $collection, Context $context) $args['out'], array_diff_key($args, ['map' => 1, 'reduce' => 1, 'out' => 1]) ); - case 'watch': return $collection->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); - default: Assert::fail('Unsupported collection operation: ' . $this->name); } @@ -296,7 +277,7 @@ private function executeForCollection(Collection $collection, Context $context) private function executeForDatabase(Database $database, Context $context) { - $args = Context::prepareOperationArguments($this->arguments); + $args = $context->prepareOperationArguments($this->arguments); switch ($this->name) { case 'aggregate': @@ -304,37 +285,30 @@ private function executeForDatabase(Database $database, Context $context) $args['pipeline'], array_diff_key($args, ['pipeline' => 1]) ); - case 'createCollection': return $database->createCollection( $args['collection'], array_diff_key($args, ['collection' => 1]) ); - case 'dropCollection': return $database->dropCollection( $args['collection'], array_diff_key($args, ['collection' => 1]) ); - case 'listCollectionNames': return iterator_to_array($database->listCollectionNames($args)); - case 'listCollections': return $database->listCollections($args); - case 'runCommand': return $database->command( $args['command'], array_diff_key($args, ['command' => 1]) )->toArray()[0]; - case 'watch': return $database->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); - default: Assert::fail('Unsupported database operation: ' . $this->name); } @@ -342,7 +316,7 @@ private function executeForDatabase(Database $database, Context $context) private function executeForTestRunner(Context $context) { - $args = Context::prepareOperationArguments($this->arguments); + $args = $context->prepareOperationArguments($this->arguments); switch ($this->name) { case 'assertCollectionExists': diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index c36f5aea5..19ecf3c66 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -3,10 +3,7 @@ namespace MongoDB\Tests\UnifiedSpecTests; use stdClass; -use UnexpectedValueException; use function in_array; -use function is_array; -use function is_string; use function version_compare; class RunOnRequirement @@ -18,8 +15,13 @@ class RunOnRequirement const VERSION_PATTERN = '/^[0-9]+(\\.[0-9]+){1,2}$/'; + /** @var string */ private $minServerVersion; + + /** @var string */ private $maxServerVersion; + + /** @var array */ private $topologies; public function __construct(stdClass $o) diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 7c47bff05..b37fd9b7f 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -4,19 +4,16 @@ use MongoDB\Client; use MongoDB\Driver\Command; -use MongoDB\Driver\Manager; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; -use MongoDB\Driver\Exception\ServerException; use MongoDB\Tests\FunctionalTestCase; -use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; use stdClass; -use function MongoDB\BSON\fromJSON; -use function MongoDB\BSON\toPHP; +use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; use function file_get_contents; use function glob; -use function in_array; -use function json_encode; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; use function sprintf; use function version_compare; From 1d8e7be99d2189324cf410cfcbc75531a85dadb5 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 29 Sep 2020 11:57:46 +0800 Subject: [PATCH 09/41] Use Matches for CollectionData and validate operator syntax --- tests/UnifiedSpecTests/CollectionData.php | 6 +- tests/UnifiedSpecTests/Constraint/Matches.php | 60 +++++++---- .../Constraint/MatchesTest.php | 99 ++++++++++++++----- 3 files changed, 120 insertions(+), 45 deletions(-) diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php index fab75861c..f884c74b4 100644 --- a/tests/UnifiedSpecTests/CollectionData.php +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -8,7 +8,7 @@ use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; -use MongoDB\Tests\UnifiedSpecTests\Constraint\DocumentsMatch; +use MongoDB\Tests\UnifiedSpecTests\Constraint\Matches; use MultipleIterator; use stdClass; use function sprintf; @@ -88,7 +88,9 @@ public function assertOutcome(Client $client) assertNotNull($expectedDocument); assertNotNull($actualDocument); - $constraint = new DocumentsMatch($expectedDocument, false, false); + /* Disable extra root keys and operators when matching, which is + * effectively an exact match that allows key order variation. */ + $constraint = new Matches($expectedDocument, null, false, false); assertThat($actualDocument, $constraint, sprintf('documents[%d] match', $i)); } } diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 29d908b08..a0619903f 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -11,6 +11,7 @@ use MongoDB\Tests\UnifiedSpecTests\EntityMap; use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\Constraint\LogicalOr; +use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; use SebastianBergmann\Comparator\ComparisonFailure; use SebastianBergmann\Comparator\Factory; @@ -47,13 +48,21 @@ class Matches extends Constraint /** @var mixed */ private $value; + /** @var bool */ + private $allowExtraRootKeys; + + /** @var bool */ + private $allowOperators; + /** @var ComparisonFailure|null */ private $lastFailure; - public function __construct($value, EntityMap $entityMap = null) + public function __construct($value, EntityMap $entityMap = null, $allowExtraRootKeys = true, $allowOperators = true) { $this->value = self::prepare($value); $this->entityMap = $entityMap ?? new EntityMap(); + $this->allowExtraRootKeys = $allowExtraRootKeys; + $this->allowOperators = $allowOperators; $this->comparatorFactory = Factory::getInstance(); } @@ -66,7 +75,13 @@ public function evaluate($other, $description = '', $returnResult = false) try { $this->assertMatches($this->value, $other); $success = true; + } catch (ExpectationFailedException $e) { + /* Rethrow internal assertion failures (e.g. operator type checks, + * EntityMap errors), which are logical errors in the code/test. */ + throw $e; } catch (RuntimeException $e) { + /* This will generally catch internal errors from failAt(), which + * include a key path to pinpoint the failure. */ $this->lastFailure = new ComparisonFailure( $this->value, $other, @@ -98,16 +113,10 @@ private function assertEquals($expected, $actual, string $keyPath) try { $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual); - } catch (ComparisonFailure $failure) { - throw new ComparisonFailure( - $expected, - $actual, - // No diff is required - '', - '', - false, - (empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath)) . $failure->getMessage() - ); + } catch (ComparisonFailure $e) { + /* Disregard other ComparisonFailure fields, as evaluate() only uses + * the message when creating its own ComparisonFailure. */ + self::failAt($e->getMessage(), $keyPath); } } @@ -150,7 +159,7 @@ private function assertMatchesArray(BSONArray $expected, $actual, string $keyPat private function assertMatchesDocument(BSONDocument $expected, $actual, string $keyPath) { - if (self::isOperator($expected)) { + if ($this->allowOperators && self::isOperator($expected)) { $this->assertMatchesOperator($expected, $actual, $keyPath); return; @@ -164,11 +173,12 @@ private function assertMatchesDocument(BSONDocument $expected, $actual, string $ foreach ($expected as $key => $expectedValue) { $actualKeyExists = $actual->offsetExists($key); - if ($expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) { + if ($this->allowOperators && $expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) { $operatorName = self::getOperatorName($expectedValue); - // TODO: Validate structure of operators if ($operatorName === '$$exists') { + assertInternalType('bool', $expectedValue['$$exists'], '$$exists requires bool'); + if ($expectedValue['$$exists'] && ! $actualKeyExists) { self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath); } @@ -200,8 +210,8 @@ private function assertMatchesDocument(BSONDocument $expected, $actual, string $ ); } - // Ignore extra fields in root documents - if (empty($keyPath)) { + // Ignore extra keys in root documents + if ($this->allowExtraRootKeys && empty($keyPath)) { return; } @@ -216,8 +226,13 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ { $name = self::getOperatorName($operator); - // TODO: Validate structure of operators if ($name === '$$type') { + assertThat( + $operator['$$type'], + logicalOr(isType('string'), logicalAnd(isInstanceOf(BSONArray::class), containsOnly('string'))), + '$$type requires string or string[]' + ); + $types = is_string($operator['$$type']) ? [$operator['$$type']] : $operator['$$type']; $constraints = []; @@ -235,6 +250,8 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$matchesEntity') { + assertInternalType('string', $operator['$$matchesEntity'], '$$matchesEntity requires string'); + $this->assertMatches( $this->prepare($this->entityMap[$operator['$$matchesEntity']]), $actual, @@ -245,6 +262,9 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$matchesHexBytes') { + assertInternalType('string', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires string'); + assertRegExp('/^([0-9a-fA-F]{2})+$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); + if (! is_resource($actual) || get_resource_type($actual) != "stream") { self::failAt(sprintf('%s is not a stream', $this->exporter()->shortenedExport($actual)), $keyPath); } @@ -274,11 +294,11 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$sessionLsid') { + assertInternalType('string', $operator['$$sessionLsid'], '$$sessionLsid requires string'); + $session = $this->entityMap[$operator['$$sessionLsid']]; - if (! $session instanceof Session) { - self::failAt(sprintf('entity "%s" is not a session', $operator['$$sessionLsid']), $keyPath); - } + assertInstanceOf(Session::class, $session, '$$sessionLsid requires session entity'); $this->assertEquals( self::prepare($session->getLogicalSessionId()), diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 5f5693d34..472106922 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -18,7 +18,6 @@ class MatchesTest extends FunctionalTestCase public function testMatchesDocument() { $c = new Matches(['x' => 1, 'y' => ['a' => 1, 'b' => 2]]); - $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are permitted'); @@ -26,6 +25,19 @@ public function testMatchesDocument() $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); } + public function testDoNotAllowExtraRootKeys() + { + $c = new Matches(['x' => 1], null, false); + $this->assertResult(false, $c, ['x' => 1, 'y' => 1], 'Extra keys in root are prohibited'); + } + + public function testDoNotAllowOperators() + { + $c = new Matches(['x' => ['$$exists' => true]], null, true, false); + $this->assertResult(false, $c, ['x' => 1], 'Operators are not processed'); + $this->assertResult(true, $c, ['x' => ['$$exists' => true]], 'Operators are not processed but compared as-is'); + } + public function testOperatorExists() { $c = new Matches(['x' => ['$$exists' => true]]); @@ -52,12 +64,10 @@ public function testOperatorExists() public function testOperatorType() { $c = new Matches(['x' => ['$$type' => 'string']]); - $this->assertResult(true, $c, ['x' => 'foo'], 'string matches string type'); $this->assertResult(false, $c, ['x' => 1], 'integer does not match string type'); $c = new Matches(['x' => ['$$type' => ['string', 'bool']]]); - $this->assertResult(true, $c, ['x' => 'foo'], 'string matches [string,bool] type'); $this->assertResult(true, $c, ['x' => true], 'bool matches [string,bool] type'); $this->assertResult(false, $c, ['x' => 1], 'integer does not match [string,bool] type'); @@ -70,30 +80,19 @@ public function testOperatorMatchesEntity() $entityMap['object'] = ['y' => 1]; $c = new Matches(['x' => ['$$matchesEntity' => 'integer']], $entityMap); - $this->assertResult(true, $c, ['x' => 1], 'value matches integer entity (embedded)'); $this->assertResult(false, $c, ['x' => 2], 'value does not match integer entity (embedded)'); $this->assertResult(false, $c, ['x' => ['y' => 1]], 'value does not match integer entity (embedded)'); $c = new Matches(['x' => ['$$matchesEntity' => 'object']], $entityMap); - $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches object entity (embedded)'); $this->assertResult(false, $c, ['x' => 1], 'value does not match object entity (embedded)'); $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (embedded)'); $c = new Matches(['$$matchesEntity' => 'object'], $entityMap); - $this->assertResult(true, $c, ['y' => 1], 'value matches object entity (root-level)'); $this->assertResult(true, $c, ['x' => 2, 'y' => 1], 'value matches object entity (root-level)'); $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (root-level)'); - - $c = new Matches(['$$matchesEntity' => 'undefined'], $entityMap); - - $this->assertResult(false, $c, 'undefined', 'value does not match undefined entity (root-level)'); - - $c = new Matches(['x' => ['$$matchesEntity' => 'undefined']], $entityMap); - - $this->assertResult(false, $c, ['x' => 'undefined'], 'value does not match undefined entity (embedded)'); } public function testOperatorMatchesHexBytes() @@ -107,13 +106,11 @@ public function testOperatorMatchesHexBytes() rewind($stream2); $c = new Matches(['$$matchesHexBytes' => 'DEADBEEF']); - $this->assertResult(true, $c, $stream1, 'value matches hex bytes (root-level)'); $this->assertResult(false, $c, $stream2, 'value does not match hex bytes (root-level)'); $this->assertResult(false, $c, 1, 'value is not a stream'); $c = new Matches(['x' => ['$$matchesHexBytes' => '90ABCDEF']]); - $this->assertResult(true, $c, ['x' => $stream2], 'value matches hex bytes (embedded)'); $this->assertResult(false, $c, ['x' => $stream1], 'value does not match hex bytes (embedded)'); $this->assertResult(false, $c, ['x' => 1], 'value is not a stream'); @@ -122,14 +119,12 @@ public function testOperatorMatchesHexBytes() public function testOperatorUnsetOrMatches() { $c = new Matches(['$$unsetOrMatches' => ['x' => 1]]); - $this->assertResult(true, $c, null, 'null value is considered unset (root-level)'); $this->assertResult(true, $c, ['x' => 1], 'value matches (root-level)'); $this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'value matches (root-level)'); $this->assertResult(false, $c, ['x' => 2], 'value does not match (root-level)'); $c = new Matches(['x' => ['$$unsetOrMatches' => ['y' => 1]]]); - $this->assertResult(true, $c, new stdClass(), 'missing value is considered unset (embedded)'); $this->assertResult(false, $c, ['x' => null], 'null value is not considered unset (embedded)'); $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches (embedded)'); @@ -151,14 +146,12 @@ public function testOperatorSessionLsid() $lsidWithExtraField = (array) $session->getLogicalSessionId() + ['y' => 1]; $c = new Matches(['$$sessionLsid' => 'session'], $entityMap); - $this->assertResult(true, $c, $session->getLogicalSessionId(), 'session LSID matches (root-level)'); $this->assertResult(false, $c, $lsidWithWrongId, 'session LSID does not match (root-level)'); $this->assertResult(false, $c, $lsidWithExtraField, 'session LSID does not match (root-level)'); $this->assertResult(false, $c, 1, 'session LSID does not match (root-level)'); $c = new Matches(['x' => ['$$sessionLsid' => 'session']], $entityMap); - $this->assertResult(true, $c, ['x' => $session->getLogicalSessionId()], 'session LSID matches (embedded)'); $this->assertResult(false, $c, ['x' => $lsidWithWrongId], 'session LSID does not match (embedded)'); $this->assertResult(false, $c, ['x' => $lsidWithExtraField], 'session LSID does not match (embedded)'); @@ -173,9 +166,9 @@ public function testErrorMessages($expectedMessagePart, Matches $constraint, $ac try { $constraint->evaluate($actualValue); $this->fail('Expected a comparison failure'); - } catch (ExpectationFailedException $failure) { - $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $failure->getMessage()); - $this->assertStringContainsString($expectedMessagePart, $failure->getMessage()); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $e->getMessage()); + $this->assertStringContainsString($expectedMessagePart, $e->getMessage()); } } @@ -250,6 +243,66 @@ public function errorMessageProvider() ]; } + /** + * @dataProvider operatorErrorMessageProvider + */ + public function testOperatorSyntaxValidation($expectedMessage, Matches $constraint) + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage($expectedMessage); + + $constraint->evaluate(['x' => 1], '', true); + } + + public function operatorErrorMessageProvider() + { + $entityMap = new EntityMap(); + $entityMap['notSession'] = 1; + + return [ + '$$exists type' => [ + '$$exists requires bool', + new Matches(['x' => ['$$exists' => 1]]), + ], + '$$type type (string)' => [ + '$$type requires string or string[]', + new Matches(['x' => ['$$type' => 1]]), + ], + '$$type type (string[])' => [ + '$$type requires string or string[]', + new Matches(['x' => ['$$type' => [1]]]), + ], + '$$matchesEntity type' => [ + '$$matchesEntity requires string', + new Matches(['x' => ['$$matchesEntity' => 1]]), + ], + '$$matchesEntity undefined entity' => [ + 'No entity is defined for "undefined"', + new Matches(['$$matchesEntity' => 'undefined']), + ], + '$$matchesHexBytes type' => [ + '$$matchesHexBytes requires string', + new Matches(['$$matchesHexBytes' => 1]), + ], + '$$matchesHexBytes string format' => [ + '$$matchesHexBytes requires pairs of hex chars', + new Matches(['$$matchesHexBytes' => 'f00']), + ], + '$$sessionLsid type' => [ + '$$sessionLsid requires string', + new Matches(['x' => ['$$sessionLsid' => 1]]), + ], + '$$sessionLsid undefined entity' => [ + 'No entity is defined for "undefined"', + new Matches(['$$sessionLsid' => 'undefined']), + ], + '$$sessionLsid invalid entity' => [ + '$$sessionLsid requires session entity', + new Matches(['x' => ['$$sessionLsid' => 'notSession']], $entityMap), + ], + ]; + } + private function assertResult($expected, Matches $constraint, $value, $message) { $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); From 7045abf4e752ee1655caa26e262f0415cb27710a Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 29 Sep 2020 13:17:39 +0800 Subject: [PATCH 10/41] Fix var usage --- tests/UnifiedSpecTests/Operation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index d1b9eaa1c..6a0f348d5 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -81,7 +81,7 @@ public function __construct(stdClass $o) */ public function assert(Context $context, bool $rethrowExceptions = false) { - $throwable = null; + $error = null; $result = null; try { @@ -98,7 +98,7 @@ public function assert(Context $context, bool $rethrowExceptions = false) $error = $e; } - $this->expectError->assert($throwable); + $this->expectError->assert($error); $this->expectResult->assert($result); // Rethrowing is primarily used for withTransaction callbacks From e7c58b2f59c7b03d535fae772265e00a72e762a8 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 29 Sep 2020 17:27:38 +0800 Subject: [PATCH 11/41] Fix static method call and allow empty string in $$matchesHexBytes --- tests/UnifiedSpecTests/Constraint/Matches.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index a0619903f..6130f7691 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -253,7 +253,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ assertInternalType('string', $operator['$$matchesEntity'], '$$matchesEntity requires string'); $this->assertMatches( - $this->prepare($this->entityMap[$operator['$$matchesEntity']]), + self::prepare($this->entityMap[$operator['$$matchesEntity']]), $actual, $keyPath ); @@ -263,7 +263,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ if ($name === '$$matchesHexBytes') { assertInternalType('string', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires string'); - assertRegExp('/^([0-9a-fA-F]{2})+$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); + assertRegExp('/^([0-9a-fA-F]{2})*$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); if (! is_resource($actual) || get_resource_type($actual) != "stream") { self::failAt(sprintf('%s is not a stream', $this->exporter()->shortenedExport($actual)), $keyPath); From a6007cd6e3c7f4626346b329e0223d08d2f38129 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 29 Sep 2020 17:38:33 +0800 Subject: [PATCH 12/41] CS fixes --- tests/UnifiedSpecTests/CollectionData.php | 4 ++++ tests/UnifiedSpecTests/Constraint/Matches.php | 9 +++++++++ tests/UnifiedSpecTests/Context.php | 16 ++++++++++++++-- tests/UnifiedSpecTests/EntityMap.php | 3 +++ tests/UnifiedSpecTests/EventObserver.php | 3 +++ tests/UnifiedSpecTests/ExpectedError.php | 10 ++++++++++ tests/UnifiedSpecTests/Operation.php | 6 ++++++ tests/UnifiedSpecTests/RunOnRequirement.php | 3 +++ 8 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php index f884c74b4..446d3138b 100644 --- a/tests/UnifiedSpecTests/CollectionData.php +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -11,6 +11,10 @@ use MongoDB\Tests\UnifiedSpecTests\Constraint\Matches; use MultipleIterator; use stdClass; +use function assertContainsOnly; +use function assertInternalType; +use function assertNotNull; +use function assertThat; use function sprintf; class CollectionData diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 6130f7691..d69878315 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -17,6 +17,11 @@ use SebastianBergmann\Comparator\Factory; use Symfony\Bridge\PhpUnit\ConstraintTrait; use function array_keys; +use function assertInstanceOf; +use function assertInternalType; +use function assertRegExp; +use function assertThat; +use function containsOnly; use function count; use function get_class; use function get_resource_type; @@ -27,6 +32,10 @@ use function is_object; use function is_resource; use function is_string; +use function isInstanceOf; +use function isType; +use function logicalAnd; +use function logicalOr; use function range; use function sprintf; use function stream_get_contents; diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 171b66f72..30ebb9ee9 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -15,11 +15,23 @@ use function array_fill_keys; use function array_key_exists; use function array_keys; +use function assertArrayHasKey; +use function assertArrayNotHasKey; +use function assertContains; +use function assertCount; +use function assertEmpty; +use function assertInstanceOf; +use function assertInternalType; +use function assertNotFalse; +use function assertStringStartsWith; +use function assertThat; use function count; use function current; use function explode; use function implode; +use function isType; use function key; +use function logicalOr; use function parse_url; use function strlen; use function strpos; @@ -146,7 +158,7 @@ public function stopEventObservers() private static function assertHasOnlyKeys($arrayOrObject, array $keys) { - assertThat($arrayOrObject, logicalOr(IsType('array'), IsType('object'))); + assertThat($arrayOrObject, logicalOr(isType('array'), isType('object'))); $diff = array_diff_key((array) $arrayOrObject, array_fill_keys($keys, 1)); assertEmpty($diff, 'Unsupported keys: ' . implode(',', array_keys($diff))); } @@ -310,7 +322,7 @@ private static function createWriteConcern(stdClass $o) : WriteConcern $wtimeoutMS = $o->wtimeoutMS ?? 0; $journal = $o->journal ?? null; - assertThat($w, logicalOr(new IsType('int'), new IsType('string'))); + assertThat($w, logicalOr(isType('int'), isType('string'))); assertInternalType('int', $wtimeoutMS); $args = [$w, $wtimeoutMS]; diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 7b56372ac..b3149548c 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -6,6 +6,9 @@ use MongoDB\Driver\Session; use PHPUnit\Framework\Assert; use function array_key_exists; +use function assertArrayHasKey; +use function assertArrayNotHasKey; +use function assertInternalType; use function sprintf; class EntityMap implements ArrayAccess diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 79926bdac..ca67ec463 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -7,6 +7,9 @@ use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; use function array_fill_keys; +use function assertArrayHasKey; +use function assertInternalType; +use function assertNotEmpty; use function MongoDB\Driver\Monitoring\addSubscriber; use function MongoDB\Driver\Monitoring\removeSubscriber; diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index 35745e274..4a1f43c2b 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -9,6 +9,16 @@ use MongoDB\Driver\Exception\ServerException; use stdClass; use Throwable; +use function assertArrayHasKey; +use function assertContainsOnly; +use function assertFalse; +use function assertInstanceOf; +use function assertInternalType; +use function assertNotNull; +use function assertNull; +use function assertObjectHasAttribute; +use function assertSame; +use function assertTrue; use function get_class; use function sprintf; diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 6a0f348d5..94481179c 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -15,9 +15,15 @@ use stdClass; use function array_diff_key; use function array_map; +use function assertArrayHasKey; +use function assertInternalType; +use function assertThat; use function get_class; use function iterator_to_array; +use function logicalOr; +use function logicalXor; use function MongoDB\is_last_pipeline_operator_write; +use function objectHasAttribute; use function strtolower; /** diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index 19ecf3c66..54948f641 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -3,6 +3,9 @@ namespace MongoDB\Tests\UnifiedSpecTests; use stdClass; +use function assertContainsOnly; +use function assertInternalType; +use function assertRegExp; use function in_array; use function version_compare; From a9f80c7e4c7033caaaaaf6d8f001deba5e841dfb Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 29 Sep 2020 17:43:39 +0800 Subject: [PATCH 13/41] Include PHPUnit functions via autoload-dev --- composer.json | 5 ++++- tests/bootstrap.php | 4 ---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e9b22faa0..ea4b30a58 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,10 @@ "files": [ "src/functions.php" ] }, "autoload-dev": { - "psr-4": { "MongoDB\\Tests\\": "tests/" } + "psr-4": { "MongoDB\\Tests\\": "tests/" }, + "// Manually include assertion functions for PHPUnit 8.x and earlier ":"", + "// See: https://github.com/sebastianbergmann/phpunit/issues/3746 ":"", + "files": [ "vendor/phpunit/phpunit/src/Framework/Assert/Functions.php" ] }, "extra": { "branch-alias": { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 878df52bb..31649c704 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,10 +10,6 @@ throw new Exception('Can\'t find autoload.php. Did you install dependencies with Composer?'); } -/* Manually include assertion functions for PHPUnit 8.x and earlier. - * See: https://github.com/sebastianbergmann/phpunit/issues/3746 */ -require_once(__DIR__ . '/../vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'); - if (! class_exists(PHPUnit\Framework\Error\Warning::class)) { class_alias(PHPUnit_Framework_Error_Warning::class, PHPUnit\Framework\Error\Warning::class); } From 3f21c0127fa1062e5024b10de3c33a7e456fc26b Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 30 Sep 2020 17:52:15 +0800 Subject: [PATCH 14/41] CR feedback --- tests/UnifiedSpecTests/CollectionData.php | 19 ++++--------------- tests/UnifiedSpecTests/Constraint/Matches.php | 14 ++++++++------ tests/UnifiedSpecTests/Context.php | 6 ------ tests/UnifiedSpecTests/RunOnRequirement.php | 9 +-------- tests/UnifiedSpecTests/UnifiedSpecTest.php | 8 ++------ 5 files changed, 15 insertions(+), 41 deletions(-) diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php index 446d3138b..eaa61ed0c 100644 --- a/tests/UnifiedSpecTests/CollectionData.php +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -41,12 +41,7 @@ public function __construct(stdClass $o) $this->documents = $o->documents; } - /** - * Prepare collection state for "initialData". - * - * @param Client $client - */ - public function prepare(Client $client) + public function prepareInitialData(Client $client) { $database = $client->selectDatabase( $this->databaseName, @@ -61,15 +56,9 @@ public function prepare(Client $client) return; } - $collection = $database->selectCollection($this->collectionName); - $collection->insertMany($this->documents); + $database->selectCollection($this->collectionName)->insertMany($this->documents); } - /** - * Assert collection contents for "outcome". - * - * @param Client $client - */ public function assertOutcome(Client $client) { $collection = $client->selectCollection( @@ -92,8 +81,8 @@ public function assertOutcome(Client $client) assertNotNull($expectedDocument); assertNotNull($actualDocument); - /* Disable extra root keys and operators when matching, which is - * effectively an exact match that allows key order variation. */ + /* Prohibit extra root keys and disable operators to enforce exact + * matching of documents. Key order variation is still allowed. */ $constraint = new Matches($expectedDocument, null, false, false); assertThat($actualDocument, $constraint, sprintf('documents[%d] match', $i)); } diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index d69878315..f130ff335 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -43,9 +43,11 @@ use const PHP_INT_SIZE; /** - * Constraint that checks if one document matches another. + * Constraint that checks if one value matches another. * - * The expected value is passed in the constructor. + * The expected value is passed in the constructor. An EntityMap may be supplied + * for resolving operators (e.g. $$matchesEntity). Behavior for allowing extra + * keys in root documents and processing operators is also configurable. */ class Matches extends Constraint { @@ -242,7 +244,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ '$$type requires string or string[]' ); - $types = is_string($operator['$$type']) ? [$operator['$$type']] : $operator['$$type']; + $types = (array) $operator['$$type']; $constraints = []; foreach ($types as $type) { @@ -286,9 +288,9 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$unsetOrMatches') { - /* If the operator is used at the top level, consider null - * values for $actual to be unset. If the operator is nested - * this check is done later document iteration. */ + /* If the operator is used at the top level, consider null values + * for $actual to be unset. If the operator is nested, this check is + * done later during document iteration. */ if ($keyPath === '' && $actual === null) { return; } diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 30ebb9ee9..51a4d2cbe 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -15,8 +15,6 @@ use function array_fill_keys; use function array_key_exists; use function array_keys; -use function assertArrayHasKey; -use function assertArrayNotHasKey; use function assertContains; use function assertCount; use function assertEmpty; @@ -83,7 +81,6 @@ public function createEntities(array $entities) $id = $def->id ?? null; assertInternalType('string', $id); - assertArrayNotHasKey($id, $this->entityMap); switch ($type) { case 'client': @@ -128,7 +125,6 @@ public function prepareOperationArguments(array $args) : array if (array_key_exists('session', $args)) { assertInternalType('string', $args['session']); - assertArrayHasKey($args['session'], $this->entityMap); $session = $this->entityMap[$args['session']]; assertInstanceOf(Session::class, $session); $args['session'] = $session; @@ -212,7 +208,6 @@ private function createCollection(stdClass $o) : Collection assertInternalType('string', $collectionName); assertInternalType('string', $database); - assertArrayHasKey($database, $this->entityMap); $database = $this->entityMap[$database]; assertInstanceOf(Database::class, $database); @@ -236,7 +231,6 @@ private function createDatabase(stdClass $o) : Database assertInternalType('string', $databaseName); assertInternalType('string', $client); - assertArrayHasKey($client, $this->entityMap); $client = $this->entityMap[$client]; assertInstanceOf(Client::class, $client); diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php index 54948f641..06c435fb0 100644 --- a/tests/UnifiedSpecTests/RunOnRequirement.php +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -48,14 +48,7 @@ public function __construct(stdClass $o) } } - /** - * Checks if the requirements are satisfied. - * - * @param string $serverVersion - * @param string $topology - * @return boolean - */ - public function isSatisfied(string $serverVersion, string $topology) + public function isSatisfied(string $serverVersion, string $topology) : bool { if (isset($this->minServerVersion) && version_compare($serverVersion, $this->minServerVersion, '<')) { return false; diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index b37fd9b7f..d04b9c655 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -225,11 +225,7 @@ private function getCachedTopology() */ private function isSchemaVersionSupported($schemaVersion) { - if (version_compare($schemaVersion, '1.0', '>=') && version_compare($schemaVersion, '1.1', '<')) { - return true; - } - - return false; + return version_compare($schemaVersion, '1.0', '>=') && version_compare($schemaVersion, '1.1', '<'); } /** @@ -277,7 +273,7 @@ private function prepareInitialData(array $initialData) foreach ($initialData as $data) { $collectionData = new CollectionData($data); - $collectionData->prepare(self::$internalClient); + $collectionData->prepareInitialData(self::$internalClient); } } } From 67e21ad45038357da3d940950fc46b1d2b57a612 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 5 Oct 2020 15:07:15 +0800 Subject: [PATCH 15/41] IsBsonType helpers and new IsStream constraint --- .../Constraint/IsBsonType.php | 18 +++++++++++ .../Constraint/IsBsonTypeTest.php | 30 ++++++++++++++++--- .../UnifiedSpecTests/Constraint/IsStream.php | 23 ++++++++++++++ .../Constraint/IsStreamTest.php | 18 +++++++++++ tests/UnifiedSpecTests/Constraint/Matches.php | 18 ++--------- .../Constraint/MatchesTest.php | 4 +-- 6 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 tests/UnifiedSpecTests/Constraint/IsStream.php create mode 100644 tests/UnifiedSpecTests/Constraint/IsStreamTest.php diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php index b746411d4..67bd9cdab 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonType.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -21,9 +21,11 @@ use MongoDB\Model\BSONArray; use MongoDB\Model\BSONDocument; use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\Constraint\LogicalOr; use RuntimeException; use Symfony\Bridge\PhpUnit\ConstraintTrait; use function array_keys; +use function array_map; use function count; use function is_array; use function is_bool; @@ -76,6 +78,22 @@ public function __construct(string $type) $this->type = $type; } + public static function any() : LogicalOr + { + return self::anyOf(...array_keys(self::$knownTypes)); + } + + public static function anyOf(string ...$types) : Constraint + { + if (count($types) === 1) { + return new self(...$types); + } + + return LogicalOr::fromConstraints(...array_map(function ($type) { + return new self($type); + }, $types)); + } + private function doMatches($other) : bool { switch ($this->type) { diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php index 15062f3d9..302e63c3b 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php @@ -15,8 +15,10 @@ use MongoDB\Model\BSONArray; use MongoDB\Model\BSONDocument; use MongoDB\Tests\TestCase; +use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\ExpectationFailedException; use stdClass; +use function fopen; use function MongoDB\BSON\fromJSON; use function MongoDB\BSON\toPHP; use function unserialize; @@ -29,9 +31,7 @@ class IsBsonTypeTest extends TestCase */ public function testConstraint($type, $value) { - $c = new IsBsonType($type); - - $this->assertTrue($c->evaluate($value, '', true)); + $this->assertResult(true, new IsBsonType($type), $value, $this->dataName() . ' is ' . $type); } public function provideTypes() @@ -69,6 +69,28 @@ public function provideTypes() ]; } + /** + * @dataProvider provideTypes + */ + public function testAny($type, $value) + { + $this->assertResult(true, IsBsonType::any(), $value, $this->dataName() . ' is a BSON type'); + } + + public function testAnyExcludesStream() + { + $this->assertResult(false, IsBsonType::any(), fopen('php://temp', 'w+b'), 'stream is not a BSON type'); + } + + public function testAnyOf() + { + $c = IsBsonType::anyOf('double', 'int'); + + $this->assertResult(true, $c, 1, 'int is double or int'); + $this->assertResult(true, $c, 1.4, 'int is double or int'); + $this->assertResult(false, $c, 'foo', 'string is not double or int'); + } + public function testErrorMessage() { $c = new IsBsonType('string'); @@ -129,7 +151,7 @@ public function testTypeJavascriptWithScope() $this->assertResult(false, $c, new Javascript('foo = 1;'), 'javascript is not javascriptWithScope'); } - private function assertResult($expected, IsBsonType $constraint, $value, $message) + private function assertResult($expected, Constraint $constraint, $value, string $message = '') { $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); } diff --git a/tests/UnifiedSpecTests/Constraint/IsStream.php b/tests/UnifiedSpecTests/Constraint/IsStream.php new file mode 100644 index 000000000..8941b816a --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/IsStream.php @@ -0,0 +1,23 @@ +assertTrue($c->evaluate(fopen('php://temp', 'w+b'), '', true)); + $this->assertFalse($c->evaluate(1, '', true)); + $this->assertFalse($c->evaluate('foo', '', true)); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index f130ff335..78b972bb6 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -10,7 +10,6 @@ use MongoDB\Model\BSONDocument; use MongoDB\Tests\UnifiedSpecTests\EntityMap; use PHPUnit\Framework\Constraint\Constraint; -use PHPUnit\Framework\Constraint\LogicalOr; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; use SebastianBergmann\Comparator\ComparisonFailure; @@ -24,14 +23,11 @@ use function containsOnly; use function count; use function get_class; -use function get_resource_type; use function gettype; use function hex2bin; use function implode; use function is_array; use function is_object; -use function is_resource; -use function is_string; use function isInstanceOf; use function isType; use function logicalAnd; @@ -244,14 +240,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ '$$type requires string or string[]' ); - $types = (array) $operator['$$type']; - $constraints = []; - - foreach ($types as $type) { - $constraints[] = new IsBsonType($type); - } - - $constraint = LogicalOr::fromConstraints(...$constraints); + $constraint = IsBsonType::anyOf(...(array) $operator['$$type']); if (! $constraint->evaluate($actual, '', true)) { self::failAt(sprintf('%s is not an expected BSON type: %s', $this->exporter()->shortenedExport($actual), implode(', ', $types)), $keyPath); @@ -275,10 +264,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ if ($name === '$$matchesHexBytes') { assertInternalType('string', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires string'); assertRegExp('/^([0-9a-fA-F]{2})*$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); - - if (! is_resource($actual) || get_resource_type($actual) != "stream") { - self::failAt(sprintf('%s is not a stream', $this->exporter()->shortenedExport($actual)), $keyPath); - } + assertThat($actual, new IsStream()); if (stream_get_contents($actual, -1, 0) !== hex2bin($operator['$$matchesHexBytes'])) { self::failAt(sprintf('%s does not match expected hex bytes: %s', $this->exporter()->shortenedExport($actual), $operator['$$matchesHexBytes']), $keyPath); diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 472106922..8e9181fe2 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -108,12 +108,10 @@ public function testOperatorMatchesHexBytes() $c = new Matches(['$$matchesHexBytes' => 'DEADBEEF']); $this->assertResult(true, $c, $stream1, 'value matches hex bytes (root-level)'); $this->assertResult(false, $c, $stream2, 'value does not match hex bytes (root-level)'); - $this->assertResult(false, $c, 1, 'value is not a stream'); $c = new Matches(['x' => ['$$matchesHexBytes' => '90ABCDEF']]); $this->assertResult(true, $c, ['x' => $stream2], 'value matches hex bytes (embedded)'); $this->assertResult(false, $c, ['x' => $stream1], 'value does not match hex bytes (embedded)'); - $this->assertResult(false, $c, ['x' => 1], 'value is not a stream'); } public function testOperatorUnsetOrMatches() @@ -303,7 +301,7 @@ public function operatorErrorMessageProvider() ]; } - private function assertResult($expected, Matches $constraint, $value, $message) + private function assertResult($expected, Matches $constraint, $value, string $message = '') { $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); } From dfc2b5af1b1a1396b4b2d7427af666cd549d7e70 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 5 Oct 2020 23:37:40 +0800 Subject: [PATCH 16/41] Changes to get CRUD POC tests running --- tests/UnifiedSpecTests/Constraint/Matches.php | 4 +- tests/UnifiedSpecTests/Context.php | 144 +++---- tests/UnifiedSpecTests/EntityMap.php | 33 ++ tests/UnifiedSpecTests/ExpectedError.php | 36 +- tests/UnifiedSpecTests/ExpectedResult.php | 315 ++++----------- tests/UnifiedSpecTests/Operation.php | 364 +++++++----------- tests/UnifiedSpecTests/UnifiedSpecTest.php | 54 ++- tests/UnifiedSpecTests/example-insertOne.json | 100 ----- tests/UnifiedSpecTests/example-insertOne.yml | 53 --- 9 files changed, 354 insertions(+), 749 deletions(-) delete mode 100644 tests/UnifiedSpecTests/example-insertOne.json delete mode 100644 tests/UnifiedSpecTests/example-insertOne.yml diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 78b972bb6..26f2217a7 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -416,7 +416,9 @@ private static function prepare($bson) } if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) { - $bson = new BSONDocument($bson); + /* If $bson is an object, any numeric keys may become inaccessible. + * We can work around this by casting back to an array. */ + $bson = new BSONDocument((array) $bson); } foreach ($bson as $key => $value) { diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 51a4d2cbe..8a2f6904f 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -6,9 +6,10 @@ use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; +use MongoDB\Driver\Manager; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; -use MongoDB\Driver\Session; +use MongoDB\Driver\Server; use MongoDB\Driver\WriteConcern; use stdClass; use function array_diff_key; @@ -101,41 +102,76 @@ public function createEntities(array $entities) } } - public function getEntityMap() : EntityMap + public static function createReadConcern(stdClass $o) : ReadConcern { - return $this->entityMap; - } + self::assertHasOnlyKeys($o, ['level']); - public function getInternalClient() : Client - { - return $this->internalClient; + $level = $o->level ?? null; + assertInternalType('string', $level); + + return new ReadConcern($level); } - public function prepareOperationArguments(array $args) : array + public static function createReadPreference(stdClass $o) : ReadPreference { - if (array_key_exists('readConcern', $args)) { - assertInternalType('object', $args['readConcern']); - $args['readConcern'] = self::prepareReadConcern($args['readConcern']); + self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); + + $mode = $o->mode ?? null; + $tagSets = $o->tagSets ?? null; + $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; + $hedge = $o->hedge ?? null; + + assertInternalType('string', $mode); + + if (isset($tagSets)) { + assertInternalType('array', $tagSets); + assertContains('object', $tagSets); } - if (array_key_exists('readPreference', $args)) { - assertInternalType('object', $args['readPreference']); - $args['readPreference'] = self::prepareReadPreference($args['readPreference']); + $options = []; + + if (isset($maxStalenessSeconds)) { + assertInternalType('int', $maxStalenessSeconds); + $options['maxStalenessSeconds'] = $maxStalenessSeconds; } - if (array_key_exists('session', $args)) { - assertInternalType('string', $args['session']); - $session = $this->entityMap[$args['session']]; - assertInstanceOf(Session::class, $session); - $args['session'] = $session; + if (isset($hedge)) { + assertInternalType('object', $hedge); + $options['hedge'] = $hedge; } - if (array_key_exists('writeConcern', $args)) { - assertInternalType('object', $args['writeConcern']); - $args['writeConcern'] = self::prepareWriteConcern($args['writeConcern']); + return new ReadPreference($mode, $tagSets, $options); + } + + public static function createWriteConcern(stdClass $o) : WriteConcern + { + self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); + + $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ + $wtimeoutMS = $o->wtimeoutMS ?? 0; + $journal = $o->journal ?? null; + + assertThat($w, logicalOr(isType('int'), isType('string'))); + assertInternalType('int', $wtimeoutMS); + + $args = [$w, $wtimeoutMS]; + + if (isset($journal)) { + assertInternalType('bool', $journal); + $args[] = $journal; } - return $args; + return new WriteConcern(...$args); + } + + public function getEntityMap() : EntityMap + { + return $this->entityMap; + } + + public function getInternalClient() : Client + { + return $this->internalClient; } public function startEventObservers() @@ -267,68 +303,6 @@ private static function prepareCollectionOrDatabaseOptions(array $options) : arr return $options; } - private static function createReadConcern(stdClass $o) : ReadConcern - { - self::assertHasOnlyKeys($o, ['level']); - - $level = $o->level ?? null; - assertInternalType('string', $level); - - return new ReadConcern($level); - } - - private static function createReadPreference(stdClass $o) : ReadPreference - { - self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); - - $mode = $o->mode ?? null; - $tagSets = $o->tagSets ?? null; - $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; - $hedge = $o->hedge ?? null; - - assertInternalType('string', $mode); - - if (isset($tagSets)) { - assertInternalType('array', $tagSets); - assertContains('object', $tagSets); - } - - $options = []; - - if (isset($maxStalenessSeconds)) { - assertInternalType('int', $maxStalenessSeconds); - $options['maxStalenessSeconds'] = $maxStalenessSeconds; - } - - if (isset($hedge)) { - assertInternalType('object', $hedge); - $options['hedge'] = $hedge; - } - - return new ReadPreference($mode, $tagSets, $options); - } - - private static function createWriteConcern(stdClass $o) : WriteConcern - { - self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); - - $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ - $wtimeoutMS = $o->wtimeoutMS ?? 0; - $journal = $o->journal ?? null; - - assertThat($w, logicalOr(isType('int'), isType('string'))); - assertInternalType('int', $wtimeoutMS); - - $args = [$w, $wtimeoutMS]; - - if (isset($journal)) { - assertInternalType('bool', $journal); - $args[] = $journal; - } - - return new WriteConcern(...$args); - } - /** * Removes mongos hosts beyond the first if the URI refers to a sharded * cluster. Otherwise, the URI is returned as-is. diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index b3149548c..230009db4 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -3,12 +3,23 @@ namespace MongoDB\Tests\UnifiedSpecTests; use ArrayAccess; +use MongoDB\ChangeStream; +use MongoDB\Client; +use MongoDB\Collection; +use MongoDB\Database; use MongoDB\Driver\Session; +use MongoDB\GridFS\Bucket; +use MongoDB\Tests\UnifiedSpecTests\Constraint\IsBsonType; +use MongoDB\Tests\UnifiedSpecTests\Constraint\IsStream; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\Constraint\Constraint; use function array_key_exists; use function assertArrayHasKey; use function assertArrayNotHasKey; use function assertInternalType; +use function assertThat; +use function isInstanceOf; +use function logicalOr; use function sprintf; class EntityMap implements ArrayAccess @@ -16,6 +27,9 @@ class EntityMap implements ArrayAccess /** @var array */ private $map = []; + /** @var Constraint */ + private static $isSupportedType; + public function __destruct() { /* TODO: Determine if this is actually necessary. References to session @@ -55,6 +69,7 @@ public function offsetSet($key, $value) { assertInternalType('string', $key); assertArrayNotHasKey($key, $this->map, sprintf('Entity already exists for key "%s" and cannot be replaced', $key)); + assertThat($value, self::isSupportedType()); $this->map[$key] = $value; } @@ -66,4 +81,22 @@ public function offsetUnset($key) { Assert::fail('Entities cannot be removed from the map'); } + + private static function isSupportedType() : Constraint + { + if (self::$isSupportedType === null) { + self::$isSupportedType = logicalOr( + isInstanceOf(Client::class), + isInstanceOf(Database::class), + isInstanceOf(Collection::class), + isInstanceOf(Session::class), + isInstanceOf(Bucket::class), + isInstanceOf(ChangeStream::class), + IsBsonType::any(), + new IsStream() + ); + } + + return self::$isSupportedType; + } } diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index 4a1f43c2b..e0c84b62a 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -7,6 +7,7 @@ use MongoDB\Driver\Exception\ExecutionTimeoutException; use MongoDB\Driver\Exception\RuntimeException; use MongoDB\Driver\Exception\ServerException; +use PHPUnit\Framework\Assert; use stdClass; use Throwable; use function assertArrayHasKey; @@ -20,6 +21,7 @@ use function assertSame; use function assertTrue; use function get_class; +use function property_exists; use function sprintf; final class ExpectedError @@ -37,7 +39,7 @@ final class ExpectedError ]; /** @var bool */ - private $isError = true; + private $isError = false; /** @var bool */ private $isClientError; @@ -60,8 +62,14 @@ final class ExpectedError /** @var ExpectedResult */ private $expectedResult; - private function __construct(stdClass $o = null) + public function __construct(Context $context, stdClass $o = null) { + if ($o === null) { + return; + } + + $this->isError = true; + if (isset($o->isError)) { assertTrue($o->isError); } @@ -89,33 +97,17 @@ private function __construct(stdClass $o = null) if (isset($o->errorLabelsContain)) { assertInternalType('array', $o->errorLabelsContain); assertContainsOnly('string', $o->errorLabelsContain); - $o->includedLabels = $o->errorLabelsContain; + $this->includedLabels = $o->errorLabelsContain; } if (isset($o->errorLabelsOmit)) { assertInternalType('array', $o->errorLabelsOmit); assertContainsOnly('string', $o->errorLabelsOmit); - $o->excludedLabels = $o->errorLabelsOmit; - } - - if (isset($o->expectedResult)) { - $o->expectedResult = new ExpectedResult($o->expectResult); + $this->excludedLabels = $o->errorLabelsOmit; } - } - - public static function fromOperation(stdClass $o) : self - { - if (! isset($o->expectError)) { - $expectedError = new self(); - $expectedError->isError = false; - - return $expectedError; - } - - $expectedError = new self($o->expectError); - if (isset($o->expectError->expectResult)) { - $o->expectResult = ExpectedResult::fromOperation($o); + if (property_exists($o, 'expectResult')) { + $this->expectedResult = new ExpectedResult($context, $o); } } diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php index 924954c4e..c876eab85 100644 --- a/tests/UnifiedSpecTests/ExpectedResult.php +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -2,291 +2,118 @@ namespace MongoDB\Tests\UnifiedSpecTests; -use LogicException; use MongoDB\BulkWriteResult; use MongoDB\DeleteResult; use MongoDB\Driver\WriteResult; -use MongoDB\Exception\InvalidArgumentException; use MongoDB\InsertManyResult; use MongoDB\InsertOneResult; +use MongoDB\Tests\UnifiedSpecTests\Constraint\Matches; use MongoDB\UpdateResult; use stdClass; -use function call_user_func; -use function is_array; +use function assertThat; use function is_object; use function property_exists; -/** - * Spec test operation result expectation. - */ final class ExpectedResult { - const ASSERT_NOTHING = 0; - const ASSERT_BULKWRITE = 1; - const ASSERT_DELETE = 2; - const ASSERT_INSERTMANY = 3; - const ASSERT_INSERTONE = 4; - const ASSERT_UPDATE = 5; - const ASSERT_SAME = 6; - const ASSERT_SAME_DOCUMENT = 7; - const ASSERT_SAME_DOCUMENTS = 8; - const ASSERT_MATCHES_DOCUMENT = 9; - const ASSERT_NULL = 10; - const ASSERT_CALLABLE = 11; - const ASSERT_DOCUMENTS_MATCH = 12; + /** @var Context */ + private $context; - /** @var integer */ - private $assertionType = self::ASSERT_NOTHING; + /** @var Matches */ + private $constraint; - /** @var mixed */ - private $expectedValue; - - /** @var callable */ - private $assertionCallable; - - /** - * @param integer $assertionType - * @param mixed $expectedValue - */ - private function __construct($assertionType, $expectedValue) + public function __construct(Context $context, stdClass $o) { - switch ($assertionType) { - case self::ASSERT_BULKWRITE: - case self::ASSERT_DELETE: - case self::ASSERT_INSERTMANY: - case self::ASSERT_INSERTONE: - case self::ASSERT_UPDATE: - if (! is_object($expectedValue)) { - throw InvalidArgumentException::invalidType('$expectedValue', $expectedValue, 'object'); - } - break; - - case self::ASSERT_SAME_DOCUMENTS: - if (! self::isArrayOfObjects($expectedValue)) { - throw InvalidArgumentException::invalidType('$expectedValue', $expectedValue, 'object[]'); - } - break; + if (property_exists($o, 'expectResult')) { + $this->constraint = new Matches($o->expectResult, $context->getEntityMap()); } - $this->assertionType = $assertionType; - $this->expectedValue = $expectedValue; - } - - public static function fromOperation(stdClass $o) : self - { - // TODO: Infer assertion type from operation - return new self(self::ASSERT_NOTHING, $o->expectResult); + $this->context = $context; } - /** - * Assert that the result expectation matches the actual outcome. - * - * @param mixed $actual Result (if any) from the actual outcome - * @throws LogicException if the assertion type is unsupported - */ - public function assert($actual) + public function assert($actual, string $saveResultAsEntity = null) { - $expected = $this->expectedValue; - - switch ($this->assertionType) { - case self::ASSERT_BULKWRITE: - /* If the bulk write was successful, the actual value should be - * a BulkWriteResult; otherwise, expect a WriteResult extracted - * from the BulkWriteException. */ - $test->assertThat($actual, $test->logicalOr( - $test->isInstanceOf(BulkWriteResult::class), - $test->isInstanceOf(WriteResult::class) - )); - - if (! $actual->isAcknowledged()) { - break; - } - - if (isset($expected->deletedCount)) { - $test->assertSame($expected->deletedCount, $actual->getDeletedCount()); - } - - if (isset($expected->insertedCount)) { - $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); - } - - // insertedIds are not available after BulkWriteException (see: PHPLIB-428) - if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) { - $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds()); - } - - if (isset($expected->matchedCount)) { - $test->assertSame($expected->matchedCount, $actual->getMatchedCount()); - } - - if (isset($expected->modifiedCount)) { - $test->assertSame($expected->modifiedCount, $actual->getModifiedCount()); - } - - if (isset($expected->upsertedCount)) { - $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount()); - } - - if (isset($expected->upsertedIds)) { - $test->assertSameDocument($expected->upsertedIds, $actual->getUpsertedIds()); - } - break; - - case self::ASSERT_CALLABLE: - call_user_func($this->assertionCallable, $expected, $actual); - break; - - case self::ASSERT_DELETE: - $test->assertInstanceOf(DeleteResult::class, $actual); - - if (isset($expected->deletedCount)) { - $test->assertSame($expected->deletedCount, $actual->getDeletedCount()); - } - break; - - case self::ASSERT_INSERTMANY: - /* If the bulk insert was successful, the actual value should be - * a InsertManyResult; otherwise, expect a WriteResult extracted - * from the BulkWriteException. */ - $test->assertThat($actual, $test->logicalOr( - $test->isInstanceOf(InsertManyResult::class), - $test->isInstanceOf(WriteResult::class) - )); - - if (isset($expected->insertedCount)) { - $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); - } - - // insertedIds are not available after BulkWriteException (see: PHPLIB-428) - if (isset($expected->insertedIds) && $actual instanceof BulkWriteResult) { - $test->assertSameDocument($expected->insertedIds, $actual->getInsertedIds()); - } - break; - - case self::ASSERT_INSERTONE: - $test->assertThat($actual, $test->logicalOr( - $test->isInstanceOf(InsertOneResult::class), - $test->isInstanceOf(WriteResult::class) - )); - - if (isset($expected->insertedCount)) { - $test->assertSame($expected->insertedCount, $actual->getInsertedCount()); - } - - if (property_exists($expected, 'insertedId')) { - $test->assertSameDocument( - ['insertedId' => $expected->insertedId], - ['insertedId' => $actual->getInsertedId()] - ); - } - break; - - case self::ASSERT_MATCHES_DOCUMENT: - $test->assertInternalType('object', $expected); - $test->assertThat($actual, $test->logicalOr( - $test->isType('array'), - $test->isType('object') - )); - $test->assertMatchesDocument($expected, $actual); - break; - - case self::ASSERT_NOTHING: - break; - - case self::ASSERT_NULL: - $test->assertNull($actual); - break; - - case self::ASSERT_SAME: - $test->assertSame($expected, $actual); - break; - - case self::ASSERT_SAME_DOCUMENT: - $test->assertInternalType('object', $expected); - $test->assertThat($actual, $test->logicalOr( - $test->isType('array'), - $test->isType('object') - )); - $test->assertSameDocument($expected, $actual); - break; - - case self::ASSERT_SAME_DOCUMENTS: - $test->assertSameDocuments($expected, $actual); - break; - - case self::ASSERT_DOCUMENTS_MATCH: - $test->assertDocumentsMatch($expected, $actual); - break; - - case self::ASSERT_UPDATE: - $test->assertInstanceOf(UpdateResult::class, $actual); - - if (isset($expected->matchedCount)) { - $test->assertSame($expected->matchedCount, $actual->getMatchedCount()); - } - - if (isset($expected->modifiedCount)) { - $test->assertSame($expected->modifiedCount, $actual->getModifiedCount()); - } + if ($this->constraint === null && $saveResultAsEntity === null) { + return; + } - if (isset($expected->upsertedCount)) { - $test->assertSame($expected->upsertedCount, $actual->getUpsertedCount()); - } + $actual = self::prepare($actual); - if (property_exists($expected, 'upsertedId')) { - $test->assertSameDocument( - ['upsertedId' => $expected->upsertedId], - ['upsertedId' => $actual->getUpsertedId()] - ); - } - break; + if ($this->constraint) { + assertThat($actual, $this->constraint); + } - default: - throw new LogicException('Unsupported assertion type: ' . $this->assertionType); + if ($saveResultAsEntity !== null) { + $entityMap[$saveResultAsEntity] = $actual; } } - public function isExpected() + public function saveResultAsEntity($actual, $id) { - return $this->assertionType !== self::ASSERT_NOTHING; + $this->context->getEntityMap()[$id] = self::prepare($actual); } - private static function isArrayOfObjects($array) + private static function prepare($value) { - if (! is_array($array)) { - return false; + if (! is_object($value)) { + return $value; } - foreach ($array as $object) { - if (! is_object($object)) { - return false; - } + if ($value instanceof BulkWriteResult || + $value instanceof WriteResult || + $value instanceof DeleteResult || + $value instanceof InsertOneResult || + $value instanceof InsertManyResult || + $value instanceof UpdateResult) { + return self::prepareWriteResult($value); } - return true; + return $value; } - /** - * Determines whether the result is actually an error expectation. - * - * @see https://github.com/mongodb/specifications/blob/master/source/transactions/tests/README.rst#test-format - * @param mixed $result - * @return boolean - */ - private static function isErrorResult($result) + private static function prepareWriteResult($value) { - if (! is_object($result)) { - return false; + $result = ['acknowledged' => $value->isAcknowledged()]; + + if (! $result['acknowledged']) { + return $result; + } + + if ($value instanceof BulkWriteResult || $value instanceof WriteResult) { + $result['deletedCount'] = $value->getDeletedCount(); + $result['insertedCount'] = $value->getInsertedCount(); + $result['matchedCount'] = $value->getMatchedCount(); + $result['modifiedCount'] = $value->getModifiedCount(); + $result['upsertedCount'] = $value->getUpsertedCount(); + $result['upsertedIds'] = (object) $value->getUpsertedIds(); + } + + // WriteResult does not provide insertedIds (see: PHPLIB-428) + if ($value instanceof BulkWriteResult) { + $result['insertedIds'] = (object) $value->getInsertedIds(); } - $keys = ['errorContains', 'errorCodeName', 'errorLabelsContain', 'errorLabelsOmit']; + if ($value instanceof DeleteResult) { + $result['deletedCount'] = $value->getDeletedCount(); + } + + if ($value instanceof InsertManyResult) { + $result['insertedCount'] = $value->getInsertedCount(); + $result['insertedIds'] = (object) $value->getInsertedIds(); + } + + if ($value instanceof InsertOneResult) { + $result['insertedCount'] = $value->getInsertedCount(); + $result['insertedId'] = $value->getInsertedId(); + } - foreach ($keys as $key) { - if (isset($result->{$key})) { - return true; - } + if ($value instanceof UpdateResult) { + $result['matchedCount'] = $value->getMatchedCount(); + $result['modifiedCount'] = $value->getModifiedCount(); + $result['upsertedCount'] = $value->getUpsertedCount(); + $result['upsertedId'] = $value->getUpsertedId(); } - return false; + return $result; } } diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 94481179c..4540aa776 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -2,33 +2,38 @@ namespace MongoDB\Tests\UnifiedSpecTests; -use LogicException; +use MongoDB\ChangeStream; use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; -use MongoDB\Driver\Cursor; use MongoDB\Driver\Server; use MongoDB\Driver\Session; use MongoDB\Model\IndexInfo; use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; +use PHPUnit\Framework\Assert; use stdClass; +use Throwable; +use Traversable; use function array_diff_key; +use function array_key_exists; use function array_map; -use function assertArrayHasKey; +use function assertContains; +use function assertCount; +use function assertInstanceOf; use function assertInternalType; +use function assertNotContains; +use function assertNull; +use function assertSame; use function assertThat; +use function current; use function get_class; use function iterator_to_array; +use function key; use function logicalOr; -use function logicalXor; -use function MongoDB\is_last_pipeline_operator_write; -use function objectHasAttribute; +use function property_exists; use function strtolower; -/** - * Spec test operation. - */ final class Operation { const OBJECT_TEST_RUNNER = 'testRunner'; @@ -42,17 +47,22 @@ final class Operation /** @var array */ private $arguments = []; + /** @var EntityMap */ + private $entityMap; + /** @var ExpectedError */ private $expectedError; /** @var ExpectedResult */ private $expectedResult; - /** @var bool */ + /** @var string */ private $saveResultAsEntity; - public function __construct(stdClass $o) + public function __construct(Context $context, stdClass $o) { + $this->entityMap = $context->getEntityMap(); + assertInternalType('string', $o->name); $this->name = $o->name; @@ -64,18 +74,13 @@ public function __construct(stdClass $o) $this->arguments = (array) $o->arguments; } - // expectError is mutually exclusive with expectResult and saveResultAsEntity - assertThat($o, logicalXor( - objectHasAttribute('expectError'), - logicalOr(objectHasAttribute('expectResult'), objectHasAttribute('saveResultAsEntity')) - )); - - $this->expectError = ExpectedError::fromOperation($o); - - if (isset($o->expectResult)) { - $this->expectResult = ExpectedResult::fromOperation($o); + if (isset($o->expectError) && (property_exists($o, 'expectResult') || isset($o->saveResultAsEntity))) { + Assert::fail('expectError is mutually exclusive with expectResult and saveResultAsEntity'); } + $this->expectError = new ExpectedError($context, $o->expectError ?? null); + $this->expectResult = new ExpectedResult($context, $o); + if (isset($o->saveResultAsEntity)) { assertInternalType('string', $o->saveResultAsEntity); $this->saveResultAsEntity = $o->saveResultAsEntity; @@ -85,27 +90,22 @@ public function __construct(stdClass $o) /** * Execute the operation and assert its outcome. */ - public function assert(Context $context, bool $rethrowExceptions = false) + public function assert(bool $rethrowExceptions = false) { $error = null; $result = null; + $saveResultAsEntity = null; try { - $result = $this->execute($context); - - /* Eagerly iterate the results of a cursor. This both allows an - * exception to be thrown sooner and ensures that any expected - * getMore command(s) can be observed even if a ResultExpectation - * is not used (e.g. Command Monitoring spec). */ - if ($result instanceof Cursor) { - $result = $result->toArray(); - } + $result = $this->execute(); + $saveResultAsEntity = $this->saveResultAsEntity; } catch (Throwable $e) { $error = $e; } $this->expectError->assert($error); - $this->expectResult->assert($result); + // TODO: Fix saveResultAsEntity behavior + $this->expectResult->assert($result, $saveResultAsEntity); // Rethrowing is primarily used for withTransaction callbacks if ($error && $rethrowExceptions) { @@ -113,59 +113,58 @@ public function assert(Context $context, bool $rethrowExceptions = false) } } - /** - * Executes the operation with a given context. - * - * @param Context $context - * @return mixed - * @throws LogicException if the entity type or operation is unsupported - */ - private function execute(Context $context) + private function execute() { if ($this->object == self::OBJECT_TEST_RUNNER) { - return $this->executeForTestRunner($context); + return $this->executeForTestRunner(); } - $entityMap = $context->getEntityMap(); - - assertArrayHasKey($this->object, $entityMap); - $object = $entityMap[$this->object]; + $object = $this->entityMap[$this->object]; assertInternalType('object', $object); switch (get_class($object)) { case Client::class: - return $this->executeForClient($object, $context); + $result = $this->executeForClient($object); + break; case Database::class: - return $this->executeForDatabase($object, $context); + $result = $this->executeForDatabase($object); + break; case Collection::class: - return $this->executeForCollection($object, $context); + $result = $this->executeForCollection($object); + break; default: Assert::fail('Unsupported entity type: ' . get_class($object)); } + + if ($result instanceof Traversable && ! $result instanceof ChangeStream) { + return iterator_to_array($result); + } + + return $result; } - private function executeForClient(Client $client, Context $context) + private function executeForClient(Client $client) { - $args = $context->prepareOperationArguments($this->arguments); + $args = $this->prepareArguments(); switch ($this->name) { - case 'listDatabaseNames': - return iterator_to_array($client->listDatabaseNames($args)); - case 'listDatabases': - return $client->listDatabases($args); - case 'watch': + case 'createChangeStream': return $client->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); + case 'listDatabaseNames': + return $client->listDatabaseNames($args); + case 'listDatabases': + return $client->listDatabases($args); default: Assert::fail('Unsupported client operation: ' . $this->name); } } - private function executeForCollection(Collection $collection, Context $context) + private function executeForCollection(Collection $collection) { - $args = $context->prepareOperationArguments($this->arguments); + $args = $this->prepareArguments(); switch ($this->name) { case 'aggregate': @@ -174,14 +173,14 @@ private function executeForCollection(Collection $collection, Context $context) array_diff_key($args, ['pipeline' => 1]) ); case 'bulkWrite': - // Merge nested and top-level options (see: SPEC-1158) - $options = isset($args['options']) ? (array) $args['options'] : []; - $options += array_diff_key($args, ['requests' => 1]); - return $collection->bulkWrite( - // TODO: Check if self can be used with a private static function - array_map([$this, 'prepareBulkWriteRequest'], $args['requests']), - $options + array_map('self::prepareBulkWriteRequest', $args['requests']), + array_diff_key($args, ['requests' => 1]) + ); + case 'createChangeStream': + return $collection->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) ); case 'createIndex': return $collection->createIndex( @@ -221,7 +220,10 @@ private function executeForCollection(Collection $collection, Context $context) return $collection->findOne($args['filter'], array_diff_key($args, ['filter' => 1])); case 'findOneAndReplace': if (isset($args['returnDocument'])) { - $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) + $args['returnDocument'] = strtolower($args['returnDocument']); + assertThat($args['returnDocument'], logicalOr(isEqual('after'), isEqual('before'))); + + $args['returnDocument'] = 'after' === $args['returnDocument'] ? FindOneAndReplace::RETURN_DOCUMENT_AFTER : FindOneAndReplace::RETURN_DOCUMENT_BEFORE; } @@ -235,7 +237,10 @@ private function executeForCollection(Collection $collection, Context $context) ); case 'findOneAndUpdate': if (isset($args['returnDocument'])) { - $args['returnDocument'] = 'after' === strtolower($args['returnDocument']) + $args['returnDocument'] = strtolower($args['returnDocument']); + assertThat($args['returnDocument'], logicalOr(isEqual('after'), isEqual('before'))); + + $args['returnDocument'] = 'after' === $args['returnDocument'] ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE; } @@ -271,19 +276,14 @@ private function executeForCollection(Collection $collection, Context $context) $args['out'], array_diff_key($args, ['map' => 1, 'reduce' => 1, 'out' => 1]) ); - case 'watch': - return $collection->watch( - $args['pipeline'] ?? [], - array_diff_key($args, ['pipeline' => 1]) - ); default: Assert::fail('Unsupported collection operation: ' . $this->name); } } - private function executeForDatabase(Database $database, Context $context) + private function executeForDatabase(Database $database) { - $args = $context->prepareOperationArguments($this->arguments); + $args = $this->prepareArguments(); switch ($this->name) { case 'aggregate': @@ -291,6 +291,11 @@ private function executeForDatabase(Database $database, Context $context) $args['pipeline'], array_diff_key($args, ['pipeline' => 1]) ); + case 'createChangeStream': + return $database->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); case 'createCollection': return $database->createCollection( $args['collection'], @@ -302,7 +307,7 @@ private function executeForDatabase(Database $database, Context $context) array_diff_key($args, ['collection' => 1]) ); case 'listCollectionNames': - return iterator_to_array($database->listCollectionNames($args)); + return $database->listCollectionNames($args); case 'listCollections': return $database->listCollections($args); case 'runCommand': @@ -310,33 +315,28 @@ private function executeForDatabase(Database $database, Context $context) $args['command'], array_diff_key($args, ['command' => 1]) )->toArray()[0]; - case 'watch': - return $database->watch( - $args['pipeline'] ?? [], - array_diff_key($args, ['pipeline' => 1]) - ); default: Assert::fail('Unsupported database operation: ' . $this->name); } } - private function executeForTestRunner(Context $context) + private function executeForTestRunner() { - $args = $context->prepareOperationArguments($this->arguments); + $args = $this->prepareArguments(); switch ($this->name) { case 'assertCollectionExists': $databaseName = $args['database']; $collectionName = $args['collection']; - $test->assertContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + assertContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); return null; case 'assertCollectionNotExists': $databaseName = $args['database']; $collectionName = $args['collection']; - $test->assertNotContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + assertNotContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); return null; case 'assertIndexExists': @@ -344,7 +344,7 @@ private function executeForTestRunner(Context $context) $collectionName = $args['collection']; $indexName = $args['index']; - $test->assertContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + assertContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); return null; case 'assertIndexNotExists': @@ -352,45 +352,43 @@ private function executeForTestRunner(Context $context) $collectionName = $args['collection']; $indexName = $args['index']; - $test->assertNotContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + assertNotContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); return null; case 'assertSessionPinned': - $test->assertInstanceOf(Session::class, $args['session']); - $test->assertInstanceOf(Server::class, $args['session']->getServer()); + assertInstanceOf(Session::class, $args['session']); + assertInstanceOf(Server::class, $args['session']->getServer()); return null; case 'assertSessionTransactionState': - $test->assertInstanceOf(Session::class, $args['session']); - /* PHPC currently does not expose the exact session state, but - * instead exposes a bool to let us know whether a transaction - * is currently in progress. This code may fail down the line - * and should be adjusted once PHPC-1438 is implemented. */ - $test->assertSame($this->arguments['state'], $args['session']->getTransactionState()); + assertInstanceOf(Session::class, $args['session']); + assertSame($this->arguments['state'], $args['session']->getTransactionState()); return null; case 'assertSessionUnpinned': - $test->assertInstanceOf(Session::class, $args['session']); - $test->assertNull($args['session']->getServer()); + assertInstanceOf(Session::class, $args['session']); + assertNull($args['session']->getServer()); + + return null; + case 'failPoint': + assertInternalType('string', $args['client']); + $client = $this->entityMap[$args['client']]; + assertInstanceOf(Client::class, $client); + + // TODO: configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); return null; case 'targetedFailPoint': - $test->assertInstanceOf(Session::class, $args['session']); - $test->configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); + assertInstanceOf(Session::class, $args['session']); + // TODO: configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); return null; default: - throw new LogicException('Unsupported test runner operation: ' . $this->name); + Assert::fail('Unsupported test runner operation: ' . $this->name); } } - /** - * @param string $databaseName - * @param string $collectionName - * - * @return array - */ - private function getIndexNames(Context $context, $databaseName, $collectionName) + private function getIndexNames(Context $context, string $databaseName, string $collectionName) : array { return array_map( function (IndexInfo $indexInfo) { @@ -400,148 +398,56 @@ function (IndexInfo $indexInfo) { ); } - /** - * @throws LogicException if the operation object is unsupported - */ - private function getResultAssertionType() + private function prepareArguments() : array { - switch ($this->object) { - case self::OBJECT_CLIENT: - return $this->getResultAssertionTypeForClient(); - case self::OBJECT_COLLECTION: - return $this->getResultAssertionTypeForCollection(); - case self::OBJECT_DATABASE: - return $this->getResultAssertionTypeForDatabase(); - case self::OBJECT_GRIDFS_BUCKET: - return ResultExpectation::ASSERT_SAME; - case self::OBJECT_SESSION0: - case self::OBJECT_SESSION1: - case self::OBJECT_TEST_RUNNER: - return ResultExpectation::ASSERT_NOTHING; - default: - throw new LogicException('Unsupported object: ' . $this->object); - } - } + $args = $this->arguments; - /** - * @throws LogicException if the collection operation is unsupported - */ - private function getResultAssertionTypeForClient() - { - switch ($this->name) { - case 'listDatabaseNames': - return ResultExpectation::ASSERT_SAME; - case 'listDatabases': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'watch': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - default: - throw new LogicException('Unsupported client operation: ' . $this->name); + if (array_key_exists('readConcern', $args)) { + assertInternalType('object', $args['readConcern']); + $args['readConcern'] = Context::createReadConcern($args['readConcern']); } - } - /** - * @throws LogicException if the collection operation is unsupported - */ - private function getResultAssertionTypeForCollection() - { - switch ($this->name) { - case 'aggregate': - /* Returning a cursor for the $out collection is optional per - * the CRUD specification and is not implemented in the library - * since we have no concept of lazy cursors. Rely on examining - * the output collection rather than the operation result. */ - if (is_last_pipeline_operator_write($this->arguments['pipeline'])) { - return ResultExpectation::ASSERT_NOTHING; - } + if (array_key_exists('readPreference', $args)) { + assertInternalType('object', $args['readPreference']); + $args['readPreference'] = Context::createReadPreference($args['readPreference']); + } - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'bulkWrite': - return ResultExpectation::ASSERT_BULKWRITE; - case 'count': - case 'countDocuments': - return ResultExpectation::ASSERT_SAME; - case 'createIndex': - case 'dropIndex': - return ResultExpectation::ASSERT_MATCHES_DOCUMENT; - case 'distinct': - case 'estimatedDocumentCount': - return ResultExpectation::ASSERT_SAME; - case 'deleteMany': - case 'deleteOne': - return ResultExpectation::ASSERT_DELETE; - case 'drop': - return ResultExpectation::ASSERT_NOTHING; - case 'findOne': - case 'findOneAndDelete': - case 'findOneAndReplace': - case 'findOneAndUpdate': - return ResultExpectation::ASSERT_SAME_DOCUMENT; - case 'find': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'insertMany': - return ResultExpectation::ASSERT_INSERTMANY; - case 'insertOne': - return ResultExpectation::ASSERT_INSERTONE; - case 'listIndexes': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'mapReduce': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'replaceOne': - case 'updateMany': - case 'updateOne': - return ResultExpectation::ASSERT_UPDATE; - case 'watch': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - default: - throw new LogicException('Unsupported collection operation: ' . $this->name); + if (array_key_exists('session', $args)) { + assertInternalType('string', $args['session']); + $session = $this->entityMap[$args['session']]; + assertInstanceOf(Session::class, $session); + $args['session'] = $session; } - } - /** - * @throws LogicException if the database operation is unsupported - */ - private function getResultAssertionTypeForDatabase() - { - switch ($this->name) { - case 'aggregate': - case 'listCollections': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - case 'listCollectionNames': - return ResultExpectation::ASSERT_SAME; - case 'createCollection': - case 'dropCollection': - case 'runCommand': - return ResultExpectation::ASSERT_MATCHES_DOCUMENT; - case 'watch': - return ResultExpectation::ASSERT_SAME_DOCUMENTS; - default: - throw new LogicException('Unsupported database operation: ' . $this->name); + if (array_key_exists('writeConcern', $args)) { + assertInternalType('object', $args['writeConcern']); + $args['writeConcern'] = Context::createWriteConcern($args['writeConcern']); } + + return $args; } - /** - * Prepares a request element for a bulkWrite operation. - * - * @param stdClass $request - * @return array - * @throws LogicException if the bulk write request is unsupported - */ - private function prepareBulkWriteRequest(stdClass $request) + private function prepareBulkWriteRequest(stdClass $request) : array { - $args = (array) $request->arguments; + $request = (array) $request; + assertCount(1, $request); + + $type = key($request); + $args = current($request); + assertInternalType('object', $args); + $args = (array) $args; - switch ($request->name) { + switch ($type) { case 'deleteMany': case 'deleteOne': return [ - $request->name => [ + $type => [ $args['filter'], array_diff_key($args, ['filter' => 1]), ], ]; case 'insertOne': - return [ 'insertOne' => [ $args['document'] ]]; + return [ 'insertOne' => [ $args['document']]]; case 'replaceOne': return [ 'replaceOne' => [ @@ -553,14 +459,14 @@ private function prepareBulkWriteRequest(stdClass $request) case 'updateMany': case 'updateOne': return [ - $request->name => [ + $type => [ $args['filter'], $args['update'], array_diff_key($args, ['filter' => 1, 'update' => 1]), ], ]; default: - throw new LogicException('Unsupported bulk write request: ' . $request->name); + Assert::fail('Unsupported bulk write request: ' . $type); } } } diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index d04b9c655..7ac9d8387 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -10,6 +10,8 @@ use MongoDB\Tests\FunctionalTestCase; use stdClass; use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; +use Throwable; +use function basename; use function file_get_contents; use function glob; use function MongoDB\BSON\fromJSON; @@ -59,16 +61,9 @@ private function doTearDown() } /** - * Execute an individual test case from the specification. - * - * @dataProvider provideTests - * @param stdClass $test Individual object in "tests[]" - * @param string $schemaVersion Top-level "schemaVersion" - * @param array $runOnRequirements Top-level "runOnRequirements" - * @param array $createEntities Top-level "createEntities" - * @param array $initialData Top-level "initialData" + * @dataProvider providePassingTests */ - public function testCase(stdClass $test, string $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) + public function testPassingTests(stdClass $test, string $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) { if (! $this->isSchemaVersionSupported($schemaVersion)) { $this->markTestIncomplete(sprintf('Test format schema version "%s" is not supported', $schemaVersion)); @@ -83,7 +78,7 @@ public function testCase(stdClass $test, string $schemaVersion, array $runOnRequ } if (isset($test->runOnRequirements)) { - $this->checkRunOnRequirements($runOnRequirements); + $this->checkRunOnRequirements($test->runOnRequirements); } if (isset($initialData)) { @@ -103,8 +98,8 @@ public function testCase(stdClass $test, string $schemaVersion, array $runOnRequ } foreach ($test->operations as $o) { - $operation = new Operation($o); - $operation->assert($context); + $operation = new Operation($context, $o); + $operation->assert(); } if (isset($test->expectedEvents)) { @@ -116,11 +111,40 @@ public function testCase(stdClass $test, string $schemaVersion, array $runOnRequ } } - public function provideTests() + public function providePassingTests() + { + return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-pass/'); + } + + /** + * @dataProvider provideFailingTests + */ + public function testFailingTests(...$args) + { + // Cannot use expectException(), as it ignores PHPUnit Exceptions + $failed = false; + + try { + $this->testCase(...$args); + } catch (Throwable $e) { + $failed = true; + } + + if (! $failed) { + $this->fail('Expected test to throw an exception'); + } + } + + public function provideFailingTests() + { + return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-fail'); + } + + private function provideTests(string $dir) { $testArgs = []; - foreach (glob(__DIR__ . '/*.json') as $filename) { + foreach (glob($dir . '/poc-c*.json') as $filename) { /* Decode the file through the driver's extended JSON parser to * ensure proper handling of special types. */ $json = toPHP(fromJSON(file_get_contents($filename))); @@ -132,7 +156,7 @@ public function provideTests() $initialData = $json->initialData ?? null; foreach ($json->tests as $test) { - $name = $description . ': ' . $test->description; + $name = basename($filename) . ': ' . $description . ': ' . $test->description; $testArgs[$name] = [$test, $schemaVersion, $runOnRequirements, $createEntities, $initialData]; } } diff --git a/tests/UnifiedSpecTests/example-insertOne.json b/tests/UnifiedSpecTests/example-insertOne.json deleted file mode 100644 index be41f9eac..000000000 --- a/tests/UnifiedSpecTests/example-insertOne.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "description": "example-insertOne", - "schemaVersion": "1.0", - "runOnRequirements": [ - { - "minServerVersion": "2.6" - } - ], - "createEntities": [ - { - "client": { - "id": "client0", - "observeEvents": [ - "commandStartedEvent" - ] - } - }, - { - "database": { - "id": "database0", - "client": "client0", - "databaseName": "test" - } - }, - { - "collection": { - "id": "collection0", - "database": "database0", - "collectionName": "coll" - } - } - ], - "initialData": [ - { - "collectionName": "coll", - "databaseName": "test", - "documents": [ - { - "_id": 1 - } - ] - } - ], - "tests": [ - { - "description": "insertOne", - "operations": [ - { - "object": "collection0", - "name": "insertOne", - "arguments": { - "document": { - "_id": 2 - } - }, - "expectResult": { - "insertedId": { - "$$unsetOrMatches": 2 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "commandName": "insert", - "databaseName": "test", - "command": { - "insert": "coll", - "documents": [ - { - "_id": 2 - } - ] - } - } - } - ] - } - ], - "outcome": [ - { - "collectionName": "coll", - "databaseName": "test", - "documents": [ - { - "_id": 1 - }, - { - "_id": 2 - } - ] - } - ] - } - ] -} diff --git a/tests/UnifiedSpecTests/example-insertOne.yml b/tests/UnifiedSpecTests/example-insertOne.yml deleted file mode 100644 index 22b27bb2f..000000000 --- a/tests/UnifiedSpecTests/example-insertOne.yml +++ /dev/null @@ -1,53 +0,0 @@ -description: "example-insertOne" - -schemaVersion: "1.0" - -runOnRequirements: - - minServerVersion: "2.6" - -createEntities: - - client: - id: &client0 client0 - observeEvents: - - commandStartedEvent - - database: - id: &database0 database0 - client: *client0 - databaseName: &database0Name test - - collection: - id: &collection0 collection0 - database: *database0 - collectionName: &collection0Name coll - -initialData: - - collectionName: *collection0Name - databaseName: *database0Name - documents: - - { _id: 1 } - -tests: - - description: "insertOne" - operations: - - - object: *collection0 - name: insertOne - arguments: - document: { _id: 2 } - expectResult: - insertedId: { $$unsetOrMatches: 2 } - expectEvents: - - client: *client0 - events: - - commandStartedEvent: - commandName: insert - databaseName: *database0Name - command: - insert: *collection0Name - documents: - - { _id: 2 } - outcome: - - collectionName: *collection0Name - databaseName: *database0Name - documents: - - { _id: 1 } - - { _id: 2 } From f88f2ca30132b461b79d75f8adb64c2d88d4218a Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 6 Oct 2020 19:09:22 +0800 Subject: [PATCH 17/41] Implement expectEvents and change stream tests --- .../Constraint/IsBsonType.php | 49 ++++--- tests/UnifiedSpecTests/Constraint/Matches.php | 18 ++- tests/UnifiedSpecTests/Context.php | 109 ++++---------- tests/UnifiedSpecTests/EventObserver.php | 138 ++++++++++++++++-- tests/UnifiedSpecTests/ExpectedError.php | 4 +- tests/UnifiedSpecTests/ExpectedResult.php | 19 +-- tests/UnifiedSpecTests/Operation.php | 59 +++++++- tests/UnifiedSpecTests/UnifiedSpecTest.php | 21 ++- 8 files changed, 275 insertions(+), 142 deletions(-) diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php index 67bd9cdab..76dbe4278 100644 --- a/tests/UnifiedSpecTests/Constraint/IsBsonType.php +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -27,6 +27,7 @@ use function array_keys; use function array_map; use function count; +use function in_array; use function is_array; use function is_bool; use function is_float; @@ -42,28 +43,28 @@ final class IsBsonType extends Constraint use ConstraintTrait; /** @var array */ - private static $knownTypes = [ - 'double' => 1, - 'string' => 1, - 'object' => 1, - 'array' => 1, - 'binData' => 1, - 'undefined' => 1, - 'objectId' => 1, - 'bool' => 1, - 'date' => 1, - 'null' => 1, - 'regex' => 1, - 'dbPointer' => 1, - 'javascript' => 1, - 'symbol' => 1, - 'javascriptWithScope' => 1, - 'int' => 1, - 'timestamp' => 1, - 'long' => 1, - 'decimal' => 1, - 'minKey' => 1, - 'maxKey' => 1, + private static $types = [ + 'double', + 'string', + 'object', + 'array', + 'binData', + 'undefined', + 'objectId', + 'bool', + 'date', + 'null', + 'regex', + 'dbPointer', + 'javascript', + 'symbol', + 'javascriptWithScope', + 'int', + 'timestamp', + 'long', + 'decimal', + 'minKey', + 'maxKey', ]; /** @var string */ @@ -71,7 +72,7 @@ final class IsBsonType extends Constraint public function __construct(string $type) { - if (! isset(self::$knownTypes[$type])) { + if (! in_array($type, self::$types)) { throw new RuntimeException(sprintf('Type specified for %s <%s> is not a valid type', self::class, $type)); } @@ -80,7 +81,7 @@ public function __construct(string $type) public static function any() : LogicalOr { - return self::anyOf(...array_keys(self::$knownTypes)); + return self::anyOf(...self::$types); } public static function anyOf(string ...$types) : Constraint diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 26f2217a7..0e503cc8b 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -27,6 +27,8 @@ use function hex2bin; use function implode; use function is_array; +use function is_float; +use function is_int; use function is_object; use function isInstanceOf; use function isType; @@ -113,8 +115,10 @@ private function assertEquals($expected, $actual, string $keyPath) $expectedType = is_object($expected) ? get_class($expected) : gettype($expected); $actualType = is_object($actual) ? get_class($actual) : gettype($actual); - // 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($expected) || self::isNumeric($actual))) { self::failAt(sprintf('%s is not expected type "%s"', $actualType, $expectedType), $keyPath); } @@ -363,6 +367,11 @@ private static function getOperatorName(BSONDocument $document) : string throw new LogicException('should not reach this point'); } + private static function isNumeric($value) + { + return is_int($value) || is_float($value) || $value instanceof Int64; + } + private static function isOperator(BSONDocument $document) : bool { if (count($document) !== 1) { @@ -400,6 +409,11 @@ private static function prepare($bson) return (int) ((string) $bson); } + /* TODO: Convert Int64 objects to integers on 32-bit platforms if they + * can be expressed as such. This is necessary to handle flexible + * numeric comparisons if the server returns 32-bit value as a 64-bit + * integer (e.g. cursor ID). */ + // Serializable can produce an array or object, so recurse on its output if ($bson instanceof Serializable) { return self::prepare($bson->bsonSerialize()); diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 8a2f6904f..e85c2c602 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -7,30 +7,22 @@ use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\Manager; -use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; -use MongoDB\Driver\WriteConcern; use stdClass; -use function array_diff_key; -use function array_fill_keys; use function array_key_exists; -use function array_keys; -use function assertContains; +use function assertArrayHasKey; use function assertCount; -use function assertEmpty; use function assertInstanceOf; use function assertInternalType; +use function assertNotEmpty; use function assertNotFalse; use function assertStringStartsWith; -use function assertThat; use function count; use function current; use function explode; use function implode; -use function isType; use function key; -use function logicalOr; use function parse_url; use function strlen; use function strpos; @@ -102,76 +94,33 @@ public function createEntities(array $entities) } } - public static function createReadConcern(stdClass $o) : ReadConcern + public function getEntityMap() : EntityMap { - self::assertHasOnlyKeys($o, ['level']); - - $level = $o->level ?? null; - assertInternalType('string', $level); - - return new ReadConcern($level); + return $this->entityMap; } - public static function createReadPreference(stdClass $o) : ReadPreference + public function getInternalClient() : Client { - self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); - - $mode = $o->mode ?? null; - $tagSets = $o->tagSets ?? null; - $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; - $hedge = $o->hedge ?? null; - - assertInternalType('string', $mode); - - if (isset($tagSets)) { - assertInternalType('array', $tagSets); - assertContains('object', $tagSets); - } - - $options = []; - - if (isset($maxStalenessSeconds)) { - assertInternalType('int', $maxStalenessSeconds); - $options['maxStalenessSeconds'] = $maxStalenessSeconds; - } - - if (isset($hedge)) { - assertInternalType('object', $hedge); - $options['hedge'] = $hedge; - } - - return new ReadPreference($mode, $tagSets, $options); + return $this->internalClient; } - public static function createWriteConcern(stdClass $o) : WriteConcern + public function assertExpectedEventsForClients(array $expectedEventsForClients) { - self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); + assertNotEmpty($expectedEventsForClients); - $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ - $wtimeoutMS = $o->wtimeoutMS ?? 0; - $journal = $o->journal ?? null; + foreach ($expectedEventsForClients as $expectedEventsForClient) { + assertInternalType('object', $expectedEventsForClient); + Util::assertHasOnlyKeys($expectedEventsForClient, ['client', 'events']); - assertThat($w, logicalOr(isType('int'), isType('string'))); - assertInternalType('int', $wtimeoutMS); + $client = $expectedEventsForClient->client ?? null; + $expectedEvents = $expectedEventsForClient->events ?? null; - $args = [$w, $wtimeoutMS]; + assertInternalType('string', $client); + assertArrayHasKey($client, $this->eventObserversByClient); + assertInternalType('array', $expectedEvents); - if (isset($journal)) { - assertInternalType('bool', $journal); - $args[] = $journal; + $this->eventObserversByClient[$client]->assert($expectedEvents); } - - return new WriteConcern(...$args); - } - - public function getEntityMap() : EntityMap - { - return $this->entityMap; - } - - public function getInternalClient() : Client - { - return $this->internalClient; } public function startEventObservers() @@ -188,16 +137,16 @@ public function stopEventObservers() } } - private static function assertHasOnlyKeys($arrayOrObject, array $keys) + public function getEventObserverForClient(string $id) : EventObserver { - assertThat($arrayOrObject, logicalOr(isType('array'), isType('object'))); - $diff = array_diff_key((array) $arrayOrObject, array_fill_keys($keys, 1)); - assertEmpty($diff, 'Unsupported keys: ' . implode(',', array_keys($diff))); + assertArrayHasKey($id, $this->eventObserversByClient); + + return $this->eventObserversByClient[$id]; } private function createClient(stdClass $o) : Client { - self::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); + Util::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); $useMultipleMongoses = $o->useMultipleMongoses ?? null; $observeEvents = $o->observeEvents ?? null; @@ -229,7 +178,7 @@ private function createClient(stdClass $o) : Client assertInternalType('array', $observeEvents); assertInternalType('array', $ignoreCommandMonitoringEvents); - $this->eventObserversByClient[$o->id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents); + $this->eventObserversByClient[$o->id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents, $o->id, $this->entityMap); } return new Client($uri, $uriOptions); @@ -237,7 +186,7 @@ private function createClient(stdClass $o) : Client private function createCollection(stdClass $o) : Collection { - self::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); + Util::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); $collectionName = $o->collectionName ?? null; $database = $o->database ?? null; @@ -260,7 +209,7 @@ private function createCollection(stdClass $o) : Collection private function createDatabase(stdClass $o) : Database { - self::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); + Util::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); $databaseName = $o->databaseName ?? null; $client = $o->client ?? null; @@ -283,21 +232,21 @@ private function createDatabase(stdClass $o) : Database private static function prepareCollectionOrDatabaseOptions(array $options) : array { - self::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); + Util::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); if (array_key_exists('readConcern', $options)) { assertInternalType('object', $options['readConcern']); - $options['readConcern'] = self::createReadConcern($options['readConcern']); + $options['readConcern'] = Util::createReadConcern($options['readConcern']); } if (array_key_exists('readPreference', $options)) { assertInternalType('object', $options['readPreference']); - $options['readPreference'] = self::createReadPreference($options['readPreference']); + $options['readPreference'] = Util::createReadPreference($options['readPreference']); } if (array_key_exists('writeConcern', $options)) { assertInternalType('object', $options['writeConcern']); - $options['writeConcern'] = self::createWriteConcern($options['writeConcern']); + $options['writeConcern'] = Util::createWriteConcern($options['writeConcern']); } return $options; diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index ca67ec463..30c6516bf 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -2,16 +2,31 @@ namespace MongoDB\Tests\UnifiedSpecTests; +use ArrayIterator; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use MongoDB\Tests\UnifiedSpecTests\Constraint\Matches; +use MultipleIterator; +use PHPUnit\Framework\Assert; +use stdClass; use function array_fill_keys; use function assertArrayHasKey; +use function assertCount; +use function assertInstanceOf; use function assertInternalType; use function assertNotEmpty; +use function assertNotNull; +use function assertSame; +use function assertThat; +use function count; +use function current; +use function get_class; +use function key; use function MongoDB\Driver\Monitoring\addSubscriber; use function MongoDB\Driver\Monitoring\removeSubscriber; +use function sprintf; class EventObserver implements CommandSubscriber { @@ -32,30 +47,36 @@ class EventObserver implements CommandSubscriber 'isMaster', ]; + /** @var array */ + private static $supportedEvents = [ + 'commandStartedEvent' => CommandStartedEvent::class, + 'commandSucceededEvent' => CommandSucceededEvent::class, + 'commandFailedEvent' => CommandFailedEvent::class, + ]; + /** @var array */ private $actualEvents = []; + /** @var string */ + private $clientId; + + /** @var EntityMap */ + private $entityMap; + /** @var array */ private $ignoreCommands = []; /** @var array */ private $observeEvents = []; - /** @var array */ - private static $commandMonitoringEvents = [ - 'commandStartedEvent' => CommandStartedEvent::class, - 'commandSucceededEvent' => CommandSucceededEvent::class, - 'commandFailedEvent' => CommandFailedEvent::class, - ]; - - public function __construct(array $observeEvents, array $ignoreCommands) + public function __construct(array $observeEvents, array $ignoreCommands, string $clientId, EntityMap $entityMap) { assertNotEmpty($observeEvents); foreach ($observeEvents as $event) { assertInternalType('string', $event); - assertArrayHasKey($event, self::$commandMonitoringEvents); - $this->observeEvents[self::$commandMonitoringEvents[$event]] = 1; + assertArrayHasKey($event, self::$supportedEvents); + $this->observeEvents[self::$supportedEvents[$event]] = 1; } $this->ignoreCommands = array_fill_keys(self::$defaultIgnoreCommands, 1); @@ -64,6 +85,9 @@ public function __construct(array $observeEvents, array $ignoreCommands) assertInternalType('string', $command); $this->ignoreCommands[$command] = 1; } + + $this->clientId = $clientId; + $this->entityMap = $entityMap; } /** @@ -128,4 +152,98 @@ public function stop() { removeSubscriber($this); } + + public function assert(array $expectedEvents) + { + assertCount(count($expectedEvents), $this->actualEvents); + + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($expectedEvents)); + $mi->attachIterator(new ArrayIterator($this->actualEvents)); + + foreach ($mi as $i => $events) { + list($expectedEvent, $actualEvent) = $events; + // TODO: assertNotNull may be redundant since counts are equal + assertNotNull($expectedEvent); + assertNotNull($actualEvent); + + assertInternalType('object', $expectedEvent); + $expectedEvent = (array) $expectedEvent; + assertCount(1, $expectedEvent); + + $type = key($expectedEvent); + assertArrayHasKey($type, self::$supportedEvents); + $data = current($expectedEvent); + assertInternalType('object', $data); + + // Message is used for actual event assertions (not test structure) + $message = sprintf('%s event[%d]', $this->clientId, $i); + + assertInstanceOf(self::$supportedEvents[$type], $actualEvent, $message . ': type matches'); + $this->assertEvent($actualEvent, $data, $message); + } + } + + private function assertEvent($actual, stdClass $expected, string $message) + { + assertInternalType('object', $actual); + + switch (get_class($actual)) { + case CommandStartedEvent::class: + return $this->assertCommandStartedEvent($actual, $expected, $message); + case CommandSucceededEvent::class: + return $this->assertCommandSucceededEvent($actual, $expected, $message); + case CommandFailedEvent::class: + return $this->assertCommandFailedEvent($actual, $expected, $message); + default: + Assert::fail($message . ': Unsupported event type: ' . get_class($actual)); + } + } + + private function assertCommandStartedEvent(CommandStartedEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['command', 'commandName', 'databaseName']); + + if (isset($expected->command)) { + assertInternalType('object', $expected->command); + $constraint = new Matches($expected->command, $this->entityMap); + assertThat($actual->getCommand(), $constraint, $message . ': command matches'); + } + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + + if (isset($expected->databaseName)) { + assertInternalType('string', $expected->databaseName); + assertSame($actual->getDatabaseName(), $expected->databaseName, $message . ': databaseName matches'); + } + } + + private function assertCommandSucceededEvent(CommandSucceededEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['reply', 'commandName']); + + if (isset($expected->reply)) { + assertInternalType('object', $expected->reply); + $constraint = new Matches($expected->reply, $this->entityMap); + assertThat($actual->getReply(), $constraint, $message . ': reply matches'); + } + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + } + + private function assertCommandFailedEvent(CommandFailedEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['commandName']); + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + } } diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index e0c84b62a..326031363 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -62,7 +62,7 @@ final class ExpectedError /** @var ExpectedResult */ private $expectedResult; - public function __construct(Context $context, stdClass $o = null) + public function __construct(stdClass $o = null, EntityMap $entityMap) { if ($o === null) { return; @@ -107,7 +107,7 @@ public function __construct(Context $context, stdClass $o = null) } if (property_exists($o, 'expectResult')) { - $this->expectedResult = new ExpectedResult($context, $o); + $this->expectedResult = new ExpectedResult($o, $entityMap); } } diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php index c876eab85..61b30aee7 100644 --- a/tests/UnifiedSpecTests/ExpectedResult.php +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -16,19 +16,19 @@ final class ExpectedResult { - /** @var Context */ - private $context; - /** @var Matches */ private $constraint; - public function __construct(Context $context, stdClass $o) + /** @var EntityMap */ + private $entityMap; + + public function __construct(stdClass $o, EntityMap $entityMap) { if (property_exists($o, 'expectResult')) { - $this->constraint = new Matches($o->expectResult, $context->getEntityMap()); + $this->constraint = new Matches($o->expectResult, $entityMap); } - $this->context = $context; + $this->entityMap = $entityMap; } public function assert($actual, string $saveResultAsEntity = null) @@ -44,15 +44,10 @@ public function assert($actual, string $saveResultAsEntity = null) } if ($saveResultAsEntity !== null) { - $entityMap[$saveResultAsEntity] = $actual; + $this->entityMap[$saveResultAsEntity] = $actual; } } - public function saveResultAsEntity($actual, $id) - { - $this->context->getEntityMap()[$id] = self::prepare($actual); - } - private static function prepare($value) { if (! is_object($value)) { diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 4540aa776..549eaa979 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -47,6 +47,9 @@ final class Operation /** @var array */ private $arguments = []; + /** @var Context */ + private $context; + /** @var EntityMap */ private $entityMap; @@ -59,8 +62,9 @@ final class Operation /** @var string */ private $saveResultAsEntity; - public function __construct(Context $context, stdClass $o) + public function __construct(stdClass $o, Context $context) { + $this->context =$context; $this->entityMap = $context->getEntityMap(); assertInternalType('string', $o->name); @@ -78,8 +82,8 @@ public function __construct(Context $context, stdClass $o) Assert::fail('expectError is mutually exclusive with expectResult and saveResultAsEntity'); } - $this->expectError = new ExpectedError($context, $o->expectError ?? null); - $this->expectResult = new ExpectedResult($context, $o); + $this->expectError = new ExpectedError($o->expectError ?? null, $this->entityMap); + $this->expectResult = new ExpectedResult($o, $this->entityMap); if (isset($o->saveResultAsEntity)) { assertInternalType('string', $o->saveResultAsEntity); @@ -132,6 +136,9 @@ private function execute() case Collection::class: $result = $this->executeForCollection($object); break; + case ChangeStream::class: + $result = $this->executeForChangeStream($object); + break; default: Assert::fail('Unsupported entity type: ' . get_class($object)); } @@ -143,16 +150,50 @@ private function execute() return $result; } + private function executeForChangeStream(ChangeStream $changeStream) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'iterateUntilDocumentOrError': + /* Note: the first iteration should use rewind, otherwise we may + * miss a document from the initial batch (possible if using a + * resume token). We can infer this from a null key; however, + * if a test ever calls this operation consecutively to expect + * multiple errors from the same ChangeStream we will need a + * different approach (e.g. examining internal hasAdvanced + * property on the ChangeStream). */ + if ($changeStream->key() === null) { + $changeStream->rewind(); + + if ($changeStream->valid()) { + return $changeStream->current(); + } + } + + do { + $changeStream->next(); + } while (! $changeStream->valid()); + + return $changeStream->current(); + default: + Assert::fail('Unsupported client operation: ' . $this->name); + } + } + private function executeForClient(Client $client) { $args = $this->prepareArguments(); switch ($this->name) { case 'createChangeStream': - return $client->watch( + $changeStream = $client->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); + $changeStream->rewind(); + + return $changeStream; case 'listDatabaseNames': return $client->listDatabaseNames($args); case 'listDatabases': @@ -178,10 +219,13 @@ private function executeForCollection(Collection $collection) array_diff_key($args, ['requests' => 1]) ); case 'createChangeStream': - return $collection->watch( + $changeStream = $collection->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); + $changeStream->rewind(); + + return $changeStream; case 'createIndex': return $collection->createIndex( $args['keys'], @@ -292,10 +336,13 @@ private function executeForDatabase(Database $database) array_diff_key($args, ['pipeline' => 1]) ); case 'createChangeStream': - return $database->watch( + $changeStream = $database->watch( $args['pipeline'] ?? [], array_diff_key($args, ['pipeline' => 1]) ); + $changeStream->rewind(); + + return $changeStream; case 'createCollection': return $database->createCollection( $args['collection'], diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 7ac9d8387..54db4dcf7 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -74,10 +74,12 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r } if (isset($test->skipReason)) { + $this->assertInternalType('string', $test->skipReason); $this->markTestSkipped($test->skipReason); } if (isset($test->runOnRequirements)) { + $this->assertInternalType('array', $test->runOnRequirements); $this->checkRunOnRequirements($test->runOnRequirements); } @@ -93,20 +95,24 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r // TODO handle distinct commands in sharded transactions - if (isset($test->expectedEvents)) { - $context->startEventObservers(); - } + $context->startEventObservers(); + + $this->assertInternalType('array', $test->operations); foreach ($test->operations as $o) { - $operation = new Operation($context, $o); + $operation = new Operation($o, $context); $operation->assert(); } - if (isset($test->expectedEvents)) { - $context->stopEventObservers(); + $context->stopEventObservers(); + + if (isset($test->expectEvents)) { + $this->assertInternalType('array', $test->expectEvents); + $context->assertExpectedEventsForClients($test->expectEvents); } if (isset($test->outcome)) { + $this->assertInternalType('array', $test->outcome); $this->assertOutcome($test->outcome); } } @@ -173,6 +179,7 @@ private function provideTests(string $dir) private function checkRunOnRequirements(array $runOnRequirements) { $this->assertNotEmpty($runOnRequirements); + $this->assertContainsOnly('object', $runOnRequirements); $serverVersion = $this->getCachedServerVersion(); $topology = $this->getCachedTopology(); @@ -284,6 +291,7 @@ private static function killAllSessions() private function assertOutcome(array $outcome) { $this->assertNotEmpty($outcome); + $this->assertContainsOnly('object', $outcome); foreach ($outcome as $data) { $collectionData = new CollectionData($data); @@ -294,6 +302,7 @@ private function assertOutcome(array $outcome) private function prepareInitialData(array $initialData) { $this->assertNotEmpty($initialData); + $this->assertContainsOnly('object', $initialData); foreach ($initialData as $data) { $collectionData = new CollectionData($data); From 82c95f5df71b545859035c60bec9769e21d93a24 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 01:26:35 +0800 Subject: [PATCH 18/41] Implement GridFS, transactions, and collate events by client --- tests/UnifiedSpecTests/Constraint/Matches.php | 6 +- .../Constraint/MatchesTest.php | 8 +- tests/UnifiedSpecTests/Context.php | 162 +++++++++++++++--- tests/UnifiedSpecTests/EntityMap.php | 70 ++++++-- tests/UnifiedSpecTests/EventObserver.php | 28 ++- tests/UnifiedSpecTests/ExpectedResult.php | 15 +- tests/UnifiedSpecTests/Operation.php | 145 +++++++++++----- tests/UnifiedSpecTests/Util.php | 112 ++++++++++++ 8 files changed, 440 insertions(+), 106 deletions(-) create mode 100644 tests/UnifiedSpecTests/Util.php diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 0e503cc8b..80bba0296 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -268,9 +268,9 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ if ($name === '$$matchesHexBytes') { assertInternalType('string', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires string'); assertRegExp('/^([0-9a-fA-F]{2})*$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); - assertThat($actual, new IsStream()); + assertInternalType('string', $actual); - if (stream_get_contents($actual, -1, 0) !== hex2bin($operator['$$matchesHexBytes'])) { + if ($actual !== hex2bin($operator['$$matchesHexBytes'])) { self::failAt(sprintf('%s does not match expected hex bytes: %s', $this->exporter()->shortenedExport($actual), $operator['$$matchesHexBytes']), $keyPath); } @@ -310,7 +310,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ return; } - throw new LogicException('unsupported operator: ' . $operator); + throw new LogicException('unsupported operator: ' . $name); } /** @see ConstraintTrait */ diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 8e9181fe2..0cac122e0 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -76,8 +76,8 @@ public function testOperatorType() public function testOperatorMatchesEntity() { $entityMap = new EntityMap(); - $entityMap['integer'] = 1; - $entityMap['object'] = ['y' => 1]; + $entityMap->set('integer', 1); + $entityMap->set('object', ['y' => 1]); $c = new Matches(['x' => ['$$matchesEntity' => 'integer']], $entityMap); $this->assertResult(true, $c, ['x' => 1], 'value matches integer entity (embedded)'); @@ -138,7 +138,7 @@ public function testOperatorSessionLsid() $session = $this->manager->startSession(); $entityMap = new EntityMap(); - $entityMap['session'] = $session; + $entityMap->set('session', $session); $lsidWithWrongId = ['id' => new Binary('0123456789ABCDEF', Binary::TYPE_UUID)]; $lsidWithExtraField = (array) $session->getLogicalSessionId() + ['y' => 1]; @@ -255,7 +255,7 @@ public function testOperatorSyntaxValidation($expectedMessage, Matches $constrai public function operatorErrorMessageProvider() { $entityMap = new EntityMap(); - $entityMap['notSession'] = 1; + $entityMap->set('notSession', 1); return [ '$$exists type' => [ diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index e85c2c602..41f7cac5d 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -4,7 +4,6 @@ use LogicException; use MongoDB\Client; -use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadPreference; @@ -17,13 +16,18 @@ use function assertInternalType; use function assertNotEmpty; use function assertNotFalse; +use function assertRegExp; use function assertStringStartsWith; use function count; use function current; use function explode; +use function fopen; +use function fwrite; +use function hex2bin; use function implode; use function key; use function parse_url; +use function rewind; use function strlen; use function strpos; use function substr_replace; @@ -77,15 +81,27 @@ public function createEntities(array $entities) switch ($type) { case 'client': - $this->entityMap[$id] = $this->createClient($def); + $this->createClient($id, $def); break; case 'database': - $this->entityMap[$id] = $this->createDatabase($def); + $this->createDatabase($id, $def); break; case 'collection': - $this->entityMap[$id] = $this->createCollection($def); + $this->createCollection($id, $def); + break; + + case 'session': + $this->createSession($id, $def); + break; + + case 'bucket': + $this->createBucket($id, $def); + break; + + case 'stream': + $this->createStream($id, $def); break; default: @@ -104,6 +120,16 @@ public function getInternalClient() : Client return $this->internalClient; } + public function isActiveClient(string $clientId) : bool + { + return $this->activeClient === $clientId; + } + + public function setActiveClient(string $clientId = null) + { + $this->activeClient = $clientId; + } + public function assertExpectedEventsForClients(array $expectedEventsForClients) { assertNotEmpty($expectedEventsForClients); @@ -144,7 +170,7 @@ public function getEventObserverForClient(string $id) : EventObserver return $this->eventObserversByClient[$id]; } - private function createClient(stdClass $o) : Client + private function createClient(string $id, stdClass $o) { Util::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); @@ -178,23 +204,23 @@ private function createClient(stdClass $o) : Client assertInternalType('array', $observeEvents); assertInternalType('array', $ignoreCommandMonitoringEvents); - $this->eventObserversByClient[$o->id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents, $o->id, $this->entityMap); + $this->eventObserversByClient[$id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents, $id, $this); } - return new Client($uri, $uriOptions); + $this->entityMap->set($id, new Client($uri, $uriOptions)); } - private function createCollection(stdClass $o) : Collection + private function createCollection(string $id, stdClass $o) { Util::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); $collectionName = $o->collectionName ?? null; - $database = $o->database ?? null; + $databaseId = $o->database ?? null; assertInternalType('string', $collectionName); - assertInternalType('string', $database); + assertInternalType('string', $databaseId); - $database = $this->entityMap[$database]; + $database = $this->entityMap[$databaseId]; assertInstanceOf(Database::class, $database); $options = []; @@ -204,20 +230,20 @@ private function createCollection(stdClass $o) : Collection $options = self::prepareCollectionOrDatabaseOptions((array) $o->collectionOptions); } - return $database->selectCollection($o->collectionName, $options); + $this->entityMap->set($id, $database->selectCollection($o->collectionName, $options), $databaseId); } - private function createDatabase(stdClass $o) : Database + private function createDatabase(string $id, stdClass $o) { Util::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); $databaseName = $o->databaseName ?? null; - $client = $o->client ?? null; + $clientId = $o->client ?? null; assertInternalType('string', $databaseName); - assertInternalType('string', $client); + assertInternalType('string', $clientId); - $client = $this->entityMap[$client]; + $client = $this->entityMap[$clientId]; assertInstanceOf(Client::class, $client); $options = []; @@ -227,31 +253,115 @@ private function createDatabase(stdClass $o) : Database $options = self::prepareCollectionOrDatabaseOptions((array) $o->databaseOptions); } - return $client->selectDatabase($databaseName, $options); + $this->entityMap->set($id, $client->selectDatabase($databaseName, $options), $clientId); + } + + private function createSession(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'client', 'sessionOptions']); + + $clientId = $o->client ?? null; + assertInternalType('string', $clientId); + $client = $this->entityMap[$clientId]; + assertInstanceOf(Client::class, $client); + + $options = []; + + if (isset($o->sessionOptions)) { + assertInternalType('object', $o->sessionOptions); + $options = self::prepareSessionOptions((array) $o->sessionOptions); + } + + $this->entityMap->set($id, $client->startSession($options), $clientId); + } + + private function createBucket(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'database', 'bucketOptions']); + + $databaseId = $o->database ?? null; + assertInternalType('string', $databaseId); + $database = $this->entityMap[$databaseId]; + assertInstanceOf(Database::class, $database); + + $options = []; + + if (isset($o->bucketOptions)) { + assertInternalType('object', $o->bucketOptions); + $options = self::prepareBucketOptions((array) $o->bucketOptions); + } + + $this->entityMap->set($id, $database->selectGridFSBucket($options), $databaseId); + } + + private function createStream(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'hexBytes']); + + $hexBytes = $o->hexBytes ?? null; + assertInternalType('string', $hexBytes); + assertRegExp('/^([0-9a-fA-F]{2})*$/', $hexBytes); + + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, hex2bin($hexBytes)); + rewind($stream); + + $this->entityMap->set($id, $stream); } private static function prepareCollectionOrDatabaseOptions(array $options) : array { Util::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); - if (array_key_exists('readConcern', $options)) { - assertInternalType('object', $options['readConcern']); - $options['readConcern'] = Util::createReadConcern($options['readConcern']); + return Util::prepareCommonOptions($options); + } + + private static function prepareBucketOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['bucketName', 'chunkSizeBytes', 'disableMD5', 'readConcern', 'readPreference', 'writeConcern']); + + if (array_key_exists('bucketName', $options)) { + assertInternalType('string', $options['bucketName']); } - if (array_key_exists('readPreference', $options)) { - assertInternalType('object', $options['readPreference']); - $options['readPreference'] = Util::createReadPreference($options['readPreference']); + if (array_key_exists('chunkSizeBytes', $options)) { + assertInternalType('int', $options['chunkSizeBytes']); } - if (array_key_exists('writeConcern', $options)) { - assertInternalType('object', $options['writeConcern']); - $options['writeConcern'] = Util::createWriteConcern($options['writeConcern']); + if (array_key_exists('disableMD5', $options)) { + assertInternalType('bool', $options['disableMD5']); + } + + return Util::prepareCommonOptions($options); + } + + private static function prepareSessionOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['causalConsistency', 'defaultTransactionOptions']); + + if (array_key_exists('causalConsistency', $options)) { + assertInternalType('bool', $options['causalConsistency']); + } + + if (array_key_exists('defaultTransactionOptions', $options)) { + assertInternalType('object', $options['defaultTransactionOptions']); + $options['defaultTransactionOptions'] = self::prepareDefaultTransactionOptions((array) $options['defaultTransactionOptions']); } return $options; } + private static function prepareDefaultTransactionOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['maxCommitTimeMS', 'readConcern', 'readPreference', 'writeConcern']); + + if (array_key_exists('maxCommitTimeMS', $options)) { + assertInternalType('int', $options['maxCommitTimeMS']); + } + + return Util::prepareCommonOptions($options); + } + /** * Removes mongos hosts beyond the first if the URI refers to a sharded * cluster. Otherwise, the URI is returned as-is. diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 230009db4..71d970eae 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -35,8 +35,8 @@ public function __destruct() /* TODO: Determine if this is actually necessary. References to session * entities should not persist between tests. */ foreach ($this->map as $entity) { - if ($entity instanceof Session) { - $entity->endSession(); + if ($entity->value instanceof Session) { + $entity->value->endSession(); } } } @@ -44,44 +44,82 @@ public function __destruct() /** * @see http://php.net/arrayaccess.offsetexists */ - public function offsetExists($key) + public function offsetExists($id) { - assertInternalType('string', $key); + assertInternalType('string', $id); - return array_key_exists($key, $this->map); + return array_key_exists($id, $this->map); } /** * @see http://php.net/arrayaccess.offsetget */ - public function offsetGet($key) + public function offsetGet($id) { - assertInternalType('string', $key); - assertArrayHasKey($key, $this->map, sprintf('No entity is defined for "%s"', $key)); + assertInternalType('string', $id); + assertArrayHasKey($id, $this->map, sprintf('No entity is defined for "%s"', $id)); - return $this->map[$key]; + return $this->map[$id]->value; } /** * @see http://php.net/arrayaccess.offsetset */ - public function offsetSet($key, $value) + public function offsetSet($id, $value) { - assertInternalType('string', $key); - assertArrayNotHasKey($key, $this->map, sprintf('Entity already exists for key "%s" and cannot be replaced', $key)); - assertThat($value, self::isSupportedType()); - - $this->map[$key] = $value; + Assert::fail('Entities can only be set via register()'); } /** * @see http://php.net/arrayaccess.offsetunset */ - public function offsetUnset($key) + public function offsetUnset($id) { Assert::fail('Entities cannot be removed from the map'); } + public function set(string $id, $value, string $parentId = null) + { + assertArrayNotHasKey($id, $this->map, sprintf('Entity already exists for "%s" and cannot be replaced', $id)); + assertThat($value, self::isSupportedType()); + + $parent = $parentId === null ? null : $this->map[$parentId]; + + $this->map[$id] = new class ($id, $value, $parent) { + /** @var string */ + public $id; + /** @var mixed */ + public $value; + /** @var self */ + public $parent; + + public function __construct(string $id, $value, self $parent = null) + { + $this->id = $id; + $this->value = $value; + $this->parent = $parent; + } + + public function getRoot() : self + { + $root = $this; + + while ($root->parent !== null) { + $root = $root->parent; + } + + return $root; + } + }; + } + + public function getRootClientIdOf(string $id) : ?string + { + $root = $this->map[$id]->getRoot(); + + return $root->value instanceof Client ? $root->id : null; + } + private static function isSupportedType() : Constraint { if (self::$isSupportedType === null) { diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 30c6516bf..6eb335b40 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -60,8 +60,8 @@ class EventObserver implements CommandSubscriber /** @var string */ private $clientId; - /** @var EntityMap */ - private $entityMap; + /** @var Context */ + private $context; /** @var array */ private $ignoreCommands = []; @@ -69,7 +69,7 @@ class EventObserver implements CommandSubscriber /** @var array */ private $observeEvents = []; - public function __construct(array $observeEvents, array $ignoreCommands, string $clientId, EntityMap $entityMap) + public function __construct(array $observeEvents, array $ignoreCommands, string $clientId, Context $context) { assertNotEmpty($observeEvents); @@ -87,7 +87,7 @@ public function __construct(array $observeEvents, array $ignoreCommands, string } $this->clientId = $clientId; - $this->entityMap = $entityMap; + $this->context = $context; } /** @@ -95,6 +95,10 @@ public function __construct(array $observeEvents, array $ignoreCommands, string */ public function commandFailed(CommandFailedEvent $event) { + if (! $this->context->isActiveClient($this->clientId)) { + return; + } + if (! isset($this->observeEvents[CommandFailedEvent::class])) { return; } @@ -111,6 +115,10 @@ public function commandFailed(CommandFailedEvent $event) */ public function commandStarted(CommandStartedEvent $event) { + if (! $this->context->isActiveClient($this->clientId)) { + return; + } + if (! isset($this->observeEvents[CommandStartedEvent::class])) { return; } @@ -127,6 +135,10 @@ public function commandStarted(CommandStartedEvent $event) */ public function commandSucceeded(CommandSucceededEvent $event) { + if (! $this->context->isActiveClient($this->clientId)) { + return; + } + if (! isset($this->observeEvents[CommandSucceededEvent::class])) { return; } @@ -161,7 +173,7 @@ public function assert(array $expectedEvents) $mi->attachIterator(new ArrayIterator($expectedEvents)); $mi->attachIterator(new ArrayIterator($this->actualEvents)); - foreach ($mi as $i => $events) { + foreach ($mi as $keys => $events) { list($expectedEvent, $actualEvent) = $events; // TODO: assertNotNull may be redundant since counts are equal assertNotNull($expectedEvent); @@ -177,7 +189,7 @@ public function assert(array $expectedEvents) assertInternalType('object', $data); // Message is used for actual event assertions (not test structure) - $message = sprintf('%s event[%d]', $this->clientId, $i); + $message = sprintf('%s event[%d]', $this->clientId, $keys[0]); assertInstanceOf(self::$supportedEvents[$type], $actualEvent, $message . ': type matches'); $this->assertEvent($actualEvent, $data, $message); @@ -206,7 +218,7 @@ private function assertCommandStartedEvent(CommandStartedEvent $actual, stdClass if (isset($expected->command)) { assertInternalType('object', $expected->command); - $constraint = new Matches($expected->command, $this->entityMap); + $constraint = new Matches($expected->command, $this->context->getEntityMap()); assertThat($actual->getCommand(), $constraint, $message . ': command matches'); } @@ -227,7 +239,7 @@ private function assertCommandSucceededEvent(CommandSucceededEvent $actual, stdC if (isset($expected->reply)) { assertInternalType('object', $expected->reply); - $constraint = new Matches($expected->reply, $this->entityMap); + $constraint = new Matches($expected->reply, $this->context->getEntityMap()); assertThat($actual->getReply(), $constraint, $message . ': reply matches'); } diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php index 61b30aee7..6c4defab0 100644 --- a/tests/UnifiedSpecTests/ExpectedResult.php +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -22,13 +22,22 @@ final class ExpectedResult /** @var EntityMap */ private $entityMap; - public function __construct(stdClass $o, EntityMap $entityMap) + /** + * ID of the entity yielding the result. This is mainly used to associate + * entities with a root client for collation of observed events. + * + * @var ?string + */ + private $yieldingEntityId; + + public function __construct(stdClass $o, EntityMap $entityMap, string $yieldingEntityId = null) { if (property_exists($o, 'expectResult')) { $this->constraint = new Matches($o->expectResult, $entityMap); } $this->entityMap = $entityMap; + $this->yieldingEntityId = $yieldingEntityId; } public function assert($actual, string $saveResultAsEntity = null) @@ -39,12 +48,12 @@ public function assert($actual, string $saveResultAsEntity = null) $actual = self::prepare($actual); - if ($this->constraint) { + if ($this->constraint !== null) { assertThat($actual, $this->constraint); } if ($saveResultAsEntity !== null) { - $this->entityMap[$saveResultAsEntity] = $actual; + $this->entityMap->set($saveResultAsEntity, $actual, $this->yieldingEntityId); } } diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 549eaa979..f19ea3971 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -6,6 +6,7 @@ use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; +use MongoDB\GridFS\Bucket; use MongoDB\Driver\Server; use MongoDB\Driver\Session; use MongoDB\Model\IndexInfo; @@ -23,14 +24,17 @@ use function assertInstanceOf; use function assertInternalType; use function assertNotContains; +use function assertNotNull; use function assertNull; use function assertSame; use function assertThat; use function current; +use function equalTo; use function get_class; use function iterator_to_array; use function key; use function logicalOr; +use function MongoDB\with_transaction; use function property_exists; use function strtolower; @@ -38,10 +42,13 @@ final class Operation { const OBJECT_TEST_RUNNER = 'testRunner'; + /** @var bool */ + private $isTestRunnerOperation; + /** @var string */ private $name; - /** @var string */ + /** @var ?string */ private $object; /** @var array */ @@ -71,7 +78,8 @@ public function __construct(stdClass $o, Context $context) $this->name = $o->name; assertInternalType('string', $o->object); - $this->object = $o->object; + $this->isTestRunnerOperation = $o->object === self::OBJECT_TEST_RUNNER; + $this->object = $this->isTestRunnerOperation ? null : $o->object; if (isset($o->arguments)) { assertInternalType('object', $o->arguments); @@ -83,7 +91,7 @@ public function __construct(stdClass $o, Context $context) } $this->expectError = new ExpectedError($o->expectError ?? null, $this->entityMap); - $this->expectResult = new ExpectedResult($o, $this->entityMap); + $this->expectResult = new ExpectedResult($o, $this->entityMap, $this->object); if (isset($o->saveResultAsEntity)) { assertInternalType('string', $o->saveResultAsEntity); @@ -108,7 +116,6 @@ public function assert(bool $rethrowExceptions = false) } $this->expectError->assert($error); - // TODO: Fix saveResultAsEntity behavior $this->expectResult->assert($result, $saveResultAsEntity); // Rethrowing is primarily used for withTransaction callbacks @@ -119,13 +126,17 @@ public function assert(bool $rethrowExceptions = false) private function execute() { - if ($this->object == self::OBJECT_TEST_RUNNER) { + $this->context->setActiveClient(null); + + if ($this->isTestRunnerOperation) { return $this->executeForTestRunner(); } $object = $this->entityMap[$this->object]; assertInternalType('object', $object); + $this->context->setActiveClient($this->entityMap->getRootClientIdOf($this->object)); + switch (get_class($object)) { case Client::class: $result = $this->executeForClient($object); @@ -139,12 +150,22 @@ private function execute() case ChangeStream::class: $result = $this->executeForChangeStream($object); break; + case Session::class: + $result = $this->executeForSession($object); + break; + case Bucket::class: + $result = $this->executeForBucket($object); + break; default: Assert::fail('Unsupported entity type: ' . get_class($object)); } if ($result instanceof Traversable && ! $result instanceof ChangeStream) { - return iterator_to_array($result); + return iterator_to_array($result, false); + } + + if (is_resource($result) && get_resource_type($result) === 'stream') { + return stream_get_contents($result, -1, 0); } return $result; @@ -265,7 +286,7 @@ private function executeForCollection(Collection $collection) case 'findOneAndReplace': if (isset($args['returnDocument'])) { $args['returnDocument'] = strtolower($args['returnDocument']); - assertThat($args['returnDocument'], logicalOr(isEqual('after'), isEqual('before'))); + assertThat($args['returnDocument'], logicalOr(equalTo('after'), equalTo('before'))); $args['returnDocument'] = 'after' === $args['returnDocument'] ? FindOneAndReplace::RETURN_DOCUMENT_AFTER @@ -282,7 +303,7 @@ private function executeForCollection(Collection $collection) case 'findOneAndUpdate': if (isset($args['returnDocument'])) { $args['returnDocument'] = strtolower($args['returnDocument']); - assertThat($args['returnDocument'], logicalOr(isEqual('after'), isEqual('before'))); + assertThat($args['returnDocument'], logicalOr(equalTo('after'), equalTo('before'))); $args['returnDocument'] = 'after' === $args['returnDocument'] ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER @@ -367,39 +388,79 @@ private function executeForDatabase(Database $database) } } + private function executeForSession(Session $session) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'abortTransaction': + return $session->abortTransaction(); + case 'commitTransaction': + return $session->commitTransaction(); + case 'startTransaction': + return $session->startTransaction($args); + case 'withTransaction': + assertInternalType('array', $args['callback']); + + $operations = array_map(function ($o) { + assertInternalType('object', $o); + + return new Operation($o, $this->context); + }, $args['callback']); + + $callback = function () use ($operations) { + foreach ($operations as $operation) { + $operation->assert(true); // rethrow exceptions + } + }; + + return with_transaction($session, $callback, array_diff_key($args, ['callback' => 1])); + default: + Assert::fail('Unsupported session operation: ' . $this->name); + } + } + + private function executeForBucket(Bucket $bucket) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'delete': + return $bucket->delete($args['id']); + case 'openDownloadStream': + return $bucket->openDownloadStream($args['id']); + case 'uploadFromStream': + return $bucket->uploadFromStream( + $args['filename'], + $this->entityMap[$args['source']], + array_diff_key($args, ['filename' => 1, 'source']) + ); + default: + Assert::fail('Unsupported bucket operation: ' . $this->name); + } + } + private function executeForTestRunner() { $args = $this->prepareArguments(); switch ($this->name) { case 'assertCollectionExists': - $databaseName = $args['database']; - $collectionName = $args['collection']; - - assertContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); + assertContains($args['collectionName'], $database->listCollectionNames()); return null; case 'assertCollectionNotExists': - $databaseName = $args['database']; - $collectionName = $args['collection']; - - assertNotContains($collectionName, $context->selectDatabase($databaseName)->listCollectionNames()); + $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); + assertNotContains($args['collectionName'], $database->listCollectionNames()); return null; case 'assertIndexExists': - $databaseName = $args['database']; - $collectionName = $args['collection']; - $indexName = $args['index']; - - assertContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + assertContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); return null; case 'assertIndexNotExists': - $databaseName = $args['database']; - $collectionName = $args['collection']; - $indexName = $args['index']; - - assertNotContains($indexName, $this->getIndexNames($context, $databaseName, $collectionName)); + assertNotContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); return null; case 'assertSessionPinned': @@ -418,16 +479,22 @@ private function executeForTestRunner() return null; case 'failPoint': + assertInternalType('object', $args['failPoint']); assertInternalType('string', $args['client']); $client = $this->entityMap[$args['client']]; assertInstanceOf(Client::class, $client); - // TODO: configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); + $client->selectDatabase('admin')->command($args['failPoint']); return null; case 'targetedFailPoint': assertInstanceOf(Session::class, $args['session']); - // TODO: configureFailPoint($this->arguments['failPoint'], $args['session']->getServer()); + assertNotNull($args['session']->getServer()); + /* We could execute a command on the server directly, but using + * a client will exercise the library's pinning logic. */ + $client = $this->entityMap[$this->entityMap->getRootClientIdOf($this->arguments['session'])]; + + $client->selectDatabase('admin')->command($args['failPoint']); return null; default: @@ -435,13 +502,13 @@ private function executeForTestRunner() } } - private function getIndexNames(Context $context, string $databaseName, string $collectionName) : array + private function getIndexNames(string $databaseName, string $collectionName) : array { return array_map( function (IndexInfo $indexInfo) { return $indexInfo->getName(); }, - iterator_to_array($context->selectCollection($databaseName, $collectionName)->listIndexes()) + iterator_to_array($this->context->getInternalClient()->selectCollection($databaseName, $collectionName)->listIndexes()) ); } @@ -449,16 +516,6 @@ private function prepareArguments() : array { $args = $this->arguments; - if (array_key_exists('readConcern', $args)) { - assertInternalType('object', $args['readConcern']); - $args['readConcern'] = Context::createReadConcern($args['readConcern']); - } - - if (array_key_exists('readPreference', $args)) { - assertInternalType('object', $args['readPreference']); - $args['readPreference'] = Context::createReadPreference($args['readPreference']); - } - if (array_key_exists('session', $args)) { assertInternalType('string', $args['session']); $session = $this->entityMap[$args['session']]; @@ -466,12 +523,8 @@ private function prepareArguments() : array $args['session'] = $session; } - if (array_key_exists('writeConcern', $args)) { - assertInternalType('object', $args['writeConcern']); - $args['writeConcern'] = Context::createWriteConcern($args['writeConcern']); - } - - return $args; + // Prepare readConcern, readPreference, and writeConcern + return Util::prepareCommonOptions($args); } private function prepareBulkWriteRequest(stdClass $request) : array diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php new file mode 100644 index 000000000..129bd500f --- /dev/null +++ b/tests/UnifiedSpecTests/Util.php @@ -0,0 +1,112 @@ +level ?? null; + assertInternalType('string', $level); + + return new ReadConcern($level); + } + + public static function createReadPreference(stdClass $o) : ReadPreference + { + self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); + + $mode = $o->mode ?? null; + $tagSets = $o->tagSets ?? null; + $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; + $hedge = $o->hedge ?? null; + + assertInternalType('string', $mode); + + if (isset($tagSets)) { + assertInternalType('array', $tagSets); + assertContains('object', $tagSets); + } + + $options = []; + + if (isset($maxStalenessSeconds)) { + assertInternalType('int', $maxStalenessSeconds); + $options['maxStalenessSeconds'] = $maxStalenessSeconds; + } + + if (isset($hedge)) { + assertInternalType('object', $hedge); + $options['hedge'] = $hedge; + } + + return new ReadPreference($mode, $tagSets, $options); + } + + public static function createWriteConcern(stdClass $o) : WriteConcern + { + self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); + + $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ + $wtimeoutMS = $o->wtimeoutMS ?? 0; + $journal = $o->journal ?? null; + + assertThat($w, logicalOr(isType('int'), isType('string'))); + assertInternalType('int', $wtimeoutMS); + + $args = [$w, $wtimeoutMS]; + + if (isset($journal)) { + assertInternalType('bool', $journal); + $args[] = $journal; + } + + return new WriteConcern(...$args); + } + + public static function prepareCommonOptions(array $options) : array + { + if (array_key_exists('readConcern', $options)) { + assertInternalType('object', $options['readConcern']); + $options['readConcern'] = self::createReadConcern($options['readConcern']); + } + + if (array_key_exists('readPreference', $options)) { + assertInternalType('object', $options['readPreference']); + $options['readPreference'] = self::createReadPreference($options['readPreference']); + } + + if (array_key_exists('writeConcern', $options)) { + assertInternalType('object', $options['writeConcern']); + $options['writeConcern'] = self::createWriteConcern($options['writeConcern']); + } + + return $options; + } +} From 9455db5daea0fe7891bbde7964a1b4e39dffca7e Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 12:28:53 +0800 Subject: [PATCH 19/41] Remove stream entities and update GridFS operations --- .../UnifiedSpecTests/Constraint/IsStream.php | 23 -------- .../Constraint/IsStreamTest.php | 18 ------ tests/UnifiedSpecTests/Constraint/Matches.php | 1 - tests/UnifiedSpecTests/Context.php | 24 -------- tests/UnifiedSpecTests/EntityMap.php | 4 +- tests/UnifiedSpecTests/Operation.php | 59 +++++++++++++++---- 6 files changed, 48 insertions(+), 81 deletions(-) delete mode 100644 tests/UnifiedSpecTests/Constraint/IsStream.php delete mode 100644 tests/UnifiedSpecTests/Constraint/IsStreamTest.php diff --git a/tests/UnifiedSpecTests/Constraint/IsStream.php b/tests/UnifiedSpecTests/Constraint/IsStream.php deleted file mode 100644 index 8941b816a..000000000 --- a/tests/UnifiedSpecTests/Constraint/IsStream.php +++ /dev/null @@ -1,23 +0,0 @@ -assertTrue($c->evaluate(fopen('php://temp', 'w+b'), '', true)); - $this->assertFalse($c->evaluate(1, '', true)); - $this->assertFalse($c->evaluate('foo', '', true)); - } -} diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 80bba0296..22340f455 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -36,7 +36,6 @@ use function logicalOr; use function range; use function sprintf; -use function stream_get_contents; use function strpos; use const PHP_INT_SIZE; diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 41f7cac5d..910003dbb 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -16,18 +16,13 @@ use function assertInternalType; use function assertNotEmpty; use function assertNotFalse; -use function assertRegExp; use function assertStringStartsWith; use function count; use function current; use function explode; -use function fopen; -use function fwrite; -use function hex2bin; use function implode; use function key; use function parse_url; -use function rewind; use function strlen; use function strpos; use function substr_replace; @@ -100,10 +95,6 @@ public function createEntities(array $entities) $this->createBucket($id, $def); break; - case 'stream': - $this->createStream($id, $def); - break; - default: throw new LogicException('Unsupported entity type: ' . $type); } @@ -294,21 +285,6 @@ private function createBucket(string $id, stdClass $o) $this->entityMap->set($id, $database->selectGridFSBucket($options), $databaseId); } - private function createStream(string $id, stdClass $o) - { - Util::assertHasOnlyKeys($o, ['id', 'hexBytes']); - - $hexBytes = $o->hexBytes ?? null; - assertInternalType('string', $hexBytes); - assertRegExp('/^([0-9a-fA-F]{2})*$/', $hexBytes); - - $stream = fopen('php://temp', 'w+b'); - fwrite($stream, hex2bin($hexBytes)); - rewind($stream); - - $this->entityMap->set($id, $stream); - } - private static function prepareCollectionOrDatabaseOptions(array $options) : array { Util::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 71d970eae..02189b737 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -10,7 +10,6 @@ use MongoDB\Driver\Session; use MongoDB\GridFS\Bucket; use MongoDB\Tests\UnifiedSpecTests\Constraint\IsBsonType; -use MongoDB\Tests\UnifiedSpecTests\Constraint\IsStream; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\Constraint; use function array_key_exists; @@ -130,8 +129,7 @@ private static function isSupportedType() : Constraint isInstanceOf(Session::class), isInstanceOf(Bucket::class), isInstanceOf(ChangeStream::class), - IsBsonType::any(), - new IsStream() + IsBsonType::any() ); } diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index f19ea3971..eddb57bcf 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -6,9 +6,9 @@ use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; -use MongoDB\GridFS\Bucket; use MongoDB\Driver\Server; use MongoDB\Driver\Session; +use MongoDB\GridFS\Bucket; use MongoDB\Model\IndexInfo; use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; @@ -26,16 +26,23 @@ use function assertNotContains; use function assertNotNull; use function assertNull; +use function assertObjectHasAttribute; +use function assertRegExp; use function assertSame; use function assertThat; use function current; use function equalTo; +use function fopen; +use function fwrite; use function get_class; +use function hex2bin; use function iterator_to_array; use function key; use function logicalOr; use function MongoDB\with_transaction; use function property_exists; +use function rewind; +use function stream_get_contents; use function strtolower; final class Operation @@ -161,11 +168,7 @@ private function execute() } if ($result instanceof Traversable && ! $result instanceof ChangeStream) { - return iterator_to_array($result, false); - } - - if (is_resource($result) && get_resource_type($result) === 'stream') { - return stream_get_contents($result, -1, 0); + return iterator_to_array($result); } return $result; @@ -427,13 +430,26 @@ private function executeForBucket(Bucket $bucket) switch ($this->name) { case 'delete': return $bucket->delete($args['id']); - case 'openDownloadStream': - return $bucket->openDownloadStream($args['id']); - case 'uploadFromStream': + case 'downloadByName': + return stream_get_contents($bucket->openDownloadStream( + $args['filename'], + array_diff_key($args, ['filename' => 1]) + )); + case 'download': + return stream_get_contents($bucket->openDownloadStream($args['id'])); + case 'uploadWithId': + $args['_id'] = $args['id']; + unset($args['id']); + + // Fall through + + case 'upload': + $args = self::prepareUploadArguments($args); + return $bucket->uploadFromStream( $args['filename'], - $this->entityMap[$args['source']], - array_diff_key($args, ['filename' => 1, 'source']) + $args['source'], + array_diff_key($args, ['filename' => 1, 'source' => 1]) ); default: Assert::fail('Unsupported bucket operation: ' . $this->name); @@ -527,7 +543,7 @@ private function prepareArguments() : array return Util::prepareCommonOptions($args); } - private function prepareBulkWriteRequest(stdClass $request) : array + private static function prepareBulkWriteRequest(stdClass $request) : array { $request = (array) $request; assertCount(1, $request); @@ -569,4 +585,23 @@ private function prepareBulkWriteRequest(stdClass $request) : array Assert::fail('Unsupported bulk write request: ' . $type); } } + + private static function prepareUploadArguments(array $args) : array + { + $source = $args['source'] ?? null; + assertInternalType('object', $source); + assertObjectHasAttribute('$$hexBytes', $source); + Util::assertHasOnlyKeys($source, ['$$hexBytes']); + $hexBytes = $source->{'$$hexBytes'}; + assertInternalType('string', $hexBytes); + assertRegExp('/^([0-9a-fA-F]{2})*$/', $hexBytes); + + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, hex2bin($hexBytes)); + rewind($stream); + + $args['source'] = $stream; + + return $args; + } } From 032cb4dda60503fada2f4ef4d1b8afff23fe3161 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 12:29:29 +0800 Subject: [PATCH 20/41] Todo items for Matches constraint --- tests/UnifiedSpecTests/Constraint/Matches.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 22340f455..e4ef6a6fc 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -93,6 +93,8 @@ public function evaluate($other, $description = '', $returnResult = false) $this->lastFailure = new ComparisonFailure( $this->value, $other, + /* TODO: Improve the exporter to canonicalize documents by + * sorting keys and remove spl_object_hash from output. */ $this->exporter()->export($this->value), $this->exporter()->export($other), false, @@ -255,6 +257,9 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ if ($name === '$$matchesEntity') { assertInternalType('string', $operator['$$matchesEntity'], '$$matchesEntity requires string'); + /* TODO: Consider including the entity ID in any error message to + * assist with diagnosing errors. Also consider disabling operators + * within this match, since entities are unlikely to use them. */ $this->assertMatches( self::prepare($this->entityMap[$operator['$$matchesEntity']]), $actual, From e548e8be0bb352e891539b5e53c472edd251f98d Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 12:29:45 +0800 Subject: [PATCH 21/41] Assert basic structure of test files in data provider --- tests/UnifiedSpecTests/UnifiedSpecTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 54db4dcf7..032da4243 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -11,7 +11,6 @@ use stdClass; use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; use Throwable; -use function basename; use function file_get_contents; use function glob; use function MongoDB\BSON\fromJSON; @@ -119,7 +118,7 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r public function providePassingTests() { - return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-pass/'); + return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-pass'); } /** @@ -150,7 +149,7 @@ private function provideTests(string $dir) { $testArgs = []; - foreach (glob($dir . '/poc-c*.json') as $filename) { + foreach (glob($dir . '/*.json') as $filename) { /* Decode the file through the driver's extended JSON parser to * ensure proper handling of special types. */ $json = toPHP(fromJSON(file_get_contents($filename))); @@ -160,9 +159,20 @@ private function provideTests(string $dir) $runOnRequirements = $json->runOnRequirements ?? null; $createEntities = $json->createEntities ?? null; $initialData = $json->initialData ?? null; + $tests = $json->tests; + + /* Assertions in data providers do not count towards test assertions + * but failures will interrupt the test suite with a warning. */ + $message = 'Invalid test file: ' . $filename; + $this->assertInternalType('string', $description, $message); + $this->assertInternalType('string', $schemaVersion, $message); + $this->assertInternalType('array', $tests, $message); foreach ($json->tests as $test) { - $name = basename($filename) . ': ' . $description . ': ' . $test->description; + $this->assertInternalType('object', $test, $message); + $this->assertInternalType('string', $test->description, $message); + + $name = $description . ': ' . $test->description; $testArgs[$name] = [$test, $schemaVersion, $runOnRequirements, $createEntities, $initialData]; } } From 260929793cd15ef95bcc1286bd27a0bf47c6ca3d Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 12:31:08 +0800 Subject: [PATCH 22/41] Use assertion for testFailingTests --- tests/UnifiedSpecTests/UnifiedSpecTest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 032da4243..9c3ba194d 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -135,9 +135,7 @@ public function testFailingTests(...$args) $failed = true; } - if (! $failed) { - $this->fail('Expected test to throw an exception'); - } + assertTrue($failed, 'Expected test to throw an exception'); } public function provideFailingTests() From 0fb58298ea4868e26ad01d5df209929996b03ca3 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 15:53:14 +0800 Subject: [PATCH 23/41] Implement session tests --- tests/UnifiedSpecTests/Constraint/Matches.php | 13 +-- tests/UnifiedSpecTests/Context.php | 42 +++++--- .../UnifiedSpecTests/DirtySessionObserver.php | 82 ++++++++++++++++ tests/UnifiedSpecTests/EntityMap.php | 38 ++++++++ tests/UnifiedSpecTests/EventObserver.php | 28 +++++- tests/UnifiedSpecTests/Operation.php | 97 ++++++++++++------- tests/UnifiedSpecTests/UnifiedSpecTest.php | 1 + 7 files changed, 240 insertions(+), 61 deletions(-) create mode 100644 tests/UnifiedSpecTests/DirtySessionObserver.php diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index e4ef6a6fc..0f44544ad 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -5,7 +5,6 @@ use LogicException; use MongoDB\BSON\Serializable; use MongoDB\BSON\Type; -use MongoDB\Driver\Session; use MongoDB\Model\BSONArray; use MongoDB\Model\BSONDocument; use MongoDB\Tests\UnifiedSpecTests\EntityMap; @@ -16,7 +15,6 @@ use SebastianBergmann\Comparator\Factory; use Symfony\Bridge\PhpUnit\ConstraintTrait; use function array_keys; -use function assertInstanceOf; use function assertInternalType; use function assertRegExp; use function assertThat; @@ -300,16 +298,9 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ if ($name === '$$sessionLsid') { assertInternalType('string', $operator['$$sessionLsid'], '$$sessionLsid requires string'); + $lsid = $this->entityMap->getLogicalSessionId($operator['$$sessionLsid']); - $session = $this->entityMap[$operator['$$sessionLsid']]; - - assertInstanceOf(Session::class, $session, '$$sessionLsid requires session entity'); - - $this->assertEquals( - self::prepare($session->getLogicalSessionId()), - $actual, - $keyPath - ); + $this->assertEquals(self::prepare($lsid), $actual, $keyPath); return; } diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 910003dbb..a492dbcb3 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -4,7 +4,6 @@ use LogicException; use MongoDB\Client; -use MongoDB\Database; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; @@ -12,7 +11,6 @@ use function array_key_exists; use function assertArrayHasKey; use function assertCount; -use function assertInstanceOf; use function assertInternalType; use function assertNotEmpty; use function assertNotFalse; @@ -21,6 +19,7 @@ use function current; use function explode; use function implode; +use function in_array; use function key; use function parse_url; use function strlen; @@ -36,6 +35,12 @@ */ final class Context { + /** @var string */ + private $activeClient; + + /** @var string[] */ + private $dirtySessions = []; + /** @var EntityMap */ private $entityMap; @@ -111,6 +116,20 @@ public function getInternalClient() : Client return $this->internalClient; } + public function isDirtySession(string $sessionId) : bool + { + return in_array($sessionId, $this->dirtySessions); + } + + public function markDirtySession(string $sessionId) + { + if ($this->isDirtySession($sessionId)) { + return; + } + + $this->dirtySessions[] = $sessionId; + } + public function isActiveClient(string $clientId) : bool { return $this->activeClient === $clientId; @@ -198,7 +217,12 @@ private function createClient(string $id, stdClass $o) $this->eventObserversByClient[$id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents, $id, $this); } - $this->entityMap->set($id, new Client($uri, $uriOptions)); + /* TODO: Remove this once PHPC-1645 is implemented. Each client needs + * its own libmongoc client to facilitate txnNumber assertions. */ + static $i = 0; + $driverOptions = isset($observeEvents) ? ['i' => $i++] : []; + + $this->entityMap->set($id, new Client($uri, $uriOptions, $driverOptions)); } private function createCollection(string $id, stdClass $o) @@ -211,8 +235,7 @@ private function createCollection(string $id, stdClass $o) assertInternalType('string', $collectionName); assertInternalType('string', $databaseId); - $database = $this->entityMap[$databaseId]; - assertInstanceOf(Database::class, $database); + $database = $this->entityMap->getDatabase($databaseId); $options = []; @@ -234,8 +257,7 @@ private function createDatabase(string $id, stdClass $o) assertInternalType('string', $databaseName); assertInternalType('string', $clientId); - $client = $this->entityMap[$clientId]; - assertInstanceOf(Client::class, $client); + $client = $this->entityMap->getClient($clientId); $options = []; @@ -253,8 +275,7 @@ private function createSession(string $id, stdClass $o) $clientId = $o->client ?? null; assertInternalType('string', $clientId); - $client = $this->entityMap[$clientId]; - assertInstanceOf(Client::class, $client); + $client = $this->entityMap->getClient($clientId); $options = []; @@ -272,8 +293,7 @@ private function createBucket(string $id, stdClass $o) $databaseId = $o->database ?? null; assertInternalType('string', $databaseId); - $database = $this->entityMap[$databaseId]; - assertInstanceOf(Database::class, $database); + $database = $this->entityMap->getDatabase($databaseId); $options = []; diff --git a/tests/UnifiedSpecTests/DirtySessionObserver.php b/tests/UnifiedSpecTests/DirtySessionObserver.php new file mode 100644 index 000000000..4ea7006ab --- /dev/null +++ b/tests/UnifiedSpecTests/DirtySessionObserver.php @@ -0,0 +1,82 @@ +lsid = $lsid; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandfailed.php + */ + public function commandFailed(CommandFailedEvent $event) + { + if (! in_array($event->getRequestId(), $this->requestIds)) { + return; + } + + if ($event->getError() instanceof ConnectionException) { + $this->observedNetworkError = true; + } + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandstarted.php + */ + public function commandStarted(CommandStartedEvent $event) + { + if ($this->lsid == ($event->getCommand()->lsid ?? null)) { + $this->requestIds[] = $event->getRequestId(); + } + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + } + + public function observedNetworkError() : bool + { + return $this->observedNetworkError; + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } +} diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 02189b737..72bb640f5 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -12,6 +12,7 @@ use MongoDB\Tests\UnifiedSpecTests\Constraint\IsBsonType; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\Constraint; +use stdClass; use function array_key_exists; use function assertArrayHasKey; use function assertArrayNotHasKey; @@ -26,6 +27,14 @@ class EntityMap implements ArrayAccess /** @var array */ private $map = []; + /** + * Track lsids so they can be accessed after Session::getLogicalSessionId() + * has been called. + * + * @var stdClass[] + */ + private $lsidsBySession = []; + /** @var Constraint */ private static $isSupportedType; @@ -82,6 +91,10 @@ public function set(string $id, $value, string $parentId = null) assertArrayNotHasKey($id, $this->map, sprintf('Entity already exists for "%s" and cannot be replaced', $id)); assertThat($value, self::isSupportedType()); + if ($value instanceof Session) { + $this->lsidsBySession[$id] = $value->getLogicalSessionId(); + } + $parent = $parentId === null ? null : $this->map[$parentId]; $this->map[$id] = new class ($id, $value, $parent) { @@ -112,6 +125,31 @@ public function getRoot() : self }; } + public function getClient(string $clientId) : Client + { + return $this[$clientId]; + } + + public function getCollection(string $collectionId) : Collection + { + return $this[$collectionId]; + } + + public function getDatabase(string $databaseId) : Database + { + return $this[$databaseId]; + } + + public function getSession(string $sessionId) : Session + { + return $this[$sessionId]; + } + + public function getLogicalSessionId(string $sessionId) : stdClass + { + return $this->lsidsBySession[$sessionId]; + } + public function getRootClientIdOf(string $id) : ?string { $root = $this->map[$id]->getRoot(); diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 6eb335b40..e57367bcd 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -12,12 +12,14 @@ use PHPUnit\Framework\Assert; use stdClass; use function array_fill_keys; +use function array_reverse; use function assertArrayHasKey; use function assertCount; use function assertInstanceOf; use function assertInternalType; use function assertNotEmpty; use function assertNotNull; +use function assertObjectHasAttribute; use function assertSame; use function assertThat; use function count; @@ -150,11 +152,6 @@ public function commandSucceeded(CommandSucceededEvent $event) $this->actualEvents[] = $event; } - public function getActualEvents() - { - return $this->actualEvents; - } - public function start() { addSubscriber($this); @@ -165,6 +162,27 @@ public function stop() removeSubscriber($this); } + public function getLsidsOnLastTwoCommands() : array + { + $lsids = []; + + foreach (array_reverse($this->actualEvents) as $event) { + if (! $event instanceof CommandStartedEvent) { + continue; + } + + $command = $event->getCommand(); + assertObjectHasAttribute('lsid', $command); + $lsids[] = $command->lsid; + + if (count($lsids) === 2) { + return $lsids; + } + } + + Assert::fail('Not enough CommandStartedEvents observed'); + } + public function assert(array $expectedEvents) { assertCount(count($expectedEvents), $this->actualEvents); diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index eddb57bcf..45f30c75d 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -13,6 +13,7 @@ use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\Exception as PHPUnitException; use stdClass; use Throwable; use Traversable; @@ -21,15 +22,19 @@ use function array_map; use function assertContains; use function assertCount; +use function assertEquals; +use function assertFalse; use function assertInstanceOf; use function assertInternalType; use function assertNotContains; +use function assertNotEquals; use function assertNotNull; use function assertNull; use function assertObjectHasAttribute; use function assertRegExp; use function assertSame; use function assertThat; +use function assertTrue; use function current; use function equalTo; use function fopen; @@ -115,13 +120,34 @@ public function assert(bool $rethrowExceptions = false) $result = null; $saveResultAsEntity = null; + if (isset($this->arguments['session'])) { + $dirtySessionObserver = new DirtySessionObserver($this->entityMap->getLogicalSessionId($this->arguments['session'])); + $dirtySessionObserver->start(); + } + try { $result = $this->execute(); $saveResultAsEntity = $this->saveResultAsEntity; } catch (Throwable $e) { + /* TODO: Consider adding operation details (e.g. operations[] index) + * to the exception message. Alternatively, throw a new exception + * and include this as the previous, since PHPUnit will render the + * chain when reporting a test failure. */ + if ($e instanceof PHPUnitException) { + throw $e; + } + $error = $e; } + if (isset($dirtySessionObserver)) { + $dirtySessionObserver->stop(); + + if ($dirtySessionObserver->observedNetworkError()) { + $this->context->markDirtySession($this->arguments['session']); + } + } + $this->expectError->assert($error); $this->expectResult->assert($result, $saveResultAsEntity); @@ -400,6 +426,8 @@ private function executeForSession(Session $session) return $session->abortTransaction(); case 'commitTransaction': return $session->commitTransaction(); + case 'endSession': + return $session->endSession(); case 'startTransaction': return $session->startTransaction($args); case 'withTransaction': @@ -460,59 +488,62 @@ private function executeForTestRunner() { $args = $this->prepareArguments(); + if (array_key_exists('client', $args)) { + assertInternalType('string', $args['client']); + $args['client'] = $this->entityMap->getClient($args['client']); + } + + // TODO: validate arguments switch ($this->name) { case 'assertCollectionExists': $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); assertContains($args['collectionName'], $database->listCollectionNames()); - - return null; + break; case 'assertCollectionNotExists': $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); assertNotContains($args['collectionName'], $database->listCollectionNames()); - - return null; + break; case 'assertIndexExists': assertContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); - - return null; + break; case 'assertIndexNotExists': assertNotContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); - - return null; + break; + case 'assertSameLsidOnLastTwoCommands': + $eventObserver = $this->context->getEventObserverForClient($this->arguments['client']); + assertEquals(...$eventObserver->getLsidsOnLastTwoCommands()); + break; + case 'assertDifferentLsidOnLastTwoCommands': + $eventObserver = $this->context->getEventObserverForClient($this->arguments['client']); + assertNotEquals(...$eventObserver->getLsidsOnLastTwoCommands()); + break; + case 'assertSessionDirty': + assertTrue($this->context->isDirtySession($this->arguments['session'])); + break; + case 'assertSessionNotDirty': + assertFalse($this->context->isDirtySession($this->arguments['session'])); + break; case 'assertSessionPinned': - assertInstanceOf(Session::class, $args['session']); assertInstanceOf(Server::class, $args['session']->getServer()); - - return null; + break; case 'assertSessionTransactionState': - assertInstanceOf(Session::class, $args['session']); assertSame($this->arguments['state'], $args['session']->getTransactionState()); - - return null; + break; case 'assertSessionUnpinned': - assertInstanceOf(Session::class, $args['session']); assertNull($args['session']->getServer()); - - return null; + break; case 'failPoint': - assertInternalType('object', $args['failPoint']); - assertInternalType('string', $args['client']); - $client = $this->entityMap[$args['client']]; - assertInstanceOf(Client::class, $client); - - $client->selectDatabase('admin')->command($args['failPoint']); - - return null; + $args['client']->selectDatabase('admin')->command($args['failPoint']); + break; case 'targetedFailPoint': - assertInstanceOf(Session::class, $args['session']); - assertNotNull($args['session']->getServer()); /* We could execute a command on the server directly, but using * a client will exercise the library's pinning logic. */ - $client = $this->entityMap[$this->entityMap->getRootClientIdOf($this->arguments['session'])]; - + assertNotNull($args['session']->getServer(), 'Session is pinned'); + $client = $this->entityMap->getClient( + $this->entityMap->getRootClientIdOf($this->arguments['session']) + ); $client->selectDatabase('admin')->command($args['failPoint']); - - return null; + break; default: Assert::fail('Unsupported test runner operation: ' . $this->name); } @@ -534,9 +565,7 @@ private function prepareArguments() : array if (array_key_exists('session', $args)) { assertInternalType('string', $args['session']); - $session = $this->entityMap[$args['session']]; - assertInstanceOf(Session::class, $session); - $args['session'] = $session; + $args['session'] = $this->entityMap->getSession($args['session']); } // Prepare readConcern, readPreference, and writeConcern diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 9c3ba194d..0c56b73f7 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -11,6 +11,7 @@ use stdClass; use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; use Throwable; +use function assertTrue; use function file_get_contents; use function glob; use function MongoDB\BSON\fromJSON; From f0f70aed76112058ec3da669f87588ab0ed0aff9 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 16:36:08 +0800 Subject: [PATCH 24/41] Consider PHPUnit Warnings for expectError in GridFS tests --- tests/UnifiedSpecTests/Operation.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 45f30c75d..c8fd22118 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -13,7 +13,7 @@ use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; use PHPUnit\Framework\Assert; -use PHPUnit\Framework\Exception as PHPUnitException; +use PHPUnit\Framework\AssertionFailedError; use stdClass; use Throwable; use Traversable; @@ -129,11 +129,15 @@ public function assert(bool $rethrowExceptions = false) $result = $this->execute(); $saveResultAsEntity = $this->saveResultAsEntity; } catch (Throwable $e) { - /* TODO: Consider adding operation details (e.g. operations[] index) + /* Note: we must be selective about what PHPUnit exceptions to pass + * through, as PHPUnit's Warning exception must be considered for + * expectError in GridFS tests (see: PHPLIB-592). + * + * TODO: Consider adding operation details (e.g. operations[] index) * to the exception message. Alternatively, throw a new exception * and include this as the previous, since PHPUnit will render the * chain when reporting a test failure. */ - if ($e instanceof PHPUnitException) { + if ($e instanceof AssertionFailedError) { throw $e; } From 2e0d254e43c097ef5b8e22b1449de463dee37e89 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 19:15:38 +0800 Subject: [PATCH 25/41] Disable fail points after tests --- tests/UnifiedSpecTests/Context.php | 3 +- tests/UnifiedSpecTests/FailPointObserver.php | 69 ++++++++++++++++++++ tests/UnifiedSpecTests/UnifiedSpecTest.php | 14 +++- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/UnifiedSpecTests/FailPointObserver.php diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index a492dbcb3..06121ef18 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -10,6 +10,7 @@ use stdClass; use function array_key_exists; use function assertArrayHasKey; +use function assertContains; use function assertCount; use function assertInternalType; use function assertNotEmpty; @@ -413,6 +414,6 @@ private static function requireMultipleMongoses(string $uri) return; } - assertStringContains(',', parse_url($uri, PHP_URL_HOST)); + assertContains(',', parse_url($uri, PHP_URL_HOST)); } } diff --git a/tests/UnifiedSpecTests/FailPointObserver.php b/tests/UnifiedSpecTests/FailPointObserver.php new file mode 100644 index 000000000..f7794f7fc --- /dev/null +++ b/tests/UnifiedSpecTests/FailPointObserver.php @@ -0,0 +1,69 @@ +getCommand(); + + if (! isset($command->configureFailPoint)) { + return; + } + + if (isset($command->mode) && $command->mode === 'off') { + return; + } + + $this->failPointsAndServers[] = [$command->configureFailPoint, $event->getServer()]; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + } + + public function disableFailPoints() + { + foreach ($this->failPointsAndServers as list($failPoint, $server)) { + $operation = new DatabaseCommand('admin', ['configureFailPoint' => $failPoint, 'mode' => 'off']); + $operation->execute($server); + } + + $this->failPointsAndServers = []; + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } +} diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 0c56b73f7..faec17c06 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -38,10 +38,15 @@ class UnifiedSpecTest extends FunctionalTestCase /** @var MongoDB\Client */ private static $internalClient; + /** @var FailPointObserver */ + private $failPointObserver; + private static function doSetUpBeforeClass() { parent::setUpBeforeClass(); + /* TODO: FunctionalTestCase::getUri() restricts to a single mongos by + * default. Determine if there's a need to override that. */ self::$internalClient = new Client(static::getUri()); self::killAllSessions(); } @@ -49,6 +54,9 @@ private static function doSetUpBeforeClass() private function doSetUp() { parent::setUp(); + + $this->failPointObserver = new FailPointObserver(); + $this->failPointObserver->start(); } private function doTearDown() @@ -57,6 +65,9 @@ private function doTearDown() self::killAllSessions(); } + $this->failPointObserver->stop(); + $this->failPointObserver->disableFailPoints(); + parent::tearDown(); } @@ -87,7 +98,8 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r $this->prepareInitialData($initialData); } - $context = new Context(self::$internalClient, static::getUri()); + // Give Context unmodified URI so it can enforce useMultipleMongoses + $context = new Context(self::$internalClient, static::getUri(true)); if (isset($createEntities)) { $context->createEntities($createEntities); From 6932497cbc77d0d2125e5fed4145d90152a3c0df Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 22:27:57 +0800 Subject: [PATCH 26/41] Fix targetedFailPoint operation --- tests/UnifiedSpecTests/Operation.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index c8fd22118..9480d7bae 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -10,6 +10,7 @@ use MongoDB\Driver\Session; use MongoDB\GridFS\Bucket; use MongoDB\Model\IndexInfo; +use MongoDB\Operation\DatabaseCommand; use MongoDB\Operation\FindOneAndReplace; use MongoDB\Operation\FindOneAndUpdate; use PHPUnit\Framework\Assert; @@ -540,13 +541,9 @@ private function executeForTestRunner() $args['client']->selectDatabase('admin')->command($args['failPoint']); break; case 'targetedFailPoint': - /* We could execute a command on the server directly, but using - * a client will exercise the library's pinning logic. */ assertNotNull($args['session']->getServer(), 'Session is pinned'); - $client = $this->entityMap->getClient( - $this->entityMap->getRootClientIdOf($this->arguments['session']) - ); - $client->selectDatabase('admin')->command($args['failPoint']); + $operation = new DatabaseCommand('admin', $args['failPoint']); + $operation->execute($args['session']->getServer()); break; default: Assert::fail('Unsupported test runner operation: ' . $this->name); From e78cbb85f5a5c4b81581ddc0eb3511011de68238 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 23:03:14 +0800 Subject: [PATCH 27/41] Fix MatchesTest and make EntityMap optional --- tests/UnifiedSpecTests/Constraint/Matches.php | 4 +- .../Constraint/MatchesTest.php | 55 +++++++------------ 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 0f44544ad..1f9ef904f 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -66,7 +66,7 @@ class Matches extends Constraint public function __construct($value, EntityMap $entityMap = null, $allowExtraRootKeys = true, $allowOperators = true) { $this->value = self::prepare($value); - $this->entityMap = $entityMap ?? new EntityMap(); + $this->entityMap = $entityMap; $this->allowExtraRootKeys = $allowExtraRootKeys; $this->allowOperators = $allowOperators; $this->comparatorFactory = Factory::getInstance(); @@ -253,6 +253,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$matchesEntity') { + assertNotNull($this->entityMap, '$$matchesEntity requires EntityMap'); assertInternalType('string', $operator['$$matchesEntity'], '$$matchesEntity requires string'); /* TODO: Consider including the entity ID in any error message to @@ -297,6 +298,7 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ } if ($name === '$$sessionLsid') { + assertNotNull($this->entityMap, '$$sessionLsid requires EntityMap'); assertInternalType('string', $operator['$$sessionLsid'], '$$sessionLsid requires string'); $lsid = $this->entityMap->getLogicalSessionId($operator['$$sessionLsid']); diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 0cac122e0..92ca20481 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -97,21 +97,13 @@ public function testOperatorMatchesEntity() public function testOperatorMatchesHexBytes() { - $stream1 = fopen('php://temp', 'w+b'); - fwrite($stream1, hex2bin('DEADBEEF')); - rewind($stream1); - - $stream2 = fopen('php://temp', 'w+b'); - fwrite($stream2, hex2bin('90ABCDEF')); - rewind($stream2); - $c = new Matches(['$$matchesHexBytes' => 'DEADBEEF']); - $this->assertResult(true, $c, $stream1, 'value matches hex bytes (root-level)'); - $this->assertResult(false, $c, $stream2, 'value does not match hex bytes (root-level)'); + $this->assertResult(true, $c, hex2bin('DEADBEEF'), 'value matches hex bytes (root-level)'); + $this->assertResult(false, $c, hex2bin('90ABCDEF'), 'value does not match hex bytes (root-level)'); $c = new Matches(['x' => ['$$matchesHexBytes' => '90ABCDEF']]); - $this->assertResult(true, $c, ['x' => $stream2], 'value matches hex bytes (embedded)'); - $this->assertResult(false, $c, ['x' => $stream1], 'value does not match hex bytes (embedded)'); + $this->assertResult(true, $c, ['x' => hex2bin('90ABCDEF')], 'value matches hex bytes (embedded)'); + $this->assertResult(false, $c, ['x' => hex2bin('DEADBEEF')], 'value does not match hex bytes (embedded)'); } public function testOperatorUnsetOrMatches() @@ -174,14 +166,14 @@ public function errorMessageProvider() { return [ 'assertEquals: type check (root-level)' => [ - 'string is not expected type "integer"', - new Matches(1), - '1', + 'boolean is not expected type "string"', + new Matches('foo'), + true, ], 'assertEquals: type check (embedded)' => [ - 'Field path "x": string is not expected type "integer"', - new Matches(['x' => 1]), - ['x' => '1'], + 'Field path "x": boolean is not expected type "string"', + new Matches(['x' => 'foo']), + ['x' => true], ], 'assertEquals: comparison failure (root-level)' => [ 'Failed asserting that two strings are equal.', @@ -254,9 +246,6 @@ public function testOperatorSyntaxValidation($expectedMessage, Matches $constrai public function operatorErrorMessageProvider() { - $entityMap = new EntityMap(); - $entityMap->set('notSession', 1); - return [ '$$exists type' => [ '$$exists requires bool', @@ -270,13 +259,13 @@ public function operatorErrorMessageProvider() '$$type requires string or string[]', new Matches(['x' => ['$$type' => [1]]]), ], + '$$matchesEntity requires EntityMap' => [ + '$$matchesEntity requires EntityMap', + new Matches(['x' => ['$$matchesEntity' => 'foo']]), + ], '$$matchesEntity type' => [ '$$matchesEntity requires string', - new Matches(['x' => ['$$matchesEntity' => 1]]), - ], - '$$matchesEntity undefined entity' => [ - 'No entity is defined for "undefined"', - new Matches(['$$matchesEntity' => 'undefined']), + new Matches(['x' => ['$$matchesEntity' => 1]], new EntityMap()), ], '$$matchesHexBytes type' => [ '$$matchesHexBytes requires string', @@ -286,17 +275,13 @@ public function operatorErrorMessageProvider() '$$matchesHexBytes requires pairs of hex chars', new Matches(['$$matchesHexBytes' => 'f00']), ], + '$$sessionLsid requires EntityMap' => [ + '$$sessionLsid requires EntityMap', + new Matches(['x' => ['$$sessionLsid' => 'foo']]), + ], '$$sessionLsid type' => [ '$$sessionLsid requires string', - new Matches(['x' => ['$$sessionLsid' => 1]]), - ], - '$$sessionLsid undefined entity' => [ - 'No entity is defined for "undefined"', - new Matches(['$$sessionLsid' => 'undefined']), - ], - '$$sessionLsid invalid entity' => [ - '$$sessionLsid requires session entity', - new Matches(['x' => ['$$sessionLsid' => 'notSession']], $entityMap), + new Matches(['x' => ['$$sessionLsid' => 1]], new EntityMap()), ], ]; } From 8853503f5506a466fc3d05a97c8aa54f94507b4b Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 23:31:55 +0800 Subject: [PATCH 28/41] Note cycling references and killAllSessions before each test --- tests/UnifiedSpecTests/Constraint/Matches.php | 1 + .../Constraint/MatchesTest.php | 3 --- tests/UnifiedSpecTests/EntityMap.php | 5 +++- tests/UnifiedSpecTests/UnifiedSpecTest.php | 23 +++++++++++++++---- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index 1f9ef904f..0b6faaaf1 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -16,6 +16,7 @@ use Symfony\Bridge\PhpUnit\ConstraintTrait; use function array_keys; use function assertInternalType; +use function assertNotNull; use function assertRegExp; use function assertThat; use function containsOnly; diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 92ca20481..aa825a6c3 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -7,10 +7,7 @@ use MongoDB\Tests\UnifiedSpecTests\EntityMap; use PHPUnit\Framework\ExpectationFailedException; use stdClass; -use function fopen; -use function fwrite; use function hex2bin; -use function rewind; use function version_compare; class MatchesTest extends FunctionalTestCase diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index 72bb640f5..f546c61de 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -41,7 +41,10 @@ class EntityMap implements ArrayAccess public function __destruct() { /* TODO: Determine if this is actually necessary. References to session - * entities should not persist between tests. */ + * entities should not persist between tests. + * + * Note: This does not appear to trigger after a test due to cyclic + * references (see comment in UnifiedSpecTest.php). */ foreach ($this->map as $entity) { if ($entity->value instanceof Session) { $entity->value->endSession(); diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index faec17c06..462edea0b 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -3,10 +3,10 @@ namespace MongoDB\Tests\UnifiedSpecTests; use MongoDB\Client; -use MongoDB\Driver\Command; use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; +use MongoDB\Operation\DatabaseCommand; use MongoDB\Tests\FunctionalTestCase; use stdClass; use Symfony\Bridge\PhpUnit\SetUpTearDownTrait; @@ -45,9 +45,9 @@ private static function doSetUpBeforeClass() { parent::setUpBeforeClass(); - /* TODO: FunctionalTestCase::getUri() restricts to a single mongos by - * default. Determine if there's a need to override that. */ - self::$internalClient = new Client(static::getUri()); + /* Provide internal client unmodified URI, since it may need to execute + * commands on multiple mongoses (e.g. killAllSessions) */ + self::$internalClient = new Client(static::getUri(true)); self::killAllSessions(); } @@ -55,6 +55,13 @@ private function doSetUp() { parent::setUp(); + /* TODO: The transactions spec advises calling killAllSessions only at + * the start of the test suite and after failed tests; however, the + * "unpin after transient error within a transaction" pinning test + * causes the subsequent transaction test to block. This can be removed + * once that is addressed. */ + self::killAllSessions(); + $this->failPointObserver = new FailPointObserver(); $this->failPointObserver->start(); } @@ -68,6 +75,10 @@ private function doTearDown() $this->failPointObserver->stop(); $this->failPointObserver->disableFailPoints(); + /* TODO: Consider manually invoking gc_collect_cycles since each test is + * prone to create cycles (perhaps due to EntityMap), which can leak and + * prevent sessions from being released back into the pool. */ + parent::tearDown(); } @@ -299,7 +310,9 @@ private static function killAllSessions() if (! isset($server->getInfo()['logicalSessionTimeoutMinutes'])) { continue; } - $server->executeCommand('admin', new Command(['killAllSessions' => []])); + + $command = new DatabaseCommand('admin', ['killAllSessions' => []]); + $command->execute($server); } catch (ServerException $e) { // Interrupted error is safe to ignore (see: SERVER-38335) if ($e->getCode() != self::SERVER_ERROR_INTERRUPTED) { From 24347005da870ad686fdea1f9d3584b4e5d11b94 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 7 Oct 2020 23:36:58 +0800 Subject: [PATCH 29/41] Sync unified spec tests --- tests/UnifiedSpecTests/UnifiedSpecTest.php | 4 +- .../entity-bucket-database-undefined.json | 18 + .../entity-collection-database-undefined.json | 19 + .../entity-database-client-undefined.json | 19 + .../entity-session-client-undefined.json | 18 + .../valid-fail/schemaVersion-unsupported.json | 10 + .../valid-pass/poc-change-streams.json | 410 ++++++++++++++ .../valid-pass/poc-command-monitoring.json | 222 ++++++++ .../UnifiedSpecTests/valid-pass/poc-crud.json | 446 ++++++++++++++++ .../valid-pass/poc-gridfs.json | 299 +++++++++++ .../valid-pass/poc-retryable-reads.json | 433 +++++++++++++++ .../valid-pass/poc-retryable-writes.json | 481 +++++++++++++++++ .../valid-pass/poc-sessions.json | 466 ++++++++++++++++ .../poc-transactions-convenient-api.json | 505 ++++++++++++++++++ .../poc-transactions-mongos-pin-auto.json | 409 ++++++++++++++ .../valid-pass/poc-transactions.json | 322 +++++++++++ 16 files changed, 4079 insertions(+), 2 deletions(-) create mode 100644 tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json create mode 100644 tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json create mode 100644 tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json create mode 100644 tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json create mode 100644 tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-change-streams.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-crud.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-gridfs.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-sessions.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json create mode 100644 tests/UnifiedSpecTests/valid-pass/poc-transactions.json diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 462edea0b..9f86e081a 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -142,7 +142,7 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r public function providePassingTests() { - return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-pass'); + return $this->provideTests(__DIR__ . '/valid-pass'); } /** @@ -164,7 +164,7 @@ public function testFailingTests(...$args) public function provideFailingTests() { - return $this->provideTests('/home/jmikola/workspace/mongodb/specifications/source/unified-test-format/tests/valid-fail'); + return $this->provideTests(__DIR__ . '/valid-fail'); } private function provideTests(string $dir) diff --git a/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json new file mode 100644 index 000000000..7f7f1978c --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json @@ -0,0 +1,18 @@ +{ + "description": "entity-bucket-database-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "bucket": { + "id": "bucket0", + "database": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json new file mode 100644 index 000000000..20b0733e3 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json @@ -0,0 +1,19 @@ +{ + "description": "entity-collection-database-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "collection": { + "id": "collection0", + "database": "foo", + "collectionName": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json new file mode 100644 index 000000000..0f8110e6d --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json @@ -0,0 +1,19 @@ +{ + "description": "entity-database-client-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "database": { + "id": "database0", + "client": "foo", + "databaseName": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json new file mode 100644 index 000000000..260356436 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json @@ -0,0 +1,18 @@ +{ + "description": "entity-session-client-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "session": { + "id": "session0", + "client": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json b/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json new file mode 100644 index 000000000..ceb553291 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json @@ -0,0 +1,10 @@ +{ + "description": "schemaVersion-unsupported", + "schemaVersion": "0.1", + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json b/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json new file mode 100644 index 000000000..dc6e332e3 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json @@ -0,0 +1,410 @@ +{ + "description": "poc-change-streams", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ], + "ignoreCommandMonitoringEvents": [ + "getMore", + "killCursors" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "change-stream-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "client": { + "id": "client1", + "useMultipleMongoses": false + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "change-stream-tests" + } + }, + { + "database": { + "id": "database2", + "client": "client1", + "databaseName": "change-stream-tests-2" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "test" + } + }, + { + "collection": { + "id": "collection2", + "database": "database1", + "collectionName": "test2" + } + }, + { + "collection": { + "id": "collection3", + "database": "database2", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "change-stream-tests", + "documents": [] + }, + { + "collectionName": "test2", + "databaseName": "change-stream-tests", + "documents": [] + }, + { + "collectionName": "test", + "databaseName": "change-stream-tests-2", + "documents": [] + } + ], + "tests": [ + { + "description": "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster.", + "runOnRequirements": [ + { + "minServerVersion": "3.8.0", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "createChangeStream", + "object": "client0", + "saveResultAsEntity": "changeStream0" + }, + { + "name": "insertOne", + "object": "collection2", + "arguments": { + "document": { + "x": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection3", + "arguments": { + "document": { + "y": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "z": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test2" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests-2", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "y": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "z": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": 1, + "cursor": {}, + "pipeline": [ + { + "$changeStream": { + "allChangesForCluster": true, + "fullDocument": { + "$$unsetOrMatches": "default" + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "admin" + } + } + ] + } + ] + }, + { + "description": "Test consecutive resume", + "runOnRequirements": [ + { + "minServerVersion": "4.1.7", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "getMore" + ], + "closeConnection": true + } + } + } + }, + { + "name": "createChangeStream", + "object": "collection0", + "arguments": { + "batchSize": 1 + }, + "saveResultAsEntity": "changeStream0" + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 2 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 3 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 2 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 3 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + }, + "resumeAfter": { + "$$exists": true + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + }, + "resumeAfter": { + "$$exists": true + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json b/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json new file mode 100644 index 000000000..499396e0b --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json @@ -0,0 +1,222 @@ +{ + "description": "poc-command-monitoring", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent", + "commandSucceededEvent", + "commandFailedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "command-monitoring-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "command-monitoring-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "tests": [ + { + "description": "A successful find event with a getmore and the server kills the cursor", + "runOnRequirements": [ + { + "minServerVersion": "3.1", + "topologies": [ + "single", + "replicaset" + ] + } + ], + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": { + "$gte": 1 + } + }, + "sort": { + "_id": 1 + }, + "batchSize": 3, + "limit": 4 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": { + "$gte": 1 + } + }, + "sort": { + "_id": 1 + }, + "batchSize": 3, + "limit": 4 + }, + "commandName": "find", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandSucceededEvent": { + "reply": { + "ok": 1, + "cursor": { + "id": { + "$$type": [ + "int", + "long" + ] + }, + "ns": "command-monitoring-tests.test", + "firstBatch": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "test", + "batchSize": 1 + }, + "commandName": "getMore", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandSucceededEvent": { + "reply": { + "ok": 1, + "cursor": { + "id": 0, + "ns": "command-monitoring-tests.test", + "nextBatch": [ + { + "_id": 4, + "x": 44 + } + ] + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "A failed find event", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "$or": true + } + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "$or": true + } + }, + "commandName": "find", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandFailedEvent": { + "commandName": "find" + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-crud.json b/tests/UnifiedSpecTests/valid-pass/poc-crud.json new file mode 100644 index 000000000..2ed86d615 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-crud.json @@ -0,0 +1,446 @@ +{ + "description": "poc-crud", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "crud-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client0", + "databaseName": "admin" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + }, + { + "collection": { + "id": "collection1", + "database": "database0", + "collectionName": "coll1" + } + }, + { + "collection": { + "id": "collection2", + "database": "database0", + "collectionName": "coll2", + "collectionOptions": { + "readConcern": { + "level": "majority" + } + } + } + } + ], + "initialData": [ + { + "collectionName": "coll0", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + }, + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + } + ] + }, + { + "collectionName": "coll2", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + }, + { + "collectionName": "aggregate_out", + "databaseName": "crud-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "BulkWrite with mixed ordered operations", + "operations": [ + { + "name": "bulkWrite", + "object": "collection0", + "arguments": { + "requests": [ + { + "insertOne": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "updateOne": { + "filter": { + "_id": 2 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "updateMany": { + "filter": { + "_id": { + "$gt": 1 + } + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "insertOne": { + "document": { + "_id": 4, + "x": 44 + } + } + }, + { + "deleteMany": { + "filter": { + "x": { + "$nin": [ + 24, + 34 + ] + } + } + } + }, + { + "replaceOne": { + "filter": { + "_id": 4 + }, + "replacement": { + "_id": 4, + "x": 44 + }, + "upsert": true + } + } + ], + "ordered": true + }, + "expectResult": { + "deletedCount": 2, + "insertedCount": 2, + "insertedIds": { + "$$unsetOrMatches": { + "0": 3, + "3": 4 + } + }, + "matchedCount": 3, + "modifiedCount": 3, + "upsertedCount": 1, + "upsertedIds": { + "5": 4 + } + } + } + ], + "outcome": [ + { + "collectionName": "coll0", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 2, + "x": 24 + }, + { + "_id": 3, + "x": 34 + }, + { + "_id": 4, + "x": 44 + } + ] + } + ] + }, + { + "description": "InsertMany continue-on-error behavior with unordered (duplicate key in requests)", + "operations": [ + { + "name": "insertMany", + "object": "collection1", + "arguments": { + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "ordered": false + }, + "expectError": { + "expectResult": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + } + } + } + ], + "outcome": [ + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + }, + { + "description": "ReplaceOne prohibits atomic modifiers", + "operations": [ + { + "name": "replaceOne", + "object": "collection1", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "$set": { + "x": 22 + } + } + }, + "expectError": { + "isClientError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [] + } + ], + "outcome": [ + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + } + ] + } + ] + }, + { + "description": "readConcern majority with out stage", + "runOnRequirements": [ + { + "minServerVersion": "4.1.0", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "aggregate", + "object": "collection2", + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "aggregate_out" + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "coll2", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "aggregate_out" + } + ], + "readConcern": { + "level": "majority" + } + }, + "commandName": "aggregate", + "databaseName": "crud-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "aggregate_out", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + }, + { + "description": "Aggregate with $listLocalSessions", + "runOnRequirements": [ + { + "minServerVersion": "3.6.0" + } + ], + "operations": [ + { + "name": "aggregate", + "object": "database1", + "arguments": { + "pipeline": [ + { + "$listLocalSessions": {} + }, + { + "$limit": 1 + }, + { + "$addFields": { + "dummy": "dummy field" + } + }, + { + "$project": { + "_id": 0, + "dummy": 1 + } + } + ] + }, + "expectResult": [ + { + "dummy": "dummy field" + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json b/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json new file mode 100644 index 000000000..c04ed89a7 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json @@ -0,0 +1,299 @@ +{ + "description": "poc-gridfs", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0" + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "gridfs-tests" + } + }, + { + "bucket": { + "id": "bucket0", + "database": "database0" + } + }, + { + "collection": { + "id": "bucket0_files_collection", + "database": "database0", + "collectionName": "fs.files" + } + }, + { + "collection": { + "id": "bucket0_chunks_collection", + "database": "database0", + "collectionName": "fs.chunks" + } + } + ], + "initialData": [ + { + "collectionName": "fs.files", + "databaseName": "gridfs-tests", + "documents": [ + { + "_id": { + "$oid": "000000000000000000000005" + }, + "length": 10, + "chunkSize": 4, + "uploadDate": { + "$date": "1970-01-01T00:00:00.000Z" + }, + "md5": "57d83cd477bfb1ccd975ab33d827a92b", + "filename": "length-10", + "contentType": "application/octet-stream", + "aliases": [], + "metadata": {} + } + ] + }, + { + "collectionName": "fs.chunks", + "databaseName": "gridfs-tests", + "documents": [ + { + "_id": { + "$oid": "000000000000000000000005" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 0, + "data": { + "$binary": { + "base64": "ESIzRA==", + "subType": "00" + } + } + }, + { + "_id": { + "$oid": "000000000000000000000006" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 1, + "data": { + "$binary": { + "base64": "VWZ3iA==", + "subType": "00" + } + } + }, + { + "_id": { + "$oid": "000000000000000000000007" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 2, + "data": { + "$binary": { + "base64": "mao=", + "subType": "00" + } + } + } + ] + } + ], + "tests": [ + { + "description": "Delete when length is 10", + "operations": [ + { + "name": "delete", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + } + } + ], + "outcome": [ + { + "collectionName": "fs.files", + "databaseName": "gridfs-tests", + "documents": [] + }, + { + "collectionName": "fs.chunks", + "databaseName": "gridfs-tests", + "documents": [] + } + ] + }, + { + "description": "Download when there are three chunks", + "operations": [ + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + }, + "expectResult": { + "$$matchesHexBytes": "112233445566778899aa" + } + } + ] + }, + { + "description": "Download when files entry does not exist", + "operations": [ + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000000" + } + }, + "expectError": { + "isError": true + } + } + ] + }, + { + "description": "Download when an intermediate chunk is missing", + "operations": [ + { + "name": "deleteOne", + "object": "bucket0_chunks_collection", + "arguments": { + "filter": { + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 1 + } + }, + "expectResult": { + "deletedCount": 1 + } + }, + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + }, + "expectError": { + "isError": true + } + } + ] + }, + { + "description": "Upload when length is 5", + "operations": [ + { + "name": "upload", + "object": "bucket0", + "arguments": { + "filename": "filename", + "source": { + "$$hexBytes": "1122334455" + }, + "chunkSizeBytes": 4 + }, + "expectResult": { + "$$type": "objectId" + }, + "saveResultAsEntity": "oid0" + }, + { + "name": "find", + "object": "bucket0_files_collection", + "arguments": { + "filter": {}, + "sort": { + "uploadDate": -1 + }, + "limit": 1 + }, + "expectResult": [ + { + "_id": { + "$$matchesEntity": "oid0" + }, + "length": 5, + "chunkSize": 4, + "uploadDate": { + "$$type": "date" + }, + "md5": "283d4fea5dded59cf837d3047328f5af", + "filename": "filename" + } + ] + }, + { + "name": "find", + "object": "bucket0_chunks_collection", + "arguments": { + "filter": { + "_id": { + "$gt": { + "$oid": "000000000000000000000007" + } + } + }, + "sort": { + "n": 1 + } + }, + "expectResult": [ + { + "_id": { + "$$type": "objectId" + }, + "files_id": { + "$$matchesEntity": "oid0" + }, + "n": 0, + "data": { + "$binary": { + "base64": "ESIzRA==", + "subType": "00" + } + } + }, + { + "_id": { + "$$type": "objectId" + }, + "files_id": { + "$$matchesEntity": "oid0" + }, + "n": 1, + "data": { + "$binary": { + "base64": "VQ==", + "subType": "00" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json b/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json new file mode 100644 index 000000000..2b65d501a --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json @@ -0,0 +1,433 @@ +{ + "description": "poc-retryable-reads", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "single", + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "retryReads": false + }, + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "retryable-reads-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "retryable-reads-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll" + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "retryable-reads-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ], + "tests": [ + { + "description": "Aggregate succeeds after InterruptedAtShutdown", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "aggregate" + ], + "errorCode": 11600 + } + } + } + }, + { + "name": "aggregate", + "object": "collection0", + "arguments": { + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "expectResult": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "coll", + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "coll", + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find succeeds on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find fails on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection1", + "arguments": { + "filter": {} + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find fails on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "ListDatabases succeeds on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "listDatabases" + ], + "closeConnection": true + } + } + } + }, + { + "name": "listDatabases", + "object": "client0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "listDatabases": 1 + } + } + }, + { + "commandStartedEvent": { + "command": { + "listDatabases": 1 + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json b/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json new file mode 100644 index 000000000..e64ce1bce --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json @@ -0,0 +1,481 @@ +{ + "description": "poc-retryable-writes", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "3.6", + "topologies": [ + "replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "retryWrites": false + }, + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "retryable-writes-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "retryable-writes-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll" + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ], + "tests": [ + { + "description": "FindOneAndUpdate is committed on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectResult": { + "_id": 1, + "x": 11 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "FindOneAndUpdate is not committed on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 1 + }, + "data": { + "failBeforeCommitExceptionCode": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectResult": { + "_id": 1, + "x": 11 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "FindOneAndUpdate is never committed", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 2 + }, + "data": { + "failBeforeCommitExceptionCode": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectError": { + "isError": true + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "InsertMany succeeds after PrimarySteppedDown", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 189, + "errorLabels": [ + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "insertMany", + "object": "collection0", + "arguments": { + "documents": [ + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ], + "ordered": true + }, + "expectResult": { + "insertedCount": 2, + "insertedIds": { + "$$unsetOrMatches": { + "0": 3, + "1": 4 + } + } + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ] + } + ] + }, + { + "description": "InsertOne fails after connection failure when retryWrites option is false", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client1", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + }, + "expectError": { + "errorLabelsOmit": [ + "RetryableWriteError" + ] + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "InsertOne fails after multiple retryable writeConcernErrors", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "insert" + ], + "writeConcernError": { + "code": 91, + "errmsg": "Replication is being shut down" + } + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + }, + "expectError": { + "errorLabelsContain": [ + "RetryableWriteError" + ] + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-sessions.json b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json new file mode 100644 index 000000000..117c9e7d0 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json @@ -0,0 +1,466 @@ +{ + "description": "poc-sessions", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "3.6.0" + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "session-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ], + "tests": [ + { + "description": "Server supports explicit sessions", + "operations": [ + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "endSession", + "object": "session0" + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + } + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$sessionLsid": "session0" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + }, + { + "description": "Server supports implicit sessions", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$type": "object" + } + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$type": "object" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + }, + { + "description": "Dirty explicit session is discarded", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "assertSessionDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "assertSessionDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "endSession", + "object": "session0" + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertDifferentLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 2 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$type": "object" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json new file mode 100644 index 000000000..820ed6592 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json @@ -0,0 +1,505 @@ +{ + "description": "poc-transactions-convenient-api", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "readConcernLevel": "local", + "w": 1 + }, + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + }, + { + "session": { + "id": "session1", + "client": "client1" + } + }, + { + "session": { + "id": "session2", + "client": "client0", + "sessionOptions": { + "defaultTransactionOptions": { + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "w": 1 + } + } + } + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "withTransaction and no transaction options set", + "operations": [ + { + "name": "withTransaction", + "object": "session0", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "$$exists": false + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "autocommit": false, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction inherits transaction options from client", + "operations": [ + { + "name": "withTransaction", + "object": "session1", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "session": "session1", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session1" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "local" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session1" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction inherits transaction options from defaultTransactionOptions", + "operations": [ + { + "name": "withTransaction", + "object": "session2", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session2", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session2" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session2" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction explicit transaction options", + "operations": [ + { + "name": "withTransaction", + "object": "session0", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ], + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "w": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json new file mode 100644 index 000000000..a0b297d59 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json @@ -0,0 +1,409 @@ +{ + "description": "poc-transactions-mongos-pin-auto", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ], + "tests": [ + { + "description": "remain pinned after non-transient Interrupted error on insertOne", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "targetedFailPoint", + "object": "testRunner", + "arguments": { + "session": "session0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 11601 + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 4 + } + }, + "expectError": { + "errorLabelsOmit": [ + "TransientTransactionError", + "UnknownTransactionCommitResult" + ], + "errorCodeName": "Interrupted" + } + }, + { + "name": "assertSessionPinned", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "commitTransaction", + "object": "session0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 4 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + }, + "recoveryToken": { + "$$type": "object" + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ] + }, + { + "description": "unpin after transient error within a transaction", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "targetedFailPoint", + "object": "testRunner", + "arguments": { + "session": "session0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 4 + } + }, + "expectError": { + "errorLabelsContain": [ + "TransientTransactionError" + ], + "errorLabelsOmit": [ + "UnknownTransactionCommitResult" + ] + } + }, + { + "name": "assertSessionUnpinned", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "abortTransaction", + "object": "session0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 4 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "abortTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + }, + "recoveryToken": { + "$$type": "object" + } + }, + "commandName": "abortTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions.json new file mode 100644 index 000000000..62528f9ce --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions.json @@ -0,0 +1,322 @@ +{ + "description": "poc-transactions", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "Client side error in command starting transaction", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": { + ".": "." + } + } + }, + "expectError": { + "isClientError": true + } + }, + { + "name": "assertSessionTransactionState", + "object": "testRunner", + "arguments": { + "session": "session0", + "state": "starting" + } + } + ] + }, + { + "description": "explicitly create collection using create command", + "runOnRequirements": [ + { + "minServerVersion": "4.3.4", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "dropCollection", + "object": "database0", + "arguments": { + "collection": "test" + } + }, + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "createCollection", + "object": "database0", + "arguments": { + "session": "session0", + "collection": "test" + } + }, + { + "name": "assertCollectionNotExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test" + } + }, + { + "name": "commitTransaction", + "object": "session0" + }, + { + "name": "assertCollectionExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "drop": "test", + "writeConcern": { + "$$exists": false + } + }, + "commandName": "drop", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "create": "test", + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "create", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ] + }, + { + "description": "create index on a non-existing collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.4", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "dropCollection", + "object": "database0", + "arguments": { + "collection": "test" + } + }, + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "createIndex", + "object": "collection0", + "arguments": { + "session": "session0", + "name": "x_1", + "keys": { + "x": 1 + } + } + }, + { + "name": "assertIndexNotExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test", + "indexName": "x_1" + } + }, + { + "name": "commitTransaction", + "object": "session0" + }, + { + "name": "assertIndexExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test", + "indexName": "x_1" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "drop": "test", + "writeConcern": { + "$$exists": false + } + }, + "commandName": "drop", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "createIndexes": "test", + "indexes": [ + { + "name": "x_1", + "key": { + "x": 1 + } + } + ], + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "createIndexes", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ] + } + ] +} From dbf42288396ef69f9929e601ef7e38339a0246e2 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 8 Oct 2020 00:21:04 +0800 Subject: [PATCH 30/41] Fix phpcs validation --- tests/UnifiedSpecTests/DirtySessionObserver.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/UnifiedSpecTests/DirtySessionObserver.php b/tests/UnifiedSpecTests/DirtySessionObserver.php index 4ea7006ab..e69f7e534 100644 --- a/tests/UnifiedSpecTests/DirtySessionObserver.php +++ b/tests/UnifiedSpecTests/DirtySessionObserver.php @@ -27,6 +27,7 @@ class DirtySessionObserver implements CommandSubscriber /** @var array */ private $requestIds = []; + /** @var bool */ private $observedNetworkError = false; public function __construct(stdClass $lsid) From 59f4c72f562572d5470e0d517154bbe72c7cf615 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 8 Oct 2020 22:51:32 +0800 Subject: [PATCH 31/41] assertHasOnlyKeys requires array or stdClass --- tests/UnifiedSpecTests/Util.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php index 129bd500f..7eb1a32ed 100644 --- a/tests/UnifiedSpecTests/Util.php +++ b/tests/UnifiedSpecTests/Util.php @@ -22,8 +22,7 @@ final class Util { public static function assertHasOnlyKeys($arrayOrObject, array $keys) { - // TODO: replace isType('object') with instanceOf(stdClass::class) - assertThat($arrayOrObject, logicalOr(isType('array'), isType('object'))); + assertThat($arrayOrObject, logicalOr(isType('array'), isInstanceOf(stdClass::class))); $diff = array_diff_key((array) $arrayOrObject, array_fill_keys($keys, 1)); assertEmpty($diff, 'Unsupported keys: ' . implode(',', array_keys($diff))); } From c49b4113d4b32e3b8fa9bf1808d0f2338464f7c3 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 8 Oct 2020 23:35:41 +0800 Subject: [PATCH 32/41] Add missing valid-fail test --- .../returnDocument-enum-invalid.json | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json diff --git a/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json new file mode 100644 index 000000000..24535b833 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json @@ -0,0 +1,65 @@ +{ + "description": "returnDocument-enum-invalid", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0" + } + }, + { + "database": null, + "id": "database0", + "client": "client0", + "databaseName": "test" + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + } + ], + "tests": [ + { + "description": "FindOneAndReplace returnDocument invalid enum value", + "operations": [ + { + "name": "findOneAndReplace", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "_id": 1, + "x": 111 + }, + "returnDocument": "invalid" + } + } + ] + }, + { + "description": "FindOneAndUpdate returnDocument invalid enum value", + "operations": [ + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "invalid" + } + } + ] + } + ] +} From 64b411852b2cfcf97709351aa18ff51e5136f246 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 8 Oct 2020 23:54:20 +0800 Subject: [PATCH 33/41] Fix phpcs error --- tests/UnifiedSpecTests/Util.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php index 7eb1a32ed..6e40b3d53 100644 --- a/tests/UnifiedSpecTests/Util.php +++ b/tests/UnifiedSpecTests/Util.php @@ -15,6 +15,7 @@ use function assertInternalType; use function assertThat; use function implode; +use function isInstanceOf; use function isType; use function logicalOr; From e54408166e5d27b0eb5edb31eb113dd3b1425e55 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 8 Oct 2020 23:57:46 +0800 Subject: [PATCH 34/41] Remove nullable return type hint for PHP 7.0 --- tests/UnifiedSpecTests/EntityMap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php index f546c61de..3329c94f3 100644 --- a/tests/UnifiedSpecTests/EntityMap.php +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -153,7 +153,7 @@ public function getLogicalSessionId(string $sessionId) : stdClass return $this->lsidsBySession[$sessionId]; } - public function getRootClientIdOf(string $id) : ?string + public function getRootClientIdOf(string $id) { $root = $this->map[$id]->getRoot(); From 97c9f39e259c3c44cfb54d4e27a7a1ce640ae388 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 9 Oct 2020 10:00:37 +0800 Subject: [PATCH 35/41] Update session test --- tests/UnifiedSpecTests/valid-pass/poc-sessions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnifiedSpecTests/valid-pass/poc-sessions.json b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json index 117c9e7d0..75f348942 100644 --- a/tests/UnifiedSpecTests/valid-pass/poc-sessions.json +++ b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json @@ -264,7 +264,7 @@ { "minServerVersion": "4.1.8", "topologies": [ - "sharded" + "sharded-replicaset" ] } ], From b572a4ca3c7b5932c88c73ccb861514e1f6e9963 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Sat, 10 Oct 2020 21:04:33 +0800 Subject: [PATCH 36/41] Sync returnDocument-enum-invalid.json --- .../valid-fail/returnDocument-enum-invalid.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json index 24535b833..ea425fb56 100644 --- a/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json +++ b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json @@ -8,10 +8,11 @@ } }, { - "database": null, - "id": "database0", - "client": "client0", - "databaseName": "test" + "database": { + "id": "database0", + "client": "client0", + "databaseName": "test" + } }, { "collection": { From abc742618c43a5eff2961ded41d9293589f60518 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 13 Oct 2020 16:08:45 +0200 Subject: [PATCH 37/41] Handle issues from code review --- tests/UnifiedSpecTests/EventObserver.php | 67 ++++++++-------------- tests/UnifiedSpecTests/ExpectedError.php | 10 ++-- tests/UnifiedSpecTests/Operation.php | 22 ++++++- tests/UnifiedSpecTests/UnifiedSpecTest.php | 67 ++++++++++++++++++---- 4 files changed, 106 insertions(+), 60 deletions(-) diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index e57367bcd..8ae1f7d23 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -97,19 +97,7 @@ public function __construct(array $observeEvents, array $ignoreCommands, string */ public function commandFailed(CommandFailedEvent $event) { - if (! $this->context->isActiveClient($this->clientId)) { - return; - } - - if (! isset($this->observeEvents[CommandFailedEvent::class])) { - return; - } - - if (isset($this->ignoreCommands[$event->getCommandName()])) { - return; - } - - $this->actualEvents[] = $event; + $this->handleEvent($event); } /** @@ -117,19 +105,7 @@ public function commandFailed(CommandFailedEvent $event) */ public function commandStarted(CommandStartedEvent $event) { - if (! $this->context->isActiveClient($this->clientId)) { - return; - } - - if (! isset($this->observeEvents[CommandStartedEvent::class])) { - return; - } - - if (isset($this->ignoreCommands[$event->getCommandName()])) { - return; - } - - $this->actualEvents[] = $event; + $this->handleEvent($event); } /** @@ -137,19 +113,7 @@ public function commandStarted(CommandStartedEvent $event) */ public function commandSucceeded(CommandSucceededEvent $event) { - if (! $this->context->isActiveClient($this->clientId)) { - return; - } - - if (! isset($this->observeEvents[CommandSucceededEvent::class])) { - return; - } - - if (isset($this->ignoreCommands[$event->getCommandName()])) { - return; - } - - $this->actualEvents[] = $event; + $this->handleEvent($event); } public function start() @@ -193,9 +157,6 @@ public function assert(array $expectedEvents) foreach ($mi as $keys => $events) { list($expectedEvent, $actualEvent) = $events; - // TODO: assertNotNull may be redundant since counts are equal - assertNotNull($expectedEvent); - assertNotNull($actualEvent); assertInternalType('object', $expectedEvent); $expectedEvent = (array) $expectedEvent; @@ -276,4 +237,26 @@ private function assertCommandFailedEvent(CommandFailedEvent $actual, stdClass $ assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); } } + + /** @param CommandStartedEvent|CommandSucceededEvent|CommandFailedEvent $event */ + private function handleEvent($event) + { + if (! $this->context->isActiveClient($this->clientId)) { + return; + } + + if (! is_object($event)) { + return; + } + + if (! isset($this->observeEvents[get_class($event)])) { + return; + } + + if (isset($this->ignoreCommands[$event->getCommandName()])) { + return; + } + + $this->actualEvents[] = $event; + } } diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php index 326031363..46ea86129 100644 --- a/tests/UnifiedSpecTests/ExpectedError.php +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -41,16 +41,16 @@ final class ExpectedError /** @var bool */ private $isError = false; - /** @var bool */ + /** @var bool|null */ private $isClientError; - /** @var string */ + /** @var string|null */ private $messageContains; - /** @var int */ + /** @var int|null */ private $code; - /** @var string */ + /** @var string|null */ private $codeName; /** @var array */ @@ -59,7 +59,7 @@ final class ExpectedError /** @var array */ private $excludedLabels = []; - /** @var ExpectedResult */ + /** @var ExpectedResult|null */ private $expectedResult; public function __construct(stdClass $o = null, EntityMap $entityMap) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 9480d7bae..5240178ca 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -308,6 +308,11 @@ private function executeForCollection(Collection $collection) array_diff_key($args, ['filter' => 1]) ); case 'distinct': + if (isset($args['session']) && $args['session']->isInTransaction()) { + // Transaction, but sharded cluster? + $collection->distinct('foo'); + } + return $collection->distinct( $args['fieldName'], $args['filter'] ?? [], @@ -498,20 +503,29 @@ private function executeForTestRunner() $args['client'] = $this->entityMap->getClient($args['client']); } - // TODO: validate arguments switch ($this->name) { case 'assertCollectionExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); assertContains($args['collectionName'], $database->listCollectionNames()); break; case 'assertCollectionNotExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); assertNotContains($args['collectionName'], $database->listCollectionNames()); break; case 'assertIndexExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + assertInternalType('string', $args['indexName']); assertContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); break; case 'assertIndexNotExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + assertInternalType('string', $args['indexName']); assertNotContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); break; case 'assertSameLsidOnLastTwoCommands': @@ -529,18 +543,24 @@ private function executeForTestRunner() assertFalse($this->context->isDirtySession($this->arguments['session'])); break; case 'assertSessionPinned': + assertInstanceOf(Session::class, $args['session']); assertInstanceOf(Server::class, $args['session']->getServer()); break; case 'assertSessionTransactionState': + assertInstanceOf(Session::class, $args['session']); assertSame($this->arguments['state'], $args['session']->getTransactionState()); break; case 'assertSessionUnpinned': + assertInstanceOf(Session::class, $args['session']); assertNull($args['session']->getServer()); break; case 'failPoint': + assertInternalType('array', $args['failPoint']); $args['client']->selectDatabase('admin')->command($args['failPoint']); break; case 'targetedFailPoint': + assertInstanceOf(Session::class, $args['session']); + assertInternalType('array', $args['failPoint']); assertNotNull($args['session']->getServer(), 'Session is pinned'); $operation = new DatabaseCommand('admin', $args['failPoint']); $operation->execute($args['session']->getServer()); diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 9f86e081a..d573c01ef 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -3,6 +3,7 @@ namespace MongoDB\Tests\UnifiedSpecTests; use MongoDB\Client; +use MongoDB\Collection; use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; @@ -13,6 +14,7 @@ use Throwable; use function assertTrue; use function file_get_contents; +use function gc_collect_cycles; use function glob; use function MongoDB\BSON\fromJSON; use function MongoDB\BSON\toPHP; @@ -30,6 +32,9 @@ class UnifiedSpecTest extends FunctionalTestCase const SERVER_ERROR_INTERRUPTED = 11601; + const MIN_SCHEMA_VERSION = '1.0'; + const MAX_SCHEMA_VERSION = '1.1'; + const TOPOLOGY_SINGLE = 'single'; const TOPOLOGY_REPLICASET = 'replicaset'; const TOPOLOGY_SHARDED = 'sharded'; @@ -55,11 +60,10 @@ private function doSetUp() { parent::setUp(); - /* TODO: The transactions spec advises calling killAllSessions only at - * the start of the test suite and after failed tests; however, the - * "unpin after transient error within a transaction" pinning test - * causes the subsequent transaction test to block. This can be removed - * once that is addressed. */ + /* The transactions spec advises calling killAllSessions only at the + * start of the test suite and after failed tests; however, the "unpin + * after transient error within a transaction" pinning test causes the + * subsequent transaction test to block. */ self::killAllSessions(); $this->failPointObserver = new FailPointObserver(); @@ -75,9 +79,10 @@ private function doTearDown() $this->failPointObserver->stop(); $this->failPointObserver->disableFailPoints(); - /* TODO: Consider manually invoking gc_collect_cycles since each test is - * prone to create cycles (perhaps due to EntityMap), which can leak and - * prevent sessions from being released back into the pool. */ + /* Manually invoking garbage collection since each test is prone to + * create cycles (perhaps due to EntityMap), which can leak and prevent + * sessions from being released back into the pool. */ + gc_collect_cycles(); parent::tearDown(); } @@ -116,12 +121,11 @@ public function testPassingTests(stdClass $test, string $schemaVersion, array $r $context->createEntities($createEntities); } - // TODO handle distinct commands in sharded transactions + $this->assertInternalType('array', $test->operations); + $this->preventStaleDbVersionError($test->operations, $context); $context->startEventObservers(); - $this->assertInternalType('array', $test->operations); - foreach ($test->operations as $o) { $operation = new Operation($o, $context); $operation->assert(); @@ -288,7 +292,7 @@ private function getCachedTopology() */ private function isSchemaVersionSupported($schemaVersion) { - return version_compare($schemaVersion, '1.0', '>=') && version_compare($schemaVersion, '1.1', '<'); + return version_compare($schemaVersion, self::MIN_SCHEMA_VERSION, '>=') && version_compare($schemaVersion, self::MAX_SCHEMA_VERSION, '<'); } /** @@ -343,4 +347,43 @@ private function prepareInitialData(array $initialData) $collectionData->prepareInitialData(self::$internalClient); } } + + /** + * Work around potential error executing distinct on sharded clusters. + * + * @see https://github.com/mongodb/specifications/tree/master/source/transactions/tests#why-do-tests-that-run-distinct-sometimes-fail-with-staledbversionts. + */ + private function preventStaleDbVersionError(array $operations, Context $context) + { + if (! $this->isShardedCluster()) { + return; + } + + $hasStartTransaction = false; + $hasDistinct = false; + $collection = null; + + foreach ($operations as $operation) { + switch ($operation->name) { + case 'distinct': + $hasDistinct = true; + $collection = $context->getEntityMap()[$operation->object]; + break; + + case 'startTransaction': + $hasStartTransaction = true; + break; + + default: + continue; + } + + if ($hasStartTransaction && $hasDistinct) { + $this->assertInstanceOf(Collection::class, $collection); + $collection->distinct('foo'); + + return; + } + } + } } From 4bb37bed7382027dcdd6c25bbe33ef6b4b25abf9 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 13 Oct 2020 16:34:55 +0200 Subject: [PATCH 38/41] fix phpcs --- tests/UnifiedSpecTests/EventObserver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php index 8ae1f7d23..b9551030e 100644 --- a/tests/UnifiedSpecTests/EventObserver.php +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -18,13 +18,13 @@ use function assertInstanceOf; use function assertInternalType; use function assertNotEmpty; -use function assertNotNull; use function assertObjectHasAttribute; use function assertSame; use function assertThat; use function count; use function current; use function get_class; +use function is_object; use function key; use function MongoDB\Driver\Monitoring\addSubscriber; use function MongoDB\Driver\Monitoring\removeSubscriber; From 9a89118a055611aee3c42eb69b6a849c0d61abaa Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 13 Oct 2020 16:36:08 +0200 Subject: [PATCH 39/41] fix tests --- tests/UnifiedSpecTests/Operation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 5240178ca..b8cfca6bf 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -555,12 +555,12 @@ private function executeForTestRunner() assertNull($args['session']->getServer()); break; case 'failPoint': - assertInternalType('array', $args['failPoint']); + assertInstanceOf(stdClass::class, $args['failPoint']); $args['client']->selectDatabase('admin')->command($args['failPoint']); break; case 'targetedFailPoint': assertInstanceOf(Session::class, $args['session']); - assertInternalType('array', $args['failPoint']); + assertInstanceOf(stdClass::class, $args['failPoint']); assertNotNull($args['session']->getServer(), 'Session is pinned'); $operation = new DatabaseCommand('admin', $args['failPoint']); $operation->execute($args['session']->getServer()); From ea4a99b64c9c6b50c76f240841c86b8366f85137 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 13 Oct 2020 16:37:20 +0200 Subject: [PATCH 40/41] Fix wrong continue statement --- tests/UnifiedSpecTests/UnifiedSpecTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index d573c01ef..e8cbeeb2f 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -375,7 +375,7 @@ private function preventStaleDbVersionError(array $operations, Context $context) break; default: - continue; + continue 2; } if ($hasStartTransaction && $hasDistinct) { From f21efac152ca28feff04e9f61148abac2c54c801 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 14 Oct 2020 10:37:55 +0200 Subject: [PATCH 41/41] Handle readPreferenceTags in URI options --- tests/UnifiedSpecTests/Context.php | 35 +++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php index 06121ef18..f52787c9f 100644 --- a/tests/UnifiedSpecTests/Context.php +++ b/tests/UnifiedSpecTests/Context.php @@ -9,6 +9,7 @@ use MongoDB\Driver\Server; use stdClass; use function array_key_exists; +use function array_map; use function assertArrayHasKey; use function assertContains; use function assertCount; @@ -181,6 +182,26 @@ public function getEventObserverForClient(string $id) : EventObserver return $this->eventObserversByClient[$id]; } + /** @param string|array $readPreferenceTags */ + private function convertReadPreferenceTags($readPreferenceTags) : array + { + return array_map( + static function (string $readPreferenceTagSet) : array { + $tags = explode(',', $readPreferenceTagSet); + + return array_map( + static function (string $tag) : array { + list($key, $value) = explode(':', $tag); + + return [$key => $value]; + }, + $tags + ); + }, + (array) $readPreferenceTags + ); + } + private function createClient(string $id, stdClass $o) { Util::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); @@ -205,10 +226,18 @@ private function createClient(string $id, stdClass $o) if (isset($o->uriOptions)) { assertInternalType('object', $o->uriOptions); - /* TODO: If readPreferenceTags is set, assert it is an array of - * strings and convert to an array of documents expected by the - * PHP driver. */ $uriOptions = (array) $o->uriOptions; + + if (! empty($uriOptions['readPreferenceTags'])) { + /* readPreferenceTags may take the following form: + * + * 1. A string containing multiple tags: "dc:ny,rack:1". + * Expected result: [["dc" => "ny", "rack" => "1"]] + * 2. An array containing multiple strings as above: ["dc:ny,rack:1", "dc:la"]. + * Expected result: [["dc" => "ny", "rack" => "1"], ["dc" => "la"]] + */ + $uriOptions['readPreferenceTags'] = $this->convertReadPreferenceTags($uriOptions['readPreferenceTags']); + } } if (isset($observeEvents)) {