CrudSpecFunctionalTest.php 19.2 KB
Newer Older
1 2 3 4
<?php

namespace MongoDB\Tests\Collection;

5 6
use IteratorIterator;
use LogicException;
7
use MongoDB\BulkWriteResult;
8
use MongoDB\Collection;
9
use MongoDB\DeleteResult;
10 11
use MongoDB\Driver\Exception\BulkWriteException;
use MongoDB\Driver\Exception\RuntimeException;
12 13
use MongoDB\InsertManyResult;
use MongoDB\InsertOneResult;
14
use MongoDB\Operation\FindOneAndReplace;
15
use MongoDB\UpdateResult;
16
use MultipleIterator;
17
use PHPUnit_Framework_SkippedTestError;
18
use Symfony\Bridge\PhpUnit\SetUpTearDownTrait;
19 20 21 22 23 24 25 26 27 28 29
use function array_diff_key;
use function array_key_exists;
use function array_map;
use function file_get_contents;
use function glob;
use function json_decode;
use function MongoDB\is_last_pipeline_operator_write;
use function sprintf;
use function str_replace;
use function strtolower;
use function version_compare;
30 31 32 33 34 35 36 37

/**
 * CRUD spec functional tests.
 *
 * @see https://github.com/mongodb/specifications/tree/master/source/crud/tests
 */
class CrudSpecFunctionalTest extends FunctionalTestCase
{
38 39
    use SetUpTearDownTrait;

40
    /** @var Collection */
41 42
    private $expectedCollection;

43
    private function doSetUp()
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
    {
        parent::setUp();

        $this->expectedCollection = new Collection($this->manager, $this->getDatabaseName(), $this->getCollectionName() . '.expected');
        $this->expectedCollection->drop();
    }

    /**
     * @dataProvider provideSpecificationTests
     */
    public function testSpecification(array $initialData, array $test, $minServerVersion, $maxServerVersion)
    {
        if (isset($minServerVersion) || isset($maxServerVersion)) {
            $this->checkServerVersion($minServerVersion, $maxServerVersion);
        }

60
        $expectedData = $test['outcome']['collection']['data'] ?? null;
61 62
        $this->initializeData($initialData, $expectedData);

63 64 65 66 67
        if (isset($test['outcome']['collection']['name'])) {
            $outputCollection = new Collection($this->manager, $this->getDatabaseName(), $test['outcome']['collection']['name']);
            $outputCollection->drop();
        }

68 69 70 71 72 73 74 75 76 77
        $result = null;
        $exception = null;

        try {
            $result = $this->executeOperation($test['operation']);
        } catch (RuntimeException $e) {
            $exception = $e;
        }

        $this->executeOutcome($test['operation'], $test['outcome'], $result, $exception);
78 79 80 81 82 83 84 85 86
    }

    public function provideSpecificationTests()
    {
        $testArgs = [];

        foreach (glob(__DIR__ . '/spec-tests/*/*.json') as $filename) {
            $json = json_decode(file_get_contents($filename), true);

87 88
            $minServerVersion = $json['minServerVersion'] ?? null;
            $maxServerVersion = $json['maxServerVersion'] ?? null;
89 90

            foreach ($json['tests'] as $test) {
91 92
                $name = str_replace(' ', '_', $test['description']);
                $testArgs[$name] = [$json['data'], $test, $minServerVersion, $maxServerVersion];
93 94 95 96 97 98 99 100 101 102 103 104 105 106
            }
        }

        return $testArgs;
    }

    /**
     * Assert that the collections contain equivalent documents.
     *
     * @param Collection $expectedCollection
     * @param Collection $actualCollection
     */
    private function assertEquivalentCollections($expectedCollection, $actualCollection)
    {
107
        $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY);
108 109 110 111 112 113 114 115 116 117 118 119 120 121
        $mi->attachIterator(new IteratorIterator($expectedCollection->find()));
        $mi->attachIterator(new IteratorIterator($actualCollection->find()));

        foreach ($mi as $documents) {
            list($expectedDocument, $actualDocument) = $documents;
            $this->assertSameDocument($expectedDocument, $actualDocument);
        }
    }

    /**
     * Checks that the server version is within the allowed bounds (if any).
     *
     * @param string|null $minServerVersion
     * @param string|null $maxServerVersion
122
     * @throws PHPUnit_Framework_SkippedTestError
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
     */
    private function checkServerVersion($minServerVersion, $maxServerVersion)
    {
        $serverVersion = $this->getServerVersion();

        if (isset($minServerVersion) && version_compare($serverVersion, $minServerVersion, '<')) {
            $this->markTestSkipped(sprintf('Server version "%s" < minServerVersion "%s"', $serverVersion, $minServerVersion));
        }

        if (isset($maxServerVersion) && version_compare($serverVersion, $maxServerVersion, '>=')) {
            $this->markTestSkipped(sprintf('Server version "%s" >= maxServerVersion "%s"', $serverVersion, $maxServerVersion));
        }
    }

    /**
     * Executes an "operation" block.
     *
     * @param array $operation
     * @return mixed
     * @throws LogicException if the operation is unsupported
     */
    private function executeOperation(array $operation)
    {
        switch ($operation['name']) {
            case 'aggregate':
                return $this->collection->aggregate(
                    $operation['arguments']['pipeline'],
                    array_diff_key($operation['arguments'], ['pipeline' => 1])
                );
152 153
            case 'bulkWrite':
                return $this->collection->bulkWrite(
154
                    array_map([$this, 'prepareBulkWriteRequest'], $operation['arguments']['requests']),
155
                    $operation['arguments']['options'] ?? []
156
                );
157
            case 'count':
158
            case 'countDocuments':
159 160
            case 'find':
                return $this->collection->{$operation['name']}(
161
                    $operation['arguments']['filter'] ?? [],
162 163
                    array_diff_key($operation['arguments'], ['filter' => 1])
                );
164 165
            case 'estimatedDocumentCount':
                return $this->collection->estimatedDocumentCount($operation['arguments']);
166 167 168 169 170 171 172 173 174 175
            case 'deleteMany':
            case 'deleteOne':
            case 'findOneAndDelete':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    array_diff_key($operation['arguments'], ['filter' => 1])
                );
            case 'distinct':
                return $this->collection->distinct(
                    $operation['arguments']['fieldName'],
176
                    $operation['arguments']['filter'] ?? [],
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
                    array_diff_key($operation['arguments'], ['fieldName' => 1, 'filter' => 1])
                );
            case 'findOneAndReplace':
                $operation['arguments'] = $this->prepareFindAndModifyArguments($operation['arguments']);
                // Fall through

            case 'replaceOne':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    $operation['arguments']['replacement'],
                    array_diff_key($operation['arguments'], ['filter' => 1, 'replacement' => 1])
                );
            case 'findOneAndUpdate':
                $operation['arguments'] = $this->prepareFindAndModifyArguments($operation['arguments']);
                // Fall through

            case 'updateMany':
            case 'updateOne':
                return $this->collection->{$operation['name']}(
                    $operation['arguments']['filter'],
                    $operation['arguments']['update'],
                    array_diff_key($operation['arguments'], ['filter' => 1, 'update' => 1])
                );
            case 'insertMany':
                return $this->collection->insertMany(
                    $operation['arguments']['documents'],
203
                    $operation['arguments']['options'] ?? []
204 205 206 207 208 209 210 211 212 213 214 215 216 217
                );
            case 'insertOne':
                return $this->collection->insertOne(
                    $operation['arguments']['document'],
                    array_diff_key($operation['arguments'], ['document' => 1])
                );
            default:
                throw new LogicException('Unsupported operation: ' . $operation['name']);
        }
    }

    /**
     * Executes an "outcome" block.
     *
218 219 220 221
     * @param array            $operation
     * @param array            $outcome
     * @param mixed            $result
     * @param RuntimeException $exception
222 223 224
     * @return mixed
     * @throws LogicException if the operation is unsupported
     */
225
    private function executeOutcome(array $operation, array $outcome, $result, RuntimeException $exception = null)
226
    {
227 228 229 230 231 232 233 234 235
        $expectedError = array_key_exists('error', $outcome) ? $outcome['error'] : false;

        if ($expectedError) {
            $this->assertNull($result);
            $this->assertNotNull($exception);

            $result = $this->extractResultFromException($operation, $outcome, $exception);
        }

236
        if (array_key_exists('result', $outcome)) {
237
            $this->executeAssertResult($operation, $outcome['result'], $result);
238 239 240 241 242 243 244 245 246 247 248
        }

        if (isset($outcome['collection'])) {
            $actualCollection = isset($outcome['collection']['name'])
                ? new Collection($this->manager, $this->getDatabaseName(), $outcome['collection']['name'])
                : $this->collection;

            $this->assertEquivalentCollections($this->expectedCollection, $actualCollection);
        }
    }

249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265
    /**
     * Extracts a result from an exception.
     *
     * Errors for bulkWrite and insertMany operations may still report a write
     * result. This method will attempt to extract such a result so that it can
     * be used in executeAssertResult().
     *
     * If no result can be extracted, null will be returned.
     *
     * @param array            $operation
     * @param RuntimeException $exception
     * @return mixed
     */
    private function extractResultFromException(array $operation, array $outcome, RuntimeException $exception)
    {
        switch ($operation['name']) {
            case 'bulkWrite':
266
                $insertedIds = $outcome['result']['insertedIds'] ?? [];
267 268 269 270 271 272 273

                if ($exception instanceof BulkWriteException) {
                    return new BulkWriteResult($exception->getWriteResult(), $insertedIds);
                }
                break;

            case 'insertMany':
274
                $insertedIds = $outcome['result']['insertedIds'] ?? [];
275 276 277 278 279 280 281 282 283 284

                if ($exception instanceof BulkWriteException) {
                    return new InsertManyResult($exception->getWriteResult(), $insertedIds);
                }
                break;
        }

        return null;
    }

285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    /**
     * Executes the "result" section of an "outcome" block.
     *
     * @param array $operation
     * @param mixed $expectedResult
     * @param mixed $actualResult
     * @throws LogicException if the operation is unsupported
     */
    private function executeAssertResult(array $operation, $expectedResult, $actualResult)
    {
        switch ($operation['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. We will not assert
                 * the result here; however, assertEquivalentCollections() will
                 * assert the output collection's contents later.
                 */
303
                if (! is_last_pipeline_operator_write($operation['arguments']['pipeline'])) {
304 305 306 307
                    $this->assertSameDocuments($expectedResult, $actualResult);
                }
                break;

308
            case 'bulkWrite':
309
                $this->assertIsArray($expectedResult);
310
                $this->assertInstanceOf(BulkWriteResult::class, $actualResult);
311

312 313 314 315
                if (isset($expectedResult['deletedCount'])) {
                    $this->assertSame($expectedResult['deletedCount'], $actualResult->getDeletedCount());
                }

316 317 318 319
                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338
                if (isset($expectedResult['insertedIds'])) {
                    $this->assertSameDocument(
                        ['insertedIds' => $expectedResult['insertedIds']],
                        ['insertedIds' => $actualResult->getInsertedIds()]
                    );
                }

                if (isset($expectedResult['matchedCount'])) {
                    $this->assertSame($expectedResult['matchedCount'], $actualResult->getMatchedCount());
                }

                if (isset($expectedResult['modifiedCount'])) {
                    $this->assertSame($expectedResult['modifiedCount'], $actualResult->getModifiedCount());
                }

                if (isset($expectedResult['upsertedCount'])) {
                    $this->assertSame($expectedResult['upsertedCount'], $actualResult->getUpsertedCount());
                }

339
                if (isset($expectedResult['upsertedIds'])) {
340
                    $this->assertSameDocument(
341 342
                        ['upsertedIds' => $expectedResult['upsertedIds']],
                        ['upsertedIds' => $actualResult->getUpsertedIds()]
343 344 345 346
                    );
                }
                break;

347
            case 'count':
348 349
            case 'countDocuments':
            case 'estimatedDocumentCount':
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
                $this->assertSame($expectedResult, $actualResult);
                break;

            case 'distinct':
                $this->assertSameDocument(
                    ['values' => $expectedResult],
                    ['values' => $actualResult]
                );
                break;

            case 'find':
                $this->assertSameDocuments($expectedResult, $actualResult);
                break;

            case 'deleteMany':
            case 'deleteOne':
366
                $this->assertIsArray($expectedResult);
367
                $this->assertInstanceOf(DeleteResult::class, $actualResult);
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383

                if (isset($expectedResult['deletedCount'])) {
                    $this->assertSame($expectedResult['deletedCount'], $actualResult->getDeletedCount());
                }
                break;

            case 'findOneAndDelete':
            case 'findOneAndReplace':
            case 'findOneAndUpdate':
                $this->assertSameDocument(
                    ['result' => $expectedResult],
                    ['result' => $actualResult]
                );
                break;

            case 'insertMany':
384
                $this->assertIsArray($expectedResult);
385
                $this->assertInstanceOf(InsertManyResult::class, $actualResult);
386 387 388 389 390 391 392 393 394 395 396 397 398 399

                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

                if (isset($expectedResult['insertedIds'])) {
                    $this->assertSameDocument(
                        ['insertedIds' => $expectedResult['insertedIds']],
                        ['insertedIds' => $actualResult->getInsertedIds()]
                    );
                }
                break;

            case 'insertOne':
400
                $this->assertIsArray($expectedResult);
401
                $this->assertInstanceOf(InsertOneResult::class, $actualResult);
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417

                if (isset($expectedResult['insertedCount'])) {
                    $this->assertSame($expectedResult['insertedCount'], $actualResult->getInsertedCount());
                }

                if (isset($expectedResult['insertedId'])) {
                    $this->assertSameDocument(
                        ['insertedId' => $expectedResult['insertedId']],
                        ['insertedId' => $actualResult->getInsertedId()]
                    );
                }
                break;

            case 'replaceOne':
            case 'updateMany':
            case 'updateOne':
418
                $this->assertIsArray($expectedResult);
419
                $this->assertInstanceOf(UpdateResult::class, $actualResult);
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441

                if (isset($expectedResult['matchedCount'])) {
                    $this->assertSame($expectedResult['matchedCount'], $actualResult->getMatchedCount());
                }

                if (isset($expectedResult['modifiedCount'])) {
                    $this->assertSame($expectedResult['modifiedCount'], $actualResult->getModifiedCount());
                }

                if (isset($expectedResult['upsertedCount'])) {
                    $this->assertSame($expectedResult['upsertedCount'], $actualResult->getUpsertedCount());
                }

                if (array_key_exists('upsertedId', $expectedResult)) {
                    $this->assertSameDocument(
                        ['upsertedId' => $expectedResult['upsertedId']],
                        ['upsertedId' => $actualResult->getUpsertedId()]
                    );
                }
                break;

            default:
442
                throw new LogicException('Unsupported operation: ' . $operation['name']);
443 444 445 446 447 448 449 450 451 452 453
        }
    }

    /**
     * Initializes data in the test collections.
     *
     * @param array $initialData
     * @param array $expectedData
     */
    private function initializeData(array $initialData, array $expectedData = null)
    {
454
        if (! empty($initialData)) {
455 456 457
            $this->collection->insertMany($initialData);
        }

458
        if (! empty($expectedData)) {
459 460 461 462
            $this->expectedCollection->insertMany($expectedData);
        }
    }

463 464 465 466 467 468 469
    /**
     * Prepares a request element for a bulkWrite operation.
     *
     * @param array $request
     * @return array
     */
    private function prepareBulkWriteRequest(array $request)
470
    {
471 472 473
        switch ($request['name']) {
            case 'deleteMany':
            case 'deleteOne':
474 475 476 477 478 479
                return [
                    $request['name'] => [
                        $request['arguments']['filter'],
                        array_diff_key($request['arguments'], ['filter' => 1]),
                    ],
                ];
480 481 482
            case 'insertOne':
                return [ 'insertOne' => [ $request['arguments']['document'] ]];
            case 'replaceOne':
483 484 485 486 487 488 489
                return [
                    'replaceOne' => [
                        $request['arguments']['filter'],
                        $request['arguments']['replacement'],
                        array_diff_key($request['arguments'], ['filter' => 1, 'replacement' => 1]),
                    ],
                ];
490 491
            case 'updateMany':
            case 'updateOne':
492 493 494 495 496 497 498
                return [
                    $request['name'] => [
                        $request['arguments']['filter'],
                        $request['arguments']['update'],
                        array_diff_key($request['arguments'], ['filter' => 1, 'update' => 1]),
                    ],
                ];
499 500
            default:
                throw new LogicException('Unsupported bulk write request: ' . $request['name']);
501 502 503
        }
    }

504 505 506 507 508 509
    /**
     * Prepares arguments for findOneAndReplace and findOneAndUpdate operations.
     *
     * @param array $arguments
     * @return array
     */
510
    private function prepareFindAndModifyArguments(array $arguments)
511 512
    {
        if (isset($arguments['returnDocument'])) {
513
            $arguments['returnDocument'] = 'after' === strtolower($arguments['returnDocument'])
514 515 516 517 518 519 520
                ? FindOneAndReplace::RETURN_DOCUMENT_AFTER
                : FindOneAndReplace::RETURN_DOCUMENT_BEFORE;
        }

        return $arguments;
    }
}