diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f778c8..fb3a5d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: - src/eQuantic.Core.Data.EntityFramework.PostgreSql/eQuantic.Core.Data.EntityFramework.PostgreSql.Net10.csproj - src/eQuantic.Core.Data.EntityFramework.MongoDb/eQuantic.Core.Data.EntityFramework.MongoDb.Net8.csproj - src/eQuantic.Core.Data.EntityFramework.MongoDb/eQuantic.Core.Data.EntityFramework.MongoDb.Net10.csproj + - src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj + - src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 @@ -57,6 +59,7 @@ jobs: - tests/eQuantic.Core.Data.EntityFramework.Tests/eQuantic.Core.Data.EntityFramework.Tests.csproj - tests/eQuantic.Core.Data.EntityFramework.SqlServer.Tests/eQuantic.Core.Data.EntityFramework.SqlServer.Tests.csproj - tests/eQuantic.Core.Data.EntityFramework.MongoDb.Tests/eQuantic.Core.Data.EntityFramework.MongoDb.Tests.csproj + - tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc821e5..8247fcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: - src/eQuantic.Core.Data.EntityFramework.PostgreSql/eQuantic.Core.Data.EntityFramework.PostgreSql.Net10.csproj - src/eQuantic.Core.Data.EntityFramework.MongoDb/eQuantic.Core.Data.EntityFramework.MongoDb.Net8.csproj - src/eQuantic.Core.Data.EntityFramework.MongoDb/eQuantic.Core.Data.EntityFramework.MongoDb.Net10.csproj + - src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj + - src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 @@ -68,6 +70,7 @@ jobs: - tests/eQuantic.Core.Data.EntityFramework.Tests/eQuantic.Core.Data.EntityFramework.Tests.csproj - tests/eQuantic.Core.Data.EntityFramework.SqlServer.Tests/eQuantic.Core.Data.EntityFramework.SqlServer.Tests.csproj - tests/eQuantic.Core.Data.EntityFramework.MongoDb.Tests/eQuantic.Core.Data.EntityFramework.MongoDb.Tests.csproj + - tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 diff --git a/README.md b/README.md index 690b10e..747977e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **The Entity Framework Core implementation of [eQuantic.Core.Data](https://github.com/eQuantic/core-data).** You code against the provider-agnostic `IRepository` / `IUnitOfWork` contracts; this package -supplies the EF Core engine for **SQL Server, PostgreSQL, MySQL and MongoDB**. +supplies the EF Core engine for **SQL Server, PostgreSQL, MySQL, MongoDB and Azure Cosmos DB**. ```csharp // A repository over any IEntity, obtained from your DbContext-backed unit of work: @@ -21,6 +21,67 @@ var page = await repo.GetPagedAsync( // page is a PagedResult: Items + TotalCount + PageIndex/PageSize/PageCount + Has*Page ``` +## Why + +The Repository pattern keeps your domain ignorant of the persistence engine — you code against +`IRepository` and can swap SQL Server for PostgreSQL, MongoDB or Cosmos DB without touching a +line of domain code. What usually rots is the *query surface*: a sprawl of `GetPaged`/`GetFiltered` +overloads and `Action` callbacks, with filters passed as magic strings. + +On the `eQuantic.Core.Data` **v5** contracts, this provider collapses that into **one `QueryOptions` +per read** — authored typed and fluent, checked at compile time, and translated to EF Core server-side. + +## Getting started + +Install the provider for your database — pick the major that matches your runtime (see +[Versioning](#versioning) below): + +```bash +dotnet add package eQuantic.Core.Data.EntityFramework.SqlServer --version 8.* +``` + +Give your entities a key via `IEntity`, keep your usual `DbContext`, and derive the provider's unit of +work: + +```csharp +using eQuantic.Core.Data.Repository; +using eQuantic.Core.Data.EntityFramework.SqlServer.Repository; +using Microsoft.EntityFrameworkCore; + +public class OrderData : IEntity +{ + public Guid Id { get; set; } + public decimal Total { get; set; } + public Guid GetKey() => Id; + public void SetKey(Guid key) => Id = key; +} + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} + +public interface IAppUnitOfWork : IQueryableUnitOfWork { } + +public class AppUnitOfWork(IServiceProvider sp, AppDbContext ctx) + : UnitOfWork(sp, ctx), IAppUnitOfWork; +``` + +Register the context and repositories — `AddRelationalRepositories` for the SQL providers, +`AddQueryableRepositories` for the document providers (MongoDB, Cosmos DB): + +```csharp +services.AddDbContext(o => o.UseSqlServer(connectionString)); +services.AddRelationalRepositories(); +``` + +Then inject `IAppUnitOfWork`, ask it for a repository, and query with a `QueryOptions` (the snippet above). +The full slice — specifications, custom repositories, a domain service — is in the +[walkthrough](Repository.md). + +> Swap the suffix (`PostgreSql`, `MySql`, `MongoDb`, `CosmosDb`) and the `UseXxx` call to target another +> database. + ## What this package gives you `eQuantic.Core.Data` defines the **contracts** — `IRepository`, `IUnitOfWork`, `QueryOptions`, @@ -63,16 +124,31 @@ end up as one predicate the provider translates. The full query-string grammar i | `eQuantic.Core.Data.EntityFramework.PostgreSql` | PostgreSQL | | `eQuantic.Core.Data.EntityFramework.MySql` | MySQL (Pomelo) | | `eQuantic.Core.Data.EntityFramework.MongoDb` | MongoDB (EF Core provider) | +| `eQuantic.Core.Data.EntityFramework.CosmosDb` | Azure Cosmos DB (EF Core provider) | + +The three relational providers share `eQuantic.Core.Data.EntityFramework.Relational`; the document providers +(`MongoDb`, `CosmosDb`) are non-relational and build directly on the base +`eQuantic.Core.Data.EntityFramework`. Register your `DbContext`-backed unit of work and the open-generic +repositories through `AddRelationalRepositories()` (relational) or the +base `AddQueryableRepositories()` (document) — the full wiring is in the +[walkthrough](Repository.md). + +**Azure Cosmos DB:** scope a read to one logical partition with the Cosmos-specific `WithPartitionKey` +extension so it doesn't fan out into a cross-partition scan: + +```csharp +new QueryOptions() + .WithPartitionKey(tenantId) + .Where(o => o.Status, FilterOperator.Equal, OrderStatus.Paid); +``` -The three relational providers share `eQuantic.Core.Data.EntityFramework.Relational`; every provider builds -on the base `eQuantic.Core.Data.EntityFramework`. Register your `DbContext`-backed unit of work and the -open-generic repositories through `AddRelationalRepositories()` — the -full wiring is in the [walkthrough](Repository.md). +Cosmos has no server-side set-based delete/update (`ExecuteDelete`/`ExecuteUpdate` are relational-only), so +`DeleteMany`/`UpdateMany` load the matching documents and modify them through the context. -## Versioning — pick the package major that matches your runtime +## Versioning -This library targets **.NET 8** and **.NET 10**, and each runtime is published as its **own package major** -so the EF Core lines never mix: +Pick the package major that matches your runtime — this library targets **.NET 8** and **.NET 10**, and each +runtime is published as its **own package major** so the EF Core lines never mix: | Your app | Install | Targets | |---|---|---| @@ -84,18 +160,6 @@ so the EF Core lines never mix: > must not be read as a .NET version. You normally consume only the provider package for your runtime > (8.x / 10.x), which pulls the right shared assemblies transitively. -## Install - -```bash -# .NET 8 app + SQL Server -dotnet add package eQuantic.Core.Data.EntityFramework.SqlServer --version 8.* - -# .NET 10 app + PostgreSQL -dotnet add package eQuantic.Core.Data.EntityFramework.PostgreSql --version 10.* -``` - -Swap the suffix for `PostgreSql`, `MySql` or `MongoDb` as needed. - ## Learn more - [Repository Pattern walkthrough](Repository.md) — data entities, unit of work, repository and diff --git a/Repository.md b/Repository.md index 93c91b4..27eb16e 100644 --- a/Repository.md +++ b/Repository.md @@ -227,8 +227,8 @@ await orders.RemoveAsync(order); // stage a delete int affected = await unitOfWork.CommitAsync(); // one round-trip, returns affected rows ``` -Set-based writes run directly in the database (EF `ExecuteUpdate`/`ExecuteDelete`) and do not need a -commit: +On the relational providers, set-based writes run as a single server-side statement (EF +`ExecuteUpdate`/`ExecuteDelete`) and do not need a commit: ```csharp long cancelled = await orders.UpdateManyAsync( @@ -240,6 +240,10 @@ long removed = await orders.DeleteManyAsync(o => o.Total == 0m); `DeleteManyAsync`/`UpdateManyAsync` also accept an `ISpecification` in place of the predicate. +> The document providers implement these two methods differently — **MongoDB** through its native driver, +> **Azure Cosmos DB** by loading the matching documents and modifying them through the context (Cosmos has no +> server-side `ExecuteUpdate`/`ExecuteDelete`). The contract is identical; only the execution differs. + ## 8. Custom repositories Need repository-specific methods, or the plain `IRepository`/`IAsyncRepository` shape resolved by diff --git a/docs/IMPROVEMENT_PLAN.md b/docs/IMPROVEMENT_PLAN.md index 9350575..5a535de 100644 --- a/docs/IMPROVEMENT_PLAN.md +++ b/docs/IMPROVEMENT_PLAN.md @@ -1,5 +1,11 @@ # Improvement Plan — eQuantic.Core.Data.EntityFramework +> **📜 Historical — completed.** This is the original pre-migration analysis. Its recommendations have been +> delivered: the security/correctness/CI phases in PR #1, and the **v5 contract migration** (per-major +> `8.x`/`10.x` + multi-framework `4.x` packages, targets trimmed to net8/net10) shipped on top of it. It is +> kept for the record and describes the **old** state (net6–net10, v4 contracts) — it does **not** reflect +> the current codebase; see the [README](../README.md) and [walkthrough](../Repository.md) instead. + > Deep analysis performed on 2026-07-16 of this repository (v4.4.2 / published 6.x–10.x lines) and of the > contracts repository [`eQuantic/core-data`](https://github.com/eQuantic/core-data) (v4.3.2). > Every finding cites `file:line` and was verified against the source code, not inferred. diff --git a/eQuantic.Core.Data.EntityFramework.sln b/eQuantic.Core.Data.EntityFramework.sln index 0f2964d..f1ed712 100644 --- a/eQuantic.Core.Data.EntityFramework.sln +++ b/eQuantic.Core.Data.EntityFramework.sln @@ -56,6 +56,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFramework.Net10", "src\eQuantic.Core.Data.EntityFramework\eQuantic.Core.Data.EntityFramework.Net10.csproj", "{B8AA3B17-808A-4D8B-8C1F-EB42E9C5C354}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eQuantic.Core.Data.EntityFramework.CosmosDb", "eQuantic.Core.Data.EntityFramework.CosmosDb", "{9B074A5E-5D97-BD47-3CDB-A58654D0504C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFramework.CosmosDb.Net8", "src\eQuantic.Core.Data.EntityFramework.CosmosDb\eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj", "{AEB46671-269B-4590-AC64-9BDDCE0FC8B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFramework", "src\eQuantic.Core.Data.EntityFramework\eQuantic.Core.Data.EntityFramework.csproj", "{709CD1F1-48C2-40C3-8470-9111FB2E0C86}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFramework.CosmosDb.Net10", "src\eQuantic.Core.Data.EntityFramework.CosmosDb\eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj", "{29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "eQuantic.Core.Data.EntityFramework.CosmosDb.Tests", "tests\eQuantic.Core.Data.EntityFramework.CosmosDb.Tests\eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj", "{59E690D6-CE93-423B-9B56-4C28B1A5359D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -234,6 +244,54 @@ Global {B8AA3B17-808A-4D8B-8C1F-EB42E9C5C354}.Release|x64.Build.0 = Release|Any CPU {B8AA3B17-808A-4D8B-8C1F-EB42E9C5C354}.Release|x86.ActiveCfg = Release|Any CPU {B8AA3B17-808A-4D8B-8C1F-EB42E9C5C354}.Release|x86.Build.0 = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|x64.Build.0 = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Debug|x86.Build.0 = Debug|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|Any CPU.Build.0 = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|x64.ActiveCfg = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|x64.Build.0 = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|x86.ActiveCfg = Release|Any CPU + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6}.Release|x86.Build.0 = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|x64.ActiveCfg = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|x64.Build.0 = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|x86.ActiveCfg = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Debug|x86.Build.0 = Debug|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|Any CPU.Build.0 = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|x64.ActiveCfg = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|x64.Build.0 = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|x86.ActiveCfg = Release|Any CPU + {709CD1F1-48C2-40C3-8470-9111FB2E0C86}.Release|x86.Build.0 = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|x64.Build.0 = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|x86.ActiveCfg = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Debug|x86.Build.0 = Debug|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|Any CPU.Build.0 = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|x64.ActiveCfg = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|x64.Build.0 = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|x86.ActiveCfg = Release|Any CPU + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC}.Release|x86.Build.0 = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|x64.ActiveCfg = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|x64.Build.0 = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|x86.ActiveCfg = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Debug|x86.Build.0 = Debug|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|Any CPU.Build.0 = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|x64.ActiveCfg = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|x64.Build.0 = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|x86.ActiveCfg = Release|Any CPU + {59E690D6-CE93-423B-9B56-4C28B1A5359D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -259,6 +317,11 @@ Global {BEB4D7A8-0559-D436-17A0-F8FA25A80731} = {0CA2F610-6D57-4D1F-92E3-EDCCEDAD8297} {0CAE00C1-4547-4DDA-A526-A4BBB78898A2} = {BEB4D7A8-0559-D436-17A0-F8FA25A80731} {B8AA3B17-808A-4D8B-8C1F-EB42E9C5C354} = {BEB4D7A8-0559-D436-17A0-F8FA25A80731} + {9B074A5E-5D97-BD47-3CDB-A58654D0504C} = {0CA2F610-6D57-4D1F-92E3-EDCCEDAD8297} + {AEB46671-269B-4590-AC64-9BDDCE0FC8B6} = {9B074A5E-5D97-BD47-3CDB-A58654D0504C} + {709CD1F1-48C2-40C3-8470-9111FB2E0C86} = {0CA2F610-6D57-4D1F-92E3-EDCCEDAD8297} + {29C8E041-4A5E-4D97-B1F2-EF9C31A951EC} = {9B074A5E-5D97-BD47-3CDB-A58654D0504C} + {59E690D6-CE93-423B-9B56-4C28B1A5359D} = {EE71F02F-413D-4BC0-832F-726B6D8C0AD7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {83FECDD1-8A97-40B1-8529-3D5216E674C3} diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/CosmosQueryOptionsExtensions.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/CosmosQueryOptionsExtensions.cs new file mode 100644 index 0000000..f08225e --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/CosmosQueryOptionsExtensions.cs @@ -0,0 +1,75 @@ +using eQuantic.Core.Data.Repository.Options; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb; + +/// +/// Azure Cosmos DB conveniences for . +/// +public static class CosmosQueryOptionsExtensions +{ + /// + /// Restricts the query to a single Cosmos DB logical partition, avoiding a (costly) cross-partition + /// scan. Applied as a before-customization so it runs on the root query. + /// + /// The entity type. + /// The query options. + /// The partition key value. + /// The same instance for chaining. + public static QueryOptions WithPartitionKey(this QueryOptions options, + string partitionKeyValue) + where TEntity : class + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return options.WithBeforeCustomization(query => query.WithPartitionKey(partitionKeyValue)); + } + +#if NET10_0_OR_GREATER + /// + /// Restricts the query to a two-level hierarchical Cosmos DB partition key (EF Core 10+). + /// + /// The entity type. + /// The query options. + /// The first-level partition key value. + /// The second-level partition key value. + /// The same instance for chaining. + public static QueryOptions WithPartitionKey(this QueryOptions options, + object partitionKeyValue, object secondLevelPartitionKeyValue) + where TEntity : class + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return options.WithBeforeCustomization(query => + query.WithPartitionKey(partitionKeyValue, secondLevelPartitionKeyValue)); + } + + /// + /// Restricts the query to a three-level hierarchical Cosmos DB partition key (EF Core 10+). + /// + /// The entity type. + /// The query options. + /// The first-level partition key value. + /// The second-level partition key value. + /// The third-level partition key value. + /// The same instance for chaining. + public static QueryOptions WithPartitionKey(this QueryOptions options, + object partitionKeyValue, object secondLevelPartitionKeyValue, object thirdLevelPartitionKeyValue) + where TEntity : class + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return options.WithBeforeCustomization(query => + query.WithPartitionKey(partitionKeyValue, secondLevelPartitionKeyValue, thirdLevelPartitionKeyValue)); + } +#endif +} diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Icon.png b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Icon.png new file mode 100644 index 0000000..a46eba3 Binary files /dev/null and b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Icon.png differ diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/CosmosUpdateExpression.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/CosmosUpdateExpression.cs new file mode 100644 index 0000000..14158f2 --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/CosmosUpdateExpression.cs @@ -0,0 +1,52 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +/// +/// Turns a member-initialization update expression — e.g. +/// x => new Order { Status = "Paid", Total = x.Total + 1 } — into the set of property +/// assignments to apply. Azure Cosmos DB has no server-side set-based update (ExecuteUpdate is +/// relational-only), so loads the matching entities and applies these +/// setters to each. The value of every assignment is evaluated against the loaded entity, so +/// expressions that reference the entity itself (x.Total + 1) are honoured correctly. +/// +internal static class CosmosUpdateExpression +{ + public static IReadOnlyList<(PropertyInfo Property, Func Value)> ExtractSetters( + Expression> updateExpression) + { + if (updateExpression is null) + { + throw new ArgumentNullException(nameof(updateExpression)); + } + + if (updateExpression.Body is not MemberInitExpression memberInit) + { + throw new NotSupportedException( + "UpdateMany requires a member-initialization expression, " + + "e.g. x => new Entity { Status = \"Paid\", Total = x.Total + 1 }."); + } + + var parameter = updateExpression.Parameters[0]; + var setters = new List<(PropertyInfo, Func)>(memberInit.Bindings.Count); + + foreach (var binding in memberInit.Bindings) + { + if (binding is not MemberAssignment assignment || assignment.Member is not PropertyInfo property) + { + throw new NotSupportedException( + $"Unsupported binding '{binding.Member.Name}' in UpdateMany; only property " + + "assignments are supported."); + } + + // Box the assigned value to object and compile against the entity parameter so member + // references such as `x.Total + 1` evaluate against each loaded entity. + var body = Expression.Convert(assignment.Expression, typeof(object)); + var valueLambda = Expression.Lambda>(body, parameter); + setters.Add((property, valueLambda.Compile())); + } + + return setters; + } +} diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/DefaultUnitOfWork.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/DefaultUnitOfWork.cs new file mode 100644 index 0000000..2d0ae13 --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/DefaultUnitOfWork.cs @@ -0,0 +1,7 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +public class DefaultUnitOfWork(IServiceProvider serviceProvider, DbContext context) + : UnitOfWork(serviceProvider, context); diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/Set.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/Set.cs new file mode 100644 index 0000000..f4ebd8c --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/Set.cs @@ -0,0 +1,86 @@ +using System.Linq.Expressions; +using System.Reflection; +using eQuantic.Core.Data.EntityFramework.Repository; +using eQuantic.Core.Data.Repository; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +/// +/// The Azure Cosmos DB entity set. Cosmos has no server-side set-based delete/update +/// (ExecuteDelete/ExecuteUpdate are relational-only), so the bulk operations load the +/// matching entities through the and delete/modify them via the change +/// tracker before saving. Every other set behaviour is inherited from . +/// +public class Set : SetBase where TEntity : class, IEntity +{ + public Set(DbContext context) : base(context) + { + } + + public override long DeleteMany(Expression> filter) + { + var items = InternalDbSet.Where(filter).ToList(); + if (items.Count == 0) + { + return 0; + } + + InternalDbSet.RemoveRange(items); + DbContext.SaveChanges(); + return items.Count; + } + + public override async Task DeleteManyAsync(Expression> filter, CancellationToken cancellationToken = default) + { + var items = await InternalDbSet.Where(filter).ToListAsync(cancellationToken).ConfigureAwait(false); + if (items.Count == 0) + { + return 0; + } + + InternalDbSet.RemoveRange(items); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return items.Count; + } + + public override long UpdateMany(Expression> filter, Expression> updateExpression) + { + var setters = CosmosUpdateExpression.ExtractSetters(updateExpression); + var items = InternalDbSet.Where(filter).ToList(); + if (items.Count == 0) + { + return 0; + } + + Apply(items, setters); + DbContext.SaveChanges(); + return items.Count; + } + + public override async Task UpdateManyAsync(Expression> filter, Expression> updateExpression, CancellationToken cancellationToken = default) + { + var setters = CosmosUpdateExpression.ExtractSetters(updateExpression); + var items = await InternalDbSet.Where(filter).ToListAsync(cancellationToken).ConfigureAwait(false); + if (items.Count == 0) + { + return 0; + } + + Apply(items, setters); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return items.Count; + } + + private static void Apply(IReadOnlyList items, + IReadOnlyList<(PropertyInfo Property, Func Value)> setters) + { + foreach (var item in items) + { + foreach (var (property, value) in setters) + { + property.SetValue(item, value(item)); + } + } + } +} diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs new file mode 100644 index 0000000..cd480e5 --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs @@ -0,0 +1,21 @@ +using System; +using eQuantic.Core.Data.EntityFramework.Repository; +using eQuantic.Core.Data.Repository; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +/// +/// The Azure Cosmos DB unit of work. All of the store-agnostic behaviour lives in +/// ; this only supplies the Cosmos . +/// +public abstract class UnitOfWork(IServiceProvider serviceProvider, DbContext context) + : EntityFrameworkUnitOfWork(serviceProvider, context) +{ + protected override Data.Repository.ISet CreateSetCore() => + new Set(Context); +} + +public abstract class UnitOfWork(IServiceProvider serviceProvider, TDbContext context) + : UnitOfWork(serviceProvider, context) + where TDbContext : DbContext; diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj new file mode 100644 index 0000000..c86214c --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj @@ -0,0 +1,43 @@ + + + + + Core Data library for Entity Framework and Azure Cosmos DB + eQuantic.Core.Data.EntityFramework.CosmosDb + 10.2.0.0 + net10.0 + eQuantic.Core.Data.EntityFramework.CosmosDb + eQuantic.Core.Data.EntityFramework.CosmosDb + eQuantic;Core;Data;Library;Repository;Pattern;CosmosDB;Azure + Entity ignorant persistance with Repository Pattern for Entity + Framework + + 10.2.0.0 + 10.2.0.0 + + enable + enable + + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).Tests + + + + + + + diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj new file mode 100644 index 0000000..c2c9d46 --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj @@ -0,0 +1,43 @@ + + + + + Core Data library for Entity Framework and Azure Cosmos DB + eQuantic.Core.Data.EntityFramework.CosmosDb + 8.3.0.0 + net8.0 + eQuantic.Core.Data.EntityFramework.CosmosDb + eQuantic.Core.Data.EntityFramework.CosmosDb + eQuantic;Core;Data;Library;Repository;Pattern;CosmosDB;Azure + Entity ignorant persistance with Repository Pattern for Entity + Framework + + 8.3.0.0 + 8.3.0.0 + + enable + enable + + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).Tests + + + + + + + diff --git a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net10.csproj b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net10.csproj index 4c4e58c..f940016 100644 --- a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net10.csproj +++ b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net10.csproj @@ -53,6 +53,9 @@ <_Parameter1>$(AssemblyName).MongoDb + + <_Parameter1>$(AssemblyName).CosmosDb + <_Parameter1>$(AssemblyName).Relational diff --git a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net8.csproj b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net8.csproj index d6d78fc..a462b89 100644 --- a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net8.csproj +++ b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.Net8.csproj @@ -53,6 +53,9 @@ <_Parameter1>$(AssemblyName).MongoDb + + <_Parameter1>$(AssemblyName).CosmosDb + <_Parameter1>$(AssemblyName).Relational diff --git a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.csproj b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.csproj index fe4806a..bb49d9c 100644 --- a/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.csproj +++ b/src/eQuantic.Core.Data.EntityFramework/eQuantic.Core.Data.EntityFramework.csproj @@ -70,5 +70,8 @@ <_Parameter1>$(AssemblyName).MongoDb + + <_Parameter1>$(AssemblyName).CosmosDb + diff --git a/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosQueryOptionsExtensionsTests.cs b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosQueryOptionsExtensionsTests.cs new file mode 100644 index 0000000..b440270 --- /dev/null +++ b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosQueryOptionsExtensionsTests.cs @@ -0,0 +1,32 @@ +using eQuantic.Core.Data.EntityFramework.CosmosDb; +using eQuantic.Core.Data.Repository.Options; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Tests; + +/// +/// Verifies the Cosmos WithPartitionKey convenience registers a before-customization on the +/// and returns the same instance for chaining. The customization +/// itself calls EF Core Cosmos' WithPartitionKey, which only runs against Cosmos, so it is not +/// executed here (the in-memory provider does not support it). +/// +public class CosmosQueryOptionsExtensionsTests +{ + [Test] + public void WithPartitionKey_RegistersBeforeCustomization_AndChains() + { + var options = new QueryOptions(); + Assert.That(options.BeforeCustomization, Is.Null); + + var result = options.WithPartitionKey("tenant-1"); + + Assert.That(result, Is.SameAs(options)); + Assert.That(options.BeforeCustomization, Is.Not.Null); + } + + [Test] + public void WithPartitionKey_Throws_WhenOptionsNull() + { + QueryOptions options = null!; + Assert.That(() => options.WithPartitionKey("tenant-1"), Throws.TypeOf()); + } +} diff --git a/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosSetTests.cs b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosSetTests.cs new file mode 100644 index 0000000..7e4b6cc --- /dev/null +++ b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosSetTests.cs @@ -0,0 +1,102 @@ +using eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Tests; + +/// +/// Exercises the Cosmos set's bulk operations against the EF Core in-memory provider (no emulator). +/// Cosmos has no server-side set-based delete/update, so the set loads and modifies through the context; +/// these tests prove the load-then-modify path deletes/updates the right rows, returns the matched +/// count, and — critically — that UpdateMany touches only the assigned members. +/// +public class CosmosSetTests +{ + private static TestContext NewContext(params TestDoc[] seed) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new TestContext(options); + if (seed.Length > 0) + { + context.Docs.AddRange(seed); + context.SaveChanges(); + } + + return context; + } + + [Test] + public void DeleteMany_RemovesMatching_ReturnsCount() + { + using var context = NewContext( + new TestDoc { Id = 1, Status = "New" }, + new TestDoc { Id = 2, Status = "Paid" }, + new TestDoc { Id = 3, Status = "Paid" }); + var set = new Set(context); + + var deleted = set.DeleteMany(d => d.Status == "Paid"); + + Assert.That(deleted, Is.EqualTo(2)); + Assert.That(context.Docs.Count(), Is.EqualTo(1)); + Assert.That(context.Docs.Single().Id, Is.EqualTo(1)); + } + + [Test] + public async Task DeleteManyAsync_RemovesMatching_ReturnsCount() + { + await using var context = NewContext( + new TestDoc { Id = 1, Status = "New" }, + new TestDoc { Id = 2, Status = "Paid" }); + var set = new Set(context); + + var deleted = await set.DeleteManyAsync(d => d.Status == "Paid"); + + Assert.That(deleted, Is.EqualTo(1)); + Assert.That(context.Docs.Count(), Is.EqualTo(1)); + } + + [Test] + public void DeleteMany_NoMatch_ReturnsZero() + { + using var context = NewContext(new TestDoc { Id = 1, Status = "New" }); + var set = new Set(context); + + Assert.That(set.DeleteMany(d => d.Status == "Paid"), Is.EqualTo(0)); + Assert.That(context.Docs.Count(), Is.EqualTo(1)); + } + + [Test] + public void UpdateMany_AppliesOnlyTheSetMembers_LeavingOthersIntact() + { + using var context = NewContext( + new TestDoc { Id = 1, Status = "New", Count = 4 }, + new TestDoc { Id = 2, Status = "New", Count = 7 }); + var set = new Set(context); + + var updated = set.UpdateMany(d => d.Status == "New", d => new TestDoc { Status = "Paid" }); + + Assert.That(updated, Is.EqualTo(2)); + var docs = context.Docs.OrderBy(d => d.Id).ToList(); + Assert.That(docs.All(d => d.Status == "Paid"), Is.True); + // Count was not part of the update expression, so it must be preserved (not reset to 0). + Assert.That(docs[0].Count, Is.EqualTo(4)); + Assert.That(docs[1].Count, Is.EqualTo(7)); + } + + [Test] + public async Task UpdateManyAsync_EvaluatesEntityReferencingValues() + { + await using var context = NewContext( + new TestDoc { Id = 1, Status = "New", Count = 4 }, + new TestDoc { Id = 2, Status = "New", Count = 10 }); + var set = new Set(context); + + var updated = await set.UpdateManyAsync(d => d.Status == "New", d => new TestDoc { Count = d.Count + 1 }); + + Assert.That(updated, Is.EqualTo(2)); + var docs = context.Docs.OrderBy(d => d.Id).ToList(); + Assert.That(docs[0].Count, Is.EqualTo(5)); + Assert.That(docs[1].Count, Is.EqualTo(11)); + } +} diff --git a/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosUpdateExpressionTests.cs b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosUpdateExpressionTests.cs new file mode 100644 index 0000000..28ecffd --- /dev/null +++ b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosUpdateExpressionTests.cs @@ -0,0 +1,45 @@ +using System.Linq.Expressions; +using eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Tests; + +/// +/// Guards the update-expression parser that powers Cosmos UpdateMany: it must read each property +/// assignment and evaluate its value against the loaded entity (so x.Count + 1 works), and +/// reject anything that is not a member-initialization. +/// +public class CosmosUpdateExpressionTests +{ + [Test] + public void ExtractSetters_ReadsConstantAndEntityReferencingAssignments() + { + Expression> update = x => new TestDoc { Status = "Paid", Count = x.Count + 1 }; + + var setters = CosmosUpdateExpression.ExtractSetters(update); + var byName = setters.ToDictionary(s => s.Property.Name, s => s.Value); + + Assert.That(setters, Has.Count.EqualTo(2)); + Assert.That(byName.ContainsKey("Status") && byName.ContainsKey("Count"), Is.True); + + var doc = new TestDoc { Count = 4 }; + Assert.That(byName["Status"](doc), Is.EqualTo("Paid")); + // Evaluated against the entity — Count + 1 = 5, not a constant. + Assert.That(byName["Count"](doc), Is.EqualTo(5)); + } + + [Test] + public void ExtractSetters_Throws_WhenNotMemberInitialization() + { + Expression> update = x => x; + + Assert.That(() => CosmosUpdateExpression.ExtractSetters(update), + Throws.TypeOf()); + } + + [Test] + public void ExtractSetters_Throws_WhenNull() + { + Assert.That(() => CosmosUpdateExpression.ExtractSetters(null!), + Throws.TypeOf()); + } +} diff --git a/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/TestFakes.cs b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/TestFakes.cs new file mode 100644 index 0000000..dde97a3 --- /dev/null +++ b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/TestFakes.cs @@ -0,0 +1,21 @@ +using eQuantic.Core.Data.Repository; +using Microsoft.EntityFrameworkCore; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Tests; + +/// Minimal used to drive the Cosmos set/expression tests. +public sealed class TestDoc : IEntity +{ + public int Id { get; set; } + public string? Status { get; set; } + public int Count { get; set; } + + public int GetKey() => Id; + public void SetKey(int key) => Id = key; +} + +/// An EF Core context backing the tests with the in-memory provider. +public sealed class TestContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Docs => Set(); +} diff --git a/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj new file mode 100644 index 0000000..ed12272 --- /dev/null +++ b/tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + +