From 29176b27774d4846793d08232d71a4e901821146 Mon Sep 17 00:00:00 2001 From: Edgar Mesquita Date: Mon, 20 Jul 2026 18:18:52 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20eQuantic.Core.Dat?= =?UTF-8?q?a.EntityFramework.CosmosDb=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New non-relational EF Core provider for Azure Cosmos DB, mirroring the MongoDb provider. The Set overrides the four bulk operations via load-then-modify through the DbContext (Cosmos has no ExecuteDelete/ExecuteUpdate); UpdateMany extracts the assigned members from the update expression and evaluates each value against the loaded entity (so x.Count + 1 works) and touches only those members. Adds a QueryOptions.WithPartitionKey extension (single string on net8/net10, plus 2- and 3-level hierarchical keys on net10). Per-major packages 8.2.0 (EF Cosmos 8.0.29) and 10.1.0 (EF Cosmos 10.0.10); base InternalsVisibleTo extended to the new assembly. Registered in the solution and the ci.yml/release.yml matrices (14 build / 4 test). 10 unit tests (setter extraction, the four bulk ops via EF in-memory, WithPartitionKey wiring). --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + README.md | 25 +- eQuantic.Core.Data.EntityFramework.sln | 63 +++++ .../CosmosQueryOptionsExtensions.cs | 75 ++++++ .../Icon.png | Bin 0 -> 1394 bytes .../Repository/CosmosUpdateExpression.cs | 52 ++++ .../Repository/DefaultUnitOfWork.cs | 7 + .../Repository/Set.cs | 86 ++++++ .../Repository/UnitOfWork.cs | 246 ++++++++++++++++++ ...Data.EntityFramework.CosmosDb.Net10.csproj | 43 +++ ....Data.EntityFramework.CosmosDb.Net8.csproj | 43 +++ ...tic.Core.Data.EntityFramework.Net10.csproj | 3 + ...ntic.Core.Data.EntityFramework.Net8.csproj | 3 + .../eQuantic.Core.Data.EntityFramework.csproj | 3 + .../CosmosQueryOptionsExtensionsTests.cs | 32 +++ .../CosmosSetTests.cs | 102 ++++++++ .../CosmosUpdateExpressionTests.cs | 45 ++++ .../TestFakes.cs | 21 ++ ...Data.EntityFramework.CosmosDb.Tests.csproj | 28 ++ 20 files changed, 878 insertions(+), 5 deletions(-) create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/CosmosQueryOptionsExtensions.cs create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/Icon.png create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/CosmosUpdateExpression.cs create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/DefaultUnitOfWork.cs create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/Set.cs create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net10.csproj create mode 100644 src/eQuantic.Core.Data.EntityFramework.CosmosDb/eQuantic.Core.Data.EntityFramework.CosmosDb.Net8.csproj create mode 100644 tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosQueryOptionsExtensionsTests.cs create mode 100644 tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosSetTests.cs create mode 100644 tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/CosmosUpdateExpressionTests.cs create mode 100644 tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/TestFakes.cs create mode 100644 tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests/eQuantic.Core.Data.EntityFramework.CosmosDb.Tests.csproj 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..44a56f0 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,26 @@ 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`; 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). +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); +``` + +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 @@ -94,7 +109,7 @@ dotnet add package eQuantic.Core.Data.EntityFramework.SqlServer --version 8.* dotnet add package eQuantic.Core.Data.EntityFramework.PostgreSql --version 10.* ``` -Swap the suffix for `PostgreSql`, `MySql` or `MongoDb` as needed. +Swap the suffix for `PostgreSql`, `MySql`, `MongoDb` or `CosmosDb` as needed. ## Learn more 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 0000000000000000000000000000000000000000..a46eba3871b3e4089d7cced1a4254aa4aaca7ecf GIT binary patch literal 1394 zcmV-&1&#WNP)Wyp6vt=HEL> zcaZ1|0CW@pP*oTZ@>FDpCtXO|gBUBkt8t&U`oQgIudQ|3fkX-XUqcMqYTfX@*#c%7 zI&V0Lf%wqDm0UW))o29E1i^%X71D2@50YbxgCxJkal-((EZRwi+pNTBq^PS5d zBubE4W#T(=c^rMl-|&AeY}My}^UxSpNqxrpff$$i6c!isC&sf9*x4Evi4wLf(zg83 zqA179Ganc81yPHF=dn3@_m8l6<}nzKnhY`SwAo6!U}Al{xIu!fK_9W)&}RorYUwpJ z%wdhXURLn|iTz@OE&h#1aQje2$)uc`={KyyU3RUwKq`|}=h%QX@+IyRVAQ2-JZuS)l6~{+e(SUG z)-h4SXO&f~WO3uhJ1k-YL8i*aj%?NurJ9a61mab3JE2tFs}+bd_3AboF|d*>= z^^2^oWzFkr!z=2r5fICyYlG06namLIKj=CC;~=XN&>kc~+tF|cx<897KcwRdzWU)dzD8&}^vb4H5?E#} z`+6SdkwVreY2rTYv{JnICVdR%J2V`hv+=+-pAQHhL0{dS#NLgDLa3`>2I5h#uYP*F zI@d(GYrC6M9}w(>zCo$V3<3WvRN?A)_5L6dBzU8+`z(}-@1DF?qjZiMvPd@W=F|OUtR69@=xW*`0pbs0 ztilH=hN<3gc;C^ktU+f0iAx|0i*WH%I~R?`uZ}Ma#K2(jJD1sf8|d{+BX^%=K$k4C zZ`|nVlP#UK0wf%yBMd;ePCN(52<`*x8eu@hMw|zK0D~Ud4#dC=@tyO0(aqR0>}H&8 zY6S|L3k@mu)X(R7+!BK5s2KbqFzrDM3?~4FsXiLOYr4ss6_r6S4*|fB?R2fc_lc17 zMv%M_k8Y1jRXv2FlSPUPnP(lGo=rhp2}43zITiHvqk8$r))GXBIbd*mf15U0foqHS z@};kKe=luF9b-3(RA)5q#!&dGLeWUQd_U9m!%w)ZoR@eKP=#lcV)Otbc!SYve zqduM=U+>R#^=9eaZZ|1uYK@%fR>ea$3sR$iR!}aVflSoSd8wE#>P&*qvwW`B85_?7 zfGpaRLLBva_y`9(8$OIfgx-qiG6aI(d+#8;C^w8k_U=3(<{nE*8Ru!)!=si%9t6a| za4N=!afqLW$r)anh^LQFLo0et@?an#yX`af8=j^oQHX>RQ2+n{07*qoM6N<$f-(D? AVE_OC literal 0 HcmV?d00001 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..9f197b0 --- /dev/null +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using eQuantic.Core.Data.Repository; +using eQuantic.Core.Data.Repository.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; + +public abstract class UnitOfWork : IQueryableUnitOfWork +{ + protected readonly IServiceProvider ServiceProvider; + + /// + /// The context + /// + protected readonly DbContext Context; + + /// + /// The disposed + /// + protected bool Disposed; + + /// + /// Initializes a new instance of the class + /// + /// + /// The context + protected UnitOfWork(IServiceProvider serviceProvider, DbContext context) + { + ServiceProvider = serviceProvider; + Context = context; + } + + public int Commit() + { + return Context.SaveChanges(); + } + + public int CommitAndRefreshChanges() + { + var changes = 0; + var saveFailed = false; + + do + { + try + { + changes = Context.SaveChanges(); + + saveFailed = false; + } + catch (DbUpdateConcurrencyException ex) + { + saveFailed = true; + + ex.Entries.ToList() + .ForEach(entry => entry.OriginalValues.SetValues(entry.GetDatabaseValues()!)); + } + } while (saveFailed); + + return changes; + } + + public async Task CommitAndRefreshChangesAsync(CancellationToken cancellationToken = default) + { + var changes = 0; + var saveFailed = false; + + do + { + try + { + changes = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + saveFailed = false; + } + catch (DbUpdateConcurrencyException ex) + { + saveFailed = true; + + ex.Entries.ToList() + .ForEach(entry => entry.OriginalValues.SetValues(entry.GetDatabaseValues()!)); + } + } while (saveFailed); + + return changes; + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + return await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public int Commit(Action options) + { + return Commit(); + } + + public int CommitAndRefreshChanges(Action options) + { + return CommitAndRefreshChanges(); + } + + public Task CommitAndRefreshChangesAsync(Action options, CancellationToken cancellationToken = default) + { + return CommitAndRefreshChangesAsync(cancellationToken); + } + + public Task CommitAsync(Action options, CancellationToken cancellationToken = default) + { + return CommitAsync(cancellationToken); + } + + public void ApplyCurrentValues(TEntity original, TEntity current) where TEntity : class, IEntity + { + ((Set)InternalCreateSet()).ApplyCurrentValues(original, current); + } + + public void Attach(TEntity item) where TEntity : class, IEntity + { + ((Set)InternalCreateSet()).Attach(item); + } + + public void LoadCollection(TEntity item, + Expression>> navigationProperty, + Expression>? filter = null) where TEntity : class where TElement : class + { + if (filter != null) + { + Context.Entry(item).Collection(navigationProperty).Query().Where(filter).Load(); + } + else + { + Context.Entry(item).Collection(navigationProperty).Load(); + } + } + + public async Task LoadCollectionAsync(TEntity item, + Expression>> navigationProperty, + Expression>? filter = null) where TEntity : class where TElement : class + { + if (filter != null) + { + await Context.Entry(item).Collection(navigationProperty).Query().Where(filter).LoadAsync().ConfigureAwait(false); + } + else + { + await Context.Entry(item).Collection(navigationProperty).LoadAsync().ConfigureAwait(false); + } + } + + public void Reload(TEntity item) where TEntity : class + { + var entry = Context.Entry(item); + entry.CurrentValues.SetValues(entry.OriginalValues); + entry.Reload(); + } + + public void RollbackChanges() + { + // set all entities in change tracker + // as 'unchanged state' + Context?.ChangeTracker.Entries() + .ToList() + .ForEach(entry => entry.State = EntityState.Unchanged); + } + + public void SetModified(TEntity item) where TEntity : class + { + //this operation also attach item in object state manager + Context.Entry(item).State = EntityState.Modified; + } + + public virtual SaveOptions GetSaveOptions() + { + return new SaveOptions(); + } + + public virtual IRepository GetRepository() + where TEntity : class, IEntity + { + return ServiceProvider.GetRequiredService>(); + } + + public IAsyncRepository GetAsyncRepository() + where TEntity : class, IEntity + { + return ServiceProvider.GetRequiredService>(); + } + + public Data.Repository.ISet CreateSet() where TEntity : class, IEntity => InternalCreateSet(); + + public IQueryableRepository GetQueryableRepository() + where TEntity : class, IEntity + { + return ServiceProvider.GetRequiredService>(); + } + + public IAsyncQueryableRepository GetAsyncQueryableRepository() + where TEntity : class, IEntity + { + return ServiceProvider.GetRequiredService>(); + } + + internal DbContext GetDbContext() => Context; + + internal Data.Repository.ISet InternalCreateSet() where TEntity : class, IEntity => + new Set(Context); + + /// + /// Disposes this instance + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the disposing + /// + /// The disposing + protected virtual void Dispose(bool disposing) + { + if (Disposed) + { + return; + } + + if (disposing) + { + Context?.Dispose(); + } + + Disposed = true; + } +} + +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..1427806 --- /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.1.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.1.0.0 + 10.1.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..f3018f1 --- /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.2.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.2.0.0 + 8.2.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 ab20736..5fc0854 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 ac1500b..56ce9d3 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 776afc9..fd15187 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 + + + + + + + + + + + + + + + + + + + + From f3c921a514f75b55f18376de4ef858a59960f98a Mon Sep 17 00:00:00 2001 From: Edgar Mesquita Date: Mon, 20 Jul 2026 18:29:08 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs:=20make=20the=20README?= =?UTF-8?q?=20a=20getting-started,=20not=20just=20a=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Why (the motivation the core-data README leads with) and a self-contained Getting started (install → entity/context/unit-of-work → registration → first query), so a reader can wire the provider up without leaving the README; folds the old Install section in. Fixes the intro to list Azure Cosmos DB alongside the other providers. Repository.md §7 now notes that set-based DeleteMany/UpdateMany run as server-side statements only on the relational providers — MongoDB uses its native driver and Cosmos DB loads-then-modifies. Marks docs/IMPROVEMENT_PLAN.md as historical/completed so it is not mistaken for the current design. --- README.md | 81 ++++++++++++++++++++++++++++++++-------- Repository.md | 8 +++- docs/IMPROVEMENT_PLAN.md | 6 +++ 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 44a56f0..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`, @@ -84,10 +145,10 @@ new QueryOptions() 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 | |---|---|---| @@ -99,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`, `MongoDb` or `CosmosDb` 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. From 9fd2e4f6e4a7e58658653da38a524d9dbb7aa5b0 Mon Sep 17 00:00:00 2001 From: Edgar Mesquita Date: Mon, 20 Jul 2026 19:17:41 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(cosmos):=20de?= =?UTF-8?q?rive=20the=20unit=20of=20work=20from=20the=20shared=20EntityFra?= =?UTF-8?q?meworkUnitOfWork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the shared EntityFrameworkUnitOfWork lives in the base package, the Cosmos unit of work drops to overriding CreateSetCore only (removing the duplicated ~200-line copy that SonarCloud flagged). Bumps CosmosDb to 8.3.0/10.2.0 to line up with the base (4.6.0) it now depends on. --- .../Repository/UnitOfWork.cs | 241 +----------------- ...Data.EntityFramework.CosmosDb.Net10.csproj | 6 +- ....Data.EntityFramework.CosmosDb.Net8.csproj | 6 +- 3 files changed, 14 insertions(+), 239 deletions(-) diff --git a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs index 9f197b0..cd480e5 100644 --- a/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs +++ b/src/eQuantic.Core.Data.EntityFramework.CosmosDb/Repository/UnitOfWork.cs @@ -1,244 +1,19 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; +using eQuantic.Core.Data.EntityFramework.Repository; using eQuantic.Core.Data.Repository; -using eQuantic.Core.Data.Repository.Options; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; namespace eQuantic.Core.Data.EntityFramework.CosmosDb.Repository; -public abstract class UnitOfWork : IQueryableUnitOfWork +/// +/// 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 readonly IServiceProvider ServiceProvider; - - /// - /// The context - /// - protected readonly DbContext Context; - - /// - /// The disposed - /// - protected bool Disposed; - - /// - /// Initializes a new instance of the class - /// - /// - /// The context - protected UnitOfWork(IServiceProvider serviceProvider, DbContext context) - { - ServiceProvider = serviceProvider; - Context = context; - } - - public int Commit() - { - return Context.SaveChanges(); - } - - public int CommitAndRefreshChanges() - { - var changes = 0; - var saveFailed = false; - - do - { - try - { - changes = Context.SaveChanges(); - - saveFailed = false; - } - catch (DbUpdateConcurrencyException ex) - { - saveFailed = true; - - ex.Entries.ToList() - .ForEach(entry => entry.OriginalValues.SetValues(entry.GetDatabaseValues()!)); - } - } while (saveFailed); - - return changes; - } - - public async Task CommitAndRefreshChangesAsync(CancellationToken cancellationToken = default) - { - var changes = 0; - var saveFailed = false; - - do - { - try - { - changes = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - - saveFailed = false; - } - catch (DbUpdateConcurrencyException ex) - { - saveFailed = true; - - ex.Entries.ToList() - .ForEach(entry => entry.OriginalValues.SetValues(entry.GetDatabaseValues()!)); - } - } while (saveFailed); - - return changes; - } - - public async Task CommitAsync(CancellationToken cancellationToken = default) - { - return await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - public int Commit(Action options) - { - return Commit(); - } - - public int CommitAndRefreshChanges(Action options) - { - return CommitAndRefreshChanges(); - } - - public Task CommitAndRefreshChangesAsync(Action options, CancellationToken cancellationToken = default) - { - return CommitAndRefreshChangesAsync(cancellationToken); - } - - public Task CommitAsync(Action options, CancellationToken cancellationToken = default) - { - return CommitAsync(cancellationToken); - } - - public void ApplyCurrentValues(TEntity original, TEntity current) where TEntity : class, IEntity - { - ((Set)InternalCreateSet()).ApplyCurrentValues(original, current); - } - - public void Attach(TEntity item) where TEntity : class, IEntity - { - ((Set)InternalCreateSet()).Attach(item); - } - - public void LoadCollection(TEntity item, - Expression>> navigationProperty, - Expression>? filter = null) where TEntity : class where TElement : class - { - if (filter != null) - { - Context.Entry(item).Collection(navigationProperty).Query().Where(filter).Load(); - } - else - { - Context.Entry(item).Collection(navigationProperty).Load(); - } - } - - public async Task LoadCollectionAsync(TEntity item, - Expression>> navigationProperty, - Expression>? filter = null) where TEntity : class where TElement : class - { - if (filter != null) - { - await Context.Entry(item).Collection(navigationProperty).Query().Where(filter).LoadAsync().ConfigureAwait(false); - } - else - { - await Context.Entry(item).Collection(navigationProperty).LoadAsync().ConfigureAwait(false); - } - } - - public void Reload(TEntity item) where TEntity : class - { - var entry = Context.Entry(item); - entry.CurrentValues.SetValues(entry.OriginalValues); - entry.Reload(); - } - - public void RollbackChanges() - { - // set all entities in change tracker - // as 'unchanged state' - Context?.ChangeTracker.Entries() - .ToList() - .ForEach(entry => entry.State = EntityState.Unchanged); - } - - public void SetModified(TEntity item) where TEntity : class - { - //this operation also attach item in object state manager - Context.Entry(item).State = EntityState.Modified; - } - - public virtual SaveOptions GetSaveOptions() - { - return new SaveOptions(); - } - - public virtual IRepository GetRepository() - where TEntity : class, IEntity - { - return ServiceProvider.GetRequiredService>(); - } - - public IAsyncRepository GetAsyncRepository() - where TEntity : class, IEntity - { - return ServiceProvider.GetRequiredService>(); - } - - public Data.Repository.ISet CreateSet() where TEntity : class, IEntity => InternalCreateSet(); - - public IQueryableRepository GetQueryableRepository() - where TEntity : class, IEntity - { - return ServiceProvider.GetRequiredService>(); - } - - public IAsyncQueryableRepository GetAsyncQueryableRepository() - where TEntity : class, IEntity - { - return ServiceProvider.GetRequiredService>(); - } - - internal DbContext GetDbContext() => Context; - - internal Data.Repository.ISet InternalCreateSet() where TEntity : class, IEntity => + protected override Data.Repository.ISet CreateSetCore() => new Set(Context); - - /// - /// Disposes this instance - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes the disposing - /// - /// The disposing - protected virtual void Dispose(bool disposing) - { - if (Disposed) - { - return; - } - - if (disposing) - { - Context?.Dispose(); - } - - Disposed = true; - } } public abstract class UnitOfWork(IServiceProvider serviceProvider, TDbContext context) 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 index 1427806..c86214c 100644 --- 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 @@ -4,7 +4,7 @@ Core Data library for Entity Framework and Azure Cosmos DB eQuantic.Core.Data.EntityFramework.CosmosDb - 10.1.0.0 + 10.2.0.0 net10.0 eQuantic.Core.Data.EntityFramework.CosmosDb eQuantic.Core.Data.EntityFramework.CosmosDb @@ -12,8 +12,8 @@ Entity ignorant persistance with Repository Pattern for Entity Framework - 10.1.0.0 - 10.1.0.0 + 10.2.0.0 + 10.2.0.0 enable enable 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 index f3018f1..c2c9d46 100644 --- 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 @@ -4,7 +4,7 @@ Core Data library for Entity Framework and Azure Cosmos DB eQuantic.Core.Data.EntityFramework.CosmosDb - 8.2.0.0 + 8.3.0.0 net8.0 eQuantic.Core.Data.EntityFramework.CosmosDb eQuantic.Core.Data.EntityFramework.CosmosDb @@ -12,8 +12,8 @@ Entity ignorant persistance with Repository Pattern for Entity Framework - 8.2.0.0 - 8.2.0.0 + 8.3.0.0 + 8.3.0.0 enable enable