Browse Source

Support PHP 8 (#275)

* Allow installing ext-mongodb from source

* Test on PHP 8

* Get tests to run on PHP 8

* Fix wrong serialisation of MongoCode objects to BSON types
Andreas Braun 5 years ago
parent
commit
59e32f2003

+ 13 - 9
.travis.yml

@@ -1,6 +1,5 @@
-dist: trusty
-sudo: false
 language: php
+dist: xenial
 
 services:
   - mongodb
@@ -12,10 +11,6 @@ php:
   - 7.3
   - 7.4
 
-env:
-  global:
-    - DRIVER_VERSION="stable"
-
 addons:
   apt:
     sources:
@@ -24,8 +19,8 @@ addons:
       - "mongodb-upstart"
     packages: ['mongodb-org-server']
 
-before_install:
-  - pecl install -f mongodb-${DRIVER_VERSION}
+install:
+  - .travis/install-extension.sh
   - composer update ${COMPOSER_FLAGS}
 
 script:
@@ -33,6 +28,13 @@ script:
 
 jobs:
   include:
+    # Run tests on PHP 8 with the upcoming extension version
+    - stage: test
+      php: 8.0snapshot
+      env: DRIVER_VERSION="1.9.0RC1"
+      before_install:
+        - composer require --ignore-platform-reqs --no-update mongodb/mongodb 1.8.0-RC1
+
     # Run tests with coverage
     - stage: test
       php: 7.3
@@ -44,13 +46,15 @@ jobs:
 
     # Test against legacy driver to ensure validity of the test suite
     - stage: Test
+      dist: trusty
       php: 5.6
       env: DRIVER_VERSION="1.7.5" SYMFONY_DEPRECATIONS_HELPER=9999999
-      install:
+      before_install:
         - yes '' | pecl -q install -f mongo
 
     # Test against set of lowest dependencies
     - stage: Test
+      dist: trusty
       php: 5.6
       env: DRIVER_VERSION="1.2.0" COMPOSER_FLAGS="--prefer-dist --prefer-lowest"
       addons:

+ 32 - 0
.travis/install-extension.sh

@@ -0,0 +1,32 @@
+#!/bin/sh
+
+# This file was copied from the MongoDB library at https://github.com/mongodb/mongo-php-library.
+# Copyright is (c) MongoDB, Inc.
+
+INI=~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini
+
+if [ "x${DRIVER_BRANCH}" != "x" ] || [ "x${DRIVER_REPO}" != "x" ]; then
+  CLONE_REPO=${DRIVER_REPO:-https://github.com/mongodb/mongo-php-driver}
+  CHECKOUT_BRANCH=${DRIVER_BRANCH:-master}
+
+  echo "Compiling driver branch ${CHECKOUT_BRANCH} from repository ${CLONE_REPO}"
+
+  mkdir -p /tmp/compile
+  git clone ${CLONE_REPO} /tmp/compile/mongo-php-driver
+  cd /tmp/compile/mongo-php-driver
+
+  git checkout ${CHECKOUT_BRANCH}
+  git submodule update --init
+  phpize
+  ./configure --enable-mongodb-developer-flags
+  make all -j20 > /dev/null
+  make install
+
+  echo "extension=mongodb.so" >> `php --ini | grep "Scan for additional .ini files in" | sed -e "s|.*:\s*||"`/mongodb.ini
+elif [ "x${DRIVER_VERSION}" != "x" ]; then
+  echo "Installing driver version ${DRIVER_VERSION} from PECL"
+  pecl install -f mongodb-${DRIVER_VERSION}
+else
+  echo "Installing latest driver version from PECL"
+  pecl install -f mongodb
+fi

+ 2 - 2
composer.json

@@ -9,14 +9,14 @@
         { "name": "Olivier Lechevalier", "email": "olivier.lechevalier@gmail.com" }
     ],
     "require": {
-        "php": "^5.6 || ^7.0",
+        "php": "^5.6 || ^7.0 || ^8.0",
         "ext-ctype": "*",
         "ext-hash": "*",
         "ext-mongodb": "^1.2.0",
         "mongodb/mongodb": "^1.0.1"
     },
     "require-dev": {
-        "symfony/phpunit-bridge": "^4.4 || ^5.1",
+        "symfony/phpunit-bridge": "5.x-dev",
         "squizlabs/php_codesniffer": "^3.2"
     },
     "provide": {

+ 2 - 2
lib/Mongo/MongoCode.php

@@ -27,7 +27,7 @@ class MongoCode implements \Alcaeus\MongoDbAdapter\TypeInterface
     private $code;
 
     /**
-     * @var array
+     * @var array|null
      */
     private $scope;
 
@@ -65,6 +65,6 @@ class MongoCode implements \Alcaeus\MongoDbAdapter\TypeInterface
      */
     public function toBSONType()
     {
-        return new \MongoDB\BSON\Javascript($this->code, $this->scope);
+        return new \MongoDB\BSON\Javascript($this->code, !empty($this->scope) ? $this->scope : null);
     }
 }

+ 16 - 0
tests/Alcaeus/MongoDbAdapter/Constraint/Constraint.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Alcaeus\MongoDbAdapter\Tests\Constraint;
+
+use PHPUnit\Framework\Constraint\Constraint as BaseConstraint;
+use function class_exists;
+
+if (class_exists('PHPUnit_Framework_Constraint')) {
+    abstract class Constraint extends \PHPUnit_Framework_Constraint
+    {
+    }
+} else {
+    abstract class Constraint extends BaseConstraint
+    {
+    }
+}

+ 18 - 0
tests/Alcaeus/MongoDbAdapter/Constraint/ConstraintTrait.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Alcaeus\MongoDbAdapter\Tests\Constraint;
+
+use Symfony\Bridge\PhpUnit\ConstraintTrait as BaseConstraintTrait;
+use Symfony\Bridge\PhpUnit\Legacy\ConstraintTraitForV6;
+
+if (class_exists('PHPUnit_Framework_Constraint')) {
+    trait ConstraintTrait
+    {
+        use ConstraintTraitForV6;
+    }
+} else {
+    trait ConstraintTrait
+    {
+        use BaseConstraintTrait;
+    }
+}

+ 202 - 0
tests/Alcaeus/MongoDbAdapter/Constraint/LICENSE

@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 386 - 0
tests/Alcaeus/MongoDbAdapter/Constraint/Matches.php

@@ -0,0 +1,386 @@
+<?php
+
+namespace Alcaeus\MongoDbAdapter\Tests\Constraint;
+
+use LogicException;
+use MongoDB\BSON\Int64;
+use MongoDB\BSON\Serializable;
+use MongoDB\BSON\Type;
+use MongoDB\Model\BSONArray;
+use MongoDB\Model\BSONDocument;
+use PHPUnit\Framework\Assert;
+use PHPUnit\Framework\ExpectationFailedException;
+use RuntimeException;
+use SebastianBergmann\Comparator\ComparisonFailure;
+use SebastianBergmann\Comparator\Factory;
+use function array_keys;
+use function count;
+use function get_class;
+use function gettype;
+use function is_array;
+use function is_float;
+use function is_int;
+use function is_object;
+use function range;
+use function sprintf;
+use function strpos;
+use const PHP_INT_SIZE;
+
+/**
+ * Constraint that checks if one value matches another.
+ *
+ * The expected value is passed in the constructor. Behavior for allowing extra
+ * keys in root documents and processing operators is also configurable.
+ */
+class Matches extends Constraint
+{
+    use ConstraintTrait;
+
+    /** @var mixed */
+    private $value;
+
+    /** @var bool */
+    private $allowExtraRootKeys;
+
+    /** @var bool */
+    private $allowExtraKeys;
+
+    /** @var bool */
+    private $allowOperators;
+
+    /** @var ComparisonFailure|null */
+    private $lastFailure;
+
+    public function __construct($value, $allowExtraRootKeys = true, $allowExtraKeys = false, $allowOperators = true)
+    {
+        $this->value = self::prepare($value);
+        $this->allowExtraRootKeys = $allowExtraRootKeys;
+        $this->allowExtraKeys = $allowExtraKeys;
+        $this->allowOperators = $allowOperators;
+        $this->comparatorFactory = Factory::getInstance();
+    }
+
+    private function doEvaluate($other, $description = '', $returnResult = false)
+    {
+        $other = self::prepare($other);
+        $success = false;
+        $this->lastFailure = null;
+
+        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,
+                /* 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,
+                $e->getMessage()
+            );
+        }
+
+        if ($returnResult) {
+            return $success;
+        }
+
+        if (! $success) {
+            $this->fail($other, $description, $this->lastFailure);
+        }
+    }
+
+    private function assertEquals($expected, $actual, $keyPath)
+    {
+        $expectedType = is_object($expected) ? get_class($expected) : gettype($expected);
+        $actualType = is_object($actual) ? get_class($actual) : gettype($actual);
+
+        /* 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);
+        }
+
+        try {
+            $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual);
+        } catch (ComparisonFailure $e) {
+            /* Disregard other ComparisonFailure fields, as evaluate() only uses
+             * the message when creating its own ComparisonFailure. */
+            self::failAt($e->getMessage(), $keyPath);
+        }
+    }
+
+    private function assertMatches($expected, $actual, $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, $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, $keyPath)
+    {
+        if ($this->allowOperators && 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 ($this->allowOperators && $expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) {
+                $operatorName = self::getOperatorName($expectedValue);
+
+                if ($operatorName === '$$exists') {
+                    Assert::assertIsBool($expectedValue['$$exists'], '$$exists requires bool');
+
+                    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 keys in root documents
+        if ($this->allowExtraKeys || ($this->allowExtraRootKeys && 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, $keyPath)
+    {
+        $name = self::getOperatorName($operator);
+
+        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 during document iteration. */
+            if ($keyPath === '' && $actual === null) {
+                return;
+            }
+
+            $this->assertMatches(
+                self::prepare($operator['$$unsetOrMatches']),
+                $actual,
+                $keyPath
+            );
+
+            return;
+        }
+
+        throw new LogicException('unsupported operator: ' . $name);
+    }
+
+    /** @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)
+    {
+        foreach ($document as $key => $_) {
+            if (strpos((string) $key, '$$') === 0) {
+                return $key;
+            }
+        }
+
+        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)
+    {
+        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);
+        }
+
+        /* 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());
+        }
+
+        /* 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) {
+            /* 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) {
+            if (is_array($value) || is_object($value)) {
+                $bson[$key] = self::prepare($value);
+            }
+        }
+
+        return $bson;
+    }
+
+    private static function isArrayEmptyOrIndexed(array $a)
+    {
+        if (empty($a)) {
+            return true;
+        }
+
+        return array_keys($a) === range(0, count($a) - 1);
+    }
+}

+ 6 - 0
tests/Alcaeus/MongoDbAdapter/Constraint/README.md

@@ -0,0 +1,6 @@
+Document Constraints for PHPUnit
+================================
+
+The constraints in this directory have been copied from the MongoDB Library at
+https://github.com/mongodb/mongo-php-library. This code is licensed under the
+Apache License Version 2.0.

+ 1 - 1
tests/Alcaeus/MongoDbAdapter/Mongo/MongoClientTest.php

@@ -77,7 +77,7 @@ class MongoClientTest extends TestCase
     {
         $client = $this->getClient();
         $hosts = $client->getHosts();
-        $this->assertArraySubset(
+        $this->assertMatches(
             [
                 'localhost:27017;-;.;' . getmypid() => [
                     'host' => 'localhost',

+ 29 - 4
tests/Alcaeus/MongoDbAdapter/Mongo/MongoCodeTest.php

@@ -23,15 +23,40 @@ class MongoCodeTest extends TestCase
         return $code;
     }
 
-    /**
-     * @depends testCreate
-     */
-    public function testConvertToBson(\MongoCode $code)
+    public function testCreateWithoutScope()
     {
+        $code = new \MongoCode('code');
+
+        $this->assertSame('code', $this->getAttributeValue($code, 'code'));
+        $this->assertSame([], $this->getAttributeValue($code, 'scope'));
+
+        $this->assertSame('code', (string) $code);
+
+        return $code;
+    }
+
+    public function testConvertToBson()
+    {
+        $code = new \MongoCode('code', ['scope' => 'bleh']);
+
+        $this->skipTestUnless($code instanceof TypeInterface);
+
+        $bsonCode = $code->toBSONType();
+        $this->assertInstanceOf('MongoDB\BSON\Javascript', $bsonCode);
+        $this->assertSame('code', $bsonCode->getCode());
+        $this->assertEquals((object) ['scope' => 'bleh'], $bsonCode->getScope());
+    }
+
+    public function testConvertToBsonWithoutScope()
+    {
+        $code = new \MongoCode('code');
+
         $this->skipTestUnless($code instanceof TypeInterface);
 
         $bsonCode = $code->toBSONType();
         $this->assertInstanceOf('MongoDB\BSON\Javascript', $bsonCode);
+        $this->assertSame('code', $bsonCode->getCode());
+        $this->assertNull($bsonCode->getScope());
     }
 
     public function testCreateWithBsonObject()

+ 22 - 19
tests/Alcaeus/MongoDbAdapter/Mongo/MongoCollectionTest.php

@@ -9,6 +9,7 @@ use Alcaeus\MongoDbAdapter\Tests\TestCase;
 use MongoId;
 use PHPUnit\Framework\Error\Warning;
 use function extension_loaded;
+use function strcasecmp;
 
 /**
  * @author alcaeus <alcaeus@alcaeus.org>
@@ -56,10 +57,8 @@ class MongoCollectionTest extends TestCase
     public function testInsertInvalidData()
     {
         // Dirty hack to support both PHPUnit 5.x and 6.x
-        $className = class_exists(Warning::class) ? Warning::class : \PHPUnit_Framework_Error_Warning::class;
-        $this->expectException($className);
-
-        $this->expectExceptionMessage('MongoCollection::insert(): expects parameter 1 to be an array or object, integer given');
+        $this->expectWarning();
+        $this->expectWarningMessage('MongoCollection::insert(): expects parameter 1 to be an array or object, integer given');
 
         $document = 8;
         $this->getCollection()->insert($document);
@@ -250,7 +249,7 @@ class MongoCollectionTest extends TestCase
             ['foo' => 'bar'],
             ['bar' => 'foo']
         ];
-        $this->assertArraySubset($expected, $this->getCollection()->batchInsert($documents));
+        $this->assertMatches($expected, $this->getCollection()->batchInsert($documents));
 
         foreach ($documents as $document) {
             $this->assertInstanceOf('MongoId', $document['_id']);
@@ -272,7 +271,7 @@ class MongoCollectionTest extends TestCase
             'a' => ['foo' => 'bar'],
             'b' => ['bar' => 'foo']
         ];
-        $this->assertArraySubset($expected, $this->getCollection()->batchInsert($documents));
+        $this->assertMatches($expected, $this->getCollection()->batchInsert($documents));
 
         $newCollection = $this->getCheckDatabase()->selectCollection('test');
         $this->assertSame(2, $newCollection->count());
@@ -292,7 +291,7 @@ class MongoCollectionTest extends TestCase
             8,
             'b' => ['bar' => 'foo']
         ];
-        $this->assertArraySubset($expected, $this->getCollection()->batchInsert($documents, ['continueOnError' => true]));
+        $this->assertMatches($expected, $this->getCollection()->batchInsert($documents, ['continueOnError' => true]));
 
         $newCollection = $this->getCheckDatabase()->selectCollection('test');
         $this->assertSame(1, $newCollection->count());
@@ -606,7 +605,7 @@ class MongoCollectionTest extends TestCase
         foreach ($cursor as $document) {
             $this->assertCount(2, $document);
             $this->assertArrayHasKey('_id', $document);
-            $this->assertArraySubset(['bar' => 'bar'], $document);
+            $this->assertMatches(['bar' => 'bar'], $document);
         }
     }
 
@@ -668,7 +667,7 @@ class MongoCollectionTest extends TestCase
         foreach ($cursor as $document) {
             $this->assertCount(1, $document);
             $this->assertArrayNotHasKey('_id', $document);
-            $this->assertArraySubset(['bar' => 'bar'], $document);
+            $this->assertMatches(['bar' => 'bar'], $document);
         }
     }
 
@@ -768,7 +767,7 @@ class MongoCollectionTest extends TestCase
 
         $document = $this->getCollection()->findOne(['foo' => 'foo'], ['bar' => true]);
         $this->assertCount(2, $document);
-        $this->assertArraySubset(['bar' => 'bar'], $document);
+        $this->assertMatches(['bar' => 'bar'], $document);
     }
 
     public function testFindOneWithLegacyProjection()
@@ -778,7 +777,7 @@ class MongoCollectionTest extends TestCase
 
         $document = $this->getCollection()->findOne(['foo' => 'foo'], ['bar']);
         $this->assertCount(2, $document);
-        $this->assertArraySubset(['bar' => 'bar'], $document);
+        $this->assertMatches(['bar' => 'bar'], $document);
     }
 
     public function testFindOneNotFound()
@@ -990,7 +989,7 @@ class MongoCollectionTest extends TestCase
         $this->assertSame(['type' => \MongoClient::RP_SECONDARY_PREFERRED, 'tagsets' => [['a' => 'b']]], $collection->getReadPreference());
 
         $this->assertTrue($collection->setSlaveOkay(false));
-        $this->assertArraySubset(['type' => \MongoClient::RP_PRIMARY], $collection->getReadPreference());
+        $this->assertMatches(['type' => \MongoClient::RP_PRIMARY], $collection->getReadPreference());
     }
 
     public function testReadPreferenceIsSetInDriver()
@@ -1398,8 +1397,8 @@ class MongoCollectionTest extends TestCase
             $expected['code'] = 27;
         }
 
-        // Using assertArraySubset because newer versions (3.4.7?) also return `codeName`
-        $this->assertArraySubset($expected, $this->getCollection()->deleteIndex('bar'));
+        // Using assertMatches because newer versions (3.4.7?) also return `codeName`
+        $this->assertMatches($expected, $this->getCollection()->deleteIndex('bar'));
 
         $this->assertCount(2, iterator_to_array($newCollection->listIndexes()));
     }
@@ -1452,7 +1451,7 @@ class MongoCollectionTest extends TestCase
         $result = $this->getCollection('nonExisting')->deleteIndexes();
 
         $this->assertSame(0.0, $result['ok']);
-        $this->assertRegExp('#ns not found#', $result['errmsg']);
+        $this->assertMatchesRegularExpression('#ns not found#', $result['errmsg']);
         if (version_compare($this->getServerVersion(), '3.4.0', '>=')) {
             $this->assertSame(26, $result['code']);
             $expected['code'] = 26;
@@ -1742,7 +1741,7 @@ class MongoCollectionTest extends TestCase
 
         $result = $collection->group($keys, $initial, $reduce, $condition);
 
-        $this->assertArraySubset(
+        $this->assertMatches(
             [
                 'retval' => [['count' => 1.0]],
                 'count' => 1.0,
@@ -1798,7 +1797,7 @@ class MongoCollectionTest extends TestCase
             'map' => new \MongoCode($map),
             'reduce' => new \MongoCode($reduce),
             'query' => (object) [],
-            'out' => ['inline' => true],
+            'out' => ['inline' => 1],
             'finalize' => new \MongoCode($finalize),
         ];
 
@@ -1823,8 +1822,12 @@ class MongoCollectionTest extends TestCase
             ],
         ];
 
+        usort($result['results'], function ($a, $b) {
+            return strcasecmp($a['_id'], $b['_id']);
+        });
+
         $this->assertSame(1.0, $result['ok']);
-        $this->assertSame($expected, $result['results']);
+        $this->assertEquals($expected, $result['results']);
     }
 
     public function testFindAndModifyResultException()
@@ -1886,7 +1889,7 @@ class MongoCollectionTest extends TestCase
         $collection->insert($document);
         $result = $collection->validate();
 
-        $this->assertArraySubset(
+        $this->assertMatches(
             [
                 'ns' => 'mongo-php-adapter.test',
                 'nrecords' => 1,

+ 2 - 2
tests/Alcaeus/MongoDbAdapter/Mongo/MongoCommandCursorTest.php

@@ -59,7 +59,7 @@ class MongoCommandCursorTest extends TestCase
             'connection_type_desc' => 'STANDALONE',
         ];
 
-        $this->assertArraySubset($expected, $cursor->info());
+        $this->assertMatches($expected, $cursor->info());
 
         $i = 0;
         foreach ($array as $key => $value) {
@@ -137,7 +137,7 @@ class MongoCommandCursorTest extends TestCase
             'mapReduceWithOutInline' => [
                 [
                     'mapReduce' => (string) $this->getCollection(),
-                    'out' => ['inline' => true],
+                    'out' => ['inline' => 1],
                 ],
                 ReadPreference::RP_SECONDARY,
             ],

+ 15 - 15
tests/Alcaeus/MongoDbAdapter/Mongo/MongoCursorTest.php

@@ -434,23 +434,23 @@ class MongoCursorTest extends TestCase
                 'parsedQuery' => [
                     'foo' => ['$eq' => 'bar']
                 ],
-                'winningPlan' => [],
-                'rejectedPlans' => [],
+                'winningPlan' => ['$$exists' => true],
+                'rejectedPlans' => ['$$exists' => true],
             ],
             'executionStats' => [
                 'executionSuccess' => true,
                 'nReturned' => 1,
                 'totalKeysExamined' => 0,
                 'totalDocsExamined' => 3,
-                'executionStages' => [],
-                'allPlansExecution' => [],
+                'executionStages' => ['$$exists' => true],
+                'allPlansExecution' => ['$$exists' => true],
             ],
             'serverInfo' => [
                 'port' => 27017,
             ],
         ];
 
-        $this->assertArraySubset($expected, $cursor->explain());
+        $this->assertMatches($expected, $cursor->explain());
     }
 
     public function testExplainWithEmptyProjection()
@@ -468,23 +468,23 @@ class MongoCursorTest extends TestCase
                 'parsedQuery' => [
                     'foo' => ['$eq' => 'bar']
                 ],
-                'winningPlan' => [],
-                'rejectedPlans' => [],
+                'winningPlan' => ['$$exists' => true],
+                'rejectedPlans' => ['$$exists' => true],
             ],
             'executionStats' => [
                 'executionSuccess' => true,
                 'nReturned' => 2,
                 'totalKeysExamined' => 0,
                 'totalDocsExamined' => 3,
-                'executionStages' => [],
-                'allPlansExecution' => [],
+                'executionStages' => ['$$exists' => true],
+                'allPlansExecution' => ['$$exists' => true],
             ],
             'serverInfo' => [
                 'port' => 27017,
             ],
         ];
 
-        $this->assertArraySubset($expected, $cursor->explain());
+        $this->assertMatches($expected, $cursor->explain());
     }
 
     public function testExplainConvertsQuery()
@@ -499,23 +499,23 @@ class MongoCursorTest extends TestCase
                 'plannerVersion' => 1,
                 'namespace' => 'mongo-php-adapter.test',
                 'indexFilterSet' => false,
-                'winningPlan' => [],
-                'rejectedPlans' => [],
+                'winningPlan' => ['$$exists' => true],
+                'rejectedPlans' => ['$$exists' => true],
             ],
             'executionStats' => [
                 'executionSuccess' => true,
                 'nReturned' => 2,
                 'totalKeysExamined' => 0,
                 'totalDocsExamined' => 3,
-                'executionStages' => [],
-                'allPlansExecution' => [],
+                'executionStages' => ['$$exists' => true],
+                'allPlansExecution' => ['$$exists' => true],
             ],
             'serverInfo' => [
                 'port' => 27017,
             ],
         ];
 
-        $this->assertArraySubset($expected, $cursor->explain());
+        $this->assertMatches($expected, $cursor->explain());
     }
 
     public function testInterfaces()

+ 4 - 4
tests/Alcaeus/MongoDbAdapter/Mongo/MongoDBTest.php

@@ -126,8 +126,8 @@ class MongoDBTest extends TestCase
             'code' => 13,
         ];
 
-        // Using assertArraySubset because newer versions (3.4.7?) also return `codeName`
-        $this->assertArraySubset($expected, $db->command(['listDatabases' => 1]));
+        // Using assertMatches because newer versions (3.4.7?) also return `codeName`
+        $this->assertMatches($expected, $db->command(['listDatabases' => 1]));
     }
 
     public function testCommandCursorTimeout()
@@ -164,7 +164,7 @@ class MongoDBTest extends TestCase
 
         $this->assertTrue($database->setSlaveOkay(false));
         // Only test a subset since we don't keep tagsets around for RP_PRIMARY
-        $this->assertArraySubset(['type' => \MongoClient::RP_PRIMARY], $database->getReadPreference());
+        $this->assertMatches(['type' => \MongoClient::RP_PRIMARY], $database->getReadPreference());
     }
 
     public function testReadPreferenceIsSetInDriver()
@@ -292,7 +292,7 @@ class MongoDBTest extends TestCase
                         ],
                     ];
                 }
-                $this->assertArraySubset($expected, $collectionInfo);
+                $this->assertMatches($expected, $collectionInfo);
                 return;
             }
         }

+ 2 - 1
tests/Alcaeus/MongoDbAdapter/Mongo/MongoGridFSCursorTest.php

@@ -2,6 +2,7 @@
 
 namespace Alcaeus\MongoDbAdapter\Tests\Mongo;
 
+use Alcaeus\MongoDbAdapter\Tests\Constraint\Matches;
 use Alcaeus\MongoDbAdapter\Tests\TestCase;
 use Countable;
 
@@ -30,7 +31,7 @@ class MongoGridFSCursorTest extends TestCase
             $this->assertInstanceOf('MongoGridFSFile', $value);
             $this->assertSame('foo', $value->getBytes());
 
-            $this->assertArraySubset([
+            $this->assertMatches([
                 'filename' => 'foo.txt',
                 'chunkSize' => 261120,
                 'length' => 3,

+ 1 - 1
tests/Alcaeus/MongoDbAdapter/Mongo/MongoGridFSFileTest.php

@@ -19,7 +19,7 @@ class MongoGridFSFileTest extends TestCase
     {
         $file = $this->getFile();
         $this->assertArrayHasKey('_id', $file->file);
-        $this->assertArraySubset(
+        $this->assertMatches(
             [
                 'length' => 666,
                 'filename' => 'file',

+ 1 - 1
tests/Alcaeus/MongoDbAdapter/Mongo/MongoInsertBatchTest.php

@@ -80,7 +80,7 @@ class MongoInsertBatchTest extends TestCase
         } catch (\MongoWriteConcernException $e) {
             $this->assertSame('Failed write', $e->getMessage());
             $this->assertSame(911, $e->getCode());
-            $this->assertArraySubset($expected, $e->getDocument());
+            $this->assertMatches($expected, $e->getDocument());
         }
     }
 }

+ 3 - 3
tests/Alcaeus/MongoDbAdapter/Mongo/MongoUpdateBatchTest.php

@@ -70,7 +70,7 @@ class MongoUpdateBatchTest extends TestCase
         } catch (\MongoWriteConcernException $e) {
             $this->assertSame('Failed write', $e->getMessage());
             $this->assertSame(911, $e->getCode());
-            $this->assertArraySubset($expected, $e->getDocument());
+            $this->assertMatches($expected, $e->getDocument());
         }
     }
 
@@ -162,7 +162,7 @@ class MongoUpdateBatchTest extends TestCase
         } catch (\MongoWriteConcernException $e) {
             $this->assertSame('Failed write', $e->getMessage());
             $this->assertSame(911, $e->getCode());
-            $this->assertArraySubset($expected, $e->getDocument());
+            $this->assertMatches($expected, $e->getDocument());
         }
     }
 
@@ -188,7 +188,7 @@ class MongoUpdateBatchTest extends TestCase
         ];
 
         $result = $batch->execute();
-        $this->assertArraySubset($expected, $result);
+        $this->assertMatches($expected, $result);
 
         $this->assertInstanceOf('MongoId', $result['upserted'][0]['_id']);
 

+ 7 - 0
tests/Alcaeus/MongoDbAdapter/TestCase.php

@@ -2,6 +2,7 @@
 
 namespace Alcaeus\MongoDbAdapter\Tests;
 
+use Alcaeus\MongoDbAdapter\Tests\Constraint\Matches;
 use MongoDB\Client;
 use PHPUnit\Framework\TestCase as BaseTestCase;
 use Symfony\Bridge\PhpUnit\SetUpTearDownTrait;
@@ -20,6 +21,12 @@ abstract class TestCase extends BaseTestCase
         parent::tearDown();
     }
 
+    public function assertMatches($expected, $value, $message = '')
+    {
+        $constraint = new Matches($expected, true, true, true);
+        $this->assertThat($value, $constraint, $message);
+    }
+
     /**
      * @return \MongoDB\Client
      */