From 3d6ff8303aac50e4a5838aa146008a70caf228aa Mon Sep 17 00:00:00 2001 From: Rasmus Date: Tue, 4 Aug 2026 09:09:21 +0200 Subject: [PATCH 1/4] fix: differentiate explicitly empty string delimiter from "no delimiter" Fixes #2294 --- .../Parser.Request.Body.cs | 6 ++-- .../Parser.Request.Path.cs | 2 +- .../Parser.Request.Query.cs | 12 +++++-- src/tests/Refit.Tests/IQueryObjectApi.cs | 13 ++++++++ .../Refit.Tests/QueryObjectFlatteningTests.cs | 33 +++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs index 970414736..9450b2593 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs @@ -300,14 +300,16 @@ internal readonly record struct BodyAttributeInfo( BodyBufferMode BufferMode); /// Form-relevant data parsed from a [Query] attribute on a parameter or body property. - /// The delimiter combined with the prefix. + /// The delimiter combined with the prefix, or when no + /// [Query] attribute was present. An explicitly supplied empty delimiter is preserved as + /// and must not be confused with the absent case. /// The field name prefix, if any. /// The value format, if any. /// The explicit collection format value, if any. /// Whether null values are serialized as empty fields. /// Whether the raw value is stringified via ToString() before formatting. internal readonly record struct QueryFormData( - string Delimiter, + string? Delimiter, string? Prefix, string? Format, int? CollectionFormatValue, diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs index 1f1fcb7aa..9b3f1b4e9 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs @@ -104,7 +104,7 @@ internal static bool TryBuildPathResidualQuery( ElementCanBeNull: false, BuildValueFormat(flattenType, null, formattableSymbol, context), residual.ToImmutableEquatableArray(), - NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter); + NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); return true; } diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs index 3f976cea7..13e0ec991 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs @@ -17,6 +17,14 @@ internal static partial class Parser /// The maximum nested-object depth flattened inline before the whole parameter falls back to reflection. private const int MaxNestingDepth = 32; + /// The delimiter joining nested query keys when no [Query] attribute supplies one. + /// + /// This is only the fallback for the absent-attribute case. A [Query] attribute always carries a delimiter - + /// QueryAttribute.Delimiter itself defaults to "." - so an explicitly supplied empty delimiter + /// must be honoured rather than replaced with this value. + /// + private const string DefaultNestingDelimiter = "."; + /// The metadata name of Refit.CollectionFormat. private const string CollectionFormatTypeName = "Refit.CollectionFormat"; @@ -258,7 +266,7 @@ internal static bool TryBuildQueryModel( ElementCanBeNull: false, BuildValueFormat(parameter.Type, format, formattableSymbol, context), properties, - NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter); + NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); } /// Builds the query model for a collection-of-simple-elements parameter, or null for any other shape. @@ -358,7 +366,7 @@ internal static bool TryBuildQueryModel( CanElementBeNull(elementType!), BuildValueFormat(elementType!, format, formattableSymbol, context), elementProperties, - NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter); + NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); } /// Builds the query model for a [QueryConverter] parameter, or null when the type is unresolved. diff --git a/src/tests/Refit.Tests/IQueryObjectApi.cs b/src/tests/Refit.Tests/IQueryObjectApi.cs index 38069e884..d5ff1f1f5 100644 --- a/src/tests/Refit.Tests/IQueryObjectApi.cs +++ b/src/tests/Refit.Tests/IQueryObjectApi.cs @@ -61,6 +61,19 @@ public interface IQueryObjectApi [Get("/nested")] Task FlattenNested([Query] NestedQueryObject query); + /// Flattens a query object with a nested object property under an explicitly empty delimiter, so the + /// nested keys are concatenated with no separator at all. + /// The query object. + /// The response body. + [Get("/nested/empty")] + Task FlattenNestedWithEmptyDelimiter([Query(delimiter: "")] NestedQueryObject query); + + /// Flattens a query object with a nested object property under a custom non-dotted delimiter. + /// The query object. + /// The response body. + [Get("/nested/colon")] + Task FlattenNestedWithCustomDelimiter([Query(delimiter: ":")] NestedQueryObject query); + /// Flattens a query object with a nullable nested value-type property under a dotted key. /// The query object. /// The response body. diff --git a/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs b/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs index 5a22f09e8..5daf565cd 100644 --- a/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs +++ b/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs @@ -210,6 +210,39 @@ await AssertParityAsync( query); } + /// + /// Verifies an explicitly empty [Query] delimiter joins nested keys with nothing at all, rather than + /// falling back to the default dot. An absent attribute and an explicitly empty delimiter are different things. + /// + /// A task that represents the asynchronous operation. + [Test] + public async Task EmptyDelimiterJoinsNestedKeysWithoutSeparator() + { + var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } }; + + await AssertParityAsync( + "/nested/empty?Name=ada&AddressCity=wien&Addressz=1010", + api => api.FlattenNestedWithEmptyDelimiter(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNestedWithEmptyDelimiter)), + query); + } + + /// Verifies a custom non-dotted [Query] delimiter joins nested keys, matching the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task CustomDelimiterJoinsNestedKeys() + { + var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } }; + + await AssertParityAsync( + "/nested/colon?Name=ada&Address%3ACity=wien&Address%3Az=1010", + api => api.FlattenNestedWithCustomDelimiter(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNestedWithCustomDelimiter)), + query); + } + /// Verifies a custom key formatter is applied to every nested key segment, matching the reflection builder. /// A task that represents the asynchronous operation. [Test] From 184a43a5401f20572c354fa425d17d3c5e3769cc Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 5 Aug 2026 09:22:45 +0200 Subject: [PATCH 2/4] Fix code style complaints from CI --- .../Parser.Request.Query.cs | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs index 13e0ec991..6871420b7 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs @@ -252,21 +252,18 @@ internal static bool TryBuildQueryModel( ? nullableObject.TypeArguments[0] : parameter.Type; - if (TryBuildQueryObjectProperties(objectType, parameterPrefixSegment, formattableSymbol, context) is not { } properties) - { - return null; - } - - return new( - urlName, - QueryParameterShape.Object, - TreatAsString: false, - preEncoded, - data.CollectionFormatValue, - ElementCanBeNull: false, - BuildValueFormat(parameter.Type, format, formattableSymbol, context), - properties, - NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); + return TryBuildQueryObjectProperties(objectType, parameterPrefixSegment, formattableSymbol, context) is not { } properties + ? null + : new( + urlName, + QueryParameterShape.Object, + TreatAsString: false, + preEncoded, + data.CollectionFormatValue, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, format, formattableSymbol, context), + properties, + NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); } /// Builds the query model for a collection-of-simple-elements parameter, or null for any other shape. @@ -352,21 +349,18 @@ internal static bool TryBuildQueryModel( return null; } - if (TryBuildQueryObjectProperties(elementType!, null, formattableSymbol, context) is not { } elementProperties) - { - return null; - } - - return new( - urlName, - QueryParameterShape.IndexedCollection, - TreatAsString: false, - preEncoded, - data.CollectionFormatValue, - CanElementBeNull(elementType!), - BuildValueFormat(elementType!, format, formattableSymbol, context), - elementProperties, - NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); + return TryBuildQueryObjectProperties(elementType!, null, formattableSymbol, context) is not { } elementProperties + ? null + : new( + urlName, + QueryParameterShape.IndexedCollection, + TreatAsString: false, + preEncoded, + data.CollectionFormatValue, + CanElementBeNull(elementType!), + BuildValueFormat(elementType!, format, formattableSymbol, context), + elementProperties, + NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter); } /// Builds the query model for a [QueryConverter] parameter, or null when the type is unresolved. From 7fe43ca40dd3763d10c6a020d15064a375d3fac5 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 5 Aug 2026 10:14:42 +0200 Subject: [PATCH 3/4] Fixed failing test --- .../IndexedCollectionGenerationTests.cs | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs b/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs index 60aaa81b3..55747bba4 100644 --- a/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs +++ b/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs @@ -81,6 +81,9 @@ public interface IGeneratedClient } """; + /// The expected query key prefix for an Indexed collection parameter, which is items[{ for the test sources above. + private const string ExpectedItemsQueryKeyPrefix = "items[{"; + /// Source where the element type has a nested object property - should fall back to reflection /// because the nested type is complex and the reflection builder walks runtime types. private const string IndexedWithSimpleScalarCollectionSource = @@ -150,15 +153,37 @@ public async Task NonNullableIndexedCollectionGeneratesInline() await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); } - /// Verifies an empty Indexed nesting delimiter falls back to the standard dot delimiter. + /// + /// Verifies an explicitly empty Indexed nesting delimiter joins the indexed key to the element's property name + /// with no separator at all, rather than falling back to the dot. The reflection builder composes this key as + /// indexedKey + attr.Delimiter + propertyKey with the delimiter used verbatim, so substituting the dot + /// for an explicitly empty delimiter would make the generated client disagree with it. + /// + /// A task representing the asynchronous test. + [Test] + public async Task EmptyIndexedDelimiterConcatenatesWithoutSeparator() + { + var result = Fixture.RunGenerator( + EmptyDelimiterIndexedSource, + generatedRequestBuilding: true + ); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix); + await Assert.That(result.GeneratedSources[Hint]).Contains("}Id"); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain("}.Id"); + } + + /// Verifies an Indexed parameter that supplies no delimiter still composes its keys under the dot, + /// which is the delimiter [Query] itself defaults to. /// A task representing the asynchronous test. [Test] - public async Task EmptyIndexedDelimiterUsesDotFallback() + public async Task UnspecifiedIndexedDelimiterUsesDot() { - var result = Fixture.RunGenerator(EmptyDelimiterIndexedSource, generatedRequestBuilding: true); + var result = Fixture.RunGenerator(NullableIndexedSource, generatedRequestBuilding: true); await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(result.GeneratedSources[Hint]).Contains("items[{"); + await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix); await Assert.That(result.GeneratedSources[Hint]).Contains("}.Id"); } @@ -195,7 +220,7 @@ public async Task GeneratedSourceContainsIndexedKeyExpression() await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains("items[{"); + await Assert.That(generated).Contains(ExpectedItemsQueryKeyPrefix); } /// Verifies a Indexed parameter whose element type is a scalar (not complex) generates inline @@ -229,7 +254,7 @@ public interface IGeneratedClient } /// Verifies a Indexed parameter whose element type is a collection with a complex element type falls back to reflective generation. - /// A task representing the asynchronous test. + /// A task representing the asynchronous test. [Test] public async Task IndexedWithComplexIndexElementFallsBackToReflective() { From eb1db83fffe7c3bb23d2062f847b0ce55a6abd58 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 5 Aug 2026 10:31:19 +0200 Subject: [PATCH 4/4] Fix styling --- .../Refit.GeneratorTests/IndexedCollectionGenerationTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs b/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs index 55747bba4..f9af8674d 100644 --- a/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs +++ b/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs @@ -163,10 +163,7 @@ public async Task NonNullableIndexedCollectionGeneratesInline() [Test] public async Task EmptyIndexedDelimiterConcatenatesWithoutSeparator() { - var result = Fixture.RunGenerator( - EmptyDelimiterIndexedSource, - generatedRequestBuilding: true - ); + var result = Fixture.RunGenerator(EmptyDelimiterIndexedSource, generatedRequestBuilding: true); await Assert.That(result.CompilesWithoutErrors).IsTrue(); await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix);