diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8a0a5a58..97912c21 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -40,7 +40,6 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 3.1.x 6.0.x 8.0.x 9.0.x diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa0eba6..4ca6b680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Represents the **NuGet** versions. +## v3.31.0 +- *Enhancement:* Moved existing reflection-based `JsonMergePatch` to `Extended.JsonMergePatchEx`; this remains the `AddJsonMergePatch` default implementation. +- *Enhancement:* Added new `JsonMergePatch` that leverages `JsonElement` and `Utf8JsonWriter` without underlying reflection; useful in scenarios where the value type is not known. This is also not as performant as the reflection-based `JsonMergePatchEx` version and the primary reason why it is not the new default. +- *Enhancement:* Refactored the `CosmosDb` capabilities such that the `CosmosDbContainer` and `CosmosDbModelContainer` are model type independent, with underlying type support implemented at the method level for greater flexibility and control. The typed `CosmosDbContainer` etc. remain and are accessed from the type independent containers as required. + - The existing `IMultiSetArgs` operations have been moved (and renamed) from `CosmosDb` to `CosmosDbContainer` and `CosmosDbModelContainer` as these are single container-specific. + - The existing `CosmosDb.UseAuthorizeFilter` operations have been moved to `CosmosDbContainer` as these are single container-specific. +- *Enhancement:* Added `Cleaner.PrepareCreate` and `Cleaner.PrepareUpdate` to encapsulate `ChangeLog.PrepareCreated` and `ChangeLog.PrepareUpdated`, and `Cleaner.ResetTenantId` to ensure consistent handling throughout _CoreEx_. +- *Enhancement:* Added `SystemTime.Timestamp` as the standard means to access the current timestamp (uses `Cleaner.Clean`) to ensure consistency throughout _CoreEx_. Therefore, the likes of `DateTime.UtcNow` should be replaced with `SystemTime.Timestamp`. The previous `ExecutionContent.SystemTime` has been removed as it was not consistent with the `ExecutionContext` pattern. +- *Enhancement:* Updated the `IExtendedException.ShouldBeLogged` implementations to check `SettingsBase` configuration to enable/disable. +- *Enhancement:* Updated dependencies to latest; including transitive where applicable. + ## v3.30.2 - *Fixed:* Missing `QueryArgs.IncludeText` added to set the `$text=true` equivalent. - *Fixed:* Simplification of creating and setting the `QueryArgs.Filter` using an implict string operator. diff --git a/Common.targets b/Common.targets index acc4bcb2..2283c31e 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.30.2 + 3.31.0 preview Avanade Avanade diff --git a/samples/My.Hr/My.Hr.Api/Startup.cs b/samples/My.Hr/My.Hr.Api/Startup.cs index 97958c3a..aab6eb7e 100644 --- a/samples/My.Hr/My.Hr.Api/Startup.cs +++ b/samples/My.Hr/My.Hr.Api/Startup.cs @@ -8,6 +8,7 @@ using CoreEx.Azure.ServiceBus.HealthChecks; using Azure.Messaging.ServiceBus; using Microsoft.Extensions.DependencyInjection; +using CoreEx.Json.Merge; namespace My.Hr.Api; @@ -34,7 +35,7 @@ public void ConfigureServices(IServiceCollection services) .AddAzureServiceBusSender() .AddAzureServiceBusPurger() .AddJsonMergePatch() - .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, _, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException()) : null)) + .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, logger, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException(null, ex), logger) : null)) .AddReferenceDataContentWebApi() .AddRequestCache(); diff --git a/samples/My.Hr/My.Hr.Api/appsettings.json b/samples/My.Hr/My.Hr.Api/appsettings.json index c8b9c000..db33d9fb 100644 --- a/samples/My.Hr/My.Hr.Api/appsettings.json +++ b/samples/My.Hr/My.Hr.Api/appsettings.json @@ -15,7 +15,8 @@ "AbsoluteExpirationRelativeToNow": "03:00:00", "SlidingExpiration": "00:45:00" } - } + }, + "LogConcurrencyException": true }, "ServiceBusConnection": { "fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret" diff --git a/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs b/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs new file mode 100644 index 00000000..6d7910ca --- /dev/null +++ b/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs @@ -0,0 +1,22 @@ +namespace My.Hr.Business.Data; + +public class Employee2Configuration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Employee2", "Hr"); + builder.Property(p => p.Id).HasColumnName("EmployeeId").HasColumnType("UNIQUEIDENTIFIER"); + builder.Property(p => p.Email).HasColumnType("NVARCHAR(250)"); + builder.Property(p => p.FirstName).HasColumnType("NVARCHAR(100)"); + builder.Property(p => p.LastName).HasColumnType("NVARCHAR(100)"); + builder.Property(p => p.Gender).HasColumnName("GenderCode").HasColumnType("NVARCHAR(50)").HasConversion(v => v!.Code, v => (Gender?)v); + builder.Property(p => p.Birthday).HasColumnType("DATE"); + builder.Property(p => p.StartDate).HasColumnType("DATE"); + builder.Property(p => p.TerminationDate).HasColumnType("DATE"); + builder.Property(p => p.TerminationReasonCode).HasColumnType("NVARCHAR(50)"); + builder.Property(p => p.PhoneNo).HasColumnType("NVARCHAR(50)"); + builder.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d)); + builder.Property(p => p.IsDeleted).HasColumnType("BIT"); + builder.HasKey("Id"); + } +} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs b/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs index caf8c1ec..dcebbd33 100644 --- a/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs +++ b/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs @@ -18,7 +18,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .ApplyConfiguration(new UsStateConfiguration()) - .ApplyConfiguration(new EmployeeConfiguration()); + .ApplyConfiguration(new EmployeeConfiguration()) + .ApplyConfiguration(new Employee2Configuration()); base.OnModelCreating(modelBuilder); } diff --git a/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs b/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs index 9c5658e7..ab9ae11c 100644 --- a/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs +++ b/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs @@ -9,6 +9,11 @@ public interface IHrEfDb : IEfDb /// Gets the entity. /// EfDbEntity Employees { get; } + + /// + /// Gets the entity. + /// + EfDbEntity Employees2 { get; } } /// @@ -27,5 +32,10 @@ public HrEfDb(HrDbContext dbContext, IMapper mapper) : base(dbContext, mapper) { /// Gets the encapsulated entity. /// public EfDbEntity Employees => new(this); + + /// + /// Gets the encapsulated entity. + /// + public EfDbEntity Employees2 => new(this); } } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Models/Employee2.cs b/samples/My.Hr/My.Hr.Business/Models/Employee2.cs new file mode 100644 index 00000000..508f395d --- /dev/null +++ b/samples/My.Hr/My.Hr.Business/Models/Employee2.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; + +namespace My.Hr.Business.Models; + +/// +/// Represents the Entity Framework (EF) model for database object 'Hr.Employee2'. +/// +public class Employee2 : IIdentifier, IETag, ILogicallyDeleted +{ + /// + /// Gets or sets the 'EmployeeId' column value. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the 'Email' column value. + /// + public string? Email { get; set; } + + /// + /// Gets or sets the 'FirstName' column value. + /// + public string? FirstName { get; set; } + + /// + /// Gets or sets the 'LastName' column value. + /// + public string? LastName { get; set; } + + /// + /// Gets or sets the 'GenderCode' column value. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public Gender? Gender { get; set; } + + /// + /// Gets or sets the 'Birthday' column value. + /// + public DateTime? Birthday { get; set; } + + /// + /// Gets or sets the 'StartDate' column value. + /// + public DateTime? StartDate { get; set; } + + /// + /// Gets or sets the 'TerminationDate' column value. + /// + public DateTime? TerminationDate { get; set; } + + /// + /// Gets or sets the 'TerminationReasonCode' column value. + /// + public string? TerminationReasonCode { get; set; } + + /// + /// Gets or sets the 'PhoneNo' column value. + /// + public string? PhoneNo { get; set; } + + /// + /// Gets or sets the 'RowVersion' column value. + /// + public string? ETag { get; set; } + + /// + /// Gets or sets the 'IsDeleted' column value. + /// + public bool? IsDeleted { get; set; } +} + +public class Employee2Collection : List { } + +public class Employee2CollectionResult : CollectionResult { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs index 3ff61f66..4d7f45cf 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs @@ -36,7 +36,7 @@ public Task VerifyEmployeeAsync(Guid id) => Result var verification = new EmployeeVerificationRequest { Name = employee!.FirstName, - Age = DateTime.UtcNow.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, + Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, Gender = employee.Gender?.Code }; diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs index a78a3f4d..dfc13371 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs @@ -56,6 +56,8 @@ public async Task UpdateEmployeeAsync(Employee employee, Guid id) throw new NotFoundException(); employee.Id = id; + + _dbContext.ChangeTracker.Clear(); // Different employee instance (result of using CoreEx.Json.JsonMergePatch vs CoreEx.Json.Extended.JsonMergePatchEx); therefore, clear the change tracker prior to update attempt. _dbContext.Employees.Update(employee); await _dbContext.SaveChangesAsync().ConfigureAwait(false); return employee; @@ -80,7 +82,7 @@ public async Task VerifyEmployeeAsync(Guid id) var verification = new EmployeeVerificationRequest { Name = employee.FirstName, - Age = DateTime.UtcNow.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, + Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, Gender = employee.Gender?.Code }; diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs index 43af40c6..ed8fd752 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs @@ -36,7 +36,7 @@ public async Task VerifyEmployeeAsync(Guid id) var verification = new EmployeeVerificationRequest { Name = employee.FirstName, - Age = DateTime.UtcNow.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, + Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, Gender = employee.Gender?.Code }; diff --git a/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs b/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs index d6610ad7..bda9f877 100644 --- a/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs +++ b/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs @@ -10,7 +10,7 @@ public EmployeeValidator() RuleFor(x => x.FirstName).NotNull().MaximumLength(100); RuleFor(x => x.LastName).NotNull().MaximumLength(100); RuleFor(x => x.Gender).NotNull().IsValid(); - RuleFor(x => x.Birthday).NotNull().LessThanOrEqualTo(DateTime.UtcNow.AddYears(-18)).WithMessage("Birthday is invalid as the Employee must be at least 18 years of age."); + RuleFor(x => x.Birthday).NotNull().LessThanOrEqualTo(SystemTime.Timestamp.AddYears(-18)).WithMessage("Birthday is invalid as the Employee must be at least 18 years of age."); RuleFor(x => x.StartDate).NotNull().GreaterThanOrEqualTo(new DateTime(1999, 01, 01, 0, 0, 0, DateTimeKind.Utc)).WithMessage("January 1, 1999"); RuleFor(x => x.PhoneNo).NotNull().MaximumLength(50); } diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql b/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql new file mode 100644 index 00000000..8eabf3f1 --- /dev/null +++ b/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql @@ -0,0 +1,25 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Hr].[Employee2] ( + [EmployeeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, -- This is the primary key + [Email] NVARCHAR(250) NULL UNIQUE, -- This is the employee's unique email address + [FirstName] NVARCHAR(100) NULL, + [LastName] NVARCHAR(100) NULL, + [GenderCode] NVARCHAR(50) NULL, -- This is the related Gender code; see Ref.Gender table + [Birthday] DATE NULL, + [StartDate] DATE NULL, + [TerminationDate] DATE NULL, + [TerminationReasonCode] NVARCHAR(50) NULL, -- This is the related Termination Reason code; see Ref.TerminationReason table + [PhoneNo] NVARCHAR(50) NULL, + [AddressJson] NVARCHAR(500) NULL, -- This is the full address persisted as JSON. + [IsDeleted] BIT NULL, -- Logical delete + [RowVersion] TIMESTAMP NOT NULL, -- This is used for concurrency version checking. + [CreatedBy] NVARCHAR(250) NULL, -- The following are standard audit columns. + [CreatedDate] DATETIME2 NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedDate] DATETIME2 NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/Startup.cs b/samples/My.Hr/My.Hr.Functions/Startup.cs index 63f1af96..5704aaef 100644 --- a/samples/My.Hr/My.Hr.Functions/Startup.cs +++ b/samples/My.Hr/My.Hr.Functions/Startup.cs @@ -5,6 +5,7 @@ using CoreEx.Database; using CoreEx.Database.HealthChecks; using CoreEx.Http.HealthChecks; +using CoreEx.Json.Merge; using Microsoft.Azure.Functions.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -40,8 +41,8 @@ public override void Configure(IFunctionsHostBuilder builder) .AddEventPublisher() .AddSingleton(sp => new Az.ServiceBusClient(sp.GetRequiredService().ServiceBusConnection__fullyQualifiedNamespace)) .AddAzureServiceBusSender() - .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, _, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException()) : null)) - .AddJsonMergePatch() + .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, logger, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException(), logger) : null)) + .AddJsonMergePatch(sp => new JsonMergePatch()) .AddWebApiPublisher() .AddAzureServiceBusSubscriber(); diff --git a/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml b/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml index ae4d854e..1b73dbf9 100644 --- a/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml +++ b/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml @@ -7,4 +7,9 @@ - EmergencyContact: - { EmergencyContactId: 201, EmployeeId: 2, FirstName: Garth, LastName: Smith, PhoneNo: (443) 678 1827, RelationshipTypeCode: PAR } - { EmergencyContactId: 202, EmployeeId: 2, FirstName: Sarah, LastName: Smith, PhoneNo: (443) 234 3837, RelationshipTypeCode: PAR } - - { EmergencyContactId: 401, EmployeeId: 4, FirstName: Michael, LastName: Manners, PhoneNo: (234) 297 9834, RelationshipTypeCode: FRD } \ No newline at end of file + - { EmergencyContactId: 401, EmployeeId: 4, FirstName: Michael, LastName: Manners, PhoneNo: (234) 297 9834, RelationshipTypeCode: FRD } + - Employee2: + - { EmployeeId: 1, Email: w.jones@org.com, FirstName: Wendy, LastName: Jones, GenderCode: F, Birthday: 1985-03-18, StartDate: 2000-12-11, PhoneNo: (425) 612 8113 } + - { EmployeeId: 2, Email: b.smith@org.com, FirstName: Brian, LastName: Smith, GenderCode: M, Birthday: 1994-11-07, StartDate: 2013-08-06, TerminationDate: 2015-04-08, TerminationReasonCode: RE, PhoneNo: (429) 120 0098, IsDeleted: false } + - { EmployeeId: 3, Email: r.Browne@org.com, FirstName: Rachael, LastName: Browne, GenderCode: F, Birthday: 1972-06-28, StartDate: 2019-11-06, PhoneNo: (421) 783 2343, IsDeleted: true } + - { EmployeeId: 4, Email: w.smither@org.com, FirstName: Waylon, LastName: Smithers, GenderCode: M, Birthday: 1952-02-21, StartDate: 2001-01-22, PhoneNo: (428) 893 2793, AddressJson: '{ "street1": "8365 851 PL NE", "city": "Redmond", "state": "WA", "postCode": "98052" }' } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs b/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs index d20dec24..77944c2e 100644 --- a/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs @@ -1,11 +1,17 @@ -using CoreEx.Database; +using CoreEx; +using CoreEx.Database; +using CoreEx.Entities; +using CoreEx.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using My.Hr.Api; using My.Hr.Business.Data; using My.Hr.Business.Models; using NUnit.Framework; +using NUnit.Framework.Internal; using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Dynamic.Core; using System.Threading.Tasks; using UnitTestEx; @@ -44,5 +50,147 @@ await c.SelectAsync(dr => Assert.That(ids, Is.EquivalentTo(ids2)); } + + [Test] + public async Task EF_00A_Query_SelectSingle() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var r = await ef.Employees2.Query(q => q.Where(x => x.Id == 1.ToGuid())).SelectSingleAsync(); + Assert.That(r, Is.Not.Null); + + await Assert.ThatAsync(async () => await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectSingleAsync(), Throws.Exception.TypeOf()); + + var r2 = await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectSingleOrDefaultAsync(); + Assert.That(r2, Is.Null); + } + + [Test] + public async Task EF_00B_Query_SelectFirst() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var r = await ef.Employees2.Query().SelectFirstAsync(); + Assert.That(r, Is.Not.Null); + + await Assert.ThatAsync(async () => await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectFirstAsync(), Throws.Exception.TypeOf()); + + var r2 = await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectFirstOrDefaultAsync(); + Assert.That(r2, Is.Null); + } + + [Test] + public async Task EF_00C_Query_SelectQuery() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var c = await ef.Employees2.Query().WithPaging(1).SelectQueryAsync>(); + + Assert.That(c, Is.Not.Null); + Assert.That(c, Has.Count.EqualTo(2)); + } + + [Test] + public async Task EF_00D_Query_SelectResult() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var r = await ef.Employees2.Query().WithPaging(PagingArgs.CreateSkipAndTake(1, null, true)).SelectResultAsync(); + + Assert.That(r, Is.Not.Null); + Assert.That(r.Items, Has.Count.EqualTo(2)); + Assert.That(r.Paging, Is.Not.Null); + Assert.That(r.Paging!.TotalCount, Is.EqualTo(3)); + } + + [Test] + public async Task EF_00E_Query_SelectQuery_Item() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var count = 0; + + await ef.Query().SelectQueryAsync(e => + { + count++; + Assert.That(e, Is.Not.Null); + Assert.That(e.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(e.FirstName, Is.Not.Null); + return count <= 1; + }); + + Assert.That(count, Is.EqualTo(2)); + } + + [Test] + public async Task EF_01_Query_IsDeleted() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var r = await ef.Employees2.Query().SelectResultAsync(); + + Assert.That(r, Is.Not.Null); + Assert.That(r.Items, Has.Count.EqualTo(3)); + } + + [Test] + public async Task EF_02_Get_IsDeleted() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var e = await ef.Employees2.GetAsync(1.ToGuid()); + Assert.That(e, Is.Not.Null); + + e = await ef.Employees2.GetAsync(2.ToGuid()); + Assert.That(e, Is.Not.Null); + + e = await ef.Employees2.GetAsync(3.ToGuid()); + Assert.That(e, Is.Null); + } + + [Test] + public async Task EF_03_Update_IsDeleted() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + var e = await ef.Employees2.GetAsync(1.ToGuid()); + Assert.That(e, Is.Not.Null); + + ef.DbContext.ChangeTracker.Clear(); + + e!.Id = 3.ToGuid(); + await Assert.ThatAsync(async () => await ef.Employees2.UpdateAsync(e), Throws.Exception.TypeOf()); + } + + [Test] + public async Task EF_04_Delete_IsDeleted() + { + using var test = ApiTester.Create(); + using var scope = test.Services.CreateScope(); + var ef = scope.ServiceProvider.GetRequiredService(); + + await ef.Employees2.DeleteAsync(1.ToGuid()); + + var e = await ef.Employees2.GetAsync(1.ToGuid()); + Assert.That(e, Is.Null); + + await Assert.ThatAsync(async () => await ef.Employees2.DeleteAsync(1.ToGuid()), Throws.Exception.TypeOf()); + } } } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs index 2da97b50..9c6e64c5 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs @@ -335,6 +335,7 @@ public void D130_Update_ConcurrencyError() v.ETag = "ZZZZZZZZZZZZ"; test.Controller() + .ExpectLogContains("fail: A concurrency error occurred; please refresh the data and try again.") // Verifies the ConcurrencyException.ShouldBeLogged was logged as expected. .Run(c => c.UpdateAsync(v.Id, null!), v) .AssertPreconditionFailed(); } @@ -390,6 +391,7 @@ public void F110_Patch_Concurrency() v.FirstName += "X"; test.Controller() + .ExpectLogContains("fail: A concurrency error occurred; please refresh the data and try again.") // Verifies the ConcurrencyException.ShouldBeLogged was logged as expected. .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }) .AssertPreconditionFailed(); } diff --git a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj index ac0822df..8e4ca919 100644 --- a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj +++ b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj @@ -21,17 +21,17 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj index 22daba38..1d8db560 100644 --- a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj +++ b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs b/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs index e964e56c..8eeba4c7 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs @@ -64,10 +64,10 @@ public abstract class WebApiBase(ExecutionContext executionContext, SettingsBase public IEnumerable SecondaryCorrelationIdNames { get; set; } = ["x-ms-client-tracking-id"]; /// - /// Gets or sets the creator function used by . + /// Gets or sets the creator function used by . /// /// This allows an alternate serialization or handling as required. Defaults to the . - public Func ExtendedExceptionActionResultCreator { get; set; } = DefaultExtendedExceptionActionResultCreator; + public Func ExtendedExceptionActionResultCreator { get; set; } = DefaultExtendedExceptionActionResultCreator; /// /// Gets the list of correlation identifier names, being and (inclusive). @@ -186,12 +186,9 @@ public static async Task CreateActionResultFromExceptionAsync(Web IActionResult? ar = null; if (exception is IExtendedException eex) { - if (eex.ShouldBeLogged) - logger.LogError(exception, "{Error}", exception.Message); - ar = owner is null - ? DefaultExtendedExceptionActionResultCreator(eex) - : owner.CreateActionResultFromExtendedException(eex); + ? DefaultExtendedExceptionActionResultCreator(exception, logger) + : owner.CreateActionResultFromExtendedException(exception, logger); } else { @@ -211,16 +208,30 @@ public static async Task CreateActionResultFromExceptionAsync(Web /// /// Creates an from an . /// - /// The . - public IActionResult CreateActionResultFromExtendedException(IExtendedException extendedException) => ExtendedExceptionActionResultCreator(extendedException); + /// The . + /// The . + /// The resulting . + public IActionResult CreateActionResultFromExtendedException(Exception extendedException, ILogger? logger) => ExtendedExceptionActionResultCreator(extendedException, logger); /// /// The default . /// /// The . + /// The . /// The resulting . - public static IActionResult DefaultExtendedExceptionActionResultCreator(IExtendedException extendedException) + public static IActionResult DefaultExtendedExceptionActionResultCreator(Exception extendedException, ILogger? logger) { + if (extendedException.ThrowIfNull(nameof(extendedException)) is not IExtendedException eex) + throw new ArgumentException($"The exception must implement {nameof(IExtendedException)}.", nameof(extendedException)); + + if (eex.ShouldBeLogged) + { + if (logger is null) + throw new ArgumentNullException(nameof(logger), $"The logger is required to log the exception (see {nameof(IExtendedException)}.{nameof(IExtendedException.ShouldBeLogged)})."); + + logger?.LogError(extendedException, "{Error}", extendedException.Message); + } + if (extendedException is ValidationException vex && vex.Messages is not null && vex.Messages.Count > 0) { var msd = new ModelStateDictionary(); @@ -237,12 +248,12 @@ public static IActionResult DefaultExtendedExceptionActionResultCreator(IExtende { Content = extendedException.Message, ContentType = MediaTypeNames.Text.Plain, - StatusCode = (int)extendedException.StatusCode, + StatusCode = (int)eex.StatusCode, BeforeExtension = r => { var th = r.GetTypedHeaders(); - th.Set(HttpConsts.ErrorTypeHeaderName, extendedException.ErrorType); - th.Set(HttpConsts.ErrorCodeHeaderName, extendedException.ErrorCode.ToString()); + th.Set(HttpConsts.ErrorTypeHeaderName, eex.ErrorType); + th.Set(HttpConsts.ErrorCodeHeaderName, eex.ErrorCode.ToString()); if (extendedException is TransientException tex) th.Set(HeaderNames.RetryAfter, tex.RetryAfterSeconds); diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs b/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs index 1e6d80e3..96233e05 100644 --- a/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs +++ b/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs @@ -752,7 +752,7 @@ public async Task PatchWithResultAsync(HttpRequest reques // Get the current value and perform a concurrency match before we perform the merge. var rv = await get(wap, ct2).ConfigureAwait(false); var ex = rv.IsFailure ? rv.Error : (rv.Value is null ? new NotFoundException() : ConcurrencyETagMatching(wap, rv.Value, jpv, simulatedConcurrency)); - return ex is null ? Result.Ok(rv.Value!) : Result.Fail(ex); + return ex is null ? Result.Ok(rv.Value) : Result.Fail(ex); }, ct).ConfigureAwait(false); // Only invoke the put function where something was *actually* changed. diff --git a/src/CoreEx.Azure/CoreEx.Azure.csproj b/src/CoreEx.Azure/CoreEx.Azure.csproj index ea96605c..248ca758 100644 --- a/src/CoreEx.Azure/CoreEx.Azure.csproj +++ b/src/CoreEx.Azure/CoreEx.Azure.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs b/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs index d57b1ce7..4c2d0dae 100644 --- a/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs +++ b/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs @@ -5,7 +5,9 @@ using Microsoft.Azure.Cosmos; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -55,6 +57,20 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin await Task.WhenAll(tasks).ConfigureAwait(false); } + /// + /// Imports (creates) a batch of . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The batch of items to create. + /// The function that enables the deserialized model value to be further updated. + /// The . + /// The . + /// Each item is added individually and is not transactional. + public static Task ImportBatchAsync(this CosmosDbContainer container, IEnumerable items, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, items, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); + /// /// Imports (creates) a batch of . /// @@ -67,7 +83,7 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin /// The . /// Each item is added individually and is not transactional. public static Task ImportBatchAsync(this CosmosDbContainer container, IEnumerable items, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.Container.Id!, items, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); + => ImportBatchAsync(container.ThrowIfNull(nameof(container)).Container, items, modelUpdater, dbArgs, cancellationToken); /// /// Imports (creates) a batch of named items from the into the specified . @@ -76,7 +92,7 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin /// The . /// The . /// The . - /// The element name where the array of items to deserialize are housed within the embedded resource. Defaults to the name. + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. /// The function that enables the deserialized model value to be further updated. /// The . /// The . @@ -92,6 +108,22 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin return true; } + /// + /// Imports (creates) a batch of named items from the into the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. + /// The function that enables the deserialized model value to be further updated. + /// The . + /// The . + /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. + /// Each item is added individually and is not transactional. + public static Task ImportBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, jsonDataReader, name, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); + /// /// Imports (creates) a batch of named items from the into the specified . /// @@ -99,14 +131,14 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin /// The cosmos model . /// The . /// The . - /// The element name where the array of items to deserialize are housed within the embedded resource. Defaults to the name. + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. /// The function that enables the deserialized model value to be further updated. /// The . /// The . /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. /// Each item is added individually and is not transactional. public static Task ImportBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.Container.Id!, jsonDataReader, name, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); + => ImportBatchAsync(container.ThrowIfNull(nameof(container)).Container, jsonDataReader, name, modelUpdater, dbArgs, cancellationToken); /// /// Imports (creates) a batch of . @@ -147,23 +179,37 @@ public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, strin /// /// The entity . /// The cosmos model . - /// The . + /// The . /// The batch of items to create. /// The function that enables the deserialized model value to be further updated. /// The . /// The . /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public static Task ImportValueBatchAsync(this CosmosDbContainer container, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { CosmosDbValue func(CosmosDbValue cvm) { - cvm.Type = container.ModelContainer.TypeName; + cvm.Type = container.Model.GetModelName(); return modelUpdater?.Invoke(cvm) ?? cvm; } - return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.Container.Id!, items, func, dbArgs ?? container.DbArgs, cancellationToken); + return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, items, func, dbArgs ?? container.DbArgs, cancellationToken); } + /// + /// Imports (creates) a batch of . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The batch of items to create. + /// The function that enables the deserialized model value to be further updated. + /// The . + /// The . + /// Each item is added individually and is not transactional. + public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).Container, items, modelUpdater, dbArgs, cancellationToken); + /// /// Imports (creates) a batch of named items from the into the specified . /// @@ -171,7 +217,7 @@ CosmosDbValue func(CosmosDbValue cvm) /// The . /// The . /// The . - /// The element name where the array of items to deserialize are housed within the embedded resource. Defaults to the name. + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. /// The function that enables the deserialized model value to be further updated. /// The . /// The . @@ -192,25 +238,41 @@ CosmosDbValue func(CosmosDbValue cvm) /// /// The entity . /// The cosmos model . - /// The . + /// The . /// The . - /// The element name where the array of items to deserialize are housed within the embedded resource. Defaults to the name. + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. /// The function that enables the deserialized model value to be further updated. /// The . /// The . /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public static Task ImportValueBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { CosmosDbValue func(CosmosDbValue cvm) { - cvm.Type = container.ModelContainer.TypeName; + cvm.Type = container.Model.GetModelName(); return modelUpdater?.Invoke(cvm) ?? cvm; } - return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.Container.Id!, jsonDataReader, name ?? container.ModelContainer.TypeName, (Func, CosmosDbValue>)func, dbArgs ?? container.DbArgs, cancellationToken); + return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, jsonDataReader, name ?? container.Model.GetModelName(), (Func, CosmosDbValue>)func, dbArgs ?? container.DbArgs, cancellationToken); } + /// + /// Imports (creates) a batch of named items from the into the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The element name where the array of items to deserialize are housed within the . Defaults to the name. + /// The function that enables the deserialized model value to be further updated. + /// The . + /// The . + /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. + /// Each item is added individually and is not transactional. + public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).Container, jsonDataReader, name, modelUpdater, dbArgs, cancellationToken); + /// /// Imports (creates) a batch of named items from the into the specified . /// @@ -253,5 +315,56 @@ public static async Task ImportValueBatchAsync(this ICosmosDb cosmosDb, st await Task.WhenAll(tasks).ConfigureAwait(false); return tasks.Count > 0; } + + /// + /// Imports (creates) a batch of JSON items from the as-is into the specified . + /// + /// The . + /// The . + /// The element name where the array of items are housed within the . Defaults to the underlying . + /// The . + /// The . + /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. + /// Each item is added individually and is not transactional. + public static Task ImportJsonBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) + => ImportJsonBatchAsync(container.CosmosDb, container.CosmosContainer.Id, jsonDataReader, name, dbArgs, cancellationToken); + + /// + /// Imports (creates) a batch of JSON items from the as-is into the specified . + /// + /// The . + /// The . + /// The . + /// The element name where the array of items are housed within the . Defaults to the . + /// The . + /// The . + /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. + /// Each item is added individually and is not transactional. + public static async Task ImportJsonBatchAsync(this ICosmosDb cosmosDb, string containerId, JsonDataReader jsonDataReader, string? name = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) + { + var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); + jsonDataReader.ThrowIfNull(nameof(jsonDataReader)); + + dbArgs ??= cosmosDb.DbArgs; + var pk = dbArgs.Value.PartitionKey ?? PartitionKey.None; + + var tasks = new List(); + + var result = await jsonDataReader.EnumerateJsonAsync(name ?? containerId, async json => + { + using var ms = new MemoryStream(Encoding.UTF8.GetBytes(json.ToString())); + + if (SequentialExecution) + { + var resp = await container.CreateItemStreamAsync(ms, pk, dbArgs.Value.ItemRequestOptions, cancellationToken).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + } + else + tasks.Add(container.CreateItemAsync(ms, pk, dbArgs.Value.ItemRequestOptions, cancellationToken)); + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + return result; + } } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDb.cs b/src/CoreEx.Cosmos/CosmosDb.cs index fb69a2f6..f9214e3f 100644 --- a/src/CoreEx.Cosmos/CosmosDb.cs +++ b/src/CoreEx.Cosmos/CosmosDb.cs @@ -1,20 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Cosmos.Model; -using CoreEx.Cosmos.Extended; using CoreEx.Entities; -using CoreEx.Json; using CoreEx.Mapping; using CoreEx.Results; using Microsoft.Azure.Cosmos; using System; using System.Collections.Concurrent; -using System.Linq; -using System.Threading.Tasks; -using System.Threading; -using System.Collections.Generic; -using System.Text; -using System.Text.Json; namespace CoreEx.Cosmos { @@ -22,32 +13,22 @@ namespace CoreEx.Cosmos /// Provides extended CosmosDb data access. /// /// The . - /// The . + /// The ; defaults to . /// Enables the to be overridden; defaults to . /// It is recommended that the is registered as a scoped service to enable capabilities such as that must be scoped. /// Use to /// register the scoped instance. /// The dependent should however be registered as a singleton as is best practice. - public class CosmosDb(Database database, IMapper mapper, CosmosDbInvoker? invoker = null) : ICosmosDb + public class CosmosDb(Database database, IMapper? mapper = null, CosmosDbInvoker? invoker = null) : ICosmosDb { private static CosmosDbInvoker? _invoker; - private readonly ConcurrentDictionary> _filters = new(); - - /// - /// Provides key as combination of model type and container identifier. - /// - private readonly struct Key(Type modelType, string containerId) - { - public Type ModelType { get; } = modelType; - - public string ContainerId { get; } = containerId; - } + private readonly ConcurrentDictionary _containers = new(); /// public Database Database { get; } = database.ThrowIfNull(nameof(database)); /// - public IMapper Mapper { get; } = mapper.ThrowIfNull(nameof(mapper)); + public IMapper Mapper { get; } = mapper ?? Mapping.Mapper.Empty; /// public CosmosDbInvoker Invoker { get; } = invoker ?? (_invoker ??= new CosmosDbInvoker()); @@ -58,36 +39,24 @@ private readonly struct Key(Type modelType, string containerId) /// public Container GetCosmosContainer(string containerId) => Database.GetContainer(containerId); - /// - public CosmosDbContainer Container(string containerId, CosmosDbArgs? dbArgs = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() => new(this, containerId, dbArgs); - - /// - public CosmosDbValueContainer ValueContainer(string containerId, CosmosDbArgs? dbArgs = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() => new(this, containerId, dbArgs); - - /// - public CosmosDbModelContainer ModelContainer(string containerId, CosmosDbArgs? dbArgs = null) where TModel : class, IEntityKey, new() => new(this, containerId, dbArgs); - - /// - public CosmosDbValueModelContainer ValueModelContainer(string containerId, CosmosDbArgs? dbArgs = null) where TModel : class, IEntityKey, new() => new(this, containerId, dbArgs); - /// - /// Sets the filter for all operations performed on the for the specified to ensure authorisation is applied. Applies automatically - /// to all queries, plus create, update, delete and get operations. + /// Gets the named leveraging the method. /// - /// The model persisted within the container. /// The identifier. - /// The filter query. - /// The instance to support fluent-style method-chaining. - public CosmosDb UseAuthorizeFilter(string containerId, Func filter) - { - if (!_filters.TryAdd(new Key(typeof(TModel), containerId.ThrowIfNull(nameof(containerId))), filter.ThrowIfNull(nameof(filter)))) - throw new InvalidOperationException("A filter cannot be overridden."); + /// The . + /// Provides indexing to the method; note that the configuration is expected to have been previously specified where required. + public CosmosDbContainer this[string containerId] => Container(containerId); - return this; - } + /// + public CosmosDbContainer Container(string containerId) => _containers.GetOrAdd(containerId.ThrowIfNullOrEmpty(nameof(containerId)), containerId => new CosmosDbContainer(this, containerId)); + + /// + public CosmosDbContainer Container(string containerId) where T : class, IEntityKey, new () where TModel : class, IEntityKey, new () + => Container(containerId).AsTyped(); /// - public Func? GetAuthorizeFilter(string containerId) => _filters.TryGetValue(new Key(typeof(TModel), containerId.ThrowIfNull(nameof(containerId))), out var filter) ? filter : null; + public CosmosDbValueContainer ValueContainer(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => Container(containerId).AsValueTyped(); /// public Result? HandleCosmosException(CosmosException cex) => OnCosmosException(cex); @@ -105,165 +74,5 @@ public CosmosDb UseAuthorizeFilter(string containerId, Func Result.Fail(new ConcurrencyException(null, cex)), _ => Result.Fail(cex) }; - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectMultiSetAsync(PartitionKey partitionKey, params IMultiSetArgs[] multiSetArgs) => SelectMultiSetAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// See for further details. - public async Task SelectMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetArgs[] multiSetArgs) => SelectMultiSetWithResultAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// The must all be from the same , be of type , and reference the same . Each - /// is verified and executed in the order specified. - /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the - /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") - /// Example usage is: - /// - /// private async Task<Result<MemberDetail?>> GetDetailOnImplementationAsync(int id) - /// { - /// MemberDetail? md = null; - /// return await Result.GoAsync(() => _cosmos.SelectMultiSetWithResultAsync(new AzCosmos.PartitionKey(id.ToString()), - /// _cosmos.Members.CreateMultiSetSingleArgs(m => md = m.CreateCopyFromAs<MemberDetail>(), isMandatory: false, stopOnNull: true), - /// _cosmos.MemberAddresses.CreateMultiSetCollArgs(mac => md.Adjust(x => x.Addresses = new (mac))))) - /// .ThenAs(() => md).ConfigureAwait(false); - /// } - /// - public async Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - { - // Verify that the multi set arguments are valid for this type of get query. - var multiSetList = multiSetArgs?.ToArray() ?? null; - if (multiSetList == null || multiSetList.Length == 0) - throw new ArgumentException($"At least one {nameof(IMultiSetArgs)} must be supplied.", nameof(multiSetArgs)); - - if (multiSetList.Any(x => x.Container.CosmosDb != this)) - throw new ArgumentException($"All {nameof(IMultiSetArgs)} containers must be from this same database.", nameof(multiSetArgs)); - - if (multiSetList.Any(x => !x.Container.IsCosmosDbValueModel)) - throw new ArgumentException($"All {nameof(IMultiSetArgs)} containers must be of type CosmosDbValueContainer.", nameof(multiSetArgs)); - - // Build the Cosmos SQL statement. - var container = multiSetList[0].Container; - var types = new Dictionary([ new KeyValuePair(container.ModelType.Name, multiSetList[0]) ]); - var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"SELECT * FROM c WHERE c.type in (\"{container.ModelType.Name}\"") : null; - - for (int i = 1; i < multiSetList.Length; i++) - { - if (multiSetList[i].Container.Container.Id != container.Container.Id) - throw new ArgumentException($"All {nameof(IMultiSetArgs)} containers must reference the same container id.", nameof(multiSetArgs)); - - if (!types.TryAdd(multiSetList[i].Container.ModelType.Name, multiSetList[i])) - throw new ArgumentException($"All {nameof(IMultiSetArgs)} containers must be of different model type.", nameof(multiSetArgs)); - - sb?.Append($", \"{multiSetList[i].Container.ModelType.Name}\""); - } - - sb?.Append(')'); - - // Execute the Cosmos DB query. - var result = await Invoker.InvokeAsync(this, container, sb?.ToString() ?? sql, types, async (_, container, sql, types, ct) => - { - // Set up for work. - var da = new CosmosDbArgs(container.DbArgs, partitionKey); - var qsi = container.Container.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); - IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; - var isStj = js is Text.Json.JsonSerializer; - - while (qsi.HasMoreResults) - { - var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); - if (!rm.IsSuccessStatusCode) - return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); - - var json = JsonDocument.Parse(rm.Content); - if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != JsonValueKind.Array) - return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); - - foreach (var jd in jds.EnumerateArray()) - { - if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != JsonValueKind.String) - return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); - - if (!types.TryGetValue(jt.GetString()!, out var msa)) - continue; // Ignore any unexpected type. - - var model = isStj - ? jd.Deserialize(msa.Container.ModelValueType, (JsonSerializerOptions)js.Options) - : js.Deserialize(jd.ToString(), msa.Container.ModelValueType); - - if (!msa.Container.IsModelValid(model, msa.Container.DbArgs, true)) - continue; - - var result = msa.AddItem(msa.Container.MapToValue(model)); - if (result.IsFailure) - return result; - } - } - - return Result.Success; - }, cancellationToken).ConfigureAwait(false); - - if (result.IsFailure) - return result; - - // Validate the multi-set args and action each accordingly. - foreach (var msa in multiSetList) - { - var r = msa.Verify(); - if (r.IsFailure) - return r.AsResult(); - - if (!r.Value && msa.StopOnNull) - break; - - msa.Invoke(); - } - - return Result.Success; - } } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbArgs.cs b/src/CoreEx.Cosmos/CosmosDbArgs.cs index d4c21580..322f188c 100644 --- a/src/CoreEx.Cosmos/CosmosDbArgs.cs +++ b/src/CoreEx.Cosmos/CosmosDbArgs.cs @@ -2,7 +2,10 @@ using CoreEx.Entities; using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Net; namespace CoreEx.Cosmos @@ -31,6 +34,7 @@ public CosmosDbArgs(CosmosDbArgs template, PartitionKey? partitionKey = null) AutoMapETag = template.AutoMapETag; CleanUpResult = template.CleanUpResult; FilterByTenantId = template.FilterByTenantId; + FilterByIsDeleted = template.FilterByIsDeleted; GetTenantId = template.GetTenantId; FormatIdentifier = template.FormatIdentifier; } @@ -117,13 +121,18 @@ private readonly QueryRequestOptions UpdateQueryRequestionOptionsPartitionKey(Qu /// public bool FilterByTenantId { get; set; } + /// + /// Indicates that when the underlying model implements it should filter out any models where the equals true. Defaults to true. + /// + public bool FilterByIsDeleted { get; set; } = true; + /// /// Gets or sets the get tenant identifier function; defaults to . /// public Func GetTenantId { get; set; } = () => ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null; /// - /// Formats a to a representation (used by and ). + /// Formats a to a representation (used by and ). /// /// The identifier as a . /// Defaults to . @@ -133,5 +142,43 @@ private readonly QueryRequestOptions UpdateQueryRequestionOptionsPartitionKey(Qu /// Provides the default implementation; being the . /// public static Func DefaultFormatIdentifier { get; } = key => key.ToString(); + + /// + /// Determines whether the model is considered valid; i.e. is not null, and where applicable, checks the and properties. + /// + /// The model . + /// The model value. + /// Indicates whether to perform the check. + /// Indicates whether to perform the check. + /// true indicates that the model is valid; otherwise, false. + /// This is used by the underlying operations to ensure the model is considered valid or not, and then handled accordingly. The query-based operations leverage the corresponding filter. + /// This leverages the to perform the check to ensure consistency of implementation. + public readonly bool IsModelValid([NotNullWhen(true)] TModel? model, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class + => model != default && WhereModelValid(new[] { model }.AsQueryable(), checkIsDeleted, checkTenantId).Any(); + + /// + /// Filters the to include only valid models; i.e. checks the and properties. + /// + /// The model . + /// The current query. + /// Indicates whether to perform the check. + /// Indicates whether to perform the check. + /// The updated query. + /// This is used by the underlying , , and to apply standardized filtering. + public readonly IQueryable WhereModelValid(IQueryable query, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class + { + query = query.ThrowIfNull(nameof(query)); + + if (checkTenantId && FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) + { + var tenantId = GetTenantId(); + query = query.Where(x => ((ITenantId)x).TenantId == tenantId); + } + + if (checkIsDeleted && FilterByIsDeleted && typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) + query = query.Where(x => !((ILogicallyDeleted)x).IsDeleted.IsDefined() || ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); + + return query; + } } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.cs b/src/CoreEx.Cosmos/CosmosDbContainer.cs index d14dcd8c..281eeed9 100644 --- a/src/CoreEx.Cosmos/CosmosDbContainer.cs +++ b/src/CoreEx.Cosmos/CosmosDbContainer.cs @@ -1,35 +1,100 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Abstractions; +using CoreEx.Cosmos.Model; using CoreEx.Entities; +using CoreEx.Json; +using CoreEx.Mapping; +using CoreEx.Results; using Microsoft.Azure.Cosmos; using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Stj = System.Text.Json; namespace CoreEx.Cosmos { /// - /// Provides the core capabilities. + /// Provides capabilities. /// - /// The . - /// The identifier. - /// The optional . - public class CosmosDbContainer(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : ICosmosDbContainerCore + /// The property () provides the underlying capabilities for direct model-based access. + public partial class CosmosDbContainer { - private CosmosDbArgs? _dbArgs = dbArgs; + private readonly Lazy _model; + private Func? _dbArgsFactory; + private readonly ConcurrentDictionary<(Type, Type), object> _containers = new(); + private readonly ConcurrentDictionary<(Type, Type), object> _valueContainers = new(); - /// - public ICosmosDb CosmosDb { get; } = cosmosDb.ThrowIfNull(nameof(cosmosDb)); + /// + /// Initializes a new instance of the . + /// + /// The . + /// The identifier. + public CosmosDbContainer(ICosmosDb cosmosDb, string containerId) + { + CosmosDb = cosmosDb.ThrowIfNull(nameof(cosmosDb)); + CosmosContainer = cosmosDb.GetCosmosContainer(containerId.ThrowIfNullOrEmpty(nameof(containerId))); + _model = new(() => new(this)); + } + + /// + /// Gets the owning . + /// + public ICosmosDb CosmosDb { get; } + + /// + /// Gets the . + /// + public Container CosmosContainer { get; } + + /// + /// Gets the Container-specific . + /// + /// Defaults to ; otherwise, . + public CosmosDbArgs DbArgs => _dbArgsFactory?.Invoke() ?? new CosmosDbArgs(CosmosDb.DbArgs); + + /// + /// Sets the container-specific . + /// + /// The creation factory. + /// This can only be set once; otherwise, a will be thrown. + public CosmosDbContainer UseDbArgs(Func dbArgsFactory) + { + dbArgsFactory.ThrowIfNull(nameof(dbArgsFactory)); + if (_dbArgsFactory is not null) + throw new InvalidOperationException($"The {nameof(UseDbArgs)} can only be specified once."); + + _dbArgsFactory = dbArgsFactory; + return this; + } - /// - public Container Container { get; } = cosmosDb.GetCosmosContainer(containerId.ThrowIfNullOrEmpty(nameof(containerId))); + /// + /// Gets the that encapsulates the direct-to-model operations. + /// + public CosmosDbModelContainer Model => _model.Value; + + /// + /// Gets or sets the SQL statement format for the MultiSet operation. + /// + /// The SQL statement format must have the {0}> place holder for the list of types represented as comma-separated strings; e.g. "Customer", "Address". + public string MultiSetSqlStatementFormat { get; private set; } = "SELECT * FROM c WHERE c.type in ({0})"; /// - /// Gets or sets the Container-specific . + /// Sets the for the MultiSet operations. /// - /// Defaults to on first access. - public CosmosDbArgs DbArgs + /// The SQL statement format. + public CosmosDbContainer UseMultiSetSqlStatement(string format) { - get => _dbArgs ??= new CosmosDbArgs(CosmosDb.DbArgs); - set => _dbArgs = value; + if (!format.ThrowIfNullOrEmpty(nameof(format)).Contains("{0}")) + throw new ArgumentException("The format must contain '{0}' to insert the 'in' list (contents).", nameof(format)); + + return this; } /// @@ -39,5 +104,862 @@ public CosmosDbArgs DbArgs /// The CosmosDb identifier. /// Uses the to format the as a string (as required). public virtual string GetCosmosId(CompositeKey key) => DbArgs.FormatIdentifier(key) ?? throw new InvalidOperationException("The CompositeKey formatting into an identifier must not result in a null."); + + /// + /// Gets the CosmosDb identifier from the . + /// + /// The model value. + /// The CosmosDb identifier. + public string GetCosmosId(TModel model) where TModel : class, IEntityKey => GetCosmosId(model.ThrowIfNull(nameof(model)).EntityKey); + + /// + /// Gets the . + /// + public PartitionKey GetPartitionKey(PartitionKey? partitionKey) => partitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; + + /// + /// Sets the function to get the from the model used by the (used by only by the Create and Update operations). + /// + /// The cosmos model . + /// The function to get the from the model. + /// This can only be set once; otherwise, a will be thrown. + public CosmosDbContainer UsePartitionKey(Func? getPartitionKey) where TModel : class, IEntityKey, new() + { + Model.UsePartitionKey(getPartitionKey); + return this; + } + + /// + /// Sets the function to get the from the used by the (used by only by the Create and Update operations). + /// + /// The cosmos model . + /// The function to get the from the model. + /// This can only be set once; otherwise, a will be thrown. + public CosmosDbContainer UseValuePartitionKey(Func, PartitionKey>? getPartitionKey) where TModel : class, IEntityKey, new() + { + Model.UsePartitionKey(getPartitionKey); + return this; + } + + /// + /// Sets (overrides) the name for the model . + /// + /// The cosmos model . + /// The model name. + public CosmosDbContainer UseModelName(string name) where TModel : class, IEntityKey, new() + { + Model.UseModelName(name); + return this; + } + + /// + /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. + /// + /// The cosmos model . + /// The authorization filter query. + public CosmosDbContainer UseAuthorizeFilter(Func, IQueryable>? filter) where TModel : class, IEntityKey, new() + { + Model.UseAuthorizeFilter(filter); + return this; + } + + /// + /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. + /// + /// The cosmos model . + /// The authorization filter query. + public CosmosDbContainer UseValueAuthorizeFilter(Func>, IQueryable>>? filter) where TModel : class, IEntityKey, new() + { + Model.UseAuthorizeFilter(filter); + return this; + } + + /// + /// Maps to the entity value formatting/updating any special properties as required. + /// + /// The entity . + /// The cosmos model . + /// The model value. + /// The . + /// The entity value. + [return: NotNullIfNotNull(nameof(model))] + public T? MapToValue(TModel? model, CosmosDbArgs dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + if (model is null) + return default; + + // Map the model to the entity value. + var val = CosmosDb.Mapper.Map(model, OperationTypes.Get)!; + if (dbArgs.AutoMapETag && val is IETag et && et.ETag != null) + et.ETag = ETagGenerator.ParseETag(et.ETag); + + return dbArgs.CleanUpResult ? Cleaner.Clean(val) : val; + } + + /// + /// Maps to the entity value formatting/updating any special properties as required. + /// + /// The entity . + /// The cosmos model . + /// The value. + /// The . + /// The entity value. + [return: NotNullIfNotNull(nameof(model))] + public T? MapToValue(CosmosDbValue? model, CosmosDbArgs dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + if (model is null) + return default; + + ((ICosmosDbValue)model).PrepareAfter(dbArgs); + var val = CosmosDb.Mapper.Map(model.Value, OperationTypes.Get)!; + if (dbArgs.AutoMapETag && val is IETag et) + { + if (et.ETag is not null) + et.ETag = ETagGenerator.ParseETag(et.ETag); + else + et.ETag = ETagGenerator.ParseETag(model.ETag); + } + + return DbArgs.CleanUpResult ? Cleaner.Clean(val) : val; + } + + /// + /// Gets (or adds) the typed for the specified and . + /// + /// The entity . + /// The cosmos model . + /// An optional action to perform one-off configuration on initial access. + /// The typed + public CosmosDbContainer AsTyped(Action>? configure = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (CosmosDbContainer)_containers.GetOrAdd((typeof(T), typeof(TModel)), _ => + { + var c = new CosmosDbContainer(this); + configure?.Invoke(c); + return c; + }); + + /// + /// Gets (or adds) the typed for the specified and . + /// + /// The entity . + /// The cosmos model . + /// An optional action to perform one-off configuration on initial access. + /// The typed + public CosmosDbValueContainer AsValueTyped(Action>? configure = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (CosmosDbValueContainer)_valueContainers.GetOrAdd((typeof(T), typeof(TModel)), _ => + { + var c = new CosmosDbValueContainer(this); + configure?.Invoke(c); + return c; + }); + + #region Query + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The entity . + /// The cosmos model . + /// The function to perform additional query execution. + /// The . + public CosmosDbQuery Query(Func, IQueryable>? query) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => Query(new CosmosDbArgs(DbArgs), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => Query(new CosmosDbArgs(DbArgs, partitionKey), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => new(this, dbArgs, query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The entity . + /// The cosmos model . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery ValueQuery(Func>, IQueryable>>? query) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ValueQuery(new CosmosDbArgs(DbArgs), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery ValueQuery(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => ValueQuery(new CosmosDbArgs(DbArgs, partitionKey), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery ValueQuery(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => new(this, dbArgs, query); + + #endregion + + #region Get + + /// + /// Gets the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => GetWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + + /// + /// Gets the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => GetWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + + /// + /// Gets the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + var result = await Model.GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); + return result.ThenAs(m => MapToValue(m, dbArgs)); + } + + /// + /// Gets the entity (using underlying ) for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => GetValueWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => GetValueWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified .. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task GetValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public async Task> GetValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + var result = await Model.GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); + return result.ThenAs(m => MapToValue(m, dbArgs)); + } + + #endregion + + #region Create + + /// + /// Creates the entity. + /// + /// The entity . + /// The cosmos model . + /// The value to create. + /// The . + /// The created value. + public async Task CreateAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await CreateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the entity with a . + /// + /// The entity . + /// The cosmos model . + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => CreateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); + + /// + /// Creates the entity. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to create. + /// The . + /// The created value. + public async Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await CreateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the entity with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to create. + /// The . + /// The created value. + public async Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + TModel model = CosmosDb.Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create)!; + + var result = await Model.CreateWithResultAsync(dbArgs, Cleaner.PrepareCreate(model), cancellationToken).ConfigureAwait(false); + return result.ThenAs(model => MapToValue(model, dbArgs)!); + } + + /// + /// Creates the entity (using underlying ). + /// + /// The entity . + /// The cosmos model . + /// The value to create. + /// The . + /// The created value. + public async Task CreateValueAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await CreateValueWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the entity (using underlying ) with a . + /// + /// The entity . + /// The cosmos model . + /// The value to create. + /// The . + /// The created value. + public Task> CreateValueWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => CreateValueWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); + + /// + /// Creates the entity (using underlying ). + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to create. + /// The . + /// The created value. + public async Task CreateValueAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await CreateValueWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the entity (using underlying ) with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to create. + /// The . + /// The created value. + public async Task> CreateValueWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + TModel model = CosmosDb.Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create); + var cdv = new CosmosDbValue(Model.GetModelName(), Cleaner.PrepareCreate(model)!); + + var result = await Model.CreateValueWithResultAsync(dbArgs, cdv, cancellationToken).ConfigureAwait(false); + return result.ThenAs(model => MapToValue(model, dbArgs)!); + } + + #endregion + + #region Update + + /// + /// Updates the entity. + /// + /// The entity . + /// The cosmos model . + /// The value to update. + /// The . + /// The updated value. + public async Task UpdateAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await UpdateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the entity with a . + /// + /// The entity . + /// The cosmos model . + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => UpdateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); + + /// + /// Updates the entity. + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to update. + /// The . + /// The updated value. + public async Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await UpdateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the entity with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to update. + /// The . + /// The updated value. + public async Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + var model = CosmosDb.Mapper.Map(Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), OperationTypes.Update); + var result = await Model.UpdateWithResultInternalAsync(dbArgs, Cleaner.PrepareUpdate(model), m => CosmosDb.Mapper.Map(value, m, OperationTypes.Update), cancellationToken).ConfigureAwait(false); + return result.ThenAs(model => MapToValue(model, dbArgs)!); + } + + /// + /// Updates the entity (using underlying ). + /// + /// The entity . + /// The cosmos model . + /// The value to update. + /// The . + /// The updated value. + public async Task UpdateValueAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await UpdateValueWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the entity (using underlying ) with a . + /// + /// The entity . + /// The cosmos model . + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateValueWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => UpdateValueWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); + + /// + /// Updates the entity (using underlying ). + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to update. + /// The . + /// The updated value. + public async Task UpdateValueAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await UpdateValueWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the entity (using underlying ) with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The value to update. + /// The . + /// The updated value. + public async Task> UpdateValueWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + var model = CosmosDb.Mapper.Map(Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), OperationTypes.Update)!; + var cdv = new CosmosDbValue(Model.GetModelName(), Cleaner.PrepareUpdate(model!)); + + var result = await Model.UpdateValueWithResultInternalAsync(dbArgs, cdv, cdv => CosmosDb.Mapper.Map(value, cdv.Value, OperationTypes.Update), cancellationToken).ConfigureAwait(false); + return result.ThenAs(model => MapToValue(model, dbArgs)!); + } + + #endregion + + #region Delete + + /// + /// Deletes the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => DeleteWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + + /// + /// Deletes the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => DeleteWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + + /// + /// Deletes the entity for the specified . + /// + /// The entity . + /// The cosmos model . + /// The .. + /// The . + /// The . + public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The .. + /// The . + /// The . + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => Model.DeleteWithResultAsync(dbArgs, key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + public async Task DeleteValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . + public Task DeleteValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => DeleteValueWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public async Task DeleteValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public Task DeleteValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => DeleteValueWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The entity . + /// The cosmos model . + /// The .. + /// The . + /// The . + public async Task DeleteValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => (await DeleteValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The entity . + /// The cosmos model . + /// The .. + /// The . + /// The . + public Task DeleteValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + => Model.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region MultiSet + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectValueMultiSetAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectValueMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// The override SQL statement; will default where not specified. + /// One or more . + /// The . + /// See for further details. + public async Task SelectValueMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + => (await SelectValueMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetWithResultAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// The override SQL statement; will default to where not specified. + /// One or more . + /// The . + /// The must be of type . Each is verified and executed in the order specified. + /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the + /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") + /// + public async Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + { + // Verify that the multi set arguments are valid for this type of get query. + var multiSetList = multiSetArgs?.ToArray() ?? null; + if (multiSetList == null || multiSetList.Length == 0) + throw new ArgumentException($"At least one {nameof(IMultiSetValueArgs)} must be supplied.", nameof(multiSetArgs)); + + // Build the Cosmos SQL statement. + var name = multiSetList[0].GetModelName(this); + var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); + var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"\"{name}\"") : null; + + if (sb is not null) + { + for (int i = 1; i < multiSetList.Length; i++) + { + name = multiSetList[i].GetModelName(this); + if (!types.TryAdd(name, multiSetList[i])) + throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); + + sb.Append($", \"{name}\""); + } + + sql = string.Format(MultiSetSqlStatementFormat, sb.ToString()); + } + + // Execute the Cosmos DB query. + var result = await CosmosDb.Invoker.InvokeAsync(CosmosDb, this, sql, types, async (_, container, sql, types, ct) => + { + // Set up for work. + var da = new CosmosDbArgs(container.DbArgs, partitionKey); + var qsi = container.CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); + IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; + var isStj = js is Text.Json.JsonSerializer; + + while (qsi.HasMoreResults) + { + var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); + if (!rm.IsSuccessStatusCode) + return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); + + var json = Stj.JsonDocument.Parse(rm.Content); + if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) + return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); + + foreach (var jd in jds.EnumerateArray()) + { + if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) + return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); + + if (!types.TryGetValue(jt.GetString()!, out var msa)) + continue; // Ignore any unexpected type. + + var model = isStj + ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) + : js.Deserialize(jd.ToString(), msa.Type); + + if (model is null) + return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); + + var result = msa.AddItem(container, da, model); + if (result.IsFailure) + return result; + } + } + + return Result.Success; + }, cancellationToken).ConfigureAwait(false); + + if (result.IsFailure) + return result; + + // Validate the multi-set args and action each accordingly. + foreach (var msa in multiSetList) + { + var r = msa.Verify(); + if (r.IsFailure) + return r.AsResult(); + + if (!r.Value && msa.StopOnNull) + break; + + msa.Invoke(); + } + + return Result.Success; + } + + #endregion } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbContainerBaseT.cs b/src/CoreEx.Cosmos/CosmosDbContainerBaseT.cs deleted file mode 100644 index c875bd88..00000000 --- a/src/CoreEx.Cosmos/CosmosDbContainerBaseT.cs +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides base operations for a container. - /// - /// The entity . - /// The cosmos model . - /// The itself. - /// The . - /// The identifier. - /// The optional . - public abstract class CosmosDbContainerBase(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : CosmosDbContainer(cosmosDb, containerId, dbArgs), ICosmosDbContainer - where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() where TSelf : CosmosDbContainerBase - { - /// - Type ICosmosDbContainer.EntityType => typeof(T); - - /// - Type ICosmosDbContainer.ModelType => typeof(TModel); - - /// - Type ICosmosDbContainer.ModelValueType => typeof(CosmosDbValue); - - /// - bool ICosmosDbContainer.IsCosmosDbValueModel => IsCosmosDbValueModel; - - /// - /// Indicates whether the is encapsulated within a . - /// - protected bool IsCosmosDbValueModel { get; set; } = false; - - /// - bool ICosmosDbContainer.IsModelValid(object? model, CoreEx.Cosmos.CosmosDbArgs args, bool checkAuthorized) => IsModelValid(model, args, checkAuthorized); - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The model value (also depends on ). - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - protected abstract bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized); - - /// - object? ICosmosDbContainer.MapToValue(object? model) => MapToValue(model); - - /// - /// Maps the model into the entity value. - /// - /// The model value (also depends on ). - /// The entity value. - protected abstract T? MapToValue(object? model); - - /// - /// Gets the CosmosDb identifier from the . - /// - /// The entity value. - /// The CosmosDb identifier. - public string GetCosmosId(T value) => GetCosmosId(value.ThrowIfNull(nameof(value)).EntityKey); - - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, cancellationToken); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); - - /// - public abstract Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - - /// - /// Creates the entity. - /// - /// The value to create. - /// The . - /// The created value. - public async Task CreateAsync(T value, CancellationToken cancellationToken = default) => await CreateWithResultAsync(value, cancellationToken).ConfigureAwait(false); - - /// - /// Creates the entity with a . - /// - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => CreateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - public async Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => await CreateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false); - - /// - public abstract Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Updates the entity. - /// - /// The value to update. - /// The . - /// The updated value. - public async Task UpdateAsync(T value, CancellationToken cancellationToken = default) => await UpdateWithResultAsync(value, cancellationToken).ConfigureAwait(false); - - /// - /// Updates the entity with a . - /// - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => UpdateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - public async Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => await UpdateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false); - - /// - public abstract Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Deletes the entity for the specified . - /// - /// The . - /// The . - public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - public abstract Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbContainerT.cs b/src/CoreEx.Cosmos/CosmosDbContainerT.cs index 70522896..c4c6497c 100644 --- a/src/CoreEx.Cosmos/CosmosDbContainerT.cs +++ b/src/CoreEx.Cosmos/CosmosDbContainerT.cs @@ -1,13 +1,10 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Abstractions; using CoreEx.Cosmos.Model; using CoreEx.Entities; -using CoreEx.Mapping; using CoreEx.Results; using Microsoft.Azure.Cosmos; using System; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,67 +12,47 @@ namespace CoreEx.Cosmos { /// - /// Provides operations for a container. + /// Provides a typed interface for the primary operations. /// /// The entity . /// The cosmos model . - public class CosmosDbContainer : CosmosDbContainerBase> where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public sealed class CosmosDbContainer where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { - private readonly Lazy> _modelContainer; + private CosmosDbModelContainer? _model; /// /// Initializes a new instance of the class. /// - /// The . - /// The identifier. - /// The optional . - public CosmosDbContainer(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : base(cosmosDb, containerId, dbArgs) - => _modelContainer = new(() => new CosmosDbModelContainer(CosmosDb, Container.Id, DbArgs)); + /// The owning . + internal CosmosDbContainer(CosmosDbContainer owner) + { + Container = owner.ThrowIfNull(nameof(owner)); + CosmosContainer = Container.CosmosContainer; + } /// - /// Gets the underlying . + /// Gets the owning . /// - public CosmosDbModelContainer ModelContainer => _modelContainer.Value; + public CosmosDbContainer Container { get; } /// - /// Sets the function to determine the ; used for (only Create and Update operations). + /// Gets the . /// - /// The function to determine the . - /// The instance to support fluent-style method-chaining. - /// This is used where there is a value and the corresponding needs to be dynamically determined. - public CosmosDbContainer UsePartitionKey(Func partitionKey) - { - ModelContainer.UsePartitionKey(partitionKey); - return this; - } - - /// - protected override bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized) => ModelContainer.IsModelValid((TModel?)model, args, checkAuthorized); - - /// - protected override T? MapToValue(object? model) => MapToValue((TModel?)model!); + public Container CosmosContainer { get; } /// - /// Maps to the entity value formatting/updating any special properties as required. + /// Gets the typed . /// - /// The model value. - /// The entity value. - [return: NotNullIfNotNull(nameof(model))] - public T? MapToValue(TModel? model) - { - var val = CosmosDb.Mapper.Map(model, OperationTypes.Get)!; - if (DbArgs.AutoMapETag && val is IETag et && et.ETag != null) - et.ETag = ETagGenerator.ParseETag(et.ETag); + public CosmosDbModelContainer Model => _model ??= new(Container); - return DbArgs.CleanUpResult ? Cleaner.Clean(val) : val; - } + #region Query /// /// Gets (creates) a to enable LINQ-style queries. /// /// The function to perform additional query execution. /// The . - public CosmosDbQuery Query(Func, IQueryable>? query) => Query(new CosmosDbArgs(DbArgs), query); + public CosmosDbQuery Query(Func, IQueryable>? query) => Container.Query(query); /// /// Gets (creates) a to enable LINQ-style queries. @@ -83,7 +60,7 @@ public CosmosDbContainer UsePartitionKey(Func p /// The . /// The function to perform additional query execution. /// The . - public CosmosDbQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Query(new CosmosDbArgs(DbArgs, partitionKey), query); + public CosmosDbQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Container.Query(partitionKey, query); /// /// Gets (creates) a to enable LINQ-style queries. @@ -91,35 +68,190 @@ public CosmosDbContainer UsePartitionKey(Func p /// The . /// The function to perform additional query execution. /// The . - public CosmosDbQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => new(this, dbArgs, query); + public CosmosDbQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => Container.Query(dbArgs, query); - /// - public async override Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) - { - var result = await ModelContainer.GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); - return result.ThenAs(MapToValue); - } + #endregion - /// - public override async Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) - { - ChangeLog.PrepareCreated(value.ThrowIfNull(nameof(value))); - TModel model = CosmosDb.Mapper.Map(value, OperationTypes.Create)!; + #region Get - var result = await ModelContainer.CreateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model)!); - } + /// + /// Gets the entity for the specified . + /// + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetAsync(key, cancellationToken); - /// - public override async Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) - { - ChangeLog.PrepareUpdated(value); - var model = CosmosDb.Mapper.Map(value.ThrowIfNull(nameof(value)), OperationTypes.Update)!; - var result = await ModelContainer.UpdateWithResultInternalAsync(dbArgs, model, m => CosmosDb.Mapper.Map(value, m, OperationTypes.Update), cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model)!); - } + /// + /// Gets the entity for the specified with a . + /// + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(key, cancellationToken); + + /// + /// Gets the entity for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the entity for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the entity for the specified . + /// + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetAsync(dbArgs, key, cancellationToken); + + /// + /// Gets the entity for the specified with a . + /// + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region Create + + /// + /// Creates the entity. + /// + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(T value, CancellationToken cancellationToken = default) => Container.CreateAsync(value, cancellationToken); + + /// + /// Creates the entity with a . + /// + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.CreateWithResultAsync(value, cancellationToken); + + /// + /// Creates the entity. + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateAsync(dbArgs, value, cancellationToken); + + /// + /// Creates the entity with a . + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Update + + /// + /// Updates the entity. + /// + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateAsync(value, cancellationToken); + + /// + /// Updates the entity with a . + /// + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateWithResultAsync(value, cancellationToken); + + /// + /// Updates the entity. + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateAsync(dbArgs, value, cancellationToken); + + /// + /// Updates the entity with a . + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Delete + + /// + /// Deletes the entity for the specified . + /// + /// The . + /// The . + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteAsync(key, cancellationToken); + + /// + /// Deletes the entity for the specified with a . + /// + /// The . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(key, cancellationToken); + + /// + /// Deletes the entity for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the entity for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the entity for the specified . + /// + /// The .. + /// The . + /// The . + public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteAsync(dbArgs, key, cancellationToken); + + /// + /// Deletes the entity for the specified with a . + /// + /// The .. + /// The . + /// The . + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(dbArgs, key, cancellationToken); - /// - public override Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => ModelContainer.DeleteWithResultAsync(dbArgs, key, cancellationToken); + #endregion } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbQuery.cs b/src/CoreEx.Cosmos/CosmosDbQuery.cs index d4a51585..709260f0 100644 --- a/src/CoreEx.Cosmos/CosmosDbQuery.cs +++ b/src/CoreEx.Cosmos/CosmosDbQuery.cs @@ -15,18 +15,13 @@ namespace CoreEx.Cosmos /// /// The resultant . /// The cosmos model . - /// The . + /// The . /// The . /// A function to modify the underlying . - public class CosmosDbQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public class CosmosDbQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { private readonly Func, IQueryable>? _query = query; - /// - /// Gets the . - /// - public new CosmosDbContainer Container => (CosmosDbContainer)base.Container; - /// /// Instantiates the . /// @@ -35,22 +30,23 @@ private IQueryable AsQueryable(bool allowSynchronousQueryExecution, bool if (!pagingSupported && Paging is not null) throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - IQueryable query = Container.Container.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); + IQueryable query = Container.CosmosContainer.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); query = _query == null ? query : _query(query); - var filter = Container.CosmosDb.GetAuthorizeFilter(Container.Container.Id); + var filter = Container.Model.GetAuthorizeFilter(); if (filter != null) - query = (IQueryable)filter(query); - - if (QueryArgs.FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => ((ITenantId)x).TenantId == QueryArgs.GetTenantId()); + query = filter(query); - if (typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => !((ILogicallyDeleted)x).IsDeleted.IsDefined() || ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); - - return query; + return QueryArgs.WhereModelValid(query); } + /// + /// Gets a pre-prepared with filtering applied as applicable. + /// + /// The . + /// The is not supported. The query will not be automatically included within an execution. + public IQueryable AsQueryable() => AsQueryable(true, false); + /// public override Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, coll, async (_, items, ct) => { @@ -62,7 +58,7 @@ public override Task SelectQueryWithResultAsync(TColl coll, Cance foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) { if (item is not null) - items.Add(Container.MapToValue(item)); + items.Add(Container.MapToValue(item, QueryArgs)!); } } diff --git a/src/CoreEx.Cosmos/CosmosDbQueryBase.cs b/src/CoreEx.Cosmos/CosmosDbQueryBase.cs index 68223ad2..d40f4e1c 100644 --- a/src/CoreEx.Cosmos/CosmosDbQueryBase.cs +++ b/src/CoreEx.Cosmos/CosmosDbQueryBase.cs @@ -16,12 +16,14 @@ namespace CoreEx.Cosmos /// The resultant . /// The cosmos model . /// The itself. - public abstract class CosmosDbQueryBase(ICosmosDbContainer container, CosmosDbArgs dbArgs) where T : class, new() where TModel : class, new() where TSelf : CosmosDbQueryBase + /// The owning . + /// The . + public abstract class CosmosDbQueryBase(CosmosDbContainer container, CosmosDbArgs dbArgs) where T : class, new() where TModel : class, new() where TSelf : CosmosDbQueryBase { /// - /// Gets the . + /// Gets the owning . /// - public ICosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); + public CosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); /// /// Gets the . @@ -203,7 +205,7 @@ public async Task> ToArrayWithResultAsync(CancellationToken cancella { var list = new List(); var result = await SelectQueryWithResultAsync(list, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => list.ToArray()); + return result.ThenAs(list.ToArray); } /// diff --git a/src/CoreEx.Cosmos/CosmosDbValue.cs b/src/CoreEx.Cosmos/CosmosDbValue.cs index cea68270..0dbe10f4 100644 --- a/src/CoreEx.Cosmos/CosmosDbValue.cs +++ b/src/CoreEx.Cosmos/CosmosDbValue.cs @@ -4,24 +4,26 @@ using CoreEx.Cosmos.Model; using CoreEx.Entities; using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace CoreEx.Cosmos { /// - /// Represents a special-purpose CosmosDb object that houses an underlying model-, including name, and flexible , for persistence. + /// Represents a special-purpose CosmosDb object that houses an underlying model-, including name, and flexible , for persistence. /// - /// The model . - /// The , and are updated internally, where possible, when interacting directly with CosmosDB. + /// The model . + /// The , and are updated internally, where possible, when interacting directly with CosmosDB. public sealed class CosmosDbValue : CosmosDbModelBase, ICosmosDbValue where TModel : class, IEntityKey, new() { private TModel _value; + private string _type; /// /// Initializes a new instance of the class. /// public CosmosDbValue() { - Type = typeof(TModel).Name; + _type = typeof(TModel).Name; _value = new(); } @@ -31,31 +33,33 @@ public CosmosDbValue() /// The value. public CosmosDbValue(TModel value) { - Type = typeof(TModel).Name; + _type = typeof(TModel).Name; _value = value.ThrowIfNull(nameof(value)); } /// /// Initializes a new instance of the class with a and . /// - /// The name override. + /// The name override. /// The value. public CosmosDbValue(string? type, TModel value) { - Type = type ?? typeof(TModel).Name; + _type = type ?? typeof(TModel).Name; _value = value.ThrowIfNull(nameof(value)); } /// - /// Gets or sets the name. + /// Gets or sets the name. /// [JsonProperty("type")] - public string Type { get; set; } + [JsonPropertyName("type")] + public string Type { get => _type; set => _type = value.ThrowIfNullOrEmpty(nameof(Type)); } /// /// Gets or sets the value. /// [JsonProperty("value")] + [JsonPropertyName("value")] public TModel Value { get => _value; set => _value = value.ThrowIfNull(nameof(Value)); } /// @@ -64,7 +68,7 @@ public CosmosDbValue(string? type, TModel value) object ICosmosDbValue.Value => _value; /// - void ICosmosDbValue.PrepareBefore(CosmosDbArgs dbArgs, string? typeName) + void ICosmosDbValue.PrepareBefore(CosmosDbArgs dbArgs, string? type) { if (Value != default) { @@ -77,8 +81,10 @@ void ICosmosDbValue.PrepareBefore(CosmosDbArgs dbArgs, string? typeName) PartitionKey = pk.PartitionKey; } - if (!string.IsNullOrEmpty(typeName)) - Type = typeName; + if (string.IsNullOrEmpty(type)) + Type ??= typeof(TModel).Name; + else + Type = type; } /// diff --git a/src/CoreEx.Cosmos/CosmosDbValueContainer.cs b/src/CoreEx.Cosmos/CosmosDbValueContainer.cs deleted file mode 100644 index 0922a514..00000000 --- a/src/CoreEx.Cosmos/CosmosDbValueContainer.cs +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides operations for a specified container. - /// - /// The entity . - /// The cosmos model . - /// Represents a special-purpose CosmosDb that houses an underlying , including name, and flexible , for persistence. - public class CosmosDbValueContainer : CosmosDbContainerBase> where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private readonly Lazy> _modelContainer; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The identifier. - /// The optional . - public CosmosDbValueContainer(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : base(cosmosDb, containerId, dbArgs) - { - _modelContainer = new(() => new CosmosDbValueModelContainer(CosmosDb, Container.Id, DbArgs)); - IsCosmosDbValueModel = true; - } - - /// - /// Gets the underlying . - /// - public CosmosDbValueModelContainer ModelContainer => _modelContainer.Value; - - /// - /// Sets the function to determine the ; used for (only Create and Update operations). - /// - /// The function to determine the . - /// The instance to support fluent-style method-chaining. - /// This is used where there is a value and the corresponding needs to be dynamically determined. - public CosmosDbValueContainer UsePartitionKey(Func, PartitionKey> partitionKey) - { - ModelContainer.UsePartitionKey(partitionKey); - return this; - } - - /// - /// Gets the value from the response updating any special properties as required. - /// - /// The response value. - /// The entity value. - internal T? GetResponseValue(Response> resp) - { - if (resp?.Resource == null) - return default; - - return MapToValue(resp.Resource); - } - - /// - protected override bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized) => ModelContainer.IsModelValid((CosmosDbValue?)model, args, checkAuthorized); - - /// - protected override T? MapToValue(object? model) => MapToValue((CosmosDbValue)model!); - - /// - /// Maps to the entity value formatting/updating any special properties as required. - /// - /// The model value. - /// The entity value. - [return: NotNullIfNotNull(nameof(model))] - public T? MapToValue(CosmosDbValue? model) - { - if (model is null) - return default; - - ((ICosmosDbValue)model).PrepareAfter(DbArgs); - var val = CosmosDb.Mapper.Map(model.Value, OperationTypes.Get)!; - if (DbArgs.AutoMapETag && val is IETag et) - { - if (et.ETag is not null) - et.ETag = ETagGenerator.ParseETag(et.ETag); - else - et.ETag = ETagGenerator.ParseETag(model.ETag); - } - - return DbArgs.CleanUpResult ? Cleaner.Clean(val) : val; - } - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(Func>, IQueryable>>? query) => Query(new CosmosDbArgs(DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Query(new CosmosDbArgs(DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => new(this, dbArgs, query); - - /// - public async override Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) - { - var result = await ModelContainer.GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); - return result.ThenAs(MapToValue); - } - - /// - public async override Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) - { - ChangeLog.PrepareCreated(value.ThrowIfNull(nameof(value))); - TModel model = CosmosDb.Mapper.Map(value, OperationTypes.Create)!; - var cvm = new CosmosDbValue(ModelContainer.TypeName, model!); - - var result = await ModelContainer.CreateWithResultAsync(dbArgs, cvm, cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model)!); - } - - /// - public async override Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) - { - ChangeLog.PrepareUpdated(value); - var model = CosmosDb.Mapper.Map(value.ThrowIfNull(nameof(value)), OperationTypes.Update)!; - var cvm = new CosmosDbValue(ModelContainer.TypeName, model!); - - var result = await ModelContainer.UpdateWithResultInternalAsync(dbArgs, cvm, cvm => CosmosDb.Mapper.Map(value, cvm.Value, OperationTypes.Update), cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model)!); - } - - /// - public override Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => ModelContainer.DeleteWithResultAsync(dbArgs, key, cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs b/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs new file mode 100644 index 00000000..006b93d4 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs @@ -0,0 +1,257 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Cosmos.Model; +using CoreEx.Entities; +using CoreEx.Results; +using Microsoft.Azure.Cosmos; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace CoreEx.Cosmos +{ + /// + /// Provides a typed interface for the primary operations. + /// + /// The entity . + /// The cosmos model . + public sealed class CosmosDbValueContainer where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + { + private CosmosDbValueModelContainer? _model; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + internal CosmosDbValueContainer(CosmosDbContainer owner) + { + Container = owner.ThrowIfNull(nameof(owner)); + CosmosContainer = owner.CosmosContainer; + } + + /// + /// Gets the owning . + /// + public CosmosDbContainer Container { get; } + + /// + /// Gets the . + /// + public Container CosmosContainer { get; } + + /// + /// Gets the typed . + /// + public CosmosDbValueModelContainer Model => _model ??= new(Container); + + #region Query + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery Query(Func>, IQueryable>>? query) => Container.ValueQuery(query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Container.ValueQuery(partitionKey, query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => Container.ValueQuery(dbArgs, query); + + #endregion + + #region Get + + /// + /// Gets the entity (using underlying ) for the specified . + /// + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueAsync(key, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(key, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetValueAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified . + /// + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueAsync(dbArgs, key, cancellationToken); + + /// + /// Gets the entity (using underlying ) for the specified with a . + /// + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region Create + + /// + /// Creates the entity (using underlying ). + /// + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(T value, CancellationToken cancellationToken = default) => Container.CreateValueAsync(value, cancellationToken); + + /// + /// Creates the entity (using underlying ) with a . + /// + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.CreateValueWithResultAsync(value, cancellationToken); + + /// + /// Creates the entity (using underlying ). + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateValueAsync(dbArgs, value, cancellationToken); + + /// + /// Creates the entity (using underlying ) with a . + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateValueWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Update + + /// + /// Updates the entity (using underlying ). + /// + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateValueAsync(value, cancellationToken); + + /// + /// Updates the entity (using underlying ) with a . + /// + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateValueWithResultAsync(value, cancellationToken); + + /// + /// Updates the entity (using underlying ). + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateValueAsync(dbArgs, value, cancellationToken); + + /// + /// Updates the entity (using underlying ) with a . + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateValueWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Delete + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The . + /// The . + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified . + /// + /// The .. + /// The . + /// The . + public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(dbArgs, key, cancellationToken); + + /// + /// Deletes the entity (using underlying ) for the specified with a . + /// + /// The .. + /// The . + /// The . + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValueQuery.cs b/src/CoreEx.Cosmos/CosmosDbValueQuery.cs index 1e5ba5d7..5edd51a9 100644 --- a/src/CoreEx.Cosmos/CosmosDbValueQuery.cs +++ b/src/CoreEx.Cosmos/CosmosDbValueQuery.cs @@ -15,18 +15,13 @@ namespace CoreEx.Cosmos /// /// The resultant . /// The cosmos model . - /// The . + /// The . /// The . /// A function to modify the underlying . - public class CosmosDbValueQuery(CosmosDbValueContainer container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public class CosmosDbValueQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { private readonly Func>, IQueryable>>? _query = query; - /// - /// Gets the . - /// - public new CosmosDbValueContainer Container => (CosmosDbValueContainer)base.Container; - /// /// Instantiates the . /// @@ -35,20 +30,14 @@ private IQueryable> AsQueryable(bool allowSynchronousQuery if (!pagingSupported && Paging is not null) throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - IQueryable> query = Container.Container.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = (_query == null ? query : _query(query)).WhereType(typeof(TModel)); + IQueryable> query = Container.CosmosContainer.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); + query = (_query == null ? query : _query(query)).WhereType(Container.Model.GetModelName()); - var filter = Container.CosmosDb.GetAuthorizeFilter(Container.Container.Id); + var filter = Container.Model.GetValueAuthorizeFilter(); if (filter != null) - query = (IQueryable>)filter(query); - - if (QueryArgs.FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => ((ITenantId)x.Value).TenantId == QueryArgs.GetTenantId()); - - if (typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => !((ILogicallyDeleted)x.Value).IsDeleted.IsDefined() || ((ILogicallyDeleted)x.Value).IsDeleted == null || ((ILogicallyDeleted)x.Value).IsDeleted == false); + query = filter(query); - return query; + return QueryArgs.WhereModelValid(query); } /// @@ -68,7 +57,7 @@ public override Task SelectQueryWithResultAsync(TColl coll, Cance { foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) { - items.Add(Container.MapToValue(item)); + items.Add(Container.MapToValue(item, QueryArgs)!); } } diff --git a/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs b/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs index 4ada6c9d..d4b2638f 100644 --- a/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs +++ b/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs @@ -1,12 +1,11 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx; -using CoreEx.Cosmos; using CoreEx.Entities; +using System; using System.Linq; using System.Linq.Dynamic.Core; -namespace System.Linq +namespace CoreEx.Cosmos { /// /// Adds additional extension methods to for where T is . @@ -20,15 +19,6 @@ public static class CosmosDbValueQueryableExtensions /// The query. /// The name. /// The query. - public static IQueryable> WhereType(this IQueryable> query, string typeName) where T : class, IEntityKey, new() => query.Where("type = @0", typeName.ThrowIfNull(nameof(typeName))); - - /// - /// Filters a sequence of values based on the equalling the . - /// - /// The being queried. - /// The query. - /// The . - /// The query. - public static IQueryable> WhereType(this IQueryable> query, Type type) where T : class, IEntityKey, new() => query.WhereType(type.ThrowIfNull(nameof(type)).Name); + public static IQueryable> WhereType(this IQueryable> query, string? typeName = null) where T : class, IEntityKey, new() => query.Where("type = @0", string.IsNullOrEmpty(typeName) ? typeof(T).Name : typeName); } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Extended/ExtendedExtensions.cs b/src/CoreEx.Cosmos/Extended/ExtendedExtensions.cs deleted file mode 100644 index b740c327..00000000 --- a/src/CoreEx.Cosmos/Extended/ExtendedExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System.Collections.Generic; -using System; - -namespace CoreEx.Cosmos.Extended -{ - /// - /// Provides extended extension methods. - /// - public static class ExtendedExtensions - { - /// - /// Creates a for the . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The action that will be invoked with the result of the set. - /// Indicates whether the value is mandatory; defaults to true. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - /// The . - /// Used by . - public static MultiSetSingleArgs CreateMultiSetSingleArgs(this CosmosDbValueContainer container, Action result, bool isMandatory = true, bool stopOnNull = false) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => new(container, result, isMandatory, stopOnNull); - - /// - /// Creates a for the . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The action that will be invoked with the result of the set. - /// The minimum number of items allowed. - /// The maximum numner of items allowed. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - /// The . - /// Used by . - public static MultiSetCollArgs CreateMultiSetCollArgs(this CosmosDbValueContainer container, Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => new(container, result, minItems, maxItems, stopOnNull); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDb.cs b/src/CoreEx.Cosmos/ICosmosDb.cs index 492e890b..19b37526 100644 --- a/src/CoreEx.Cosmos/ICosmosDb.cs +++ b/src/CoreEx.Cosmos/ICosmosDb.cs @@ -1,12 +1,10 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Cosmos.Model; using CoreEx.Entities; using CoreEx.Mapping; using CoreEx.Results; using Microsoft.Azure.Cosmos; using System; -using System.Linq; namespace CoreEx.Cosmos { @@ -43,42 +41,29 @@ public interface ICosmosDb Container GetCosmosContainer(string containerId); /// - /// Gets (creates) the for the specified . + /// Gets (or adds) the for the specified . /// - /// The entity . - /// The cosmos model . /// The identifier. - /// The . - /// The . - CosmosDbContainer Container(string containerId, CosmosDbArgs? dbArgs = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); + /// The . + CosmosDbContainer Container(string containerId); /// - /// Gets (creates) the for the specified . + /// Gets (or adds) the typed for the specified and . /// /// The entity . /// The cosmos model . /// The identifier. - /// The . - /// The . - CosmosDbValueContainer ValueContainer(string containerId, CosmosDbArgs? dbArgs = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); + /// The typed + CosmosDbContainer Container(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); /// - /// Gets (creates) the for the specified . - /// - /// The cosmos model . - /// The identifier. - /// The . - /// The . - CosmosDbModelContainer ModelContainer(string containerId, CosmosDbArgs? dbArgs = null) where TModel : class, IEntityKey, new(); - - /// - /// Gets (creates) the for the specified . + /// Gets (or adds) the typed for the specified and . /// + /// The entity . /// The cosmos model . /// The identifier. - /// The . - /// The . - CosmosDbValueModelContainer ValueModelContainer(string containerId, CosmosDbArgs? dbArgs = null) where TModel : class, IEntityKey, new(); + /// The typed + CosmosDbValueContainer ValueContainer(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); /// /// Invoked where a has been thrown. @@ -88,13 +73,5 @@ public interface ICosmosDb /// Provides an opportunity to inspect and handle the exception before it is returned. A resulting that is is not considered sensical; therefore, will result in the originating /// exception being thrown. Result? HandleCosmosException(CosmosException cex); - - /// - /// Gets the authorization filter. - /// - /// The cosmos model persisted within the container. - /// The identifier. - /// The filter query where found; otherwise, null. - Func? GetAuthorizeFilter(string containerId); } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbContainer.cs b/src/CoreEx.Cosmos/ICosmosDbContainer.cs deleted file mode 100644 index c9c6c9e0..00000000 --- a/src/CoreEx.Cosmos/ICosmosDbContainer.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Cosmos -{ - /// - /// Enables the entity and model capabilities. - /// - public interface ICosmosDbContainer : ICosmosDbContainerCore - { - /// - /// Gets the underlying entity . - /// - Type EntityType { get; } - - /// - /// Gets the underlying Cosmos model . - /// - Type ModelType { get; } - - /// - /// Gets the underlying Cosmos model . - /// - Type ModelValueType { get; } - - /// - /// Indicates whether the is encapsulated within a . - /// - bool IsCosmosDbValueModel { get; } - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The model value (also depends on ). - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized); - - /// - /// Maps the model into the entity value. - /// - /// The model value (also depends on ). - /// The entity value. - object? MapToValue(object? model); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbContainerCore.cs b/src/CoreEx.Cosmos/ICosmosDbContainerCore.cs deleted file mode 100644 index 3a0ef4df..00000000 --- a/src/CoreEx.Cosmos/ICosmosDbContainerCore.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos -{ - /// - /// Enables the core capabilities. - /// - public interface ICosmosDbContainerCore - { - /// - /// Gets the owning . - /// - ICosmosDb CosmosDb { get; } - - /// - /// Gets the . - /// - Container Container { get; } - - /// - /// Gets the Container-specific . - /// - CosmosDbArgs DbArgs { get; } - - /// - /// Gets the CosmosDb identifier from the . - /// - /// The . - /// The CosmosDb identifier. - string GetCosmosId(CompositeKey key); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbContainerT.cs b/src/CoreEx.Cosmos/ICosmosDbContainerT.cs deleted file mode 100644 index 3d7fccae..00000000 --- a/src/CoreEx.Cosmos/ICosmosDbContainerT.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Enables the core CRUD (create, read, update and delete) operations. - /// - /// The entity . - /// The cosmos model . - public interface ICosmosDbContainer : ICosmosDbContainer, ICosmosDbContainerCore where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - - /// - /// Creates the entity. - /// - /// The . - /// The value to create. - /// The . - /// The created value. - Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Updates the entity. - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Deletes the entity for the specified . - /// - /// The . - /// The . - /// The . - Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - - /// - /// Creates the entity with a . - /// - /// The . - /// The value to create. - /// The . - /// The created value. - Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Updates the entity with a . - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default); - - /// - /// Deletes the entity for the specified with a . - /// - /// The . - /// The . - /// The . - Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbType.cs b/src/CoreEx.Cosmos/ICosmosDbType.cs new file mode 100644 index 00000000..23070c96 --- /dev/null +++ b/src/CoreEx.Cosmos/ICosmosDbType.cs @@ -0,0 +1,16 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Cosmos +{ + /// + /// Defines the property. + /// + public interface ICosmosDbType + { + /// + /// Gets the model name. + /// + /// Enables multiple models to be managed within a single container leveraging different types. + string Type { get; } + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbValue.cs b/src/CoreEx.Cosmos/ICosmosDbValue.cs index dbd6e5e5..7d4022d9 100644 --- a/src/CoreEx.Cosmos/ICosmosDbValue.cs +++ b/src/CoreEx.Cosmos/ICosmosDbValue.cs @@ -1,19 +1,15 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Entities; +using System; namespace CoreEx.Cosmos { /// /// Defines the core capabilities for the special-purpose CosmosDb object that houses an underlying model-. /// - public interface ICosmosDbValue : IIdentifier + public interface ICosmosDbValue : IIdentifier, ICosmosDbType { - /// - /// Gets or sets the name. - /// - string Type { get; } - /// /// Gets the model value. /// @@ -23,8 +19,8 @@ public interface ICosmosDbValue : IIdentifier /// Prepares the object before sending to Cosmos. /// /// The . - /// The name override. - void PrepareBefore(CosmosDbArgs dbArgs, string? typeName); + /// The model name override. + void PrepareBefore(CosmosDbArgs dbArgs, string? type); /// /// Prepares the object after getting from Cosmos. diff --git a/src/CoreEx.Cosmos/IMultiSetValueArgs.cs b/src/CoreEx.Cosmos/IMultiSetValueArgs.cs new file mode 100644 index 00000000..ebaec70b --- /dev/null +++ b/src/CoreEx.Cosmos/IMultiSetValueArgs.cs @@ -0,0 +1,11 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Cosmos.Model; + +namespace CoreEx.Cosmos +{ + /// + /// Enables the multi-set arguments. + /// + public interface IMultiSetValueArgs : IMultiSetArgs { } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs index 11e15c07..67ec6143 100644 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs +++ b/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs @@ -2,96 +2,248 @@ using CoreEx.Abstractions; using CoreEx.Entities; +using CoreEx.Json; using CoreEx.Results; using Microsoft.Azure.Cosmos; using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Stj = System.Text.Json; namespace CoreEx.Cosmos.Model { /// - /// Provides model-only container. + /// Provides the underlying operations for model-based access within the . /// - /// The cosmos model . - /// The . - /// The identifier. - /// The optional . - public class CosmosDbModelContainer(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : CosmosDbModelContainerBase>(cosmosDb, containerId, dbArgs) where TModel : class, IEntityKey, new() + public sealed class CosmosDbModelContainer { - private Func? _partitionKey; + private readonly CosmosDbContainer _owner; + private readonly Lazy>> _partitionKeyGets = new(); + private readonly Lazy> _typeNames = new(); + private readonly Lazy> _filters = new(); /// - /// Sets the function to determine the ; used for (only Create and Update operations). + /// Initializes a new instance of the class. /// - /// The function to determine the . - /// The instance to support fluent-style method-chaining. - /// This is used where there is a value and the corresponding needs to be dynamically determined. - public CosmosDbModelContainer UsePartitionKey(Func partitionKey) + /// The owning . + internal CosmosDbModelContainer(CosmosDbContainer owner) => _owner = owner; + + /// + /// Checks whether the is in a valid state for the operation. + /// + /// The cosmos model . + /// The model value. + /// The specific for the operation. + /// Indicates whether an additional authorization check should be performed against the . + /// true indicates that the model is in a valid state; otherwise, false. + public bool IsModelValid(TModel? model, CosmosDbArgs dbArgs, bool checkAuthorized) where TModel : class, IEntityKey, new() + => !(!dbArgs.IsModelValid(model) + || (model is ICosmosDbType mt && mt.Type != GetModelName()) + || (checkAuthorized && IsAuthorized(model).IsFailure)); + + /// + /// Checks whether the is in a valid state for the operation. + /// + /// The cosmos model . + /// The model value. + /// The specific for the operation. + /// Indicates whether an additional authorization check should be performed against the . + /// true indicates that the model is in a valid state; otherwise, false. + public bool IsModelValid(CosmosDbValue? model, CosmosDbArgs dbArgs, bool checkAuthorized) where TModel : class, IEntityKey, new() + => !(model is null + || !dbArgs.IsModelValid(model.Value) + || model.Type != GetModelName() + || (checkAuthorized && IsAuthorized(model).IsFailure)); + + /// + /// Sets the function to get the from the model used by the (used by only the Create and Update operations). + /// + /// The cosmos model . + /// The function to get the from the model. + internal void UsePartitionKey(Func? getPartitionKey) where TModel : class, IEntityKey, new() { - _partitionKey = partitionKey; - return this; + // Where the function is null we should ignore unless previously set. + if (getPartitionKey is null) + { + if (_partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.ContainsKey(typeof(TModel))) + throw new InvalidOperationException($"PartitionKey already set for {typeof(TModel).Name}."); + + return; + } + + if (!_partitionKeyGets.Value.TryAdd(typeof(TModel), model => getPartitionKey.ThrowIfNull(nameof(getPartitionKey)).Invoke((TModel)model))) + throw new InvalidOperationException($"PartitionKey already set for {typeof(TModel).Name}."); } /// - /// Gets the from the (only Create and Update operations). + /// Gets the from the (used by only by the Create and Update operations). /// - /// The model to infer from. + /// The cosmos model . /// The . /// The . /// Will be thrown where the infered is not equal to (where not null). - public PartitionKey GetPartitionKey(TModel model, CosmosDbArgs dbArgs) + public PartitionKey GetPartitionKey(TModel model, CosmosDbArgs dbArgs) where TModel : class, IEntityKey, new() { - var dbpk = DbArgs.PartitionKey; - var pk = _partitionKey?.Invoke(model) ?? dbArgs.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; + var dbpk = _owner.DbArgs.PartitionKey; + var pk = _partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.TryGetValue(typeof(TModel), out var gpk) ? gpk(model!) : null; + + if (!pk.HasValue) + pk = dbArgs.PartitionKey ?? _owner.DbArgs.PartitionKey ?? PartitionKey.None; + if (dbpk is not null && dbpk != PartitionKey.None && dbpk != pk) throw new AuthorizationException(); - return pk; + return pk.Value; } /// - /// Gets the CosmosDb identifier from the . + /// Sets the function to get the from the used by the (used by only the Create and Update operations). /// - /// The model value. - /// The CosmosDb identifier. - public string GetCosmosId(TModel model) => GetCosmosId(model.ThrowIfNull(nameof(model)).EntityKey); + /// The cosmos model . + /// The function to get the from the model. + internal void UsePartitionKey(Func, PartitionKey>? getPartitionKey) where TModel : class, IEntityKey, new() + { + // Where the function is null we should ignore unless previously set. + if (getPartitionKey is null) + { + if (_partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.ContainsKey(typeof(CosmosDbValue))) + throw new InvalidOperationException($"PartitionKey already set for {typeof(CosmosDbValue).Name}."); + + return; + } + + if (!_partitionKeyGets.Value.TryAdd(typeof(CosmosDbValue), model => getPartitionKey.ThrowIfNull(nameof(getPartitionKey)).Invoke((CosmosDbValue)model))) + throw new InvalidOperationException($"PartitionKey already set for {typeof(CosmosDbValue).Name}."); + } /// - /// Gets the value from the response updating any special properties as required. + /// Gets the from the (used by only by the Create and Update operations). /// - /// The response value. - /// The entity value. - internal static TModel? GetResponseValue(Response resp) => resp?.Resource == null ? default : resp.Resource; + /// The cosmos model . + /// The . + /// The . + /// Will be thrown where the infered is not equal to (where not null). + public PartitionKey GetPartitionKey(CosmosDbValue model, CosmosDbArgs dbArgs) where TModel : class, IEntityKey, new() + { + var dbpk = _owner.DbArgs.PartitionKey; + var pk = _partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.TryGetValue(typeof(CosmosDbValue), out var gpk) ? gpk(model!) : null; - /// - protected override bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized) => IsModelValid((TModel?)model, args, checkAuthorized); + if (!pk.HasValue) + pk = dbArgs.PartitionKey ?? _owner.DbArgs.PartitionKey ?? PartitionKey.None; + + if (dbpk is not null && dbpk != PartitionKey.None && dbpk != pk) + throw new AuthorizationException(); + + return pk.Value; + } /// - /// Checks whether the is in a valid state for the operation. + /// Sets the name for the model . /// - /// The model value. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - public bool IsModelValid(TModel? model, CosmosDbArgs args, bool checkAuthorized) - => !(model == null - || (args.FilterByTenantId && model is ITenantId tenantId && tenantId.TenantId != args.GetTenantId()) - || (model is ILogicallyDeleted ld && ld.IsDeleted.HasValue && ld.IsDeleted.Value) - || (checkAuthorized && IsAuthorized(model).IsFailure)); + /// The cosmos model . + /// The model name. + internal void UseModelName(string name) where TModel : class, IEntityKey, new() + { + if (!_typeNames.Value.TryAdd(typeof(TModel), name)) + throw new InvalidOperationException($"Model Type Name already set for {typeof(TModel).Name}."); + } /// - /// Checks the value to determine whether the user is authorized with the . + /// Gets the name for the model . /// + /// The cosmos model . + /// The model name where configured (see ); otherwise, defaults to . + public string GetModelName() where TModel : class, IEntityKey, new() => _typeNames.IsValueCreated && _typeNames.Value.TryGetValue(typeof(TModel), out var name) ? name : typeof(TModel).Name; + + /// + /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. + /// + /// The cosmos model . + /// The authorization filter query. + internal void UseAuthorizeFilter(Func, IQueryable>? filter) where TModel : class, IEntityKey, new() + { + if (filter is null) + { + if (_filters.IsValueCreated && _filters.Value.ContainsKey(typeof(TModel))) + throw new InvalidOperationException($"Filter already set for {typeof(TModel).Name}."); + + return; + } + + if (!_filters.Value.TryAdd(typeof(TModel), filter)) + throw new InvalidOperationException($"Filter already set for {typeof(TModel).Name}."); + } + + /// + /// Checks the value to determine whether the user is authorized with the . + /// + /// The cosmos model . /// The model value. /// Either or . - public Result IsAuthorized(TModel model) + public Result IsAuthorized(TModel model) where TModel : class, IEntityKey, new() { if (model != default) { - var filter = CosmosDb.GetAuthorizeFilter(Container.Id); - if (filter != null && !((IQueryable)filter(new TModel[] { model }.AsQueryable())).Any()) + var filter = GetAuthorizeFilter(); + if (filter != null && !filter(new [] { model }.AsQueryable()).Any()) + return Result.AuthorizationError(); + } + + return Result.Success; + } + + /// + /// Gets the authorization filter for the . + /// + /// The cosmos model . + /// The authorization filter query where configured; otherwise, null. + public Func, IQueryable>? GetAuthorizeFilter() where TModel : class, IEntityKey, new() + => _filters.IsValueCreated && _filters.Value.TryGetValue(typeof(TModel), out var filter) ? (Func, IQueryable>)filter : null; + + /// + /// Sets the filter for all operations performed on the to ensure authorization is applied. Applies automatically to all queries, plus create, update, delete and get operations. + /// + /// The cosmos model . + /// The authorization filter query. + internal void UseAuthorizeFilter(Func>, IQueryable>>? filter) where TModel : class, IEntityKey, new() + { + if (filter is null) + { + if (_filters.IsValueCreated && _filters.Value.ContainsKey(typeof(CosmosDbValue))) + throw new InvalidOperationException($"Filter already set for {typeof(CosmosDbValue).Name}."); + + return; + } + + if (!_filters.Value.TryAdd(typeof(CosmosDbValue), filter)) + throw new InvalidOperationException($"Filter already set for {typeof(CosmosDbValue).Name}."); + } + + /// + /// Gets the authorization filter for the . + /// + /// The cosmos model . + /// The authorization filter query where configured; otherwise, null. + public Func>, IQueryable>>? GetValueAuthorizeFilter() where TModel : class, IEntityKey, new() + => _filters.IsValueCreated && _filters.Value.TryGetValue(typeof(CosmosDbValue), out var filter) ? (Func>, IQueryable>>)filter : null; + + /// + /// Checks the value to determine whether the user is authorized with the . + /// + /// The cosmos model . + /// The model value. + /// Either or . + public Result IsAuthorized(CosmosDbValue model) where TModel : class, IEntityKey, new() + { + if (model != null && model.Value != default) + { + var filter = GetValueAuthorizeFilter(); + if (filter != null && !filter(new CosmosDbValue[] { model }.AsQueryable()).Any()) return Result.AuthorizationError(); } @@ -99,291 +251,870 @@ public Result IsAuthorized(TModel model) } /// - /// Gets (creates) a to enable LINQ-style queries. + /// Gets the value from the response updating any special properties as required. + /// + /// The cosmos model . + /// The response value. + /// The entity value. + internal static TModel? GetResponseValue(Response resp) where TModel : class, IEntityKey, new() => resp?.Resource == null ? default : resp.Resource; + + /// + /// Gets the value from the response updating any special properties as required. + /// + /// The cosmos model . + /// The response value. + /// The entity value. + internal static CosmosDbValue? GetResponseValue(Response> resp) where TModel : class, IEntityKey, new() => resp?.Resource == null ? default : resp.Resource; + + #region Query + + /// + /// Gets (creates) a to enable LINQ-style queries. /// /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(Func, IQueryable>? query) => Query(new CosmosDbArgs(DbArgs), query); + /// The . + public CosmosDbModelQuery Query(Func, IQueryable>? query) where TModel : class, IEntityKey, new() => Query(new CosmosDbArgs(_owner.DbArgs), query); /// - /// Gets (creates) a to enable LINQ-style queries. + /// Gets (creates) a to enable LINQ-style queries. /// /// The . /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Query(new CosmosDbArgs(DbArgs, partitionKey), query); + /// The . + public CosmosDbModelQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) where TModel : class, IEntityKey, new() => Query(new CosmosDbArgs(_owner.DbArgs, partitionKey), query); /// - /// Gets (creates) a to enable LINQ-style queries. + /// Gets (creates) a to enable LINQ-style queries. /// /// The . /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => new(this, dbArgs, query); + /// The . + public CosmosDbModelQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) where TModel : class, IEntityKey, new() => new(_owner, dbArgs, query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery ValueQuery(Func>, IQueryable>>? query) where TModel : class, IEntityKey, new() => ValueQuery(new CosmosDbArgs(_owner.DbArgs), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery ValueQuery(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) where TModel : class, IEntityKey, new() => ValueQuery(new CosmosDbArgs(_owner.DbArgs, partitionKey), query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery ValueQuery(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) where TModel : class, IEntityKey, new() => new(_owner, dbArgs, query); + + #endregion + + #region Get /// /// Gets the model for the specified . /// + /// The cosmos model . /// The . /// The . /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, cancellationToken); + public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(key, cancellationToken)).Value; /// /// Gets the model for the specified with a . /// + /// The cosmos model . /// The . /// The . /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => GetWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); /// /// Gets the model for the specified . /// + /// The cosmos model . /// The . /// The . Defaults to . /// The . /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false); + public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; /// /// Gets the model for the specified with a . /// + /// The cosmos model . /// The . /// The . Defaults to . /// The . /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => GetWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); /// /// Gets the model for the specified . /// + /// The cosmos model . /// The . /// The . /// The . /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; + public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; /// /// Gets the model for the specified with a . /// + /// The cosmos model . /// The . /// The . /// The . /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, GetCosmosId(key), dbArgs, async (_, id, args, ct) => + public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() { - try + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => { - var pk = args.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - var resp = await Container.ReadItemAsync(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return args.NullOnNotFound ? Result.None : Result.NotFoundError(); + try + { + var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); + var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) + return args.NullOnNotFound ? Result.None : Result.NotFoundError(); - return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); - } - catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result.None : Result.NotFoundError(); } - }, cancellationToken, nameof(GetWithResultAsync)); + return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); + } + catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result.None : Result.NotFoundError(); } + }, cancellationToken, nameof(GetWithResultAsync)); + } + + /// + /// Gets the for the specified . + /// + /// The cosmos model . + /// The . + /// The . + /// The value where found; otherwise, null (see ). + public async Task?> GetValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . + /// The value where found; otherwise, null (see ). + public Task?>> GetValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => GetValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); + + /// + /// Gets the for the specified . + /// + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The value where found; otherwise, null (see ). + public async Task?> GetValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + /// The value where found; otherwise, null (see ). + public Task?>> GetValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => GetValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); + + /// + /// Gets the for the specified . + /// + /// The cosmos model . + /// The . + /// The . + /// The . + /// The value where found; otherwise, null (see ). + public async Task?> GetValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . + /// The . + /// The value where found; otherwise, null (see ). + public Task?>> GetValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + { + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => + { + try + { + var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); + var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) + return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); + + return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); + } + catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); } + }, cancellationToken, nameof(GetWithResultAsync)); + } + + #endregion + + #region Create + + /// + /// Creates the model. + /// + /// The cosmos model . + /// The model to create. + /// The . + /// The created model. + public async Task CreateAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await CreateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; /// /// Creates the model. /// + /// The cosmos model . + /// The . /// The model to create. /// The . /// The created model. - public async Task CreateAsync(TModel model, CancellationToken cancellationToken = default) => await CreateWithResultAsync(model, cancellationToken).ConfigureAwait(false); + public async Task CreateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await CreateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; /// /// Creates the model with a . /// + /// The cosmos model . /// The model to create. /// The . /// The created model. - public Task> CreateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => CreateWithResultAsync(new CosmosDbArgs(DbArgs), model, cancellationToken); + public Task> CreateWithResultAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => CreateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken); /// - /// Creates the model. + /// Creates the model with a . /// + /// The cosmos model . /// The . /// The model to create. /// The . /// The created model. - public async Task CreateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) => (await CreateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; + public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + { + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareCreate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => + { + var pk = GetPartitionKey(m, args); + return await Result + .Go(IsAuthorized(model)) + .ThenAsAsync(() => _owner.CosmosContainer.CreateItemAsync(Cleaner.PrepareCreate(model), pk, args.GetItemRequestOptions(), ct)) + .ThenAs(resp => GetResponseValue(resp!)!); + }, cancellationToken, nameof(CreateWithResultAsync)); + } /// - /// Creates the model with a . + /// Creates the with a . /// + /// The cosmos model . + /// The model to create. + /// The . + /// The created model. + public async Task> CreateValueAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await CreateValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the with a . + /// + /// The cosmos model . /// The . /// The model to create. /// The . /// The created model. - public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, model.ThrowIfNull(nameof(model)), dbArgs, async (_, m, args, ct) => + public async Task> CreateValueAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await CreateValueWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the with a . + /// + /// The cosmos model . + /// The model to create. + /// The . + /// The created model. + public Task>> CreateValueWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => CreateValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken); + + /// + /// Creates the with a . + /// + /// The cosmos model . + /// The . + /// The model to create. + /// The . + /// The created model. + public Task>> CreateValueWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() { - Cleaner.ResetTenantId(m); - var pk = GetPartitionKey(model, dbArgs); - return await Result - .Go(IsAuthorized(model)) - .ThenAsAsync(() => Container.CreateItemAsync(model, pk, args.GetItemRequestOptions(), ct)) - .ThenAs(resp => GetResponseValue(resp!)!); - }, cancellationToken, nameof(CreateWithResultAsync)); + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareCreate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => + { + var pk = GetPartitionKey(m, args); + return await Result + .Go(IsAuthorized(m)) + .ThenAsAsync(async () => + { + ((ICosmosDbValue)m).PrepareBefore(args, typeof(TModel).Name); + Cleaner.PrepareCreate(m.Value); + var resp = await _owner.CosmosContainer.CreateItemAsync(m, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); + return GetResponseValue(resp)!; + }); + }, cancellationToken, nameof(CreateWithResultAsync)); + } + + #endregion + + #region Update /// /// Updates the model. /// + /// The cosmos model . /// The model to update. /// The . /// The updated model. - public async Task UpdateAsync(TModel model, CancellationToken cancellationToken = default) => await UpdateWithResultAsync(model, cancellationToken).ConfigureAwait(false); + public async Task UpdateAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await UpdateWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken).ConfigureAwait(false)).Value; /// - /// Updates the model with a . + /// Updates the model. /// + /// The cosmos model . + /// The . /// The model to update. /// The . /// The updated model. - public Task> UpdateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => UpdateWithResultAsync(new CosmosDbArgs(DbArgs), model, cancellationToken); + public async Task UpdateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken).ConfigureAwait(false)).Value; /// - /// Updates the model. + /// Updates the model with a . /// - /// The . + /// The cosmos model . /// The model to update. /// The . /// The updated model. - public async Task UpdateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) => (await UpdateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; + public Task> UpdateWithResultAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => UpdateWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken); /// /// Updates the model with a . /// + /// The cosmos model . /// The . /// The model to update. /// The . /// The updated model. - public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) => UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken); + public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken); /// /// Updates the model with a (internal). /// + /// The cosmos model . /// The . /// The model to update. /// The action to update the model after the read. /// The . /// The updated model. - internal Task> UpdateWithResultInternalAsync(CosmosDbArgs dbArgs, TModel model, Action? modelUpdater, CancellationToken cancellationToken) => CosmosDb.Invoker.InvokeAsync(CosmosDb, model.ThrowIfNull(nameof(model)), dbArgs, async (_, m, args, ct) => + internal Task> UpdateWithResultInternalAsync(CosmosDbArgs dbArgs, TModel model, Action? modelUpdater, CancellationToken cancellationToken) where TModel : class, IEntityKey, new() { - // Where supporting etag then use IfMatch for concurrency. - var ro = args.GetItemRequestOptions(); - if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) - ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); - - // Must read existing to update. - var id = GetCosmosId(m); - var pk = GetPartitionKey(model, dbArgs); - var resp = await Container.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.NotFoundError(); - - return await Result - .Go(IsAuthorized(resp)) - .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) - .Then(() => - { - ro.SessionToken = resp.Headers?.Session; - modelUpdater?.Invoke(resp.Resource); - Cleaner.ResetTenantId(resp.Resource); - - // Re-check auth to make sure not updating to something not allowed. - return IsAuthorized(resp); - }) - .ThenAsAsync(async () => - { - resp = await Container.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(UpdateWithResultAsync)); + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareUpdate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => + { + // Where supporting etag then use IfMatch for concurrency. + var ro = args.GetItemRequestOptions(); + if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) + ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); + + // Must read existing to update. + var id = _owner.GetCosmosId(m); + var pk = GetPartitionKey(model, dbArgs); + var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) + return Result.NotFoundError(); + + return await Result + .Go(IsAuthorized(resp)) + .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) + .Then(() => + { + ro.SessionToken = resp.Headers?.Session; + modelUpdater?.Invoke(resp.Resource); + Cleaner.ResetTenantId(resp.Resource); + + // Re-check auth to make sure not updating to something not allowed. + return IsAuthorized(resp); + }) + .ThenAsAsync(async () => + { + resp = await _owner.CosmosContainer.ReplaceItemAsync(Cleaner.PrepareUpdate(resp.Resource), id, pk, ro, ct).ConfigureAwait(false); + return GetResponseValue(resp)!; + }); + }, cancellationToken, nameof(UpdateWithResultAsync)); + } + + /// + /// Updates the . + /// + /// The cosmos model . + /// The model to update. + /// The . + /// The updated model. + public async Task> UpdateValueAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await UpdateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the . + /// + /// The cosmos model . + /// The . + /// The model to update. + /// The . + /// The updated model. + public async Task> UpdateValueAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await UpdateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the with a . + /// + /// The cosmos model . + /// The model to update. + /// The . + /// The updated model. + public Task>> UpdateValueWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => UpdateValueWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken); + + /// + /// Updates the with a . + /// + /// The cosmos model . + /// The . + /// The model to update. + /// The . + /// The updated model. + public Task>> UpdateValueWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => UpdateValueWithResultInternalAsync(dbArgs, model, null, cancellationToken); + + /// + /// Updates the with a (internal). + /// + /// The cosmos model . + /// The . + /// The model to update. + /// The action to update the model after the read. + /// The . + /// The updated model. + internal Task>> UpdateValueWithResultInternalAsync(CosmosDbArgs dbArgs, CosmosDbValue model, Action>? modelUpdater, CancellationToken cancellationToken) where TModel : class, IEntityKey, new() + { + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareUpdate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => + { + // Where supporting etag then use IfMatch for concurrency. + var ro = args.GetItemRequestOptions(); + if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) + ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); + + // Must read existing to update. + ((ICosmosDbValue)m).PrepareBefore(dbArgs, GetModelName()); + var id = m.Id; + var pk = GetPartitionKey(m, dbArgs); + var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) + return Result>.NotFoundError(); + + return await Result + .Go(IsAuthorized(resp.Resource)) + .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) + .Then(() => + { + ro.SessionToken = resp.Headers?.Session; + modelUpdater?.Invoke(resp.Resource); + Cleaner.ResetTenantId(m.Value); + ((ICosmosDbValue)resp.Resource).PrepareBefore(dbArgs, GetModelName()); + + // Re-check auth to make sure not updating to something not allowed. + return IsAuthorized(resp); + }) + .ThenAsAsync(async () => + { + Cleaner.PrepareUpdate(resp.Resource.Value); + resp = await _owner.CosmosContainer.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); + return GetResponseValue(resp)!; + }); + }, cancellationToken, nameof(UpdateValueWithResultAsync)); + } + + #endregion + + #region Delete /// /// Deletes the model for the specified . /// + /// The cosmos model . /// The . /// The . - public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); /// /// Deletes the model for the specified with a . /// + /// The cosmos model . /// The . /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); /// /// Deletes the model for the specified . /// + /// The cosmos model . /// The . /// The . Defaults to . /// The . - public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); /// /// Deletes the model for the specified with a . /// + /// The cosmos model . /// The . /// The . Defaults to . /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); /// - /// Deletes the model for the specified with a . + /// Deletes the model for the specified . /// + /// The cosmos model . /// The . /// The . /// The . - public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); /// /// Deletes the model for the specified with a . /// + /// The cosmos model . /// The . /// The . /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, GetCosmosId(key), dbArgs, async (_, id, args, ct) => + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() { - try + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => { - // Must read the existing to validate. - var ro = args.GetItemRequestOptions(); - var pk = args.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - var resp = await Container.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.Success; + try + { + // Must read the existing to validate. + var ro = args.GetItemRequestOptions(); + var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); + var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) + return Result.Success; + + // Delete; either logically or physically. + if (resp.Resource is ILogicallyDeleted ild) + { + if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) + return Result.Success; + + ild.IsDeleted = true; + return await Result + .Go(IsAuthorized(resp.Resource)) + .ThenAsync(async () => + { + ro.SessionToken = resp.Headers?.Session; + await _owner.CosmosContainer.ReplaceItemAsync(Cleaner.PrepareUpdate(resp.Resource), id, pk, ro, ct).ConfigureAwait(false); + return Result.Success; + }); + } + + return await Result + .Go(IsAuthorized(resp.Resource)) + .ThenAsync(async () => + { + ro.SessionToken = resp.Headers?.Session; + await _owner.CosmosContainer.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); + return Result.Success; + }); + } + catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } + }, cancellationToken, nameof(DeleteWithResultAsync)); + } + + /// + /// Deletes the for the specified . + /// + /// The cosmos model . + /// The . + /// The . + public async Task DeleteValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - // Delete; either logically or physically. - if (resp.Resource is ILogicallyDeleted ild) + /// + /// Deletes the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . + public Task DeleteValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); + + /// + /// Deletes the for the specified . + /// + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public async Task DeleteValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . Defaults to . + /// The . + public Task DeleteValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); + + /// + /// Deletes the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . + /// The . + public async Task DeleteValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + => (await DeleteValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the for the specified with a . + /// + /// The cosmos model . + /// The . + /// The . + /// The . + public Task DeleteValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() + { + return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => + { + try { - if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) + // Must read existing to delete and to make sure we are deleting for the correct Type; don't just trust the key. + var ro = args.GetItemRequestOptions(); + var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); + var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); + if (!IsModelValid(resp.Resource, args, false)) return Result.Success; - ild.IsDeleted = true; + // Delete; either logically or physically. + if (resp.Resource.Value is ILogicallyDeleted ild) + { + if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) + return Result.Success; + + ild.IsDeleted = true; + return await Result + .Go(IsAuthorized(resp.Resource)) + .ThenAsync(async () => + { + ro.SessionToken = resp.Headers?.Session; + Cleaner.PrepareUpdate(resp.Resource.Value); + await _owner.CosmosContainer.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); + return Result.Success; + }); + } + return await Result .Go(IsAuthorized(resp.Resource)) .ThenAsync(async () => { ro.SessionToken = resp.Headers?.Session; - await Container.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); + await _owner.CosmosContainer.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); return Result.Success; }); } + catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } + }, cancellationToken, nameof(DeleteValueWithResultAsync)); + } - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => + #endregion + + #region MultiSet + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectMultiSetAsync(PartitionKey partitionKey, params IMultiSetModelArgs[] multiSetArgs) => SelectMultiSetAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// The . + /// The override SQL statement; will default where not specified. + /// One or more . + /// The . + /// See for further details. + public async Task SelectMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + => (await SelectMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetModelArgs[] multiSetArgs) => SelectMultiSetWithResultAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// The override SQL statement; will default where not specified. + /// One or more . + /// The . + /// The must be of type . Each is verified and executed in the order specified. + /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the + /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") + /// + public async Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + { + // Verify that the multi set arguments are valid for this type of get query. + var multiSetList = multiSetArgs?.ToArray() ?? null; + if (multiSetList == null || multiSetList.Length == 0) + throw new ArgumentException($"At least one {nameof(IMultiSetModelArgs)} must be supplied.", nameof(multiSetArgs)); + + // Build the Cosmos SQL statement. + var name = multiSetList[0].GetModelName(_owner); + var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); + var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"\"{name}\"") : null; + + if (sb is not null) + { + for (int i = 1; i < multiSetList.Length; i++) + { + name = multiSetList[i].GetModelName(_owner); + if (!types.TryAdd(name, multiSetList[i])) + throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); + + sb.Append($", \"{name}\""); + } + + sql = string.Format(_owner.MultiSetSqlStatementFormat, sb.ToString()); + } + + // Execute the Cosmos DB query. + var result = await _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner, sql, types, async (_, container, sql, types, ct) => + { + // Set up for work. + var da = new CosmosDbArgs(container.DbArgs, partitionKey); + var qsi = container.CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); + IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; + var isStj = js is Text.Json.JsonSerializer; + + while (qsi.HasMoreResults) + { + var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); + if (!rm.IsSuccessStatusCode) + return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); + + var json = Stj.JsonDocument.Parse(rm.Content); + if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) + return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); + + foreach (var jd in jds.EnumerateArray()) { - ro.SessionToken = resp.Headers?.Session; - await Container.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); + if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) + return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); + + if (!types.TryGetValue(jt.GetString()!, out var msa)) + continue; // Ignore any unexpected type. + + var model = isStj + ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) + : js.Deserialize(jd.ToString(), msa.Type); + + if (model is null) + return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); + + var result = msa.AddItem(container, da, model); + if (result.IsFailure) + return result; + } + } + + return Result.Success; + }, cancellationToken).ConfigureAwait(false); + + if (result.IsFailure) + return result; + + // Validate the multi-set args and action each accordingly. + foreach (var msa in multiSetList) + { + var r = msa.Verify(); + if (r.IsFailure) + return r.AsResult(); + + if (!r.Value && msa.StopOnNull) + break; + + msa.Invoke(); } - catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } - }, cancellationToken, nameof(DeleteWithResultAsync)); + + return Result.Success; + } + + #endregion } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelContainerBase.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelContainerBase.cs deleted file mode 100644 index 90228e15..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelContainerBase.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Microsoft.Azure.Cosmos; -using System; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides the base capabilities. - /// - /// The cosmos model . - /// The itself. - /// The . - /// The identifier. - /// The optional . - public abstract class CosmosDbModelContainerBase(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : CosmosDbContainer(cosmosDb, containerId, dbArgs), ICosmosDbModelContainer - where TModel : class, IEntityKey, new () where TSelf : CosmosDbModelContainerBase - { - /// - bool ICosmosDbModelContainer.IsModelValid(object? model, CoreEx.Cosmos.CosmosDbArgs args, bool checkAuthorized) => IsModelValid(model, args, checkAuthorized); - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The model to be checked. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - protected abstract bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs new file mode 100644 index 00000000..3538f65b --- /dev/null +++ b/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs @@ -0,0 +1,235 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Results; +using Microsoft.Azure.Cosmos; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace CoreEx.Cosmos.Model +{ + /// + /// Provides a typed interface for the primary operations. + /// + /// The cosmos model . + public class CosmosDbModelContainer where TModel : class, IEntityKey, new() + { + /// + /// Initializes a new instance of the class. + /// + /// The owning . + internal CosmosDbModelContainer(CosmosDbContainer owner) => Owner = owner.ThrowIfNull(nameof(owner)); + + /// + /// Gets the owning . + /// + public CosmosDbContainer Owner { get; } + + /// + /// Gets the . + /// + public Container CosmosContainer => Owner.CosmosContainer; + + #region Query + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The function to perform additional query execution. + /// The . + public CosmosDbModelQuery Query(Func, IQueryable>? query) => Owner.Model.Query(query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbModelQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Owner.Model.Query(partitionKey, query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbModelQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => Owner.Model.Query(dbArgs, query); + + #endregion + + #region Get + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(key, cancellationToken); + + /// + /// Gets the model for the specified with a . + /// + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(key, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + /// The model value where found; otherwise, null (see ). + public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the model for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + /// The model value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(dbArgs, key, cancellationToken); + + /// + /// Gets the model for the specified with a . + /// + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region Create + + /// + /// Creates the model. + /// + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateAsync(value, cancellationToken); + + /// + /// Creates the model with a . + /// + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateWithResultAsync(value, cancellationToken); + + /// + /// Creates the model. + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task CreateAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateAsync(dbArgs, value, cancellationToken); + + /// + /// Creates the model with a . + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Update + + /// + /// Updates the model. + /// + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateAsync(value, cancellationToken); + + /// + /// Updates the model with a . + /// + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateWithResultAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateWithResultAsync(value, cancellationToken); + + /// + /// Updates the model. + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task UpdateAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Delete + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(key, cancellationToken); + + /// + /// Deletes the model for the specified with a . + /// + /// The . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The .. + /// The . + /// The . + public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(dbArgs, key, cancellationToken); + + /// + /// Deletes the model for the specified with a . + /// + /// The .. + /// The . + /// The . + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs index 0bff3cf1..45d36e62 100644 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs +++ b/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs @@ -14,10 +14,10 @@ namespace CoreEx.Cosmos.Model /// Encapsulates a CosmosDb model-only query enabling all select-like capabilities. /// /// The cosmos model . - /// The . + /// The . /// The . /// A function to modify the underlying . - public class CosmosDbModelQuery(ICosmosDbContainerCore container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbModelQueryBase>(container, dbArgs) where TModel : class, new() + public class CosmosDbModelQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbModelQueryBase>(container, dbArgs) where TModel : class, IEntityKey, new() { private readonly Func, IQueryable>? _query = query; @@ -29,20 +29,14 @@ private IQueryable AsQueryable(bool allowSynchronousQueryExecution, bool if (!pagingSupported && Paging is not null) throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - IQueryable query = Container.Container.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); + IQueryable query = Container.CosmosContainer.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); query = _query == null ? query : _query(query); - var filter = Container.CosmosDb.GetAuthorizeFilter(Container.Container.Id); + var filter = Container.Model.GetAuthorizeFilter(); if (filter != null) - query = (IQueryable)filter(query); + query = filter(query); - if (QueryArgs.FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => ((ITenantId)x).TenantId == QueryArgs.GetTenantId()); - - if (typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => !((ILogicallyDeleted)x).IsDeleted.IsDefined() || ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); - - return query; + return QueryArgs.WhereModelValid(query); } /// diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs index 67f85c07..c1f7fc89 100644 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs +++ b/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs @@ -15,12 +15,14 @@ namespace CoreEx.Cosmos.Model /// /// The cosmos model . /// The itself. - public abstract class CosmosDbModelQueryBase(ICosmosDbContainerCore container, CosmosDbArgs dbArgs) where TModel : new() where TSelf : CosmosDbModelQueryBase + /// The . + /// The . + public abstract class CosmosDbModelQueryBase(CosmosDbContainer container, CosmosDbArgs dbArgs) where TModel : new() where TSelf : CosmosDbModelQueryBase { /// - /// Gets the . + /// Gets the . /// - public ICosmosDbContainerCore Container { get; } = container.ThrowIfNull(nameof(container)); + public CosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); /// /// Gets the . diff --git a/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainer.cs b/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainer.cs deleted file mode 100644 index e6dc289e..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainer.cs +++ /dev/null @@ -1,406 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides model-only container. - /// - /// The cosmos model . - /// The . - /// The identifier. - /// The optional . - public class CosmosDbValueModelContainer(ICosmosDb cosmosDb, string containerId, CosmosDbArgs? dbArgs = null) : CosmosDbModelContainerBase>(cosmosDb, containerId, dbArgs) where TModel : class, IEntityKey, new() - { - private Func, PartitionKey>? _partitionKey; - - /// - /// Gets the name. - /// - /// enables override. - public string TypeName { get; private set; } = typeof(TModel).Name; - - /// - /// Overrides the . - /// - /// The type name override. - /// The instance to support fluent-style method-chaining. - public CosmosDbValueModelContainer UseTypeName(string typeName) - { - TypeName = typeName; - return this; - } - - /// - /// Sets the function to determine the ; used for (only Create and Update operations). - /// - /// The function to determine the . - /// The instance to support fluent-style method-chaining. - /// This is used where there is a value and the corresponding needs to be dynamically determined. - public CosmosDbValueModelContainer UsePartitionKey(Func, PartitionKey> partitionKey) - { - _partitionKey = partitionKey; - return this; - } - - /// - /// Gets the from the (only Create and Update operations). - /// - /// The model to infer from. - /// The . - /// The . - /// Will be thrown where the infered is not equal to (where not null). - public PartitionKey GetPartitionKey(CosmosDbValue model, CosmosDbArgs dbArgs) - { - var dbpk = DbArgs.PartitionKey; - var pk = _partitionKey?.Invoke(model) ?? dbArgs.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - if (dbpk is not null && dbpk != PartitionKey.None && dbpk != pk) - throw new AuthorizationException(); - - return pk; - } - - /// - /// Gets the value from the response updating any special properties as required. - /// - /// The response value. - /// The entity value. - internal static CosmosDbValue? GetResponseValue(Response> resp) => resp?.Resource; - - /// - protected override bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized) => IsModelValid((CosmosDbValue?)model, args, checkAuthorized); - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The model value. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - public bool IsModelValid(CosmosDbValue? model, CosmosDbArgs args, bool checkAuthorized) - => !(model == null - || model.Type != TypeName - || (args.FilterByTenantId && model.Value is ITenantId tenantId && tenantId.TenantId != args.GetTenantId()) - || (model.Value is ILogicallyDeleted ld && ld.IsDeleted.HasValue && ld.IsDeleted.Value) - || (checkAuthorized && IsAuthorized(model).IsFailure)); - - /// - /// Checks the value to determine whether the user is authorized with the . - /// - /// The model value. - /// Either or . - public Result IsAuthorized(CosmosDbValue model) - { - if (model != null && model.Value != default) - { - var filter = CosmosDb.GetAuthorizeFilter(Container.Id); - if (filter != null && !((IQueryable>)filter(new CosmosDbValue[] { model }.AsQueryable())).Any()) - return Result.AuthorizationError(); - } - - return Result.Success; - } - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(Func>, IQueryable>>? query) => Query(new CosmosDbArgs(DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Query(new CosmosDbArgs(DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => new(this, dbArgs, query); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task?> GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, cancellationToken); - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task?> GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false); - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => GetWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task?> GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - var pk = args.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - var resp = await Container.ReadItemAsync>(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); - - return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); - } - catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); } - }, cancellationToken, nameof(GetWithResultAsync)); - - /// - /// Creates the model. - /// - /// The model to create. - /// The . - /// The created model. - public async Task> CreateAsync(CosmosDbValue model, CancellationToken cancellationToken = default) => await CreateWithResultAsync(model, cancellationToken).ConfigureAwait(false); - - /// - /// Creates the model with a . - /// - /// The model to create. - /// The . - /// The created model. - public Task>> CreateWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) => CreateWithResultAsync(new CosmosDbArgs(DbArgs), model, cancellationToken); - - /// - /// Creates the model. - /// - /// The . - /// The model to create. - /// The . - /// The created model. - public async Task> CreateAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) => (await CreateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the model with a . - /// - /// The . - /// The model to create. - /// The . - /// The created model. - public Task>> CreateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, model.ThrowIfNull(nameof(model)), dbArgs, async (_, m, args, ct) => - { - Cleaner.ResetTenantId(m); - var pk = GetPartitionKey(m, dbArgs); - return await Result - .Go(IsAuthorized(m)) - .ThenAsAsync(async () => - { - ((ICosmosDbValue)m).PrepareBefore(dbArgs, TypeName); - var resp = await Container.CreateItemAsync(m, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(CreateWithResultAsync)); - - /// - /// Updates the model. - /// - /// The model to update. - /// The . - /// The updated model. - public async Task> UpdateAsync(CosmosDbValue model, CancellationToken cancellationToken = default) => await UpdateWithResultAsync(model, cancellationToken).ConfigureAwait(false); - - /// - /// Updates the model with a . - /// - /// The model to update. - /// The . - /// The updated model. - public Task>> UpdateWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) => UpdateWithResultAsync(new CosmosDbArgs(DbArgs), model, cancellationToken); - - /// - /// Updates the model. - /// - /// The . - /// The model to update. - /// The . - /// The updated model. - public async Task> UpdateAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) => (await UpdateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the model with a . - /// - /// The . - /// The model to update. - /// The . - /// The updated model. - public Task>> UpdateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) => UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken); - - /// - /// Updates the model with a (internal). - /// - /// The . - /// The model to update. - /// The action to update the model after the read. - /// The . - /// The updated model. - internal Task>> UpdateWithResultInternalAsync(CosmosDbArgs dbArgs, CosmosDbValue model, Action>? modelUpdater, CancellationToken cancellationToken) => CosmosDb.Invoker.InvokeAsync(CosmosDb, model.ThrowIfNull(nameof(model)), dbArgs, async (_, m, args, ct) => - { - // Where supporting etag then use IfMatch for concurrency. - var ro = args.GetItemRequestOptions(); - if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) - ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); - - // Must read existing to update. - ((ICosmosDbValue)m).PrepareBefore(dbArgs, TypeName); - var id = m.Id; - var pk = GetPartitionKey(m, dbArgs); - var resp = await Container.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result>.NotFoundError(); - - return await Result - .Go(IsAuthorized(resp.Resource)) - .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) - .Then(() => - { - ro.SessionToken = resp.Headers?.Session; - modelUpdater?.Invoke(resp.Resource); - Cleaner.ResetTenantId(m.Value); - ((ICosmosDbValue)resp.Resource).PrepareBefore(dbArgs, TypeName); - - // Re-check auth to make sure not updating to something not allowed. - return IsAuthorized(resp); - }) - .ThenAsAsync(async () => - { - resp = await Container.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(UpdateWithResultAsync)); - - /// - /// Deletes the model for the specified . - /// - /// The . - /// The . - public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Deletes the model for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . - /// The . - public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => CosmosDb.Invoker.InvokeAsync(CosmosDb, GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - // Must read existing to delete and to make sure we are deleting for the correct Type; don't just trust the key. - var ro = args.GetItemRequestOptions(); - var pk = args.PartitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - var resp = await Container.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.Success; - - // Delete; either logically or physically. - if (resp.Resource.Value is ILogicallyDeleted ild) - { - if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) - return Result.Success; - - ild.IsDeleted = true; - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - await Container.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - await Container.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } - }, cancellationToken, nameof(DeleteWithResultAsync)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs b/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs new file mode 100644 index 00000000..8385d21f --- /dev/null +++ b/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs @@ -0,0 +1,394 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Json; +using CoreEx.Results; +using Microsoft.Azure.Cosmos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Stj = System.Text.Json; + +namespace CoreEx.Cosmos.Model +{ + /// + /// Provides a typed interface for the primary operations. + /// + /// The cosmos model . + public sealed class CosmosDbValueModelContainer where TModel : class, IEntityKey, new() + { + /// + /// Initializes a new instance of the class. + /// + /// The owning . + internal CosmosDbValueModelContainer(CosmosDbContainer owner) => Owner = owner.ThrowIfNull(nameof(owner)); + + /// + /// Gets the owning . + /// + public CosmosDbContainer Owner { get; } + + /// + /// Gets the . + /// + public Container CosmosContainer => Owner.CosmosContainer; + + #region Query + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery Query(Func>, IQueryable>>? query) => Owner.Model.ValueQuery(query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Owner.Model.ValueQuery(partitionKey, query); + + /// + /// Gets (creates) a to enable LINQ-style queries. + /// + /// The . + /// The function to perform additional query execution. + /// The . + public CosmosDbValueModelQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => Owner.Model.ValueQuery(dbArgs, query); + + #endregion + + #region Get + + /// + /// Gets the model (using underlying ) for the specified . + /// + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?> GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(key, cancellationToken); + + /// + /// Gets the model (using underlying ) for the specified with a . + /// + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?>> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(key, cancellationToken); + + /// + /// Gets the model (using underlying ) for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?> GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the model (using underlying ) for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?>> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Gets the model (using underlying ) for the specified . + /// + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?> GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(dbArgs, key, cancellationToken); + + /// + /// Gets the model (using underlying ) for the specified with a . + /// + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null (see ). + public Task?>> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region Create + + /// + /// Creates the model (using underlying ). + /// + /// The value to create. + /// The . + /// The created value. + public Task> CreateAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueAsync(value, cancellationToken); + + /// + /// Creates the model (using underlying ) with a . + /// + /// The value to create. + /// The . + /// The created value. + public Task>> CreateWithResultAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueWithResultAsync(value, cancellationToken); + + /// + /// Creates the model (using underlying ). + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task> CreateAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueAsync(dbArgs, value, cancellationToken); + + /// + /// Creates the model (using underlying ) with a . + /// + /// The . + /// The value to create. + /// The . + /// The created value. + public Task>> CreateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Update + + /// + /// Updates the model (using underlying ). + /// + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueAsync(value, cancellationToken); + + /// + /// Updates the model (using underlying ) with a . + /// + /// The value to update. + /// The . + /// The updated value. + public Task>> UpdateWithResultAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueWithResultAsync(value, cancellationToken); + + /// + /// Updates the model (using underlying ). + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task> UpdateAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueAsync(dbArgs, value, cancellationToken); + + /// + /// Updates the model (using underlying ) with a . + /// + /// The . + /// The value to update. + /// The . + /// The updated value. + public Task>> UpdateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueWithResultAsync(dbArgs, value, cancellationToken); + + #endregion + + #region Delete + + /// + /// Deletes the model (using underlying ) for the specified . + /// + /// The . + /// The . + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(key, cancellationToken); + + /// + /// Deletes the model (using underlying ) for the specified with a . + /// + /// The . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(key, cancellationToken); + + /// + /// Deletes the model (using underlying ) for the specified . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the model (using underlying ) for the specified with a . + /// + /// The . + /// The . Defaults to . + /// The . + public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(key, partitionKey, cancellationToken); + + /// + /// Deletes the model (using underlying ) for the specified . + /// + /// The .. + /// The . + /// The . + public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(dbArgs, key, cancellationToken); + + /// + /// Deletes the model (using underlying ) for the specified with a . + /// + /// The .. + /// The . + /// The . + public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); + + #endregion + + #region MultiSet + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectValueMultiSetAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectValueMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// The override SQL statement; will default where not specified. + /// One or more . + /// The . + /// See for further details. + public async Task SelectValueMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + => (await SelectValueMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// See for further details. + public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetWithResultAsync(partitionKey, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// One or more . + /// The . + /// See for further details. + public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more with a . + /// + /// The . + /// The override SQL statement; will default where not specified. + /// One or more . + /// The . + /// The must be of type . Each is verified and executed in the order specified. + /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the + /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") + /// + public async Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + { + // Verify that the multi set arguments are valid for this type of get query. + var multiSetList = multiSetArgs?.ToArray() ?? null; + if (multiSetList == null || multiSetList.Length == 0) + throw new ArgumentException($"At least one {nameof(IMultiSetValueArgs)} must be supplied.", nameof(multiSetArgs)); + + // Build the Cosmos SQL statement. + var name = multiSetList[0].GetModelName(Owner); + var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); + var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"SELECT * FROM c WHERE c.type in (\"{name}\"") : null; + + for (int i = 1; i < multiSetList.Length; i++) + { + name = multiSetList[i].GetModelName(Owner); + if (!types.TryAdd(name, multiSetList[i])) + throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); + + sb?.Append($", \"{name}\""); + } + + sb?.Append(')'); + + // Execute the Cosmos DB query. + var result = await Owner.CosmosDb.Invoker.InvokeAsync(Owner.CosmosDb, Owner, sb?.ToString() ?? sql, types, async (_, container, sql, types, ct) => + { + // Set up for work. + var da = new CosmosDbArgs(container.DbArgs, partitionKey); + var qsi = CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); + IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; + var isStj = js is Text.Json.JsonSerializer; + + while (qsi.HasMoreResults) + { + var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); + if (!rm.IsSuccessStatusCode) + return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); + + var json = Stj.JsonDocument.Parse(rm.Content); + if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) + return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); + + foreach (var jd in jds.EnumerateArray()) + { + if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) + return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); + + if (!types.TryGetValue(jt.GetString()!, out var msa)) + continue; // Ignore any unexpected type. + + var model = isStj + ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) + : js.Deserialize(jd.ToString(), msa.Type); + + if (model is null) + return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); + + var result = msa.AddItem(Owner, da, model); + if (result.IsFailure) + return result; + } + } + + return Result.Success; + }, cancellationToken).ConfigureAwait(false); + + if (result.IsFailure) + return result; + + // Validate the multi-set args and action each accordingly. + foreach (var msa in multiSetList) + { + var r = msa.Verify(); + if (r.IsFailure) + return r.AsResult(); + + if (!r.Value && msa.StopOnNull) + break; + + msa.Invoke(); + } + + return Result.Success; + } + + #endregion + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs b/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs index ac3d8d3e..0b0e2629 100644 --- a/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs +++ b/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs @@ -14,10 +14,10 @@ namespace CoreEx.Cosmos.Model /// Encapsulates a CosmosDb model-only query enabling all select-like capabilities. /// /// The cosmos model . - /// The . + /// The . /// The . /// A function to modify the underlying . - public class CosmosDbValueModelQuery(ICosmosDbContainerCore container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbModelQueryBase, CosmosDbValueModelQuery>(container, dbArgs) where TModel : class, IEntityKey, new() + public class CosmosDbValueModelQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbModelQueryBase, CosmosDbValueModelQuery>(container, dbArgs) where TModel : class, IEntityKey, new() { private readonly Func>, IQueryable>>? _query = query; @@ -29,20 +29,14 @@ private IQueryable> AsQueryable(bool allowSynchronousQuery if (!pagingSupported && Paging is not null) throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - IQueryable> query = Container.Container.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = (_query == null ? query : _query(query)).WhereType(typeof(TModel)); + IQueryable> query = Container.CosmosContainer.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); + query = (_query == null ? query : _query(query)).WhereType(Container.Model.GetModelName()); - var filter = Container.CosmosDb.GetAuthorizeFilter(Container.Container.Id); + var filter = Container.Model.GetValueAuthorizeFilter(); if (filter != null) - query = (IQueryable>)filter(query); + query = filter(query); - if (QueryArgs.FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => ((ITenantId)x.Value).TenantId == QueryArgs.GetTenantId()); - - if (typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => !((ILogicallyDeleted)x.Value).IsDeleted.IsDefined() || ((ILogicallyDeleted)x.Value).IsDeleted == null || ((ILogicallyDeleted)x.Value).IsDeleted == false); - - return query; + return QueryArgs.WhereModelValid(query); } /// diff --git a/src/CoreEx.Cosmos/Model/ICosmosDbModelContainer.cs b/src/CoreEx.Cosmos/Model/ICosmosDbModelContainer.cs deleted file mode 100644 index 812d1a29..00000000 --- a/src/CoreEx.Cosmos/Model/ICosmosDbModelContainer.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Enables the model-only . - /// - public interface ICosmosDbModelContainer : ICosmosDbContainerCore - { - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The model to be checked. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - bool IsModelValid(object? model, CosmosDbArgs args, bool checkAuthorized); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/ICosmosDbModelContainerT.cs b/src/CoreEx.Cosmos/Model/ICosmosDbModelContainerT.cs deleted file mode 100644 index 4bbb4b96..00000000 --- a/src/CoreEx.Cosmos/Model/ICosmosDbModelContainerT.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Azure.Cosmos; -using System; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Enables the model-only . - /// - /// The cosmos model . - public interface ICosmosDbModelContainer : ICosmosDbModelContainer where TModel : class, new() { } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs b/src/CoreEx.Cosmos/Model/IMultiSetArgs.cs similarity index 59% rename from src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs rename to src/CoreEx.Cosmos/Model/IMultiSetArgs.cs index 4f75abe4..77e0d99d 100644 --- a/src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs +++ b/src/CoreEx.Cosmos/Model/IMultiSetArgs.cs @@ -1,19 +1,15 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Results; +using System; -namespace CoreEx.Cosmos.Extended +namespace CoreEx.Cosmos.Model { /// /// Enables the CosmosDb multi-set arguments. /// public interface IMultiSetArgs { - /// - /// Gets the that contains the container configuration. - /// - ICosmosDbContainer Container { get; } - /// /// Gets the minimum number of items allowed. /// @@ -30,10 +26,17 @@ public interface IMultiSetArgs bool StopOnNull { get; } /// - /// Adds an entity item for its respective dataset. + /// Gets the model . /// - /// The entity item. - Result AddItem(object? item); + Type Type { get; } + + /// + /// Adds a model for its respective dataset. + /// + /// The . + /// The . + /// The model item. + Result AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item); /// /// Verify against contraints. @@ -45,5 +48,12 @@ public interface IMultiSetArgs /// Invokes the underlying action. /// void Invoke(); + + /// + /// Gets the name for the model . + /// + /// The . + /// The model name. + string GetModelName(CosmosDbContainer container); } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs b/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs new file mode 100644 index 00000000..8ba74e4d --- /dev/null +++ b/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs @@ -0,0 +1,9 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Cosmos.Model +{ + /// + /// Enables the model with multi-set arguments. + /// + public interface IMultiSetModelArgs : IMultiSetArgs { } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs b/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs new file mode 100644 index 00000000..c1cadd1d --- /dev/null +++ b/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs @@ -0,0 +1,82 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Results; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CoreEx.Cosmos.Model +{ + /// + /// Provides the CosmosDb when expecting a collection of items. + /// + /// The cosmos model . + public class MultiSetModelCollArgs : IMultiSetModelArgs where TModel : class, ICosmosDbType, IEntityKey, new() + { + private List? _items; + private readonly Action> _result; + + /// + /// Initializes a new instance of the class. + /// + /// The action that will be invoked with the result of the set. + /// The minimum number of items allowed. + /// The maximum numner of items allowed. + /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). + public MultiSetModelCollArgs(Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) + { + _result = result.ThrowIfNull(nameof(result)); + if (maxItems.HasValue && minItems <= maxItems.Value) + throw new ArgumentException("Max Items is less than Min Items.", nameof(maxItems)); + + MinItems = minItems; + MaxItems = maxItems; + StopOnNull = stopOnNull; + } + + /// + public int MinItems { get; } + + /// + public int? MaxItems { get; } + + /// + public bool StopOnNull { get; set; } + + /// + Type IMultiSetArgs.Type => typeof(TModel); + + /// + Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) + => MultiSetModelSingleArgs.Validate(container, dbArgs, (TModel)item) + .WhenAs(m => m is not null, m => + { + _items ??= []; + _items.Add(m); + return !MaxItems.HasValue || _items.Count <= MaxItems.Value + ? Result.Success + : Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); + }); + + /// + Result IMultiSetArgs.Verify() + { + var count = _items?.Count ?? 0; + if (count < MinItems) + return Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned less items ({count}) than expected ({MinItems}).")); + + return count > 0; + } + + /// + void IMultiSetArgs.Invoke() + { + if (_items is not null) + _result(_items.AsEnumerable()); + } + + /// + string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs b/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs new file mode 100644 index 00000000..a5b67502 --- /dev/null +++ b/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs @@ -0,0 +1,86 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Results; +using System; +using System.Collections.Generic; + +namespace CoreEx.Cosmos.Model +{ + /// + /// Provides the CosmosDb when expecting a single item only. + /// + /// The cosmos model . + /// The action that will be invoked with the result of the set. + /// Indicates whether the value is mandatory; defaults to true. + /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). + public class MultiSetModelSingleArgs(Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetModelArgs where TModel : class, ICosmosDbType, IEntityKey, new() + { + private List? _items; + private readonly Action _result = result.ThrowIfNull(nameof(result)); + + /// + /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. + /// + public bool IsMandatory { get; set; } = isMandatory; + + /// + public int MinItems => IsMandatory ? 1 : 0; + + /// + public int? MaxItems => 1; + + /// + public bool StopOnNull { get; set; } = stopOnNull; + + /// + Type IMultiSetArgs.Type => typeof(TModel); + + /// + Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) + => Validate(container, dbArgs, (TModel)item) + .WhenAs(v => v is not null, v => + { + _items ??= []; + _items.Add(v); + return !MaxItems.HasValue || _items.Count <= MaxItems.Value + ? Result.Success + : Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); + }); + + /// + /// Validate and map the to the . + /// + /// The . + /// The . + /// The value. + /// The validated and converted value. + internal static Result Validate(CosmosDbContainer container, CosmosDbArgs dbArgs, TModel model) + { + if (!container.Model.IsModelValid(model, dbArgs, true)) + return Result.Success; + + return model; + } + + /// + Result IMultiSetArgs.Verify() + { + var count = _items?.Count ?? 0; + if (count < MinItems) + return Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned less items ({count}) than expected ({MinItems}).")); + + return count > 0; + } + + /// + void IMultiSetArgs.Invoke() + { + if (_items is not null && _items.Count == 1) + _result(_items[0]); + } + + /// + string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); + } +} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs b/src/CoreEx.Cosmos/MultiSetValueCollArgs.cs similarity index 58% rename from src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs rename to src/CoreEx.Cosmos/MultiSetValueCollArgs.cs index 025cf585..df64215f 100644 --- a/src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs +++ b/src/CoreEx.Cosmos/MultiSetValueCollArgs.cs @@ -1,34 +1,33 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Cosmos.Model; using CoreEx.Entities; using CoreEx.Results; using System; using System.Collections.Generic; using System.Linq; -namespace CoreEx.Cosmos.Extended +namespace CoreEx.Cosmos { /// - /// Provides the CosmosDb multi-set arguments when expecting a collection of items. + /// Provides the CosmosDb when expecting a collection of items. /// /// The entity . /// The cosmos model . - public class MultiSetCollArgs : IMultiSetArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public class MultiSetValueCollArgs : IMultiSetValueArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { private List? _items; private readonly Action> _result; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The that contains the and container configuration. /// The action that will be invoked with the result of the set. /// The minimum number of items allowed. /// The maximum numner of items allowed. /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public MultiSetCollArgs(ICosmosDbContainer container, Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) + public MultiSetValueCollArgs(Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) { - Container = container.ThrowIfNull(nameof(container)); _result = result.ThrowIfNull(nameof(result)); if (maxItems.HasValue && minItems <= maxItems.Value) throw new ArgumentException("Max Items is less than Min Items.", nameof(maxItems)); @@ -38,14 +37,6 @@ public MultiSetCollArgs(ICosmosDbContainer container, Action - ICosmosDbContainer IMultiSetArgs.Container => Container; - - /// - /// Gets the that contains the and container configuration. - /// - public ICosmosDbContainer Container { get; } - /// public int MinItems { get; } @@ -56,17 +47,19 @@ public MultiSetCollArgs(ICosmosDbContainer container, Action - Result IMultiSetArgs.AddItem(object? item) - { - if (item is null) - return Result.Success; + Type IMultiSetArgs.Type => typeof(CosmosDbValue); - _items ??= []; - _items.Add((T)item); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); - } + /// + Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) + => MultiSetValueSingleArgs.ValidateAndMap(container, dbArgs, (CosmosDbValue)item) + .WhenAs(v => v is not null, v => + { + _items ??= []; + _items.Add(v); + return !MaxItems.HasValue || _items.Count <= MaxItems.Value + ? Result.Success + : Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); + }); /// Result IMultiSetArgs.Verify() @@ -84,5 +77,8 @@ void IMultiSetArgs.Invoke() if (_items is not null) _result(_items.AsEnumerable()); } + + /// + string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); } } \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs b/src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs similarity index 51% rename from src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs rename to src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs index b4c6b41d..c9d15a04 100644 --- a/src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs +++ b/src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs @@ -1,34 +1,26 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Cosmos.Model; using CoreEx.Entities; using CoreEx.Results; using System; using System.Collections.Generic; -namespace CoreEx.Cosmos.Extended +namespace CoreEx.Cosmos { /// - /// Provides the CosmosDb multi-set arguments when expecting a single item only. + /// Provides the CosmosDb when expecting a single item only. /// /// The entity . /// The cosmos model . - /// The that contains the and container configuration. /// The action that will be invoked with the result of the set. /// Indicates whether the value is mandatory; defaults to true. /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public class MultiSetSingleArgs(ICosmosDbContainer container, Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() + public class MultiSetValueSingleArgs(Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetValueArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() { private List? _items; private readonly Action _result = result.ThrowIfNull(nameof(result)); - /// - ICosmosDbContainer IMultiSetArgs.Container => Container; - - /// - /// Gets the that contains the and container configuration. - /// - public ICosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); - /// /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. /// @@ -44,16 +36,33 @@ namespace CoreEx.Cosmos.Extended public bool StopOnNull { get; set; } = stopOnNull; /// - Result IMultiSetArgs.AddItem(object? item) + Type IMultiSetArgs.Type => typeof(CosmosDbValue); + + /// + Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) + => ValidateAndMap(container, dbArgs, (CosmosDbValue)item) + .WhenAs(v => v is not null, v => + { + _items ??= []; + _items.Add(v); + return !MaxItems.HasValue || _items.Count <= MaxItems.Value + ? Result.Success + : Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); + }); + + /// + /// Validate and map the to the . + /// + /// The . + /// The . + /// The . + /// The validated and converted value. + internal static Result ValidateAndMap(CosmosDbContainer container, CosmosDbArgs dbArgs, CosmosDbValue model) { - if (item is null) + if (!container.Model.IsModelValid(model, dbArgs, true)) return Result.Success; - _items ??= []; - _items.Add((T)item); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); + return container.MapToValue(model, dbArgs); } /// @@ -72,5 +81,8 @@ void IMultiSetArgs.Invoke() if (_items is not null && _items.Count == 1) _result(_items[0]); } + + /// + string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); } } \ No newline at end of file diff --git a/src/CoreEx.Data/CoreEx.Data.csproj b/src/CoreEx.Data/CoreEx.Data.csproj index 36ab17ca..a3660597 100644 --- a/src/CoreEx.Data/CoreEx.Data.csproj +++ b/src/CoreEx.Data/CoreEx.Data.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj b/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj index 13f52eba..0d1731ed 100644 --- a/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj +++ b/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj @@ -11,7 +11,19 @@ - + + + + + + + + + + + + + diff --git a/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj b/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj index b4dcef20..3df57401 100644 --- a/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj +++ b/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/CoreEx.Database.Postgres/PostgresDatabase.cs b/src/CoreEx.Database.Postgres/PostgresDatabase.cs index 0acc31bf..5093fc78 100644 --- a/src/CoreEx.Database.Postgres/PostgresDatabase.cs +++ b/src/CoreEx.Database.Postgres/PostgresDatabase.cs @@ -65,7 +65,7 @@ public class PostgresDatabase(Func create, ILogger, and . /// /// The username (where null the value will default to ). - /// The timestamp (where null the value will default to ). + /// The timestamp (where null the value will default to ). /// The tenant identifer (where null the value will not be used). /// The unique user identifier (where null the value will not be used). /// The . @@ -79,7 +79,7 @@ public Task SetPostgresSessionContextAsync(string? username, DateTime? timestamp { return await StoredProcedure(SessionContextStoredProcedure) .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) - .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? ExecutionContext.SystemTime.UtcNow) + .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? SystemTime.Timestamp) .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") .NonQueryAsync(ct).ConfigureAwait(false); diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs index ba44cff3..b9c6de76 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs @@ -66,7 +66,7 @@ public class SqlServerDatabase(Func create, ILogger, and . /// /// The username (where null the value will default to ). - /// The timestamp (where null the value will default to ). + /// The timestamp (where null the value will default to ). /// The tenant identifer (where null the value will not be used). /// The unique user identifier (where null the value will not be used). /// The . @@ -80,7 +80,7 @@ public Task SetSqlSessionContextAsync(string? username, DateTime? timestamp, str { return await StoredProcedure(SessionContextStoredProcedure) .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) - .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? ExecutionContext.SystemTime.UtcNow) + .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? SystemTime.Timestamp) .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") .NonQueryAsync(ct).ConfigureAwait(false); diff --git a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs index d902d33b..d1c1933b 100644 --- a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs +++ b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs @@ -103,9 +103,9 @@ public static RefDataLoader ReferenceData( // Set ChangeLog properties where appropriate. if (operationType == OperationTypes.Create) - ChangeLog.PrepareCreated(value); + Cleaner.PrepareCreate(value); else - ChangeLog.PrepareUpdated(value); + Cleaner.PrepareUpdate(value); // Map the parameters. var map = (IDatabaseMapper)args.Mapper; diff --git a/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj b/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj index 0e504ee7..1c5a23c2 100644 --- a/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj +++ b/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj index 3dcb9db9..f4385028 100644 --- a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj +++ b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/CoreEx.EntityFrameworkCore/EfDb.cs b/src/CoreEx.EntityFrameworkCore/EfDb.cs index 2444a331..c7235af2 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDb.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDb.cs @@ -18,33 +18,34 @@ namespace CoreEx.EntityFrameworkCore /// /// - Will use the as the underlying entity key. /// - Will automatically update the corresponding properties depending on where performing a Create or an Update. - /// Will automatically update the from the to ensure not overridden. + /// - Will automatically update the from the to ensure not overridden. /// /// /// Additionally, extended functionality is performed where the EF model implements any of the following: /// - /// - Get and Update will automatically + /// - Query, Get and Update will automatically /// filter out previously deleted items. On a Delete the property will be automatically set and updated, - /// versus, performing a physical delete. A Query will need to be handled explicitly by the developer; recommend using EF native filtering to + /// versus, performing a physical delete. Although the Query will automatically filter; it is also recommended to use the EF native filtering to /// achieve; for example: entity.HasQueryFilter(v => v.IsDeleted != true);. /// /// /// /// The . - /// The . + /// The ; defaults to . /// Enables the to be overridden; defaults to . - public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? invoker = null) : IEfDb where TDbContext : DbContext, IEfDbContext + public class EfDb(TDbContext dbContext, IMapper? mapper = null, EfDbInvoker? invoker = null) : IEfDb where TDbContext : DbContext, IEfDbContext { - private readonly TDbContext _dbContext = dbContext.ThrowIfNull(nameof(dbContext)); + /// + DbContext IEfDb.DbContext => DbContext; /// - public DbContext DbContext => _dbContext; + public TDbContext DbContext { get; } = dbContext.ThrowIfNull(nameof(dbContext)); /// public EfDbInvoker Invoker { get; } = invoker ?? new EfDbInvoker(); /// - public IDatabase Database => _dbContext.BaseDatabase; + public IDatabase Database => DbContext.BaseDatabase; /// public IMapper Mapper { get; } = mapper.ThrowIfNull(nameof(mapper)); @@ -52,28 +53,55 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? /// public EfDbArgs DbArgs { get; set; } = new EfDbArgs(); + /// + public EfDbQuery Query(EfDbArgs args, Func, IQueryable>? query = null) where TModel : class, new() => new(this, args, query); + /// public EfDbQuery Query(EfDbArgs args, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, new() => new(this, args, query); /// - public async Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() + public async Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => (await GetWithResultInternalAsync(args, key, nameof(GetAsync), cancellationToken).ConfigureAwait(false)).Value; - + /// public Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => GetWithResultInternalAsync(args, key, nameof(GetWithResultAsync), cancellationToken); - private async Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, key, async (_, key, ct) => + /// + /// Performs the get internal. + /// + private Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() + => Result.GoAsync(() => GetWithResultInternalAsync(args, key, memberName, cancellationToken)) + .WhenAs(model => model is not null, model => + { + var val = Mapper.Map(model, OperationTypes.Get); + if (val is null) + return Result.Fail(new InvalidOperationException("Mapping from the EF model must not result in a null value.")); + else + return Result.Ok(val); + }); + + /// + async Task IEfDb.GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken) where TModel : class + => (await GetWithResultInternalAsync(args, key, nameof(GetAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + Task> IEfDb.GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken) where TModel : class + => GetWithResultInternalAsync(args, key, nameof(GetWithResultAsync), cancellationToken); + + /// + /// Performs the get internal (model-only). + /// + internal async Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where TModel : class, new() => await Invoker.InvokeAsync(this, key, async (_, key, ct) => { var model = await DbContext.FindAsync([.. key.Args], cancellationToken).ConfigureAwait(false); if (args.ClearChangeTrackerAfterGet) DbContext.ChangeTracker.Clear(); - if (model == default || (args.FilterByTenantId && model is ITenantId tenantId && tenantId.TenantId != DbArgs.GetTenantId()) || (model is ILogicallyDeleted ld && ld.IsDeleted.HasValue && ld.IsDeleted.Value)) - return Result.Ok(default!); + if (!args.IsModelValid(model)) + return Result.Ok(default!); - var result = Mapper.Map(model, OperationTypes.Get); - return (result is not null) ? Result.Ok(CleanUpResult(result)) : Result.Fail(new InvalidOperationException("Mapping from the EF model must not result in a null value.")); + return Result.Ok(model); }, cancellationToken, memberName).ConfigureAwait(false); /// @@ -89,20 +117,15 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? /// private async Task> CreateWithResultInternalAsync(EfDbArgs args, T value, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() { - CheckSaveArgs(args); - - ChangeLog.PrepareCreated(value.ThrowIfNull(nameof(value))); - Cleaner.ResetTenantId(value); + args.CheckSaveArgs(); - return await Invoker.InvokeAsync(this, args, value, async (_, args, value, ct) => + return await Invoker.InvokeAsync(this, args, Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), async (_, args, value, ct) => { TModel model = Mapper.Map(value, Mapping.OperationTypes.Create); if (model == null) return Result.Fail(new InvalidOperationException("Mapping to the EF model must not result in a null value.")); - Cleaner.ResetTenantId(model); - - DbContext.Add(model); + DbContext.Add(Cleaner.PrepareCreate(model)); if (args.SaveChanges) await DbContext.SaveChangesAsync(true, ct).ConfigureAwait(false); @@ -124,16 +147,13 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? /// private async Task> UpdateWithResultInternalAsync(EfDbArgs args, T value, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() { - CheckSaveArgs(args); - - ChangeLog.PrepareUpdated(value.ThrowIfNull(nameof(value))); - Cleaner.ResetTenantId(value); + args.CheckSaveArgs(); - return await Invoker.InvokeAsync(this, args, value, async (_, args, value, ct) => + return await Invoker.InvokeAsync(this, args, Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), async (_, args, value, ct) => { // Check (find) if the entity exists. var model = await DbContext.FindAsync(GetEfKeys(value), ct).ConfigureAwait(false); - if (model == null || (args.FilterByTenantId && model is ITenantId tenantId && tenantId.TenantId != DbArgs.GetTenantId()) || (model is ILogicallyDeleted ld && ld.IsDeleted.HasValue && ld.IsDeleted.Value)) + if (!args.IsModelValid(model)) return Result.NotFoundError(); // Check optimistic concurrency of etag/rowversion to ensure valid. This is needed as underlying EF uses the row version from the find above ignoring the value.ETag where overridden; this is needed to achieve. @@ -142,9 +162,8 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? // Update (map) the model from the entity then perform a dbcontext update which will discover/track changes. model = Mapper.Map(value, model, OperationTypes.Update); - Cleaner.ResetTenantId(model); - DbContext.Update(model); + DbContext.Update(Cleaner.PrepareUpdate(model)); if (args.SaveChanges) await DbContext.SaveChangesAsync(true, ct).ConfigureAwait(false); @@ -154,25 +173,33 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? } /// - public async Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => (await DeleteWithResultInternalAsync(args, key, nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + public Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() + => DeleteAsync(args, key, cancellationToken); /// public Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => DeleteWithResultInternalAsync(args, key, nameof(DeleteWithResultAsync), cancellationToken); + => DeleteWithResultAsync(args, key, cancellationToken); + + /// + public async Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new() + => (await DeleteWithResultInternalAsync(args, key, nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + public Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new() + => DeleteWithResultInternalAsync(args, key, nameof(DeleteWithResultAsync), cancellationToken); /// /// Performs the delete internal. /// - private async Task DeleteWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey where TModel : class, new() + private async Task DeleteWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where TModel : class, new() { - CheckSaveArgs(args); + args.CheckSaveArgs(); return await Invoker.InvokeAsync(this, args, key, async (_, args, key, ct) => { - // A pre-read is required to get the row version for concurrency. + // A pre-read is required to verify validity. var model = await DbContext.FindAsync([.. key.Args], ct).ConfigureAwait(false); - if (model == null || (args.FilterByTenantId && model is ITenantId tenantId && tenantId.TenantId != DbArgs.GetTenantId())) + if (!args.IsModelValid(model, checkIsDeleted: false)) return Result.NotFoundError(); // Delete; either logically or physically. @@ -182,7 +209,7 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? return Result.NotFoundError(); ld.IsDeleted = true; - DbContext.Update(model); + DbContext.Update(Cleaner.PrepareUpdate(model)); } else DbContext.Remove(model); @@ -201,15 +228,6 @@ public class EfDb(TDbContext dbContext, IMapper mapper, EfDbInvoker? /// The key values. public virtual object?[] GetEfKeys(T value) where T : IEntityKey => [.. value.EntityKey.Args]; - /// - /// Check the consistency of the save arguments. - /// - private static void CheckSaveArgs(EfDbArgs args) - { - if (args.Refresh && !args.SaveChanges) - throw new ArgumentException($"The {nameof(EfDbArgs.Refresh)} property cannot be set to true without the {nameof(EfDbArgs.SaveChanges)} also being set to true (given the save will occur after this method call).", nameof(args)); - } - /// /// Cleans up the result where specified within the args. /// diff --git a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs index ed53bcc2..63d5cc4e 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs @@ -1,7 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Diagnostics.CodeAnalysis; namespace CoreEx.EntityFrameworkCore { @@ -23,6 +25,12 @@ public EfDbArgs(EfDbArgs template) { SaveChanges = template.SaveChanges; Refresh = template.Refresh; + QueryNoTracking = template.QueryNoTracking; + ClearChangeTrackerAfterGet = template.ClearChangeTrackerAfterGet; + CleanUpResult = template.CleanUpResult; + FilterByTenantId = template.FilterByTenantId; + FilterByIsDeleted = template.FilterByIsDeleted; + GetTenantId = template.GetTenantId; } /// @@ -48,18 +56,70 @@ public EfDbArgs(EfDbArgs template) public bool ClearChangeTrackerAfterGet { get; set; } = false; /// - /// Indicates whether the result should be cleaned up. + /// Indicates whether the result should be cleaned up. /// public bool CleanUpResult { get; set; } = false; /// - /// Indicates that when the underlying model implements it is to be filtered by the corresponding value. Defaults to true. + /// Indicates that when the underlying model implements it is to be filtered by the corresponding value. Defaults to true. /// public bool FilterByTenantId { get; set; } = true; + /// + /// Indicates that when the underlying model implements it should filter out any models where the equals true. Defaults to true. + /// + public bool FilterByIsDeleted { get; set; } = true; + /// /// Gets or sets the get tenant identifier function; defaults to . /// public Func GetTenantId { get; set; } = () => ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null; + + /// + /// Checks the and properties to ensure that they are valid. + /// + public readonly void CheckSaveArgs() + { + if (Refresh && !SaveChanges) + throw new InvalidOperationException($"The {nameof(Refresh)} property cannot be set to true without the {nameof(SaveChanges)} also being set to true (given the save will occur after this method call)."); + } + + /// + /// Determines whether the model is considered valid; i.e. is not null, and where applicable, checks the and properties. + /// + /// The model . + /// The model value. + /// Indicates whether to perform the check. + /// Indicates whether to perform the check. + /// true indicates that the model is valid; otherwise, false. + /// This is used by the underlying operations to ensure the model is considered valid or not, and then handled accordingly. The query-based operations leverage the corresponding filter. + /// This leverages the to perform the check to ensure consistency of implementation. + public readonly bool IsModelValid([NotNullWhen(true)] TModel? model, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class + => model != default && WhereModelValid(new[] { model }.AsQueryable(), checkIsDeleted, checkTenantId).Any(); + + /// + /// Filters the to include only valid models; i.e. checks the and properties. + /// + /// The model . + /// The current query. + /// Indicates whether to perform the check. + /// Indicates whether to perform the check. + /// The updated query. + /// This is used by the underlying and to apply standardized filtering. + public readonly IQueryable WhereModelValid(IQueryable query, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class + { + query = query.ThrowIfNull(nameof(query)); + + if (checkTenantId && FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) + { + var tenantId = GetTenantId(); + query = query.Where(x => ((ITenantId)x).TenantId == tenantId); + } + + if (checkIsDeleted && FilterByIsDeleted && typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) + query = query.Where(x => ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); + + return query; + } } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs b/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs index c0019ac9..b9ce2fbd 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs @@ -6,7 +6,7 @@ namespace CoreEx.EntityFrameworkCore { /// - /// Provides a lightweight typed Entity Framework wrapper over the operations. + /// Provides a lightweight typed Entity Framework wrapper over the operations mapping from to (and vice versa). /// /// The resultant . /// The entity framework model . diff --git a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs index daabe3ce..ae0b42a4 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs @@ -11,6 +11,23 @@ namespace CoreEx.EntityFrameworkCore /// public static class EfDbExtensions { + /// + /// Creates an to enable select-like capabilities. + /// + /// The entity framework model . + /// The . + /// The function to further define the query. + /// Optionally override the specified/default . + /// A . + public static EfDbQuery Query(this IEfDb efDb, Func, IQueryable>? query = null, bool? noTracking = null) where TModel : class, new() + { + var ea = new EfDbArgs(efDb.DbArgs); + if (noTracking.HasValue) + ea.QueryNoTracking = noTracking.Value; + + return efDb.Query(ea, query); + } + /// /// Creates an to enable select-like capabilities. /// diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs new file mode 100644 index 00000000..b1732a6c --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs @@ -0,0 +1,110 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Results; + +namespace CoreEx.EntityFrameworkCore +{ + /// + /// Provides a lightweight typed Entity Framework wrapper over the operations that are -specific. + /// + /// The entity framework model . + /// The . + public readonly struct EfDbModel(IEfDb efDb) where TModel : class, new() + { + /// + public IEfDb EfDb { get; } = efDb.ThrowIfNull(nameof(efDb)); + + /// + /// Creates an to enable select-like capabilities. + /// + /// The function to further define the query. + /// A . + public EfDbQuery Query(Func, IQueryable>? query = null) => Query(new EfDbArgs(EfDb.DbArgs), query); + + /// + /// Creates an to enable select-like capabilities. + /// + /// The . + /// The function to further define the query. + /// A . + public EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) => new(EfDb, queryArgs, query); + + #region Standard + + /// + /// Gets the model for the specified . + /// + /// The key values. + /// The entity value where found; otherwise, null. + public Task GetAsync(params object?[] keys) => GetAsync(keys, default); + + /// + /// Gets the model for the specified . + /// + /// The key values. + /// The . + /// The entity value where found; otherwise, null. + public Task GetAsync(object?[] keys, CancellationToken cancellationToken = default) => GetAsync(CompositeKey.Create(keys), cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The entity value where found; otherwise, null. + /// Where the model implements and is true then null will be returned. + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The entity value where found; otherwise, null. + /// Where the model implements and is true then null will be returned. + public Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => EfDb.GetAsync(args, key, cancellationToken); + + #endregion + + #region WithResult + + /// + /// Gets the model for the specified with a . + /// + /// The key values. + /// The model value where found; otherwise, null. + public Task> GetWithResultAsync(params object?[] keys) => GetWithResultAsync(keys, default); + + /// + /// Gets the model for the specified with a . + /// + /// The key values. + /// The . + /// The model value where found; otherwise, null. + public Task> GetWithResultAsync(object?[] keys, CancellationToken cancellationToken = default) => GetWithResultAsync(CompositeKey.Create(keys), cancellationToken); + + /// + /// Gets the entity for the specified with a . + /// + /// The . + /// The . + /// The model value where found; otherwise, null. + /// Where the model implements and is true then null will be returned. + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); + + /// + /// Gets the entity for the specified with a . + /// + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null. + /// Where the model implements and is true then null will be returned. + public Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.GetWithResultAsync(args, key, cancellationToken); + + #endregion + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs b/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs new file mode 100644 index 00000000..54837dae --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs @@ -0,0 +1,308 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using CoreEx.Results; +using Microsoft.EntityFrameworkCore; + +namespace CoreEx.EntityFrameworkCore +{ + /// + /// Encapsulates an Entity Framework query enabling all select-like capabilities on a specified . + /// + /// The entity framework model . + /// Queried entities by default are not tracked; this behavior can be overridden using . + /// Reminder: leverage and then explictly include to improve performance where applicable. + /// Automatic filtering is applied using the . + public class EfDbQuery where TModel : class, new() + { + private readonly Func, IQueryable>? _query; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// A function to modify the underlying . + internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryable>? query = null) + { + EfDb = efdb.ThrowIfNull(nameof(efdb)); + Args = args; + _query = query; + Paging = null; + } + + /// + /// Gets the . + /// + public IEfDb EfDb { get; } + + /// + /// Gets the . + /// + public EfDbArgs Args { get; } + + /// + /// Gets the . + /// + public PagingResult? Paging { get; private set; } + + /// + /// Adds to the query. + /// + /// The . + /// The to suport fluent-style method-chaining. + public EfDbQuery WithPaging(PagingArgs? paging) + { + Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); + return this; + } + + /// + /// Adds to the query. + /// + /// The specified number of elements in a sequence to bypass. + /// The specified number of contiguous elements from the start of a sequence. + /// The to suport fluent-style method-chaining. + public EfDbQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); + + /// + /// Manages the DbContext and underlying query construction and lifetime. + /// + private async Task> ExecuteQueryAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) => await EfDb.Invoker.InvokeAsync(EfDb, EfDb, _query, Args, async (_, efdb, query, args, ct) => + { + var dbSet = args.WhereModelValid(args.QueryNoTracking ? efdb.DbContext.Set().AsNoTracking() : efdb.DbContext.Set()); + return Result.Ok(await executeAsync((query == null) ? dbSet : query(dbSet), ct).ConfigureAwait(false)); + }, cancellationToken, memberName).ConfigureAwait(false); + + /// + /// Executes the query and cleans. + /// + private async Task> ExecuteQueryInternalAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) + { + var result = await ExecuteQueryAsync(executeAsync, memberName, cancellationToken).ConfigureAwait(false); + if (result.IsFailure) + return Result.Fail(result.Error); + + return result.Value is TModel model ? model : default; + } + + /// + /// Sets the paging from the . + /// + private static IQueryable SetPaging(IQueryable query, PagingArgs? paging) + { + if (paging == null) + return query; + + var q = query; + if (paging.Skip > 0) + q = q.Skip((int)paging.Skip); + + return q.Take((int)(paging == null ? PagingArgs.DefaultTake : paging.Take)); + } + + /// + /// Selects a single item. + /// + /// The . + /// The single item. + public async Task SelectSingleAsync(CancellationToken cancellationToken = default) + => (await ExecuteQueryInternalAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false))!; + + /// + /// Selects a single item with a . + /// + /// The . + /// The single item. + public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) + => (await ExecuteQueryInternalAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleWithResultAsync), cancellationToken).ConfigureAwait(false))!; + + /// + /// Selects a single item or default. + /// + /// The . + /// The single item or default. + public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) + => await ExecuteQueryInternalAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false); + + /// + /// Selects a single item or default with a . + /// + /// The . + /// The single item or default. + public Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) + => ExecuteQueryInternalAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultWithResultAsync), cancellationToken); + + /// + /// Selects first item. + /// + /// The . + /// The first item. + public async Task SelectFirstAsync(CancellationToken cancellationToken = default) + => (await ExecuteQueryInternalAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false))!; + + /// + /// Selects first item with a . + /// + /// The . + /// The first item. + public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) + => (await ExecuteQueryInternalAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstWithResultAsync), cancellationToken).ConfigureAwait(false))!; + + /// + /// Selects first item or default. + /// + /// The . + /// The single item or default. + public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) + => await ExecuteQueryInternalAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false); + + /// + /// Selects first item or default with a . + /// + /// The . + /// The single item or default. + public Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) + => ExecuteQueryInternalAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultWithResultAsync), cancellationToken); + + /// + /// Executes the query command creating a . + /// + /// The . + /// The . + /// The . + /// The resulting . + public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult + { + Paging = Paging, + Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultAsync), cancellationToken).ConfigureAwait(false)).Value + }; + + /// + /// Executes the query command creating a with a . + /// + /// The . + /// The . + /// The . + /// The resulting . + public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult + { + Paging = Paging, + Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultWithResultAsync), cancellationToken).ConfigureAwait(false)).Value + }; + + /// + /// Executes the query command creating a resultant collection. + /// + /// The collection . + /// A resultant collection. + public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + => (await SelectQueryWithResultInternalAsync(nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Executes the query command creating a resultant collection with a . + /// + /// The collection . + /// A resultant collection. + public Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + => SelectQueryWithResultInternalAsync(nameof(SelectQueryWithResultAsync), cancellationToken); + + /// + /// Executes the query command creating a resultant collection with a internal. + /// + private async Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() + { + var coll = new TColl(); + return await SelectQueryWithResultInternalAsync(coll, memberName, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes a query adding to the passed collection. + /// + /// The collection . + /// The . + /// The collection to add items to. + /// The . + public async Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection + => (await SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Executes a query adding to the passed collection with a . + /// + /// The collection . + /// The . + /// The collection to add items to. + /// The . + public Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection + => SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryWithResultAsync), cancellationToken); + + /// + /// Executes a query adding to the passed collection with a internal. + /// + private async Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection + { + collection.ThrowIfNull(nameof(collection)); + + var result = await SelectQueryWithResultAsync(item => + { + collection.Add(item); + return true; + }, memberName, cancellationToken).ConfigureAwait(false); + + return result.ThenAs(() => collection); + } + + /// + /// Executes a query with per processing + /// + /// The item function. + /// The . + /// The returns a which indicates whether to continue enumerating. A true indicates + /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. + public async Task SelectQueryAsync(Func item, CancellationToken cancellationToken = default) + => (await SelectQueryWithResultAsync(model => item.ThrowIfNull(nameof(item))(model), nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Executes a query with per processing with a . + /// + /// The item function. + /// The . + /// The . + /// The returns a where the corresponding indicates whether to continue enumerating. A true indicates + /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. + public Task SelectQueryWithResultAsync(Func> item, CancellationToken cancellationToken = default) + => SelectQueryWithResultAsync(item.ThrowIfNull(nameof(item)), nameof(SelectQueryWithResultAsync), cancellationToken); + + /// + /// Executes a query with per processing. + /// + /// The item function. + /// The member name. + /// The . + /// The . + /// The returns a where the corresponding indicates whether to continue enumerating. A true indicates + /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. + internal async Task SelectQueryWithResultAsync(Func> item, string memberName, CancellationToken cancellationToken) + { + var result = await ExecuteQueryAsync(async (query, ct) => + { + var q = SetPaging(query, Paging); + Result r = Result.None; + + await foreach (var model in q.AsAsyncEnumerable().WithCancellation(ct)) + { + r = item(model); + if (r.IsFailure || !r.Value) + break; + } + + if (r.IsSuccess && Paging != null && Paging.IsGetCount) + Paging.TotalCount = query.LongCount(); + + return r; + }, memberName, cancellationToken); + + return result.AsResult(); + } + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs b/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs index dedd82ad..ec4ededc 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs @@ -8,15 +8,15 @@ namespace CoreEx.EntityFrameworkCore { /// - /// Encapsulates an Entity Framework query enabling all select-like capabilities. + /// Encapsulates an Entity Framework query enabling all select-like capabilities on a specified automatically mapping to the resultant . /// /// The resultant . /// The entity framework model . /// Queried entities by default are not tracked; this behavior can be overridden using . /// Reminder: leverage and then explictly include to improve performance where applicable. - public struct EfDbQuery where T : class, new() where TModel : class, new() + public class EfDbQuery where T : class, new() where TModel : class, new() { - private readonly Func, IQueryable>? _query; + private readonly EfDbQuery _query; /// /// Initializes a new instance of the struct. @@ -24,33 +24,27 @@ namespace CoreEx.EntityFrameworkCore /// The . /// The . /// A function to modify the underlying . - internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryable>? query = null) - { - EfDb = efdb.ThrowIfNull(nameof(efdb)); - Args = args; - _query = query; - Paging = null; - } + internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryable>? query = null) => _query = new EfDbQuery(efdb, args, query); /// /// Gets the . /// - public IEfDb EfDb { get; } + public IEfDb EfDb => _query.EfDb; /// /// Gets the . /// - public EfDbArgs Args { get; } + public EfDbArgs Args => _query.Args; /// /// Gets the . /// - public readonly IMapper Mapper => EfDb.Mapper; + public IMapper Mapper => EfDb.Mapper; /// /// Gets the . /// - public PagingResult? Paging { get; private set; } + public PagingResult? Paging => _query.Paging; /// /// Adds to the query. @@ -59,7 +53,7 @@ internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryabl /// The to suport fluent-style method-chaining. public EfDbQuery WithPaging(PagingArgs? paging) { - Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); + _query.WithPaging(paging); return this; } @@ -71,110 +65,94 @@ public EfDbQuery WithPaging(PagingArgs? paging) /// The to suport fluent-style method-chaining. public EfDbQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - /// - /// Manages the DbContext and underlying query construction and lifetime. - /// - private async readonly Task> ExecuteQueryAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) => await EfDb.Invoker.InvokeAsync(EfDb, EfDb, _query, Args, async (_, efdb, query, args, ct) => - { - var dbSet = args.QueryNoTracking ? efdb.DbContext.Set().AsNoTracking() : efdb.DbContext.Set(); - - if (args.FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - dbSet = dbSet.Where(x => ((ITenantId)x).TenantId == args.GetTenantId()); - - return Result.Ok(await executeAsync((query == null) ? dbSet : query(dbSet), ct).ConfigureAwait(false)); - }, cancellationToken, memberName).ConfigureAwait(false); - /// /// Executes the query and maps. /// - private async readonly Task> ExecuteQueryAndMapAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) + private async Task> ExecuteQueryAndMapAsync(Func>> executeAsync, CancellationToken cancellationToken) { - var result = await ExecuteQueryAsync(executeAsync, memberName, cancellationToken).ConfigureAwait(false); + var result = await executeAsync(cancellationToken).ConfigureAwait(false); if (result.IsFailure) - return Result.Fail(result.Error); + return Result.Fail(result.Error); - var val = result.Value == null ? default! : Mapper.Map(result.Value, Mapping.OperationTypes.Get); + var val = result.Value == null ? default! : Mapper.Map(result.Value, Mapping.OperationTypes.Get); return Args.CleanUpResult ? Cleaner.Clean(val) : val; } - /// - /// Sets the paging from the . - /// - private static IQueryable SetPaging(IQueryable query, PagingArgs? paging) - { - if (paging == null) - return query; - - var q = query; - if (paging.Skip > 0) - q = q.Skip((int)paging.Skip); - - return q.Take((int)(paging == null ? PagingArgs.DefaultTake : paging.Take)); - } - /// /// Selects a single item. /// /// The . /// The single item. - public async readonly Task SelectSingleAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false))!; + public async Task SelectSingleAsync(CancellationToken cancellationToken = default) + => (await SelectSingleWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; /// /// Selects a single item with a . /// /// The . /// The single item. - public async readonly Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleWithResultAsync), cancellationToken).ConfigureAwait(false))!; + public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) + { + var q = _query; + return await ExecuteQueryAndMapAsync(q.SelectSingleWithResultAsync, cancellationToken).ConfigureAwait(false); + } /// /// Selects a single item or default. /// /// The . /// The single item or default. - public async readonly Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) - => await ExecuteQueryAndMapAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false); + public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) + => (await SelectSingleOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; /// /// Selects a single item or default with a . /// /// The . /// The single item or default. - public readonly Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => ExecuteQueryAndMapAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultWithResultAsync), cancellationToken); + public async Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) + { + var q = _query; + return await ExecuteQueryAndMapAsync(q.SelectSingleOrDefaultWithResultAsync, cancellationToken).ConfigureAwait(false); + } /// /// Selects first item. /// /// The . /// The first item. - public async readonly Task SelectFirstAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false))!; + public async Task SelectFirstAsync(CancellationToken cancellationToken = default) + => (await SelectFirstWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; /// /// Selects first item with a . /// /// The . /// The first item. - public async readonly Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstWithResultAsync),cancellationToken).ConfigureAwait(false))!; + public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) + { + var q = _query; + return await ExecuteQueryAndMapAsync(q.SelectFirstWithResultAsync, cancellationToken).ConfigureAwait(false); + } /// /// Selects first item or default. /// /// The . /// The single item or default. - public async readonly Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) - => await ExecuteQueryAndMapAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false); + public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) + => (await SelectFirstOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; /// /// Selects first item or default with a . /// /// The . /// The single item or default. - public readonly Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => ExecuteQueryAndMapAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultWithResultAsync), cancellationToken); + public async Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) + { + var q = _query; + return await ExecuteQueryAndMapAsync(q.SelectFirstOrDefaultWithResultAsync, cancellationToken).ConfigureAwait(false); + } /// /// Executes the query command creating a . @@ -183,9 +161,9 @@ public async readonly Task> SelectFirstWithResultAsync(CancellationTok /// The . /// The . /// The resulting . - public async readonly Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult + public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult { - Paging = Paging, + Paging = _query.Paging, Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultAsync), cancellationToken).ConfigureAwait(false)).Value }; @@ -196,9 +174,9 @@ public async readonly Task> SelectFirstWithResultAsync(CancellationTok /// The . /// The . /// The resulting . - public async readonly Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult + public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult { - Paging = Paging, + Paging = _query.Paging, Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultWithResultAsync), cancellationToken).ConfigureAwait(false)).Value }; @@ -207,7 +185,7 @@ public async readonly Task> SelectFirstWithResultAsync(CancellationTok /// /// The collection . /// A resultant collection. - public async readonly Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() => (await SelectQueryWithResultInternalAsync(nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; /// @@ -215,13 +193,13 @@ public async readonly Task> SelectFirstWithResultAsync(CancellationTok /// /// The collection . /// A resultant collection. - public readonly Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + public Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() => SelectQueryWithResultInternalAsync(nameof(SelectQueryWithResultAsync), cancellationToken); /// /// Executes the query command creating a resultant collection with a internal. /// - private async readonly Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() + private async Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() { var coll = new TColl(); return await SelectQueryWithResultInternalAsync(coll, memberName, cancellationToken).ConfigureAwait(false); @@ -234,7 +212,7 @@ public async readonly Task> SelectFirstWithResultAsync(CancellationTok /// The . /// The collection to add items to. /// The . - public async readonly Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection + public async Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection => (await SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; /// @@ -244,35 +222,29 @@ public async readonly Task SelectQueryAsync(TColl collection, Canc /// The . /// The collection to add items to. /// The . - public readonly Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection + public Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection => SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryWithResultAsync), cancellationToken); /// /// Executes a query adding to the passed collection with a internal. /// - private async readonly Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection + private async Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection { collection.ThrowIfNull(nameof(collection)); - var paging = Paging; var mapper = Mapper; - var args = Args; - return await ExecuteQueryAsync(async (query, ct) => + var result = await _query.SelectQueryWithResultAsync(item => { - var q = SetPaging(query, paging); - - await foreach (var item in q.AsAsyncEnumerable().WithCancellation(ct)) - { - var val = mapper.Map(item, OperationTypes.Get) ?? throw new InvalidOperationException("Mapping from the EF model must not result in a null value."); - collection.Add(args.CleanUpResult ? Cleaner.Clean(val) : val); - } + var val = mapper.Map(item, OperationTypes.Get); + if (val is null) + return new InvalidOperationException("Mapping from the EF model must not result in a null value."); - if (paging != null && paging.IsGetCount) - paging.TotalCount = query.LongCount(); + collection.Add(val); + return true; + }, memberName, cancellationToken).ConfigureAwait(false); - return Result.Ok(collection); - }, memberName, cancellationToken); + return result.ThenAs(() => collection); } } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs index 4fc597b5..598c2d5b 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs @@ -10,12 +10,12 @@ namespace Microsoft.Extensions.DependencyInjection public static class EfDbServiceCollectionExtensions { /// - /// Adds the as a scoped service. + /// Adds the as a scoped service. /// - /// The corresponding entity framework . + /// The corresponding entity framework implementation . /// The . /// The to support fluent-style method-chaining. - public static IServiceCollection AddEfDb(this IServiceCollection services) where TEfDb : class, IEfDb => services.AddScoped(); + public static IServiceCollection AddEfDb(this IServiceCollection services) where TEfDb : class, IEfDb => services.AddScoped(); /// /// Adds the as a scoped service. diff --git a/src/CoreEx.EntityFrameworkCore/IEfDb.cs b/src/CoreEx.EntityFrameworkCore/IEfDb.cs index 6ca2351a..823ce054 100644 --- a/src/CoreEx.EntityFrameworkCore/IEfDb.cs +++ b/src/CoreEx.EntityFrameworkCore/IEfDb.cs @@ -39,7 +39,16 @@ public interface IEfDb EfDbArgs DbArgs { get; } /// - /// Creates an to enable select-like capabilities. + /// Creates an to enable select-like capabilities on a specified . + /// + /// The entity framework model . + /// The . + /// The function to further define the query. + /// A . + EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) where TModel : class, new(); + + /// + /// Creates an to enable select-like capabilities on a specified automatically mapping to the resultant . /// /// The resultant . /// The entity framework model . @@ -48,6 +57,26 @@ public interface IEfDb /// A . EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, new(); + /// + /// Gets the model for the specified . + /// + /// The entity framework model . + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null. + Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); + + /// + /// Gets the model for the specified with a . + /// + /// The entity framework model . + /// The . + /// The . + /// The . + /// The model value where found; otherwise, null. + Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); + /// /// Gets the entity for the specified mapping from to . /// @@ -114,6 +143,27 @@ public interface IEfDb /// The value (refreshed where specified). Task> UpdateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); + /// + /// Performs a delete for the specified . + /// + /// The entity framework model . + /// The . + /// The . + /// The . + /// Where the model implements then this will update the with true versus perform a physical deletion. + Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); + + /// + /// Performs a delete for the specified with a . + /// + /// The entity framework model . + /// The . + /// The . + /// The . + /// The . + /// Where the model implements then this will update the with true versus perform a physical deletion. + Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); + /// /// Performs a delete for the specified . /// diff --git a/src/CoreEx.OData/ODataClient.cs b/src/CoreEx.OData/ODataClient.cs index 400c4ebe..0b18233b 100644 --- a/src/CoreEx.OData/ODataClient.cs +++ b/src/CoreEx.OData/ODataClient.cs @@ -47,21 +47,15 @@ public class ODataClient(Soc.ODataClient client, IMapper? mapper = null, ODataIn /// public async Task> CreateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, async (_, ct) => { - ChangeLog.PrepareCreated(value); - Cleaner.ResetTenantId(value); - - var model = Mapper.Map(value, OperationTypes.Create)!; - Cleaner.ResetTenantId(model); - - var created = await Client.For(collectionName).Set(model).InsertEntryAsync(true, ct).ConfigureAwait(false); + var model = Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create)!; + var created = await Client.For(collectionName).Set(Cleaner.PrepareCreate(model)).InsertEntryAsync(true, ct).ConfigureAwait(false); return MapToValue(args, created); }, this, cancellationToken); /// public async Task> UpdateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, async (_, ct) => { - ChangeLog.PrepareUpdated(value); - Cleaner.ResetTenantId(value); + Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))); TModel model; if (args.PreReadOnUpdate) @@ -77,7 +71,7 @@ public class ODataClient(Soc.ODataClient client, IMapper? mapper = null, ODataIn else model = Mapper.Map(value, OperationTypes.Update)!; - var updated = await Client.For(collectionName).Key(value.EntityKey.Args).Set(model).UpdateEntryAsync(true, ct).ConfigureAwait(false); + var updated = await Client.For(collectionName).Key(value.EntityKey.Args).Set(Cleaner.PrepareUpdate(model)).UpdateEntryAsync(true, ct).ConfigureAwait(false); return updated is null ? Result.NotFoundError() : Result.Ok(MapToValue(args, updated)); }, this, cancellationToken); diff --git a/src/CoreEx.OData/ODataItemCollection.cs b/src/CoreEx.OData/ODataItemCollection.cs index 455b92b9..c0ef5206 100644 --- a/src/CoreEx.OData/ODataItemCollection.cs +++ b/src/CoreEx.OData/ODataItemCollection.cs @@ -101,8 +101,7 @@ namespace CoreEx.OData /// The value (refreshed where specified). public async Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => { - ChangeLog.PrepareCreated(value.ThrowIfNull()); - var item = ODataItem.MapFrom(Mapper, value, OperationTypes.Create); + var item = ODataItem.MapFrom(Mapper, Cleaner.PrepareCreate(value.ThrowIfNull()), OperationTypes.Create); Mapper.MapToOData(value, item, OperationTypes.Create); var created = await Owner.Client.For(CollectionName).Set(item.Attributes).InsertEntryAsync(true, ct).ConfigureAwait(false); return created is null ? Result.NotFoundError() : Result.Ok(MapFromOData(new ODataItem(created), OperationTypes.Get)); @@ -125,8 +124,7 @@ public async Task> CreateWithResultAsync(T value, CancellationToken ca public async Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => { ODataItem item; - ChangeLog.PrepareUpdated(value.ThrowIfNull()); - var key = Mapper.GetODataKey(value, OperationTypes.Update); + var key = Mapper.GetODataKey(Cleaner.PrepareUpdate(value.ThrowIfNull()), OperationTypes.Update); if (Args.PreReadOnUpdate) { diff --git a/src/CoreEx.Solace/CoreEx.Solace.csproj b/src/CoreEx.Solace/CoreEx.Solace.csproj index 434dc998..c9c7539b 100644 --- a/src/CoreEx.Solace/CoreEx.Solace.csproj +++ b/src/CoreEx.Solace/CoreEx.Solace.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj b/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj index 97ca2d5a..21f9de8e 100644 --- a/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj +++ b/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj b/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj index f2033110..0686c1f3 100644 --- a/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj +++ b/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj index 19bca45a..c4578e96 100644 --- a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj +++ b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs index b05859c2..d680bebe 100644 --- a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs +++ b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using CoreEx.Http; using CoreEx.Json; using CoreEx.Json.Merge; +using CoreEx.Json.Merge.Extended; using CoreEx.Mapping; using CoreEx.RefData; using CoreEx.RefData.Caching; @@ -244,16 +245,27 @@ public static IServiceCollection AddJsonSerializer(this IServiceCollection servi .AddSingleton(); /// - /// Adds the as the singleton service. + /// Adds the as the singleton service. /// /// The . - /// The action to enable the to be further configured. + /// The action to enable the to be further configured. /// The . - public static IServiceCollection AddJsonMergePatch(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => + public static IServiceCollection AddJsonMergePatch(this IServiceCollection services, Action? configure = null) => AddJsonMergePatch(services, sp => { - var jmpo = new JsonMergePatchOptions(sp.GetService()); + var jmpo = new JsonMergePatchExOptions(sp.GetService()); configure?.Invoke(jmpo); - return new JsonMergePatch(jmpo); + return new JsonMergePatchEx(jmpo); + }); + + /// + /// Adds the singleton service. + /// + /// The . + /// The function to create the instance.. + /// The . + public static IServiceCollection AddJsonMergePatch(this IServiceCollection services, Func? createFactory) => CheckServices(services).AddSingleton(sp => + { + return createFactory?.Invoke(sp) ?? throw new InvalidOperationException($"Unable to create '{nameof(IJsonMergePatch)}' instance; '{nameof(createFactory)}' resulted in null."); }); /// diff --git a/src/CoreEx/Abstractions/Internal.cs b/src/CoreEx/Abstractions/Internal.cs index d4740226..3401be6e 100644 --- a/src/CoreEx/Abstractions/Internal.cs +++ b/src/CoreEx/Abstractions/Internal.cs @@ -1,6 +1,8 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Configuration; using Microsoft.Extensions.Caching.Memory; +using System; using System.Runtime.CompilerServices; [assembly: @@ -21,7 +23,7 @@ public static class Internal private static IMemoryCache? _fallbackCache; /// - /// Gets the CoreEx fallback . + /// Gets the CoreEx . /// internal static IMemoryCache MemoryCache => ExecutionContext.GetService() ?? (_fallbackCache ??= new MemoryCache(new MemoryCacheOptions())); @@ -29,5 +31,15 @@ public static class Internal /// Represents a cache for internal capabilities. /// public interface IInternalCache : IMemoryCache { } + + /// + /// Indicates whether the specified should be logged. + /// + /// The . + internal static bool ShouldExceptionBeLogged() where TException : Exception, IExtendedException => MemoryCache.GetOrCreate(typeof(TException), entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + return ExecutionContext.GetService()?.GetCoreExValue($"Log{typeof(TException).Name}") ?? false; + }); } } \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs index 033a03b5..2f8ecc8d 100644 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs +++ b/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs @@ -3,9 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Linq.Expressions; namespace CoreEx.Abstractions.Reflection diff --git a/src/CoreEx/AuthenticationException.cs b/src/CoreEx/AuthenticationException.cs index 88f97cfa..9e6b976c 100644 --- a/src/CoreEx/AuthenticationException.cs +++ b/src/CoreEx/AuthenticationException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class AuthenticationException : Exception, IExtendedException { private const string _message = "An authentication error occurred; the credentials you provided are not valid."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/AuthorizationException.cs b/src/CoreEx/AuthorizationException.cs index 72a163ba..a912030e 100644 --- a/src/CoreEx/AuthorizationException.cs +++ b/src/CoreEx/AuthorizationException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class AuthorizationException : Exception, IExtendedException { private const string _message = "An authorization error occurred; you are not permitted to perform this action."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/BusinessException.cs b/src/CoreEx/BusinessException.cs index 9e80283e..26cf68b3 100644 --- a/src/CoreEx/BusinessException.cs +++ b/src/CoreEx/BusinessException.cs @@ -15,11 +15,12 @@ namespace CoreEx public class BusinessException : Exception, IExtendedException { private const string _message = "A business error occurred."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/ConcurrencyException.cs b/src/CoreEx/ConcurrencyException.cs index b28ff774..ba1cdfd8 100644 --- a/src/CoreEx/ConcurrencyException.cs +++ b/src/CoreEx/ConcurrencyException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class ConcurrencyException : Exception, IExtendedException { private const string _message = "A concurrency error occurred; please refresh the data and try again."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/ConflictException.cs b/src/CoreEx/ConflictException.cs index 82d6a931..9869f9c3 100644 --- a/src/CoreEx/ConflictException.cs +++ b/src/CoreEx/ConflictException.cs @@ -15,11 +15,12 @@ namespace CoreEx public class ConflictException : Exception, IExtendedException { private const string _message = "A data conflict occurred."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/CoreEx.csproj b/src/CoreEx/CoreEx.csproj index 594fdbd9..9eb1b2d9 100644 --- a/src/CoreEx/CoreEx.csproj +++ b/src/CoreEx/CoreEx.csproj @@ -13,16 +13,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -67,7 +67,7 @@ - + diff --git a/src/CoreEx/DataConsistencyException.cs b/src/CoreEx/DataConsistencyException.cs index c1aa2e71..e62bd01b 100644 --- a/src/CoreEx/DataConsistencyException.cs +++ b/src/CoreEx/DataConsistencyException.cs @@ -15,11 +15,12 @@ namespace CoreEx public class DataConsistencyException : Exception, IExtendedException { private const string _message = "A potential data consistency error occurred."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/DuplicateException.cs b/src/CoreEx/DuplicateException.cs index 800592f1..8e75c22f 100644 --- a/src/CoreEx/DuplicateException.cs +++ b/src/CoreEx/DuplicateException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class DuplicateException : Exception, IExtendedException { private const string _message = "A duplicate error occurred."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/Entities/ChangeLog.cs b/src/CoreEx/Entities/ChangeLog.cs index b9376acd..4aaa813f 100644 --- a/src/CoreEx/Entities/ChangeLog.cs +++ b/src/CoreEx/Entities/ChangeLog.cs @@ -89,6 +89,6 @@ public static IChangeLogAudit PrepareUpdated(IChangeLogAudit changeLog, Executio /// /// Gets the timestamp. /// - private static DateTime GetTimestamp(ExecutionContext? ec) => ec != null ? ec.Timestamp : ExecutionContext.SystemTime.UtcNow; + private static DateTime GetTimestamp(ExecutionContext? ec) => ec != null ? ec.Timestamp : SystemTime.Timestamp; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/Cleaner.cs b/src/CoreEx/Entities/Cleaner.cs index 1b9e7e94..8089cf83 100644 --- a/src/CoreEx/Entities/Cleaner.cs +++ b/src/CoreEx/Entities/Cleaner.cs @@ -4,6 +4,7 @@ using CoreEx.Globalization; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; namespace CoreEx.Entities @@ -275,13 +276,58 @@ public static void CleanUp(params object?[] values) /// /// The value . /// The value. - public static void ResetTenantId(T? value) + /// The optional . + public static void ResetTenantId(T? value, ExecutionContext? executionContext = null) { if (value == null || value is not ITenantId ti) return; - if (ExecutionContext.HasCurrent) - ti.TenantId = ExecutionContext.Current.TenantId; + if (executionContext is null) + { + + if (ExecutionContext.HasCurrent) + ti.TenantId = ExecutionContext.Current.TenantId; + } + else + ti.TenantId = executionContext.TenantId; + } + + /// + /// Prepares the value for create by encapsulating and . + /// + /// The value . + /// The value. + /// The optional . + /// The value to support fluent-style method-chaining. + [return: NotNullIfNotNull(nameof(value))] + public static T? PrepareCreate(T? value, ExecutionContext? executionContext = null) + { + if (value is not null) + { + ChangeLog.PrepareCreated(value, executionContext); + ResetTenantId(value, executionContext); + } + + return value; + } + + /// + /// Prepares the value for update by encapsulating and . + /// + /// The value . + /// The value. + /// The optional . + /// The value to support fluent-style method-chaining. + [return: NotNullIfNotNull(nameof(value))] + public static T? PrepareUpdate(T? value, ExecutionContext? executionContext = null) + { + if (value is not null) + { + ChangeLog.PrepareUpdated(value, executionContext); + ResetTenantId(value, executionContext); + } + + return value; } } } \ No newline at end of file diff --git a/src/CoreEx/Entities/PagingArgs.cs b/src/CoreEx/Entities/PagingArgs.cs index fe510b69..549e5fb4 100644 --- a/src/CoreEx/Entities/PagingArgs.cs +++ b/src/CoreEx/Entities/PagingArgs.cs @@ -49,8 +49,15 @@ public static long MaxTake /// /// Gets or sets the default . /// + /// Defaults to false. public static bool DefaultIsGetCount { get; set; } + /// + /// Indicates whether -based paging is supported. + /// + /// Defaults to false. + public static bool IsTokenSupported { get; set; } = false; + /// /// Creates a for a specified page number and size. /// @@ -63,7 +70,7 @@ public static PagingArgs CreatePageAndSize(long page, long? size = null, bool? i var pa = new PagingArgs { Page = page < 0 ? 1 : page, - Take = !size.HasValue || size.Value < 1 ? DefaultTake : (size.Value > MaxTake ? MaxTake : size.Value), + Take = !size.HasValue || size.Value < 1 ? DefaultTake : Math.Min(size.Value, MaxTake), IsGetCount = isGetCount == null ? DefaultIsGetCount : isGetCount.Value }; @@ -94,7 +101,7 @@ public static PagingArgs CreatePageAndSize(long page, long? size = null, bool? i /// The . public static PagingArgs CreateTokenAndTake(string token, long? take = null, bool? isGetCount = null) => new () { - Token = token.ThrowIfNullOrEmpty(), + Token = IsTokenSupported ? token.ThrowIfNullOrEmpty() : throw new NotSupportedException($"{nameof(Token)}-based paging is not supported."), Take = !take.HasValue || take.Value< 1 ? DefaultTake : (take.Value > MaxTake? MaxTake : take.Value), IsGetCount = isGetCount == null ? DefaultIsGetCount : isGetCount.Value }; diff --git a/src/CoreEx/Events/EventDataFormatter.cs b/src/CoreEx/Events/EventDataFormatter.cs index 2a97a7fc..7f1cd194 100644 --- a/src/CoreEx/Events/EventDataFormatter.cs +++ b/src/CoreEx/Events/EventDataFormatter.cs @@ -13,7 +13,7 @@ namespace CoreEx.Events /// Provides the formatting options and corresponding . /// /// This enables further standardized formatting of the prior to serialization. - /// The , and will default, where null, to , and + /// The , and will default, where null, to , and /// respectively. public class EventDataFormatter { @@ -152,7 +152,7 @@ public virtual void Format(EventData @event) var value = @event.Value; @event.Id ??= Guid.NewGuid().ToString(); - @event.Timestamp ??= new DateTimeOffset(ExecutionContext.HasCurrent ? ExecutionContext.Current.Timestamp : ExecutionContext.SystemTime.UtcNow); + @event.Timestamp ??= new DateTimeOffset(SystemTime.Timestamp); if (PropertySelection.HasFlag(EventDataProperty.Key)) { diff --git a/src/CoreEx/ExecutionContext.cs b/src/CoreEx/ExecutionContext.cs index ac0a7cb0..488601d8 100644 --- a/src/CoreEx/ExecutionContext.cs +++ b/src/CoreEx/ExecutionContext.cs @@ -131,11 +131,6 @@ public static object GetRequiredService(Type type) throw new InvalidOperationException($"Attempted to get service '{type.FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); } - /// - /// Gets the instance from the ; where not found the will be used. - /// - public static ISystemTime SystemTime => GetService() ?? CoreEx.SystemTime.Default; - /// /// Gets the username from the settings. /// @@ -182,8 +177,9 @@ public static object GetRequiredService(Type type) /// /// Gets or sets the timestamp for the lifetime; i.e (to enable consistent execution-related timestamping). /// - /// Defaults the value from , where this has not been registered it will default to . The value will also be passed through . - public DateTime Timestamp { get => _timestamp ??= SystemTime.UtcNow; set => _timestamp = Cleaner.Clean(value); } + /// Defaults to ; where this has not been registered it will default to . The value will also be passed through and will have the configured applied. + /// This value will remain unchanged for the life of the to ensure consistency of the value. + public DateTime Timestamp { get => _timestamp ??= Cleaner.Clean(SystemTime.Get().UtcNow); set => _timestamp = Cleaner.Clean(value); } /// /// Gets the that is intended to be returned to the originating consumer. @@ -206,7 +202,7 @@ public static object GetRequiredService(Type type) /// /// Gets the . /// - /// Where not configured will instantiate a . + /// Where not configured will automatically instantiate a on first access. public IReferenceDataContext ReferenceDataContext => _referenceDataContext ??= (GetService() ?? new ReferenceDataContext()); /// @@ -310,7 +306,7 @@ private static MessageItemCollection CreateWithNoErrorTypeSupport() private static void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null && e.NewItems.OfType().Any(m => m.Type == MessageType.Error)) - throw new InvalidOperationException("An error message can not be added to the ExecutionContext.Messages collection; this is intended for warning and information messages only."); + throw new InvalidOperationException("An error message cannot be added to the ExecutionContext.Messages collection; this is intended for warning and information messages only."); } #region Security diff --git a/src/CoreEx/Json/Compare/JsonElementComparer.cs b/src/CoreEx/Json/Compare/JsonElementComparer.cs index cb94cb50..9dac85ec 100644 --- a/src/CoreEx/Json/Compare/JsonElementComparer.cs +++ b/src/CoreEx/Json/Compare/JsonElementComparer.cs @@ -6,7 +6,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; namespace CoreEx.Json.Compare { @@ -74,17 +73,6 @@ public JsonElementComparerResult Compare(string left, string right, params strin return Compare(lje.Value, rje.Value, pathsToIgnore); } - /// - /// Compare two values for equality. - /// - /// The left . - /// The right . - /// Optional list of paths to exclude from the comparison. Qualified paths, that include indexing, are also supported. - /// The . - /// Note: this immediately converts each into a corresponding to perform the comparison, as such there may be a small performance cost. - public JsonElementComparerResult Compare(JsonNode left, JsonNode right, params string[] pathsToIgnore) - => Compare(System.Text.Json.JsonSerializer.Deserialize(left), System.Text.Json.JsonSerializer.Deserialize(right), pathsToIgnore); - /// /// Compare two values for equality. /// @@ -94,7 +82,7 @@ public JsonElementComparerResult Compare(JsonNode left, JsonNode right, params s /// The . public JsonElementComparerResult Compare(JsonElement left, JsonElement right, params string[] pathsToIgnore) { - var result = new JsonElementComparerResult(left, right, Options.MaxDifferences, Options.AlwaysReplaceAllArrayItems); + var result = new JsonElementComparerResult(left, right, Options.MaxDifferences, Options.ReplaceAllArrayItemsOnMerge); Compare(left, right, new CompareState(result, Options.PathComparer, pathsToIgnore)); return result; } @@ -186,10 +174,7 @@ private void Compare(JsonElement left, JsonElement right, CompareState state) break; case JsonValueKind.Object: - var lprops = left.EnumerateObject().ToList(); - var rprops = right.EnumerateObject().ToList(); - - foreach (var l in lprops) + foreach (var l in left.EnumerateObject()) { state.Compare(l.Name, () => { @@ -208,7 +193,7 @@ private void Compare(JsonElement left, JsonElement right, CompareState state) break; } - foreach (var r in rprops) + foreach (var r in right.EnumerateObject()) { state.Compare(r.Name, () => { @@ -256,7 +241,7 @@ private void Compare(JsonElement left, JsonElement right, CompareState state) } /// - /// Performs the configured TryGetProperty; either exact or semantic. + /// Performs the configured TryGetProperty; using the comparer. /// private bool TryGetProperty(JsonElement json, string propertyName, out JsonElement value) => Options.PropertyNameComparer is null ? json.TryGetProperty(propertyName, out value) : json.TryGetProperty(propertyName, Options.PropertyNameComparer, out value); diff --git a/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs b/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs index 40da89cc..a55c8e49 100644 --- a/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs +++ b/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs @@ -67,7 +67,7 @@ public static JsonElementComparerOptions Default /// /// The formal specification explictly states that an is to be a replacement operation. /// Where set to false and there is an array length difference this will always result in a replace (i.e. all); no means to reliably determine what has been added, deleted, modified, resequenced, etc. - public bool AlwaysReplaceAllArrayItems { get; set; } = true; + public bool ReplaceAllArrayItemsOnMerge { get; set; } = true; /// /// Clones the . @@ -81,7 +81,7 @@ public static JsonElementComparerOptions Default ValueComparison = ValueComparison, NullComparison = NullComparison, JsonSerializer = JsonSerializer, - AlwaysReplaceAllArrayItems = AlwaysReplaceAllArrayItems + ReplaceAllArrayItemsOnMerge = ReplaceAllArrayItemsOnMerge }; } } \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementComparerResult.cs b/src/CoreEx/Json/Compare/JsonElementComparerResult.cs index c7029a9f..c7fd4a1b 100644 --- a/src/CoreEx/Json/Compare/JsonElementComparerResult.cs +++ b/src/CoreEx/Json/Compare/JsonElementComparerResult.cs @@ -22,14 +22,14 @@ public sealed class JsonElementComparerResult /// /// The left . /// The right . - /// The maximum number of differences detect. - /// Indicates whether to always replace all array items where at least one item has changed. - internal JsonElementComparerResult(JsonElement left, JsonElement right, int maxDifferences, bool alwaysReplaceAllArrayItems = true) + /// The maximum number of differences to detect. + /// Indicates whether to always replace all array items where at least one item has changed when performing a corresponding . + internal JsonElementComparerResult(JsonElement left, JsonElement right, int maxDifferences, bool replaceAllArrayItemsOnMerge = true) { Left = left; Right = right; MaxDifferences = maxDifferences; - AlwaysReplaceAllArrayItems = alwaysReplaceAllArrayItems; + ReplaceAllArrayItemsOnMerge = replaceAllArrayItemsOnMerge; } /// @@ -68,10 +68,11 @@ internal JsonElementComparerResult(JsonElement left, JsonElement right, int maxD public bool IsMaxDifferencesFound => DifferenceCount >= MaxDifferences; /// - /// Indicates whether to always replace all array items (where at least one item has changed). + /// Indicates whether to always replace all array items where at least one item has changed when performing a corresponding . /// - /// The formal specification explictly states that an is to be a replacement operation. - public bool AlwaysReplaceAllArrayItems { get; } + /// The formal specification explictly states that an is to be a replacement operation. + /// Where set to false and there is an array length difference this will always result in a replace (i.e. all); no means to reliably determine what has been added, deleted, modified, resequenced, etc. + public bool ReplaceAllArrayItemsOnMerge { get; } /// /// Gets the array. @@ -200,7 +201,7 @@ private PathMatch MergePatch(JsonArray ja, MergePatchState state) PathMatch match; var overall = PathMatch.None; - if (AlwaysReplaceAllArrayItems && state.GetMatch(ja) != PathMatch.None) + if (ReplaceAllArrayItemsOnMerge && state.GetMatch(ja) != PathMatch.None) return PathMatch.Full; for (var i = ja.Count - 1; i >= 0; i--) diff --git a/src/CoreEx/Json/Data/JsonDataReader.cs b/src/CoreEx/Json/Data/JsonDataReader.cs index 7253142f..27547a4a 100644 --- a/src/CoreEx/Json/Data/JsonDataReader.cs +++ b/src/CoreEx/Json/Data/JsonDataReader.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Reflection; using System.Text.Json; +using System.Threading.Tasks; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; @@ -192,6 +193,11 @@ public bool TryDeserialize(string? name, [NotNullWhen(true)] out List? ite (items ??= []).Add(item); _args.IdentifierGenerator?.AssignIdentifierAsync(item); ChangeLog.PrepareCreated(item, _executionContext); + + // Only reset tenant id where not explicitly set. + if (item is ITenantId tenantId && tenantId is null) + Cleaner.ResetTenantId(item, _executionContext); + PrepareReferenceData(typeof(T), item, jd, items.Count - 1); } } @@ -225,6 +231,11 @@ public bool TryDeserialize(Type type, string? name, [NotNullWhen(true)] out List (items ??= []).Add(item); _args.IdentifierGenerator?.AssignIdentifierAsync(item); ChangeLog.PrepareCreated(item, _executionContext); + + // Only reset tenant id where not explicitly set. + if (item is ITenantId tenantId && tenantId is null) + Cleaner.ResetTenantId(item, _executionContext); + PrepareReferenceData(type, item, jd, items.Count - 1); } } @@ -234,6 +245,34 @@ public bool TryDeserialize(Type type, string? name, [NotNullWhen(true)] out List return items != null; } + /// + /// Enumerate the named element and invoke the action per item. + /// + /// The element name where the array of items to invoke are housed. + /// The resulting item function. + /// true indicates that one or more items were invoked; otherwise, false for none found. + public async Task EnumerateJsonAsync(string name, Func json) + { + name.ThrowIfNullOrEmpty(nameof(name)); + json.ThrowIfNull(nameof(json)); + bool any = false; + + // Find the named object and action. + foreach (var ji in _json.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) + { + foreach (var jo in ji.EnumerateObject().Where(x => x.Name == name && x.Value.ValueKind == JsonValueKind.Array)) + { + foreach (var jd in jo.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) + { + await json(jd).ConfigureAwait(false); + any = true; + } + } + } + + return any; + } + /// /// Deserialize the JSON replacing any dynamic parameters. /// diff --git a/src/CoreEx/Json/Data/JsonDataReaderArgs.cs b/src/CoreEx/Json/Data/JsonDataReaderArgs.cs index c7ec5276..f49a524c 100644 --- a/src/CoreEx/Json/Data/JsonDataReaderArgs.cs +++ b/src/CoreEx/Json/Data/JsonDataReaderArgs.cs @@ -27,13 +27,13 @@ public class JsonDataReaderArgs /// /// The . /// The user name. Defaults to '\'. - /// The current . Defaults to . + /// The current . Defaults to . /// The defaults to a new instance with a added to the default /// to support numbers specified as strings which YAML is more permissive with. public JsonDataReaderArgs(IJsonSerializer? jsonSerializer = null, string? username = null, DateTime? dateTimeNow = null) { Parameters.Add(UserNameKey, username ?? (Environment.UserDomainName == null ? Environment.UserName : $"{Environment.UserDomainName}\\{Environment.UserName}")); - Parameters.Add(DateTimeNowKey, dateTimeNow ?? DateTime.UtcNow); + Parameters.Add(DateTimeNowKey, dateTimeNow ?? SystemTime.Timestamp); RefDataColumnDefaults.Add(nameof(IReferenceData.IsActive), _ => true); RefDataColumnDefaults.Add(nameof(IReferenceData.SortOrder), i => i + 1); diff --git a/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs b/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs new file mode 100644 index 00000000..3a750d41 --- /dev/null +++ b/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs @@ -0,0 +1,376 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Abstractions.Reflection; +using CoreEx.Entities; +using CoreEx.Results; +using System; +using System.Collections; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace CoreEx.Json.Merge.Extended +{ + /// + /// Provides a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing object value (instance) as per + /// using .NET Reflection (see ). + /// + /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. + /// Additional logic has been added to the merge patch enabled by and . Note: these capabilities are + /// unique to CoreEx and not part of the formal specification . + public class JsonMergePatchEx : IJsonMergePatch + { + private const string EKCollectionName = "EKColl"; + private readonly TypeReflectorArgs _trArgs; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. + public JsonMergePatchEx(JsonMergePatchExOptions? options = null) + { + Options = options ?? new JsonMergePatchExOptions(); + + _trArgs = new TypeReflectorArgs(Options.JsonSerializer) + { + AutoPopulateProperties = true, + NameComparer = Options.PropertyNameComparer, + TypeBuilder = tr => + { + // Determine if type implements IEntityKeyCollection. + if (tr.Type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICompositeKeyCollection<>))) + tr.Data.Add(EKCollectionName, true); + }, + PropertyBuilder = pr => pr.PropertyExpression.IsJsonSerializable // Only interested in properties that are considered serializable. + }; + } + + /// + /// Gets the . + /// + public JsonMergePatchExOptions Options { get; } + + /// + public bool Merge(BinaryData json, ref T? value) + { + // Parse the JSON. + var j = ParseJson(json.ThrowIfNull(nameof(json))); + + // Perform the root merge patch. + return MergeRoot(j.JsonElement, j.Value, ref value); + } + + /// + public async Task<(bool HasChanges, T? Value)> MergeAsync(BinaryData json, Func> getValue, CancellationToken cancellationToken = default) + { + getValue.ThrowIfNull(nameof(getValue)); + + // Parse the JSON. + var j = ParseJson(json.ThrowIfNull(nameof(json))); + + // Get the value. + T? value = await getValue(j.Value, cancellationToken).ConfigureAwait(false); + if (value == null) + return (false, default!); + + // Perform the root merge patch. + return (MergeRoot(j.JsonElement, j.Value, ref value), value); + } + + /// + public async Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default) + { + getValue.ThrowIfNull(nameof(getValue)); + + // Parse the JSON. + var j = ParseJson(json.ThrowIfNull(nameof(json))); + + // Get the value. + var result = await getValue(j.Value!, cancellationToken).ConfigureAwait(false); + return result.ThenAs(value => + { + if (value == null) + return Result<(bool, T?)>.Ok((false, default)); + + // Perform the root merge patch. + return Result.Ok((MergeRoot(j.JsonElement, j.Value, ref value), value)); + }); + } + + /// + /// Performs the merge patch. + /// + private bool MergeRoot(JsonElement json, T? srce, ref T? dest) + { + bool hasChanged = false; + + var tr = TypeReflector.GetReflector(_trArgs); + + switch (json.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.String: + case JsonValueKind.Number: + hasChanged = !tr.Compare(srce, dest); + dest = srce; + break; + + case JsonValueKind.Object: + if (tr.TypeCode == TypeReflectorTypeCode.IDictionary) + { + // Where merging into a dictionary this can be a replace or per item merge. + if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) + { + hasChanged = !tr.Compare(dest, srce); + if (hasChanged) + dest = srce; + } + else + dest = (T)MergeDictionary(tr, "$", json, (IDictionary)srce!, (IDictionary)dest!, ref hasChanged)!; + } + else + { + if (srce == null || dest == null) + { + hasChanged = !tr.Compare(dest, srce); + if (hasChanged) + dest = srce; + } + else + MergeObject(tr, "$", json, srce, dest!, ref hasChanged); + } + + break; + + case JsonValueKind.Array: + // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. + if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) + dest = (T)MergeKeyedCollection(tr, "$", json, (ICompositeKeyCollection)srce!, (ICompositeKeyCollection)dest!, ref hasChanged); + else + { + hasChanged = !tr.Compare(dest, srce); + if (hasChanged) + dest = srce; + } + + break; + + default: + throw new InvalidOperationException($"A JSON element of '{json.ValueKind}' is invalid where merging the root."); + } + + return hasChanged; + } + + /// + /// Parses the JSON. + /// + private (JsonElement JsonElement, T? Value) ParseJson(BinaryData json) + { + try + { + // Deserialize into a temporary value which will be used as the merge source. + var value = Options.JsonSerializer.Deserialize(json); + + // Parse the JSON into a JsonElement which will be used to navigate the merge. + var jr = new Utf8JsonReader(json); + var je = JsonElement.ParseValue(ref jr); + return (je, value); + } + catch (JsonException jex) + { + throw new JsonMergePatchException(jex.Message, jex); + } + } + + /// + /// Merge the object. + /// + private void MergeObject(ITypeReflector tr, string root, JsonElement json, object? srce, object dest, ref bool hasChanged) + { + foreach (var jp in json.EnumerateObject()) + { + // Find the named property; skip when not found. + var pr = tr.GetJsonProperty(jp.Name); + if (pr == null) + continue; + + MergeProperty(pr, $"{root}.{jp.Name}", jp, srce, dest, ref hasChanged); + } + } + + /// + /// Merge the property. + /// + private void MergeProperty(IPropertyReflector pr, string path, JsonProperty json, object? srce, object dest, ref bool hasChanged) + { + // Update according to the value kind. + switch (json.Value.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.String: + case JsonValueKind.Number: + // Update the value directly from the source. + SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); + break; + + case JsonValueKind.Object: + // Where existing is null, copy source as-is; otherwise, merge object property-by-property. + var current = pr.PropertyExpression.GetValue(dest); + if (current == null) + SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); + else + { + if (pr.TypeCode == TypeReflectorTypeCode.IDictionary) + { + // Where the merging into a dictionary this can be a replace or per item merge. + if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) + SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); + else + { + var dict = MergeDictionary(pr.GetTypeReflector()!, path, json.Value, (IDictionary)pr.PropertyExpression.GetValue(srce)!, (IDictionary)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); + SetPropertyValue(pr, dict, dest, ref hasChanged); + } + } + else + MergeObject(pr.GetTypeReflector()!, path, json.Value, pr.PropertyExpression.GetValue(srce), current, ref hasChanged); + } + + break; + + case JsonValueKind.Array: + // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. + var tr = pr.GetTypeReflector()!; + if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) + { + var coll = MergeKeyedCollection(tr, path, json.Value, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(srce)!, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); + SetPropertyValue(pr, coll, dest, ref hasChanged); + } + else + SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); + + break; + } + } + + /// + /// Sets the property value. + /// + private static void SetPropertyValue(IPropertyReflector pr, object? srce, object dest, ref bool hasChanged) + { + var curr = pr.PropertyExpression.GetValue(dest); + if (pr.Compare(curr, srce)) + return; + + pr.PropertyExpression.SetValue(dest, srce); + hasChanged = true; + } + + /// + /// Merge an . + /// + private IDictionary? MergeDictionary(ITypeReflector tr, string root, JsonElement json, IDictionary srce, IDictionary? dest, ref bool hasChanged) + { + var dict = dest; + + // Iterate through the properties, each is an item that will be added to the new dictionary. + foreach (var jp in json.EnumerateObject()) + { + var path = $"{root}.{jp.Name}"; + var srceitem = srce[jp.Name]; + + if (srceitem == null) + { + // A null value results in a remove operation. + if (dict != null && dict.Contains(jp.Name)) + { + dict.Remove(jp.Name); + hasChanged = true; + } + + continue; + } + + // Create new destination dictionary where it does not exist already. + dict ??= (IDictionary)tr.CreateInstance(); + + // Find the existing and merge; otherwise, add as-is. + if (dict.Contains(jp.Name)) + { + var destitem = dict[jp.Name]!; + switch (tr.ItemTypeCode) + { + case TypeReflectorTypeCode.Simple: + if (!hasChanged && !tr.GetItemTypeReflector()!.Compare(dict[jp.Name], destitem)) + hasChanged = true; + + dict[jp.Name] = srceitem; + continue; + + case TypeReflectorTypeCode.Complex: + MergeObject(tr.GetItemTypeReflector()!, path, jp.Value, srceitem, destitem, ref hasChanged); + dict[jp.Name] = destitem; + continue; + + default: + throw new NotSupportedException("A merge where a dictionary value is an array or other collection type is not supported."); + } + } + else + { + // Represents an add. + hasChanged = true; + dict[jp.Name] = srceitem; + } + } + + return dict; + } + + /// + /// Merge a . + /// + private ICompositeKeyCollection MergeKeyedCollection(ITypeReflector tr, string root, JsonElement json, ICompositeKeyCollection srce, ICompositeKeyCollection? dest, ref bool hasChanged) + { + if (srce!.IsAnyDuplicates()) + throw new JsonMergePatchException($"The JSON array must not contain items with duplicate '{nameof(IEntityKey)}' keys. Path: {root}"); + + if (dest != null && dest.IsAnyDuplicates()) + throw new JsonMergePatchException($"The JSON array destination collection must not contain items with duplicate '{nameof(IEntityKey)}' keys prior to merge. Path: {root}"); + + // Create new destination collection; add each to maintain sent order as this may be important to the consuming application. + var coll = (ICompositeKeyCollection)tr.CreateInstance(); + + // Iterate through the items and add to the new collection. + var i = 0; + ITypeReflector ier = tr.GetItemTypeReflector()!; + + foreach (var ji in json.EnumerateArray()) + { + var path = $"{root}[{i}]"; + if (ji.ValueKind != JsonValueKind.Object && ji.ValueKind != JsonValueKind.Null) + throw new JsonMergePatchException($"The JSON array item must be an Object where the destination collection supports '{nameof(IEntityKey)}' keys. Path: {path}"); + + var srceitem = (IEntityKey)srce[i++]!; + + // Find the existing and merge; otherwise, add as-is. + var destitem = srceitem == null ? null : dest?.GetByKey(srceitem.EntityKey); + if (destitem != null) + { + MergeObject(ier, path, ji, srceitem, destitem, ref hasChanged); + coll.Add(destitem); + } + else + coll.Add(srceitem!); + } + + return coll; + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs b/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs new file mode 100644 index 00000000..d45e3b9d --- /dev/null +++ b/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using System; + +namespace CoreEx.Json.Merge.Extended +{ + /// + /// The options. + /// + /// The . Defaults to . + public class JsonMergePatchExOptions(IJsonSerializer? jsonSerializer = null) + { + /// + /// Gets the . + /// + public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? Json.JsonSerializer.Default; + + /// + /// Gets or sets the for matching the JSON name (defaults to ). + /// + public StringComparer PropertyNameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; + + /// + /// Gets or sets the . Defaults to . + /// + public DictionaryMergeApproach DictionaryMergeApproach { get; set; } = DictionaryMergeApproach.Merge; + + /// + /// Gets or sets the . Defaults to . + /// + public EntityKeyCollectionMergeApproach EntityKeyCollectionMergeApproach { get; set; } = EntityKeyCollectionMergeApproach.Replace; + } +} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/IJsonMergePatch.cs b/src/CoreEx/Json/Merge/IJsonMergePatch.cs index fd93b559..d8596e18 100644 --- a/src/CoreEx/Json/Merge/IJsonMergePatch.cs +++ b/src/CoreEx/Json/Merge/IJsonMergePatch.cs @@ -13,7 +13,7 @@ namespace CoreEx.Json.Merge public interface IJsonMergePatch { /// - /// Merges the JSON content into the . + /// Merges the content into the . /// /// The value . /// The JSON to merge. @@ -22,7 +22,7 @@ public interface IJsonMergePatch bool Merge(BinaryData json, ref T? value); /// - /// Merges the JSON content into the value returned by the function. + /// Merges the content into the value returned by the function. /// /// The value . /// The JSON to merge. @@ -33,7 +33,7 @@ public interface IJsonMergePatch Task<(bool HasChanges, T? Value)> MergeAsync(BinaryData json, Func> getValue, CancellationToken cancellationToken = default); /// - /// Merges the JSON content into the value returned by the function (with a ). + /// Merges the content into the value returned by the function (with a ). /// /// The value . /// The JSON to merge. @@ -41,6 +41,6 @@ public interface IJsonMergePatch /// The . /// true indicates that changes were made to the entity value as a result of the merge; otherwise, false for no changes. The merged value is also returned. /// Provides the opportunity to validate the JSON before getting the value where this execution order is important; i.e. get operation is expensive (latency). - Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default); + Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/JsonMergePatch.cs b/src/CoreEx/Json/Merge/JsonMergePatch.cs index f0ddf3d4..3acac61f 100644 --- a/src/CoreEx/Json/Merge/JsonMergePatch.cs +++ b/src/CoreEx/Json/Merge/JsonMergePatch.cs @@ -1,11 +1,10 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; +using CoreEx.Json.Compare; using CoreEx.Results; +using CoreEx.Text.Json; using System; -using System.Collections; -using System.Linq; +using System.Buffers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -13,52 +12,30 @@ namespace CoreEx.Json.Merge { /// - /// Provides a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing object value as per . + /// Provides a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing JSON document resulting in a new merged JSON document as per . /// - /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. - /// Additional logic has been added to the merge patch enabled by and . Note: these capabilities are - /// unique to CoreEx and not part of the formal specification . - public class JsonMergePatch : IJsonMergePatch + /// The . + public class JsonMergePatch(JsonMergePatchOptions? options = null) : IJsonMergePatch { - private const string EKCollectionName = "EKColl"; - private readonly TypeReflectorArgs _trArgs; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. - public JsonMergePatch(JsonMergePatchOptions? options = null) - { - Options = options ?? new JsonMergePatchOptions(); - - _trArgs = new TypeReflectorArgs(Options.JsonSerializer) - { - AutoPopulateProperties = true, - NameComparer = Options.NameComparer, - TypeBuilder = tr => - { - // Determine if type implements IEntityKeyCollection. - if (tr.Type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICompositeKeyCollection<>))) - tr.Data.Add(EKCollectionName, true); - }, - PropertyBuilder = pr => pr.PropertyExpression.IsJsonSerializable // Only interested in properties that are considered serializable. - }; - } - /// /// Gets the . /// - public JsonMergePatchOptions Options { get; set; } + public JsonMergePatchOptions Options { get; } = options ?? new JsonMergePatchOptions(); /// public bool Merge(BinaryData json, ref T? value) { // Parse the JSON. var j = ParseJson(json.ThrowIfNull(nameof(json))); + var t = SerializeToJsonElement(value); // Perform the root merge patch. - return MergeRoot(j.JsonElement, j.Value, ref value); + if (!TryMerge(j.JsonElement, t, out var merged)) + return false; + + // Deserialize the merged JSON. + value = DeserializeFromJsonElement(merged); + return true; } /// @@ -70,16 +47,21 @@ public bool Merge(BinaryData json, ref T? value) var j = ParseJson(json.ThrowIfNull(nameof(json))); // Get the value. - T? value = await getValue(j.Value, cancellationToken).ConfigureAwait(false); + var value = await getValue(j.Value, cancellationToken).ConfigureAwait(false); if (value == null) return (false, default!); - // Perform the root merge patch. - return (MergeRoot(j.JsonElement, j.Value, ref value), value); + // Perform the merge patch. + var t = SerializeToJsonElement(value); + if (!TryMerge(j.JsonElement, t, out var merged)) + return (false, value); + + // Deserialize the merged JSON. + return (true, DeserializeFromJsonElement(merged)); } /// - public async Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default) + public async Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default) { getValue.ThrowIfNull(nameof(getValue)); @@ -91,78 +73,44 @@ public bool Merge(BinaryData json, ref T? value) return result.ThenAs(value => { if (value == null) - return Result<(bool, T)>.Ok((false, default!)); + return (false, default); + + // Perform the merge patch. + var t = SerializeToJsonElement(value); + if (!TryMerge(true, j.JsonElement, t, out var merged)) + return (false, value); - // Perform the root merge patch. - return Result.Ok((MergeRoot(j.JsonElement, j.Value, ref value!), value)); + // Deserialize the merged JSON. + return (true, DeserializeFromJsonElement(merged)); }); } /// - /// Performs the merge patch. + /// Serialize the to a . /// - private bool MergeRoot(JsonElement json, T? srce, ref T? dest) + private JsonElement SerializeToJsonElement(T value) { - bool hasChanged = false; - - var tr = TypeReflector.GetReflector(_trArgs); - - switch (json.ValueKind) - { - case JsonValueKind.Null: - case JsonValueKind.True: - case JsonValueKind.False: - case JsonValueKind.String: - case JsonValueKind.Number: - hasChanged = !tr.Compare(srce, dest); - dest = srce; - break; - - case JsonValueKind.Object: - if (tr.TypeCode == TypeReflectorTypeCode.IDictionary) - { - // Where merging into a dictionary this can be a replace or per item merge. - if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - else - dest = (T)MergeDictionary(tr, "$", json, (IDictionary)srce!, (IDictionary)dest!, ref hasChanged)!; - } - else - { - if (srce == null || dest == null) - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - else - MergeObject(tr, "$", json, srce, dest!, ref hasChanged); - } - - break; - - case JsonValueKind.Array: - // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. - if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) - dest = (T)MergeKeyedCollection(tr, "$", json, (ICompositeKeyCollection)srce!, (ICompositeKeyCollection)dest!, ref hasChanged); - else - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - - break; + // Fast path where using System.Text.Json. + if (Options.JsonSerializer is CoreEx.Text.Json.JsonSerializer js) + return System.Text.Json.JsonSerializer.SerializeToElement(value, js.Options); + + // Otherwise, serialize and then parse as two separate operations (slower path). + var bd = Options.JsonSerializer.SerializeToBinaryData(value); + var jr = new Utf8JsonReader(bd); + return JsonElement.ParseValue(ref jr).Clone(); + } - default: - throw new InvalidOperationException($"A JSON element of '{json.ValueKind}' is invalid where merging the root."); - } + /// + /// Deserialize the to a . + /// + private T? DeserializeFromJsonElement(JsonElement json) + { + // Fast path where using System.Text.Json. + if (Options.JsonSerializer is CoreEx.Text.Json.JsonSerializer js) + return json.Deserialize(js.Options); - return hasChanged; + // Otherwise, deserialize using the specified serializer. + return Options.JsonSerializer.Deserialize(json.GetRawText()); } /// @@ -177,7 +125,7 @@ private bool MergeRoot(JsonElement json, T? srce, ref T? dest) // Parse the JSON into a JsonElement which will be used to navigate the merge. var jr = new Utf8JsonReader(json); - var je = JsonElement.ParseValue(ref jr); + var je = JsonElement.ParseValue(ref jr).Clone(); return (je, value); } catch (JsonException jex) @@ -187,189 +135,147 @@ private bool MergeRoot(JsonElement json, T? srce, ref T? dest) } /// - /// Merge the object. + /// Merges the with (into) the . /// - private void MergeObject(ITypeReflector tr, string root, JsonElement json, object? srce, object dest, ref bool hasChanged) + /// The JSON to merge. + /// The JSON target to merge with (into). + /// The resulting merged . + public JsonElement Merge(JsonElement json, JsonElement target) { - foreach (var jp in json.EnumerateObject()) - { - // Find the named property; skip when not found. - var pr = tr.GetJsonProperty(jp.Name); - if (pr == null) - continue; - - MergeProperty(pr, $"{root}.{jp.Name}", jp, srce, dest, ref hasChanged); - } + TryMerge(false, json, target, out var merged); + return merged; } /// - /// Merge the property. + /// Merges the with (into) the resulting in the where changes were made. /// - private void MergeProperty(IPropertyReflector pr, string path, JsonProperty json, object? srce, object dest, ref bool hasChanged) + /// The JSON to merge. + /// The JSON to merge with (into). + /// The resulting JSON where changes were made. + /// true indicates that changes were made as a result of the merge (see resulting ); otherwise, false for no changes. + public bool TryMerge(JsonElement json, JsonElement target, out JsonElement merged) { - // Update according to the value kind. - switch (json.Value.ValueKind) + if (TryMerge(true, json, target, out var m)) { - case JsonValueKind.Null: - case JsonValueKind.True: - case JsonValueKind.False: - case JsonValueKind.String: - case JsonValueKind.Number: - // Update the value directly from the source. - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - break; - - case JsonValueKind.Object: - // Where existing is null, copy source as-is; otherwise, merge object property-by-property. - var current = pr.PropertyExpression.GetValue(dest); - if (current == null) - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - else - { - if (pr.TypeCode == TypeReflectorTypeCode.IDictionary) - { - // Where the merging into a dictionary this can be a replace or per item merge. - if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - else - { - var dict = MergeDictionary(pr.GetTypeReflector()!, path, json.Value, (IDictionary)pr.PropertyExpression.GetValue(srce)!, (IDictionary)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); - SetPropertyValue(pr, dict, dest, ref hasChanged); - } - } - else - MergeObject(pr.GetTypeReflector()!, path, json.Value, pr.PropertyExpression.GetValue(srce), current, ref hasChanged); - } - - break; - - case JsonValueKind.Array: - // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. - var tr = pr.GetTypeReflector()!; - if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) - { - var coll = MergeKeyedCollection(tr, path, json.Value, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(srce)!, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); - SetPropertyValue(pr, coll, dest, ref hasChanged); - } - else - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - - break; + merged = m; + return true; } + + merged = target; + return false; } /// - /// Sets the property value. + /// Orchestrates the 'actual' merge processing. The `checkForChanges` is used to determine whether to check for changes after the completed merge only where necessary; therefore, the boolean result is not always guaranteed to be accurate :-) /// - private static void SetPropertyValue(IPropertyReflector pr, object? srce, object dest, ref bool hasChanged) + private bool TryMerge(bool checkForChanges, JsonElement json, JsonElement target, out JsonElement merged) { - var curr = pr.PropertyExpression.GetValue(dest); - if (pr.Compare(curr, srce)) - return; + // Create writer for the merged output. + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer); + + var changed = TryMerge(json, target, writer, false); + + writer.Flush(); + + // Read the merged output and parse. + var reader = new Utf8JsonReader(buffer.WrittenSpan); + merged = JsonElement.ParseValue(ref reader).Clone(); + + // Where check for changes is enabled then compare the target and merged JSON if not previously identified. + if (checkForChanges && !changed) + { + var comparer = new JsonElementComparer(new JsonElementComparerOptions { JsonSerializer = Options.JsonSerializer, PropertyNameComparer = Options.PropertyNameComparer, MaxDifferences = 1 }); + changed = comparer.Compare(target, merged).HasDifferences; + } - pr.PropertyExpression.SetValue(dest, srce); - hasChanged = true; + return changed; } /// - /// Merge an . + /// Merge the JSON element (record 'change' where no additional cost to do so). /// - private IDictionary? MergeDictionary(ITypeReflector tr, string root, JsonElement json, IDictionary srce, IDictionary? dest, ref bool hasChanged) + private bool TryMerge(JsonElement json, JsonElement target, Utf8JsonWriter writer, bool changed) { - var dict = dest; - - // Iterate through the properties, each is an item that will be added to the new dictionary. - foreach (var jp in json.EnumerateObject()) + // Where the kinds are different then simply accept the merge and acknowledge as changed. + if (json.ValueKind != target.ValueKind) { - var path = $"{root}.{jp.Name}"; - var srceitem = srce[jp.Name]; + json.WriteTo(writer); + return true; + } - if (srceitem == null) - { - // A null value results in a remove operation. - if (dict != null && dict.Contains(jp.Name)) - { - dict.Remove(jp.Name); - hasChanged = true; - } + // Where the kinds are the same then process accordingly. + switch (json.ValueKind) + { + case JsonValueKind.Object: + // An object is a property-by-property merge. + return TryObjectMerge(json, target, writer, changed); - continue; - } + case JsonValueKind.Array: + // An array is always a replacement. + json.WriteTo(writer); + if (json.GetArrayLength() != target.GetArrayLength()) + changed = true; - // Create new destination dictionary where it does not exist already. - dict ??= (IDictionary)tr.CreateInstance(); + break; - // Find the existing and merge; otherwise, add as-is. - if (dict.Contains(jp.Name)) - { - var destitem = dict[jp.Name]!; - switch (tr.ItemTypeCode) - { - case TypeReflectorTypeCode.Simple: - if (!hasChanged && !tr.GetItemTypeReflector()!.Compare(dict[jp.Name], destitem)) - hasChanged = true; - - dict[jp.Name] = srceitem; - continue; - - case TypeReflectorTypeCode.Complex: - MergeObject(tr.GetItemTypeReflector()!, path, jp.Value, srceitem, destitem, ref hasChanged); - dict[jp.Name] = destitem; - continue; - - default: - throw new NotSupportedException("A merge where a dictionary value is an array or other collection type is not supported."); - } - } - else - { - // Represents an add. - hasChanged = true; - dict[jp.Name] = srceitem; - } + default: + // Accept merge as-is. + json.WriteTo(writer); + break; } - return dict; + return changed; } /// - /// Merge a . + /// Merge the JSON object and properties (record 'change' where no cost to do so). /// - private ICompositeKeyCollection MergeKeyedCollection(ITypeReflector tr, string root, JsonElement json, ICompositeKeyCollection srce, ICompositeKeyCollection? dest, ref bool hasChanged) + private bool TryObjectMerge(JsonElement json, JsonElement target, Utf8JsonWriter writer, bool changed) { - if (srce!.IsAnyDuplicates()) - throw new JsonMergePatchException($"The JSON array must not contain items with duplicate '{nameof(IEntityKey)}' keys. Path: {root}"); - - if (dest != null && dest.IsAnyDuplicates()) - throw new JsonMergePatchException($"The JSON array destination collection must not contain items with duplicate '{nameof(IEntityKey)}' keys prior to merge. Path: {root}"); - - // Create new destination collection; add each to maintain sent order as this may be important to the consuming application. - var coll = (ICompositeKeyCollection)tr.CreateInstance(); + writer.WriteStartObject(); - // Iterate through the items and add to the new collection. - var i = 0; - ITypeReflector ier = tr.GetItemTypeReflector()!; - - foreach (var ji in json.EnumerateArray()) + // Apply merge add/override. + foreach (var j in json.EnumerateObject()) { - var path = $"{root}[{i}]"; - if (ji.ValueKind != JsonValueKind.Object && ji.ValueKind != JsonValueKind.Null) - throw new JsonMergePatchException($"The JSON array item must be an Object where the destination collection supports keys. Path: {path}"); + // Where the property is new then add. + if (!TryGetProperty(target, j.Name, out var t)) + { + if (j.Value.ValueKind != JsonValueKind.Null) + j.WriteTo(writer); - var srceitem = (IEntityKey)srce[i++]!; + changed = true; + continue; + } - // Find the existing and merge; otherwise, add as-is. - var destitem = srceitem == null ? null : dest?.GetByKey(srceitem.EntityKey); - if (destitem != null) + // Null is a remove; otherwise, override. + if (j.Value.ValueKind != JsonValueKind.Null) { - MergeObject(ier, path, ji, srceitem, destitem, ref hasChanged); - coll.Add(destitem); + writer.WritePropertyName(j.Name); + changed = TryMerge(j.Value, t, writer, changed); } else - coll.Add(srceitem!); + changed = true; + } + + // Add existing target properties not being merged. + foreach (var t in target.EnumerateObject()) + { + // Where found then consider as handled above! + if (TryGetProperty(json, t.Name, out _)) + continue; + + t.WriteTo(writer); } - return coll; + writer.WriteEndObject(); + return changed; } + + /// + /// Performs the TryGetProperty using the configured comparer. + /// + private bool TryGetProperty(JsonElement json, string propertyName, out JsonElement value) + => Options.PropertyNameComparer is null ? json.TryGetProperty(propertyName, out value) : json.TryGetProperty(propertyName, Options.PropertyNameComparer, out value); } } \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs b/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs index 5c550f28..ccca9a4e 100644 --- a/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs +++ b/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs @@ -13,21 +13,11 @@ public class JsonMergePatchOptions(IJsonSerializer? jsonSerializer = null) /// /// Gets the . /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? Json.JsonSerializer.Default; + public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? ExecutionContext.GetService() ?? Json.JsonSerializer.Default; /// /// Gets or sets the for matching the JSON name (defaults to ). /// - public StringComparer NameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; - - /// - /// Gets or sets the . Defaults to . - /// - public DictionaryMergeApproach DictionaryMergeApproach { get; set; } = DictionaryMergeApproach.Merge; - - /// - /// Gets or sets the . Defaults to . - /// - public EntityKeyCollectionMergeApproach EntityKeyCollectionMergeApproach { get; set; } = EntityKeyCollectionMergeApproach.Replace; + public StringComparer PropertyNameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; } } \ No newline at end of file diff --git a/src/CoreEx/NotFoundException.cs b/src/CoreEx/NotFoundException.cs index 14156a85..ffa45c3f 100644 --- a/src/CoreEx/NotFoundException.cs +++ b/src/CoreEx/NotFoundException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class NotFoundException : Exception, IExtendedException { private const string _message = "Requested data was not found."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs b/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs index 123f6124..f1e13951 100644 --- a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs +++ b/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs @@ -60,7 +60,7 @@ public ReferenceDataBaseEx() /// /// Note to classes that override: the base should be called as it verifies , and that the and are not outside of the - /// where configured (otherwise ). This is accessed via + /// where configured (otherwise ). This is accessed via /// where . The will always return false when not . public virtual bool IsActive { @@ -71,7 +71,7 @@ public virtual bool IsActive if (StartDate != null || EndDate != null) { - var date = ExecutionContext.HasCurrent ? ExecutionContext.Current.ReferenceDataContext[GetType()] : Cleaner.Clean(ExecutionContext.SystemTime.UtcNow, DateTimeTransform.DateOnly); + var date = Cleaner.Clean(ExecutionContext.HasCurrent ? ExecutionContext.Current.ReferenceDataContext[GetType()] : SystemTime.Timestamp, DateTimeTransform.DateOnly); if (StartDate != null && date < StartDate) return false; diff --git a/src/CoreEx/RefData/ReferenceDataContext.cs b/src/CoreEx/RefData/ReferenceDataContext.cs index 75f95ec3..49c88f14 100644 --- a/src/CoreEx/RefData/ReferenceDataContext.cs +++ b/src/CoreEx/RefData/ReferenceDataContext.cs @@ -18,10 +18,10 @@ public class ReferenceDataContext : IReferenceDataContext /// /// Gets or sets the and contextual validation date. /// - /// Defaults to . + /// Defaults to . public DateTime? Date { - get => _date ??= Cleaner.Clean(ExecutionContext.SystemTime.UtcNow, DateTimeTransform.DateOnly); + get => _date ??= Cleaner.Clean(SystemTime.Timestamp, DateTimeTransform.DateOnly); set => _date = Cleaner.Clean(value, DateTimeTransform.DateOnly); } diff --git a/src/CoreEx/SystemTime.cs b/src/CoreEx/SystemTime.cs index 21b8dc31..c2a26b3f 100644 --- a/src/CoreEx/SystemTime.cs +++ b/src/CoreEx/SystemTime.cs @@ -31,6 +31,12 @@ public class SystemTime : ISystemTime /// The using the where configured; otherwise, a new instance of . public static ISystemTime Get() => ExecutionContext.GetService() ?? new SystemTime(); + /// + /// Gets the timestamp for the lifetime; i.e (to enable consistent execution-related timestamping). + /// + /// Where the then the will be used; otherwise, will use (passed through ). + public static DateTime Timestamp => ExecutionContext.HasCurrent ? ExecutionContext.Current.Timestamp : Cleaner.Clean(Get().UtcNow); + /// public DateTime UtcNow => _time ?? DateTime.UtcNow; } diff --git a/src/CoreEx/TransientException.cs b/src/CoreEx/TransientException.cs index be998ebc..478d4c2b 100644 --- a/src/CoreEx/TransientException.cs +++ b/src/CoreEx/TransientException.cs @@ -14,11 +14,12 @@ namespace CoreEx public class TransientException : Exception, IExtendedException { private const string _message = "A transient error has occurred; please try again."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/src/CoreEx/ValidationException.cs b/src/CoreEx/ValidationException.cs index a3ad31f8..47d4c47a 100644 --- a/src/CoreEx/ValidationException.cs +++ b/src/CoreEx/ValidationException.cs @@ -18,11 +18,12 @@ namespace CoreEx public class ValidationException : Exception, IExtendedException { private const string _message = "A data validation error occurred."; + private static bool? _shouldExceptionBeLogged; /// /// Get or sets the value. /// - public static bool ShouldExceptionBeLogged { get; set; } + public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } /// /// Initializes a new instance of the class. diff --git a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj index af4619b5..c30f13a8 100644 --- a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj +++ b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable false @@ -18,23 +18,23 @@ - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Cosmos.Test/CosmosDb.cs b/tests/CoreEx.Cosmos.Test/CosmosDb.cs index 333cc97a..54aef6fb 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDb.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDb.cs @@ -2,28 +2,25 @@ namespace CoreEx.Cosmos.Test { - public class CosmosDb : CoreEx.Cosmos.CosmosDb + public class CosmosDb : Cosmos.CosmosDb { - private readonly bool _partitioning; - public CosmosDb(bool auth, bool partitioning = false) : base(TestSetUp.CosmosDatabase!, TestSetUp.Mapper!) { - if (auth) - { - UseAuthorizeFilter("Persons1", q => ((IQueryable)q).Where(x => x.Locked == false)); - UseAuthorizeFilter("Persons2", q => ((IQueryable)q).Where(x => x.Locked == false)); - UseAuthorizeFilter("Persons3", q => ((IQueryable>)q).Where(x => x.Value.Locked == false)); - } - - _partitioning = partitioning; + // Apply the container configurations. + Container("Persons1").UsePartitionKey(partitioning ? v => new PartitionKey(v.Filter) : null).UseAuthorizeFilter(q => auth ? q.Where(x => x.Locked == false) : q); + Container("Persons2").UsePartitionKey(partitioning ? v => new PartitionKey(v.Filter) : null!).UseAuthorizeFilter(q => auth ? q.Where(x => x.Locked == false) : q); + this["Persons3"].UseValuePartitionKey(partitioning ? v => new PartitionKey(v.Value.Filter) : null!).UseValueAuthorizeFilter(q => auth ? q.Where(x => x.Value.Locked == false) : q); + Container("Persons3").UseValuePartitionKey(partitioning ? v => new PartitionKey(v.Value.Filter) : null!); } - public CosmosDbContainer Persons1 => Container("Persons1").UsePartitionKey(_partitioning ? v => new PartitionKey(v.Filter) : null!); + public CosmosDbContainer Persons1 => Container("Persons1").AsTyped(); + + public CosmosDbContainer Persons2 => Container("Persons2"); - public CosmosDbContainer Persons2 => Container("Persons2").UsePartitionKey(_partitioning ? v => new PartitionKey(v.Filter) : null!); + public CosmosDbValueContainer Persons3 => this["Persons3"].AsValueTyped(); - public CosmosDbValueContainer Persons3 => ValueContainer("Persons3").UsePartitionKey(_partitioning ? v => new PartitionKey(v.Value.Filter) : null!); + public CosmosDbValueContainer Persons3X => ValueContainer("Persons3"); - public CosmosDbValueContainer Persons3X => ValueContainer("Persons3").UsePartitionKey(_partitioning ? v => new PartitionKey(v.Value.Filter) : null!); + public CosmosDbContainer PersonsX => Container("PersonsX"); } } \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs index 479e1357..7cd6f17c 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs @@ -4,7 +4,9 @@ [Category("WithCosmos")] public class CosmosDbContainerAuthTest { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. private CosmosDb _db; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. [OneTimeSetUp] public async Task SetUp() @@ -14,10 +16,10 @@ public async Task SetUp() } [Test] - public void AsQueryable1() => Assert.That(_db.Persons1.ModelContainer.Query().AsQueryable().Count(), Is.EqualTo(3)); + public void AsQueryable1() => Assert.That(_db.Persons1.Query().AsQueryable().Count(), Is.EqualTo(3)); [Test] - public void AsQueryable2() => Assert.That(_db.Persons2.ModelContainer.Query().AsQueryable().Count(), Is.EqualTo(3)); + public void AsQueryable2() => Assert.That(_db.Persons2.Query().AsQueryable().Count(), Is.EqualTo(3)); [Test] public void AsQueryable3() => Assert.That(_db.Persons3.Query().AsQueryable().Count(), Is.EqualTo(3)); @@ -29,7 +31,7 @@ public async Task Get1Async() var v = await _db.Persons1.GetAsync(1.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid().ToString())); + Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); Assert.ThrowsAsync(() => _db.Persons1.GetAsync(2.ToGuid().ToString())); } @@ -41,7 +43,7 @@ public async Task Get2Async() var v = await _db.Persons2.GetAsync(1.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid().ToString())); + Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); Assert.ThrowsAsync(() => _db.Persons2.GetAsync(2.ToGuid().ToString())); } @@ -53,7 +55,7 @@ public async Task Get3Async() var v = await _db.Persons3.GetAsync(1.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid())); + Assert.That(v!.Id, Is.EqualTo(1.ToGuid())); Assert.ThrowsAsync(() => _db.Persons3.GetAsync(2.ToGuid())); } @@ -107,14 +109,14 @@ public async Task Create3Async() public async Task Update1Async() { // Update where not auth. - var v = (await _db.Persons1.Container.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + var v = (await _db.Persons1.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); // Update to something not auth. - v = (await _db.Persons1.Container.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + v = (await _db.Persons1.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; @@ -129,14 +131,14 @@ public async Task Update1Async() public async Task Update2Async() { // Update where not auth. - var v = (await _db.Persons2.Container.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + var v = (await _db.Persons2.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); // Update to something not auth. - v = (await _db.Persons2.Container.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + v = (await _db.Persons2.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; @@ -151,14 +153,14 @@ public async Task Update2Async() public async Task Update3Async() { // Update where not auth. - var v = (await _db.Persons3.Container.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + var v = (await _db.Persons3.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); // Update to something not auth. - v = (await _db.Persons3.Container.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; + v = (await _db.Persons3.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; Assert.That(v, Is.Not.Null); v.Name += "X"; diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs index cd697dc6..9741ff05 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs @@ -1,5 +1,4 @@ using Microsoft.Azure.Cosmos; -using CoreEx.Cosmos.Extended; namespace CoreEx.Cosmos.Test { @@ -7,7 +6,9 @@ namespace CoreEx.Cosmos.Test [Category("WithCosmos")] public class CosmosDbContainerPartitioningTest { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. private CosmosDb _db; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. [OneTimeSetUp] public async Task SetUp() @@ -17,9 +18,6 @@ public async Task SetUp() { DbArgs = new CosmosDbArgs(new PartitionKey("A")) }; - _db.Persons1.UsePartitionKey(p => new PartitionKey(p.Filter)); - _db.Persons2.UsePartitionKey(p => new PartitionKey(p.Filter)); - _db.Persons3.UsePartitionKey(p => new PartitionKey(p.Value.Filter)); } [Test] @@ -30,7 +28,7 @@ public async Task Get1Async() v = await _db.Persons1.GetAsync(4.ToGuid()); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Sally")); + Assert.That(v!.Name, Is.EqualTo("Sally")); } [Test] @@ -45,7 +43,7 @@ public async Task Create1Async() v = await _db.Persons1.GetAsync(id); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Michelle")); + Assert.That(v!.Name, Is.EqualTo("Michelle")); } [Test] @@ -54,21 +52,21 @@ public async Task Update1Async() var v = await _db.Persons1.GetAsync(4.ToGuid()); Assert.That(v, Is.Not.Null); - v.Name += "X"; + v!.Name += "X"; v.Filter = "B"; Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); v.Filter = "A"; v = await _db.Persons1.UpdateAsync(v); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("SallyX")); + Assert.That(v!.Name, Is.EqualTo("SallyX")); } [Test] public async Task Delete1Async() { Assert.ThrowsAsync(() => _db.Persons1.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons1.Container.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); + var ir = await _db.Persons1.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); Assert.That(ir, Is.Not.Null); Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); @@ -85,7 +83,7 @@ public async Task Get2Async() v = await _db.Persons2.GetAsync(4.ToGuid()); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Sally")); + Assert.That(v!.Name, Is.EqualTo("Sally")); } [Test] @@ -100,7 +98,7 @@ public async Task Create2Async() v = await _db.Persons2.GetAsync(id); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Michelle")); + Assert.That(v!.Name, Is.EqualTo("Michelle")); } [Test] @@ -109,7 +107,7 @@ public async Task Update2Async() var v = await _db.Persons2.GetAsync(4.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - v.Name += "X"; + v!.Name += "X"; v.Filter = "B"; Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); @@ -123,7 +121,7 @@ public async Task Update2Async() public async Task Delete2Async() { Assert.ThrowsAsync(() => _db.Persons2.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons2.Container.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); + var ir = await _db.Persons2.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); Assert.That(ir, Is.Not.Null); Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); @@ -140,7 +138,7 @@ public async Task Get3Async() v = await _db.Persons3.GetAsync(4.ToGuid()); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Sally")); + Assert.That(v!.Name, Is.EqualTo("Sally")); } [Test] @@ -155,7 +153,7 @@ public async Task Create3Async() v = await _db.Persons3.GetAsync(id); Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("Michelle")); + Assert.That(v!.Name, Is.EqualTo("Michelle")); } [Test] @@ -164,7 +162,7 @@ public async Task Update3Async() var v = await _db.Persons3.GetAsync(4.ToGuid()); Assert.That(v, Is.Not.Null); - v.Name += "X"; + v!.Name += "X"; v.Filter = "B"; Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); @@ -178,7 +176,7 @@ public async Task Update3Async() public async Task Delete3Async() { Assert.ThrowsAsync(() => _db.Persons3.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons3.Container.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); + var ir = await _db.Persons3.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); Assert.That(ir, Is.Not.Null); Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); @@ -188,19 +186,37 @@ public async Task Delete3Async() } [Test] - public async Task SelectMultiSetAsync() + public async Task SelectValueMultiSetAsync_A() { Person3[] people = Array.Empty(); var hasPerson = false; - var result = await _db.SelectMultiSetWithResultAsync(new PartitionKey("A"), - _db.Persons3.CreateMultiSetCollArgs(r => people = r.ToArray()), - _db.Persons3X.CreateMultiSetSingleArgs(r => hasPerson = true, isMandatory: false)); + var result = await _db["Persons3"].SelectValueMultiSetWithResultAsync(new PartitionKey("A"), + new MultiSetValueCollArgs(r => people = r.ToArray()), + new MultiSetValueSingleArgs(r => hasPerson = true, isMandatory: false)); Assert.Multiple(() => { Assert.That(result.IsSuccess, Is.True); Assert.That(people, Has.Length.EqualTo(3)); + Assert.That(hasPerson, Is.True); + }); + } + + [Test] + public async Task SelectValueMultiSetAsync_B() + { + Person3[] people = Array.Empty(); + var hasPerson = false; + + var result = await _db["Persons3"].SelectValueMultiSetWithResultAsync(new PartitionKey("B"), + new MultiSetValueCollArgs(r => people = r.ToArray()), + new MultiSetValueSingleArgs(r => hasPerson = true, isMandatory: false)); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(people, Has.Length.EqualTo(2)); Assert.That(hasPerson, Is.False); }); } diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs index fbcf8ad8..3def41d3 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs @@ -20,10 +20,13 @@ public async Task Get1Async() var v = await _db.Persons1.GetAsync(1.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid().ToString())); - Assert.That(v.Name, Is.EqualTo("Rebecca")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); - Assert.That(v.Salary, Is.EqualTo(150000m)); + Assert.Multiple(() => + { + Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); + Assert.That(v.Name, Is.EqualTo("Rebecca")); + Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); + Assert.That(v.Salary, Is.EqualTo(150000m)); + }); } [Test] @@ -33,12 +36,12 @@ public async Task Get2Async() var v = await _db.Persons2.GetAsync(1.ToGuid().ToString()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid().ToString())); + Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); Assert.That(v.Name, Is.EqualTo("Rebecca")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); Assert.That(v.Salary, Is.EqualTo(150000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ETag, Is.Not.Null); Assert.That(v.ETag, Does.Not.StartsWith("\"")); @@ -51,12 +54,12 @@ public async Task Get3Async() var v = await _db.Persons3.GetAsync(1.ToGuid()); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(1.ToGuid())); + Assert.That(v!.Id, Is.EqualTo(1.ToGuid())); Assert.That(v.Name, Is.EqualTo("Rebecca")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); Assert.That(v.Salary, Is.EqualTo(150000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Null); @@ -83,7 +86,7 @@ public async Task Create1Async() v = await _db.Persons1.GetAsync(v.Id); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); + Assert.That(v!.Id, Is.EqualTo(id)); Assert.That(v.Name, Is.EqualTo("Michelle")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); Assert.That(v.Salary, Is.EqualTo(181000m)); @@ -98,12 +101,12 @@ public async Task Create2Async() var v = new Person2 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; v = await _db.Persons2.CreateAsync(v); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); + Assert.That(v!.Id, Is.EqualTo(id)); Assert.That(v.Name, Is.EqualTo("Michelle")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); Assert.That(v.Salary, Is.EqualTo(181000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Null); @@ -112,12 +115,12 @@ public async Task Create2Async() v = await _db.Persons2.GetAsync(v.Id); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); + Assert.That(v!.Id, Is.EqualTo(id)); Assert.That(v.Name, Is.EqualTo("Michelle")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); Assert.That(v.Salary, Is.EqualTo(181000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Null); @@ -134,12 +137,12 @@ public async Task Create3Async() var v = new Person3 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; v = await _db.Persons3.CreateAsync(v); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); + Assert.That(v!.Id, Is.EqualTo(id)); Assert.That(v.Name, Is.EqualTo("Michelle")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); Assert.That(v.Salary, Is.EqualTo(181000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Null); @@ -148,12 +151,12 @@ public async Task Create3Async() v = await _db.Persons3.GetAsync(v.Id); Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); + Assert.That(v!.Id, Is.EqualTo(id)); Assert.That(v.Name, Is.EqualTo("Michelle")); Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); Assert.That(v.Salary, Is.EqualTo(181000m)); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Null); @@ -171,7 +174,7 @@ public async Task Update1Async() Assert.That(v, Is.Not.Null); // Update testing. - v.Id = 404.ToGuid().ToString(); + v!.Id = 404.ToGuid().ToString(); Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); v.Id = 5.ToGuid().ToString(); @@ -192,7 +195,7 @@ public async Task Update2Async() Assert.That(v, Is.Not.Null); // Update testing. - v.Id = 404.ToGuid().ToString(); + v!.Id = 404.ToGuid().ToString(); Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); v.Id = 5.ToGuid().ToString(); @@ -202,7 +205,7 @@ public async Task Update2Async() Assert.That(v.Id, Is.EqualTo(5.ToGuid().ToString())); Assert.That(v.Name, Is.EqualTo("MikeX")); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Not.Null); @@ -224,7 +227,7 @@ public async Task Update3Async() Assert.That(v, Is.Not.Null); // Update testing. - v.Id = 404.ToGuid(); + v!.Id = 404.ToGuid(); Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); v.Id = 5.ToGuid(); @@ -234,7 +237,7 @@ public async Task Update3Async() Assert.That(v.Id, Is.EqualTo(5.ToGuid())); Assert.That(v.Name, Is.EqualTo("MikeX")); Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedBy, Is.Not.Null); + Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedBy, Is.Not.Null); Assert.That(v.ChangeLog.UpdatedDate, Is.Not.Null); @@ -253,7 +256,7 @@ public async Task Delete1Async() await _db.Persons1.DeleteAsync(4.ToGuid().ToString()); - using (var r = await _db.Persons1.Container.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) + using (var r = await _db.Persons1.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) { Assert.That(r, Is.Not.Null); Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); @@ -267,7 +270,7 @@ public async Task Delete2Async() await _db.Persons2.DeleteAsync(4.ToGuid().ToString()); - using (var r = await _db.Persons2.Container.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) + using (var r = await _db.Persons2.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) { Assert.That(r, Is.Not.Null); Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); @@ -281,13 +284,13 @@ public async Task Delete3Async() await _db.Persons3.DeleteAsync(4.ToGuid()); - using (var r = await _db.Persons3.Container.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) + using (var r = await _db.Persons3.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) { Assert.That(r, Is.Not.Null); Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); }; - using (var r = await _db.Persons3.Container.ReadItemStreamAsync(100.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) + using (var r = await _db.Persons3.CosmosContainer.ReadItemStreamAsync(100.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) { Assert.That(r, Is.Not.Null); Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs new file mode 100644 index 00000000..5f00fdad --- /dev/null +++ b/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs @@ -0,0 +1,42 @@ +using CoreEx.Cosmos.Model; +using Microsoft.Azure.Cosmos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CoreEx.Cosmos.Test +{ + [TestFixture] + [Category("WithCosmos")] + public class CosmosDbModelTest + { + private CosmosDb _db; + + [OneTimeSetUp] + public async Task SetUp() + { + await TestSetUp.SetUpAsync().ConfigureAwait(false); + _db = new CosmosDb(auth: false); + } + + [Test] + public async Task SelectMultiSetWithResultAsync() + { + PersonX1[] people = Array.Empty(); + var hasPerson = false; + + var result = await _db.PersonsX.Model.SelectMultiSetWithResultAsync(PartitionKey.None, + new MultiSetModelCollArgs(r => people = r.ToArray()), + new MultiSetModelSingleArgs(r => hasPerson = true)); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(people, Has.Length.EqualTo(2)); + Assert.That(hasPerson, Is.True); + }); + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs index 89f5d0b1..1ba10d94 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs @@ -51,7 +51,7 @@ public async Task Query_Paging1() Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging.TotalCount, Is.EqualTo(3)); + Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); } [Test] @@ -74,7 +74,7 @@ public async Task Query_Wildcards1() Assert.That(v[2].Name, Is.EqualTo("Mike")); var ex = Assert.ThrowsAsync(() => _db.Persons1.Query(q => q.WhereWildcard(x => x.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex.Message, Is.EqualTo("Wildcard selection text is not supported.")); + Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); } [Test] @@ -111,7 +111,7 @@ public async Task Query_Paging2() Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging.TotalCount, Is.EqualTo(3)); + Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); } [Test] @@ -134,7 +134,7 @@ public async Task Query_Wildcards2() Assert.That(v[2].Name, Is.EqualTo("Mike")); var ex = Assert.ThrowsAsync(() => _db.Persons2.Query(q => q.WhereWildcard(x => x.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex.Message, Is.EqualTo("Wildcard selection text is not supported.")); + Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); } [Test] @@ -171,7 +171,7 @@ public async Task Query_Paging3() Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging.TotalCount, Is.EqualTo(3)); + Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); } [Test] @@ -194,20 +194,20 @@ public async Task Query_Wildcards3() Assert.That(v[2].Name, Is.EqualTo("Mike")); var ex = Assert.ThrowsAsync(() => _db.Persons3.Query(q => q.WhereWildcard(x => x.Value.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex.Message, Is.EqualTo("Wildcard selection text is not supported.")); + Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); } [Test] public async Task ModelQuery_Paging3() { var pr = new Entities.PagingResult(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)); - var v = await _db.Persons3.ModelContainer.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); + var v = await _db.Persons3.Model.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); Assert.That(v, Has.Length.EqualTo(2)); Assert.That(v[0].Value.Name, Is.EqualTo("Gary")); Assert.That(v[1].Value.Name, Is.EqualTo("Greg")); Assert.That(pr.TotalCount, Is.EqualTo(5)); - v = await _db.Persons3.ModelContainer.Query(q => q.OrderBy(x => x.Value.Name)).WithPaging(1, 2).ToArrayAsync(); + v = await _db.Persons3.Model.Query(q => q.OrderBy(x => x.Value.Name)).WithPaging(1, 2).ToArrayAsync(); Assert.That(v, Has.Length.EqualTo(2)); Assert.That(v[0].Value.Name, Is.EqualTo("Greg")); Assert.That(v[1].Value.Name, Is.EqualTo("Mike")); @@ -221,7 +221,7 @@ public async Task ModelQuery_WithFilter() .AddField("Name", "Value.Name", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) .AddField("Birthday", "Value.Birthday")); - var v = await _db.Persons3.ModelContainer.Query(q => q.Where(qac, QueryArgs.Create("endswith(name, 'Y')")).OrderBy(x => x.Id)).ToArrayAsync(); + var v = await _db.Persons3.Model.Query(q => q.Where(qac, QueryArgs.Create("endswith(name, 'Y')")).OrderBy(x => x.Id)).ToArrayAsync(); Assert.That(v, Has.Length.EqualTo(2)); Assert.That(v[0].Value.Name, Is.EqualTo("Gary")); Assert.That(v[1].Value.Name, Is.EqualTo("Sally")); diff --git a/tests/CoreEx.Cosmos.Test/Data/Data.yaml b/tests/CoreEx.Cosmos.Test/Data/Data.yaml index 96baec94..ebc337fc 100644 --- a/tests/CoreEx.Cosmos.Test/Data/Data.yaml +++ b/tests/CoreEx.Cosmos.Test/Data/Data.yaml @@ -16,4 +16,9 @@ - { id: ^2, name: "Gary", birthday: 1986-11-04, salary: 95000, locked: true, filter: B } - { id: ^3, name: "Greg", birthday: 1970-07-06, salary: 101000, locked: false, filter: A } - { id: ^4, name: "Sally", birthday: 1999-02-28, salary: 50000, locked: true, filter: A } - - { id: ^5, name: "Mike", birthday: 1967-09-13, salary: 135000, locked: false, filter: A } \ No newline at end of file + - { id: ^5, name: "Mike", birthday: 1967-09-13, salary: 135000, locked: false, filter: A } + - PersonX: + - { id: A, type: PersonX1, text: "AAA" } + - { id: B, type: PersonX2, name: "BBB" } + - { id: C, type: PersonX1, text: "CCC" } + - { id: D, type: PersonX3, text: "DDD" } \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/Models.cs b/tests/CoreEx.Cosmos.Test/Models.cs index 6a19718f..6d35c720 100644 --- a/tests/CoreEx.Cosmos.Test/Models.cs +++ b/tests/CoreEx.Cosmos.Test/Models.cs @@ -47,4 +47,22 @@ public class Person3Collection : List { } public class Person3CollectionResult : CollectionResult { } public class Gender : ReferenceDataBase { } + + public class PersonX1 : IIdentifier, ICosmosDbType + { + public string? Id { get; set; } + + public string Type { get; set; } = "PersonX1"; + + public string? Text { get; set; } + } + + public class PersonX2 : IIdentifier, ICosmosDbType + { + public string? Id { get; set; } + + public string Type { get; set; } = "PersonX2"; + + public string? Name { get; set; } + } } \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/TestSetUp.cs b/tests/CoreEx.Cosmos.Test/TestSetUp.cs index b04b571a..2d3c75e2 100644 --- a/tests/CoreEx.Cosmos.Test/TestSetUp.cs +++ b/tests/CoreEx.Cosmos.Test/TestSetUp.cs @@ -1,6 +1,7 @@ using CoreEx.Cosmos.Batch; using CoreEx.Json.Data; using CoreEx.Mapping; +using Microsoft.Azure.Cosmos; using AzCosmos = Microsoft.Azure.Cosmos; namespace CoreEx.Cosmos.Test @@ -79,13 +80,20 @@ public static async Task SetUpAsync(string partitionKeyPath = "/_partitionKey", UniqueKeyPolicy = new AzCosmos.UniqueKeyPolicy { UniqueKeys = { new AzCosmos.UniqueKey { Paths = { "/type", "/value/code" } } } } }, 400); + var c5 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties + { + Id = "PersonsX", + PartitionKeyPath = "/_partitionKey" + }, 400); + var db = new CosmosDb(auth: false); var jdr = JsonDataReader.ParseYaml("Data.yaml"); await db.Persons1.ImportBatchAsync(jdr); await db.Persons2.ImportBatchAsync(jdr); await db.Persons3.ImportValueBatchAsync(jdr); - await db.ImportValueBatchAsync("Persons3", new Person1[] { new() { Id = 100.ToGuid().ToString() } }); // Add other random "type" to Person3. + await db.ImportValueBatchAsync("Persons3", new Person1[] { new() { Id = 100.ToGuid().ToString(), Filter = "A" } }); // Add other random "type" to Person3. + await db.PersonsX.ImportJsonBatchAsync(jdr, "PersonX"); jdr = JsonDataReader.ParseYaml("RefData.yaml", new JsonDataReaderArgs(new Text.Json.ReferenceDataContentJsonSerializer())); await db.ImportValueBatchAsync("RefData", jdr, new Type[] { typeof(Gender) }); diff --git a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj b/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj index 5b23f8fa..e810a7dc 100644 --- a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj +++ b/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 enable enable false @@ -14,13 +14,13 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CoreEx.Test/CoreEx.Test.csproj b/tests/CoreEx.Test/CoreEx.Test.csproj index d93a566a..2cbacba2 100644 --- a/tests/CoreEx.Test/CoreEx.Test.csproj +++ b/tests/CoreEx.Test/CoreEx.Test.csproj @@ -1,29 +1,29 @@  - net6.0 + net8.0 enable false preview - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs b/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs index ddccda6e..c0896c5d 100644 --- a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs +++ b/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs @@ -139,7 +139,7 @@ public void GetReflector_PropertyReflector_Salary() } [Test] - public void GetReflector_Compare() + public void GetReflector_Compare_Int() { var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); Assert.Multiple(() => @@ -149,6 +149,17 @@ public void GetReflector_Compare() }); } + [Test] + public void GetReflector_Compare_String() + { + var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); + Assert.Multiple(() => + { + Assert.That(tr.Compare(["a", "aa"], ["a", "aa"]), Is.True); + Assert.That(tr.Compare(["a", "aa"], ["b", "bb"]), Is.False); + }); + } + [Test] public void GetReflector_PropertyReflector_Compare_Int() { diff --git a/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs b/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs index d8bab63d..8688eb70 100644 --- a/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs +++ b/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs @@ -33,6 +33,7 @@ public void CreatePageAndSize() [Test] public void CreateTokenAndTake() { + PagingArgs.IsTokenSupported = true; var pa = PagingArgs.CreateTokenAndTake("blah-blah", 20); Assert.Multiple(() => { @@ -41,5 +42,8 @@ public void CreateTokenAndTake() Assert.That(pa.Option, Is.EqualTo(PagingOption.TokenAndTake)); }); } + + [TearDown] + public void TearDown() => PagingArgs.IsTokenSupported = false; } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs b/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs index d78381be..e285f66c 100644 --- a/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs +++ b/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs @@ -234,6 +234,7 @@ public void Get_SuccessWithCollectionResultAndPaging() [Test] public void Get_SuccessWithCollectionResultAndTokenPaging() { + PagingArgs.IsTokenSupported = true; var pc = new ProductCollection { new Product { Id = "abc", Name = "banana", Price = 0.99m }, new Product { Id = "def", Name = "apple", Price = 0.49m } }; var mcf = MockHttpClientFactory.Create(); @@ -261,6 +262,9 @@ public void Get_SuccessWithCollectionResultAndTokenPaging() mcf.VerifyAll(); } + [TearDown] + public void TearDown() => PagingArgs.IsTokenSupported = false; + [Test] public void Post_Success() { diff --git a/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs b/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs index 2a7057e2..b4b70a82 100644 --- a/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs +++ b/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs @@ -302,7 +302,7 @@ public void ToMergePatch_Object_With_ReplaceAllArray() [Test] public void ToMergePatch_Object_With_NoReplaceAllArray() { - var o = new JsonElementComparerOptions { AlwaysReplaceAllArrayItems = false }; + var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; var jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch(); Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"address\":{\"street\":2}}]}")); @@ -329,7 +329,7 @@ public void ToMergePatch_Object_With_Array_Paths() [Test] public void ToMergePatch_Object_With_NoReplaceAllArray_Paths() { - var o = new JsonElementComparerOptions { AlwaysReplaceAllArrayItems = false }; + var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; var jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch("names.name"); Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); @@ -371,7 +371,7 @@ public void ToMergePatch_Root_Array() jn = new JsonElementComparer().Compare("[1,2,3]", "[1,null,3,{\"age\":21}]").ToMergePatch(); Assert.That(jn!.ToJsonString(), Is.EqualTo("[1,null,3,{\"age\":21}]")); - var o = new JsonElementComparerOptions { AlwaysReplaceAllArrayItems = false }; + var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; jn = new JsonElementComparer(o).Compare("[1,2,3]", "[1,9,3]").ToMergePatch(); Assert.That(jn!.ToJsonString(), Is.EqualTo("[9]")); diff --git a/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs b/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs new file mode 100644 index 00000000..cf9e773e --- /dev/null +++ b/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs @@ -0,0 +1,1370 @@ +using CoreEx.Entities; +using CoreEx.Json.Merge; +using CoreEx.Json.Merge.Extended; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace CoreEx.Test.Framework.Json.Merge +{ + [TestFixture] + public class JsonMergePatchExTest + { + public class SubData + { + public string? Code { get; set; } + public string? Text { get; set; } + public int Count { get; set; } + } + + public class KeyData : IPrimaryKey + { + public string? Code { get; set; } + public string? Text { get; set; } + public string? Other { get; set; } + public CompositeKey PrimaryKey => new(Code); + } + + public class KeyDataCollection : EntityKeyCollection { } + + public class NonKeyData + { + public string? Code { get; set; } + + public string? Text { get; set; } + } + + public class NonKeyDataCollection : List { } + + public class TestData + { + public Guid Id { get; set; } + public string? Name { get; set; } + [JsonIgnore] + public string? Ignore { get; set; } + public bool IsValid { get; set; } + public DateTime Date { get; set; } + public int Count { get; set; } + public decimal Amount { get; set; } + public SubData? Sub { get; set; } + public int[]? Values { get; set; } + public List? NoKeys { get; set; } + public List? Keys { get; set; } + public KeyDataCollection? KeysColl { get; set; } + public NonKeyDataCollection? NonKeys { get; set; } + public Dictionary? Dict { get; set; } + public Dictionary? Dict2 { get; set; } + } + + [Test] + public void Merge_NullJsonArgument() + { + var td = new TestData(); + Assert.Throws(() => { new JsonMergePatchEx().Merge(null!, ref td); }); + } + + [Test] + public void Merge_Malformed() + { + var td = new TestData(); + var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString(""), ref td)); + Assert.That(ex!.Message, Is.EqualTo("'<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.")); + } + + [Test] + public void Merge_Empty() + { + var td = new TestData(); + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ }"), ref td), Is.False); + } + + [Test] + public void Merge_Int() + { + int i = 1; + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("1"), ref i), Is.False); + + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("2"), ref i), Is.True); + Assert.That(i, Is.EqualTo(2)); + } + + [Test] + public void Merge_Property_StringValue() + { + var td = new TestData { Name = "Fred" }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": \"Barry\" }"), ref td), Is.True); + Assert.That(td!.Name, Is.EqualTo("Barry")); + }); + } + + [Test] + public void Merge_Property_StringValue_DifferentNameCasingSupported() + { + var td = new TestData { Name = "Fred" }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.True); + Assert.That(td!.Name, Is.EqualTo("Barry")); + }); + } + + [Test] + public void Merge_Property_StringValue_DifferentNameCasingNotSupported() + { + var td = new TestData { Name = "Fred" }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { PropertyNameComparer = StringComparer.Ordinal }).Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.False); + Assert.That(td!.Name, Is.EqualTo("Fred")); + }); + } + + [Test] + public void Merge_Property_StringNull() + { + var td = new TestData { Name = "Fred" }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": null }"), ref td), Is.True); + Assert.That(td!.Name, Is.Null); + }); + } + + [Test] + public void Merge_Property_StringNumberValue() + { + var td = new TestData { Name = "Fred" }; + var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": 123 }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 13.")); + } + + [Test] + public void Merge_Property_String_MalformedA() + { + var td = new TestData(); + var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": [ \"Barry\" ] }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 11.")); + } + + [Test] + public void Merge_PrimitiveTypesA() + { + var td = new TestData(); + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); + + Assert.That(td!.Id, Is.EqualTo(new Guid("13512759-4f50-e911-b35c-bc83850db74d"))); + Assert.That(td.Name, Is.EqualTo("Barry")); + Assert.That(td.IsValid, Is.True); + Assert.That(td.Date, Is.EqualTo(new DateTime(2018, 12, 31))); + Assert.That(td.Count, Is.EqualTo(12)); + }); + Assert.That(td.Amount, Is.EqualTo(132.58m)); + } + + [Test] + public void Merge_PrimitiveTypes_NonCached_X100() + { + for (int i = 0; i < 100; i++) + { + var td = new TestData(); + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); + } + } + + [Test] + public void Merge_PrimitiveTypes_Cached_X100() + { + var jom = new JsonMergePatchEx(); + for (int i = 0; i < 100; i++) + { + var td = new TestData(); + Assert.That(jom.Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); + } + } + + [Test] + public void Merge_Property_SubEntityNull() + { + var td = new TestData { Sub = new SubData() }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": null }"), ref td), Is.True); + Assert.That(td!.Sub, Is.Null); + }); + } + + [Test] + public void Merge_Property_SubEntityNewEmpty() + { + var td = new TestData(); + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.True); + Assert.That(td!.Sub, Is.Not.Null); + Assert.That(td.Sub!.Code, Is.Null); + Assert.That(td.Sub.Text, Is.Null); + }); + } + + [Test] + public void Merge_Property_SubEntityExistingEmpty() + { + var td = new TestData { Sub = new SubData() }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.False); + Assert.That(td!.Sub, Is.Not.Null); + Assert.That(td.Sub!.Code, Is.Null); + Assert.That(td.Sub.Text, Is.Null); + }); + } + + [Test] + public void Merge_Property_SubEntityExistingChanged() + { + var td = new TestData { Sub = new SubData() }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { \"code\": \"x\", \"text\": \"xxx\" } }"), ref td), Is.True); + Assert.That(td!.Sub, Is.Not.Null); + Assert.That(td.Sub!.Code, Is.EqualTo("x")); + Assert.That(td.Sub.Text, Is.EqualTo("xxx")); + }); + } + + [Test] + public void Merge_Property_ArrayMalformed() + { + var td = new TestData(); + var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": { } }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Int32[]. Path: $.values | LineNumber: 0 | BytePositionInLine: 13.")); + } + + [Test] + public void Merge_Property_ArrayNull() + { + var td = new TestData { Values = new int[] { 1, 2, 3 } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": null }"), ref td), Is.True); + Assert.That(td!.Values, Is.Null); + }); + } + + [Test] + public void Merge_Property_ArrayEmpty() + { + var td = new TestData { Values = new int[] { 1, 2, 3 } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [] }"), ref td), Is.True); + Assert.That(td!.Values, Is.Not.Null); + Assert.That(td.Values!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_ArrayValues_NoChanges() + { + var td = new TestData { Values = new int[] { 1, 2, 3 } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [ 1, 2, 3] }"), ref td), Is.False); + Assert.That(td!.Values, Is.Not.Null); + Assert.That(td.Values!, Has.Length.EqualTo(3)); + }); + } + + [Test] + public void Merge_Property_ArrayValues_Changes() + { + var td = new TestData { Values = new int[] { 1, 2, 3 } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [ 3, 2, 1] }"), ref td), Is.True); + Assert.That(td!.Values, Is.Not.Null); + Assert.That(td.Values!, Has.Length.EqualTo(3)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Values[0], Is.EqualTo(3)); + Assert.That(td.Values[1], Is.EqualTo(2)); + Assert.That(td.Values[2], Is.EqualTo(1)); + }); + } + + [Test] + public void Merge_Property_NoKeys_ListNull() + { + var td = new TestData { NoKeys = new List { new() } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": null }"), ref td), Is.True); + Assert.That(td!.Values, Is.Null); + }); + } + + [Test] + public void Merge_Property_NoKeys_ListEmpty() + { + var td = new TestData { NoKeys = new List { new() } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": [ ] }"), ref td), Is.True); + Assert.That(td!.NoKeys, Is.Not.Null); + Assert.That(td!.NoKeys!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_NoKeys_List() + { + var td = new TestData { NoKeys = new List { new() } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ] }"), ref td), Is.True); + Assert.That(td!.NoKeys, Is.Not.Null); + Assert.That(td.NoKeys!, Has.Count.EqualTo(3)); + Assert.That(td.NoKeys![0], Is.Not.Null); + }); + Assert.Multiple(() => + { + Assert.That(td.NoKeys[0].Code, Is.EqualTo("abc")); + Assert.That(td.NoKeys[0].Text, Is.EqualTo("xyz")); + Assert.That(td.NoKeys[1], Is.Not.Null); + }); + Assert.Multiple(() => + { + Assert.That(td.NoKeys[1].Code, Is.Null); + Assert.That(td.NoKeys[1].Text, Is.Null); + Assert.That(td.NoKeys[2], Is.Null); + }); + } + + [Test] + public void Merge_Property_Keys_ListNull() + { + var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": null }"), ref td), Is.True); + Assert.That(td!.Keys, Is.Null); + }); + } + + [Test] + public void Merge_Property_Keys_ListEmpty() + { + var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ ] }"), ref td), Is.True); + + Assert.That(td!.Keys, Is.Not.Null); + Assert.That(td.Keys!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_Keys_Null() + { + var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ null ] }"), ref td), Is.True); + + Assert.That(td!.Keys, Is.Not.Null); + Assert.That(td.Keys!, Has.Count.EqualTo(1)); + Assert.That(td.Keys![0], Is.Null); + }); + } + + [Test] + public void Merge_Property_Keys_Replace() + { + var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\" }, { \"code\": \"uvw\", \"text\": \"xyz\" } ] }"), ref td), Is.True); + + Assert.That(td!.Keys, Is.Not.Null); + Assert.That(td.Keys!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); + Assert.That(td.Keys[0].Text, Is.EqualTo(null)); + Assert.That(td.Keys[1].Code, Is.EqualTo("uvw")); + Assert.That(td.Keys[1].Text, Is.EqualTo("xyz")); + }); + } + + [Test] + public void Merge_Property_Keys_NoChanges() + { + // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. + var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\", \"text\": \"def\" } ] }"), ref td), Is.True); + + Assert.That(td!.Keys, Is.Not.Null); + Assert.That(td.Keys!, Has.Count.EqualTo(1)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); + Assert.That(td.Keys[0].Text, Is.EqualTo("def")); + }); + } + + [Test] + public void Merge_Property_KeysColl_ListNull() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.True); + Assert.That(td!.Values, Is.Null); + }); + } + + [Test] + public void Merge_Property_KeysColl_ListEmpty() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.True); + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_KeysColl_Null() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "abc", Text = "def" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.True); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); + Assert.That(td.KeysColl![0], Is.Null); + }); + } + + [Test] + public void Merge_Property_KeysColl_DuplicateNulls() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; + var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null, null ] }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); + } + + [Test] + public void Merge_Property_KeysColl_DuplicateVals1() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; + var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { }, { } ] }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); + } + + [Test] + public void Merge_Property_KeysColl_DuplicateVals2() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; + var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"a\" } ] }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); + } + + [Test] + public void Merge_Property_KeysColl_DuplicateVals_Dest() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData(), new KeyData() } }; + var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { } ] }"), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON array destination collection must not contain items with duplicate 'IEntityKey' keys prior to merge. Path: $.keyscoll")); + } + + [Test] + public void Merge_Property_KeysColl_Null_NoChanges() + { + var td = new TestData { }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.False); + + Assert.That(td!.KeysColl, Is.Null); + }); + } + + [Test] + public void Merge_Property_KeysColl_Empty_NoChanges() + { + var td = new TestData { KeysColl = new KeyDataCollection() }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.False); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_KeysColl_NullItem_NoChanges() + { + var td = new TestData { KeysColl = new KeyDataCollection { null! } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.False); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); + Assert.That(td.KeysColl![0], Is.Null); + }); + } + + [Test] + public void Merge_Property_KeysColl_Item_NoChanges() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" } ] }"), ref td), Is.False); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); + Assert.That(td.KeysColl![0].Code, Is.EqualTo("a")); + }); + } + + [Test] + public void Merge_Property_KeysColl_KeyedItem_Changes() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\", \"text\": \"zz\" }, { \"code\": \"b\" } ] }"), ref td), Is.True); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); + }); + Assert.Multiple(() => + { + Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); + Assert.That(td.KeysColl[0].Text, Is.EqualTo("zz")); + Assert.That(td.KeysColl[1].Code, Is.EqualTo("b")); + Assert.That(td.KeysColl[1].Text, Is.EqualTo("bb")); + }); + } + + [Test] + public void Merge_Property_KeysColl_KeyedItem_SequenceChanges() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"b\", \"text\": \"yy\" }, { \"code\": \"a\" } ] }"), ref td), Is.True); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.KeysColl[0].Code, Is.EqualTo("b")); + Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); + Assert.That(td.KeysColl[1].Code, Is.EqualTo("a")); + Assert.That(td.KeysColl[1].Text, Is.EqualTo("aa")); + }); + } + + [Test] + public void Merge_Property_KeysColl_KeyedItem_AllNew() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"y\", \"text\": \"yy\" }, { \"code\": \"z\", \"text\": \"zz\" } ] }"), ref td), Is.True); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.KeysColl[0].Code, Is.EqualTo("y")); + Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); + Assert.That(td.KeysColl[1].Code, Is.EqualTo("z")); + Assert.That(td.KeysColl[1].Text, Is.EqualTo("zz")); + }); + } + + [Test] + public void Merge_Property_KeysColl_KeyedItem_Delete() + { + var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" }, new KeyData { Code = "c", Text = "cc" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"c\" } ] }"), ref td), Is.True); + + Assert.That(td!.KeysColl, Is.Not.Null); + Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); + Assert.That(td.KeysColl[0].Text, Is.EqualTo("aa")); + Assert.That(td.KeysColl[1].Code, Is.EqualTo("c")); + Assert.That(td.KeysColl[1].Text, Is.EqualTo("cc")); + }); + } + + // *** Dictionary - DictionaryMergeApproach.Replace + + [Test] + public void Merge_Property_DictReplace_Null() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); + Assert.That(td!.Dict, Is.Null); + }); + } + + [Test] + public void Merge_Property_DictReplace_Empty() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_DictReplace_NullValue() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo(null)); + }); + } + + [Test] + public void Merge_Property_DictReplace_DuplicateKeys_IntoNull() + { + var td = new TestData(); + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo("v2")); + }); + } + + [Test] + public void Merge_Property_DictReplace_DuplicateKeys_IntoEmpty() + { + var td = new TestData { Dict = new Dictionary() }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo("v2")); + }); + } + + [Test] + public void Merge_Property_DictReplace_NoChange() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("v")); + Assert.That(td.Dict["k1"], Is.EqualTo("v1")); + }); + } + + [Test] + public void Merge_Property_DictReplace_ReOrder_NoChange() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("v")); + Assert.That(td.Dict["k1"], Is.EqualTo("v1")); + }); + } + + [Test] + public void Merge_Property_DictReplace_AddUpdateDelete_Replace() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k2\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("v")); + Assert.That(td.Dict["k2"], Is.EqualTo("v2")); + }); + } + + // *** Dictionary - DictionaryMergeApproach.Merge + + [Test] + public void Merge_Property_DictMerge_Null() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); + Assert.That(td!.Dict, Is.Null); + }); + } + + [Test] + public void Merge_Property_DictMerge_Empty() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + // Should result in no changes as no property (key) was provided. + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.False); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo("v")); + }); + } + + [Test] + public void Merge_Property_DictMerge_NullValue() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; + + Assert.Multiple(() => + { + // A key with a value of null indicates it should be removed. + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Is.Empty); + }); + } + + [Test] + public void Merge_Property_DictMerge_DuplicateKeys_IntoNull() + { + var td = new TestData { }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo("v2")); + }); + } + + [Test] + public void Merge_Property_DictMerge_DuplicateKeys_IntoEmpty() + { + var td = new TestData { Dict = new Dictionary() }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(1)); + Assert.That(td.Dict!["k"], Is.EqualTo("v2")); + }); + } + + [Test] + public void Merge_Property_DictMerge_NoChange() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("v")); + Assert.That(td.Dict["k1"], Is.EqualTo("v1")); + }); + } + + [Test] + public void Merge_Property_DictMerge_ReOrder_NoChange() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("v")); + Assert.That(td.Dict["k1"], Is.EqualTo("v1")); + }); + } + + [Test] + public void Merge_Property_DictMerge_AddUpdateDelete() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\",\"k1\":null}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("vx")); + Assert.That(td.Dict["k2"], Is.EqualTo("v2")); + }); + } + + [Test] + public void Merge_Property_DictMerge_AddUpdate() + { + var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\"}}"), ref td), Is.True); + Assert.That(td!.Dict, Is.Not.Null); + Assert.That(td.Dict!, Has.Count.EqualTo(3)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict["k"], Is.EqualTo("vx")); + Assert.That(td.Dict["k1"], Is.EqualTo("v1")); + Assert.That(td.Dict["k2"], Is.EqualTo("v2")); + }); + } + + // *** + + [Test] + public void Merge_Property_Dict2Replace_DuplicateKeys_IntoEmpty() + { + var td = new TestData { Dict2 = new Dictionary() }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(1)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); + }); + } + + [Test] + public void Merge_Property_Dict2Replace_NoChange() + { + // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. + var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); + Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); + Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); + }); + } + + [Test] + public void Merge_Property_Dict2Replace_AddUpdateDelete_Replace() + { + var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo(null)); + Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); + Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); + }); + } + + // *** + + [Test] + public void Merge_Property_KeyDict2Merge_DuplicateKeys_IntoEmpty() + { + var td = new TestData { Dict2 = new Dictionary() }; + + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(1)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); + }); + } + + [Test] + public void Merge_Property_KeyDict2Merge_NoChange() + { + var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.False); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); + Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); + Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); + }); + } + + [Test] + public void Merge_Property_KeyDict2Merge_AddUpdateDelete() + { + var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"},\"b\":null}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(2)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); + Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); + Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); + }); + } + + [Test] + public void Merge_Property_KeyDict2Merge_AddUpdate() + { + var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; + Assert.Multiple(() => + { + Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); + Assert.That(td!.Dict2, Is.Not.Null); + Assert.That(td.Dict2!, Has.Count.EqualTo(3)); + }); + + Assert.Multiple(() => + { + Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); + Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); + Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); + Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); + Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); + Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); + }); + } + + // *** + + [Test] + public void Merge_XLoadTest_NoCache_1_1000() + { + for (int i = 0; i < 1000; i++) + { + var td = JsonMergePatchTest._testData; + new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); + } + } + + [Test] + public void Merge_XLoadTest_WithCache_1_1000() + { + var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }); + for (int i = 0; i < 1000; i++) + { + var td = JsonMergePatchTest._testData; + jmp.Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); + } + } + + [Test] + public void Merge_XLoadTest_NoCache_2_1000() + { + for (int i = 0; i < 1000; i++) + { + var td = JsonMergePatchTest._testData; + new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); + } + } + + [Test] + public void Merge_XLoadTest_WithCache_2_1000() + { + var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }); + for (int i = 0; i < 1000; i++) + { + var td = JsonMergePatchTest._testData; + jmp.Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); + } + } + + [Test] + public async Task Merge_XLoadTest_WithRead_10000() + { + var jmp = new JsonMergePatchEx(); + + for (int i = 0; i < 10000; i++) + { + var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; + _ = await jmp.MergeWithResultAsync(new BinaryData(JsonMergePatchTest._text), async (v, _) => await Task.FromResult(v).ConfigureAwait(false)); + } + } + + // ** + + [Test] + public void MergeInvalidMergeValueForType() + { + var jmp = new JsonMergePatchEx(); + var td = new TestData(); + var ex = Assert.Throws(() => jmp.Merge(BinaryData.FromString("""{ "id": { "value": "123" }}"""), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Guid. Path: $.id | LineNumber: 0 | BytePositionInLine: 9.")); + } + + [Test] + public void Merge_RootSimple_NullString() + { + string? s = null; + var jmp = new JsonMergePatchEx(); + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.False); + Assert.That(s, Is.EqualTo(null)); + }); + + s = "x"; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.True); + Assert.That(s, Is.EqualTo(null)); + }); + + s = null; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.True); + Assert.That(s, Is.EqualTo("x")); + }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.False); + Assert.That(s, Is.EqualTo("x")); + }); + } + + [Test] + public void Merge_RootSimple_NullInt() + { + int? i = null; + var jmp = new JsonMergePatchEx(); + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.False); + Assert.That(i, Is.EqualTo(null)); + }); + + i = 88; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.True); + Assert.That(i, Is.EqualTo(null)); + }); + + i = null; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); + Assert.That(i, Is.EqualTo(88)); + }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.False); + Assert.That(i, Is.EqualTo(88)); + }); + } + + [Test] + public void Merge_RootSimple_Int() + { + int i = 0; + var jmp = new JsonMergePatchEx(); + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("0"), ref i), Is.False); + Assert.That(i, Is.EqualTo(0)); + + Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); + Assert.That(i, Is.EqualTo(88)); + }); + } + + [Test] + public void Merge_RootComplex_Null() + { + SubData? sd = null!; + var jmp = new JsonMergePatchEx(); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.False); + Assert.That(sd, Is.Null); + }); + + sd = new SubData { Code = "X" }; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.True); + Assert.That(sd, Is.Null); + }); + + sd = null; + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("{\"code\":\"x\"}"), ref sd), Is.True); + Assert.That(sd, Is.Not.Null); + }); + Assert.That(sd!.Code, Is.EqualTo("x")); + } + + [Test] + public void Merge_RootArray_Simple_Null() + { + var arr = Array.Empty(); + var jmp = new JsonMergePatchEx(); + jmp.Merge(BinaryData.FromString("null"), ref arr); + Assert.That(arr, Is.EqualTo(null)); + + arr = new int[] { 1, 2 }; + jmp.Merge(BinaryData.FromString("null"), ref arr); + Assert.That(arr, Is.EqualTo(null)); + + int[]? arr2 = null; + jmp.Merge(BinaryData.FromString("null"), ref arr2); + Assert.That(arr2, Is.EqualTo(null)); + + arr2 = new int[] { 1, 2 }; + jmp.Merge(BinaryData.FromString("null"), ref arr2); + Assert.That(arr2, Is.EqualTo(null)); + } + + [Test] + public void Merge_RootArray_Simple() + { + var arr = Array.Empty(); + var jmp = new JsonMergePatchEx(); + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.True); + Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); + }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.False); + Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); + }); + } + + [Test] + public void Merge_RootArray_Complex() + { + var arr = new SubData[] { new() { Code = "a", Text = "aa" }, new() { Code = "b", Text = "bb" } }; + var jmp = new JsonMergePatchEx(); + + Assert.Multiple(() => + { + // No equality checker so will appear as changed - is a replacement. + Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"a\",\"text\":\"aa\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); + Assert.That(arr!, Has.Length.EqualTo(2)); + + // Replaced. + Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"c\",\"text\":\"cc\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); + Assert.That(arr!, Has.Length.EqualTo(2)); + Assert.That(arr![0].Code, Is.EqualTo("c")); + }); + } + + [Test] + public void Merge_RootDictionary_Simple() + { + var dict = new Dictionary(); + var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); + Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); + }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); + Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); + }); + + dict = new Dictionary(); + jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); + Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); + }); + + Assert.Multiple(() => + { + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); + Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); + }); + } + + [Test] + public void Merge_RootDictionary_Complex() + { + var dict = new Dictionary(); + var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); + + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); + Assert.Multiple(() => + { + Assert.That(dict, Is.Not.Null); + Assert.That(dict!, Has.Count.EqualTo(2)); + Assert.That(dict!["x"].Code, Is.EqualTo("xx")); + Assert.That(dict!["x"].Text, Is.EqualTo(null)); + Assert.That(dict!["y"].Code, Is.EqualTo("yy")); + Assert.That(dict!["y"].Text, Is.EqualTo("YY")); + }); + + Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); + Assert.Multiple(() => + { + Assert.That(dict, Is.Not.Null); + Assert.That(dict!, Has.Count.EqualTo(2)); + Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); + Assert.That(dict!["x"].Text, Is.EqualTo(null)); + Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); + Assert.That(dict!["y"].Text, Is.EqualTo("YY")); + }); + + // -- + + dict = new Dictionary(); + jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); + + Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); + Assert.Multiple(() => + { + Assert.That(dict, Is.Not.Null); + Assert.That(dict!, Has.Count.EqualTo(2)); + Assert.That(dict!["x"].Code, Is.EqualTo("xx")); + Assert.That(dict!["x"].Text, Is.EqualTo(null)); + Assert.That(dict!["y"].Code, Is.EqualTo("yy")); + Assert.That(dict!["y"].Text, Is.EqualTo("YY")); + }); + + Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); + Assert.Multiple(() => + { + + Assert.That(dict, Is.Not.Null); + Assert.That(dict!, Has.Count.EqualTo(2)); + Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); + Assert.That(dict!["x"].Text, Is.EqualTo(null)); + Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); + Assert.That(dict!["y"].Text, Is.EqualTo(null)); + }); + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs b/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs index 87f52ef1..e100dee4 100644 --- a/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs +++ b/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs @@ -3,7 +3,9 @@ using NUnit.Framework; using System; using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace CoreEx.Test.Framework.Json.Merge { @@ -78,6 +80,16 @@ public void Merge_Empty() Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ }"), ref td), Is.False); } + [Test] + public void Merge_Int() + { + int i = 1; + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("1"), ref i), Is.False); + + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("2"), ref i), Is.True); + Assert.That(i, Is.EqualTo(2)); + } + [Test] public void Merge_Property_StringValue() { @@ -106,7 +118,8 @@ public void Merge_Property_StringValue_DifferentNameCasingNotSupported() var td = new TestData { Name = "Fred" }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { NameComparer = StringComparer.Ordinal }).Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.False); + // Returns true as this is intitially patching the JSON-to-JSON directly, then deserializing, so seen as a JSON change, but then ignored during deserialization?! Sorry! + Assert.That(new JsonMergePatch(new JsonMergePatchOptions { PropertyNameComparer = StringComparer.Ordinal }).Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.True); Assert.That(td!.Name, Is.EqualTo("Fred")); }); } @@ -415,334 +428,6 @@ public void Merge_Property_Keys_NoChanges() }); } - [Test] - public void Merge_Property_KeysColl_ListNull() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_ListEmpty() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.True); - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_KeysColl_Null() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateNulls() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null, null ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals1() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { }, { } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals2() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"a\" } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals_Dest() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData(), new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array destination collection must not contain items with duplicate 'IEntityKey' keys prior to merge. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_Null_NoChanges() - { - var td = new TestData { }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_Empty_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_KeysColl_NullItem_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { null! } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_Item_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" } ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0].Code, Is.EqualTo("a")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_Changes() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\", \"text\": \"zz\" }, { \"code\": \"b\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("zz")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("b")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("bb")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_SequenceChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"b\", \"text\": \"yy\" }, { \"code\": \"a\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("b")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("aa")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_AllNew() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"y\", \"text\": \"yy\" }, { \"code\": \"z\", \"text\": \"zz\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("y")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("z")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("zz")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_Delete() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" }, new KeyData { Code = "c", Text = "cc" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"c\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("aa")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("c")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("cc")); - }); - } - - // *** Dictionary - DictionaryMergeApproach.Replace - - [Test] - public void Merge_Property_DictReplace_Null() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Null); - }); - } - - [Test] - public void Merge_Property_DictReplace_Empty() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_DictReplace_NullValue() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo(null)); - }); - } - - [Test] - public void Merge_Property_DictReplace_DuplicateKeys_IntoNull() - { - var td = new TestData(); - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictReplace_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictReplace_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictReplace_ReOrder_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictReplace_AddUpdateDelete_Replace() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k2\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - // *** Dictionary - DictionaryMergeApproach.Merge [Test] @@ -752,7 +437,7 @@ public void Merge_Property_DictMerge_Null() Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); Assert.That(td!.Dict, Is.Null); }); } @@ -765,7 +450,7 @@ public void Merge_Property_DictMerge_Empty() Assert.Multiple(() => { // Should result in no changes as no property (key) was provided. - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.False); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.False); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Has.Count.EqualTo(1)); Assert.That(td.Dict!["k"], Is.EqualTo("v")); @@ -780,7 +465,7 @@ public void Merge_Property_DictMerge_NullValue() Assert.Multiple(() => { // A key with a value of null indicates it should be removed. - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Is.Empty); }); @@ -793,7 +478,7 @@ public void Merge_Property_DictMerge_DuplicateKeys_IntoNull() Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Has.Count.EqualTo(1)); Assert.That(td.Dict!["k"], Is.EqualTo("v2")); @@ -807,7 +492,7 @@ public void Merge_Property_DictMerge_DuplicateKeys_IntoEmpty() Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Has.Count.EqualTo(1)); Assert.That(td.Dict!["k"], Is.EqualTo("v2")); @@ -820,7 +505,7 @@ public void Merge_Property_DictMerge_NoChange() var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Has.Count.EqualTo(2)); }); @@ -838,7 +523,7 @@ public void Merge_Property_DictMerge_ReOrder_NoChange() var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); Assert.That(td!.Dict, Is.Not.Null); Assert.That(td.Dict!, Has.Count.EqualTo(2)); }); @@ -889,68 +574,6 @@ public void Merge_Property_DictMerge_AddUpdate() // *** - [Test] - public void Merge_Property_Dict2Replace_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict2 = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_Dict2Replace_NoChange() - { - // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_Dict2Replace_AddUpdateDelete_Replace() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo(null)); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - // *** - [Test] public void Merge_Property_KeyDict2Merge_DuplicateKeys_IntoEmpty() { @@ -958,7 +581,7 @@ public void Merge_Property_KeyDict2Merge_DuplicateKeys_IntoEmpty() Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); Assert.That(td!.Dict2, Is.Not.Null); Assert.That(td.Dict2!, Has.Count.EqualTo(1)); }); @@ -976,7 +599,7 @@ public void Merge_Property_KeyDict2Merge_NoChange() var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.False); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.False); Assert.That(td!.Dict2, Is.Not.Null); Assert.That(td.Dict2!, Has.Count.EqualTo(2)); }); @@ -996,7 +619,7 @@ public void Merge_Property_KeyDict2Merge_AddUpdateDelete() var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"},\"b\":null}}"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"},\"b\":null}}"), ref td), Is.True); Assert.That(td!.Dict2, Is.Not.Null); Assert.That(td.Dict2!, Has.Count.EqualTo(2)); }); @@ -1016,7 +639,7 @@ public void Merge_Property_KeyDict2Merge_AddUpdate() var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; Assert.Multiple(() => { - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); + Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); Assert.That(td!.Dict2, Is.Not.Null); Assert.That(td.Dict2!, Has.Count.EqualTo(3)); }); @@ -1032,39 +655,6 @@ public void Merge_Property_KeyDict2Merge_AddUpdate() }); } - // *** - - [Test] - public void Merge_XLoadTest_NoCache_1000() - { - var text = "{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58, \"dict\": {\"k\":\"v\",\"k1\":\"v1\"}, " - + "\"values\": [ 1, 2, 4], \"sub\": { \"code\": \"abc\", \"text\": \"xyz\" }, \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, null, { } ], " - + "\"keys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ],\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"; - - for (int i = 0; i < 1000; i++) - { - var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; - new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString(text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_WithCache_1000() - { - var jmp = new JsonMergePatch(new JsonMergePatchOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - var text = "{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58, \"dict\": {\"k\":\"v\",\"k1\":\"v1\"}, " - + "\"values\": [ 1, 2, 4], \"sub\": { \"code\": \"abc\", \"text\": \"xyz\" }, \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, null, { } ], " - + "\"keys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ],\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"; - - for (int i = 0; i < 1000; i++) - { - var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; - jmp.Merge(BinaryData.FromString(text), ref td); - } - } - - // ** - [Test] public void Merge_RootSimple_NullString() { @@ -1220,7 +810,7 @@ public void Merge_RootArray_Complex() Assert.Multiple(() => { // No equality checker so will appear as changed - is a replacement. - Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"a\",\"text\":\"aa\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); + Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"a\",\"text\":\"aa\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.False); Assert.That(arr!, Has.Length.EqualTo(2)); // Replaced. @@ -1234,22 +824,7 @@ public void Merge_RootArray_Complex() public void Merge_RootDictionary_Simple() { var dict = new Dictionary(); - var jmp = new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - dict = new Dictionary(); - jmp = new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); + var jmp = new JsonMergePatch(); Assert.Multiple(() => { @@ -1268,7 +843,7 @@ public void Merge_RootDictionary_Simple() public void Merge_RootDictionary_Complex() { var dict = new Dictionary(); - var jmp = new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); + var jmp = new JsonMergePatch(); Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); Assert.Multiple(() => @@ -1291,34 +866,98 @@ public void Merge_RootDictionary_Complex() Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); Assert.That(dict!["y"].Text, Is.EqualTo("YY")); }); + } - // -- + [Test] + public void Merge_XLoadTest_1_1000() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) + { + var td = _testData; + jmp.Merge(BinaryData.FromString(_text), ref td); + } + } - dict = new Dictionary(); - jmp = new JsonMergePatch(new JsonMergePatchOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); + [Test] + public void Merge_XLoadTest_2_1000() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) + { + var td = _testData; + jmp.Merge(BinaryData.FromString(_text), ref td); + } + } - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); - Assert.Multiple(() => + [Test] + public void Merge_XLoadTest_1000_1_NoChangeCheck() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); + _ = jmp.Merge(_jtext, _jtestdata); + } + } - Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); - Assert.Multiple(() => + [Test] + public void Merge_XLoadTest_1000_1_WithChangeCheck() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) { + _ = jmp.TryMerge(_jtext, _jtestdata, out _); + } + } - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); - Assert.That(dict!["y"].Text, Is.EqualTo(null)); - }); + [Test] + public void Merge_XLoadTest_1000_2_NoChangeCheck() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) + { + _ = jmp.Merge(_jtext, _jtestdata); + } } + + [Test] + public void Merge_XLoadTest_1000_2_WithChangeCheck() + { + var jmp = new JsonMergePatch(); + for (int i = 0; i < 1000; i++) + { + _ = jmp.TryMerge(_jtext, _jtestdata, out _); + } + } + + [Test] + public async Task Merge_XLoadTest_WithRead_10000() + { + var jmp = new JsonMergePatch(); + + for (int i = 0; i < 10000; i++) + { + var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; + _ = await jmp.MergeWithResultAsync(new BinaryData(JsonMergePatchTest._text), async (v, _) => await Task.FromResult(v).ConfigureAwait(false)); + } + } + + [Test] + public void MergeInvalidMergeValueForType() + { + var jmp = new JsonMergePatch(); + var td = new TestData(); + var ex = Assert.Throws(() => jmp.Merge(BinaryData.FromString("""{ "id": { "value": "123" }}"""), ref td)); + Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Guid. Path: $.id | LineNumber: 0 | BytePositionInLine: 9.")); + } + + internal const string _text = "{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58, \"dict\": {\"k\":\"v\",\"k1\":\"v1\"}, " + + "\"values\": [ 1, 2, 4], \"sub\": { \"code\": \"abc\", \"text\": \"xyz\" }, \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, null, { } ], " + + "\"keys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ],\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"; + + internal static readonly TestData _testData = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; + + internal static readonly JsonElement _jtext = JsonDocument.Parse(_text).RootElement; + internal static readonly JsonElement _jtestdata = JsonDocument.Parse(JsonSerializer.Serialize(_testData)).RootElement; } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs index 107edc7f..cf8459fe 100644 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs +++ b/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs @@ -68,6 +68,8 @@ public void GetRequestOptions_Configured() [Test] public void GetRequestOptions_Configured_TokenPaging() { + PagingArgs.IsTokenSupported = true; + using var test = FunctionTester.Create(); var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); var ro = new HttpRequestOptions { ETag = "etag-value", IncludeText = true, IncludeInactive = true, UrlQueryString = "fruit=apples" }.WithPaging(PagingArgs.CreateTokenAndTake("token", 25, true)).Include("fielda", "fieldb").Exclude("fieldc"); @@ -95,5 +97,8 @@ public void GetRequestOptions_Configured_TokenPaging() Assert.That(wro.Paging.IsGetCount, Is.True); }); } + + [TearDown] + public void TearDown() => PagingArgs.IsTokenSupported = false; } } \ No newline at end of file diff --git a/tests/CoreEx.Test2/CoreEx.Test2.csproj b/tests/CoreEx.Test2/CoreEx.Test2.csproj index 8f369af1..54e6d529 100644 --- a/tests/CoreEx.Test2/CoreEx.Test2.csproj +++ b/tests/CoreEx.Test2/CoreEx.Test2.csproj @@ -12,15 +12,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/CoreEx.TestApi/CoreEx.TestApi.csproj b/tests/CoreEx.TestApi/CoreEx.TestApi.csproj index 4f725d2e..994c8466 100644 --- a/tests/CoreEx.TestApi/CoreEx.TestApi.csproj +++ b/tests/CoreEx.TestApi/CoreEx.TestApi.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 diff --git a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj index 50472ff7..8684ed09 100644 --- a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj +++ b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 v4 enable latest diff --git a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj b/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj index a7217415..7f01b965 100644 --- a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj +++ b/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj @@ -12,11 +12,11 @@ - + - +