Skip to content

RESTier vNext: Modernize to ASP.NET Core, OData 8.x, and .NET 8/9/10#776

Draft
jspuij wants to merge 512 commits into
OData:mainfrom
jspuij:feature/vnext
Draft

RESTier vNext: Modernize to ASP.NET Core, OData 8.x, and .NET 8/9/10#776
jspuij wants to merge 512 commits into
OData:mainfrom
jspuij:feature/vnext

Conversation

@jspuij
Copy link
Copy Markdown
Contributor

@jspuij jspuij commented Apr 19, 2026

Summary

This PR is the cumulative result of the RESTier vNext effort — a ground-up modernization of the framework to align with current .NET, OData, and ASP.NET Core ecosystems. It spans 521 changed files across architecture, platform support, testing infrastructure, documentation, and samples.

Platform & dependency upgrades

  • Target frameworks: .NET 8.0, .NET 9.0, and .NET 10.0 (drops .NET Framework 4.x and legacy .NET Core)
  • OData stack: Microsoft.OData.Core/Edm 8.x, Microsoft.OData.ModelBuilder 2.x, Microsoft.AspNetCore.OData 9.x
  • Entity Framework: EF Core 8.x–10.x multi-targeted; EF6 retained for backwards compatibility
  • Test framework: Migrated entirely from MSTest to xUnit v3 with FluentAssertions and NSubstitute
  • Package versions constrained with upper bounds to prevent accidental major-version drift

Architecture changes

Removed legacy ASP.NET (System.Web) support

The Microsoft.Restier.AspNet project and its shared project (AspNet.Shared) have been removed. RESTier is now exclusively an ASP.NET Core framework.

New dynamic routing system

Replaced the 8-file template-based OData routing convention system with a single RestierRouteValueTransformer that uses ASP.NET Core's DynamicRouteValueTransformer for dynamic OData path parsing. A new MapRestier() endpoint route builder extension provides the public API, with RestierRouteMarker as a sentinel service for route identification.

Redesigned DI and initialization API

  • New AddRestier() / MapRestier() registration surface using Microsoft.Extensions.DependencyInjection
  • Chain of Responsibility pipeline services wired via IChainedService<T> with automatic Inner property injection
  • Per-route service containers preserved but registration simplified

Relocated model building

Model builders (RestierWebApiModelBuilder, RestierWebApiModelExtender, RestierWebApiOperationModelBuilder, RestierWebApiModelMapper) moved from the removed shared project into Microsoft.Restier.AspNetCore under Model/ApiExtension/.

Swagger / OpenAPI rewrite

Ported Microsoft.Restier.AspNetCore.Swagger from the legacy Swashbuckle provider model to ASP.NET Core's built-in OpenAPI middleware (RestierOpenApiDocumentGenerator + RestierOpenApiMiddleware), compatible with Swashbuckle 10.x.

Bug fixes

  • Routing: Normalize PathBase in BuildBaseAddress to prevent double-slash URLs
  • Protocol compliance: Reject non-GET requests on $metadata and service document endpoints; include PathBase in base address
  • Query: Fix $count combined with $select/$expand; implement FilterSegment handler in RestierQueryBuilder; work around OData v9 $expand/$select incompatibility with EF6
  • Deserialization: Fix deserializer guard for non-entity payloads
  • Batch: Re-enable OData batch support; fix test ordering flakiness with collection attributes
  • Authorization: Fix ODataPath IList cast in GetPathKeyValues
  • Breakdance: Work around TestSetup infinite recursion bug in Breakdance 8.0
  • Cherry-picked all bug fixes from the Restier 1.2 RTM release on main

New features

  • DateOnly/TimeOnly support: Full type mapping pipeline support including TimeOnly for EFCore TimeOfDay converter and provider-specific metadata baselines
  • $filter path segment: RestierQueryBuilder now handles $filter as a path segment (OData 4.01)
  • PostgreSQL sample: New Microsoft.Restier.Samples.Postgres.AspNetCore project demonstrating EF Core + Npgsql with migrations and seed data
  • Naming conventions (camelCase): Opt-in lower camelCase JSON property naming via RestierNamingConvention parameter on AddRestierRoute. Three modes: PascalCase (default), LowerCamelCase (properties only), and LowerCamelCaseWithEnumMembers (properties + enum members). Implemented end-to-end across model building, serialization, deserialization (RestierResourceDeserializer), query options, ETag/concurrency handling (NormalizePropertyNames), and enum parsing. Property name mapping handled by new EdmClrPropertyMapper utility. Per-route configuration allows different naming conventions on different API routes.

Testing infrastructure overhaul

  • All test projects moved from src/ to test/ directory
  • Removed legacy and obsolete test projects (Tests.Legacy, Tests.Breakdance, Tests.AspNet, Tests.AspNetCorePlusEF6)
  • Created shared test infrastructure: Tests.Shared, Tests.Shared.EntityFramework, Tests.Shared.EntityFrameworkCore
  • Dual EF6/EFCore testing: Feature, metadata, and regression tests refactored to run against both EF6 and EF Core using shared scenario files and test helpers
  • SQL Server required for tests: In-memory database fallbacks removed; tests require SQL Server connection strings configured via dotnet user-secrets. Thread-safe database seeding prevents race conditions in parallel test runs.
  • Naming convention integration tests: 14 tests covering GET (with $select, $filter, $expand, $orderby), POST, PATCH, PUT, DELETE, ETag concurrency, and enum handling for both LowerCamelCase and LowerCamelCaseWithEnumMembers modes
  • InternalsVisibleTo auto-configured from source to matching test project

Documentation

Complete rewrite of the docs/msdocs/ documentation to reflect the vNext API:

  • Getting Started guide: Full ASP.NET Core + EF Core walkthrough
  • Interceptors, filters, authorization, model building: All rewritten with current API patterns
  • New pages: Operations (actions/functions), Swagger/OpenAPI, Breakdance testing framework, Naming Conventions (camelCase configuration with examples)
  • Contribution guidelines: Updated with current tooling and test conventions
  • Removed empty placeholder files and outdated content

Build & project structure

  • Solution file migrated to .slnx format (RESTier.slnx)
  • Directory.Build.props and .editorconfig moved from src/ to repository root
  • Strong name signing key (restier.snk) moved to repository root
  • Removed obsolete conditional compilation directives
  • Warnings-as-errors enabled globally; implicit usings disabled

Test plan

  • dotnet build RESTier.slnx succeeds on all target frameworks (net8.0, net9.0, net10.0)
  • dotnet test RESTier.slnx — all tests pass (xUnit v3)
  • EF6 integration tests pass against SQL Server
  • EF Core integration tests pass against SQL Server (connection strings via user-secrets)
  • Naming convention tests pass for LowerCamelCase and LowerCamelCaseWithEnumMembers modes
  • Northwind sample starts and serves OData endpoints
  • PostgreSQL sample starts with migrations and seed data (requires Postgres connection)
  • Swagger UI renders at configured endpoint in sample projects
  • OData batch requests work end-to-end
  • $metadata, service document, $filter path segment all resolve correctly

@jspuij jspuij mentioned this pull request Apr 19, 2026
@jspuij
Copy link
Copy Markdown
Contributor Author

jspuij commented Apr 19, 2026

To start the conversation. Some opinionated choices have been made regarding the service registration and the new constructor signature for ApiBase which both eliminates the ServiceLocator anti-pattern and provides better extensibility. Service chaining has been decoupled from the underlying DI container and does not use reflection anymore (but an interface) to accomplish the chaining.

The branch has switched to xUnit and FluentAssertions to better align with the other OData projects. BreakDance is still being used to setup the tests and generate API surfaces.
In addition to the old integration tests, the entire project has complete coverage with unit tests now. During generation of the tests I found several bugs that I've fixed in the mean time. They are probably scattered in the 100 commits.

Full OData.NET and AspNetCoreOdata support. dotnet 8, 9 and 10, although we are still waiting on the RTM versions of OData.NET and AspNetCoreOdata for dotnet 10, so it's forward compatibility for now.

I might add some other fixes here and there for issues to this branch, while it's being reviewed. Full disclosure: The first part of this migration was done by hand with a little help of copilot. The last month I've been using Claude Code extensively.

@robertmclaws shall we work together on this? What do you think.

@gathogojr I see that you're involved now as well. Let's discuss. I've done previous upgrades of RESTier as well.

@robertmclaws
Copy link
Copy Markdown
Collaborator

@jspuij I just dropped you an email. Let's catch up this week and talk about it.

Jan-Willem Spuij and others added 19 commits May 1, 2026 10:54
Wires the new package into DotNetDocs SDK's source-project list so
api-reference MDX gets auto-generated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recommended OpenAPI page covering AddRestierNSwag, UseRestierOpenApi /
UseRestierReDoc / UseRestierNSwagUI, the OpenApiConvertSettings
configurator, multi-route discovery, and the combined-with-plain-MVC
scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n the dropdown

Previously the Swagger UI dropdown listed Restier routes only.
User-registered AddOpenApiDocument(...) docs were served at NSwag's
default /swagger/{name}/swagger.json path but did not appear in the UI.
This fix discovers all NSwag-registered documents at pipeline-config
time and appends them to the same SwaggerRoutes collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the brainstormed design for issue OData#660: a chained IModelBuilder
that maps standard .NET attributes to OData vocabulary annotations,
flowing through to OpenAPI/Swagger output. Includes server-behavior
implications (Computed/Immutable already drive the submit pipeline) and
the integration tests that guard them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 TDD tasks covering the convention-based annotation builder, unit and
integration tests (including submit-pipeline behavior assertions), and
documentation updates with the new openapi-annotations.mdx page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hain

Plumbing for issue OData#660. The builder is a no-op pass-through; subsequent
commits add scanner logic per attribute family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First scanner for the convention-based annotation builder. Establishes
the fixtures + StaticInnerBuilder helper used by the rest of the unit
test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the chain-preserving pattern from RestierWebApiModelBuilder so a
custom inner builder returning an IEdmModel that is not an EdmModel
flows through unchanged rather than collapsing the chain to null.
Tightens an unused pattern variable, documents StubApi runtime
constraint, and prunes unused usings in the test file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an explicit test for complex-type description annotation. The
existing scanner already handles this because complex types implement
IEdmStructuredType; this test guards that invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the operation-method index built at constructor time, mirroring
RestierWebApiOperationModelBuilder.ScanForOperations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maps DatabaseGeneratedOption.Identity and DatabaseGeneratedOption.Computed
to Core.V1.Computed = true. None is skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maps RangeAttribute to Org.OData.Validation.V1.Minimum/Maximum, picking
the constant expression type to match the target property's primitive
kind (integer/floating/decimal). Non-numeric properties are logged and
skipped rather than emitting a malformed annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Asserts that pre-existing annotations are preserved, that null inputs
return null cleanly, that the constructor rejects null apiType, and
that [MaxLength] does not produce a vocabulary annotation (the
structural facet handles it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Jan-Willem Spuij and others added 4 commits May 19, 2026 16:12
Make explicit that the toggle also turns the /$count variant into a
404 instead of 200 OK { 0 } when the parent doesn't exist.
Mirrors the EFCore NoTrackingTests Breakdance suite onto the EF6 provider.
Now feasible because the SQL Server fixture force-drop fix in 10d5d7a
unblocked EF6 HTTP-level tests.

Covers:
* EF6 Default: GET applies AsNoTracking, surfaced in the executor's
  IQueryable expression as ObjectQuery.MergeAs(NoTracking). The substring
  "NoTracking" is the robust marker because EF6 lowers AsNoTracking into
  MergeAs rather than leaving a literal AsNoTracking method-call in the
  tree (no AsNoTrackingWithIdentityResolution equivalent on EF6).
* EF6 TrackAll: GET leaves the DbSet bare (no NoTracking marker).
* EF6 NoTracking explicit: same as Default.
* EF6 Default + recursive $expand: falls back to tracked. Uses the cross-
  type cycle Publisher -> Book -> Publisher which the IExpandCycleDetector
  flags via QueryRequest.HasRecursiveExpand. Rooted from /Publishers
  (not /Books) to sidestep an unrelated NRE in the client-projection path
  for the seeded orphan "Sea of Rust" book whose Publisher nav is null.

Refs: OData#726
SelectExpandHelper.ExecuteCoreAsync runs the OData-generated SelectExpand
projection lambda in-memory as a workaround for the AspNetCoreOData OData#367
IEdmModel-constant bug. That lambda is generated SQL-style (no null
guards) because EF6's legacy LINQ provider relies on LEFT JOIN + SQL
null propagation. When executed in-memory the C# null semantics throw:
e.g. /Books?$expand=Publisher($expand=Books) NREs on the seed's orphan
"Sea of Rust" because book.Publisher.Books dereferences a null Publisher.

Add a NullSafeMemberAccessRewriter (ExpressionVisitor) that wraps member
access and instance/extension method calls on potentially-null reference
receivers with "receiver == null ? default : ..." before compiling the
lambda. Matches SQL-path semantics; the OData serializer emits the
expanded slot as null for the orphan as expected.

Add EF6 regression tests rooted at /Books so the orphan is included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Jan-Willem Spuij and others added 25 commits May 19, 2026 16:55
DeepInsert_ExceedsMaxDepth_Returns400 was failing because its
services.AddSingleton<DeepOperationSettings>(...) inside the route
IServiceCollection was silently replaced by AddRestierRoute, which
applies the RestierRouteOptions bag after configureRouteServices. The
request ran with the default MaxDepth=5, accepted the 2-level payload,
and returned 201 instead of 400.

Configure MaxDepth=1 through configureOptions (the canonical channel)
in both DeepInsert_ExceedsMaxDepth_Returns400 and
DeepInsert_MaxDepth1_AllowsOneLevel. The latter previously passed for
the wrong reason — depth 1 fits both the intended limit and the
unintended default of 5 — and now actually exercises MaxDepth=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-maps EF6 / EF Core keyless DbSets to ComplexType + unbound
FunctionImport returning Collection(ComplexType); RestierOperationExecutor
gets a registry-based fallback that sources the underlying DbSet through
api.QueryAsync so conventions still fire. Closes OData#741.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four substantive corrections:

1. v1 bypasses api.QueryAsync. ApiBase.QueryAsync only accepts
   QueryableSource<T>; routing keyless views through it requires
   IModelMapper + IQueryExpressionSourcer changes + composable-function-
   import resolution that don't exist yet. Executor returns the factory's
   IQueryable directly; AspNetCore.OData applies $filter / $select etc.
   at the OData layer.

2. OnFiltering<View> conventions do NOT fire in v1. The convention
   processor returns early unless the model reference is an
   IEdmEntitySet of IEdmEntityType. Function imports are explicitly
   listed as out-of-scope follow-up work.

3. Registry lives in Microsoft.Restier.Core and is lifetime-bridged
   manually by AddRestierRoute (mirroring the existing
   RestierWebApiModelExtender bridge across the modelBuildingServices
   dispose boundary). Constructor-injected into RestierOperationExecutor.

4. Writes return HTTP 405 via RestierController.Post's existing
   function-import branch, not 404. Test assertion updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing MDX page (guides/server/keyless-views.mdx), navigation
update via the docsproj MintlifyTemplate, cross-links from
model-building.mdx and operations.mdx, and a release-notes entry are
now part of v1 — not a follow-up.

Convention hooks and no-tracking are pulled out of the flat
out-of-scope list into a dedicated Follow-ups section with explicit
code-path-by-code-path scope (mapper, processor, sourcer, executor
swap) so the deferred work is trackable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 tasks across 6 phases:
- Phase 1: KeylessViewRegistry + lifetime bridge
- Phase 2: shared model builder + EFCore partial
- Phase 3: executor dispatch + EFCore end-to-end
- Phase 4: EF6 model builder + tests
- Phase 5: docs (new page, cross-links, navigation, release notes)
- Phase 6: Swagger verification, follow-up issue, full test run

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. New Task 9b wires LibraryWithViewsContext seeding into the shared
   EF test helper (EFCore + EF6 branches). Without this the
   end-to-end tests would run against an empty database.

2. New Task 8b adds matching MethodNotAllowed guards to
   RestierController.Delete and the private Update method (PUT/PATCH).
   Today only Post has the function-import branch; DELETE/PUT/PATCH
   throw NotImplementedException, surfacing as HTTP 500. All four
   write verbs now return 405 consistently. Regression tests in
   Tasks 10 and 14 use a [Theory] over POST/PUT/PATCH/DELETE.

3. Task 4 step 2 now updates the two direct
   EFModelBuilder<IntegrationContext> ctor calls in
   EFModelBuilderSpatialIntegrationTests.cs (lines 42 and 61)
   so the new required KeylessViewRegistry parameter compiles.
   Verified by grep — those are the only direct construction
   sites in the repo.

4. Task 11 source-factory probe simplified to "property assignable
   to IQueryable<clrType>" — one check that covers DbSet<T>,
   IDbSet<T>, and DbQuery<T>. Spec edge-cases and the docs mapping
   table call out the discovery limitation: a property not
   configured via modelBuilder.Entity<T>() / EDMX won't appear in
   efEntityContainer.EntitySets and so isn't auto-mapped. Users
   must configure the entity in the model first regardless of
   property type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. BooksByPublisher.PublisherId changed from int to string. The shared
   Library fixture has Publisher.Id as string ("Publisher1",
   "Publisher2"), so the planned view CLR types, the CREATE VIEW SQL,
   the OData $filter literals ('Publisher1' instead of 1), and the
   docs metadata sample now line up with the actual repo fixtures.
   PublisherName dropped from the view to avoid EF6/EFCore OwnsOne
   column-naming drift; BookCount narrowed from decimal to int.

2. EF6 LibraryWithViewsTestInitializer no longer calls a non-existent
   SeedFor method. Task 12 step 3a refactors EF6 LibraryTestInitializer
   to expose a public static SeedLibraryData(LibraryContext) under the
   #if EF6 branch; the new initialiser delegates to that helper. The
   protected Seed override stays in place and now calls
   SeedLibraryData itself. EFCore needs no refactor — its Seed is
   already public via IDatabaseInitializer.

3. File inventory now describes the source-factory map correctly as
   Dictionary<string, Func<object, IQueryable>> (keyed by entity-set
   name) instead of Dictionary<Type, ...>. Spec and plan now match
   the implementation steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…otNullOrEmpty)

The Ensure helper in src/Microsoft.Restier.Core/Helpers/Ensure.cs only
exposes NotNullOrWhiteSpace; NotNullOrEmpty does not exist. Caught
before dispatching Task 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Also restores the <see cref="KeylessViewRegistry"/> in KeylessViewEntry's
summary doc comment, which had to be downgraded to <c>...</c> in the
previous commit because the type didn't exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registers KeylessViewRegistry in the temporary model-building service
provider, captures the populated instance before disposal, then
re-registers the same instance into the per-route services lambda.
Mirrors the existing RestierWebApiModelExtender pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the two direct-construction call sites in the EFCore Spatial
integration tests to pass a fresh KeylessViewRegistry. Production code
gets the registry via DI through the lifetime bridge in
AddRestierRoute (see prior commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pipes a Dictionary<string, Func<object, IQueryable>> source-factory map
from the EFCore partial through the shared GetEdmModel. The shared
BuildEdmModelFromEntitySetMaps now splits the entity-set map into keyed
entity sets and keyless view sets:

- keyed entries proceed through the existing EntitySet<T> path
- keyless entries are registered as ComplexType<T> and get an unbound
  FunctionImport added to the EdmEntityContainer post-build, returning
  Collection(<ComplexType>); they are also recorded in
  KeylessViewRegistry alongside their source factory so the request-time
  dispatch path (RestierOperationExecutor, next phase) can find them.

Empty key lists are normalised to 'keyless' so the EF6 path (which
produces empty lists rather than nulls for keyless entity sets) lands
in the same branch when Task 11 wires up its source-factory map.

EF6 build is intentionally broken at this commit - its partial's
EntityFramework6GetEntitySets still has the old 2-out signature. Task 11
adds the matching 3-out signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flips the existing 'should throw on keyless' assertion to verify the new
auto-mapping behaviour. Adds a mixed-model test asserting regular entity
sets coexist with a keyless view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the reflective method lookup on the API returns null, consult
KeylessViewRegistry. On hit, invoke the source factory and return its
IQueryable directly so AspNetCore.OData can apply query options at the
OData layer. On miss, throw the existing NotImplementedException
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the existing 405 branch in Post. Without this, DELETE/PUT/PATCH
on a function-import URL (e.g. a keyless-view import) threw
NotImplementedException, surfacing as HTTP 500. Now all four write verbs
return 405 Method Not Allowed consistently the desired UX for a
read-only resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes BooksByPublisher, LibraryWithViewsContext, and LibraryWithViewsApi
into Tests.Shared.EntityFrameworkCore so the AspNetCore regression tests
can reference them. The DbContext gains an IsConfigured-guarded
OnConfiguring fallback to in-memory so model-shape tests still work
without a provider being supplied, while end-to-end tests can route
through a relational provider via the user-secret connection string.

Adds an instrumented OnFilteringBooksByPublisher method to assert the
v1 limitation (convention does not fire for keyless-view function
imports).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Core)

EFCore: SeedDatabase<LibraryWithViewsContext, LibraryWithViewsTestInitializer>
runs after AddEFCoreProviderServices, populating publishers/books from
the existing LibraryTestInitializer then creating the BooksByPublisher
SQL view on top.

EF6 path will be wired in Task 12 (creates the EF6 LibraryWithViewsContext
plus its DropCreateDatabaseAlways initialiser).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures a Dictionary<string, Func<object, IQueryable>> alongside the
entity-set and key maps. Source-factory selection: prefer reflection on
a context property whose type is assignable to IQueryable<T> (covers
DbSet<T>, IDbSet<T>, DbQuery<T>); fall back to ObjectContext.CreateQuery
with an ESQL '[Container].[EntitySet]' string for EDMX-only entity sets.

Empty KeyProperties lists are now normalised (filtering null elements)
so the shared builder treats them as keyless and routes them to the
ComplexType + FunctionImport path.

Also fixes a collateral break from Task 8 (RestierOperationExecutor ctor
gained a KeylessViewRegistry parameter): RestierOperationExecutorTests
constructed the executor directly and needed the new arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue741_KeylessViews:
- GET /BooksByPublisher() returns 200 with rows
- \$filter=PublisherId eq 'Publisher1' narrows correctly
- OnFilteringBooksByPublisher call count stays at 0 (pins v1 limitation
  that convention hooks don't fire for keyless-view function imports;
  see Follow-up A in the spec)
- POST/PUT/PATCH/DELETE all return HTTP 405

Tests require ConnectionStrings:LibraryWithViewsContext to be set in
the test project's user-secrets, e.g.:

    cd test/Microsoft.Restier.Tests.Shared.EntityFrameworkCore
    dotnet user-secrets set "ConnectionStrings:LibraryWithViewsContext" \
      "Server=...;Database=LibraryWithViewsContext;..."

The LibraryWithViewsTestInitializer (committed in 09d64431) populates
publishers/books via LibraryTestInitializer.Seed then creates the
BooksByPublisher SQL view via ExecuteSqlRaw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ture

Replaces the dedicated LibraryWithViewsContext / LibraryWithViewsTestInitializer
with a BooksByPublisher DbSet directly on the shared LibraryContext (EFCore
only). Saves a user-secret connection string and reduces ongoing fixture
maintenance.

Substantive changes:

- BooksByPublisher CLR type lives in Tests.Shared/Scenarios/Library/ (no EF
  attribute, fully TFM-agnostic).
- LibraryContext gets a `DbSet<BooksByPublisher>` and fluent
  `HasNoKey().ToView("BooksByPublisher")` under #if EFCore. EF6 code-first
  doesn't support keyless entity types, so EF6 testing of keyless views
  remains a separate concern (the EF6 source-factory work in
  src/Microsoft.Restier.EntityFramework/Model/EfModelBuilder.cs still covers
  the EDMX-defined-keyless-entity-set case for production EF6 users).
- LibraryTestInitializer.Seed creates the BooksByPublisher SQL view via
  ExecuteSqlRaw, guarded by IsRelational() so in-memory-provider tests
  (Issue704, Issue519 etc.) don't trip on the relational-only API.
- The EdmFunction emitted by AddKeylessViewFunctionImports moves to a
  "<namespace>.Views" sub-namespace. The previous code put the function in
  the same namespace as the ComplexType (with the same name), which violated
  the OData CSDL uniqueness rule for schema-level elements — caught when the
  full AspNetCore test suite started failing model validation. The
  FunctionImport name (and so the URL `GET /odata/<view>()`) is unchanged.
- LibraryWithViewsApi keeps existing — slimmed down to inherit from
  EntityFrameworkApi<LibraryContext>; its only job is to host the
  instrumented OnFilteringBooksByPublisher probe used by the regression
  test to pin the v1 limitation.
- Deletes LibraryWithViewsContext, LibraryWithViewsTestInitializer, and the
  matching `else if` branch in the shared EF helper.
- Refreshes the LibraryApi-EFCore-ApiMetadata.txt baseline to include the
  new ComplexType + FunctionImport + Views schema.

Verified: Tests.Core 345/345, Tests.EntityFrameworkCore 10/10, and
Tests.AspNetCore 451/451 (+4 skipped pre-existing) all green on net9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EF6 can't host keyless entity types in code-first (model validation
rejects entities without a key), and the EDMX-defined-keyless-entity-set
path is explicitly out of scope. EF6 users who want view-shaped
read-only resources continue to hand-author [UnboundOperation] methods.

Substantive changes:

- src/Microsoft.Restier.EntityFramework/Model/EfModelBuilder.cs: reverts
  the EDMX/source-factory work added in 55cf33ca. The EF6 partial now
  throws InvalidOperationException with a clear "keyless not supported
  on EF6, use EF Core" message if it encounters an entity set with empty
  KeyProperties. Restores the original 2-out method signature.
- src/Microsoft.Restier.EntityFramework.Shared/Model/EFModelBuilder.cs:
  EF6 branch of GetEdmModel synthesises an empty sourceFactoryMap so
  the shared BuildEdmModelFromEntitySetMaps signature stays compatible.

Spec + plan:

- Spec Goal / Decisions / Components tables updated to EFCore-only.
- EF6 sections in the Edge cases and Testing tables collapsed to a
  single "Not supported" row pointing at hand-authored UnboundOperation.
- Spec Follow-up C is now an explicit EF6-support follow-up (the
  attribute + SqlQuery escape hatch) deferred until real demand emerges.
- Plan: Phase 4 (Tasks 11-14, ~550 lines) replaced with a brief stub
  explaining the scope change. File inventory updated.
- MDX page outline: Tabs collapsed to a single EF Core code sample;
  EF6 callout added as a Note. Mapping table rewritten.

Tests: Tests.Core 345/345, Tests.EntityFramework 7/7, Tests.EntityFrameworkCore
10/10, Tests.AspNetCore 451/451 (+4 skipped pre-existing). All green
on net9.0. Solution builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- guides/server/keyless-views.mdx: new user-facing page covering when
  the feature applies, the auto-generated EDM shape, query examples,
  v1 limitations callout, and the EF6 not-supported Note.
- model-building.mdx: short paragraph in "Customizing the Entity Model"
  pointing to the keyless-views page; replaces the older "if you're
  using Model First + SQL Views you'll need a PK" hand-wave.
- operations.mdx: adds a Note that auto-generated function imports
  for keyless views aren't hand-authored with [UnboundOperation].
- docsproj MintlifyTemplate: keyless-views added to the Server nav
  group; vnext release-notes stub added to the Release Notes nav.
- release-notes/vnext.md: placeholder entry for the keyless-views
  feature; user renames / merges into a versioned file at release-cut
  time.
- docs.json: regenerated by the docsproj build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iour

Two findings from review:

1. (High) Keyless-view dispatch bypassed PerformPreEvent / PerformPostEvent
   in RestierOperationExecutor. Any IOperationFilter implementations
   (auditing, metrics, mutation, validation) silently stopped applying
   to auto-generated function imports while still running for hand-
   authored [UnboundOperation] methods. Fixed: wrap the SourceFactory
   call in the same pre/post filter calls the normal method path uses.

2. (Medium-as-reported / false-alarm-in-practice) The original concern
   was that function-import names weren't lower-camel-cased under
   RestierNamingConvention.LowerCamelCase. Empirical check of the
   metadata under that convention shows ODataConventionModelBuilder.
   EnableLowerCamelCase() only lower-camel-cases *property* and
   *enum-member* names — NOT container-level names like EntitySet,
   Singleton, FunctionImport. So keyless-view function imports staying
   PascalCase is the correct behaviour, matching how regular entity
   sets surface.

   Added EFModelBuilder_LowerCamelCase_KeylessViewImport_MatchesEntitySetCasing
   to pin this: under LowerCamelCase, EntitySet Name="Books" + Property
   Name="isbn", and FunctionImport Name="BooksByPublisher" (PascalCase).
   Comment in AddKeylessViewFunctionImports calls out the convention.

Verified: AspNetCore 451/451, EFCore 11/11 (the new test added),
warning-clean build across net8/9/10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the normal-path invariant — RestierOperationContext.ParameterValues
is set to a non-null array before PerformPreEvent runs. Keyless-view
function imports have no parameters, so the value is Array.Empty<object>().
Custom IOperationFilter implementations can now read context.ParameterValues
without null-guarding, just like they can on hand-authored
[UnboundOperation] methods.

Verified by ExecuteOperationAsync_KeylessView_Invokes_Filters_With_NonNull_ParameterValues:
captures the array the filter sees when OnOperationExecutingAsync runs and
asserts non-null + empty. Sanity-checked by temporarily reverting the
assignment — test correctly fails with "Expected capturedParameterValues
not to be <null>" — then restored.

AspNetCore 452/452 green on net9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants