diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 887cffd4f84d..d899c0b9fc54 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -1145,6 +1145,8 @@ public function update(array $options = []) * matches the given value. * @type string $ifMetagenerationMatch Makes the operation conditional on whether the object's current * metageneration matches the given value. + * @type bool $deleteSourceObjects If true, the source objects will be + * deleted after a successful compose operation. * } * @return StorageObject * @throws \InvalidArgumentException @@ -1185,11 +1187,31 @@ public function compose(array $sourceObjects, $name, array $options = []) throw new \InvalidArgumentException('A content type could not be detected and must be provided manually.'); } + $deleteSourceObjects = $options['deleteSourceObjects'] ?? false; + unset($options['metadata']); unset($options['predefinedAcl']); $response = $this->connection->composeObject(array_filter($options)); + if ($deleteSourceObjects) { + $deleteOptions = array_filter([ + 'userProject' => $this->identity['userProject'] + ]); + + foreach ($sourceObjects as $sourceObject) { + try { + if ($sourceObject instanceof StorageObject) { + $sourceObject->delete($deleteOptions); + } else { + $this->object($sourceObject)->delete($deleteOptions); + } + } catch (NotFoundException $e) { + // Gracefully ignore duplicate source files or parallel deletions + } + } + } + return new StorageObject( $this->connection, $response['name'], diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index dcce6fbecb50..f6e429ebe708 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -4398,6 +4398,11 @@ "type": "string", "description": "The project to be billed for this request. Required for Requester Pays buckets.", "location": "query" + }, + "deleteSourceObjects": { + "type": "boolean", + "description": "If true, the source objects will be deleted after a successful compose operation.", + "location": "query" } }, "parameterOrder": [ diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 847ad64cc0be..abdca16cfbe3 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -597,6 +597,84 @@ public function testComposeObjects($object) return $composedObject; } + public function testComposeObjectsWithDeleteSourceObjects() + { + $source1 = self::$bucket->upload('content1', ['name' => uniqid(self::TESTING_PREFIX) . '-s1.txt']); + $source2 = self::$bucket->upload('content2', ['name' => uniqid(self::TESTING_PREFIX) . '-s2.txt']); + + $this->assertTrue($source1->exists()); + $this->assertTrue($source2->exists()); + + $name = uniqid(self::TESTING_PREFIX) . '-composed.txt'; + $composedObject = self::$bucket->compose( + [$source1, $source2], + $name, + ['deleteSourceObjects' => true] + ); + + $this->assertEquals($name, $composedObject->name()); + $this->assertEquals('content1content2', $composedObject->downloadAsString()); + + $this->assertFalse($source1->exists()); + $this->assertFalse($source2->exists()); + + $composedObject->delete(); + } + + public function testComposeObjectsWithDeleteSourceObjectsFalse() + { + $source1 = self::$bucket->upload('content1', ['name' => uniqid(self::TESTING_PREFIX) . '-s1.txt']); + $source2 = self::$bucket->upload('content2', ['name' => uniqid(self::TESTING_PREFIX) . '-s2.txt']); + + $this->assertTrue($source1->exists()); + $this->assertTrue($source2->exists()); + + $name = uniqid(self::TESTING_PREFIX) . '-composed.txt'; + $composedObject = self::$bucket->compose( + [$source1, $source2], + $name, + ['deleteSourceObjects' => false] + ); + + $this->assertEquals($name, $composedObject->name()); + $this->assertEquals('content1content2', $composedObject->downloadAsString()); + + // Source objects should still exist because deleteSourceObjects is false + $this->assertTrue($source1->exists()); + $this->assertTrue($source2->exists()); + + $source1->delete(); + $source2->delete(); + $composedObject->delete(); + } + + public function testComposeObjectsWithDeleteSourceObjectsNull() + { + $source1 = self::$bucket->upload('content1', ['name' => uniqid(self::TESTING_PREFIX) . '-s1.txt']); + $source2 = self::$bucket->upload('content2', ['name' => uniqid(self::TESTING_PREFIX) . '-s2.txt']); + + $this->assertTrue($source1->exists()); + $this->assertTrue($source2->exists()); + + $name = uniqid(self::TESTING_PREFIX) . '-composed.txt'; + $composedObject = self::$bucket->compose( + [$source1, $source2], + $name, + ['deleteSourceObjects' => null] + ); + + $this->assertEquals($name, $composedObject->name()); + $this->assertEquals('content1content2', $composedObject->downloadAsString()); + + // Source objects should still exist because deleteSourceObjects is null + $this->assertTrue($source1->exists()); + $this->assertTrue($source2->exists()); + + $source1->delete(); + $source2->delete(); + $composedObject->delete(); + } + public function testSoftDeleteObject() { $softDeleteBucketName = "soft-delete-bucket-" . uniqid(); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 23618a7c68d8..cb8a09ebc715 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -419,6 +419,96 @@ public function testComposesObjects( $this->assertEquals($destinationObject, $object->name()); } + public function testComposeWithDeleteSourceObjects() + { + $acl = 'private'; + $destinationObject = 'combined-files.txt'; + $this->connection->composeObject([ + 'destinationBucket' => self::BUCKET_NAME, + 'destinationObject' => $destinationObject, + 'destinationPredefinedAcl' => $acl, + 'destination' => ['contentType' => 'text/plain'], + 'sourceObjects' => [['name' => 'file1.txt'], ['name' => 'file2.txt']], + 'deleteSourceObjects' => true, + ]) + ->willReturn([ + 'name' => $destinationObject, + 'generation' => 1 + ]) + ->shouldBeCalledTimes(1); + + $this->connection->deleteObject([ + 'bucket' => self::BUCKET_NAME, + 'object' => 'file1.txt' + ])->shouldBeCalledTimes(1); + + $this->connection->deleteObject([ + 'bucket' => self::BUCKET_NAME, + 'object' => 'file2.txt' + ])->shouldBeCalledTimes(1); + + $bucket = $this->getBucket(); + + $object = $bucket->compose(['file1.txt', 'file2.txt'], $destinationObject, [ + 'predefinedAcl' => $acl, + 'deleteSourceObjects' => true + ]); + + $this->assertEquals($destinationObject, $object->name()); + } + + public function testComposeWithDeleteSourceObjectsFalse() + { + $acl = 'private'; + $destinationObject = 'combined-files.txt'; + $this->connection->composeObject([ + 'destinationBucket' => self::BUCKET_NAME, + 'destinationObject' => $destinationObject, + 'destinationPredefinedAcl' => $acl, + 'destination' => ['contentType' => 'text/plain'], + 'sourceObjects' => [['name' => 'file1.txt'], ['name' => 'file2.txt']], + ]) + ->willReturn([ + 'name' => $destinationObject, + 'generation' => 1 + ]) + ->shouldBeCalledTimes(1); + $bucket = $this->getBucket(); + + $object = $bucket->compose(['file1.txt', 'file2.txt'], $destinationObject, [ + 'predefinedAcl' => $acl, + 'deleteSourceObjects' => false + ]); + + $this->assertEquals($destinationObject, $object->name()); + } + + public function testComposeWithDeleteSourceObjectsNull() + { + $acl = 'private'; + $destinationObject = 'combined-files.txt'; + $this->connection->composeObject([ + 'destinationBucket' => self::BUCKET_NAME, + 'destinationObject' => $destinationObject, + 'destinationPredefinedAcl' => $acl, + 'destination' => ['contentType' => 'text/plain'], + 'sourceObjects' => [['name' => 'file1.txt'], ['name' => 'file2.txt']], + ]) + ->willReturn([ + 'name' => $destinationObject, + 'generation' => 1 + ]) + ->shouldBeCalledTimes(1); + $bucket = $this->getBucket(); + + $object = $bucket->compose(['file1.txt', 'file2.txt'], $destinationObject, [ + 'predefinedAcl' => $acl, + 'deleteSourceObjects' => null + ]); + + $this->assertEquals($destinationObject, $object->name()); + } + public function composeProvider() { $object1 = $this->prophesize(StorageObject::class);