Skip to content

Commit ae22466

Browse files
feat(storage): add support for object context (#32902)
1 parent 6acaff3 commit ae22466

18 files changed

Lines changed: 781 additions & 91 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
require "storage_helper"
16+
17+
describe Google::Cloud::Storage::Bucket, :contexts, :storage do
18+
let(:bucket_name) { $bucket_names[0] }
19+
let :bucket do
20+
storage.bucket(bucket_name) ||
21+
storage.create_bucket(bucket_name)
22+
end
23+
let(:custom_context_key1) { "my-custom-key" }
24+
let(:custom_context_value1) { "my-custom-value" }
25+
let(:custom_context_key2) { "my-custom-key-2" }
26+
let(:custom_context_value2) { "my-custom-value-2" }
27+
let(:local_file) { "acceptance/data/CloudPlatform_128px_Retina.png" }
28+
let(:file_name) { "CloudLogo1" }
29+
let(:file_name2) { "CloudLogo2" }
30+
31+
before(:all) do
32+
bucket.create_file local_file, file_name
33+
bucket.create_file local_file, file_name2
34+
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
35+
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
36+
set_object_contexts bucket_name: bucket.name, file_name: file_name, custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
37+
set_object_contexts bucket_name: bucket.name, file_name: file_name2, custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
38+
end
39+
40+
it "lists objects with a specific context key and value" do
41+
list = bucket.files filter: "contexts.\"#{custom_context_key1}\"=\"#{custom_context_value1}\""
42+
list.each do |file|
43+
_(file.name).must_equal file_name
44+
end
45+
end
46+
47+
it "lists objects with a specific context key" do
48+
list = bucket.files filter: "contexts.\"#{custom_context_key1}\":*"
49+
list.each do |file|
50+
_(file.name).must_equal file_name
51+
end
52+
end
53+
54+
it "lists objects that do not have a specific context key" do
55+
list = bucket.files filter: "-contexts.\"#{custom_context_key1}\":*"
56+
list.each do |file|
57+
_(file.name).wont_equal file_name
58+
end
59+
end
60+
61+
it "lists objects that do not have a specific context key and value" do
62+
list = bucket.files filter: "-contexts.\"#{custom_context_key2}\"=\"#{custom_context_value2}\""
63+
list.each do |file|
64+
_(file.name).must_equal file_name
65+
_(file.name).wont_equal file_name2
66+
end
67+
end
68+
69+
end

google-cloud-storage/acceptance/storage/file_test.rb

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,4 +1044,161 @@
10441044
expect { uploaded_file.retention = retention }.must_raise Google::Cloud::PermissionDeniedError
10451045
end
10461046
end
1047+
1048+
describe "object contexts" do
1049+
let(:custom_context_key1) { "my-custom-key" }
1050+
let(:custom_context_value1) { "my-custom-value" }
1051+
let(:custom_context_key2) { "my-custom-key-2" }
1052+
let(:custom_context_value2) { "my-custom-value-2" }
1053+
let(:local_file) { "acceptance/data/CloudPlatform_128px_Retina.png" }
1054+
let(:file_name) { "CloudLogo1" }
1055+
1056+
before do
1057+
bucket.create_file local_file, file_name
1058+
end
1059+
1060+
it "sets and retrieves custom context key and value" do
1061+
file = bucket.file file_name
1062+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1063+
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
1064+
)
1065+
file.reload!
1066+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1067+
end
1068+
1069+
it "rejects special characters in custom context key and value" do
1070+
invalid_key = 'my"-invalid-key'
1071+
custom_value = 'my-custom-value'
1072+
1073+
file = bucket.file file_name
1074+
1075+
err = _ {
1076+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1077+
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
1078+
)
1079+
}.must_raise Google::Cloud::InvalidArgumentError
1080+
1081+
_(err.message).must_match(/Object context key cannot contain/)
1082+
1083+
invalid_key = 'my-custom-key'
1084+
custom_value = 'my-invalid/value'
1085+
1086+
err = _ {
1087+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1088+
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
1089+
)
1090+
}.must_raise Google::Cloud::InvalidArgumentError
1091+
1092+
_(err.message).must_match(/Object context value cannot contain/)
1093+
end
1094+
1095+
it "rejects unicode characters in keys and values" do
1096+
invalid_key = '🚀-launcher'
1097+
custom_value = 'my-custom-value'
1098+
file = bucket.file file_name
1099+
err = _ {
1100+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1101+
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
1102+
)
1103+
}.must_raise Google::Cloud::InvalidArgumentError
1104+
_(err.message).must_match(/Object context key must start with an alphanumeric character./)
1105+
1106+
invalid_key = "my-custom-key"
1107+
custom_value = '✨-sparkle'
1108+
1109+
err = _ {
1110+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1111+
custom: context_custom_hash(custom_context_key: invalid_key, custom_context_value: custom_value)
1112+
)
1113+
}.must_raise Google::Cloud::InvalidArgumentError
1114+
1115+
_(err.message).must_match(/Object context value must start with an alphanumeric character./)
1116+
end
1117+
1118+
it "modifies existing custom context key and value" do
1119+
file = bucket.file file_name
1120+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1121+
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
1122+
)
1123+
file.reload!
1124+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1125+
1126+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1127+
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value2)
1128+
)
1129+
file.reload!
1130+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value2
1131+
end
1132+
1133+
it "overwrites existing context key and value" do
1134+
file = bucket.file file_name
1135+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1136+
custom: context_custom_hash(custom_context_key: custom_context_key1 ,custom_context_value: custom_context_value1)
1137+
)
1138+
file.reload!
1139+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1140+
1141+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1142+
custom: context_custom_hash(custom_context_key: custom_context_key2 ,custom_context_value: custom_context_value2)
1143+
)
1144+
file.reload!
1145+
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
1146+
end
1147+
1148+
it "sets and retrieves multiple custom context keys and values" do
1149+
file = bucket.file file_name
1150+
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
1151+
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
1152+
1153+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1154+
custom: {
1155+
custom_context_key1 => custom_hash1[custom_context_key1],
1156+
custom_context_key2 => custom_hash2[custom_context_key2]
1157+
}
1158+
)
1159+
file.reload!
1160+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1161+
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
1162+
end
1163+
1164+
it "removes individual context" do
1165+
file = bucket.file file_name
1166+
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
1167+
custom_hash2 = context_custom_hash custom_context_key: custom_context_key2, custom_context_value: custom_context_value2
1168+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1169+
custom: {
1170+
custom_context_key1 => custom_hash1[custom_context_key1],
1171+
custom_context_key2 => custom_hash2[custom_context_key2]
1172+
}
1173+
)
1174+
file.reload!
1175+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1176+
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
1177+
1178+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1179+
custom: {
1180+
custom_context_key1 => nil
1181+
}
1182+
)
1183+
file.reload!
1184+
_(file.contexts.custom[custom_context_key1]).must_be_nil
1185+
_(file.contexts.custom[custom_context_key2].value).must_equal custom_context_value2
1186+
end
1187+
1188+
it "clears all contexts" do
1189+
file = bucket.file file_name
1190+
custom_hash1 = context_custom_hash custom_context_key: custom_context_key1, custom_context_value: custom_context_value1
1191+
file.contexts = Google::Apis::StorageV1::Object::Contexts.new(
1192+
custom: {
1193+
custom_context_key1=> custom_hash1[custom_context_key1]
1194+
}
1195+
)
1196+
file.reload!
1197+
_(file.contexts.custom[custom_context_key1].value).must_equal custom_context_value1
1198+
1199+
file.contexts = nil
1200+
file.reload!
1201+
_(file.contexts).must_be_nil
1202+
end
1203+
end
10471204
end

google-cloud-storage/acceptance/storage_helper.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,27 @@ def clean_up_storage_bucket bucket
206206
puts "Error while cleaning up bucket #{bucket.name}\n\n#{e}"
207207
end
208208

209+
def set_object_contexts bucket_name:, file_name:, custom_context_key:, custom_context_value:
210+
bucket = storage.bucket bucket_name
211+
file = bucket.file file_name
212+
contexts = Google::Apis::StorageV1::Object::Contexts.new(
213+
custom: context_custom_hash(custom_context_key: custom_context_key, custom_context_value: custom_context_value)
214+
)
215+
file.update do |file|
216+
file.contexts = contexts
217+
end
218+
end
219+
220+
def context_custom_hash custom_context_key: ,custom_context_value:
221+
payload = Google::Apis::StorageV1::ObjectCustomContextPayload.new(
222+
value: custom_context_value
223+
)
224+
custom_hash = {
225+
custom_context_key => payload
226+
}
227+
custom_hash
228+
end
229+
209230
Minitest.after_run do
210231
clean_up_storage_buckets
211232
if $storage_2

google-cloud-storage/lib/google/cloud/storage/bucket.rb

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,7 +1605,23 @@ def delete if_metageneration_match: nil, if_metageneration_not_match: nil
16051605
# Only applicable if delimiter is set to '/'.
16061606
# @param [Boolean] soft_deleted If true, only soft-deleted object
16071607
# versions will be listed. The default is false.
1608+
# @param [String] filter An optional string for filtering listed objects.
1609+
# Supported fields: contexts
1610+
# If delimiter is set, the returned prefixes are exempt from this filter
1611+
# List any object that has a context with the specified key attached
1612+
# filter = "contexts.\"KEY\":*";
16081613
#
1614+
# List any object that has a context with the specified key attached and value attached
1615+
# filter = "contexts.\"keyA\"=\"valueA\""
1616+
#
1617+
# List any object that does not have a context with the specified key attached
1618+
# filter = "-contexts.\"KEY\":*";
1619+
#
1620+
# List any object that has a context with the specified key and value attached
1621+
# filter = "contexts.\"KEY\"=\"VALUE\"";
1622+
#
1623+
# List any object that does not have a context with the specified key and value attached
1624+
# filter = "-contexts.\"KEY\"=\"VALUE\"";
16091625
# @return [Array<Google::Cloud::Storage::File>] (See
16101626
# {Google::Cloud::Storage::File::List})
16111627
#
@@ -1631,23 +1647,34 @@ def delete if_metageneration_match: nil, if_metageneration_not_match: nil
16311647
# puts file.name
16321648
# end
16331649
#
1650+
# @example Filter files by context:
1651+
# require "google/cloud/storage"
1652+
# storage = Google::Cloud::Storage.new
1653+
# bucket = storage.bucket "my-bucket"
1654+
# files = bucket.files filter: "contexts.\"myKey\"=\"myValue\""
1655+
# files.each do |file|
1656+
# puts file.name
1657+
# end
1658+
#
16341659
def files prefix: nil, delimiter: nil, token: nil, max: nil,
16351660
versions: nil, match_glob: nil, include_folders_as_prefixes: nil,
1636-
soft_deleted: nil
1661+
soft_deleted: nil, filter: nil
16371662
ensure_service!
16381663
gapi = service.list_files name, prefix: prefix, delimiter: delimiter,
16391664
token: token, max: max,
16401665
versions: versions,
16411666
user_project: user_project,
16421667
match_glob: match_glob,
16431668
include_folders_as_prefixes: include_folders_as_prefixes,
1644-
soft_deleted: soft_deleted
1669+
soft_deleted: soft_deleted,
1670+
filter: filter
16451671
File::List.from_gapi gapi, service, name, prefix, delimiter, max,
16461672
versions,
16471673
user_project: user_project,
16481674
match_glob: match_glob,
16491675
include_folders_as_prefixes: include_folders_as_prefixes,
1650-
soft_deleted: soft_deleted
1676+
soft_deleted: soft_deleted,
1677+
filter: filter
16511678
end
16521679
alias find_files files
16531680

google-cloud-storage/lib/google/cloud/storage/file.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,45 @@ def content_type= content_type
370370
update_gapi! :content_type
371371
end
372372

373+
##
374+
# User-defined object contexts. Each object context is a key-
375+
# payload pair, where the key provides the identification and the payload holds
376+
# the associated value and additional metadata.
377+
# Object contexts are used to provide additional information about an object
378+
# @return [Google::Apis::StorageV1::Object::Contexts, nil] The object contexts, or `nil` if there are none.
379+
380+
def contexts
381+
@gapi.contexts
382+
end
383+
384+
##
385+
# Sets the object context.
386+
# To pass generation and/or metageneration preconditions, call this
387+
# method within a block passed to {#update}.
388+
# @param [Google::Apis::StorageV1::Object::Contexts] contexts The object contexts to set.
389+
# @see https://docs.cloud.google.com/storage/docs/use-object-contexts#attach-modify-contexts Object Contexts documentation
390+
# @example
391+
# require "google/cloud/storage"
392+
# storage = Google::Cloud::Storage.new
393+
# bucket = storage.bucket "my-bucket"
394+
# file = bucket.file "path/to/my-file.ext"
395+
# payload = Google::Apis::StorageV1::ObjectCustomContextPayload.new(
396+
# value: "your-custom-context-value"
397+
# )
398+
# custom_hash = {
399+
# "your-custom-context-key" => payload
400+
# }
401+
# contexts = Google::Apis::StorageV1::Object::Contexts.new(
402+
# custom: custom_hash
403+
# )
404+
# file.update do |file|
405+
# file.contexts = contexts
406+
# end
407+
def contexts= contexts
408+
@gapi.contexts = contexts
409+
update_gapi! :contexts
410+
end
411+
373412
##
374413
# A custom time specified by the user for the file, or `nil`.
375414
#

google-cloud-storage/lib/google/cloud/storage/file/list.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def self.from_gapi gapi_list, service, bucket = nil, prefix = nil,
167167
delimiter = nil, max = nil, versions = nil,
168168
user_project: nil, match_glob: nil,
169169
include_folders_as_prefixes: nil,
170-
soft_deleted: nil
170+
soft_deleted: nil, filter: nil
171171
files = new(Array(gapi_list.items).map do |gapi_object|
172172
File.from_gapi gapi_object, service, user_project: user_project
173173
end)
@@ -183,6 +183,7 @@ def self.from_gapi gapi_list, service, bucket = nil, prefix = nil,
183183
files.instance_variable_set :@match_glob, match_glob
184184
files.instance_variable_set :@include_folders_as_prefixes, include_folders_as_prefixes
185185
files.instance_variable_set :@soft_deleted, soft_deleted
186+
files.instance_variable_set :@filter, filter
186187
files
187188
end
188189

0 commit comments

Comments
 (0)