Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type, string?>` that resolves entity set names for the parameterless `For<T>()` 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<T>("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
Expand Down
44 changes: 44 additions & 0 deletions Documentation/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -43,6 +44,49 @@ foreach (var product in response.Value)
}
```

## Entity Set Name Resolution

The parameterless `For<T>()` 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<T>()` 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<CollectionNameAttribute>()?.Name
});

var query = client.For<Product>(); // service_products
```

Passing an entity set name explicitly always takes precedence and does not invoke the resolver:

```csharp
var query = client.For<Product>("products_override");
```

## Fluent Query Execution

Execute queries directly from the query builder for streamlined code:
Expand Down
192 changes: 192 additions & 0 deletions PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ internal sealed class EntitySetAttribute(string entitySet) : Attribute
[EntitySet("Mailbox")]
internal sealed class GeneratedMailbox { }

internal sealed class CustomResolvedEntity { }

/// <summary>
/// Mirrors an application-defined entity-set attribute (as used by the Integration Team OData
/// extensions) to exercise attribute-based <see cref="ODataClientOptions.EntitySetNameResolver"/> wiring.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
internal sealed class CollectionNameAttribute(string name) : Attribute
{
public string Name { get; } = name;
}

[CollectionName("service_products")]
internal sealed class AttributeResolvedEntity { }

/// <summary>
/// Unit tests for ODataClient query operations.
/// </summary>
Expand Down Expand Up @@ -119,6 +134,183 @@ public void For_EntitySetAttribute_OverridesPluralization()
url.Should().Be("Mailbox");
}

/// <summary>
/// Tests that the configured entity set name resolver overrides the built-in conventions.
/// </summary>
[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<CustomResolvedEntity>().BuildUrl();

url.Should().Be("custom_entities");
}

/// <summary>
/// Tests that the configured entity set name resolver takes precedence over the entity set attribute.
/// </summary>
[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<GeneratedMailbox>().BuildUrl();

url.Should().Be("custom_mailboxes");
}

/// <summary>
/// Tests that a whitespace resolver result uses the existing entity set attribute convention.
/// </summary>
[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<GeneratedMailbox>().BuildUrl();

url.Should().Be("Mailbox");
}

/// <summary>
/// Tests that a null resolver result uses the existing pluralization convention.
/// </summary>
[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<Product>().BuildUrl();

url.Should().Be("Products");
}

/// <summary>
/// Tests that an explicit entity set name does not invoke the configured resolver.
/// </summary>
[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<Product>("CustomProducts").BuildUrl();

url.Should().Be("CustomProducts");
}

/// <summary>
/// Tests that an empty resolver result falls back to the existing pluralization convention.
/// </summary>
[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<Product>().BuildUrl();

url.Should().Be("Products");
}

/// <summary>
/// Tests that the resolver is invoked with the requested entity type.
/// </summary>
[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<Product>().BuildUrl();

observedType.Should().Be<Product>();
}

/// <summary>
/// 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.
/// </summary>
[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<CollectionNameAttribute>()
.FirstOrDefault()?.Name
});

var url = client.For<AttributeResolvedEntity>().BuildUrl();

url.Should().Be("service_products");
}

/// <summary>
/// Tests that AutoPluralization = false uses the type name as-is.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions PanoramicData.OData.Client/ODataClient.Query.cs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,12 @@ private string GetEntitySetName<T>()
{
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");
Expand Down
26 changes: 26 additions & 0 deletions PanoramicData.OData.Client/ODataClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ public class ODataClientOptions
/// </remarks>
public bool AutoPluralization { get; set; } = true;

/// <summary>
/// Gets or sets a function that resolves entity set names for the parameterless
/// <c>For&lt;T&gt;()</c> overload.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>[EntitySet]</c> attribute lookup
/// and the <see cref="AutoPluralization"/> convention. Return <c>null</c>, an empty string, or
/// whitespace to fall through to those existing conventions.
/// </para>
/// <para>
/// The function is invoked on every parameterless <c>For&lt;T&gt;()</c> call, so it should be
/// fast and free of side effects. The explicit <c>For&lt;T&gt;("EntitySetName")</c> overload
/// never invokes the resolver. A typical use is mapping a model type to an entity set via a
/// custom attribute:
/// </para>
/// <example>
/// <code>
/// EntitySetNameResolver = type =>
/// type.GetCustomAttribute&lt;CollectionNameAttribute&gt;()?.Name
/// </code>
/// </example>
/// </remarks>
public Func<Type, string?>? EntitySetNameResolver { get; set; }

/// <summary>
/// Gets or sets a value indicating whether a 404 Not Found response should return <c>null</c>
/// instead of throwing an <see cref="ODataNotFoundException"/>.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()
// 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 =>
Expand Down