Add Infrastructure layer with EF6/EF Core support

Introduced a robust Infrastructure layer for the ECMJobRunner system:
- Added `AGENTS.md` with detailed project documentation.
- Implemented generic repository and unit-of-work patterns.
- Added `CfgProfileRepository`, `ProfileSqlJobRepository`, and `ProfileHistoryRepository`.
- Integrated AutoMapper for DTO-to-entity mapping.
- Added multi-framework support for .NET Framework 4.8 (EF6) and .NET 8.0 (EF Core) using conditional compilation.
- Updated `ECMJobRunner.Infrastructure.csproj` with metadata fixes and dependencies.
- Introduced dependency injection extension for .NET 8.0.
- Enhanced project structure and database context with entity mappings.
This commit is contained in:
2026-07-09 13:35:02 +02:00
parent ff6faa1e5b
commit 7d53b599de
7 changed files with 525 additions and 3 deletions

View File

@@ -0,0 +1,205 @@
# ECMJobRunner.Infrastructure
## Project Overview
ECMJobRunner.Infrastructure is the **infrastructure layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
- **DbContext implementation** with Entity Framework
- **Repository implementations** for data access
- **Entity configurations** (EF mappings)
- **Database connection management**
**Key Principle**: This layer implements the interfaces defined in the Domain layer and handles all database concerns.
## Target Frameworks
- **.NET Framework 4.8** (`net480`) - Uses **Entity Framework 6.5.1**
- **.NET 8.0** (`net8.0`) - Uses **Entity Framework Core 8.0.11**
The project uses conditional compilation to support both EF6 and EF Core with the same codebase.
## Architecture
This project follows **Clean Architecture** principles:
- **Depends on Domain layer** (implements domain interfaces)
- **No dependencies from Domain** (dependency inversion)
- **Conditional compilation** for EF6 vs EF Core differences
- **Repository pattern** implementation
- **Unit of Work pattern** implementation
## Project Structure
```
ECMJobRunner.Infrastructure/
├── Data/
│ └── JobRunnerDbContext.cs # DbContext with conditional compilation
├── Repositories/
│ ├── Repository.cs # Generic repository implementation
│ ├── ProfileRepository.cs # Profile-specific repository
│ ├── ProfileSqlJobRepository.cs
│ ├── ProfileHistoryRepository.cs
│ └── UnitOfWork.cs # Unit of Work implementation
├── ECMJobRunner.Infrastructure.csproj
└── AGENTS.md # This file
```
## Database Context
### JobRunnerDbContext
Multi-targeted DbContext supporting both EF6 and EF Core:
**DbSets:**
- `Profiles`: DbSet<Profile>
- `ProfileSqlJobs`: DbSet<ProfileSqlJob>
- `ProfileHistories`: DbSet<ProfileHistory>
**Configuration:**
- Connection string via constructor parameter
- Conditional compilation directives (`#if NET48` / `#else`)
- Fluent API configurations in `OnModelCreating`
**Table Mappings:**
- `Profile``dbo.TBJR_CFG_PROFILE`
- `ProfileSqlJob``dbo.TBJR_CFG_PROFILE_SQLJOB`
- `ProfileHistory``dbo.TBJR_OUT_PROFILE_HISTORY`
**Column Mappings:** (Examples)
- `Profile.ProfileName``PROFILE_NAME`
- `ProfileSqlJob.SqlCheckQuery``SQL_CHECK_QUERY`
- All navigation properties configured with relationships
## Repository Implementations
### Repository<TEntity>
Generic repository implementing `IRepository<TEntity>` with full CRUD operations.
**Key Methods:**
- Synchronous: `GetById`, `GetAll`, `Find`, `SingleOrDefault`, `Add`, `Update`, `Remove`
- Asynchronous: `GetByIdAsync`, `GetAllAsync`, `FindAsync`, `SingleOrDefaultAsync`
### Entity-Specific Repositories
- `ProfileRepository : Repository<Profile>, IProfileRepository`
- `ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository`
- `ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository`
These can be extended with entity-specific query methods as needed.
### UnitOfWork
Implements `IUnitOfWork` interface:
- Manages DbContext lifecycle
- Provides repository instances
- Handles transaction management via `SaveChanges`/`SaveChangesAsync`
## Connection String
Default connection string (configured in consuming applications):
```
Server=SDD-VMP04-SQL17\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;
```
## Database Schema Source
SQL scripts and schema definitions are located at:
```
M:\Datenbank\[DD_ECM]-Database\JobRunner\
```
## Dependencies
### .NET Framework 4.8 (`net480`)
- **ECMJobRunner.Domain** (project reference)
- **EntityFramework 6.5.1** (NuGet package)
### .NET 8.0 (`net8.0`)
- **ECMJobRunner.Domain** (project reference)
- **Microsoft.EntityFrameworkCore 8.0.11** (NuGet package)
- **Microsoft.EntityFrameworkCore.SqlServer 8.0.11** (NuGet package)
## Conditional Compilation
The project uses `#if NET48` / `#else` directives to handle differences between EF6 and EF Core:
**EF6 (.NET Framework 4.8):**
```csharp
#if NET48
using System.Data.Entity;
public class JobRunnerDbContext : DbContext
#endif
```
**EF Core (.NET 8.0):**
```csharp
#if !NET48
using Microsoft.EntityFrameworkCore;
public class JobRunnerDbContext : DbContext
#endif
```
## Building the Project
```bash
dotnet build ECMJobRunner.Infrastructure.csproj
```
For specific framework:
```bash
dotnet build ECMJobRunner.Infrastructure.csproj -f net8.0
dotnet build ECMJobRunner.Infrastructure.csproj -f net480
```
## Entity Framework Differences
### DbContext Constructor
- **EF6**: Accepts connection string directly
- **EF Core**: Requires `DbContextOptions<T>`
### Configuration
- **EF6**: `DbModelBuilder` in `OnModelCreating`
- **EF Core**: `ModelBuilder` in `OnModelCreating`
### Querying
- **EF6**: `DbSet<T>.AsNoTracking()` extension
- **EF Core**: Same API, built-in support
### Async Operations
- **EF6**: Limited async support
- **EF Core**: Full async/await support
## Development Notes
- **Nullable navigation properties**: `IEnumerable<T>?` to support defensive programming
- **No lazy loading**: Navigation properties loaded explicitly via `.Include()`
- **Transaction management**: Handled by Unit of Work pattern
- **Repository pattern**: Encapsulates EF-specific code
- **Clean Architecture**: Infrastructure depends on Domain, not vice versa
## Usage Example
```csharp
// Create DbContext (connection string from configuration)
var connectionString = ConfigurationManager.ConnectionStrings["JobRunner"].ConnectionString;
var context = new JobRunnerDbContext(connectionString);
// Use Unit of Work
using var unitOfWork = new UnitOfWork(context);
// Query profiles
var activeProfiles = await unitOfWork.Profiles
.FindAsync(p => p.Active);
// Add new profile
var profile = new Profile { ProfileName = "Test", Active = true };
unitOfWork.Profiles.Add(profile);
await unitOfWork.SaveChangesAsync();
```
## Related Projects
- **ECMJobRunner.Domain**: Contains entities and repository interfaces
- **ECMJobRunner.Application**: Application layer consuming repositories
## Company Information
**Author**: Digital Data GmbH
**Copyright**: 2026
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git

View File

@@ -0,0 +1,41 @@
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
#endif
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using ECMJobRunner.Infrastructure.Repositories;
namespace ECMJobRunner.Infrastructure
{
#if !NET48
/// <summary>
/// Dependency injection extension methods for Infrastructure layer
/// </summary>
public static class DependencyExtension
{
/// <summary>
/// Add Infrastructure services to dependency injection container
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="connectionString">Database connection string</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
// Register DbContext
services.AddDbContext<JobRunnerDbContext>(options =>
options.UseSqlServer(connectionString));
// Register repositories
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
return services;
}
}
#endif
}

View File

@@ -4,18 +4,24 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>EECMJobRunner.Infrastructure</PackageId>
<PackageId>ECMJobRunner.Infrastructure</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.Domain</Product>
<Product>ECMJobRunner.Infrastructure</Product>
<Copyright>Copyright 2026</Copyright>
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
<PackageTags>digital data ecm job runner Infrastructure</PackageTags>
<PackageTags>digital data ecm job runner infrastructure</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- Entity Framework 6 for .NET Framework 4.8 -->
<PackageReference Include="EntityFramework" Version="6.5.1" />
<!-- AutoMapper for .NET Framework 4.8 -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
@@ -23,5 +29,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<!-- AutoMapper for .NET 8 -->
<PackageReference Include="AutoMapper" Version="13.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// CfgProfile repository implementation
/// </summary>
public class CfgProfileRepository : Repository<CfgProfile>, ICfgProfileRepository
{
/// <summary>
/// Constructor
/// </summary>
public CfgProfileRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
// Entity-specific methods can be added here in the future
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// ProfileHistory repository implementation
/// </summary>
public class ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository
{
/// <summary>
/// Constructor
/// </summary>
public ProfileHistoryRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
// Entity-specific methods can be added here in the future
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// ProfileSqlJob repository implementation
/// </summary>
public class ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository
{
/// <summary>
/// Constructor
/// </summary>
public ProfileSqlJobRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
{
}
// Entity-specific methods can be added here in the future
}
}

View File

@@ -0,0 +1,202 @@
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// Generic repository implementation for Entity Framework
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
{
protected readonly JobRunnerDbContext _context;
protected readonly DbSet<TEntity> _dbSet;
protected readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
public Repository(JobRunnerDbContext context, IMapper mapper)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_dbSet = context.Set<TEntity>();
}
/// <inheritdoc/>
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
#if NET48
return await Task.Run(() => _dbSet.Find(id), cancellationToken);
#else
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
#if NET48
return await Task.Run(() => _dbSet.AsNoTracking().ToList(), cancellationToken);
#else
return await _dbSet.AsNoTracking().ToListAsync(cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
#if NET48
return await Task.Run(() => _dbSet.Where(predicate).AsNoTracking().ToList(), cancellationToken);
#else
return await _dbSet.Where(predicate).AsNoTracking().ToListAsync(cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
#if NET48
return await Task.Run(() => _dbSet.SingleOrDefault(predicate), cancellationToken);
#else
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
// Map DTO to new entity
var entity = _mapper.Map<TEntity>(dto);
#if NET48
await Task.Run(() => _dbSet.Add(entity), cancellationToken);
#else
await _dbSet.AddAsync(entity, cancellationToken);
#endif
await SaveChangesAsync(cancellationToken);
return entity;
}
/// <inheritdoc/>
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
{
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
var dtoList = dtos.ToList();
if (!dtoList.Any())
return 0;
// Map DTOs to entities
var entities = _mapper.Map<List<TEntity>>(dtoList);
#if NET48
await Task.Run(() => _dbSet.AddRange(entities), cancellationToken);
#else
await _dbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
// Get entities with tracking enabled for update
#if NET48
var entities = await Task.Run(() => _dbSet.Where(predicate).ToList(), cancellationToken);
#else
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
#endif
if (!entities.Any())
return 0;
foreach (var entity in entities)
{
// Map DTO onto existing entity (only DTO properties are updated)
_mapper.Map(dto, entity);
}
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
// Get entity with tracking enabled for update
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
// Map DTO onto existing entity (only DTO properties are updated)
_mapper.Map(dto, entity);
await SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
// Get entities with tracking enabled for delete
#if NET48
var entities = await Task.Run(() => _dbSet.Where(predicate).ToList(), cancellationToken);
#else
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
#endif
if (!entities.Any())
return 0;
_dbSet.RemoveRange(entities);
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
// Get entity with tracking enabled for delete
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
_dbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
#if NET48
return await _context.SaveChangesAsync();
#else
return await _context.SaveChangesAsync(cancellationToken);
#endif
}
}
}