diff --git a/backend/PokeData.sln b/backend/PokeData.sln index 12be20d..e2c058c 100644 --- a/backend/PokeData.sln +++ b/backend/PokeData.sln @@ -33,6 +33,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PokeData", "src\PokeData\Po EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PokeData.Contracts", "src\PokeData.Contracts\PokeData.Contracts.csproj", "{6F2213F6-F202-4C6E-B5DA-5957213C917E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PokeData.Seeding", "src\PokeData.Seeding\PokeData.Seeding.csproj", "{EA695551-C8E1-44D8-B9A4-8B4E779AA7FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +77,10 @@ Global {6F2213F6-F202-4C6E-B5DA-5957213C917E}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F2213F6-F202-4C6E-B5DA-5957213C917E}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F2213F6-F202-4C6E-B5DA-5957213C917E}.Release|Any CPU.Build.0 = Release|Any CPU + {EA695551-C8E1-44D8-B9A4-8B4E779AA7FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA695551-C8E1-44D8-B9A4-8B4E779AA7FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA695551-C8E1-44D8-B9A4-8B4E779AA7FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA695551-C8E1-44D8-B9A4-8B4E779AA7FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/backend/src/PokeData.Domain/Roster/IPokemonRosterRepository.cs b/backend/src/PokeData.Domain/Roster/IPokemonRosterRepository.cs index 35a7872..52fce31 100644 --- a/backend/src/PokeData.Domain/Roster/IPokemonRosterRepository.cs +++ b/backend/src/PokeData.Domain/Roster/IPokemonRosterRepository.cs @@ -2,6 +2,7 @@ public interface IPokemonRosterRepository { + Task DeleteAllAsync(CancellationToken cancellationToken = default); Task DeleteAsync(ushort speciesId, CancellationToken cancellationToken = default); Task SaveAsync(PokemonRoster roster, CancellationToken cancellationToken = default); } diff --git a/backend/src/PokeData.Domain/Species/IPokemonSpeciesRepository.cs b/backend/src/PokeData.Domain/Species/IPokemonSpeciesRepository.cs index 68de4d9..412a793 100644 --- a/backend/src/PokeData.Domain/Species/IPokemonSpeciesRepository.cs +++ b/backend/src/PokeData.Domain/Species/IPokemonSpeciesRepository.cs @@ -3,4 +3,5 @@ public interface IPokemonSpeciesRepository { Task LoadAsync(ushort id, CancellationToken cancellationToken = default); + Task LoadAsync(string name, CancellationToken cancellationToken = default); } diff --git a/backend/src/PokeData.ETL/Program.cs b/backend/src/PokeData.ETL/Program.cs index a8001ae..c65d5a8 100644 --- a/backend/src/PokeData.ETL/Program.cs +++ b/backend/src/PokeData.ETL/Program.cs @@ -3,13 +3,12 @@ namespace PokeData.ETL; -public class Program +internal class Program { public static void Main(string[] args) { HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Services.AddHostedService(); - builder.Services.AddMemoryCache(); builder.Services.AddPokeDataWithEntityFrameworkCoreSqlServer(builder.Configuration); builder.Services.AddPokeDataWithPokeApiClient(); diff --git a/backend/src/PokeData.ETL/Worker.cs b/backend/src/PokeData.ETL/Worker.cs index 9a2d0bc..c1dc7b6 100644 --- a/backend/src/PokeData.ETL/Worker.cs +++ b/backend/src/PokeData.ETL/Worker.cs @@ -37,6 +37,6 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) } chrono.Stop(); - _logger.LogInformation("Operation completed in {elapsed}ms.", chrono.ElapsedMilliseconds); + _logger.LogInformation("Operation completed in {Elapsed}ms.", chrono.ElapsedMilliseconds); } } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Db.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Db.cs deleted file mode 100644 index 0563633..0000000 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Db.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Logitar.Data; -using PokeData.EntityFrameworkCore.Relational.Entities; - -namespace PokeData.EntityFrameworkCore.Relational; - -internal static class Db -{ - public static class PokemonTypes - { - public static readonly TableId Table = new(nameof(PokemonContext.PokemonTypes)); - - public static readonly ColumnId DisplayName = new(nameof(PokemonTypeEntity.DisplayName), Table); - public static readonly ColumnId PokemonTypeId = new(nameof(PokemonTypeEntity.PokemonTypeId), Table); - public static readonly ColumnId UniqueName = new(nameof(PokemonTypeEntity.UniqueName), Table); - public static readonly ColumnId UniqueNameNormalized = new(nameof(PokemonTypeEntity.UniqueNameNormalized), Table); - } - - public static class Regions - { - public static readonly TableId Table = new(nameof(PokemonContext.Regions)); - - public static readonly ColumnId DisplayName = new(nameof(RegionEntity.DisplayName), Table); - public static readonly ColumnId RegionId = new(nameof(RegionEntity.RegionId), Table); - public static readonly ColumnId UniqueName = new(nameof(RegionEntity.UniqueName), Table); - public static readonly ColumnId UniqueNameNormalized = new(nameof(RegionEntity.UniqueNameNormalized), Table); - } -} diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/AggregateEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/AggregateEntity.cs index 22c9e6b..9f01d0d 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/AggregateEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/AggregateEntity.cs @@ -27,7 +27,7 @@ protected AggregateEntity(DomainEvent @event) Update(@event); } - public IEnumerable GetActorIds() => new ActorId[] { new(CreatedBy), new(UpdatedBy) }; + public IEnumerable GetActorIds() => [new(CreatedBy), new(UpdatedBy)]; protected void Update(DomainEvent @event) { diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/GenerationEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/GenerationEntity.cs index b8c1532..753e309 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/GenerationEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/GenerationEntity.cs @@ -10,7 +10,7 @@ internal class GenerationEntity : AggregateEntity public string UniqueName { get; private set; } = string.Empty; public string UniqueNameNormalized { - get => UniqueName.ToUpper(); + get => PokemonDb.Normalize(UniqueName); private set { } } public string? DisplayName { get; private set; } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RosterEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonRosterEntity.cs similarity index 100% rename from backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RosterEntity.cs rename to backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonRosterEntity.cs diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonSpeciesEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonSpeciesEntity.cs index 68095ce..a13c599 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonSpeciesEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonSpeciesEntity.cs @@ -21,7 +21,7 @@ internal class PokemonSpeciesEntity : AggregateEntity public string UniqueName { get; private set; } = string.Empty; public string UniqueNameNormalized { - get => UniqueName.ToUpper(); + get => PokemonDb.Normalize(UniqueName); private set { } } public string? DisplayName { get; private set; } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonTypeEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonTypeEntity.cs index 3b32275..35a1aba 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonTypeEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonTypeEntity.cs @@ -10,7 +10,7 @@ internal class PokemonTypeEntity : AggregateEntity public string UniqueName { get; private set; } = string.Empty; public string UniqueNameNormalized { - get => UniqueName.ToUpper(); + get => PokemonDb.Normalize(UniqueName); private set { } } public string? DisplayName { get; private set; } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonVarietyEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonVarietyEntity.cs index 1a52a3d..99cba49 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonVarietyEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/PokemonVarietyEntity.cs @@ -12,7 +12,7 @@ internal class PokemonVarietyEntity : AggregateEntity public string UniqueName { get; private set; } = string.Empty; public string UniqueNameNormalized { - get => UniqueName.ToUpper(); + get => PokemonDb.Normalize(UniqueName); private set { } } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RegionEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RegionEntity.cs index 5c11b9f..55959e3 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RegionEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/RegionEntity.cs @@ -10,7 +10,7 @@ internal class RegionEntity : AggregateEntity public string UniqueName { get; private set; } = string.Empty; public string UniqueNameNormalized { - get => UniqueName.ToUpper(); + get => PokemonDb.Normalize(UniqueName); private set { } } public string? DisplayName { get; private set; } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/ResourceEntity.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/ResourceEntity.cs index 2c86b7d..0c7dfbb 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/ResourceEntity.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Entities/ResourceEntity.cs @@ -10,7 +10,7 @@ internal class ResourceEntity : AggregateEntity public string Source { get; private set; } = string.Empty; public string SourceNormalized { - get => Source.ToUpper(); + get => PokemonDb.Normalize(Source); private set { } } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/ISqlHelper.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/ISqlHelper.cs index 1f5d047..4516eae 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/ISqlHelper.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/ISqlHelper.cs @@ -6,5 +6,6 @@ namespace PokeData.EntityFrameworkCore.Relational; public interface ISqlHelper { IQueryBuilder ApplyTextSearch(IQueryBuilder builder, TextSearch search, params ColumnId[] columns); + IDeleteBuilder DeleteFrom(TableId table); IQueryBuilder QueryFrom(TableId table); } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/PokemonDb.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/PokemonDb.cs new file mode 100644 index 0000000..c525b79 --- /dev/null +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/PokemonDb.cs @@ -0,0 +1,64 @@ +using Logitar.Data; +using PokeData.EntityFrameworkCore.Relational.Entities; + +namespace PokeData.EntityFrameworkCore.Relational; + +internal static class PokemonDb +{ + public static class PokemonRoster + { + public static readonly TableId Table = new(nameof(PokemonContext.PokemonRoster)); + + public static readonly ColumnId AggregateId = new(nameof(PokemonRosterEntity.AggregateId), Table); + public static readonly ColumnId Category = new(nameof(PokemonRosterEntity.Category), Table); + public static readonly ColumnId CreatedBy = new(nameof(PokemonRosterEntity.CreatedBy), Table); + public static readonly ColumnId CreatedOn = new(nameof(PokemonRosterEntity.CreatedOn), Table); + public static readonly ColumnId IsBaby = new(nameof(PokemonRosterEntity.IsBaby), Table); + public static readonly ColumnId IsLegendary = new(nameof(PokemonRosterEntity.IsLegendary), Table); + public static readonly ColumnId IsMythical = new(nameof(PokemonRosterEntity.IsMythical), Table); + public static readonly ColumnId Name = new(nameof(PokemonRosterEntity.Name), Table); + public static readonly ColumnId Number = new(nameof(PokemonRosterEntity.Number), Table); + public static readonly ColumnId PokemonSpeciesId = new(nameof(PokemonRosterEntity.PokemonSpeciesId), Table); + public static readonly ColumnId PrimaryTypeId = new(nameof(PokemonRosterEntity.PrimaryTypeId), Table); + public static readonly ColumnId RegionId = new(nameof(PokemonRosterEntity.RegionId), Table); + public static readonly ColumnId SecondaryTypeId = new(nameof(PokemonRosterEntity.SecondaryTypeId), Table); + public static readonly ColumnId UpdatedBy = new(nameof(PokemonRosterEntity.UpdatedBy), Table); + public static readonly ColumnId UpdatedOn = new(nameof(PokemonRosterEntity.UpdatedOn), Table); + public static readonly ColumnId Version = new(nameof(PokemonRosterEntity.Version), Table); + } + + public static class PokemonTypes + { + public static readonly TableId Table = new(nameof(PokemonContext.PokemonTypes)); + + public static readonly ColumnId AggregateId = new(nameof(PokemonTypeEntity.AggregateId), Table); + public static readonly ColumnId CreatedBy = new(nameof(PokemonTypeEntity.CreatedBy), Table); + public static readonly ColumnId CreatedOn = new(nameof(PokemonTypeEntity.CreatedOn), Table); + public static readonly ColumnId DisplayName = new(nameof(PokemonTypeEntity.DisplayName), Table); + public static readonly ColumnId PokemonTypeId = new(nameof(PokemonTypeEntity.PokemonTypeId), Table); + public static readonly ColumnId UniqueName = new(nameof(PokemonTypeEntity.UniqueName), Table); + public static readonly ColumnId UniqueNameNormalized = new(nameof(PokemonTypeEntity.UniqueNameNormalized), Table); + public static readonly ColumnId UpdatedBy = new(nameof(PokemonTypeEntity.UpdatedBy), Table); + public static readonly ColumnId UpdatedOn = new(nameof(PokemonTypeEntity.UpdatedOn), Table); + public static readonly ColumnId Version = new(nameof(PokemonTypeEntity.Version), Table); + } + + public static class Regions + { + public static readonly TableId Table = new(nameof(PokemonContext.Regions)); + + public static readonly ColumnId AggregateId = new(nameof(RegionEntity.AggregateId), Table); + public static readonly ColumnId CreatedBy = new(nameof(RegionEntity.CreatedBy), Table); + public static readonly ColumnId CreatedOn = new(nameof(RegionEntity.CreatedOn), Table); + public static readonly ColumnId DisplayName = new(nameof(RegionEntity.DisplayName), Table); + public static readonly ColumnId MainGenerationId = new(nameof(RegionEntity.MainGenerationId), Table); + public static readonly ColumnId RegionId = new(nameof(RegionEntity.RegionId), Table); + public static readonly ColumnId UniqueName = new(nameof(RegionEntity.UniqueName), Table); + public static readonly ColumnId UniqueNameNormalized = new(nameof(RegionEntity.UniqueNameNormalized), Table); + public static readonly ColumnId UpdatedBy = new(nameof(RegionEntity.UpdatedBy), Table); + public static readonly ColumnId UpdatedOn = new(nameof(RegionEntity.UpdatedOn), Table); + public static readonly ColumnId Version = new(nameof(RegionEntity.Version), Table); + } + + public static string Normalize(string value) => value.Trim().ToUpper(); +} diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/PokemonTypeQuerier.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/PokemonTypeQuerier.cs index 053af51..697bb03 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/PokemonTypeQuerier.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/PokemonTypeQuerier.cs @@ -33,7 +33,7 @@ public PokemonTypeQuerier(IActorService actorService, PokemonContext context, IS public async Task ReadAsync(string uniqueName, CancellationToken cancellationToken) { - string uniqueNameNormalized = uniqueName.Trim().ToUpper(); + string uniqueNameNormalized = PokemonDb.Normalize(uniqueName); PokemonTypeEntity? region = await _regions.AsNoTracking() .SingleOrDefaultAsync(x => x.UniqueNameNormalized == uniqueNameNormalized, cancellationToken); @@ -43,13 +43,13 @@ public PokemonTypeQuerier(IActorService actorService, PokemonContext context, IS public async Task> SearchAsync(SearchPokemonTypesPayload payload, CancellationToken cancellationToken) { - IQueryBuilder builder = _sqlHelper.QueryFrom(Db.PokemonTypes.Table) - .SelectAll(Db.PokemonTypes.Table); - _sqlHelper.ApplyTextSearch(builder, payload.Search, Db.PokemonTypes.UniqueName, Db.PokemonTypes.DisplayName); + IQueryBuilder builder = _sqlHelper.QueryFrom(PokemonDb.PokemonTypes.Table) + .SelectAll(PokemonDb.PokemonTypes.Table); + _sqlHelper.ApplyTextSearch(builder, payload.Search, PokemonDb.PokemonTypes.UniqueName, PokemonDb.PokemonTypes.DisplayName); if (payload.NumberIn.Count > 1) { - builder.Where(Db.PokemonTypes.PokemonTypeId, Operators.IsIn(payload.NumberIn)); + builder.Where(PokemonDb.PokemonTypes.PokemonTypeId, Operators.IsIn(payload.NumberIn)); } IQueryable query = _regions.FromQuery(builder).AsNoTracking(); diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/RegionQuerier.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/RegionQuerier.cs index f0edbac..b416f3d 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/RegionQuerier.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Queriers/RegionQuerier.cs @@ -33,7 +33,7 @@ public RegionQuerier(IActorService actorService, PokemonContext context, ISqlHel public async Task ReadAsync(string uniqueName, CancellationToken cancellationToken) { - string uniqueNameNormalized = uniqueName.Trim().ToUpper(); + string uniqueNameNormalized = PokemonDb.Normalize(uniqueName); RegionEntity? region = await _regions.AsNoTracking() .SingleOrDefaultAsync(x => x.UniqueNameNormalized == uniqueNameNormalized, cancellationToken); @@ -43,13 +43,13 @@ public RegionQuerier(IActorService actorService, PokemonContext context, ISqlHel public async Task> SearchAsync(SearchRegionsPayload payload, CancellationToken cancellationToken) { - IQueryBuilder builder = _sqlHelper.QueryFrom(Db.Regions.Table) - .SelectAll(Db.Regions.Table); - _sqlHelper.ApplyTextSearch(builder, payload.Search, Db.Regions.UniqueName, Db.Regions.DisplayName); + IQueryBuilder builder = _sqlHelper.QueryFrom(PokemonDb.Regions.Table) + .SelectAll(PokemonDb.Regions.Table); + _sqlHelper.ApplyTextSearch(builder, payload.Search, PokemonDb.Regions.UniqueName, PokemonDb.Regions.DisplayName); if (payload.NumberIn.Count > 1) { - builder.Where(Db.Regions.RegionId, Operators.IsIn(payload.NumberIn)); + builder.Where(PokemonDb.Regions.RegionId, Operators.IsIn(payload.NumberIn)); } IQueryable query = _regions.FromQuery(builder) diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonRosterRepository.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonRosterRepository.cs index 5cdf626..b90c65e 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonRosterRepository.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonRosterRepository.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using Logitar.Data; +using Microsoft.EntityFrameworkCore; using PokeData.Domain.Roster; using PokeData.EntityFrameworkCore.Relational.Entities; @@ -7,10 +8,18 @@ namespace PokeData.EntityFrameworkCore.Relational.Repositories; internal class PokemonRosterRepository : IPokemonRosterRepository { private readonly PokemonContext _context; + private readonly ISqlHelper _sqlHelper; - public PokemonRosterRepository(PokemonContext context) + public PokemonRosterRepository(PokemonContext context, ISqlHelper sqlHelper) { _context = context; + _sqlHelper = sqlHelper; + } + + public async Task DeleteAllAsync(CancellationToken cancellationToken) + { + ICommand command = _sqlHelper.DeleteFrom(PokemonDb.PokemonRoster.Table).Build(); + return await _context.Database.ExecuteSqlRawAsync(command.Text, command.Parameters.ToArray()); } public async Task DeleteAsync(ushort speciesId, CancellationToken cancellationToken) diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonSpeciesRepository.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonSpeciesRepository.cs index 952a9ef..c51744a 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonSpeciesRepository.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonSpeciesRepository.cs @@ -23,4 +23,17 @@ public PokemonSpeciesRepository(PokemonContext context) return species == null ? null : DomainMapper.ToPokemonSpecies(species); } + + public async Task LoadAsync(string name, CancellationToken cancellationToken) + { + string uniqueNameNormalized = PokemonDb.Normalize(name); + + PokemonSpeciesEntity? species = await _context.PokemonSpecies.AsNoTracking() + .Include(x => x.Generation).ThenInclude(x => x!.MainRegion) + .Include(x => x.Varieties).ThenInclude(x => x.PrimaryType) + .Include(x => x.Varieties).ThenInclude(x => x.SecondaryType) + .SingleOrDefaultAsync(x => x.UniqueNameNormalized == uniqueNameNormalized, cancellationToken); + + return species == null ? null : DomainMapper.ToPokemonSpecies(species); + } } diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonTypeRepository.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonTypeRepository.cs index e7641d3..4068da1 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonTypeRepository.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/PokemonTypeRepository.cs @@ -26,7 +26,7 @@ public PokemonTypeRepository(PokemonContext context) if (type == null) { - string uniqueNameNormalized = idOrUniqueName.Trim().ToUpper(); + string uniqueNameNormalized = PokemonDb.Normalize(idOrUniqueName); type = await _context.PokemonTypes.AsNoTracking() .SingleOrDefaultAsync(x => x.UniqueNameNormalized == uniqueNameNormalized, cancellationToken); diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/RegionRepository.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/RegionRepository.cs index 8fbb646..50979c0 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/RegionRepository.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/Repositories/RegionRepository.cs @@ -26,7 +26,7 @@ public RegionRepository(PokemonContext context) if (region == null) { - string uniqueNameNormalized = idOrUniqueName.Trim().ToUpper(); + string uniqueNameNormalized = PokemonDb.Normalize(idOrUniqueName); region = await _context.Regions.AsNoTracking() .Include(x => x.MainGeneration) diff --git a/backend/src/PokeData.EntityFrameworkCore.Relational/SqlHelper.cs b/backend/src/PokeData.EntityFrameworkCore.Relational/SqlHelper.cs index 1e5f53d..b356574 100644 --- a/backend/src/PokeData.EntityFrameworkCore.Relational/SqlHelper.cs +++ b/backend/src/PokeData.EntityFrameworkCore.Relational/SqlHelper.cs @@ -40,5 +40,7 @@ public IQueryBuilder ApplyTextSearch(IQueryBuilder builder, TextSearch search, p } protected virtual ConditionalOperator CreateOperator(string pattern) => Operators.IsLike(pattern); + public abstract IDeleteBuilder DeleteFrom(TableId table); + public abstract IQueryBuilder QueryFrom(TableId table); } diff --git a/backend/src/PokeData.EntityFrameworkCore.SqlServer/SqlServerHelper.cs b/backend/src/PokeData.EntityFrameworkCore.SqlServer/SqlServerHelper.cs index c14bb19..6d04246 100644 --- a/backend/src/PokeData.EntityFrameworkCore.SqlServer/SqlServerHelper.cs +++ b/backend/src/PokeData.EntityFrameworkCore.SqlServer/SqlServerHelper.cs @@ -6,5 +6,7 @@ namespace PokeData.EntityFrameworkCore.SqlServer; internal class SqlServerHelper : SqlHelper { + public override IDeleteBuilder DeleteFrom(TableId table) => SqlServerDeleteBuilder.From(table); + public override IQueryBuilder QueryFrom(TableId table) => SqlServerQueryBuilder.From(table); } diff --git a/backend/src/PokeData.Infrastructure/DependencyInjectionExtensions.cs b/backend/src/PokeData.Infrastructure/DependencyInjectionExtensions.cs index 738f93d..a9068de 100644 --- a/backend/src/PokeData.Infrastructure/DependencyInjectionExtensions.cs +++ b/backend/src/PokeData.Infrastructure/DependencyInjectionExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection AddPokeDataInfrastructure(this IServiceCollecti return services .AddLogitarEventSourcingInfrastructure() .AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())) + .AddMemoryCache() .AddPokeDataApplication() .AddSingleton(serviceProvider => { diff --git a/backend/src/PokeData.Infrastructure/PokeData.Infrastructure.csproj b/backend/src/PokeData.Infrastructure/PokeData.Infrastructure.csproj index 40cecb1..36e3f76 100644 --- a/backend/src/PokeData.Infrastructure/PokeData.Infrastructure.csproj +++ b/backend/src/PokeData.Infrastructure/PokeData.Infrastructure.csproj @@ -9,7 +9,7 @@ - + diff --git a/backend/src/PokeData.Seeding/Commands/SeedRegionsCommand.cs b/backend/src/PokeData.Seeding/Commands/SeedRegionsCommand.cs new file mode 100644 index 0000000..c09898e --- /dev/null +++ b/backend/src/PokeData.Seeding/Commands/SeedRegionsCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace PokeData.Seeding.Commands; + +internal record SeedRegionsCommand(string Path, Encoding Encoding) : INotification; diff --git a/backend/src/PokeData.Seeding/Commands/SeedRegionsCommandHandler.cs b/backend/src/PokeData.Seeding/Commands/SeedRegionsCommandHandler.cs new file mode 100644 index 0000000..c5cc60d --- /dev/null +++ b/backend/src/PokeData.Seeding/Commands/SeedRegionsCommandHandler.cs @@ -0,0 +1,39 @@ +using CsvHelper; +using Logitar; +using MediatR; +using PokeData.Contracts.Regions; +using PokeData.Seeding.Models; + +namespace PokeData.Seeding.Commands; + +internal class SeedRegionsCommandHandler : INotificationHandler +{ + private readonly ILogger _logger; + private readonly IRegionService _regionService; + + public SeedRegionsCommandHandler(ILogger logger, IRegionService regionService) + { + _logger = logger; + _regionService = regionService; + } + + public async Task Handle(SeedRegionsCommand command, CancellationToken cancellationToken) + { + using StreamReader reader = new(command.Path, command.Encoding); + using CsvReader csv = new(reader, CultureInfo.InvariantCulture); + + RegionData[] records = csv.GetRecords().ToArray(); + _logger.LogInformation("Found {Count} regions to seed.", records.Length); + + foreach (RegionData record in records) + { + SaveRegionPayload payload = new() + { + UniqueName = record.UniqueName.Trim(), + DisplayName = record.DisplayName?.CleanTrim() + }; + Region region = await _regionService.SaveAsync(record.Number, payload, cancellationToken); + _logger.LogInformation("Seeded {Region}.", region); + } + } +} diff --git a/backend/src/PokeData.Seeding/Commands/SeedRosterCommand.cs b/backend/src/PokeData.Seeding/Commands/SeedRosterCommand.cs new file mode 100644 index 0000000..275d43b --- /dev/null +++ b/backend/src/PokeData.Seeding/Commands/SeedRosterCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace PokeData.Seeding.Commands; + +internal record SeedRosterCommand(string Path, Encoding Encoding) : INotification; diff --git a/backend/src/PokeData.Seeding/Commands/SeedRosterCommandHandler.cs b/backend/src/PokeData.Seeding/Commands/SeedRosterCommandHandler.cs new file mode 100644 index 0000000..8c7a3dc --- /dev/null +++ b/backend/src/PokeData.Seeding/Commands/SeedRosterCommandHandler.cs @@ -0,0 +1,78 @@ +using CsvHelper; +using MediatR; +using PokeData.Contracts.Roster; +using PokeData.Domain.Roster; +using PokeData.Domain.Species; +using PokeData.Seeding.Models; + +namespace PokeData.Seeding.Commands; + +internal class SeedRosterCommandHandler : INotificationHandler +{ + private readonly ILogger _logger; + private readonly IPokemonRosterRepository _pokemonRosterRepository; + private readonly IPokemonRosterService _pokemonRosterService; + private readonly IPokemonSpeciesRepository _pokemonSpeciesRepository; + private readonly bool _resetRoster; + + public SeedRosterCommandHandler(IConfiguration configuration, ILogger logger, + IPokemonRosterRepository pokemonRosterRepository, IPokemonRosterService pokemonRosterService, IPokemonSpeciesRepository pokemonSpeciesRepository) + { + _logger = logger; + _pokemonRosterRepository = pokemonRosterRepository; + _pokemonRosterService = pokemonRosterService; + _pokemonSpeciesRepository = pokemonSpeciesRepository; + _resetRoster = configuration.GetValue("ResetRoster"); + } + + public async Task Handle(SeedRosterCommand command, CancellationToken cancellationToken) + { + if (_resetRoster) + { + int deleted = await _pokemonRosterRepository.DeleteAllAsync(cancellationToken); + _logger.LogInformation("Deleted {Count} Pokémon roster items.", deleted); + } + + string[] paths = Directory.GetFiles(command.Path, "*.csv"); + _logger.LogInformation("Found {Count} roster files to seed.", paths.Length); + + foreach (string path in paths) + { + using StreamReader reader = new(path, command.Encoding); + using CsvReader csv = new(reader, CultureInfo.InvariantCulture); + + RosterItemData[] records = csv.GetRecords().ToArray(); + _logger.LogInformation("Found {Count} roster items to seed.", records.Length); + + foreach (RosterItemData record in records) + { + PokemonSpecies? species = null; + if (record.Number.HasValue) + { + species = await _pokemonSpeciesRepository.LoadAsync(record.Number.Value, cancellationToken); + } + else if (!string.IsNullOrWhiteSpace(record.Name)) + { + species = await _pokemonSpeciesRepository.LoadAsync(record.Name.Trim(), cancellationToken); + } + + if (species == null) + { + _logger.LogWarning("The Pokémon species for seed #{Id} could not be found.", record.SpeciesId); + continue; + } + + try + { + SaveRosterItemPayload payload = record.ToRosterItemPayload(species); + await _pokemonRosterService.SaveItemAsync(record.SpeciesId, payload, cancellationToken); + _logger.LogInformation("Seeded Pokémon #{Id}.", record.SpeciesId); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "The Pokémon roster #{Id} could not be seeded.", record.SpeciesId); + } + } + } + } +} diff --git a/backend/src/PokeData.Seeding/Data/Regions.csv b/backend/src/PokeData.Seeding/Data/Regions.csv new file mode 100644 index 0000000..54d8afd --- /dev/null +++ b/backend/src/PokeData.Seeding/Data/Regions.csv @@ -0,0 +1,3 @@ +Number,UniqueName,DisplayName +9,hisui,Hisui +99,quebec,Québec diff --git a/backend/src/PokeData.Seeding/Data/Roster/Starters.csv b/backend/src/PokeData.Seeding/Data/Roster/Starters.csv new file mode 100644 index 0000000..bb70608 --- /dev/null +++ b/backend/src/PokeData.Seeding/Data/Roster/Starters.csv @@ -0,0 +1,10 @@ +SpeciesId,Number,Name,Category,Region,PrimaryType,SecondaryType,IsBaby,IsLegendary,IsMythical +1,,Rowlet,,,,,,, +2,,Dartrix,,,,,,, +3,,Decidueye,,Hisui,,Fighting,,, +4,,Cyndaquil,,,,,,, +5,,Quilava,,,,,,, +6,,Typhlosion,,Hisui,,Ghost,,, +7,,Oshawott,,,,,,, +8,,Dewott,,,,,,, +9,,Samurott,,Hisui,,Dark,,, diff --git a/backend/src/PokeData.Seeding/Dockerfile b/backend/src/PokeData.Seeding/Dockerfile new file mode 100644 index 0000000..4e4ea14 --- /dev/null +++ b/backend/src/PokeData.Seeding/Dockerfile @@ -0,0 +1,23 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +USER app +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["src/PokeData.Seeding/PokeData.Seeding.csproj", "src/PokeData.Seeding/"] +RUN dotnet restore "./src/PokeData.Seeding/PokeData.Seeding.csproj" +COPY . . +WORKDIR "/src/src/PokeData.Seeding" +RUN dotnet build "./PokeData.Seeding.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./PokeData.Seeding.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PokeData.Seeding.dll"] \ No newline at end of file diff --git a/backend/src/PokeData.Seeding/Models/RegionData.cs b/backend/src/PokeData.Seeding/Models/RegionData.cs new file mode 100644 index 0000000..3eafe9b --- /dev/null +++ b/backend/src/PokeData.Seeding/Models/RegionData.cs @@ -0,0 +1,15 @@ +using CsvHelper.Configuration.Attributes; + +namespace PokeData.Seeding.Models; + +public record RegionData +{ + [Index(0)] + public byte Number { get; set; } + + [Index(1)] + public string UniqueName { get; set; } = string.Empty; + + [Index(2)] + public string? DisplayName { get; set; } +} diff --git a/backend/src/PokeData.Seeding/Models/RosterItemData.cs b/backend/src/PokeData.Seeding/Models/RosterItemData.cs new file mode 100644 index 0000000..ea63037 --- /dev/null +++ b/backend/src/PokeData.Seeding/Models/RosterItemData.cs @@ -0,0 +1,81 @@ +using CsvHelper.Configuration.Attributes; +using Logitar; +using PokeData.Contracts.Roster; +using PokeData.Domain.Species; + +namespace PokeData.Seeding.Models; + +internal record RosterItemData +{ + [Index(0)] + public ushort SpeciesId { get; set; } + + [Index(1)] + public ushort? Number { get; set; } + + [Index(2)] + public string? Name { get; set; } + + [Index(3)] + public string? Category { get; set; } + + [Index(4)] + public string? Region { get; set; } + + [Index(5)] + public string? PrimaryType { get; set; } + + [Index(6)] + public string? SecondaryType { get; set; } + + [Index(7)] + public bool? IsBaby { get; set; } + + [Index(8)] + public bool? IsLegendary { get; set; } + + [Index(9)] + public bool? IsMythical { get; set; } + + public SaveRosterItemPayload ToRosterItemPayload(PokemonSpecies species) + { + if (species.Generation == null) + { + throw new ArgumentException($"The '{nameof(species.Generation)}' is required.", nameof(species)); + } + else if (species.Generation.MainRegion == null) + { + throw new ArgumentException($"The '{nameof(species.Generation)}.{nameof(species.Generation.MainRegion)}' is required.", nameof(species)); + } + + PokemonVariety[] varieties = species.Varieties.Where(v => v.IsDefault).ToArray(); + if (varieties.Length == 0) + { + throw new ArgumentException($"The Pokémon species #{species.Number:D4} has no default variety.", nameof(species)); + } + else if (varieties.Length > 1) + { + throw new ArgumentException($"The Pokémon species #{species.Number:D4} has multiple default varieties.", nameof(species)); + } + PokemonVariety variety = varieties.Single(); + if (variety.PrimaryType == null) + { + throw new ArgumentException($"The Pokémon species #{species.Number:D4} default variety has no primary type.", nameof(species)); + } + + return new SaveRosterItemPayload + { + Number = Number ?? species.Number, + Name = Name?.CleanTrim() ?? species.DisplayName ?? species.UniqueName, + Category = IsNull(Category) ? null : (Category?.CleanTrim() ?? species.Category), + Region = Region?.CleanTrim() ?? species.Generation.MainRegion.UniqueName, + PrimaryType = PrimaryType?.CleanTrim() ?? variety.PrimaryType.UniqueName, + SecondaryType = IsNull(SecondaryType) ? null : (SecondaryType?.CleanTrim() ?? variety.SecondaryType?.UniqueName), + IsBaby = IsBaby ?? species.IsBaby, + IsLegendary = IsLegendary ?? species.IsLegendary, + IsMythical = IsMythical ?? species.IsMythical + }; + } + + private static bool IsNull(string? value) => value?.Trim().Equals("", StringComparison.InvariantCultureIgnoreCase) == true; +} diff --git a/backend/src/PokeData.Seeding/PokeData.Seeding.csproj b/backend/src/PokeData.Seeding/PokeData.Seeding.csproj new file mode 100644 index 0000000..78a854b --- /dev/null +++ b/backend/src/PokeData.Seeding/PokeData.Seeding.csproj @@ -0,0 +1,51 @@ + + + + net8.0 + enable + enable + dotnet-PokeData.Seeding-91fcde88-eaaf-49a7-b264-7645c4d0df84 + Linux + ..\.. + + + + True + + + + True + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/PokeData.Seeding/Program.cs b/backend/src/PokeData.Seeding/Program.cs new file mode 100644 index 0000000..3859594 --- /dev/null +++ b/backend/src/PokeData.Seeding/Program.cs @@ -0,0 +1,19 @@ +using PokeData.EntityFrameworkCore.SqlServer; +using PokeData.Infrastructure.PokeApiClient; // TODO(fpion): remove this dependency + +namespace PokeData.Seeding; + +internal class Program +{ + public static void Main(string[] args) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + builder.Services.AddHostedService(); + builder.Services.AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + builder.Services.AddPokeDataWithEntityFrameworkCoreSqlServer(builder.Configuration); + builder.Services.AddPokeDataWithPokeApiClient(); + + IHost host = builder.Build(); + host.Run(); + } +} diff --git a/backend/src/PokeData.Seeding/Properties/launchSettings.json b/backend/src/PokeData.Seeding/Properties/launchSettings.json new file mode 100644 index 0000000..99ff8ed --- /dev/null +++ b/backend/src/PokeData.Seeding/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "PokeData.Seeding": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "Container (Dockerfile)": { + "commandName": "Docker" + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/backend/src/PokeData.Seeding/Worker.cs b/backend/src/PokeData.Seeding/Worker.cs new file mode 100644 index 0000000..33f29af --- /dev/null +++ b/backend/src/PokeData.Seeding/Worker.cs @@ -0,0 +1,39 @@ +using MediatR; +using PokeData.Seeding.Commands; + +namespace PokeData.Seeding; + +internal class Worker : BackgroundService +{ + private readonly Encoding _encoding; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public Worker(IConfiguration configuration, ILogger logger, IServiceProvider serviceProvider) + { + _encoding = Encoding.GetEncoding(configuration.GetValue("Encoding") ?? string.Empty); + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + Stopwatch chrono = Stopwatch.StartNew(); + + try + { + using IServiceScope scope = _serviceProvider.CreateScope(); + IPublisher publisher = scope.ServiceProvider.GetRequiredService(); + + await publisher.Publish(new SeedRegionsCommand("Data/Regions.csv", _encoding), cancellationToken); + await publisher.Publish(new SeedRosterCommand("Data/Roster", _encoding), cancellationToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "An unhandled exception has occurred."); + } + + chrono.Stop(); + _logger.LogInformation("Operation completed in {Elapsed}ms.", chrono.ElapsedMilliseconds); + } +} diff --git a/backend/src/PokeData.Seeding/appsettings.Development.json b/backend/src/PokeData.Seeding/appsettings.Development.json new file mode 100644 index 0000000..e4de9a6 --- /dev/null +++ b/backend/src/PokeData.Seeding/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ResetRoster": true, + "SQLCONNSTR_Pokemon": "Server=host.docker.internal,27945;Database=Pokemon;User Id=SA;Password=m7tPnE6dB5TQxYCW;Persist Security Info=False;Encrypt=False;" +} diff --git a/backend/src/PokeData.Seeding/appsettings.json b/backend/src/PokeData.Seeding/appsettings.json new file mode 100644 index 0000000..31d0a2d --- /dev/null +++ b/backend/src/PokeData.Seeding/appsettings.json @@ -0,0 +1,9 @@ +{ + "Encoding": "UTF-8", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/backend/src/PokeData/Program.cs b/backend/src/PokeData/Program.cs index d85bd1c..f1b78d6 100644 --- a/backend/src/PokeData/Program.cs +++ b/backend/src/PokeData/Program.cs @@ -3,7 +3,7 @@ namespace PokeData; -public class Program +internal class Program { public static async Task Main(string[] args) { diff --git a/backend/src/PokeData/Startup.cs b/backend/src/PokeData/Startup.cs index 278cb9f..9f1a899 100644 --- a/backend/src/PokeData/Startup.cs +++ b/backend/src/PokeData/Startup.cs @@ -35,7 +35,6 @@ public override void ConfigureServices(IServiceCollection services) services.AddCors(corsSettings); services.AddApplicationInsightsTelemetry(); - services.AddMemoryCache(); IHealthChecksBuilder healthChecks = services.AddHealthChecks(); if (_enableOpenApi) @@ -43,7 +42,6 @@ public override void ConfigureServices(IServiceCollection services) services.AddOpenApi(); } - services.AddMemoryCache(); services.AddPokeDataWithPokeApiClient(); DatabaseProvider databaseProvider = _configuration.GetValue("DatabaseProvider")