refactor(application): consolidate query architecture with AutoMapper integration

- Add AutoMapper profile for CfgProfile and ProfileSqlJob entity-to-DTO mappings
- Consolidate GetProfileByIdQuery and GetAllActiveProfilesQuery into unified GetProfileQuery
- Implement flexible filtering with nullable query options (Id, Active, TypeId, ProfileName)
- Add IncludeSqlJobs option for optimized SQL job loading
- Move CfgProfileDto to Common/Dtos for better architecture alignment
- Add comprehensive unit tests for GetProfileQuery with multiple filter scenarios
- Move ISQLExecutor interface from Domain to Application layer
- Add GetByIdWithSqlJobsAsync and GetAllActiveWithSqlJobsAsync to repository
This commit is contained in:
2026-07-11 19:14:51 +02:00
parent 3f924b75b0
commit eaf24e05ee
15 changed files with 629 additions and 16 deletions

View File

@@ -1,9 +1,9 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;

View File

@@ -1,9 +1,9 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;

View File

@@ -1,7 +1,7 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using ReC.Client;

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
namespace ECMJobRunner.Application.Common.Dtos
{
/// <summary>
/// Data Transfer Object for CfgProfile entity
/// Used for querying and returning profile data
/// </summary>
public class CfgProfileDto
{
/// <summary>
/// Profile ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Profile name
/// </summary>
public string ProfileName { get; set; } = null!;
/// <summary>
/// Profile type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
public byte TypeId { get; set; }
/// <summary>
/// Schedule in Cron format
/// </summary>
public string Schedule { get; set; } = null!;
/// <summary>
/// Optional description
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// Created by
/// </summary>
public string AddedWho { get; set; } = null!;
/// <summary>
/// Created at
/// </summary>
public DateTime AddedWhen { get; set; }
/// <summary>
/// Modified by
/// </summary>
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
public DateTime? ChangedWhen { get; set; }
/// <summary>
/// SQL Jobs associated with this profile
/// </summary>
public List<ProfileSqlJobDto>? SqlJobs { get; set; }
}
/// <summary>
/// Data Transfer Object for ProfileSqlJob entity
/// </summary>
public class ProfileSqlJobDto
{
/// <summary>
/// SQL Job ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// Profile ID (foreign key)
/// </summary>
public long ProfileId { get; set; }
/// <summary>
/// Active / Inactive switch
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Execution sequence order
/// </summary>
public short Sequence { get; set; }
/// <summary>
/// Job name
/// </summary>
public string? Name { get; set; }
/// <summary>
/// SQL query for pre-check validation
/// </summary>
public string? SqlCheckQuery { get; set; }
/// <summary>
/// Main SQL query to execute
/// </summary>
public string? SqlMainQuery { get; set; }
/// <summary>
/// API command to execute
/// </summary>
public string? ApiCommand { get; set; }
/// <summary>
/// Optional description
/// </summary>
public string? Comment { get; set; }
}
}

View File

@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Domain.Interfaces
namespace ECMJobRunner.Application.Common.Interfaces
{
/// <summary>
/// Interface for executing SQL queries and mapping results to DTOs

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Application.Common.Mapping
{
/// <summary>
/// AutoMapper profile for CfgProfile and ProfileSqlJob mappings
/// Maps domain entities to DTOs
/// </summary>
public class ProfileMappingProfile : Profile
{
public ProfileMappingProfile()
{
// CfgProfile -> CfgProfileDto
CreateMap<CfgProfile, CfgProfileDto>()
.ForMember(dest => dest.SqlJobs, opt => opt.MapFrom(src => src.SqlJobs));
// ProfileSqlJob -> ProfileSqlJobDto
CreateMap<ProfileSqlJob, ProfileSqlJobDto>();
}
}
}

View File

@@ -0,0 +1,124 @@
using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Queries
{
/// <summary>
/// Query to retrieve profiles with flexible filtering options
/// All query options are nullable - when no filters are specified, returns all profiles
/// </summary>
public class GetProfileQuery : IRequest<List<CfgProfileDto>>
{
/// <summary>
/// Profile ID to retrieve (optional)
/// When specified, returns only the profile with this ID
/// </summary>
public long? Id { get; set; }
/// <summary>
/// Filter by active status (optional)
/// When null, returns both active and inactive profiles
/// When true, returns only active profiles
/// When false, returns only inactive profiles
/// </summary>
public bool? Active { get; set; }
/// <summary>
/// Filter by profile type (optional)
/// When specified, returns only profiles with this type
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
public byte? TypeId { get; set; }
/// <summary>
/// Filter by profile name (optional)
/// When specified, returns profiles with matching name (case-insensitive contains)
/// </summary>
public string? ProfileName { get; set; }
/// <summary>
/// Include associated SQL jobs in the result (optional)
/// Default: true
/// </summary>
public bool IncludeSqlJobs { get; set; } = true;
}
/// <summary>
/// Handler for GetProfileQuery
/// Retrieves profiles with optional filtering and uses AutoMapper for DTO mapping
/// </summary>
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, List<CfgProfileDto>>
{
private readonly ICfgProfileRepository _profileRepository;
private readonly IMapper _mapper;
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
{
_profileRepository = profileRepository;
_mapper = mapper;
}
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
{
IEnumerable<Domain.Entities.CfgProfile> profiles;
// If ID is specified, get single profile by ID
if (request.Id.HasValue)
{
var profile = request.IncludeSqlJobs
? await _profileRepository.GetByIdWithSqlJobsAsync(request.Id.Value, cancellationToken)
: await _profileRepository.GetByIdAsync(request.Id.Value, cancellationToken);
profiles = profile != null ? new[] { profile } : [];
}
// Otherwise, get profiles with filters
else
{
// Get all profiles with SQL jobs if requested
if (request.IncludeSqlJobs)
{
// If Active filter is specified and true, use optimized method
if (request.Active.HasValue && request.Active.Value)
{
profiles = await _profileRepository.GetAllActiveWithSqlJobsAsync(cancellationToken);
}
else
{
// Generic query with filters
profiles = await _profileRepository.FindAsync(p => true, cancellationToken);
}
}
else
{
profiles = await _profileRepository.GetAllAsync(cancellationToken);
}
// Apply filters
if (request.Active.HasValue)
{
profiles = profiles.Where(p => p.Active == request.Active.Value);
}
if (request.TypeId.HasValue)
{
profiles = profiles.Where(p => p.TypeId == request.TypeId.Value);
}
if (!string.IsNullOrWhiteSpace(request.ProfileName))
{
var searchName = request.ProfileName.ToLowerInvariant();
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
}
}
// Use AutoMapper to map entities to DTOs
return _mapper.Map<List<CfgProfileDto>>(profiles.ToList());
}
}
}

View File

@@ -1,4 +1,7 @@
using ECMJobRunner.Domain.Entities;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Domain.Interfaces
{
@@ -7,6 +10,19 @@ namespace ECMJobRunner.Domain.Interfaces
/// </summary>
public interface ICfgProfileRepository : IRepository<CfgProfile>
{
// Add custom CfgProfile-specific methods here if needed
/// <summary>
/// Gets a profile by ID with associated SQL jobs eagerly loaded
/// </summary>
/// <param name="id">Profile ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Profile with SQL jobs or null if not found</returns>
Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active profiles with associated SQL jobs eagerly loaded
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of active profiles with SQL jobs</returns>
Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
#endif
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using ECMJobRunner.Infrastructure.Repositories;

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
@@ -28,8 +29,8 @@
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- Dependency Injection for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">

View File

@@ -2,6 +2,15 @@ using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
namespace ECMJobRunner.Infrastructure.Repositories
{
@@ -17,6 +26,25 @@ namespace ECMJobRunner.Infrastructure.Repositories
{
}
// Entity-specific methods can be added here in the future
/// <summary>
/// Gets a profile by ID with associated SQL jobs eagerly loaded
/// </summary>
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
.Include(p => p.SqlJobs)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
/// <summary>
/// Gets all active profiles with associated SQL jobs eagerly loaded
/// </summary>
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
.Include(p => p.SqlJobs)
.Where(p => p.Active)
.ToListAsync(cancellationToken);
}
}
}

View File

@@ -4,7 +4,7 @@ using System.Linq;
#else
using Microsoft.EntityFrameworkCore;
#endif
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Threading;
using System.Threading.Tasks;

View File

@@ -2,9 +2,9 @@ using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;

View File

@@ -0,0 +1,302 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using FluentAssertions;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class GetProfileByIdQueryTests
{
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
private readonly GetProfileByIdQueryHandler _handler;
public GetProfileByIdQueryTests()
{
_mockProfileRepo = new Mock<ICfgProfileRepository>();
_handler = new GetProfileByIdQueryHandler(_mockProfileRepo.Object);
}
[Fact]
public async Task Handle_WithValidProfileId_ReturnsProfile()
{
// Arrange
var profileId = 123L;
var profile = new CfgProfile
{
Id = profileId,
Active = true,
ProfileName = "Test Profile",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
Comment = "Test comment",
AddedWho = "TestUser",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob
{
Id = 1,
ProfileId = profileId,
Active = true,
Sequence = 1,
Name = "Job 1",
SqlMainQuery = "SELECT 1",
SqlCheckQuery = "SELECT COUNT(*) FROM Table1"
}
}
};
_mockProfileRepo
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = true };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(profileId);
result.ProfileName.Should().Be("Test Profile");
result.Active.Should().BeTrue();
result.TypeId.Should().Be(2);
result.SqlJobs.Should().NotBeNull();
result.SqlJobs!.Count.Should().Be(1);
result.SqlJobs[0].Name.Should().Be("Job 1");
}
[Fact]
public async Task Handle_WithNonExistentProfileId_ReturnsNull()
{
// Arrange
var profileId = 999L;
_mockProfileRepo
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync((CfgProfile?)null);
var query = new GetProfileByIdQuery { ProfileId = profileId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
{
// Arrange
var profileId = 123L;
var profile = new CfgProfile
{
Id = profileId,
Active = true,
ProfileName = "Test Profile",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "TestUser",
AddedWhen = DateTime.Now
};
_mockProfileRepo
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.SqlJobs.Should().BeNull();
_mockProfileRepo.Verify(
r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()),
Times.Once);
_mockProfileRepo.Verify(
r => r.GetByIdWithSqlJobsAsync(It.IsAny<long>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_MapsAllProperties_Correctly()
{
// Arrange
var profileId = 123L;
var addedWhen = new DateTime(2026, 1, 1, 10, 0, 0);
var changedWhen = new DateTime(2026, 1, 2, 14, 30, 0);
var profile = new CfgProfile
{
Id = profileId,
Active = false,
ProfileName = "Inactive Profile",
TypeId = 3,
Schedule = "0 0 12 * * ?",
Comment = "Detailed comment",
AddedWho = "Admin",
AddedWhen = addedWhen,
ChangedWho = "Editor",
ChangedWhen = changedWhen
};
_mockProfileRepo
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
.ReturnsAsync(profile);
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(profileId);
result.Active.Should().BeFalse();
result.ProfileName.Should().Be("Inactive Profile");
result.TypeId.Should().Be(3);
result.Schedule.Should().Be("0 0 12 * * ?");
result.Comment.Should().Be("Detailed comment");
result.AddedWho.Should().Be("Admin");
result.AddedWhen.Should().Be(addedWhen);
result.ChangedWho.Should().Be("Editor");
result.ChangedWhen.Should().Be(changedWhen);
}
}
public class GetAllActiveProfilesQueryTests
{
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
private readonly GetAllActiveProfilesQueryHandler _handler;
public GetAllActiveProfilesQueryTests()
{
_mockProfileRepo = new Mock<ICfgProfileRepository>();
_handler = new GetAllActiveProfilesQueryHandler(_mockProfileRepo.Object);
}
[Fact]
public async Task Handle_ReturnsAllActiveProfiles()
{
// Arrange
var profiles = new List<CfgProfile>
{
new CfgProfile
{
Id = 1,
Active = true,
ProfileName = "Profile 1",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "User1",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob { Id = 1, ProfileId = 1, Sequence = 1, Name = "Job 1" }
}
},
new CfgProfile
{
Id = 2,
Active = true,
ProfileName = "Profile 2",
TypeId = 3,
Schedule = "0 0 12 * * ?",
AddedWho = "User2",
AddedWhen = DateTime.Now,
SqlJobs = new List<ProfileSqlJob>
{
new ProfileSqlJob { Id = 2, ProfileId = 2, Sequence = 1, Name = "Job 2" }
}
}
};
_mockProfileRepo
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(profiles);
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = true };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(2);
result[0].ProfileName.Should().Be("Profile 1");
result[1].ProfileName.Should().Be("Profile 2");
result[0].SqlJobs.Should().NotBeNull();
result[0].SqlJobs!.Count.Should().Be(1);
result[1].SqlJobs.Should().NotBeNull();
result[1].SqlJobs!.Count.Should().Be(1);
}
[Fact]
public async Task Handle_WithNoActiveProfiles_ReturnsEmptyList()
{
// Arrange
_mockProfileRepo
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CfgProfile>());
var query = new GetAllActiveProfilesQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
{
// Arrange
var profiles = new List<CfgProfile>
{
new CfgProfile
{
Id = 1,
Active = true,
ProfileName = "Profile 1",
TypeId = 2,
Schedule = "0 30 4 ? * MON-SAT",
AddedWho = "User1",
AddedWhen = DateTime.Now
}
};
_mockProfileRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(profiles);
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = false };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(1);
result[0].SqlJobs.Should().BeNull();
_mockProfileRepo.Verify(
r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()),
Times.Once);
_mockProfileRepo.Verify(
r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()),
Times.Never);
}
}
}

View File

@@ -2,9 +2,9 @@ using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;