diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6bc8e..1c5f3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,208 +1,231 @@ -# Changelog - -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). - -## [vNext] - -### Changed -- Rename four builder parameters that duplicated their method name, so that they read - distinctly in IntelliSense: `Key(key)` to `Key(keyValue)`, `Filter(filter)` to - `Filter(filterExpression)`, `OrderBy(orderBy)` to `OrderBy(orderByExpression)` and - `QueryOptions(queryOptions)` to `QueryOptions(rawQueryOptions)`, on - `ODataQueryBuilder`, `FluentODataQueryBuilder`, `NestedExpandBuilder` and - `ODataCrossJoinBuilder`. Source-compatible except for callers passing these arguments - by name -- Share `ODataQueryBuilder`'s operator table and reflection cache across all closed - generic types instead of rebuilding them per entity type. They were `static` fields of a - generic type, so each `ODataQueryBuilder`, `ODataQueryBuilder` and so on - had its own copy and could never share a cache hit -- Rename `Run-Benchmarks.ps1`'s `-Profile` switch to `-EnableProfiling`, because `$Profile` - shadows a PowerShell automatic variable. `-Profile` still works, as an alias - -### Fixed -- Preserve the original stack trace when a request fails after its retries are exhausted - -## [10.0.109] - 2026-08-07 - -### Added -- Add `MaximumRetryAfterDelay` option to `ODataClientOptions` (default 30 seconds). A server-supplied `Retry-After` header is now honoured in preference to `RetryDelay`, bounded by this value so that a large or malformed header cannot stall the caller. Set to `TimeSpan.Zero` to ignore `Retry-After` entirely and always use `RetryDelay` - -### Fixed -- Retry HTTP 408 (Request Timeout) and 429 (Too Many Requests) alongside 5xx. Previously any status below 500 was returned to the caller immediately, so a 408 from an intervening proxy or a 429 from a rate limiter was never retried. Both are cases where the server rejected the request without processing it, so retrying is safe even for methods that are not idempotent. Other 4xx statuses remain non-retryable, in particular 409, which is a routine "already exists" outcome for callers that create-or-overwrite - -## [10.0.106] - 2026-07-08 - -### Fixed -- Fix `.OrderBy(p => p.Nav.Prop)` and `.NavigateTo(p => p.Nav.Prop)` resolving only the leaf property name (e.g. `$orderby=FirstName`) instead of the full navigation path (`$orderby=BestFriend/FirstName`) for nested (dotted) property selectors - -## [10.0.87] - 2026-06-12 - -### Added -- Add `RetryAttemptLogLevel` option to `ODataClientOptions` (default `Debug`) controlling the level at which individual failed attempts that will be retried are logged - set to `Warning` to log every attempt prominently, or `None` to disable per-attempt logging entirely -- Log a single `Warning` when all retries are exhausted (new EventIds 22 and 23), including the transient-exception path which previously logged nothing on the final failed attempt - -### Changed -- Individual failed attempts that will be retried are now logged at `Debug` by default instead of `Warning`, so transient failures that recover no longer flood consumer logs - -## [10.0.86] - 2026-06-03 - -### Fixed -- Fix non-nullable enum properties in LINQ filter expressions emitting integer values instead of quoted OData enum member names - -## [10.0.85] - 2026-06-03 - -### Fixed -- Render enum literals in LINQ filter expressions as quoted OData enum member names instead of underlying numeric values - -## [10.0.84] - 2026-05-21 - -### Fixed -- Remove duplicate `Content-Transfer-Encoding: binary` header in batch requests; `HttpMessageContent` constructor already adds it, so the extra call in `ODataClient.Batch.cs` was redundant - -### Tests -- Add `Batch_OperationContent_ShouldIncludeRequiredHeadersExactlyOnce` to assert batch part headers appear exactly once in the serialized multipart body -- Add `DateTimeKind.Utc` assertion to `Read_SimpleDateFormat_ParsesCorrectly` to make intent explicit and guard against future converter changes -- Add regression coverage for inline enum literals and captured enum variables in filter expressions - -## [10.0.83] - 2026-05-19 - -### Fixed -- Parse all `` elements in CSDL metadata documents; previously only the first schema was read, causing entity types and entity sets to be missing for services (e.g. Northwind) that split their definitions across multiple schemas - -## [10.0.82] - 2026-05-19 - -### Fixed -- Fix `"Invalid request URI"` when a provided `HttpClient` has no `BaseAddress` set - the client now automatically sets `BaseAddress` from `options.BaseUrl` if the provided `HttpClient` does not already have one - -## [10.0.81] - 2026-05-19 - -### Fixed -- Fix batch requests throwing `"This operation is not supported for a relative URI"` - batch operation URLs are relative by design; `HttpMessageContent` now resolves them against the client base URL before building the request line and `Host` header - -## [10.0.80] - 2026-05-19 - -### Fixed -- Fix `GetFirstOrDefaultAsync` and `GetSingleAsync` with a key set: now deserializes the response as a single JSON object instead of expecting a `{"value":[...]}` collection wrapper, matching the actual response shape of single-entity endpoints. Also no longer appends `$top` which is rejected by some APIs (e.g. Exchange Online) on single-entity URLs - -## [10.0.78] - 2026-05-19 - -### Added -- Add `AutoPluralization` option to `ODataClientOptions` (default `true`) - set to `false` to use type names as-is, avoiding automatic pluralization for APIs such as Exchange Online that use singular endpoint names (e.g. `Mailbox` instead of `Mailboxes`) -- Respect `[EntitySet("...")]` attribute on generated DTOs (e.g. from Microsoft.OData.Client tooling) when deriving entity set names in `For()`, without requiring a reference to `Microsoft.OData.Client` - -## [10.0.75] - 2026-05-19 - -### Added -- Add `FindEntriesAsync()` to `ODataQueryBuilder` as a Simple.OData.Client-compatible alias for `GetAllAsync()`, returning `IEnumerable` directly - enabling the `.NavigateTo(expr).As().FindEntriesAsync()` chain without needing `.Value` - -## [10.0.74] - 2026-05-19 - -### Added -- Add non-generic `NavigateTo(expr)` overload to `ODataQueryBuilder` returning `FluentODataQueryBuilder`, `As()` on `FluentODataQueryBuilder` for re-typing, and `FindEntriesAsync()` alias - enabling Simple.OData.Client-compatible NavigateTo/As/FindEntriesAsync chain -- Add `NavigateTo()` method to `ODataQueryBuilder` and `NavigateTo()` to `FluentODataQueryBuilder`, enabling navigation to dependent collections via EntitySet(key)/NavigationProperty URL paths - -## [10.0.72] - 2026-05-18 - -### Fixed -- Fix IgnoreResourceNotFoundException being ignored in fluent .For(...).Key(...).GetEntryAsync() - now returns null on 404 as expected - -## [10.0.71] - 2026-05-16 - -### Fixed -- Fix PATCH body serialization: `UpdateAsync` now passes the runtime type to `JsonContent.Create`, preventing `Dictionary` patch bodies from being serialized as empty `{}` objects -- Fix `ODataTypeAnnotationConverter` to exclude dictionary types (`Dictionary<,>`, `IDictionary` implementors) and `typeof(object)` from `@odata.type` annotation injection, preventing corrupt PATCH bodies that caused ASP.NET OData `Delta` model binding to return null and produce 400 "A PATCH request body is required" responses - -## [10.0.69] - 2026-04-11 - -### Added -- Add `QueryOptions(string)` method to `ODataQueryBuilder` and `FluentODataQueryBuilder` for verbatim vendor-specific query parameters (e.g. `PropertySet=Minimum,AddressList`) without quoting - -## [10.0.68] - 2026-05-01 - -### Added -- Add `GetByKeyOrDefaultAsync` method - always returns null on 404 without requiring `IgnoreResourceNotFoundException` option -- Add `IgnoreResourceNotFoundException` option to `ODataClientOptions` - returns null instead of throwing `ODataNotFoundException` on 404 responses - -## [10.0.67] - 2026-04-23 - -### Fixed -- Fix `ODataTypeAnnotationConverter` type detection to exclude OData framework types (including `Delta`), preventing ASP.NET Core OData PATCH `Delta` model binding from being intercepted - -## [10.0.66] - 2026-04-23 - -### Fixed -- Fix `ODataTypeAnnotationConverter` to identify and exclude `Delta` and other OData framework types, preventing `ArgumentNullException` when a `Delta` parameter is null in PATCH operations - -## [10.0.65] - 2026-04-22 - -### Added -- Add `ODataTypeAnnotationConverter` - automatically injects `@odata.type` into POST/PATCH bodies when serializing a derived type, enabling polymorphic `CreateAsync` calls against OData servers using Table-Per-Hierarchy (TPH) inheritance -- Add `ODataTypeAnnotationAttribute` - optional attribute to override the auto-derived type name (e.g. `TypeName = "#MyNamespace.Employee"`) or force annotation inclusion on non-polymorphic types (`AlwaysInclude = true`) - -## [10.0.60] - 2026-03-29 - -### Fixed -- Fix DateTime formatting consistency - FormatFunctionParameterValue and FormatArrayElementValue now respect DateTimeKind -- Fix DateTime filter formatting for Unspecified kind - now formats without Z suffix to match OData Edm.DateTime type, preventing timezone conversion errors -- Fix date-only string parsing to treat as UTC instead of local time, preventing timezone conversion errors - -## [10.0.46] - 2025-12-19 - -## [10.0.43] - 2025-12-17 - -### Added -- Add fluent execution methods (GetAsync, GetAllAsync, GetFirstOrDefaultAsync, GetSingleAsync, GetSingleOrDefaultAsync, GetCountAsync) directly on ODataQueryBuilder for streamlined query execution -- CODE_COVERAGE.md with comprehensive test coverage plan for full code coverage - -## [10.0.40-beta] - 2025-01-20 - -### Added -- NestedExpandBuilder for configuring nested expand options (select, expand, filter, orderby, top, skip) -- ExpandWithSelect method for expand with nested select syntax (fixes #4) -- Fluent batch API with clean method chaining (`CreateBatch().Get().Create().Delete().ExecuteAsync()`) -- `Changeset(Action)` pattern for atomic batch operations -- Index-based result access on `ODataBatchResponse` (`response[0]`, `response.GetResult(0)`) -- `HasErrors` property on `ODataBatchResponse` -- `TryGetResult(int index, out T? result)` for safe result access -- Nested expand expression support (`p => new { p.Parent, p.Parent!.Children }` produces `$expand=Parent($expand=Children)`) -- `Function()` and `Apply()` methods to `FluentODataQueryBuilder` for feature parity -- Changelog system with `Add-ChangelogEntry.ps1` script -- Automatic version replacement in `Publish.ps1` - -### Changed -- Split multi-type files into one type per file for better maintainability -- `ODataBatchBuilder` methods now return builder for fluent chaining (breaking change from string operation IDs) -- `ODataChangesetBuilder` methods now return builder for fluent chaining -- `CreateChangeset()` replaced with `Changeset(Action)` pattern - -### Removed -- Non-fluent batch API methods that returned operation IDs - -## [10.0.36-beta] - 2025-01-15 - -### Added -- Initial public beta release -- OData V4 query builder with LINQ expression support -- Full CRUD operations (Create, Read, Update, Delete) -- Batch request support with changesets -- Delta query support for change tracking -- Metadata parsing and caching -- Service document retrieval -- Singleton entity support -- Stream property support -- Entity reference management -- Cross-join queries -- Async long-running operation support -- Retry policies with configurable delays -- ETag-based optimistic concurrency -- Comprehensive logging via `ILogger` -- Fluent and typed query APIs - - - - - - +# Changelog + +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). + +## [vNext] + +### Added + +- Add `EntitySetNameResolver` option to `ODataClientOptions` for resolving entity set names in parameterless `For()` calls, with fallback to declared attributes and existing pluralization conventions. + + +### Changed + +- Rename four builder parameters that duplicated their method name, so that they read + + distinctly in IntelliSense: `Key(key)` to `Key(keyValue)`, `Filter(filter)` to + + `Filter(filterExpression)`, `OrderBy(orderBy)` to `OrderBy(orderByExpression)` and + + `QueryOptions(queryOptions)` to `QueryOptions(rawQueryOptions)`, on + + `ODataQueryBuilder`, `FluentODataQueryBuilder`, `NestedExpandBuilder` and + + `ODataCrossJoinBuilder`. Source-compatible except for callers passing these arguments + + by name + +- Share `ODataQueryBuilder`'s operator table and reflection cache across all closed + + generic types instead of rebuilding them per entity type. They were `static` fields of a + + generic type, so each `ODataQueryBuilder`, `ODataQueryBuilder` and so on + + had its own copy and could never share a cache hit + +- Rename `Run-Benchmarks.ps1`'s `-Profile` switch to `-EnableProfiling`, because `$Profile` + + shadows a PowerShell automatic variable. `-Profile` still works, as an alias + + + +### Fixed + +- Preserve the original stack trace when a request fails after its retries are exhausted + + + +## [10.0.109] - 2026-08-07 + +### Added +- Add `MaximumRetryAfterDelay` option to `ODataClientOptions` (default 30 seconds). A server-supplied `Retry-After` header is now honoured in preference to `RetryDelay`, bounded by this value so that a large or malformed header cannot stall the caller. Set to `TimeSpan.Zero` to ignore `Retry-After` entirely and always use `RetryDelay` + +### Fixed +- Retry HTTP 408 (Request Timeout) and 429 (Too Many Requests) alongside 5xx. Previously any status below 500 was returned to the caller immediately, so a 408 from an intervening proxy or a 429 from a rate limiter was never retried. Both are cases where the server rejected the request without processing it, so retrying is safe even for methods that are not idempotent. Other 4xx statuses remain non-retryable, in particular 409, which is a routine "already exists" outcome for callers that create-or-overwrite + +## [10.0.106] - 2026-07-08 + +### Fixed +- Fix `.OrderBy(p => p.Nav.Prop)` and `.NavigateTo(p => p.Nav.Prop)` resolving only the leaf property name (e.g. `$orderby=FirstName`) instead of the full navigation path (`$orderby=BestFriend/FirstName`) for nested (dotted) property selectors + +## [10.0.87] - 2026-06-12 + +### Added +- Add `RetryAttemptLogLevel` option to `ODataClientOptions` (default `Debug`) controlling the level at which individual failed attempts that will be retried are logged - set to `Warning` to log every attempt prominently, or `None` to disable per-attempt logging entirely +- Log a single `Warning` when all retries are exhausted (new EventIds 22 and 23), including the transient-exception path which previously logged nothing on the final failed attempt + +### Changed +- Individual failed attempts that will be retried are now logged at `Debug` by default instead of `Warning`, so transient failures that recover no longer flood consumer logs + +## [10.0.86] - 2026-06-03 + +### Fixed +- Fix non-nullable enum properties in LINQ filter expressions emitting integer values instead of quoted OData enum member names + +## [10.0.85] - 2026-06-03 + +### Fixed +- Render enum literals in LINQ filter expressions as quoted OData enum member names instead of underlying numeric values + +## [10.0.84] - 2026-05-21 + +### Fixed +- Remove duplicate `Content-Transfer-Encoding: binary` header in batch requests; `HttpMessageContent` constructor already adds it, so the extra call in `ODataClient.Batch.cs` was redundant + +### Tests +- Add `Batch_OperationContent_ShouldIncludeRequiredHeadersExactlyOnce` to assert batch part headers appear exactly once in the serialized multipart body +- Add `DateTimeKind.Utc` assertion to `Read_SimpleDateFormat_ParsesCorrectly` to make intent explicit and guard against future converter changes +- Add regression coverage for inline enum literals and captured enum variables in filter expressions + +## [10.0.83] - 2026-05-19 + +### Fixed +- Parse all `` elements in CSDL metadata documents; previously only the first schema was read, causing entity types and entity sets to be missing for services (e.g. Northwind) that split their definitions across multiple schemas + +## [10.0.82] - 2026-05-19 + +### Fixed +- Fix `"Invalid request URI"` when a provided `HttpClient` has no `BaseAddress` set - the client now automatically sets `BaseAddress` from `options.BaseUrl` if the provided `HttpClient` does not already have one + +## [10.0.81] - 2026-05-19 + +### Fixed +- Fix batch requests throwing `"This operation is not supported for a relative URI"` - batch operation URLs are relative by design; `HttpMessageContent` now resolves them against the client base URL before building the request line and `Host` header + +## [10.0.80] - 2026-05-19 + +### Fixed +- Fix `GetFirstOrDefaultAsync` and `GetSingleAsync` with a key set: now deserializes the response as a single JSON object instead of expecting a `{"value":[...]}` collection wrapper, matching the actual response shape of single-entity endpoints. Also no longer appends `$top` which is rejected by some APIs (e.g. Exchange Online) on single-entity URLs + +## [10.0.78] - 2026-05-19 + +### Added +- Add `AutoPluralization` option to `ODataClientOptions` (default `true`) - set to `false` to use type names as-is, avoiding automatic pluralization for APIs such as Exchange Online that use singular endpoint names (e.g. `Mailbox` instead of `Mailboxes`) +- Respect `[EntitySet("...")]` attribute on generated DTOs (e.g. from Microsoft.OData.Client tooling) when deriving entity set names in `For()`, without requiring a reference to `Microsoft.OData.Client` + +## [10.0.75] - 2026-05-19 + +### Added +- Add `FindEntriesAsync()` to `ODataQueryBuilder` as a Simple.OData.Client-compatible alias for `GetAllAsync()`, returning `IEnumerable` directly - enabling the `.NavigateTo(expr).As().FindEntriesAsync()` chain without needing `.Value` + +## [10.0.74] - 2026-05-19 + +### Added +- Add non-generic `NavigateTo(expr)` overload to `ODataQueryBuilder` returning `FluentODataQueryBuilder`, `As()` on `FluentODataQueryBuilder` for re-typing, and `FindEntriesAsync()` alias - enabling Simple.OData.Client-compatible NavigateTo/As/FindEntriesAsync chain +- Add `NavigateTo()` method to `ODataQueryBuilder` and `NavigateTo()` to `FluentODataQueryBuilder`, enabling navigation to dependent collections via EntitySet(key)/NavigationProperty URL paths + +## [10.0.72] - 2026-05-18 + +### Fixed +- Fix IgnoreResourceNotFoundException being ignored in fluent .For(...).Key(...).GetEntryAsync() - now returns null on 404 as expected + +## [10.0.71] - 2026-05-16 + +### Fixed +- Fix PATCH body serialization: `UpdateAsync` now passes the runtime type to `JsonContent.Create`, preventing `Dictionary` patch bodies from being serialized as empty `{}` objects +- Fix `ODataTypeAnnotationConverter` to exclude dictionary types (`Dictionary<,>`, `IDictionary` implementors) and `typeof(object)` from `@odata.type` annotation injection, preventing corrupt PATCH bodies that caused ASP.NET OData `Delta` model binding to return null and produce 400 "A PATCH request body is required" responses + +## [10.0.69] - 2026-04-11 + +### Added +- Add `QueryOptions(string)` method to `ODataQueryBuilder` and `FluentODataQueryBuilder` for verbatim vendor-specific query parameters (e.g. `PropertySet=Minimum,AddressList`) without quoting + +## [10.0.68] - 2026-05-01 + +### Added +- Add `GetByKeyOrDefaultAsync` method - always returns null on 404 without requiring `IgnoreResourceNotFoundException` option +- Add `IgnoreResourceNotFoundException` option to `ODataClientOptions` - returns null instead of throwing `ODataNotFoundException` on 404 responses + +## [10.0.67] - 2026-04-23 + +### Fixed +- Fix `ODataTypeAnnotationConverter` type detection to exclude OData framework types (including `Delta`), preventing ASP.NET Core OData PATCH `Delta` model binding from being intercepted + +## [10.0.66] - 2026-04-23 + +### Fixed +- Fix `ODataTypeAnnotationConverter` to identify and exclude `Delta` and other OData framework types, preventing `ArgumentNullException` when a `Delta` parameter is null in PATCH operations + +## [10.0.65] - 2026-04-22 + +### Added +- Add `ODataTypeAnnotationConverter` - automatically injects `@odata.type` into POST/PATCH bodies when serializing a derived type, enabling polymorphic `CreateAsync` calls against OData servers using Table-Per-Hierarchy (TPH) inheritance +- Add `ODataTypeAnnotationAttribute` - optional attribute to override the auto-derived type name (e.g. `TypeName = "#MyNamespace.Employee"`) or force annotation inclusion on non-polymorphic types (`AlwaysInclude = true`) + +## [10.0.60] - 2026-03-29 + +### Fixed +- Fix DateTime formatting consistency - FormatFunctionParameterValue and FormatArrayElementValue now respect DateTimeKind +- Fix DateTime filter formatting for Unspecified kind - now formats without Z suffix to match OData Edm.DateTime type, preventing timezone conversion errors +- Fix date-only string parsing to treat as UTC instead of local time, preventing timezone conversion errors + +## [10.0.46] - 2025-12-19 + +## [10.0.43] - 2025-12-17 + +### Added +- Add fluent execution methods (GetAsync, GetAllAsync, GetFirstOrDefaultAsync, GetSingleAsync, GetSingleOrDefaultAsync, GetCountAsync) directly on ODataQueryBuilder for streamlined query execution +- CODE_COVERAGE.md with comprehensive test coverage plan for full code coverage + +## [10.0.40-beta] - 2025-01-20 + +### Added +- NestedExpandBuilder for configuring nested expand options (select, expand, filter, orderby, top, skip) +- ExpandWithSelect method for expand with nested select syntax (fixes #4) +- Fluent batch API with clean method chaining (`CreateBatch().Get().Create().Delete().ExecuteAsync()`) +- `Changeset(Action)` pattern for atomic batch operations +- Index-based result access on `ODataBatchResponse` (`response[0]`, `response.GetResult(0)`) +- `HasErrors` property on `ODataBatchResponse` +- `TryGetResult(int index, out T? result)` for safe result access +- Nested expand expression support (`p => new { p.Parent, p.Parent!.Children }` produces `$expand=Parent($expand=Children)`) +- `Function()` and `Apply()` methods to `FluentODataQueryBuilder` for feature parity +- Changelog system with `Add-ChangelogEntry.ps1` script +- Automatic version replacement in `Publish.ps1` + +### Changed +- Split multi-type files into one type per file for better maintainability +- `ODataBatchBuilder` methods now return builder for fluent chaining (breaking change from string operation IDs) +- `ODataChangesetBuilder` methods now return builder for fluent chaining +- `CreateChangeset()` replaced with `Changeset(Action)` pattern + +### Removed +- Non-fluent batch API methods that returned operation IDs + +## [10.0.36-beta] - 2025-01-15 + +### Added +- Initial public beta release +- OData V4 query builder with LINQ expression support +- Full CRUD operations (Create, Read, Update, Delete) +- Batch request support with changesets +- Delta query support for change tracking +- Metadata parsing and caching +- Service document retrieval +- Singleton entity support +- Stream property support +- Entity reference management +- Cross-join queries +- Async long-running operation support +- Retry policies with configurable delays +- ETag-based optimistic concurrency +- Comprehensive logging via `ILogger` +- Fluent and typed query APIs + + + + + + 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 a3cc018..51c42a8 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 how ODataClient resolves entity set names and navigation paths. /// @@ -88,6 +103,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 4c5eac9..9c327e4 100644 --- a/PanoramicData.OData.Client/ODataClient.Query.cs +++ b/PanoramicData.OData.Client/ODataClient.Query.cs @@ -479,31 +479,27 @@ private string GetEntitySetName() { var type = typeof(T); - return TryGetDeclaredEntitySetName(type) - ?? (_options.AutoPluralization ? Pluralize(type.Name) : type.Name); - } + var resolvedEntitySetName = _options.EntitySetNameResolver?.Invoke(type); + if (!string.IsNullOrWhiteSpace(resolvedEntitySetName)) + { + return resolvedEntitySetName; + } - /// - /// Reads the entity set name off an [EntitySet("...")] attribute, as emitted by - /// Microsoft.OData.Client generated DTOs. Matched by name rather than by type, so the - /// attribute's assembly does not have to be referenced. - /// - /// The declared name, or if the type does not declare one. - private static string? TryGetDeclaredEntitySetName(Type type) - { + // Respect [EntitySet("...")] attribute from Microsoft.OData.Client generated DTOs var entitySetAttr = type.GetCustomAttributes(false) .FirstOrDefault(a => a.GetType().Name == "EntitySetAttribute"); - if (entitySetAttr is null) + if (entitySetAttr is not null) { - return null; - } + var prop = entitySetAttr.GetType().GetProperty("EntitySet") + ?? entitySetAttr.GetType().GetProperty("Name"); - var prop = entitySetAttr.GetType().GetProperty("EntitySet") - ?? entitySetAttr.GetType().GetProperty("Name"); + if (prop?.GetValue(entitySetAttr) is string entitySetName && !string.IsNullOrWhiteSpace(entitySetName)) + { + return entitySetName; + } + } - return prop?.GetValue(entitySetAttr) is string entitySetName && !string.IsNullOrWhiteSpace(entitySetName) - ? entitySetName - : null; + return _options.AutoPluralization ? Pluralize(type.Name) : type.Name; } /// diff --git a/PanoramicData.OData.Client/ODataClientOptions.cs b/PanoramicData.OData.Client/ODataClientOptions.cs index 7e2ffa8..05e38df 100644 --- a/PanoramicData.OData.Client/ODataClientOptions.cs +++ b/PanoramicData.OData.Client/ODataClientOptions.cs @@ -105,6 +105,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 533b9bf..9ac4781 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,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 =>