diff --git a/.evergreen/config/build-variants.yml b/.evergreen/config/build-variants.yml index 3178f9fde..43b7ed4f2 100644 --- a/.evergreen/config/build-variants.yml +++ b/.evergreen/config/build-variants.yml @@ -23,10 +23,16 @@ buildvariants: run_on: rhel90-small tasks: - name: "build-all-php" - - name: build-rhel83-zseries - display_name: "Build: RHEL 8.3 Zseries" + - name: build-rhel9-zseries + display_name: "Build: RHEL 9 Zseries" tags: ["build", "rhel", "zseries", "tag"] - run_on: rhel83-zseries-small + run_on: rhel9-zseries-small + tasks: + - name: "build-all-php" + - name: build-rhel9-power + display_name: "Build: RHEL 9 PPC" + tags: ["build", "rhel", "power", "tag"] + run_on: rhel9-power-small tasks: - name: "build-all-php" - name: build-rhel82-arm64 @@ -35,12 +41,6 @@ buildvariants: run_on: rhel82-arm64 tasks: - name: "build-all-php" - - name: build-rhel81-power8 - display_name: "Build: RHEL 8.1 Power8" - tags: ["build", "rhel", "power8", "tag"] - run_on: rhel81-power8-large - tasks: - - name: "build-all-php" - name: build-rhel80 display_name: "Build: RHEL 8.0" tags: ["build", "rhel", "x64", "pr", "tag"] diff --git a/.evergreen/config/generated/build/build-extension.yml b/.evergreen/config/generated/build/build-extension.yml index b7359c1e8..993396ec7 100644 --- a/.evergreen/config/generated/build/build-extension.yml +++ b/.evergreen/config/generated/build/build-extension.yml @@ -7,37 +7,41 @@ tasks: vars: PHP_VERSION: "8.4" - func: "compile extension" - - func: "upload extension" - - name: "build-php-8.4-lowest" - tags: ["build", "php8.4", "lowest", "pr", "tag"] - commands: - - func: "locate PHP binaries" - vars: - PHP_VERSION: "8.4" - - func: "compile extension" - vars: - EXTENSION_VERSION: "1.20.0" - - func: "upload extension" - - name: "build-php-8.4-next-stable" - tags: ["build", "php8.4", "next-stable", "pr", "tag"] - commands: - - func: "locate PHP binaries" - vars: - PHP_VERSION: "8.4" - - func: "compile extension" - vars: - EXTENSION_BRANCH: "v1.20" - - func: "upload extension" - - name: "build-php-8.4-next-minor" - tags: ["build", "php8.4", "next-minor"] - commands: - - func: "locate PHP binaries" - vars: - PHP_VERSION: "8.4" - - func: "compile extension" + # TODO: remove once 2.0.0 is released vars: - EXTENSION_BRANCH: "v1.x" + EXTENSION_BRANCH: "v2.x" - func: "upload extension" + # TODO: re-enable once 2.0.0 is released +# - name: "build-php-8.4-lowest" +# tags: ["build", "php8.4", "lowest", "pr", "tag"] +# commands: +# - func: "locate PHP binaries" +# vars: +# PHP_VERSION: "8.4" +# - func: "compile extension" +# vars: +# EXTENSION_VERSION: "2.0.0" +# - func: "upload extension" +# - name: "build-php-8.4-next-stable" +# tags: ["build", "php8.4", "next-stable", "pr", "tag"] +# commands: +# - func: "locate PHP binaries" +# vars: +# PHP_VERSION: "8.4" +# - func: "compile extension" +# vars: +# EXTENSION_BRANCH: "v2.0" +# - func: "upload extension" +# - name: "build-php-8.4-next-minor" +# tags: ["build", "php8.4", "next-minor"] +# commands: +# - func: "locate PHP binaries" +# vars: +# PHP_VERSION: "8.4" +# - func: "compile extension" +# vars: +# EXTENSION_BRANCH: "v2.x" +# - func: "upload extension" - name: "build-php-8.3" tags: ["build", "php8.3", "stable", "pr", "tag"] commands: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bbb23c7a0..d771b123a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,4 +7,4 @@ updates: - package-ecosystem: "gitsubmodule" directory: "/" schedule: - interval: "daily" + interval: "weekly" diff --git a/src/Client.php b/src/Client.php index 43856439b..e00ee9192 100644 --- a/src/Client.php +++ b/src/Client.php @@ -168,7 +168,7 @@ public function __debugInfo(): array */ public function __get(string $databaseName): Database { - return $this->selectDatabase($databaseName); + return $this->getDatabase($databaseName); } /** @@ -230,6 +230,37 @@ public function dropDatabase(string $databaseName, array $options = []): void $operation->execute($server); } + /** + * Returns a collection instance. + * + * If the collection does not exist in the database, it is not created when + * invoking this method. + * + * @see Collection::__construct() for supported options + * @throws InvalidArgumentException for parameter/option parsing errors + */ + public function getCollection(string $databaseName, string $collectionName, array $options = []): Collection + { + $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder]; + + return new Collection($this->manager, $databaseName, $collectionName, $options); + } + + /** + * Returns a database instance. + * + * If the database does not exist on the server, it is not created when + * invoking this method. + * + * @see Database::__construct() for supported options + */ + public function getDatabase(string $databaseName, array $options = []): Database + { + $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder]; + + return new Database($this->manager, $databaseName, $options); + } + /** * Return the Manager. */ @@ -329,9 +360,7 @@ final public function removeSubscriber(Subscriber $subscriber): void */ public function selectCollection(string $databaseName, string $collectionName, array $options = []): Collection { - $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder]; - - return new Collection($this->manager, $databaseName, $collectionName, $options); + return $this->getCollection($databaseName, $collectionName, $options); } /** @@ -344,9 +373,7 @@ public function selectCollection(string $databaseName, string $collectionName, a */ public function selectDatabase(string $databaseName, array $options = []): Database { - $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder]; - - return new Database($this->manager, $databaseName, $options); + return $this->getDatabase($databaseName, $options); } /** diff --git a/src/Database.php b/src/Database.php index 08817dbba..a3064ddd3 100644 --- a/src/Database.php +++ b/src/Database.php @@ -171,7 +171,7 @@ public function __debugInfo(): array */ public function __get(string $collectionName): Collection { - return $this->selectCollection($collectionName); + return $this->getCollection($collectionName); } /** @@ -384,6 +384,28 @@ public function dropCollection(string $collectionName, array $options = []): voi $operation->execute($server); } + /** + * Returns a collection instance. + * + * If the collection does not exist in the database, it is not created when + * invoking this method. + * + * @see Collection::__construct() for supported options + * @throws InvalidArgumentException for parameter/option parsing errors + */ + public function getCollection(string $collectionName, array $options = []): Collection + { + $options += [ + 'builderEncoder' => $this->builderEncoder, + 'readConcern' => $this->readConcern, + 'readPreference' => $this->readPreference, + 'typeMap' => $this->typeMap, + 'writeConcern' => $this->writeConcern, + ]; + + return new Collection($this->manager, $this->databaseName, $collectionName, $options); + } + /** * Returns the database name. */ @@ -534,15 +556,7 @@ public function renameCollection(string $fromCollectionName, string $toCollectio */ public function selectCollection(string $collectionName, array $options = []): Collection { - $options += [ - 'builderEncoder' => $this->builderEncoder, - 'readConcern' => $this->readConcern, - 'readPreference' => $this->readPreference, - 'typeMap' => $this->typeMap, - 'writeConcern' => $this->writeConcern, - ]; - - return new Collection($this->manager, $this->databaseName, $collectionName, $options); + return $this->getCollection($collectionName, $options); } /** diff --git a/src/GridFS/Bucket.php b/src/GridFS/Bucket.php index 234af8954..8eee0b356 100644 --- a/src/GridFS/Bucket.php +++ b/src/GridFS/Bucket.php @@ -219,6 +219,23 @@ public function delete(mixed $id): void } } + /** + * Delete all the revisions of a file name from the GridFS bucket. + * + * @param string $filename Filename + * + * @throws FileNotFoundException if no file could be selected + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function deleteByName(string $filename): void + { + $count = $this->collectionWrapper->deleteFileAndChunksByFilename($filename); + + if ($count === 0) { + throw FileNotFoundException::byFilename($filename); + } + } + /** * Writes the contents of a GridFS file to a writable stream. * @@ -590,6 +607,24 @@ public function rename(mixed $id, string $newFilename): void } } + /** + * Renames all the revisions of a file name in the GridFS bucket. + * + * @param string $filename Filename + * @param string $newFilename New filename + * + * @throws FileNotFoundException if no file could be selected + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function renameByName(string $filename, string $newFilename): void + { + $count = $this->collectionWrapper->updateFilenameForFilename($filename, $newFilename); + + if ($count === 0) { + throw FileNotFoundException::byFilename($filename); + } + } + /** * Writes the contents of a readable stream to a GridFS file. * diff --git a/src/GridFS/CollectionWrapper.php b/src/GridFS/CollectionWrapper.php index 5941992ef..62be77fc0 100644 --- a/src/GridFS/CollectionWrapper.php +++ b/src/GridFS/CollectionWrapper.php @@ -148,9 +148,6 @@ public function findChunksByFileId(mixed $id, int $fromChunk = 0): CursorInterfa */ public function findFileByFilenameAndRevision(string $filename, int $revision): ?object { - $filename = $filename; - $revision = $revision; - if ($revision < 0) { $skip = abs($revision) - 1; $sortOrder = -1; diff --git a/tests/GridFS/BucketFunctionalTest.php b/tests/GridFS/BucketFunctionalTest.php index 5abc7187f..8c347c5cd 100644 --- a/tests/GridFS/BucketFunctionalTest.php +++ b/tests/GridFS/BucketFunctionalTest.php @@ -158,6 +158,34 @@ public function testDeleteStillRemovesChunksIfFileDoesNotExist($input, $expected $this->assertCollectionCount($this->chunksCollection, 0); } + public function testDeleteByName(): void + { + $this->bucket->uploadFromStream('filename', self::createStream('foobar1')); + $this->bucket->uploadFromStream('filename', self::createStream('foobar2')); + $this->bucket->uploadFromStream('filename', self::createStream('foobar3')); + + $this->bucket->uploadFromStream('other', self::createStream('foobar')); + + $this->assertCollectionCount($this->filesCollection, 4); + $this->assertCollectionCount($this->chunksCollection, 4); + + $this->bucket->deleteByName('filename'); + + $this->assertCollectionCount($this->filesCollection, 1); + $this->assertCollectionCount($this->chunksCollection, 1); + + $this->bucket->deleteByName('other'); + + $this->assertCollectionCount($this->filesCollection, 0); + $this->assertCollectionCount($this->chunksCollection, 0); + } + + public function testDeleteByNameShouldRequireFileToExist(): void + { + $this->expectException(FileNotFoundException::class); + $this->bucket->deleteByName('nonexistent-name'); + } + public function testDownloadingFileWithMissingChunk(): void { $id = $this->bucket->uploadFromStream('filename', self::createStream('foobar')); @@ -721,6 +749,24 @@ public function testRenameShouldRequireFileToExist(): void $this->bucket->rename('nonexistent-id', 'b'); } + public function testRenameByName(): void + { + $this->bucket->uploadFromStream('filename', self::createStream('foo')); + $this->bucket->uploadFromStream('filename', self::createStream('foo')); + $this->bucket->uploadFromStream('filename', self::createStream('foo')); + + $this->bucket->renameByName('filename', 'newname'); + + $this->assertNull($this->bucket->findOne(['filename' => 'filename']), 'No file has the old name'); + $this->assertStreamContents('foo', $this->bucket->openDownloadStreamByName('newname')); + } + + public function testRenameByNameShouldRequireFileToExist(): void + { + $this->expectException(FileNotFoundException::class); + $this->bucket->renameByName('nonexistent-name', 'b'); + } + public function testUploadFromStream(): void { $options = [ diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php index e1b659a3e..b793614a4 100644 --- a/tests/UnifiedSpecTests/Constraint/Matches.php +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -3,6 +3,7 @@ namespace MongoDB\Tests\UnifiedSpecTests\Constraint; use LogicException; +use MongoDB\BSON\Document; use MongoDB\BSON\Serializable; use MongoDB\BSON\Type; use MongoDB\Model\BSONArray; @@ -25,10 +26,13 @@ use function is_int; use function is_object; use function ltrim; +use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertIsBool; use function PHPUnit\Framework\assertIsString; +use function PHPUnit\Framework\assertJson; use function PHPUnit\Framework\assertMatchesRegularExpression; use function PHPUnit\Framework\assertNotNull; +use function PHPUnit\Framework\assertStringStartsWith; use function PHPUnit\Framework\assertThat; use function PHPUnit\Framework\containsOnly; use function PHPUnit\Framework\isInstanceOf; @@ -39,6 +43,7 @@ use function sprintf; use function str_starts_with; use function strrchr; +use function trim; /** * Constraint that checks if one value matches another. @@ -263,6 +268,35 @@ private function assertMatchesOperator(BSONDocument $operator, $actual, string $ return; } + if ($name === '$$matchAsDocument') { + assertInstanceOf(BSONDocument::class, $operator['$$matchAsDocument'], '$$matchAsDocument requires a BSON document'); + assertIsString($actual, '$$matchAsDocument requires actual value to be a JSON string'); + assertJson($actual, '$$matchAsDocument requires actual value to be a JSON string'); + + /* Note: assertJson() accepts array and scalar values, but the spec + * assumes that the JSON string will yield a document. */ + assertStringStartsWith('{', trim($actual), '$$matchAsDocument requires actual value to be a JSON string denoting an object'); + + $actualDocument = Document::fromJSON($actual)->toPHP(); + $constraint = new Matches($operator['$$matchAsDocument'], $this->entityMap, allowExtraRootKeys: false); + + if (! $constraint->evaluate($actualDocument, '', true)) { + self::failAt(sprintf('%s did not match: %s', (new Exporter())->shortenedExport($actual), $constraint->additionalFailureDescription(null)), $keyPath); + } + + return; + } + + if ($name === '$$matchAsRoot') { + $constraint = new Matches($operator['$$matchAsRoot'], $this->entityMap, allowExtraRootKeys: true); + + if (! $constraint->evaluate($actual, '', true)) { + self::failAt(sprintf('$actual did not match as root-level document: %s', $constraint->additionalFailureDescription(null)), $keyPath); + } + + return; + } + if ($name === '$$matchesEntity') { assertNotNull($this->entityMap, '$$matchesEntity requires EntityMap'); assertIsString($operator['$$matchesEntity'], '$$matchesEntity requires string'); diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php index 0de7aa9cb..41b57fad1 100644 --- a/tests/UnifiedSpecTests/Constraint/MatchesTest.php +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -30,6 +30,12 @@ public function testFlexibleNumericComparison(): void $this->assertResult(true, $c, ['x' => 1.0, 'y' => 1.0], 'Float instead of expected int matches'); $this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'Int instead of expected float matches'); $this->assertResult(false, $c, ['x' => 'foo', 'y' => 1.0], 'Different type does not match'); + + /* Matches uses PHPUnit's comparators, which follow PHP behavior. This + * is more liberal than the comparison logic called for by the unified + * test format. This test can be removed when PHPLIB-1577 is addressed. + */ + $this->assertResult(true, $c, ['x' => '1.0', 'y' => '1'], 'Numeric strings may match ints and floats'); } public function testDoNotAllowExtraRootKeys(): void @@ -171,6 +177,37 @@ public function testOperatorSessionLsid(): void $this->assertResult(false, $c, ['x' => 1], 'session LSID does not match (embedded)'); } + public function testOperatorMatchAsDocument(): void + { + $c = new Matches(['json' => ['$$matchAsDocument' => ['x' => 1]]]); + $this->assertResult(true, $c, ['json' => '{"x": 1}'], 'JSON document matches'); + $this->assertResult(false, $c, ['json' => '{"x": 2}'], 'JSON document does not match'); + $this->assertResult(false, $c, ['json' => '{"x": 1, "y": 2}'], 'JSON document cannot contain extra fields'); + + $c = new Matches(['json' => ['$$matchAsDocument' => ['x' => 1.0]]]); + $this->assertResult(true, $c, ['json' => '{"x": 1}'], 'JSON document matches (flexible numeric comparison)'); + + $c = new Matches(['json' => ['$$matchAsDocument' => ['x' => ['$$exists' => true]]]]); + $this->assertResult(true, $c, ['json' => '{"x": 1}'], 'JSON document matches (special operators)'); + $this->assertResult(false, $c, ['json' => '{"y": 1}'], 'JSON document does not match (special operators)'); + + $c = new Matches(['json' => ['$$matchAsDocument' => ['x' => ['$$type' => 'objectId']]]]); + $this->assertResult(true, $c, ['json' => '{"x": {"$oid": "57e193d7a9cc81b4027498b5"}}'], 'JSON document matches (extended JSON)'); + $this->assertResult(false, $c, ['json' => '{"x": {"$numberDecimal": "1234.5"}}'], 'JSON document does not match (extended JSON)'); + } + + public function testOperatorMatchAsRoot(): void + { + $c = new Matches(['x' => ['$$matchAsRoot' => ['y' => 2]]]); + $this->assertResult(true, $c, ['x' => ['y' => 2, 'z' => 3]], 'Nested document matches (allow extra fields)'); + $this->assertResult(true, $c, ['x' => ['y' => 2.0, 'z' => 3.0]], 'Nested document matches (flexible numeric comparison)'); + $this->assertResult(false, $c, ['x' => ['y' => 3, 'z' => 3]], 'Nested document does not match'); + + $c = new Matches(['x' => ['$$matchAsRoot' => ['y' => ['$$exists' => true]]]]); + $this->assertResult(true, $c, ['x' => ['y' => 2, 'z' => 3]], 'Nested document matches (special operators)'); + $this->assertResult(false, $c, ['x' => ['z' => 3]], 'Nested document matches (special operators)'); + } + #[DataProvider('errorMessageProvider')] public function testErrorMessages($expectedMessageRegex, Matches $constraint, $actualValue): void { @@ -302,6 +339,10 @@ public static function operatorErrorMessageProvider() '$$sessionLsid requires string', new Matches(['x' => ['$$sessionLsid' => 1]], new EntityMap()), ], + '$$matchAsDocument type' => [ + '$$matchAsDocument requires a BSON document', + new Matches(['x' => ['$$matchAsDocument' => 'foo']]), + ], ]; } diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 63ca8fcd9..96e1703ce 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -777,6 +777,12 @@ private function executeForBucket(Bucket $bucket) return $bucket->delete($args['id']); + case 'deleteByName': + assertArrayHasKey('filename', $args); + assertIsString($args['filename']); + + return $bucket->deleteByName($args['filename']); + case 'downloadByName': assertArrayHasKey('filename', $args); assertIsString($args['filename']); @@ -791,6 +797,23 @@ private function executeForBucket(Bucket $bucket) return stream_get_contents($bucket->openDownloadStream($args['id'])); + case 'rename': + assertArrayHasKey('id', $args); + assertArrayHasKey('newFilename', $args); + assertIsString($args['newFilename']); + + $bucket->rename($args['id'], $args['newFilename']); + + return null; + + case 'renameByName': + assertArrayHasKey('filename', $args); + assertArrayHasKey('newFilename', $args); + assertIsString($args['filename']); + assertIsString($args['newFilename']); + + return $bucket->renameByName($args['filename'], $args['newFilename']); + case 'uploadWithId': assertArrayHasKey('id', $args); $args['_id'] = $args['id']; diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 46ce8adbe..1c4df0478 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -42,6 +42,8 @@ class UnifiedSpecTest extends FunctionalTestCase 'crud/BulkWrite updateOne-sort' => 'Sort for update operations is not supported (PHPLIB-1492)', 'crud/replaceOne-sort' => 'Sort for replace operations is not supported (PHPLIB-1492)', 'crud/updateOne-sort' => 'Sort for update operations is not supported (PHPLIB-1492)', + 'crud/bypassDocumentValidation' => 'bypassDocumentValidation is handled by libmongoc (PHPLIB-1576)', + 'crud/distinct-hint' => 'Hint for distinct operations is not supported (PHPLIB-1582)', ]; /** @var array */ @@ -59,7 +61,7 @@ class UnifiedSpecTest extends FunctionalTestCase 'valid-pass/expectedEventsForClient-eventType: eventType defaults to command if unset' => 'PHPC does not implement CMAP', // CSOT is not yet implemented (PHPC-1760) 'valid-pass/collectionData-createOptions: collection is created with the correct options' => 'CSOT is not yet implemented (PHPC-1760)', - 'valid-pass/matches-lte-operator: special lte matching operator' => 'CSOT is not yet implemented (PHPC-1760)', + 'valid-pass/operator-lte: special lte matching operator' => 'CSOT is not yet implemented (PHPC-1760)', // libmongoc always adds readConcern to aggregate command 'index-management/search index operations ignore read and write concern: listSearchIndexes ignores read and write concern' => 'libmongoc appends readConcern to aggregate command', // Uses an invalid object name diff --git a/tests/UnifiedSpecTests/UnifiedTestRunner.php b/tests/UnifiedSpecTests/UnifiedTestRunner.php index 93d3a3421..92dd0f61a 100644 --- a/tests/UnifiedSpecTests/UnifiedTestRunner.php +++ b/tests/UnifiedSpecTests/UnifiedTestRunner.php @@ -61,7 +61,7 @@ final class UnifiedTestRunner * - 1.9: Only createEntities operation is implemented * - 1.10: Not implemented * - 1.11: Not implemented, but CMAP is not applicable - * - 1.13: Not implemented + * - 1.13: Only $$matchAsDocument and $$matchAsRoot is implemented * - 1.14: Not implemented */ public const MAX_SCHEMA_VERSION = '1.15'; diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php index 49ec40ee3..563c11ff0 100644 --- a/tests/UnifiedSpecTests/Util.php +++ b/tests/UnifiedSpecTests/Util.php @@ -132,8 +132,11 @@ final class Util ], Bucket::class => [ 'delete' => ['id'], + 'deleteByName' => ['filename'], 'downloadByName' => ['filename', 'revision'], 'download' => ['id'], + 'rename' => ['id', 'newFilename'], + 'renameByName' => ['filename', 'newFilename'], // "disableMD5" is ignored but allowed for backward compatibility 'uploadWithId' => ['id', 'filename', 'source', 'chunkSizeBytes', 'disableMD5', 'metadata'], 'upload' => ['filename', 'source', 'chunkSizeBytes', 'disableMD5', 'metadata'], diff --git a/tests/specifications b/tests/specifications index 04560fc74..daf9e0744 160000 --- a/tests/specifications +++ b/tests/specifications @@ -1 +1 @@ -Subproject commit 04560fc74c823732c693e89a63027bc1453e6dc3 +Subproject commit daf9e0744692d1e48a8a2d90d585602ea965e520