BaseRepository takes an ILogger via its constructor and logs the failing file path and exception in Save's catch block before rethrowing. Every concrete repository takes ILogger<ThisRepo> and passes it up so failures are categorised by repository type. Pulls Microsoft.Extensions.Logging.Abstractions into the Db project. TransactionRepositoryTestShould and AccountRepositoryTestShould pass NullLogger<T>.Instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
125 lines
No EOL
4.4 KiB
C#
125 lines
No EOL
4.4 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Xml;
|
|
using System.Xml.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Schaad.Accounting.Interfaces;
|
|
|
|
namespace Schaad.Accounting.Repositories
|
|
{
|
|
public abstract class BaseRepository
|
|
{
|
|
// One lock per absolute file path so concurrent Save+Save and Save+Load are serialized
|
|
// and can't observe a half-written file.
|
|
private static readonly ConcurrentDictionary<string, object> FileLocks =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
protected readonly ISettingsService settingsService;
|
|
protected readonly RepositoryCache cache;
|
|
protected readonly ILogger logger;
|
|
|
|
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger)
|
|
{
|
|
this.settingsService = settingsService;
|
|
this.cache = cache;
|
|
this.logger = logger;
|
|
}
|
|
|
|
protected void EnsureFileExists(string fileName)
|
|
{
|
|
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
|
lock (GetLock(filePath))
|
|
{
|
|
if (File.Exists(filePath) == false)
|
|
{
|
|
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
|
if (File.Exists(lastYearFile))
|
|
{
|
|
File.Copy(lastYearFile, filePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load a list, cached per scope. First call reads the XML file; subsequent calls in
|
|
/// the same scope return the cached list. <see cref="Save"/> invalidates the entry.
|
|
/// </summary>
|
|
protected List<T> LoadList<T>(string fileName)
|
|
{
|
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
|
return cache.GetOrLoad(filePath, () => Load<List<T>>(fileName) ?? new List<T>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save an object to an xml file. Writes to a .tmp sibling and then atomically
|
|
/// renames it, so a crash mid-write leaves the previous file intact. Invalidates
|
|
/// the corresponding cache entry.
|
|
/// </summary>
|
|
protected void Save<T>(T obj, string fileName)
|
|
{
|
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
|
var tmpPath = filePath + ".tmp";
|
|
|
|
lock (GetLock(filePath))
|
|
{
|
|
var settings = new XmlWriterSettings
|
|
{
|
|
Encoding = Encoding.UTF8,
|
|
Indent = true
|
|
};
|
|
|
|
try
|
|
{
|
|
using (var writer = XmlWriter.Create(tmpPath, settings))
|
|
{
|
|
var serializer = new XmlSerializer(typeof(T));
|
|
serializer.Serialize(writer, obj);
|
|
}
|
|
|
|
File.Move(tmpPath, filePath, overwrite: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to save {FilePath}", filePath);
|
|
if (File.Exists(tmpPath))
|
|
{
|
|
try { File.Delete(tmpPath); } catch { /* best effort */ }
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
cache.Invalidate(filePath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load an object from an xml file (uncached; used for one-off reads such as file
|
|
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads.
|
|
/// </summary>
|
|
protected T Load<T>(string fileName)
|
|
{
|
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
|
|
|
lock (GetLock(filePath))
|
|
{
|
|
if (File.Exists(filePath) == false)
|
|
{
|
|
return default(T);
|
|
}
|
|
|
|
using (XmlReader reader = XmlReader.Create(filePath))
|
|
{
|
|
var serializer = new XmlSerializer(typeof(T));
|
|
return (T)serializer.Deserialize(reader);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static object GetLock(string filePath)
|
|
=> FileLocks.GetOrAdd(filePath, _ => new object());
|
|
}
|
|
} |