Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
8.2 KiB
TableSyncOperation Migration Design
Goal
Migrate TableSyncOperation from using IStagingTableManager with TableSpec metadata to using IBulkMergeHelper with expression-based IMergeConfiguration<T> classes. Remove old staging table code after migration.
Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Column metadata source | Expression lambdas | Compile-time safety, explicit configuration |
| Configuration style | Fluent IMergeConfiguration<T> classes |
Clean Architecture - keeps domain entities clean |
| Configuration location | DataSync project | Infrastructure concern, not domain |
| Mass update support | Add to BulkMergeHelper |
Unified bulk operations API |
| Index management | Built into BulkMergeHelper |
Simple API, matches current behavior |
| DataReader generation | Source generator (fix first) | Best long-term solution, block until working |
| Old code removal | Full removal | Clean break, no dead code |
| DI registration | Explicit per-entity | Visibility and control |
Component Design
1. IMergeConfiguration Interface
Location: src/JdeScoping.DataSync/Contracts/IMergeConfiguration.cs
public interface IMergeConfiguration<T> where T : class
{
/// <summary>Table name in SQL Server cache.</summary>
string TableName { get; }
/// <summary>Columns to match on (primary key).</summary>
Expression<Func<T, object>> MatchOn { get; }
/// <summary>Columns to update when matched. Null = all non-PK columns.</summary>
Expression<Func<T, object>>? UpdateColumns { get; }
/// <summary>Condition for when to update. Null = always update on match.</summary>
Expression<Func<T, T, bool>>? UpdateWhen { get; }
/// <summary>Columns to insert. Null = all columns.</summary>
Expression<Func<T, object>>? InsertColumns { get; }
}
2. Example Merge Configuration
Location: src/JdeScoping.DataSync/Configuration/MergeConfigurations/WorkOrderMergeConfiguration.cs
public class WorkOrderMergeConfiguration : IMergeConfiguration<WorkOrder>
{
public string TableName => "WorkOrder";
public Expression<Func<WorkOrder, object>> MatchOn =>
x => new { x.WorkOrderNumber, x.BranchCode };
public Expression<Func<WorkOrder, object>>? UpdateColumns =>
x => new { x.Status, x.Quantity, x.LastUpdateDt };
public Expression<Func<WorkOrder, WorkOrder, bool>>? UpdateWhen =>
(src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt;
public Expression<Func<WorkOrder, object>>? InsertColumns => null; // All columns
}
3. Extended IBulkMergeHelper
Added to: src/JdeScoping.DataSync/Contracts/IBulkMergeHelper.cs
/// <summary>
/// Performs a mass insert (full table refresh) with optional index management.
/// Truncates table, disables non-clustered indexes, bulk copies data, rebuilds indexes.
/// </summary>
Task<MassInsertResult> MassInsertAsync<T>(
IAsyncEnumerable<T> data,
string destinationTable,
bool rebuildIndexes = true,
int batchSize = 0,
CancellationToken cancellationToken = default) where T : class;
New result type:
public record MassInsertResult(
long TotalRowsInserted,
TimeSpan Elapsed,
bool IndexesRebuilt);
4. MergeConfigurationRegistry
Location: src/JdeScoping.DataSync/Services/MergeConfigurationRegistry.cs
public interface IMergeConfigurationRegistry
{
IMergeConfiguration<T> GetConfiguration<T>() where T : class;
bool HasConfiguration<T>() where T : class;
}
internal class MergeConfigurationRegistry : IMergeConfigurationRegistry
{
private readonly IServiceProvider _serviceProvider;
public MergeConfigurationRegistry(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IMergeConfiguration<T> GetConfiguration<T>() where T : class
{
return _serviceProvider.GetService<IMergeConfiguration<T>>()
?? throw new InvalidOperationException(
$"No merge configuration registered for {typeof(T).Name}");
}
public bool HasConfiguration<T>() where T : class
{
return _serviceProvider.GetService<IMergeConfiguration<T>>() != null;
}
}
5. Refactored TableSyncOperation
Key changes to src/JdeScoping.DataSync/Services/TableSyncOperation.cs:
- Remove
IStagingTableManagerdependency - Add
IBulkMergeHelperdependency - Add
IMergeConfigurationRegistrydependency - Remove
ILotFinderRepositorydependency (no longer needGetTableSpecAsync)
private async Task<long> ExecuteIncrementalUpdateAsync<T>(
IAsyncEnumerable<T> data,
DataUpdateTask task,
CancellationToken ct) where T : class
{
var config = _configRegistry.GetConfiguration<T>();
var result = await _bulkMergeHelper.MergeAsync(
data,
config.TableName,
config.MatchOn,
config.UpdateColumns,
config.UpdateWhen,
config.InsertColumns,
batchSize: _options.Value.BatchSize,
cancellationToken: ct);
return result.TotalRowsProcessed;
}
private async Task<long> ExecuteMassUpdateAsync<T>(
IAsyncEnumerable<T> data,
DataUpdateTask task,
CancellationToken ct) where T : class
{
var config = _configRegistry.GetConfiguration<T>();
var result = await _bulkMergeHelper.MassInsertAsync(
data,
config.TableName,
rebuildIndexes: task.ScheduleConfig.ReIndexData,
batchSize: _options.Value.BulkCopyBatchSize,
cancellationToken: ct);
return result.TotalRowsInserted;
}
6. DI Registration
In ServiceCollectionExtensions.cs:
// Merge configuration registry
services.AddSingleton<IMergeConfigurationRegistry, MergeConfigurationRegistry>();
// Merge configurations - explicit registration
services.AddSingleton<IMergeConfiguration<WorkOrder>, WorkOrderMergeConfiguration>();
services.AddSingleton<IMergeConfiguration<LotUsage>, LotUsageMergeConfiguration>();
services.AddSingleton<IMergeConfiguration<Item>, ItemMergeConfiguration>();
// ... one line per synced entity
Source Generator Fix
The generator at JdeScoping.DataSync.SourceGenerators must produce IDataReader implementations.
Verification steps:
- Confirm
.csprojreference hasOutputItemType="Analyzer"andReferenceOutputAssembly="false" - Add diagnostic logging to generator
- Ensure
[GenerateDataReader]attribute is accessible - Generator emits
{EntityName}DataReader : IDataReaderper attributed entity
Generated output pattern:
public sealed class WorkOrderDataReader : IDataReader
{
private readonly IAsyncEnumerator<WorkOrder> _enumerator;
private WorkOrder? _current;
private static readonly string[] ColumnNames =
["WorkOrderNumber", "BranchCode", "Status", "Quantity", "LastUpdateDt"];
public object GetValue(int i) => i switch
{
0 => _current!.WorkOrderNumber,
1 => _current!.BranchCode,
// ...
};
}
Code Removal (After Migration Verified)
Delete:
src/JdeScoping.DataSync/Contracts/IStagingTableManager.cssrc/JdeScoping.DataSync/Services/StagingTableManager.cstests/JdeScoping.DataSync.Tests/StagingTableManagerTests.cstests/JdeScoping.DataSync.IntegrationTests/StagingTableManagerTests.cs
Update:
- Remove
IStagingTableManager/StagingTableManagerfromServiceCollectionExtensions.cs - Remove
GetTableSpecAsyncusage from DataSync (if no longer needed elsewhere)
Testing Strategy
Unit tests - new:
MergeConfigurationRegistryTestsBulkMergeHelper.MassInsertAsynctests
Unit tests - update:
TableSyncOperationTests- update mocks
Integration tests:
BulkMergeHelperTests- add mass insert testsTableSyncOperationTests- end-to-end verification
Source generator tests:
- Verify generated
IDataReaderimplementations - Test with
SqlBulkCopy
Verification before old code removal:
- All existing tests pass
- Integration tests verify same behavior
- Manual smoke test if possible