From e460bfef29db4f20f58633552ef8b969fe929e5a Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Wed, 20 May 2026 12:24:13 +0000 Subject: [PATCH 1/4] Implementation delete source object when file merge using compose --- Storage/src/Bucket.php | 14 ++++ .../ServiceDefinition/storage-v1.json | 5 ++ Storage/tests/System/ManageObjectsTest.php | 78 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 887cffd4f84d..80f2c5bd0c80 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,23 @@ 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) { + foreach ($sourceObjects as $sourceObject) { + if ($sourceObject instanceof StorageObject) { + $sourceObject->delete(); + } else { + $this->object($sourceObject)->delete(); + } + } + } + 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(); From d82c34b9d9a668235625fcc183741f7a063df18c Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Thu, 21 May 2026 10:44:18 +0000 Subject: [PATCH 2/4] Unit Test case --- Storage/tests/Unit/BucketTest.php | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) 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); From 173d7ab1411824f188198dfa5e868ab3c0cfc56f Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Thu, 21 May 2026 11:40:30 +0000 Subject: [PATCH 3/4] Sorted Gemini review --- Storage/src/Bucket.php | 16 ++++++++++++---- .../Connection/ServiceDefinition/storage-v1.json | 5 ----- Storage/tests/Unit/BucketTest.php | 1 - 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 80f2c5bd0c80..d899c0b9fc54 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -1195,11 +1195,19 @@ public function compose(array $sourceObjects, $name, array $options = []) $response = $this->connection->composeObject(array_filter($options)); if ($deleteSourceObjects) { + $deleteOptions = array_filter([ + 'userProject' => $this->identity['userProject'] + ]); + foreach ($sourceObjects as $sourceObject) { - if ($sourceObject instanceof StorageObject) { - $sourceObject->delete(); - } else { - $this->object($sourceObject)->delete(); + 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 } } } diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index f6e429ebe708..dcce6fbecb50 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -4398,11 +4398,6 @@ "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/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index cb8a09ebc715..65d1846e1c9c 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -429,7 +429,6 @@ public function testComposeWithDeleteSourceObjects() 'destinationPredefinedAcl' => $acl, 'destination' => ['contentType' => 'text/plain'], 'sourceObjects' => [['name' => 'file1.txt'], ['name' => 'file2.txt']], - 'deleteSourceObjects' => true, ]) ->willReturn([ 'name' => $destinationObject, From a167c71076f77138d0d0d961a998f52dcbc0de7c Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Thu, 21 May 2026 12:10:18 +0000 Subject: [PATCH 4/4] Updated one --- Storage/src/Connection/ServiceDefinition/storage-v1.json | 5 +++++ Storage/tests/Unit/BucketTest.php | 1 + 2 files changed, 6 insertions(+) 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/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 65d1846e1c9c..cb8a09ebc715 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -429,6 +429,7 @@ public function testComposeWithDeleteSourceObjects() 'destinationPredefinedAcl' => $acl, 'destination' => ['contentType' => 'text/plain'], 'sourceObjects' => [['name' => 'file1.txt'], ['name' => 'file2.txt']], + 'deleteSourceObjects' => true, ]) ->willReturn([ 'name' => $destinationObject,