August 22nd, 2025
0 reactions

OData .NET (ODL) 9 Preview Release

John Gathogo
Senior Software Engineer
We’re happy to announce that OData .NET (ODL) 9 Preview 2 has been officially released and is available on NuGet:

What’s New in OData .NET (ODL) 9 Preview 1 & 2

The OData .NET (ODL) 9.0 previews introduce a set of important changes and cleanup efforts to modernize the library, align with .NET’s evolving runtime, and simplify the OData developer experience. Below, we’ll go through the notable changes included in the first two preview releases.

Targeting .NET 10 Preview

ODL now builds against .NET 10 preview, ensuring compatibility with the upcoming runtime. This helps early adopters validate OData workloads on the latest platform.

Removed JSONP Support

JSONP (JSON with Padding) was an early workaround for browsers’ same-origin policy before CORS became widely adopted. It allowed a client to request JSON data from another domain by wrapping the payload inside a JavaScript function call.

For example, consider a normal OData JSON response:

{
  "@odata.context": "http://localhost/$metadata#Orders",
  "value": [
    { "Id": 1, "Status": "Pending" },
    { "Id": 2, "Status": "Shipped" }
  ]
}

With JSONP enabled and a callback function defined, the same response could be returned as:

processOrders({
  "@odata.context": "http://localhost/$metadata#Orders",
  "value": [
    { "Id": 1, "Status": "Pending" },
    { "Id": 2, "Status": "Shipped" }
  ]
});

This allowed the client’s browser to execute the function processOrders with the JSON payload as its argument, bypassing cross-domain restrictions.

Why It Was Removed

  • Security concerns: JSONP allows arbitrary JavaScript execution, which can be exploited in XSS-style attacks.
  • Superseded by CORS: Modern browsers and frameworks natively support Cross-Origin Resource Sharing (CORS), which provides a secure and standards-based way to enable cross-domain requests.
  • Low usage: Very few ODL consumers relied on JSONP in recent years.

By removing JSONP support, ODL 9 reduces attack surface and simplifies the codebase, without impacting the majority of modern OData clients. Developers needing cross-domain access should instead configure CORS, which is the modern, secure alternative.

Case-Insensitive URI Resolver by Default

Previously, consumers had to explicitly enable case-insensitive handling for query options like $filter, $select, etc.

According to the OData V4.01 specification, services MUST support case-insensitive system query option names, whether or not they are prefixed with $.

ASP.NET Core OData already complies with this requirement by injecting a case-insensitive UnqualifiedODataUriResolver by default. To ensure consistent behavior across the OData ecosystem, ODL now defaults EnableCaseInsensitive = true when creating a new instance of ODataUriResolver: ODataUriResolver.cs

private static readonly ODataUriResolver Default = new ODataUriResolver(); // EnableCaseInsensitive is now true by default

Example

With this change, query option names are parsed equivalently regardless of casing. For instance, both of the following requests now succeed and are interpreted the same way:

GET https://localhost:54001/odata/Books?$expand=Authors
GET https://localhost:54001/odata/Books?$expand=authors

Benefits

  • Spec compliance: Matches the OData V4.01 requirement.
  • Consistency: Aligns ODL behavior with ASP.NET Core OData defaults.
  • Developer experience: Eliminates common case-sensitivity pitfalls in query construction.

Improved CSDL Type Name Validation

ODL now performs stricter validation of type names when parsing CSDL documents. This ensures that only valid type names are accepted and that clear, consistent error messages are produced when invalid cases are encountered.

The updated logic rejects malformed or unsupported type names, closing gaps where previously invalid inputs could slip through silently.

What Changed

Validation checks have been enhanced to catch issues such as:

  • Unmatched or misplaced parentheses
    • ns.myType)
    • Collection(ns.myType
    • (ns.myType
  • Unsupported type prefixes
    • Enumerable(ns.myType)
  • Invalid suffixes or extra characters
    • Collection(ns.myType)s
    • Collection)ns.myType()
  • Empty or whitespace-only type names
  • Invalid use of Collection or Ref
    • Collection()
    • Ref
    • Ref()

Why It Matters

  • Correctness: Prevents invalid EDM models from being accepted, which could otherwise cause subtle runtime issues.
  • Clarity: Developers get clear, actionable error messages instead of confusing parsing behavior.
  • Interoperability: Ensures models parsed by ODL conform more strictly to OData CSDL standards, improving tooling and ecosystem alignment.

Removed Deprecated Flags and Properties

KeyComparisonGeneratesFilterQuery

In ODL 9 we removed the DataServiceContext.KeyComparisonGeneratesFilterQuery flag entirely. The OData .NET Client now always translates a LINQ Where that compares key properties into a $filter query, and it reserves the key segment form for explicit ByKey(...) calls.

What Changed

Historically, if you had the expression:

// Single-key entity
DataServiceContextInstance.Customers.Where(c => c.Id == 13)

where Id is the key property, the client would emit a key segment URL:

/Customers(13)

instead of the natural filter form:

/Customers?$filter=Id eq 13

This would happen so long as the properties referenced in the Where expression predicate comprised of only key properties.

Why This Matters

The inconsistent behavior meant that a simple Where could silently depend on a “get-by-key” executable endpoint on the server (e.g., Get(int key)), while in other cases it wouldn’t — an inconsistency that surprised developers and produced hard-to-diagnose bugs.

This overlap created confusion because Where is naturally a filtering operation and should translate to $filter.

The canonical way to express “get-by-key” semantics has always been through the dedicated ByKey API.

// ByKey semantics require a dedicated get-by-key endpoint
[EnableQuery]
public ActionResult<Customer> Get(int key) { ... }

// Whereas $filter works fine with a queryable Get endpoint
[EnableQuery]
public ActionResult<IQueryable<Customer>> Get() { ... }

By enforcing the distinction, ODL avoids subtle bugs and makes it explicit when a key-based endpoint is required. Developers upgrading from 8.x should review their LINQ queries to ensure that any intended key lookups use ByKey explicitly.

Deprecation Path
  • ODL 7.x: Introduced the KeyComparisonGeneratesFilterQuery flag (default: false) to avoid an immediate breaking change.
  • ODL 8.x: Changed the default to true, so Where expressions would generate $filter unless explicitly toggled back.
  • ODL 9.x: Removed the flag entirely. Developers must now use ByKey for key lookups, and Where will consistently generate $filter.
Benefits
  • Clarity: Developers know exactly when they’re generating ByKey versus $filter queries.
  • Consistency: Eliminates situations where the same LINQ query might require different service endpoints.
  • Correctness: Prevents hidden coupling between query translation and service controller design.

Timeout Property

The obsolete DataServiceContext.Timeout property has been fully removed. As recommended since 8.x, configure timeouts on your HttpClient via IHttpClientFactory and related handlers, rather than on the OData client surface. This keeps HTTP concerns in the HTTP layer and aligns with modern .NET guidance and our 8.x deprecation notes

Reduced Redundant Payload Annotations

When generating payloads, the OData client would serialize @odata.type annotations even for declared properties and non-derived instances. While technically valid, these annotations inflated payload sizes and added noise without providing additional value.

Example

Consider an Order entity with declared properties like Status, Tags, and ShippingAddress. Previously, serializing such an entity in OData client produced redundant annotations:

{
  "@odata.type": "#Ns.Models.Order",
  "Id": 1,
  "Status@odata.type": "#Ns.Models.OrderStatus",
  "Status": "Pending",
  "Tags@odata.type": "#Collection(String)",
  "Tags": ["Urgent"],
  "ShippingAddress": {
    "@odata.type": "#Ex340.Models.Address",
    "Street": "123 Main St"
  }
}

Here, the annotations are unnecessary because:

  • Status is a declared enum property.
  • Tags is a declared collection of strings.
  • ShippingAddress is a declared complex type.

What Changed

With ODL 9, these redundant @odata.type annotations are omitted unless:

  • The instance is of a derived type, or
  • The property is dynamic (not declared in metadata).

The same payload is now serialized much more concisely:

{
  "Id": 1,
  "Status": "Pending",
  "Tags": ["Urgent"],
  "ShippingAddress": {
    "Street": "123 Main St"
  }
}

Benefits

  • Smaller payloads: Reduced bandwidth usage and faster responses.
  • Cleaner JSON: Easier to read, debug, and consume.
  • Correctness preserved: Annotations still appear when needed for derived or dynamic values.

Removed Support for Nullable Generic Key Types

In earlier versions of the OData client, there was a bug that allowed nullable properties to be designated as key properties in an entity type definition. For example:

public class Person
{
    public int? PersonId { get; set; }  // Nullable key (incorrectly allowed)
    public string Name { get; set; }
}

Why This Was a Problem

  • Violation of the OData spec: Entity keys must always be non-nullable. A null key would make the identity of the entity undefined.
  • Potential runtime errors: Nullable keys could lead to invalid URIs or unpredictable server behavior.
  • Silent inconsistency: Since OData client didn’t enforce correctness, developers could model data in ways that would later fail when interacting with an OData service.

Why the Fix Landed in ODL 9

Correcting this in a minor version would have been a breaking change, since previously accepted models would suddenly fail validation. To avoid disrupting existing consumers in patch or minor releases, the fix was deferred until ODL 9, where breaking changes are expected.

Benefits

  • Correctness: Ensures entity keys comply with the OData specification.
  • Predictability: Eliminates undefined edge cases caused by nullable keys.
  • Clarity: Developers now get clear validation errors if they attempt to use a nullable key property.

Custom URI Extensions Registered per EDM Model

Another significant change in ODL 9 is how custom URI functions, literal prefixes, and literal parsers are registered. Previously, these were registered globally using static helpers:

CustomUriLiteralPrefixes.AddCustomLiteralPrefix("temp", EdmCoreModel.Instance.GetDouble(false));
CustomUriLiteralParsers.AddCustomUriLiteralParser(EdmCoreModel.Instance.GetDouble(false), new TemperatureUriLiteralParser());
CustomUriFunctions.AddCustomUriFunction(
    "tofahrenheit",
    new FunctionSignatureWithReturnType(
        EdmCoreModel.Instance.GetDouble(false),
        EdmCoreModel.Instance.GetDouble(false)));

This global registration caused problems for multi-model scenarios, where different EDM models might require different sets of extensions.

What Changed

In ODL 9, these are now registered per EDM model instead of globally. This improves isolation, avoids unintended conflicts, and gives fine-grained control.

For example:

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Weather>("WeatherForecast");
var model = modelBuilder.GetEdmModel();
// Register extensions per model
model.AddCustomLiteralPrefix("temp", EdmCoreModel.Instance.GetDouble(false));
model.AddCustomUriLiteralParser(EdmCoreModel.Instance.GetDouble(false), new TemperatureUriLiteralParser());
model.AddCustomUriFunction(
    "tofahrenheit",
    new FunctionSignatureWithReturnType(
        EdmCoreModel.Instance.GetDouble(false),
        EdmCoreModel.Instance.GetDouble(false)));
  • Custom URI Functions: Extend OData query semantics by adding custom functions usable in $filter, $orderby, etc. (e.g., converting Celsius to Fahrenheit).
  • Custom URI Literal Prefixes: Allow introducing new literal formats (e.g., temp'79.7:Fahrenheit').
  • Custom URI Literal Parsers: Implement logic to parse and interpret these custom literals into CLR values.

Example in Action

Custom URI function usage:

GET http://localhost/WeatherForecast?$filter=tofahrenheit(Temperature) gt 70

Custom URI literal usage:

GET http://localhost/WeatherForecast?$filter=Temperature eq temp'79.7:Fahrenheit'

Benefits

  • Isolation: Different models can have different custom functions and literals without clashing.
  • Flexibility: Enables richer, domain-specific query expressions.
  • Cleaner design: No more static/global state; behavior is tied to the model where it belongs.

Summary

The ODL 9 previews bring meaningful updates:

  • Modern runtime support (targeting .NET 10).
  • Removal of outdated features (JSONP, deprecated properties/flags).
  • Usability improvements (case-insensitive resolver by default, reduced payload noise).
  • More robust model-specific approach to custom URI functions, prefixes, and parsers.

These changes pave the way for a cleaner, more predictable OData experience moving forward. In future updates, we’ll provide deeper examples and migration guidance for developers upgrading to ODL 9.

Try the ODL 9 preview packages on NuGet and share feedback on GitHub repo.

Category
OData

Author

John Gathogo
Senior Software Engineer

0 comments