diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ce27e..aaab79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Add `EntitySetNameResolver` option to `ODataClientOptions`, an opt-in `Func` that resolves entity set names for the parameterless `For()` overload. A non-empty result is used verbatim and short-circuits the `[EntitySet]` attribute lookup and `AutoPluralization`; returning `null`, empty, or whitespace falls through to the existing conventions. The explicit `For("EntitySetName")` overload never invokes the resolver. This lets consumers wire attribute-based conventions (e.g. a custom `[CollectionName]`/`[EntitySetName]` attribute) without repeating the entity set name at every call site + ## [10.0.106] - 2026-07-08 ### Fixed diff --git a/Documentation/querying.md b/Documentation/querying.md index f4faaca..cd916df 100644 --- a/Documentation/querying.md +++ b/Documentation/querying.md @@ -5,6 +5,7 @@ This document covers all query options supported by the PanoramicData.OData.Clie ## Table of Contents - [Basic Queries](#basic-queries) +- [Entity Set Name Resolution](#entity-set-name-resolution) - [Fluent Query Execution](#fluent-query-execution) - [Filtering ($filter)](#filtering-filter) - [Selecting Fields ($select)](#selecting-fields-select) @@ -43,6 +44,49 @@ foreach (var product in response.Value) } ``` +## Entity Set Name Resolution + +The parameterless `For()` overload resolves an entity set name using these rules, in order: + +1. `ODataClientOptions.EntitySetNameResolver`, when it returns a non-empty value. +2. An `[EntitySet]` attribute on the model type. +3. The built-in type-name convention, including pluralization when `AutoPluralization` is enabled. + +Use `EntitySetNameResolver` to integrate an application-specific model convention without repeating an entity set name at every call site. Return `null`, an empty string, or whitespace to use the remaining conventions. + +A non-empty resolver value is used verbatim and short-circuits both the `[EntitySet]` attribute lookup and `AutoPluralization`. The resolver runs on every parameterless `For()` call, so keep it fast and side-effect free. + +```csharp +using System.Reflection; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class CollectionNameAttribute(string name) : Attribute +{ + public string Name { get; } = name; +} + +[CollectionName("service_products")] +public sealed class Product +{ + public int Id { get; init; } +} + +var client = new ODataClient(new ODataClientOptions +{ + BaseUrl = "https://api.example.com/odata", + EntitySetNameResolver = type => + type.GetCustomAttribute()?.Name +}); + +var query = client.For(); // service_products +``` + +Passing an entity set name explicitly always takes precedence and does not invoke the resolver: + +```csharp +var query = client.For("products_override"); +``` + ## Fluent Query Execution Execute queries directly from the query builder for streamlined code: diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs index 7bc3d4a..9558f39 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs @@ -12,6 +12,21 @@ internal sealed class EntitySetAttribute(string entitySet) : Attribute [EntitySet("Mailbox")] internal sealed class GeneratedMailbox { } +internal sealed class CustomResolvedEntity { } + +/// +/// Mirrors an application-defined entity-set attribute (as used by the Integration Team OData +/// extensions) to exercise attribute-based wiring. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +internal sealed class CollectionNameAttribute(string name) : Attribute +{ + public string Name { get; } = name; +} + +[CollectionName("service_products")] +internal sealed class AttributeResolvedEntity { } + /// /// Unit tests for ODataClient query operations. /// @@ -119,6 +134,183 @@ public void For_EntitySetAttribute_OverridesPluralization() url.Should().Be("Mailbox"); } + /// + /// Tests that the configured entity set name resolver overrides the built-in conventions. + /// + [Fact] + public void For_EntitySetNameResolver_UsesResolvedName() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = type => type == typeof(CustomResolvedEntity) ? "custom_entities" : null + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("custom_entities"); + } + + /// + /// Tests that the configured entity set name resolver takes precedence over the entity set attribute. + /// + [Fact] + public void For_EntitySetNameResolver_OverridesEntitySetAttribute() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = type => type == typeof(GeneratedMailbox) ? "custom_mailboxes" : null + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("custom_mailboxes"); + } + + /// + /// Tests that a whitespace resolver result uses the existing entity set attribute convention. + /// + [Fact] + public void For_EntitySetNameResolverReturnsWhitespace_UsesEntitySetAttribute() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = _ => " " + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("Mailbox"); + } + + /// + /// Tests that a null resolver result uses the existing pluralization convention. + /// + [Fact] + public void For_EntitySetNameResolverReturnsNull_UsesPluralization() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = _ => null + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("Products"); + } + + /// + /// Tests that an explicit entity set name does not invoke the configured resolver. + /// + [Fact] + public void For_WithExplicitEntitySetName_DoesNotInvokeResolver() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = _ => throw new InvalidOperationException("Resolver should not be invoked.") + }); + + var url = client.For("CustomProducts").BuildUrl(); + + url.Should().Be("CustomProducts"); + } + + /// + /// Tests that an empty resolver result falls back to the existing pluralization convention. + /// + [Fact] + public void For_EntitySetNameResolverReturnsEmpty_UsesPluralization() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = _ => string.Empty + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("Products"); + } + + /// + /// Tests that the resolver is invoked with the requested entity type. + /// + [Fact] + public void For_EntitySetNameResolver_ReceivesRequestedType() + { + Type? observedType = null; + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = type => + { + observedType = type; + return null; + } + }); + + client.For().BuildUrl(); + + observedType.Should().Be(); + } + + /// + /// Tests that an attribute-based resolver (the Integration Team OData extensions pattern) + /// resolves the entity set name from a custom attribute on the model type. + /// + [Fact] + public void For_EntitySetNameResolver_ResolvesFromCustomAttribute() + { + using var httpClient = new HttpClient(_mockHandler.Object) { BaseAddress = new Uri("https://test.odata.org/") }; + using var client = new ODataClient(new ODataClientOptions + { + BaseUrl = "https://test.odata.org/", + HttpClient = httpClient, + Logger = NullLogger.Instance, + RetryCount = 0, + EntitySetNameResolver = type => type + .GetCustomAttributes(typeof(CollectionNameAttribute), inherit: false) + .OfType() + .FirstOrDefault()?.Name + }); + + var url = client.For().BuildUrl(); + + url.Should().Be("service_products"); + } + /// /// Tests that AutoPluralization = false uses the type name as-is. /// diff --git a/PanoramicData.OData.Client/ODataClient.Query.cs b/PanoramicData.OData.Client/ODataClient.Query.cs index 858f219..13d2f50 100644 --- a/PanoramicData.OData.Client/ODataClient.Query.cs +++ b/PanoramicData.OData.Client/ODataClient.Query.cs @@ -469,6 +469,12 @@ private string GetEntitySetName() { var type = typeof(T); + var resolvedEntitySetName = _options.EntitySetNameResolver?.Invoke(type); + if (!string.IsNullOrWhiteSpace(resolvedEntitySetName)) + { + return resolvedEntitySetName; + } + // Respect [EntitySet("...")] attribute from Microsoft.OData.Client generated DTOs var entitySetAttr = type.GetCustomAttributes(false) .FirstOrDefault(a => a.GetType().Name == "EntitySetAttribute"); diff --git a/PanoramicData.OData.Client/ODataClientOptions.cs b/PanoramicData.OData.Client/ODataClientOptions.cs index 60196a1..e960295 100644 --- a/PanoramicData.OData.Client/ODataClientOptions.cs +++ b/PanoramicData.OData.Client/ODataClientOptions.cs @@ -87,6 +87,32 @@ public class ODataClientOptions /// public bool AutoPluralization { get; set; } = true; + /// + /// Gets or sets a function that resolves entity set names for the parameterless + /// For<T>() overload. + /// + /// + /// + /// The resolver is the first step in entity set name resolution. When it returns a non-empty + /// value that value is used verbatim, short-circuiting the [EntitySet] attribute lookup + /// and the convention. Return null, an empty string, or + /// whitespace to fall through to those existing conventions. + /// + /// + /// The function is invoked on every parameterless For<T>() call, so it should be + /// fast and free of side effects. The explicit For<T>("EntitySetName") overload + /// never invokes the resolver. A typical use is mapping a model type to an entity set via a + /// custom attribute: + /// + /// + /// + /// EntitySetNameResolver = type => + /// type.GetCustomAttribute<CollectionNameAttribute>()?.Name + /// + /// + /// + public Func? EntitySetNameResolver { get; set; } + /// /// Gets or sets a value indicating whether a 404 Not Found response should return null /// instead of throwing an . diff --git a/README.md b/README.md index cccf488..5fcd2ff 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,10 @@ var client = new ODataClient(new ODataClientOptions // Optional: Custom JSON serialization settings JsonSerializerOptions = customOptions, + + // Optional: Override entity set names used by For() + // Return null to use the existing attribute and pluralization conventions + EntitySetNameResolver = type => type == typeof(LegacyProduct) ? "legacy_products" : null, // Optional: Configure headers for every request ConfigureRequest = request =>