diff --git a/src/SanctionsSearch.Worker.Tests/Fakes/SdnFaker.cs b/src/SanctionsSearch.Worker.Tests/Fakes/SdnFaker.cs new file mode 100644 index 0000000..cc0b7b6 --- /dev/null +++ b/src/SanctionsSearch.Worker.Tests/Fakes/SdnFaker.cs @@ -0,0 +1,22 @@ +using Bogus; + +namespace SanctionsSearch.Worker.Tests.Fakes; + +class SdnFaker : Faker +{ + public SdnFaker() + { + RuleFor(x => x.Id, f => f.Random.Int(1, int.MaxValue)); + RuleFor(x => x.Name, f => f.Person.FullName); + RuleFor(x => x.Type, f => f.Lorem.Word()); + RuleFor(x => x.Program, f => f.Lorem.Word()); + RuleFor(x => x.Title, f => f.Lorem.Word()); + RuleFor(x => x.CallSign, f => f.Lorem.Word()); + RuleFor(x => x.VesselType, f => f.Lorem.Word()); + RuleFor(x => x.Tonnage, f => f.Lorem.Word()); + RuleFor(x => x.GrossRegisteredTonnage, f => f.Lorem.Word()); + RuleFor(x => x.VesselFlag, f => f.Lorem.Word()); + RuleFor(x => x.VesselOwner, f => f.Lorem.Word()); + RuleFor(x => x.Remarks, f => f.Lorem.Word()); + } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/Integration/EfRepositoryTests.cs b/src/SanctionsSearch.Worker.Tests/Integration/EfRepositoryTests.cs new file mode 100644 index 0000000..8e005f9 --- /dev/null +++ b/src/SanctionsSearch.Worker.Tests/Integration/EfRepositoryTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +using SanctionsSearch.Worker.Tests.Fakes; + +namespace SanctionsSearch.Worker.Tests.Integration; + +public class EfRepositoryTests : IAsyncLifetime +{ + private readonly AppDbContext _context; + private readonly EfRepository _sdnRepository; + private readonly SdnFaker _sdnFaker = new(); + + public EfRepositoryTests() + { + var loggerFactory = LoggerFactory.Create(builder => builder.ClearProviders()); + + _context = new AppDbContext(new DbOptions { DatabaseName = $"{Guid.NewGuid()}.db" }); + _sdnRepository = new EfRepository(_context, loggerFactory.CreateLogger>()); + } + + [Fact] + public async Task Upsert_WithNewEntity_ShouldAddEntityToDatabase() + { + var sdn = _sdnFaker.Generate(); + + await _sdnRepository.Upsert(sdn); + await _context.SaveChangesAsync(); + + var result = await _context.Sdns.FindAsync(sdn.Id); + + result.Should().BeEquivalentTo(sdn); + result!.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Upsert_WithExistingEntity_ShouldUpdateEntityInDatabase() + { + var sdn = _sdnFaker.Generate(); + + await _sdnRepository.Upsert(sdn); + await _context.SaveChangesAsync(); + + var createdSdn = await _context.Sdns.FindAsync(sdn.Id); + + createdSdn.Should().BeEquivalentTo(sdn); + createdSdn!.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + createdSdn.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + + var updatedSdn = _sdnFaker.Generate(); + updatedSdn.Id = sdn.Id; + + await _sdnRepository.Upsert(updatedSdn); + await _context.SaveChangesAsync(); + + var result = await _context.Sdns.FindAsync(sdn.Id); + + result.Should().BeEquivalentTo(updatedSdn); + result!.CreatedAt.Should().BeSameDateAs(createdSdn.CreatedAt); + result.UpdatedAt.Should().BeAfter(createdSdn.UpdatedAt); + } + + public async Task InitializeAsync() + { + await _context.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + + SqliteConnection.ClearAllPools(); + + File.Delete(_context.DatabasePath); + } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker.Tests/SanctionsSearch.Worker.Tests.csproj b/src/SanctionsSearch.Worker.Tests/SanctionsSearch.Worker.Tests.csproj index b9f0b6b..7817dc0 100644 --- a/src/SanctionsSearch.Worker.Tests/SanctionsSearch.Worker.Tests.csproj +++ b/src/SanctionsSearch.Worker.Tests/SanctionsSearch.Worker.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/SanctionsSearch.Worker.Tests/Usings.cs b/src/SanctionsSearch.Worker.Tests/Usings.cs index 05ea043..21226f5 100644 --- a/src/SanctionsSearch.Worker.Tests/Usings.cs +++ b/src/SanctionsSearch.Worker.Tests/Usings.cs @@ -8,4 +8,6 @@ global using Moq; global using RichardSzalay.MockHttp; global using SanctionsSearch.Worker.Services; -global using SanctionsSearch.Worker.Options; \ No newline at end of file +global using SanctionsSearch.Worker.Options; +global using SanctionsSearch.Worker.Models; +global using SanctionsSearch.Worker.Persistence; \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Interfaces/IRepository.cs b/src/SanctionsSearch.Worker/Interfaces/IRepository.cs new file mode 100644 index 0000000..36ce61b --- /dev/null +++ b/src/SanctionsSearch.Worker/Interfaces/IRepository.cs @@ -0,0 +1,7 @@ +namespace SanctionsSearch.Worker.Interfaces; + +interface IRepository where T : Entity +{ + Task Upsert(T entity); + Task> Find(Expression> predicate); +} diff --git a/src/SanctionsSearch.Worker/Interfaces/ISdnRepository.cs b/src/SanctionsSearch.Worker/Interfaces/ISdnRepository.cs new file mode 100644 index 0000000..a64d046 --- /dev/null +++ b/src/SanctionsSearch.Worker/Interfaces/ISdnRepository.cs @@ -0,0 +1,5 @@ +namespace SanctionsSearch.Worker.Interfaces; + +interface ISdnRepository : IRepository +{ +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Interfaces/IUnitOfWork.cs b/src/SanctionsSearch.Worker/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..6a34797 --- /dev/null +++ b/src/SanctionsSearch.Worker/Interfaces/IUnitOfWork.cs @@ -0,0 +1,7 @@ +namespace SanctionsSearch.Worker.Interfaces; + +interface IUnitOfWork +{ + ISdnRepository Sdns { get; } + Task SaveChangesAsync(); +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.Designer.cs b/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.Designer.cs new file mode 100644 index 0000000..45ea13a --- /dev/null +++ b/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SanctionsSearch.Worker.Persistence; + +#nullable disable + +namespace SanctionsSearch.Worker.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20240821035135_update_models_with_timestamps")] + partial class update_models_with_timestamps + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CityProvincePostal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Country") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Remarks") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SdnId") + .HasColumnType("INTEGER"); + + b.Property("StreetAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SdnId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Alias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Remarks") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SdnId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SdnId"); + + b.ToTable("Aliases"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Remarks") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SdnId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SdnId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Sdn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CallSign") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GrossRegisteredTonnage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Program") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Remarks") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tonnage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VesselFlag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VesselOwner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VesselType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sdns"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Address", b => + { + b.HasOne("SanctionsSearch.Worker.Models.Sdn", "Sdn") + .WithMany() + .HasForeignKey("SdnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sdn"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Alias", b => + { + b.HasOne("SanctionsSearch.Worker.Models.Sdn", "Sdn") + .WithMany("Aliases") + .HasForeignKey("SdnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sdn"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Comment", b => + { + b.HasOne("SanctionsSearch.Worker.Models.Sdn", "Sdn") + .WithMany() + .HasForeignKey("SdnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sdn"); + }); + + modelBuilder.Entity("SanctionsSearch.Worker.Models.Sdn", b => + { + b.Navigation("Aliases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.cs b/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.cs new file mode 100644 index 0000000..b44c782 --- /dev/null +++ b/src/SanctionsSearch.Worker/Migrations/20240821035135_update_models_with_timestamps.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SanctionsSearch.Worker.Migrations +{ + /// + public partial class update_models_with_timestamps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Sdns", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Sdns", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Comments", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Comments", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Aliases", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Aliases", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Addresses", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Addresses", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Sdns"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Sdns"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Comments"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Comments"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Aliases"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Aliases"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Addresses"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Addresses"); + } + } +} diff --git a/src/SanctionsSearch.Worker/Migrations/AppDbContextModelSnapshot.cs b/src/SanctionsSearch.Worker/Migrations/AppDbContextModelSnapshot.cs index 7c079ac..7b61329 100644 --- a/src/SanctionsSearch.Worker/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SanctionsSearch.Worker/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -30,6 +31,9 @@ namespace SanctionsSearch.Worker.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + b.Property("Remarks") .IsRequired() .HasColumnType("TEXT"); @@ -41,6 +45,9 @@ namespace SanctionsSearch.Worker.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("SdnId"); @@ -54,6 +61,9 @@ namespace SanctionsSearch.Worker.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT"); @@ -69,6 +79,9 @@ namespace SanctionsSearch.Worker.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("SdnId"); @@ -82,6 +95,9 @@ namespace SanctionsSearch.Worker.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + b.Property("Remarks") .IsRequired() .HasColumnType("TEXT"); @@ -89,6 +105,9 @@ namespace SanctionsSearch.Worker.Migrations b.Property("SdnId") .HasColumnType("INTEGER"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("SdnId"); @@ -106,6 +125,9 @@ namespace SanctionsSearch.Worker.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + b.Property("GrossRegisteredTonnage") .IsRequired() .HasColumnType("TEXT"); @@ -134,6 +156,9 @@ namespace SanctionsSearch.Worker.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + b.Property("VesselFlag") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/SanctionsSearch.Worker/Models/Address.cs b/src/SanctionsSearch.Worker/Models/Address.cs index d4d9acc..b7f3f0c 100644 --- a/src/SanctionsSearch.Worker/Models/Address.cs +++ b/src/SanctionsSearch.Worker/Models/Address.cs @@ -1,9 +1,8 @@ namespace SanctionsSearch.Worker.Models; -class Address +class Address : Entity { public int SdnId { get; set; } - public int Id { get; set; } public string StreetAddress { get; set; } = string.Empty; public string CityProvincePostal { get; set; } = string.Empty; public string Country { get; set; } = string.Empty; diff --git a/src/SanctionsSearch.Worker/Models/Alias.cs b/src/SanctionsSearch.Worker/Models/Alias.cs index 848d427..7ce8af1 100644 --- a/src/SanctionsSearch.Worker/Models/Alias.cs +++ b/src/SanctionsSearch.Worker/Models/Alias.cs @@ -1,9 +1,8 @@ namespace SanctionsSearch.Worker.Models; -class Alias +class Alias : Entity { public int SdnId { get; set; } - public int Id { get; set; } public string Type { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Remarks { get; set; } = string.Empty; diff --git a/src/SanctionsSearch.Worker/Models/Comment.cs b/src/SanctionsSearch.Worker/Models/Comment.cs index b811d5d..9772e22 100644 --- a/src/SanctionsSearch.Worker/Models/Comment.cs +++ b/src/SanctionsSearch.Worker/Models/Comment.cs @@ -1,9 +1,8 @@ namespace SanctionsSearch.Worker.Models; -class Comment +class Comment : Entity { public int SdnId { get; set; } - public int Id { get; set; } public string Remarks { get; set; } = string.Empty; public virtual Sdn Sdn { get; set; } = default!; diff --git a/src/SanctionsSearch.Worker/Models/Entity.cs b/src/SanctionsSearch.Worker/Models/Entity.cs new file mode 100644 index 0000000..460037b --- /dev/null +++ b/src/SanctionsSearch.Worker/Models/Entity.cs @@ -0,0 +1,8 @@ +namespace SanctionsSearch.Worker.Models; + +abstract class Entity +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Models/Sdn.cs b/src/SanctionsSearch.Worker/Models/Sdn.cs index 187380f..8499e3d 100644 --- a/src/SanctionsSearch.Worker/Models/Sdn.cs +++ b/src/SanctionsSearch.Worker/Models/Sdn.cs @@ -1,8 +1,7 @@ namespace SanctionsSearch.Worker.Models; -class Sdn +class Sdn : Entity { - public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Program { get; set; } = string.Empty; diff --git a/src/SanctionsSearch.Worker/Persistence/AppDbContext.cs b/src/SanctionsSearch.Worker/Persistence/AppDbContext.cs index ac53569..8db2a0c 100644 --- a/src/SanctionsSearch.Worker/Persistence/AppDbContext.cs +++ b/src/SanctionsSearch.Worker/Persistence/AppDbContext.cs @@ -1,13 +1,10 @@ -using System.Reflection; - -using Microsoft.Data.Sqlite; - namespace SanctionsSearch.Worker.Persistence; -class AppDbContext(IOptionsSnapshot options) : DbContext, IDisposable +class AppDbContext(DbOptions options) : DbContext { - private readonly IOptionsSnapshot _options = options; + private readonly DbOptions _options = options; private SqliteConnection? _connection; + internal string DatabasePath { get; private set; } = string.Empty; public DbSet Sdns { get; set; } = default!; public DbSet
Addresses { get; set; } = default!; public DbSet Aliases { get; set; } = default!; @@ -24,7 +21,8 @@ class AppDbContext(IOptionsSnapshot options) : DbContext, IDisposable Directory.CreateDirectory(dataDir); } - var dbPath = Path.Combine(dataDir, _options.Value.DatabaseName); + var dbPath = Path.Combine(dataDir, _options.DatabaseName); + DatabasePath = dbPath; _connection = new SqliteConnection($"Data Source={dbPath}"); _connection.Open(); @@ -32,9 +30,40 @@ class AppDbContext(IOptionsSnapshot options) : DbContext, IDisposable optionsBuilder.UseSqlite(_connection); } - public override void Dispose() + public async override Task SaveChangesAsync(CancellationToken cancellationToken = default) { - _connection?.Dispose(); - base.Dispose(); + // TODO: Use TimeProvider instead of DateTime.UtcNow + var now = DateTime.UtcNow; + + foreach (var changedEntity in ChangeTracker.Entries()) + { + if (changedEntity.Entity is Entity entity) + { + switch (changedEntity.State) + { + case EntityState.Added: + entity.CreatedAt = now; + entity.UpdatedAt = now; + break; + + case EntityState.Modified: + Entry(entity).Property(x => x.CreatedAt).IsModified = false; + entity.UpdatedAt = now; + break; + } + } + } + + return await base.SaveChangesAsync(cancellationToken); + } + + public async override ValueTask DisposeAsync() + { + if (_connection is not null) + { + await _connection.DisposeAsync(); + } + + await base.DisposeAsync(); } } \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Persistence/EfRepository.cs b/src/SanctionsSearch.Worker/Persistence/EfRepository.cs new file mode 100644 index 0000000..92c920d --- /dev/null +++ b/src/SanctionsSearch.Worker/Persistence/EfRepository.cs @@ -0,0 +1,46 @@ +namespace SanctionsSearch.Worker.Persistence; + +class EfRepository : IRepository where T : Entity +{ + protected readonly AppDbContext _context; + protected readonly ILogger> _logger; + + public EfRepository(AppDbContext context, ILogger> logger) + { + _context = context; + _logger = logger; + } + + public async Task> Find(Expression> predicate) + { + try + { + return await _context.Set().Where(predicate).ToListAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding entities of type {Type}", typeof(T).Name); + return []; + } + } + + public async Task Upsert(T entity) + { + try + { + var existing = await _context.Set().FindAsync(entity.Id); + + if (existing is null) + { + await _context.Set().AddAsync(entity); + return; + } + + _context.Entry(existing).CurrentValues.SetValues(entity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error upserting entity of type {Type} with {Id}", typeof(T).Name, entity.Id); + } + } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs b/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs new file mode 100644 index 0000000..9c372f4 --- /dev/null +++ b/src/SanctionsSearch.Worker/Persistence/EfUnitOfWork.cs @@ -0,0 +1,17 @@ +namespace SanctionsSearch.Worker.Persistence; + +class EfUnitOfWork(AppDbContext context, ILoggerFactory loggerFactory) : IUnitOfWork, IAsyncDisposable +{ + private readonly AppDbContext _context = context; + public ISdnRepository Sdns { get; } = new SdnRepository(context, loggerFactory.CreateLogger()); + + public async Task SaveChangesAsync() + { + await _context.SaveChangesAsync(); + } + + public async ValueTask DisposeAsync() + { + await _context.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Persistence/SdnRepository.cs b/src/SanctionsSearch.Worker/Persistence/SdnRepository.cs new file mode 100644 index 0000000..8eef5a3 --- /dev/null +++ b/src/SanctionsSearch.Worker/Persistence/SdnRepository.cs @@ -0,0 +1,8 @@ +namespace SanctionsSearch.Worker.Persistence; + +class SdnRepository( + AppDbContext dbContext, + ILogger logger +) : EfRepository(dbContext, logger), ISdnRepository +{ +} \ No newline at end of file diff --git a/src/SanctionsSearch.Worker/Program.cs b/src/SanctionsSearch.Worker/Program.cs index 9161995..d437dec 100644 --- a/src/SanctionsSearch.Worker/Program.cs +++ b/src/SanctionsSearch.Worker/Program.cs @@ -31,6 +31,7 @@ try builder.Services.ConfigureOptions(); builder.Services.ConfigureOptions(); + builder.Services.AddScoped(rs => rs.GetRequiredService>().Value); builder.Services.AddDbContext(); builder.Services.AddHostedService(); diff --git a/src/SanctionsSearch.Worker/Usings.cs b/src/SanctionsSearch.Worker/Usings.cs index dc795a2..ecd75f9 100644 --- a/src/SanctionsSearch.Worker/Usings.cs +++ b/src/SanctionsSearch.Worker/Usings.cs @@ -9,4 +9,10 @@ global using Serilog; global using Serilog.Exceptions; global using Serilog.Formatting.Compact; -global using Microsoft.EntityFrameworkCore; \ No newline at end of file +global using Microsoft.EntityFrameworkCore; + +global using System.Linq.Expressions; + +global using System.Reflection; + +global using Microsoft.Data.Sqlite; \ No newline at end of file