From 6c924d04c7029632c90f62c76eb1d9fa6b7239e7 Mon Sep 17 00:00:00 2001 From: Serge Pavlov Date: Mon, 9 Feb 2026 12:51:57 -0500 Subject: [PATCH 1/2] perf: cache reflection results to eliminate PropertyInfo regeneration overhead --- Src/Support/Google.Apis.Core/AssemblyInfo.cs | 1 + .../Parameters/ParameterCollection.cs | 24 +- .../Requests/Parameters/ParameterUtils.cs | 124 +++++++++-- .../Requests/Parameters/ParameterValue.cs | 48 ++++ .../Requests/Parameters/TypedParameter.cs | 46 ++++ .../Util/PropertyWithAttribute.cs | 47 ++++ .../Google.Apis.Core/Util/ReflectionCache.cs | 62 ++++++ .../Requests/Parameters/ParameterUtilsTest.cs | 210 +++++++++++++++++- .../Upload/ResumableUploadTest.MultiChunk.cs | 41 ++++ .../Apis/Utils/ReflectionCacheTest.cs | 119 ++++++++++ .../Google.Apis/Upload/ResumableUpload.cs | 35 +-- 11 files changed, 692 insertions(+), 65 deletions(-) create mode 100644 Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs create mode 100644 Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs create mode 100644 Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs create mode 100644 Src/Support/Google.Apis.Core/Util/ReflectionCache.cs create mode 100644 Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs diff --git a/Src/Support/Google.Apis.Core/AssemblyInfo.cs b/Src/Support/Google.Apis.Core/AssemblyInfo.cs index 9d929c12b56..747aa2a2c86 100644 --- a/Src/Support/Google.Apis.Core/AssemblyInfo.cs +++ b/Src/Support/Google.Apis.Core/AssemblyInfo.cs @@ -16,5 +16,6 @@ limitations under the License. using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Google.Apis,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] [assembly: InternalsVisibleTo("Google.Apis.Tests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] [assembly: InternalsVisibleTo("Google.Apis.IntegrationTests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs index c3c0d85ec3c..7a7f3f9d65f 100644 --- a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs +++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs @@ -17,6 +17,7 @@ limitations under the License. using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using Google.Apis.Util; @@ -142,23 +143,16 @@ public static ParameterCollection FromQueryString(string qs) /// public static ParameterCollection FromDictionary(IDictionary dictionary) { + // Convert to typed dictionary (defaulting to Query) so we can reuse the shared expansion logic. + var typedDict = dictionary.ToDictionary( + kvp => kvp.Key, + kvp => new ParameterValue(RequestParameterType.Query, kvp.Value)); + + // Expand any enumerable values into repeated parameters. var collection = new ParameterCollection(); - foreach (KeyValuePair pair in dictionary) + foreach (var param in ParameterUtils.ExpandParametersWithTypes(typedDict)) { - // Try parsing the value of the pair as an enumerable. - var valueAsEnumerable = pair.Value as IEnumerable; - if (!(pair.Value is string) && valueAsEnumerable != null) - { - foreach (var value in valueAsEnumerable) - { - collection.Add(pair.Key, Util.Utilities.ConvertToString(value)); - } - } - else - { - // Otherwise just convert it to a string. - collection.Add(pair.Key, pair.Value == null ? null : Util.Utilities.ConvertToString(pair.Value)); - } + collection.Add(param.Name, param.Value); } return collection; } diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs index d57c27b6a0a..979b78deb8a 100644 --- a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs +++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs @@ -15,6 +15,7 @@ limitations under the License. */ using System; +using System.Collections; using System.Collections.Generic; using System.Net.Http; using System.Linq; @@ -38,7 +39,7 @@ public static class ParameterUtils /// attribute. /// /// - /// A request object which contains properties with + /// A request object which contains properties with /// attribute. Those properties will be serialized /// to the returned . /// @@ -61,17 +62,40 @@ public static FormUrlEncodedContent CreateFormUrlEncodedContent(object request) /// attribute. /// /// - /// A request object which contains properties with + /// A request object which contains properties with /// attribute. Those properties will be set /// in the output dictionary. /// public static IDictionary CreateParameterDictionary(object request) { - var dict = new Dictionary(); + // Use the typed implementation, then drop type information to preserve the legacy return type. + return CreateParameterDictionaryWithTypes(request) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); + } + + /// + /// Creates a parameter dictionary with type information by using reflection to iterate over all properties with + /// attribute. + /// + /// + /// A request object which contains properties with + /// attribute. Those properties will be set + /// in the output dictionary along with their parameter type. + /// + /// + /// Thrown when multiple properties set the same parameter name to non-null values. + /// + /// + /// A dictionary where the key is the parameter name and the value is a ParameterValue containing type and value. + /// + private static IDictionary CreateParameterDictionaryWithTypes(object request) + { + var dict = new Dictionary(); IterateParameters(request, (type, name, value) => { - if (dict.TryGetValue(name, out var existingValue)) + if (dict.TryGetValue(name, out var existingEntry)) { + var existingValue = existingEntry.Value; // Repeated enum query parameters end up with two properties: a single // one, and a Repeatable (where the T is always non-nullable, whether or not the parameter // is optional). If both properties are set, we fail. Note that this delegate is called @@ -81,7 +105,7 @@ public static IDictionary CreateParameterDictionary(object reque if (existingValue is null && value is object) { // Overwrite null value with non-null value - dict[name] = value; + dict[name] = new ParameterValue(type, value); } else if (value is null) { @@ -89,14 +113,14 @@ public static IDictionary CreateParameterDictionary(object reque } else { - // Throw if we see a second null value + // Throw if we see a second non-null value throw new InvalidOperationException( $"The query parameter '{name}' is set by multiple properties. For repeated enum query parameters, ensure that only one property is set to a non-null value."); } } else { - dict.Add(name, value); + dict.Add(name, new ParameterValue(type, value)); } }); return dict; @@ -108,8 +132,8 @@ public static IDictionary CreateParameterDictionary(object reque /// /// The request builder /// - /// A request object which contains properties with - /// attribute. Those properties will be set in the + /// A request object which contains properties with + /// attribute. Those properties will be set in the /// given request builder object /// public static void InitParameters(RequestBuilder builder, object request) @@ -120,6 +144,73 @@ public static void InitParameters(RequestBuilder builder, object request) }); } + /// + /// Sets request parameters in the given builder with all properties with the + /// attribute + /// by expanding values into multiple parameters. + /// + /// The request builder + /// + /// A request object which contains properties with + /// attribute. Those properties will be set in the + /// given request builder object + /// + /// + /// This method is internal and is called from the Google.Apis assembly via InternalsVisibleTo. + /// + internal static void InitParametersWithExpansion(RequestBuilder builder, object request) + { + // Use typed methods to preserve RequestParameterType information + var parametersWithTypes = CreateParameterDictionaryWithTypes(request); + + // Expand and add all parameters to the builder with their correct types + foreach (var param in ExpandParametersWithTypes(parametersWithTypes)) + { + builder.AddParameter(param.Type, param.Name, param.Value); + } + } + + /// + /// Expands a dictionary of typed parameters into a sequence of instances. + /// + /// + /// If a parameter value implements (and is not a ), it is expanded into + /// multiple instances with the same name and . + /// This supports repeatable parameters represented as (which is ) and other + /// enumerable values. + /// + /// + /// A dictionary where the key is the parameter name and the value is a containing both + /// the parameter type and raw value. + /// + /// + /// An enumerable of instances, with enumerable values expanded into repeated parameters. + /// + internal static IEnumerable ExpandParametersWithTypes(IDictionary dictionary) + { + foreach (var pair in dictionary) + { + var paramType = pair.Value.Type; + var value = pair.Value.Value; + var name = pair.Key; + + // Try parsing the value as an enumerable. + var valueAsEnumerable = value as IEnumerable; + if (!(value is string) && valueAsEnumerable != null) + { + foreach (var elem in valueAsEnumerable) + { + yield return new TypedParameter(paramType, name, Utilities.ConvertToString(elem)); + } + } + else + { + // Otherwise just convert it to a string. + yield return new TypedParameter(paramType, name, Utilities.ConvertToString(value)); + } + } + } + /// /// Iterates over all properties in the request /// object and invokes the specified action for each of them. @@ -128,18 +219,11 @@ public static void InitParameters(RequestBuilder builder, object request) /// An action to invoke which gets the parameter type, name and its value private static void IterateParameters(object request, Action action) { - // Use reflection to build the parameter dictionary. - foreach (PropertyInfo property in request.GetType().GetProperties(BindingFlags.Instance | - BindingFlags.Public)) + // Use ReflectionCache to avoid repeated reflection + attribute lookup on every call. + foreach (var propertyWithAttribute in ReflectionCache.GetRequestParameterProperties(request.GetType())) { - // Retrieve the RequestParameterAttribute. - RequestParameterAttribute attribute = - property.GetCustomAttributes(typeof(RequestParameterAttribute), false).FirstOrDefault() as - RequestParameterAttribute; - if (attribute == null) - { - continue; - } + var property = propertyWithAttribute.Property; + var attribute = propertyWithAttribute.Attribute; // Get the name of this parameter from the attribute, if it doesn't exist take a lower-case variant of // property name. diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs new file mode 100644 index 00000000000..10861ecdb55 --- /dev/null +++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs @@ -0,0 +1,48 @@ +/* +Copyright 2026 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Util; + +namespace Google.Apis.Requests.Parameters +{ + /// + /// Represents a parameter value together with its metadata. + /// + /// + /// contains the raw CLR value (prior to any string conversion for the wire format). + /// + internal readonly struct ParameterValue + { + /// + /// Gets the parameter type (Path, Query, etc.) + /// + public RequestParameterType Type { get; } + + /// + /// Gets the parameter value. + /// + public object Value { get; } + + /// + /// Constructs a new parameter value with type. + /// + public ParameterValue(RequestParameterType type, object value) + { + Type = type; + Value = value; + } + } +} diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs new file mode 100644 index 00000000000..c67f88104a0 --- /dev/null +++ b/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs @@ -0,0 +1,46 @@ +/* +Copyright 2026 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Util; + +namespace Google.Apis.Requests.Parameters +{ + /// + /// Represents a parameter together with its metadata. + /// + /// + /// is the string value to be sent on the wire (after conversion via Utilities.ConvertToString). + /// + internal readonly struct TypedParameter + { + /// Gets the parameter type (Path, Query, etc.) + public RequestParameterType Type { get; } + + /// Gets the parameter name. + public string Name { get; } + + /// Gets the parameter value. + public string Value { get; } + + /// Constructs a new typed parameter. + public TypedParameter(RequestParameterType type, string name, string value) + { + Type = type; + Name = name; + Value = value; + } + } +} diff --git a/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs new file mode 100644 index 00000000000..3602a483a21 --- /dev/null +++ b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs @@ -0,0 +1,47 @@ +/* +Copyright 2026 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using System.Reflection; + +namespace Google.Apis.Util +{ + /// + /// Represents a property with its associated RequestParameterAttribute. + /// + internal readonly struct PropertyWithAttribute + { + /// + /// The PropertyInfo for the property. + /// + public PropertyInfo Property { get; } + + /// + /// The RequestParameterAttribute associated with this property. + /// + public RequestParameterAttribute Attribute { get; } + + /// + /// Initializes a new instance of PropertyWithAttribute. + /// + /// The property info. + /// The associated . + public PropertyWithAttribute(PropertyInfo property, RequestParameterAttribute attribute) + { + Property = property; + Attribute = attribute; + } + } +} diff --git a/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs new file mode 100644 index 00000000000..2f5d87831ac --- /dev/null +++ b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs @@ -0,0 +1,62 @@ +/* +Copyright 2026 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; + +namespace Google.Apis.Util +{ + /// + /// Provides cached reflection results for request parameter discovery. + /// + /// + /// This cache is intentionally unbounded and keyed by request type. The set of request types decorated with + /// is expected to be finite and stable for the lifetime of the application. + /// + internal static partial class ReflectionCache + { + /// + /// Cache of properties filtered by RequestParameterAttribute. + /// Key is the type, value is an array of PropertyWithAttribute structs. + /// + /// + /// This cache is intentionally unbounded; it is keyed by request types + /// annotated with RequestParameterAttribute, which are expected to be + /// a finite set in normal usage. + /// + private static readonly ConcurrentDictionary RequestParameterPropertiesCache = + new ConcurrentDictionary(); + + /// + /// Returns the cached set of request-parameter properties for the specified request type. + /// + /// The type to get request parameter properties for. + /// An array of structs containing properties and their RequestParameterAttribute. + internal static PropertyWithAttribute[] GetRequestParameterProperties(Type type) + { + return RequestParameterPropertiesCache.GetOrAdd(type, t => + { + // Get properties, filter by attribute, and cache only the filtered result + return t.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(prop => new PropertyWithAttribute(prop, prop.GetCustomAttribute(inherit: false))) + .Where(pwa => pwa.Attribute != null) + .ToArray(); + }); + } + } +} diff --git a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs index e44eadb6c92..97e8903ec8b 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs @@ -130,5 +130,213 @@ public void RepeatableEnum_BothPropertiesSet() }; Assert.Throws(() => ParameterUtils.CreateParameterDictionary(request)); } + + // Tests for InitParametersWithExpansion + + private class TestRequestWithScalars + { + [RequestParameter("name", RequestParameterType.Query)] + public string Name { get; set; } + + [RequestParameter("id", RequestParameterType.Path)] + public int Id { get; set; } + + [RequestParameter("nullparam", RequestParameterType.Query)] + public string NullParam { get; set; } + } + + [Fact] + public void InitParametersWithExpansion_ScalarParameters() + { + var request = new TestRequestWithScalars + { + Name = "test", + Id = 123, + NullParam = null + }; + var builder = new RequestBuilder + { + BaseUri = new Uri("https://example.com/api"), + Path = "items/{id}" // Path template for path parameter + }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // Verify parameters were added by building the URI + var uri = builder.BuildUri(); + Assert.Contains("name=test", uri.AbsoluteUri); + Assert.Contains("items/123", uri.AbsoluteUri); // Path parameter replaces {id} + // Null parameter should not be in the URI + Assert.DoesNotContain("nullparam", uri.AbsoluteUri); + } + + private class TestRequestWithRepeatable + { + [RequestParameter("part", RequestParameterType.Query)] + public Repeatable Part { get; set; } + } + + [Fact] + public void InitParametersWithExpansion_RepeatableParameters() + { + var request = new TestRequestWithRepeatable + { + Part = new[] { "snippet", "contentDetails" } + }; + var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // Should expand to multiple parameters with the same name + var uri = builder.BuildUri(); + Assert.Contains("part=snippet", uri.AbsoluteUri); + Assert.Contains("part=contentDetails", uri.AbsoluteUri); + } + + private class TestRequestWithDateTime + { + [RequestParameter("time", RequestParameterType.Query)] + public DateTime? MinTime { get; set; } + } + + [Fact] + public void InitParametersWithExpansion_DateTimeConversion() + { + var request = new TestRequestWithDateTime + { + MinTime = new DateTime(2023, 1, 15, 10, 30, 0, DateTimeKind.Utc) + }; + var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // DateTime should be converted to RFC3339 format + var uri = builder.BuildUri(); + Assert.Contains("time=", uri.AbsoluteUri); + Assert.Contains("2023-01-15", uri.AbsoluteUri); + } + + private class TestRequestWithEnum + { + [RequestParameter("mode", RequestParameterType.Query)] + public Repeatable ModeList { get; set; } + } + + [Fact] + public void InitParametersWithExpansion_EnumConversion() + { + var request = new TestRequestWithEnum + { + ModeList = new[] { FileMode.Open, FileMode.Append } + }; + var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // Should expand enum list and convert each value using StringValue attribute + var uri = builder.BuildUri(); + Assert.Contains("mode=Open", uri.AbsoluteUri); + Assert.Contains("mode=Append", uri.AbsoluteUri); + } + + private class TestRequestWithBoolean + { + [RequestParameter("flag", RequestParameterType.Query)] + public bool Flag { get; set; } + } + + [Fact] + public void InitParametersWithExpansion_BooleanConversion() + { + var request = new TestRequestWithBoolean { Flag = true }; + var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // Boolean should be lowercase + var uri = builder.BuildUri(); + Assert.Contains("flag=true", uri.AbsoluteUri); + } + + [Fact] + public void InitParametersWithExpansion_CacheUsage() + { + var request1 = new TestRequestWithScalars { Name = "test1", Id = 1 }; + var request2 = new TestRequestWithScalars { Name = "test2", Id = 2 }; + var builder1 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + var builder2 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + // First call - cache is populated + ParameterUtils.InitParametersWithExpansion(builder1, request1); + var properties1 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); + + // Second call - should reuse cached PropertyInfo + ParameterUtils.InitParametersWithExpansion(builder2, request2); + var properties2 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); + + // Verify same PropertyInfo instances are returned (object reference equality) + Assert.Equal(properties1.Length, properties2.Length); + for (int i = 0; i < properties1.Length; i++) + { + Assert.Same(properties1[i].Property, properties2[i].Property); + Assert.Same(properties1[i].Attribute, properties2[i].Attribute); + } + } + + /// + /// Regression test for null handling in enumerable expansion. + /// Tests that null elements in an enumerable produce the same result as Utilities.ConvertToString(null). + /// This test does not hard-code expectations - it dynamically tests against the actual behavior. + /// + [Fact] + public void InitParametersWithExpansion_NullElementsInEnumerable() + { + // Get the expected behavior from Utilities.ConvertToString(null) - don't hard-code! + string expectedNullValue = Utilities.ConvertToString(null); + + var request = new TestRequestWithRepeatable + { + Part = new[] { "value1", null, "value2" } + }; + var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + ParameterUtils.InitParametersWithExpansion(builder, request); + + // Build the URI and check the result + var uri = builder.BuildUri(); + var query = uri.Query; + + // Count how many "part=" occurrences there are + int partCount = System.Text.RegularExpressions.Regex.Matches(query, "part=").Count; + + // Verify behavior matches what Utilities.ConvertToString(null) produces + if (expectedNullValue == null) + { + // If ConvertToString(null) returns null, the null element should be filtered out + // by RequestBuilder (which doesn't add null-valued parameters) + // So we should only see 2 parameters: "value1" and "value2" + Assert.Equal(2, partCount); + Assert.Contains("part=value1", query); + Assert.Contains("part=value2", query); + } + else if (expectedNullValue == "") + { + // If ConvertToString(null) returns empty string, we should see 3 parameters + // including one with an empty value + Assert.Equal(3, partCount); + Assert.Contains("part=value1", query); + Assert.Contains("part=value2", query); + // One of the "part=" should have empty value (part=&) + } + else + { + // If ConvertToString(null) returns some other string, we should see 3 parameters + // with that string value + Assert.Equal(3, partCount); + Assert.Contains("part=value1", query); + Assert.Contains("part=value2", query); + Assert.Contains($"part={Uri.EscapeDataString(expectedNullValue)}", query); + } + } } -} \ No newline at end of file +} diff --git a/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs b/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs index ef71117f9bf..95d9d64ff1a 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs @@ -577,6 +577,47 @@ public void TestUploadWithQueryAndPathParameters() } } + private class TestResumableUploadWithRepeatableParameters : TestResumableUpload + { + public TestResumableUploadWithRepeatableParameters(IClientService service, string path, string method, Stream stream, + string contentType, int chunkSize) + : base(service, path, method, stream, contentType, chunkSize) { } + + [RequestParameter("id", RequestParameterType.Path)] + public int Id { get; set; } + + [RequestParameter("part", RequestParameterType.Query)] + public Repeatable Part { get; set; } + + [RequestParameter("tag", RequestParameterType.Query)] + public Repeatable Tag { get; set; } + } + + /// + /// Uploader correctly adds repeatable (IEnumerable) query parameters to initial server call. + /// + [Fact] + public void TestUploadWithRepeatableParameters() + { + var id = 456; + // Repeatable parameters should be sent multiple times with the same key + var pathAndQuery = $"testPath/{id}?uploadType=resumable&part=snippet&part=contentDetails&tag=important&tag=urgent"; + using (var server = new MultiChunkQueriedServer(_server, pathAndQuery)) + using (var service = new MockClientService(server.HttpPrefix)) + { + var content = new MemoryStream(UploadTestBytes); + var uploader = new TestResumableUploadWithRepeatableParameters(service, "testPath/{id}", "POST", content, "text/plain", 100) + { + Id = id, + Part = new[] { "snippet", "contentDetails" }, + Tag = new[] { "important", "urgent" } + }; + var progress = uploader.Upload(); + Assert.Equal(UploadStatus.Completed, progress.Status); + Assert.Equal(6, server.Requests.Count); + } + } + /// A mock request object. private class TestRequest : IEquatable { diff --git a/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs new file mode 100644 index 00000000000..32bf4ae2012 --- /dev/null +++ b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs @@ -0,0 +1,119 @@ +/* +Copyright 2026 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Util; +using System.Linq; +using Xunit; + +namespace Google.Apis.Tests.Apis.Utils +{ + /// Tests for . + public class ReflectionCacheTest + { + private class TestClass + { + [RequestParameter("test_param", RequestParameterType.Query)] + public string TestProperty { get; set; } + + [RequestParameter("another_param", RequestParameterType.Path)] + public int AnotherProperty { get; set; } + + public string PropertyWithoutAttribute { get; set; } + } + + [Fact] + public void GetRequestParameterPropertiesWithAttribute_ReturnsPropertiesAndAttributes() + { + // Act + var propertiesWithAttributes = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + + // Assert + Assert.NotNull(propertiesWithAttributes); + Assert.Equal(2, propertiesWithAttributes.Length); + + // Check that both property and attribute are returned + var testProperty = propertiesWithAttributes.Single(p => p.Property.Name == "TestProperty"); + Assert.NotNull(testProperty.Property); + Assert.NotNull(testProperty.Attribute); + Assert.Equal("test_param", testProperty.Attribute.Name); + Assert.Equal(RequestParameterType.Query, testProperty.Attribute.Type); + + var anotherProperty = propertiesWithAttributes.Single(p => p.Property.Name == "AnotherProperty"); + Assert.NotNull(anotherProperty.Property); + Assert.NotNull(anotherProperty.Attribute); + Assert.Equal("another_param", anotherProperty.Attribute.Name); + Assert.Equal(RequestParameterType.Path, anotherProperty.Attribute.Type); + + var isPropertyWithoutAttributeExists = propertiesWithAttributes.Any(p => p.Property.Name == "PropertyWithoutAttribute"); + Assert.False(isPropertyWithoutAttributeExists); + } + + [Fact] + public void GetRequestParameterPropertiesWithAttribute_CachesResults() + { + // Act - Call twice + var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + + // Assert - Should return the same array instance (cached) + Assert.Same(result1, result2); + + // Assert - Individual PropertyInfo and Attribute instances should be the same + for (int i = 0; i < result1.Length; i++) + { + Assert.Same(result1[i].Property, result2[i].Property); + Assert.Same(result1[i].Attribute, result2[i].Attribute); + } + } + + [Fact] + public void GetRequestParameterProperties_ReturnsOnlyPropertiesWithAttribute() + { + // Act + var properties = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + // Assert + Assert.NotNull(properties); + Assert.Equal(2, properties.Length); + Assert.Contains(properties, p => p.Property.Name == "TestProperty"); + Assert.Contains(properties, p => p.Property.Name == "AnotherProperty"); + Assert.DoesNotContain(properties, p => p.Property.Name == "PropertyWithoutAttribute"); + } + + [Fact] + public void GetRequestParameterPropertiesWithAttribute_RegressionTest_NoNewInstancesCreated() + { + // This regression test ensures that repeated calls don't create new PropertyInfo or Attribute instances. + + // Act - Get properties multiple times + var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + var result3 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + + // Assert - All calls should return the exact same array instance + Assert.Same(result1, result2); + Assert.Same(result2, result3); + + // Assert - Individual PropertyInfo and Attribute objects should be the same instances + for (int i = 0; i < result1.Length; i++) + { + Assert.Same(result1[i].Property, result2[i].Property); + Assert.Same(result2[i].Property, result3[i].Property); + Assert.Same(result1[i].Attribute, result2[i].Attribute); + Assert.Same(result2[i].Attribute, result3[i].Attribute); + } + } + } +} diff --git a/Src/Support/Google.Apis/Upload/ResumableUpload.cs b/Src/Support/Google.Apis/Upload/ResumableUpload.cs index 9dd3f75bfa1..579f4c1fc48 100644 --- a/Src/Support/Google.Apis/Upload/ResumableUpload.cs +++ b/Src/Support/Google.Apis/Upload/ResumableUpload.cs @@ -18,6 +18,7 @@ limitations under the License. using Google.Apis.Json; using Google.Apis.Logging; using Google.Apis.Requests; +using Google.Apis.Requests.Parameters; using Google.Apis.Responses; using Google.Apis.Services; using Google.Apis.Testing; @@ -1180,35 +1181,11 @@ private HttpRequestMessage CreateInitializeRequest() /// private void SetAllPropertyValues(RequestBuilder requestBuilder) { - Type myType = this.GetType(); - var properties = myType.GetProperties(); - - foreach (var property in properties) - { - var attribute = Utilities.GetCustomAttribute(property); - - if (attribute != null) - { - string name = attribute.Name ?? property.Name.ToLowerInvariant(); - object value = property.GetValue(this, null); - if (value != null) - { - var valueAsEnumerable = value as IEnumerable; - if (!(value is string) && valueAsEnumerable != null) - { - foreach (var elem in valueAsEnumerable) - { - requestBuilder.AddParameter(attribute.Type, name, Utilities.ConvertToString(elem)); - } - } - else - { - // Otherwise just convert it to a string. - requestBuilder.AddParameter(attribute.Type, name, Utilities.ConvertToString(value)); - } - } - } - } + // Use InitParametersWithExpansion which: + // 1. Uses ReflectionCache to avoid PropertyInfo regeneration (performance) + // 2. Expands IEnumerable/Repeatable parameters correctly (functionality) + // Note: Using the expansion variant ensures mismatched library versions fail at compile time + ParameterUtils.InitParametersWithExpansion(requestBuilder, this); } #endregion Upload Implementation From 725ecdd47ff11a3c122dddb50fb8b27aa7e597e9 Mon Sep 17 00:00:00 2001 From: Serge Pavlov Date: Mon, 9 Mar 2026 04:54:20 -0400 Subject: [PATCH 2/2] perf: opt-in reflection cache via ApplicationContext; promote internal API to public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make reflection cache opt-in via ApplicationContext.EnableReflectionCache (default false), following the library's established startup-configuration pattern. Promote ReflectionCache, PropertyWithAttribute, and ParameterUtils.InitParametersWithExpansion to public; remove InternalsVisibleTo("Google.Apis", …) from AssemblyInfo. --- .../Google.Apis.Core/ApplicationContext.cs | 24 +++++- Src/Support/Google.Apis.Core/AssemblyInfo.cs | 1 - .../Requests/Parameters/ParameterUtils.cs | 5 +- .../Util/PropertyWithAttribute.cs | 32 +++++-- .../Google.Apis.Core/Util/ReflectionCache.cs | 78 ++++++++++++++--- .../Requests/Parameters/ParameterUtilsTest.cs | 85 +++++++++++++++---- .../Apis/Utils/ReflectionCacheTest.cs | 64 +++++++++++++- 7 files changed, 241 insertions(+), 48 deletions(-) diff --git a/Src/Support/Google.Apis.Core/ApplicationContext.cs b/Src/Support/Google.Apis.Core/ApplicationContext.cs index d316446bae7..e9207ac105e 100644 --- a/Src/Support/Google.Apis.Core/ApplicationContext.cs +++ b/Src/Support/Google.Apis.Core/ApplicationContext.cs @@ -19,13 +19,33 @@ limitations under the License. namespace Google { - /// Defines the context in which this library runs. It allows setting up custom loggers. + /// Defines the context in which this library runs. It allows setting up custom loggers and performance options. public static class ApplicationContext { private static ILogger logger; // For testing - internal static void Reset() => logger = null; + internal static void Reset() + { + logger = null; + EnableReflectionCache = false; + } + + /// + /// Gets or sets whether to enable reflection result caching for request parameter properties. + /// + /// + /// + /// When enabled, lookups for request parameter + /// properties are cached per request type, eliminating repeated reflection overhead. + /// + /// + /// Default is false. Set to true early in application startup before making + /// any API requests. This setting is intended for applications that make many requests and + /// where reflection overhead has been identified as a bottleneck. + /// + /// + public static bool EnableReflectionCache { get; set; } /// Returns the logger used within this application context. /// It creates a if no logger was registered previously diff --git a/Src/Support/Google.Apis.Core/AssemblyInfo.cs b/Src/Support/Google.Apis.Core/AssemblyInfo.cs index 747aa2a2c86..9d929c12b56 100644 --- a/Src/Support/Google.Apis.Core/AssemblyInfo.cs +++ b/Src/Support/Google.Apis.Core/AssemblyInfo.cs @@ -16,6 +16,5 @@ limitations under the License. using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Google.Apis,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] [assembly: InternalsVisibleTo("Google.Apis.Tests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] [assembly: InternalsVisibleTo("Google.Apis.IntegrationTests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs index 979b78deb8a..2c1d58ec3b5 100644 --- a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs +++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs @@ -155,10 +155,7 @@ public static void InitParameters(RequestBuilder builder, object request) /// attribute. Those properties will be set in the /// given request builder object /// - /// - /// This method is internal and is called from the Google.Apis assembly via InternalsVisibleTo. - /// - internal static void InitParametersWithExpansion(RequestBuilder builder, object request) + public static void InitParametersWithExpansion(RequestBuilder builder, object request) { // Use typed methods to preserve RequestParameterType information var parametersWithTypes = CreateParameterDictionaryWithTypes(request); diff --git a/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs index 3602a483a21..5bdfb7bd346 100644 --- a/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs +++ b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs @@ -19,25 +19,43 @@ limitations under the License. namespace Google.Apis.Util { /// - /// Represents a property with its associated RequestParameterAttribute. + /// Pairs a with its associated . /// - internal readonly struct PropertyWithAttribute + /// + /// Instances of this struct are produced by + /// and consumed by ParameterUtils when building request URLs and form bodies. Only properties + /// that are decorated with are represented; properties without + /// the attribute are filtered out before any value is created. + /// + public readonly struct PropertyWithAttribute { /// - /// The PropertyInfo for the property. + /// Gets the for the request parameter property. /// + /// + /// The that describes the request parameter property on the request type. + /// public PropertyInfo Property { get; } /// - /// The RequestParameterAttribute associated with this property. + /// Gets the applied to . /// + /// + /// The that annotates , providing the + /// parameter name and used when serializing the request. + /// + /// + /// This value is never null on instances returned by + /// ; properties without the attribute + /// are excluded from the results. + /// public RequestParameterAttribute Attribute { get; } /// - /// Initializes a new instance of PropertyWithAttribute. + /// Initializes a new instance of . /// - /// The property info. - /// The associated . + /// The of the request parameter property. + /// The applied to . public PropertyWithAttribute(PropertyInfo property, RequestParameterAttribute attribute) { Property = property; diff --git a/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs index 2f5d87831ac..8951fe40586 100644 --- a/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs +++ b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs @@ -18,6 +18,7 @@ limitations under the License. using System.Collections.Concurrent; using System.Linq; using System.Reflection; +using Google; namespace Google.Apis.Util { @@ -25,10 +26,37 @@ namespace Google.Apis.Util /// Provides cached reflection results for request parameter discovery. /// /// - /// This cache is intentionally unbounded and keyed by request type. The set of request types decorated with - /// is expected to be finite and stable for the lifetime of the application. + /// + /// This class is thread-safe. The internal cache uses , + /// which allows concurrent reads and writes without external locking. + /// + /// + /// Caching is opt-in: set to true + /// at application startup to activate it. By default, reflection results are recomputed on every call + /// to preserve the existing no-overhead-at-rest behavior. + /// + /// + /// When caching is enabled, each unique request type incurs a one-time reflection cost. Subsequent calls + /// for the same type return the cached array directly, eliminating + /// per-call reflection and attribute-lookup overhead. + /// + /// + /// The cache is intentionally unbounded, but in practice it is finite: entries are keyed by the concrete + /// request types that carry -decorated properties. The set of such + /// types in any application is small and fixed at compile time. + /// /// - internal static partial class ReflectionCache + /// + /// Enable caching once at application startup, before issuing any API requests: + /// + /// // Enable caching at application startup + /// ApplicationContext.EnableReflectionCache = true; + /// + /// // The cache is used automatically by ParameterUtils + /// // (no further configuration required) + /// + /// + public static partial class ReflectionCache { /// /// Cache of properties filtered by RequestParameterAttribute. @@ -43,20 +71,42 @@ internal static partial class ReflectionCache new ConcurrentDictionary(); /// - /// Returns the cached set of request-parameter properties for the specified request type. + /// Returns the set of -decorated properties for the specified + /// request type. /// - /// The type to get request parameter properties for. - /// An array of structs containing properties and their RequestParameterAttribute. - internal static PropertyWithAttribute[] GetRequestParameterProperties(Type type) + /// The request type whose parameter properties should be returned. + /// + /// An array of values, each pairing a + /// with its . + /// Only properties that carry the attribute are included; properties without it are omitted. + /// + /// + /// When is true, the result is + /// stored in an internal and returned on subsequent + /// calls without re-executing reflection. When the setting is false (the default), reflection + /// is performed on every invocation. + /// + public static PropertyWithAttribute[] GetRequestParameterProperties(Type type) { - return RequestParameterPropertiesCache.GetOrAdd(type, t => + // Only use cache if explicitly enabled by user + if (ApplicationContext.EnableReflectionCache) { - // Get properties, filter by attribute, and cache only the filtered result - return t.GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Select(prop => new PropertyWithAttribute(prop, prop.GetCustomAttribute(inherit: false))) - .Where(pwa => pwa.Attribute != null) - .ToArray(); - }); + return RequestParameterPropertiesCache.GetOrAdd(type, ComputeProperties); + } + + // Default behavior: compute properties without caching + return ComputeProperties(type); + } + + /// + /// Computes the request parameter properties for a given type using reflection. + /// + private static PropertyWithAttribute[] ComputeProperties(Type type) + { + return type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(prop => new PropertyWithAttribute(prop, prop.GetCustomAttribute(inherit: false))) + .Where(pwa => pwa.Attribute != null) + .ToArray(); } } } diff --git a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs index 97e8903ec8b..da3a7ff15be 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs @@ -14,6 +14,7 @@ You may obtain a copy of the License at limitations under the License. */ +using Google; using Google.Apis.Requests; using Google.Apis.Requests.Parameters; using Google.Apis.Util; @@ -261,25 +262,36 @@ public void InitParametersWithExpansion_BooleanConversion() [Fact] public void InitParametersWithExpansion_CacheUsage() { - var request1 = new TestRequestWithScalars { Name = "test1", Id = 1 }; - var request2 = new TestRequestWithScalars { Name = "test2", Id = 2 }; - var builder1 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; - var builder2 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; - - // First call - cache is populated - ParameterUtils.InitParametersWithExpansion(builder1, request1); - var properties1 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); - - // Second call - should reuse cached PropertyInfo - ParameterUtils.InitParametersWithExpansion(builder2, request2); - var properties2 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); - - // Verify same PropertyInfo instances are returned (object reference equality) - Assert.Equal(properties1.Length, properties2.Length); - for (int i = 0; i < properties1.Length; i++) + var originalState = ApplicationContext.EnableReflectionCache; + try { - Assert.Same(properties1[i].Property, properties2[i].Property); - Assert.Same(properties1[i].Attribute, properties2[i].Attribute); + // Arrange - explicitly enable cache + ApplicationContext.EnableReflectionCache = true; + + var request1 = new TestRequestWithScalars { Name = "test1", Id = 1 }; + var request2 = new TestRequestWithScalars { Name = "test2", Id = 2 }; + var builder1 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + var builder2 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") }; + + // First call - cache is populated + ParameterUtils.InitParametersWithExpansion(builder1, request1); + var properties1 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); + + // Second call - should reuse cached PropertyInfo + ParameterUtils.InitParametersWithExpansion(builder2, request2); + var properties2 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars)); + + // Verify same PropertyInfo instances are returned (object reference equality) + Assert.Equal(properties1.Length, properties2.Length); + for (int i = 0; i < properties1.Length; i++) + { + Assert.Same(properties1[i].Property, properties2[i].Property); + Assert.Same(properties1[i].Attribute, properties2[i].Attribute); + } + } + finally + { + ApplicationContext.EnableReflectionCache = originalState; } } @@ -338,5 +350,42 @@ public void InitParametersWithExpansion_NullElementsInEnumerable() Assert.Contains($"part={Uri.EscapeDataString(expectedNullValue)}", query); } } + + [Theory] + [InlineData(false)] // Cache disabled (default) + [InlineData(true)] // Cache enabled + public void IterateParameters_WorksWithBothCacheModes(bool enableCache) + { + // Arrange + var originalState = ApplicationContext.EnableReflectionCache; + try + { + ApplicationContext.EnableReflectionCache = enableCache; + var request = new TestRequestUrl() + { + FirstParam = "firstOne", + SecondParam = "secondOne", + ParamsCollection = new List>{ + new KeyValuePair("customParam1","customVal1"), + new KeyValuePair("customParam2","customVal2") + } + }; + + // Act + var result = request.Build().AbsoluteUri; + + // Assert - behavior should be identical regardless of cache setting + Assert.Contains("first_query_param=firstOne", result); + Assert.Contains("second_query_param=secondOne", result); + Assert.Contains("customParam1=customVal1", result); + Assert.Contains("customParam2=customVal2", result); + Assert.DoesNotContain("query_param_attribute_name", result); + } + finally + { + // Restore original state + ApplicationContext.EnableReflectionCache = originalState; + } + } } } diff --git a/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs index 32bf4ae2012..bcc790281ce 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs @@ -14,15 +14,30 @@ You may obtain a copy of the License at limitations under the License. */ +using Google; using Google.Apis.Util; +using System; using System.Linq; using Xunit; namespace Google.Apis.Tests.Apis.Utils { /// Tests for . - public class ReflectionCacheTest + public class ReflectionCacheTest : IDisposable { + private readonly bool _originalCacheState; + + public ReflectionCacheTest() + { + // Save original state to restore after each test + _originalCacheState = ApplicationContext.EnableReflectionCache; + } + + public void Dispose() + { + // Restore original state after each test + ApplicationContext.EnableReflectionCache = _originalCacheState; + } private class TestClass { [RequestParameter("test_param", RequestParameterType.Query)] @@ -37,6 +52,9 @@ private class TestClass [Fact] public void GetRequestParameterPropertiesWithAttribute_ReturnsPropertiesAndAttributes() { + // Arrange - cache disabled (default behavior) + ApplicationContext.EnableReflectionCache = false; + // Act var propertiesWithAttributes = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); @@ -64,6 +82,9 @@ public void GetRequestParameterPropertiesWithAttribute_ReturnsPropertiesAndAttri [Fact] public void GetRequestParameterPropertiesWithAttribute_CachesResults() { + // Arrange - explicitly enable cache + ApplicationContext.EnableReflectionCache = true; + // Act - Call twice var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); @@ -82,6 +103,9 @@ public void GetRequestParameterPropertiesWithAttribute_CachesResults() [Fact] public void GetRequestParameterProperties_ReturnsOnlyPropertiesWithAttribute() { + // Arrange - cache disabled (default behavior) + ApplicationContext.EnableReflectionCache = false; + // Act var properties = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); // Assert @@ -95,8 +119,11 @@ public void GetRequestParameterProperties_ReturnsOnlyPropertiesWithAttribute() [Fact] public void GetRequestParameterPropertiesWithAttribute_RegressionTest_NoNewInstancesCreated() { - // This regression test ensures that repeated calls don't create new PropertyInfo or Attribute instances. + // This regression test ensures that repeated calls with cache enabled don't create new PropertyInfo or Attribute instances. + // Arrange - explicitly enable cache + ApplicationContext.EnableReflectionCache = true; + // Act - Get properties multiple times var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); @@ -115,5 +142,38 @@ public void GetRequestParameterPropertiesWithAttribute_RegressionTest_NoNewInsta Assert.Same(result2[i].Attribute, result3[i].Attribute); } } + + [Fact] + public void GetRequestParameterProperties_WithCacheDisabled_ReturnsDifferentInstanceOnSecondCall() + { + // Arrange + ApplicationContext.EnableReflectionCache = false; + + // Act + var firstCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + var secondCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + + // Assert - should be different array instances when not caching + Assert.NotSame(firstCall, secondCall); + + // But content should be equivalent + Assert.Equal(firstCall.Length, secondCall.Length); + } + + [Fact] + public void GetRequestParameterProperties_DefaultBehaviorIsNotCached() + { + // Arrange - don't set EnableReflectionCache, use default (false) + // (IDisposable restores the original state, so this tests a fresh-default scenario + // only when run as the first test; checking NotSame is sufficient) + ApplicationContext.EnableReflectionCache = false; + + // Act + var firstCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + var secondCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass)); + + // Assert - default should be no caching + Assert.NotSame(firstCall, secondCall); + } } }