| | | 1 | | using ArturRios.Data.Export.Interfaces; |
| | | 2 | | using ArturRios.Output; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | |
| | | 5 | | namespace ArturRios.Data.Export.Exporters; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Base for exporters: handles null-guarding, envelope conversion, cancellation propagation, and |
| | | 9 | | /// file-stream lifetime. Concrete exporters implement <see cref="WriteCoreAsync" />. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <typeparam name="T">The record type.</typeparam> |
| | | 12 | | /// <param name="logger"> |
| | | 13 | | /// Optional logger. Envelopes carry no exception text, so a write failure is otherwise |
| | | 14 | | /// undiagnosable: supply a logger and the full exception, plus the destination path for file |
| | | 15 | | /// writes, is written at <see cref="LogLevel.Error" />. Record contents are never logged. |
| | | 16 | | /// Resolved from DI when logging is registered. |
| | | 17 | | /// </param> |
| | 108 | 18 | | public abstract class ExporterBase<T>(ILogger? logger = null) : IExporter<T> where T : class |
| | | 19 | | { |
| | | 20 | | /// <summary>Message returned when a write fails.</summary> |
| | | 21 | | protected const string ExportFailedMessage = "An export error occurred."; |
| | | 22 | | |
| | | 23 | | /// <summary>Message returned when the caller passes no records.</summary> |
| | | 24 | | protected const string NullDataMessage = "An export error occurred: data is null."; |
| | | 25 | | |
| | | 26 | | /// <summary>Message returned when the caller passes no destination stream.</summary> |
| | | 27 | | protected const string NullDestinationMessage = "An export error occurred: destination is null."; |
| | | 28 | | |
| | | 29 | | /// <summary>Message returned when the caller passes no destination path.</summary> |
| | | 30 | | protected const string EmptyPathMessage = "An export error occurred: path is null or empty."; |
| | | 31 | | |
| | | 32 | | /// <inheritdoc /> |
| | | 33 | | public Task<ProcessOutput> WriteAsync(IEnumerable<T> data, Stream destination, CancellationToken ct = default) => |
| | 144 | 34 | | GuardedWriteAsync(data, destination, stream => WriteCoreAsync(data, stream, ct)); |
| | | 35 | | |
| | | 36 | | /// <inheritdoc /> |
| | | 37 | | public Task<ProcessOutput> WriteToFileAsync(IEnumerable<T> data, string path, CancellationToken ct = default) => |
| | 22 | 38 | | GuardedFileAsync(data, path, stream => WriteCoreAsync(data, stream, ct)); |
| | | 39 | | |
| | | 40 | | /// <summary>Guards a stream write: null checks, envelope conversion, cancellation propagation.</summary> |
| | | 41 | | protected async Task<ProcessOutput> GuardedWriteAsync(IEnumerable<T> data, Stream destination, |
| | | 42 | | Func<Stream, Task> write) |
| | | 43 | | { |
| | 78 | 44 | | if (data is null) |
| | 4 | 45 | | return ProcessOutput.New.WithError(NullDataMessage); |
| | 74 | 46 | | if (destination is null) |
| | 2 | 47 | | return ProcessOutput.New.WithError(NullDestinationMessage); |
| | | 48 | | |
| | | 49 | | try |
| | | 50 | | { |
| | 72 | 51 | | await write(destination).ConfigureAwait(false); |
| | 66 | 52 | | return ProcessOutput.New; |
| | | 53 | | } |
| | 4 | 54 | | catch (OperationCanceledException) { throw; } |
| | 8 | 55 | | catch (Exception ex) { return Fail(ex, destination: null); } |
| | 76 | 56 | | } |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// Guards a file write: writes through <paramref name="write" /> to a temporary file beside |
| | | 60 | | /// <paramref name="path" />, then moves it over <paramref name="path" />. A write that fails or is |
| | | 61 | | /// cancelled leaves an existing file untouched and no partial file behind. |
| | | 62 | | /// </summary> |
| | | 63 | | protected async Task<ProcessOutput> GuardedFileAsync(IEnumerable<T> data, string path, Func<Stream, Task> write) |
| | | 64 | | { |
| | 12 | 65 | | if (data is null) |
| | 0 | 66 | | return ProcessOutput.New.WithError(NullDataMessage); |
| | 12 | 67 | | if (string.IsNullOrEmpty(path)) |
| | 0 | 68 | | return ProcessOutput.New.WithError(EmptyPathMessage); |
| | | 69 | | |
| | 12 | 70 | | string? temporaryPath = null; |
| | | 71 | | try |
| | | 72 | | { |
| | 12 | 73 | | var fullPath = Path.GetFullPath(path); |
| | 12 | 74 | | temporaryPath = Path.Combine(Path.GetDirectoryName(fullPath)!, |
| | 12 | 75 | | $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); |
| | | 76 | | |
| | 12 | 77 | | var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); |
| | 10 | 78 | | await using (stream.ConfigureAwait(false)) |
| | | 79 | | { |
| | 10 | 80 | | await write(stream).ConfigureAwait(false); |
| | | 81 | | } |
| | | 82 | | |
| | 4 | 83 | | File.Move(temporaryPath, fullPath, overwrite: true); |
| | 4 | 84 | | return ProcessOutput.New; |
| | | 85 | | } |
| | 2 | 86 | | catch (OperationCanceledException) |
| | | 87 | | { |
| | 2 | 88 | | DeleteQuietly(temporaryPath); |
| | 2 | 89 | | throw; |
| | | 90 | | } |
| | 6 | 91 | | catch (Exception ex) |
| | | 92 | | { |
| | 6 | 93 | | DeleteQuietly(temporaryPath); |
| | 6 | 94 | | return Fail(ex, path); |
| | | 95 | | } |
| | 10 | 96 | | } |
| | | 97 | | |
| | | 98 | | private static void DeleteQuietly(string? path) |
| | | 99 | | { |
| | 8 | 100 | | if (path is null) |
| | | 101 | | { |
| | 0 | 102 | | return; |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | try |
| | | 106 | | { |
| | 8 | 107 | | File.Delete(path); |
| | 6 | 108 | | } |
| | 2 | 109 | | catch |
| | | 110 | | { |
| | | 111 | | // Best effort: the temporary file is hidden and uniquely named, so a leftover never collides. |
| | 2 | 112 | | } |
| | 8 | 113 | | } |
| | | 114 | | |
| | | 115 | | /// <summary> |
| | | 116 | | /// Logs the failure when a logger is configured, and returns the caller-safe envelope. |
| | | 117 | | /// Exception text embeds absolute paths and OS error detail, so it goes to the log and |
| | | 118 | | /// never to the caller. |
| | | 119 | | /// </summary> |
| | | 120 | | /// <param name="ex">The exception caught by a guard.</param> |
| | | 121 | | /// <param name="destination">The file path being written, or <see langword="null" /> for a stream write.</param> |
| | | 122 | | protected ProcessOutput Fail(Exception ex, string? destination) |
| | | 123 | | { |
| | 10 | 124 | | logger?.LogError(ex, "Export failed. Exporter: {Exporter}, record: {Record}, destination: {Destination}", |
| | 10 | 125 | | GetType().Name, typeof(T).Name, destination ?? "<stream>"); |
| | | 126 | | |
| | 10 | 127 | | return ProcessOutput.New.WithError(ExportFailedMessage); |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | /// <summary>Format-specific write. Implementations must honor <paramref name="ct" /> and not dispose the stream.</s |
| | | 131 | | protected abstract Task WriteCoreAsync(IEnumerable<T> data, Stream destination, CancellationToken ct); |
| | | 132 | | } |