diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index f005b19bebc2..bf1c44534ca3 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -274,6 +274,12 @@ public function exists(array $options = []) * @type array $metadata The full list of available options are outlined * at the [JSON API docs](https://cloud.google.com/storage/docs/json_api/v1/objects/insert#request-body). * @type array $metadata.metadata User-provided metadata, in key/value pairs. + * @type array $contexts Object contexts. See at the + * [API docs](https://docs.cloud.google.com/storage/docs/use-object-contexts) for more details. + * @type string $contexts.custom.{key}.createTime The time the context + * was created in RFC 3339 format. **(read only)** + * @type string $contexts.custom.{key}.updateTime The time the context + * was last updated in RFC 3339 format. **(read only)** * @type string $encryptionKey A base64 encoded AES-256 customer-supplied * encryption key. If you would prefer to manage encryption * utilizing the Cloud Key Management Service (KMS) please use the @@ -294,6 +300,15 @@ public function upload($data, array $options = []) throw new \InvalidArgumentException('A name is required when data is of type string or null.'); } + if (isset($options['contexts'])) { + if (!is_array($options['contexts'])) { + throw new \InvalidArgumentException('Object contexts must be an array.'); + } + if (isset($options['contexts']['custom']) && !is_array($options['contexts']['custom'])) { + throw new \InvalidArgumentException('Object contexts custom field must be an array.'); + } + } + $encryptionKey = $options['encryptionKey'] ?? null; $encryptionKeySHA256 = $options['encryptionKeySHA256'] ?? null; @@ -703,6 +718,9 @@ public function restore($name, $generation, array $options = []) * distinct results. **Defaults to** `false`. * @type string $fields Selector which will cause the response to only * return the specified fields. + * @type string $filter Filter results to include only objects to which the + * specified context is attached. You can filter by the presence, + * absence, or specific value of context keys. * @type string $matchGlob A glob pattern to filter results. The string * value must be UTF-8 encoded. See: * https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob @@ -712,7 +730,6 @@ public function restore($name, $generation, array $options = []) public function objects(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false); - return new ObjectIterator( new ObjectPageIterator( function (array $object) { diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index afa1dde86e7d..e2d7c7519953 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -526,6 +526,12 @@ private function resolveUploadOptions(array $args) $args['metadata']['retention'] = $args['retention']; unset($args['retention']); } + if (isset($args['contexts'])) { + // during object creation context properties are part of the object resource + // and should be included in the request body. + $args['metadata']['contexts'] = $args['contexts']; + unset($args['contexts']); + } unset($args['name']); $args['contentType'] = $args['metadata']['contentType'] ?? MimeType::fromFilename($args['metadata']['name']); diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index b85db2a22024..dcce6fbecb50 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1561,6 +1561,41 @@ "type": "string", "description": "The modification time of the object metadata in RFC 3339 format. Set initially to object creation time and then updated whenever any metadata of the object changes. This includes changes made by a requester, such as modifying custom metadata, as well as changes made by Cloud Storage on behalf of a requester, such as changing the storage class based on an Object Lifecycle Configuration.", "format": "date-time" + }, + "contexts" : { + "type": "object", + "description": "User-defined or system-defined object contexts. Represented as key-payload pairs, where the key identifies the context and the payload contains the associated value and additional metadata.", + "properties" : { + "custom" : { + "type": "object", + "description": "User-provided object contexts where each entry consists of a unique key and a corresponding payload.", + "additionalProperties": { + "$ref": "ObjectCustomContextPayload", + "description": "A single user-defined object context." + } + } + } + } + } + }, + "ObjectCustomContextPayload": { + "id": "ObjectCustomContextPayload", + "type": "object", + "description": "The payload associated with a user-defined context key.", + "properties": { + "value": { + "type": "string", + "description": "The value of the object contexts." + }, + "createTime": { + "type": "string", + "format": "date-time", + "description": "The time at which the object contexts was created in RFC 3339 format." + }, + "updateTime": { + "type": "string", + "format": "date-time", + "description": "The time at which the object context was last updated in RFC 3339 format." } } }, @@ -4865,6 +4900,11 @@ "required": true, "location": "path" }, + "filter": { + "type": "string", + "description": "Filter results to include only objects to which the specified context is attached. You can filter by the presence, absence, or specific value of context keys.", + "location": "query" + }, "delimiter": { "type": "string", "description": "Returns results in a directory-like mode. items will contain only objects whose names, aside from the prefix, do not contain delimiter. Objects whose names, aside from the prefix, contain delimiter will have their name, truncated after the delimiter, returned in prefixes. Duplicate prefixes are omitted.", diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index dd11ac358bcd..376c494904aa 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -231,6 +231,12 @@ public function delete(array $options = []) * This is the retention configuration set for this object. * @type string $retention.mode The mode of the retention configuration, * which can be either `"Unlocked"` or `"Locked"`. + * @type array $contexts Object contexts. See at the + * [API docs](https://docs.cloud.google.com/storage/docs/use-object-contexts) for more details. + * @type string $contexts.custom.{key}.createTime The time the context + * was created in RFC 3339 format. **(read only)** + * @type string $contexts.custom.{key}.updateTime The time the context + * was last updated in RFC 3339 format. **(read only)** * @type bool $overrideUnlockedRetention Applicable for objects that * have an unlocked retention configuration. Required to be set to * `true` if the operation includes a retention property that diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 22152334b876..847ad64cc0be 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -215,6 +215,316 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } + private function createObjectWithContexts(array $uploadContexts) + { + $bucket = self::$bucket; + $object = $bucket->upload(self::DATA, [ + 'name' => 'object-contexts-' . uniqid(), + 'contexts' => $uploadContexts + ]); + return $object; + } + + public function testPatchObjectContexts() + { + $initialContexts = [ + 'custom' => [ + 'new-key' => ['value' => 'new-value'], + 'another-key' => ['value' => 'another-value'] + ], + ]; + + //Adding Individual Contexts + $object = $this->createObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertEquals('new-value', $info['contexts']['custom']['new-key']['value']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Modifying individual contexts + $object->update([ + 'contexts' => [ + 'custom' => [ + 'new-key' => ['value' => 'modified-value'] + ] + ] + ]); + $info = $object->info(); + $this->assertEquals('modified-value', $info['contexts']['custom']['new-key']['value']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Removing individual contexts + $object->update([ + 'contexts' => [ + 'custom' => [ + 'new-key' => null + ] + ] + ]); + $info = $object->info(); + $this->assertArrayNotHasKey('new-key', $info['contexts']['custom']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Clearing all contexts + $object->update([ + 'contexts' => null + ]); + $info = $object->info(); + $this->assertArrayNotHasKey('contexts', $info); + $object->delete(); + } + + public function testCreateRetrieveAndUpdateObjectContexts() + { + $initialContexts = [ + 'custom' => [ + 'team-owner' => ['value' => 'storage-team'], + 'priority' => ['value' => 'high'], + ], + ]; + + $object = $this->createObjectWithContexts($initialContexts); + $metadata = $object->info(); + $this->assertArrayHasKey('contexts', $metadata); + $this->assertEquals( + 'storage-team', + $metadata['contexts']['custom']['team-owner']['value'] + ); + $this->assertArrayHasKey('createTime', $metadata['contexts']['custom']['team-owner']); + + $metadata = [ + 'contexts' => [ + 'custom' => [ + 'priority' => ['value' => 'critical'], + 'env' => ['value' => 'prod'], + 'team-owner' => null, + ], + ], + ]; + $updatedMetadata = $object->update($metadata); + $finalCustom = $updatedMetadata['contexts']['custom']; + $this->assertEquals('critical', $finalCustom['priority']['value']); + $this->assertEquals('prod', $finalCustom['env']['value']); + $this->assertArrayNotHasKey('team-owner', $finalCustom); + $this->assertArrayHasKey('updateTime', $finalCustom['priority']); + $object->delete(); + } + + public function testGetContextsWithServerTime() + { + $initialContexts = [ + 'custom' => [ + 'temp-key' => ['value' => 'temp'] + ], + ]; + + $object = $this->createObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertArrayHasKey('contexts', $info); + + $context = $info['contexts']['custom']; + $this->assertEquals('temp', $context['temp-key']['value']); + $this->assertArrayHasKey( + 'createTime', + $context['temp-key'] + ); + $this->assertArrayHasKey( + 'updateTime', + $context['temp-key'] + ); + $object->delete(); + } + + public function testClearAllExistingContexts() + { + $initialContexts = [ + 'custom' => [ + 'temp-key' => ['value' => 'temp'], + 'status' => ['value' => 'to-be-cleared'], + ], + ]; + + $object = $this->createObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertArrayHasKey('contexts', $info); + $this->assertEquals('temp', $info['contexts']['custom']['temp-key']['value']); + $this->assertEquals('to-be-cleared', $info['contexts']['custom']['status']['value']); + + $object->update([ + 'contexts' => null + ]); + $this->assertArrayNotHasKey('contexts', $object->info()); + $object->delete(); + } + + public function testRewriteObjectWithContexts() + { + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'orignal'], + ], + ]; + + $object = $this->createObjectWithContexts($initialContexts); + + // Inherit object contexts during a rewrite operation. + $inherited = $object->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); + $this->assertEquals('orignal', $inherited->info()['contexts']['custom']['tag']['value']); + + // Override object contexts during a rewrite operation. + $overrideKey = 'override-key'; + $overrideVal = 'override-val'; + $overridden = $object->rewrite(self::$bucket, [ + 'name' => 'override-' . uniqid(), + 'contexts' => ['custom' => [$overrideKey => ['value' => $overrideVal]]] + ]); + + $info = $overridden->info(); + $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); + $this->assertArrayNotHasKey('tag', $info['contexts']['custom']); + $object->delete(); + } + + public function testOverrideContextsDuringCopy() + { + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'original'], + ], + ]; + $source = $this->createObjectWithContexts($initialContexts); + $this->assertEquals('original', $source->info()['contexts']['custom']['tag']['value']); + $overrideVal = 'new-value'; + $overridden = $source->copy(self::$bucket, [ + 'name' => 'overridden-' . uniqid() . '.txt', + 'contexts' => [ + 'custom' => [ + 'tag' => ['value' => $overrideVal] + ] + ] + ]); + + $this->assertEquals($overrideVal, $overridden->info()['contexts']['custom']['tag']['value']); + $source->delete(); + } + + public function testComposeObjectWithOverrideAndInheritContexts() + { + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'file1'], + ], + ]; + + $source1 = $this->createObjectWithContexts($initialContexts); + $bucket = self::$client->bucket($source1->info()['bucket']); + $s2Key = 's2-key'; + $source2 = $bucket->upload(self::DATA, [ + 'name' => 'override-object-contexts-' . 's2-' . uniqid(), + 'contexts' => ['custom' => [$s2Key => ['value' => 'file2']]] + ]); + + //Inherit contexts during compose + $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); + $custom = $inherit->info()['contexts']['custom']; + $this->assertEquals('file1', $custom['tag']['value']); + + // Override contexts during compose + $oKey = 'c-override'; + $oVal = 'c-val'; + $override = $bucket->compose([$source1, $source2], 'c-ovr-' . uniqid() . '.txt'); + $info = $override->update([ + 'contexts' => [ + 'custom' => [ + $oKey => ['value' => $oVal], + 'insert-key' => null + ] + ] + ]); + + $this->assertEquals($oVal, $info['contexts']['custom'][$oKey]['value']); + $this->assertArrayNotHasKey('insert-key', $info['contexts']['custom']); + $source1->delete(); + } + + public function testListObjectsWithContextFilters() + { + $bucketName = 'test-context-filter-' . time(); + $bucket = self::createBucket(self::$client, $bucketName); + try { + $activeFile = $bucket->upload('content', [ + 'name' => 'test-active.txt', + 'contexts' => ['custom' => ['status' => ['value' => 'active']]] + ]); + + $inactiveFile = $bucket->upload('content', [ + 'name' => 'test-inactive.txt', + 'contexts' => ['custom' => ['status' => ['value' => 'inactive']]] + ]); + + $noneFile = $bucket->upload('content', [ + 'name' => 'test-none.txt' + ]); + + // Should list all objects matching a prefix + $objects = iterator_to_array($bucket->objects()); + $this->assertCount(3, $objects); + + // Should filter by presence of key/value pair + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="inactive"' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($inactiveFile->name(), $objects[0]->name()); + + // Should filter by presence of different value + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="active"' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($activeFile->name(), $objects[0]->name()); + + // Should filter by absence of key/value pair (NOT) + $objects = iterator_to_array($bucket->objects([ + 'filter' => '-contexts."status"="active"' + ])); + $this->assertCount(2, $objects); + + // Should filter by presence of key regardless of value (Existence) + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status":*' + ])); + $this->assertCount(2, $objects); + + // Should filter by absence of key regardless of value (Non-existence) + $objects = iterator_to_array($bucket->objects([ + 'filter' => '-contexts."status":*' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($noneFile->name(), $objects[0]->name()); + + // Should return empty list when no contexts match the filter + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="ghost"' + ])); + $this->assertCount(0, $objects); + + // Should correctly handle double quotes in filter keys + $bucket->upload('content', [ + 'name' => 'quoted.txt', + 'metadata' => ['contexts' => ['custom' => ['priority' => ['value' => 'quoted-val']]]] + ]); + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."priority"="quoted-val"' + ])); + $this->assertCount(1, $objects); + } finally { + foreach ($bucket->objects() as $object) { + $object->delete(); + } + $bucket->delete(); + } + } + public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 2584fff032cc..23618a7c68d8 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -54,7 +54,7 @@ class BucketTest extends TestCase const BUCKET_NAME = 'my-bucket'; const PROJECT_ID = 'my-project'; const NOTIFICATION_ID = '1234'; - + const FILE_NAME_TEST = 'test.txt'; private $connection; private $resumableUploader; private $multipartUploader; @@ -642,6 +642,214 @@ public function testIsWritableServerException() $bucket->isWritable(); // raises exception } + public function testCreateObjectWithValidContexts() + { + $contexts = [ + 'custom' => [ + 'dept' => ['value' => 'engineering'], + 'env' => ['value' => 'production'] + ] + ]; + $this->resumableUploader->upload()->willReturn([ + 'name' => 'data.txt', + 'generation' => 123, + 'contexts' => $contexts + ]); + + $this->connection->insertObject(Argument::any()) + ->willReturn($this->resumableUploader->reveal()); + $object = $this->getBucket()->upload('upload', [ + 'name' => 'data.txt', + 'contexts' => $contexts + ]); + $this->assertInstanceOf(StorageObject::class, $object); + $this->assertEquals($contexts, $object->info()['contexts']); + } + + public function testUpdateReplacesAllMetadataIncludingContexts() + { + $objectName = 'replace-test.txt'; + $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); + $newContexts = ['custom' => ['new-key' => ['value' => 'new-val']]]; + + $this->connection->patchObject(Argument::withEntry('contexts', $newContexts)) + ->shouldBeCalled() + ->willReturn([ + 'name' => $objectName, + 'contexts' => $newContexts, + ]); + + $result = $object->update(['contexts' => $newContexts]); + $this->assertEquals('new-val', $result['contexts']['custom']['new-key']['value']); + } + + public function testAddAndModifyWithIndividualContexts() + { + $patchMetadata = [ + 'contexts' => [ + 'custom' => [ + 'new-key' => ['value' => 'added'], + 'existing-key' => ['value' => 'modified'] + ] + ] + ]; + + $this->connection->patchObject(Argument::withEntry('metadata', $patchMetadata)) + ->willReturn(['metadata' => $patchMetadata]); + + $file = $this->getBucket()->object(self::FILE_NAME_TEST); + $response = $file->update(['metadata' => $patchMetadata]); + + $this->assertArrayHasKey('contexts', $response['metadata']); + $this->assertSame('added', $response['metadata']['contexts']['custom']['new-key']['value']); + $this->assertSame('modified', $response['metadata']['contexts']['custom']['existing-key']['value']); + } + + /** + * @dataProvider removeAndClearAllContextsDataProvider + */ + public function testRemoveAndClearAllObjectContexts($objectContexts) + { + $this->connection->patchObject( + Argument::withEntry('contexts', $objectContexts) + )->shouldBeCalled()->willReturn([ + 'name' => self::FILE_NAME_TEST, + 'contexts' => $objectContexts + ]); + + $object = new StorageObject( + $this->connection->reveal(), + self::FILE_NAME_TEST, + '', + 1, + ['bucket' => self::BUCKET_NAME] + ); + $object->update(['contexts' => $objectContexts]); + $info = $object->info(); + if ($objectContexts === null) { + $hasContexts = isset($info['contexts']) && $info['contexts'] !== null; + $this->assertFalse($hasContexts); + } else { + $actualContexts = $object->info()['contexts'] ?? null; + $this->assertEquals($objectContexts, $actualContexts); + } + } + + public function removeAndClearAllContextsDataProvider() + { + return [ + 'remove an individual context by setting it to null' => [ + ['custom' => ['key-to-delete' => null]] + ], + 'clear all contexts by setting custom to null' => [ + ['custom' => null] + ] + ]; + } + + public function testCopyObjectWithMetadataOverrides() + { + $destFileName = 'destination.txt'; + $metadata = [ + 'contexts' => [ + 'custom' => ['tag' => ['value' => 'overridden']], + ], + ]; + + $destinationObject = $this->prophesize(StorageObject::class); + $destinationObject->info()->willReturn(['metadata' => $metadata]); + $sourceObject = $this->prophesize(StorageObject::class); + $sourceObject->copy(Argument::any(), Argument::withEntry('metadata', $metadata)) + ->shouldBeCalled() + ->willReturn($destinationObject->reveal()); + + $response = $sourceObject->reveal()->copy(self::BUCKET_NAME, [ + 'name' => $destFileName, + 'metadata' => $metadata + ]); + + $this->assertSame( + $metadata['contexts'], + $response->info()['metadata']['contexts'] + ); + } + + public function testListFiltersByPresenceOfKeyValuePair() + { + $filter = 'contexts."status"="active"'; + $this->connection->listObjects(Argument::withEntry('filter', $filter)) + ->shouldBeCalled() + ->willReturn([ + 'items' => null + ]); + + $bucket = $this->getBucket(); + $iterator = $bucket->objects([ + 'filter' => $filter + ]); + $this->assertCount(0, iterator_to_array($iterator)); + } + + /** + * @dataProvider listFilterExistenceDataProvider + */ + public function testListFiltersByExistence($filter) + { + $this->connection->listObjects(Argument::withEntry('filter', $filter)) + ->shouldBeCalled() + ->willReturn([ + 'items' => null + ]); + + $bucket = $this->getBucket(); + $iterator = $bucket->objects([ + 'filter' => $filter + ]); + + $this->assertCount(0, iterator_to_array($iterator)); + } + + public function listFilterExistenceDataProvider() + { + return [ + 'presence of key (Existence)' => ['contexts."status":*'], + 'absence of key (Non-existence)' => ['-contexts."status":*'] + ]; + } + + public function testGetFilesIncludesContextsInMetadata() + { + $fileMetadata = [ + 'name' => 'filename', + 'metadata' => [ + 'contexts' => [ 'custom' => [ 'dept' => + [ + 'value' => 'eng', + 'createTime' => '2026-04-16T01:01:01.045123456Z', + 'updateTime' => '2026-04-16T01:01:01.045123' + ] + ] + ] + ] + ]; + $this->connection->listObjects(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'items' => [$fileMetadata] + ]); + + $bucket = $this->getBucket(); + $files = iterator_to_array($bucket->objects()); + + $this->assertCount(1, $files); + $this->assertInstanceOf(StorageObject::class, $files[0]); + + $this->assertEquals( + $fileMetadata['metadata']['contexts'], + $files[0]->info()['metadata']['contexts'] + ); + } + public function testIam() { $bucketInfo = [