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..6871420b7 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";
@@ -244,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: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter);
+ 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.
@@ -344,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: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter);
+ 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.
diff --git a/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs b/src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs
index 60aaa81b3..f9af8674d 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,34 @@ 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 EmptyIndexedDelimiterUsesDotFallback()
+ public async Task EmptyIndexedDelimiterConcatenatesWithoutSeparator()
{
var result = Fixture.RunGenerator(EmptyDelimiterIndexedSource, 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");
+ 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 UnspecifiedIndexedDelimiterUsesDot()
+ {
+ var result = Fixture.RunGenerator(NullableIndexedSource, generatedRequestBuilding: true);
+
+ await Assert.That(result.CompilesWithoutErrors).IsTrue();
+ await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix);
await Assert.That(result.GeneratedSources[Hint]).Contains("}.Id");
}
@@ -195,7 +217,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 +251,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()
{
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]