diff --git a/src/CsvProc9000/Csv/CsvColumn.cs b/src/CsvProc9000/Csv/CsvColumn.cs new file mode 100644 index 0000000..96e2852 --- /dev/null +++ b/src/CsvProc9000/Csv/CsvColumn.cs @@ -0,0 +1,4 @@ +namespace CsvProc9000.Csv +{ + public record CsvColumn(int Index, string Name); +} \ No newline at end of file diff --git a/src/CsvProc9000/Csv/CsvField.cs b/src/CsvProc9000/Csv/CsvField.cs index a85c95e..69f3556 100644 --- a/src/CsvProc9000/Csv/CsvField.cs +++ b/src/CsvProc9000/Csv/CsvField.cs @@ -1,4 +1,4 @@ namespace CsvProc9000.Csv { - public record CsvField(string Name, string Value); + public record CsvField(CsvColumn Column, string Value); } \ No newline at end of file diff --git a/src/CsvProc9000/Csv/CsvFile.cs b/src/CsvProc9000/Csv/CsvFile.cs index 8667e4e..34c3efc 100644 --- a/src/CsvProc9000/Csv/CsvFile.cs +++ b/src/CsvProc9000/Csv/CsvFile.cs @@ -27,48 +27,5 @@ public void AddRow([NotNull] CsvRow row) _rows.Add(row); } - - public async Task SaveToAsync( - IFileSystem fileSystem, - string destinationFileName, - string delimiter) - { - var contentStringBuilder = new StringBuilder(); - - var fieldNames = Rows - .SelectMany(row => row.Fields) - .Select(field => field.Name) - .Distinct() - .ToList(); - - // add header row - contentStringBuilder.AppendJoin(delimiter, fieldNames); - contentStringBuilder.AppendLine(); - - foreach (var row in Rows) - { - var firstIteration = true; - - foreach (var fieldName in fieldNames) - { - // append the delimiter to the previous field when get here not in the first iteration - if (firstIteration) firstIteration = false; - else contentStringBuilder.Append(delimiter); - - var field = row.Fields.FirstOrDefault(f => f.Name == fieldName); - var fieldValue = string.Empty; - - if (field != null) - fieldValue = field.Value; - - contentStringBuilder.Append(fieldValue); - } - - contentStringBuilder.AppendLine(); - } - - var content = contentStringBuilder.ToString(); - await fileSystem.File.WriteAllTextAsync(destinationFileName, content); - } } } \ No newline at end of file diff --git a/src/CsvProc9000/Csv/CsvReader.cs b/src/CsvProc9000/Csv/CsvReader.cs index d618fa7..39f09a9 100644 --- a/src/CsvProc9000/Csv/CsvReader.cs +++ b/src/CsvProc9000/Csv/CsvReader.cs @@ -67,11 +67,13 @@ private static CsvRow ProcessCsvRow(IReaderRow csvReader, IEnumerable he { var csvRow = new CsvRow(); - foreach (var header in headers) + var headersList = headers.ToList(); + for (var index = 0; index < headersList.Count; index++) { - if (!csvReader.TryGetField(header, out var fieldValue)) continue; - - csvRow.AddOrUpdateField(header, fieldValue); + if (!csvReader.TryGetField(index, out var fieldValue)) continue; + + var column = new CsvColumn(index, headersList[index]); + csvRow.AddField(column, fieldValue); } return csvRow; diff --git a/src/CsvProc9000/Csv/CsvRow.cs b/src/CsvProc9000/Csv/CsvRow.cs index 6ff886c..b04b956 100644 --- a/src/CsvProc9000/Csv/CsvRow.cs +++ b/src/CsvProc9000/Csv/CsvRow.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; +using System.Linq; namespace CsvProc9000.Csv { @@ -10,13 +11,68 @@ public class CsvRow public IEnumerable Fields => _fields; - public void AddOrUpdateField([NotNull] string fieldName, [NotNull] string fieldValue) + public void AddField([NotNull] CsvColumn column, [NotNull] string fieldValue) { + if (column == null) throw new ArgumentNullException(nameof(column)); + if (fieldValue == null) throw new ArgumentNullException(nameof(fieldValue)); + + _fields.Add(new CsvField(column, fieldValue)); + } + + public void AddField([NotNull] string fieldName, [NotNull] string fieldValue) + { + if (fieldValue == null) throw new ArgumentNullException(nameof(fieldValue)); if (string.IsNullOrWhiteSpace(fieldName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(fieldName)); + + var nextIndexForColumn = Fields.Count() + 1; + var column = new CsvColumn(nextIndexForColumn, fieldName); + + AddField(column, fieldValue); + } + + public void AddOrUpdateField([NotNull] string fieldName, [NotNull] string fieldValue, int? fieldIndex) + { if (fieldValue == null) throw new ArgumentNullException(nameof(fieldValue)); + if (string.IsNullOrWhiteSpace(fieldName)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(fieldName)); + + if (!TryGetCandidateToChange(fieldName, fieldIndex, out var fieldToChange)) + { + AddField(fieldName, fieldValue); + return; + } + + var index = _fields.IndexOf(fieldToChange); + _fields.Remove(fieldToChange); + + var changedField = fieldToChange with { Value = fieldValue }; + _fields.Insert(index, changedField); + } + + private bool TryGetCandidateToChange(string fieldName, int? fieldIndex, out CsvField fieldToChange) + { + // get possible fields with the given name + var fieldCandidates = _fields + .Where(field => field.Column.Name == fieldName) + .ToList(); + + fieldToChange = null; + + // when we found more than one field with that name, we need to find a possible field with the given index + if (fieldCandidates.Count > 1) + { + if (!fieldIndex.HasValue) + throw new ArgumentException( + $"Found more than one candidate for field name {fieldName} but no field index was provided", + nameof(fieldIndex)); + + fieldToChange = fieldCandidates.FirstOrDefault(field => field.Column.Index == fieldIndex.Value); + } + else + fieldToChange = fieldCandidates.FirstOrDefault(); - _fields.Add(new CsvField(fieldName, fieldValue)); + return fieldToChange != null; } } } \ No newline at end of file diff --git a/src/CsvProc9000/Options/Change.cs b/src/CsvProc9000/Options/Change.cs new file mode 100644 index 0000000..1b9e814 --- /dev/null +++ b/src/CsvProc9000/Options/Change.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; + +namespace CsvProc9000.Options +{ + public class Change + { + [UsedImplicitly] + public string Field { get; set; } + + /// + /// When the Field-Name is not unique + /// + [UsedImplicitly] + public int? FieldIndex { get; set; } + + [UsedImplicitly] + public ChangeMode Mode { get; set; } = ChangeMode.AddOrUpdate; + + [UsedImplicitly] + public string Value { get; set; } + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Options/ChangeMode.cs b/src/CsvProc9000/Options/ChangeMode.cs new file mode 100644 index 0000000..7725fa3 --- /dev/null +++ b/src/CsvProc9000/Options/ChangeMode.cs @@ -0,0 +1,8 @@ +namespace CsvProc9000.Options +{ + public enum ChangeMode + { + Add, + AddOrUpdate + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Options/Rule.cs b/src/CsvProc9000/Options/Rule.cs index 2ec2822..0208973 100644 --- a/src/CsvProc9000/Options/Rule.cs +++ b/src/CsvProc9000/Options/Rule.cs @@ -9,6 +9,6 @@ public class Rule public List Conditions { get; set; } [UsedImplicitly] - public Dictionary Steps { get; set; } + public List Changes { get; set; } } } \ No newline at end of file diff --git a/src/CsvProc9000/Processors/ApplyRulesToCsvFile.cs b/src/CsvProc9000/Processors/ApplyRulesToCsvFile.cs new file mode 100644 index 0000000..8a9d7f8 --- /dev/null +++ b/src/CsvProc9000/Processors/ApplyRulesToCsvFile.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using CsvProc9000.Csv; +using CsvProc9000.Options; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CsvProc9000.Processors +{ + public class ApplyRulesToCsvFile : IApplyRulesToCsvFile + { + private readonly ILogger _logger; + private readonly CsvProcessorOptions _processorOptions; + + public ApplyRulesToCsvFile( + [NotNull] IOptions processorOptions, + [NotNull] ILogger logger) + { + _processorOptions = processorOptions.Value ?? throw new ArgumentNullException(nameof(processorOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Apply(CsvFile csvFile) + { + _logger.LogDebug("Processor: Applying rules to file {File}...", csvFile.OriginalFileName); + + if (_processorOptions.Rules == null || !_processorOptions.Rules.Any()) + { + _logger.LogWarning("Processor: Cannot process file {File} because there are no rules defined", + csvFile.OriginalFileName); + return; + } + + foreach (var rule in _processorOptions.Rules) + ApplyRuleToFile(csvFile, rule); + } + + private void ApplyRuleToFile(CsvFile csvFile, Rule rule) + { + if (rule.Conditions == null || !rule.Conditions.Any()) + { + _logger.LogWarning("Processor: Skipping Rule at index {Index} because it has no conditions", + _processorOptions.Rules.IndexOf(rule)); + return; + } + + foreach (var row in csvFile.Rows) + ApplyRuleToRow(row, rule, csvFile); + } + + private void ApplyRuleToRow(CsvRow row, Rule rule, CsvFile file) + { + if (!MeetsRowConditions(row, rule)) return; + + _logger.LogTrace("Processor: Row at index {RowIndex} meets rule at index {RuleIndex}. Applying change(s)...", + file.Rows.ToList().IndexOf(row), _processorOptions.Rules.IndexOf(rule)); + + foreach (var change in rule.Changes) + try + { + ApplyChangeToRow(row, rule, file, change); + } + catch (Exception e) + { + _logger.LogError(e, + "Processor: Error occured while applying change at index {ChangeIndex} to row at index {RowIndex}", + rule.Changes.IndexOf(change), file.Rows.ToList().IndexOf(row)); + } + } + + private void ApplyChangeToRow(CsvRow row, Rule rule, CsvFile file, Change change) + { + if (string.IsNullOrWhiteSpace(change.Field)) + { + _logger.LogWarning( + "Processor: Not applying change at index {ChangeIndex} for rule at index {RuleIndex} because no field name given", + rule.Changes.IndexOf(change), _processorOptions.Rules.IndexOf(rule)); + return; + } + + _logger.LogTrace( + "Processor: Row at index {RowIndex}: Applying change at index {ChangeIndex}: Field={Field}, Value={Value}, Mode={Mode}, Index={Index}", + file.Rows.ToList().IndexOf(row), rule.Changes.IndexOf(change), change.Field, change.Value, change.Mode, change.FieldIndex); + + switch (change.Mode) + { + case ChangeMode.Add: + row.AddField(change.Field, change.Value); + break; + case ChangeMode.AddOrUpdate: + row.AddOrUpdateField(change.Field, change.Value, change.FieldIndex); + break; + default: +#pragma warning disable CA2208 + throw new ArgumentOutOfRangeException(nameof(change.Mode), + $"Unknown value {change.Mode} for {nameof(ChangeMode)}"); +#pragma warning restore CA2208 + } + } + + private static bool MeetsRowConditions(CsvRow row, Rule rule) + { + var meetsConditions = true; + + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator + foreach (var condition in rule.Conditions) + { + var potentialFields = row + .Fields + // first we select every field with the desired + .Where(field => field.Column.Name == condition.Field) + // then we check if those fields have the desired value + .Where(field => field.Value == condition.Value); + + var anyFieldMatchesCondition = potentialFields.Any(); + + /* + * the conditions are met, when we found any fields that match the conditions in that row + * + * for clarity, what that boolean operation down there does: + * + * - case: meetsConditions = true, anyFieldMatchesCondition = true + * conditions were met, because at least on field meets the conditions in this row + * --> meetsConditions = true + * + * - case: meetsConditions = true, anyFieldMatchesCondition = false + * conditions are not met, because no field meets the conditions in this row + * --> meetsConditions = false + * + * - case: meetsConditions = false, anyFieldMatchesCondition = true + * conditions were not met before, but we need every condition to be met (AND link) + * --> meetsConditions = false + * + * - case: meetsConditions = false, anyFieldMatchesCondition = false + * nothing to explain here i guess + * --> meetsConditions = false + */ + meetsConditions = meetsConditions && anyFieldMatchesCondition; + } + + return meetsConditions; + } + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Processors/CsvProcessor.cs b/src/CsvProc9000/Processors/CsvProcessor.cs index 1d9c6f2..862d61b 100644 --- a/src/CsvProc9000/Processors/CsvProcessor.cs +++ b/src/CsvProc9000/Processors/CsvProcessor.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.IO.Abstractions; -using System.Linq; using System.Threading.Tasks; using CsvProc9000.Csv; using CsvProc9000.Options; @@ -16,18 +15,24 @@ internal sealed class CsvProcessor : ICsvProcessor private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly ICsvReader _csvReader; + private readonly IApplyRulesToCsvFile _applyRulesToCsvFile; + private readonly ISaveCsvFile _saveCsvFile; private readonly CsvProcessorOptions _processorOptions; public CsvProcessor( [NotNull] ILogger logger, [NotNull] IOptions processorOptions, [NotNull] IFileSystem fileSystem, - [NotNull] ICsvReader csvReader) + [NotNull] ICsvReader csvReader, + [NotNull] IApplyRulesToCsvFile applyRulesToCsvFile, + [NotNull] ISaveCsvFile saveCsvFile) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _processorOptions = processorOptions.Value ?? throw new ArgumentNullException(nameof(processorOptions)); _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); _csvReader = csvReader ?? throw new ArgumentNullException(nameof(csvReader)); + _applyRulesToCsvFile = applyRulesToCsvFile ?? throw new ArgumentNullException(nameof(applyRulesToCsvFile)); + _saveCsvFile = saveCsvFile ?? throw new ArgumentNullException(nameof(saveCsvFile)); } public async Task ProcessAsync(IFileInfo file) @@ -66,15 +71,12 @@ private async Task ProcessInternalAsync(IFileSystemInfo file) _logger.LogDebug("Processor: Reading in file {File}...", file.FullName); var csvFile = await _csvReader.ReadAsync(file.FullName, _processorOptions.InboxDelimiter); - - _logger.LogDebug("Processor: Applying rules to file {File}...", file.FullName); - ApplyRulesToFile(csvFile); + + _applyRulesToCsvFile.Apply(csvFile); await SaveResultAsync(file, csvFile); } - - private async Task SaveResultAsync(IFileSystemInfo file, CsvFile csvFile) { var fileName = file.Name; @@ -84,8 +86,8 @@ private async Task SaveResultAsync(IFileSystemInfo file, CsvFile csvFile) if (_fileSystem.Directory.Exists(_processorOptions.Outbox)) _fileSystem.Directory.CreateDirectory(_processorOptions.Outbox); - - await csvFile.SaveToAsync(_fileSystem, destinationFileName, _processorOptions.OutboxDelimiter); + + await _saveCsvFile.SaveToAsync(csvFile, destinationFileName, _processorOptions.OutboxDelimiter); if (!file.Exists) return; if (!_processorOptions.DeleteInboxFile) return; @@ -95,83 +97,6 @@ private async Task SaveResultAsync(IFileSystemInfo file, CsvFile csvFile) _fileSystem.File.Delete(file.FullName); } - - private void ApplyRulesToFile(CsvFile csvFile) - { - if (_processorOptions.Rules == null || !_processorOptions.Rules.Any()) - { - _logger.LogWarning("Processor: Cannot process file {File} because there are no rules defined", - csvFile.OriginalFileName); - return; - } - - foreach (var rule in _processorOptions.Rules) - ApplyRuleToFile(csvFile, rule); - } - - private void ApplyRuleToFile(CsvFile csvFile, Rule rule) - { - if (rule.Conditions == null || !rule.Conditions.Any()) - { - _logger.LogWarning("Processor: Skipping Rule at index {Index} because it has no conditions", - _processorOptions.Rules.IndexOf(rule)); - return; - } - - foreach (var row in csvFile.Rows) - ApplyRuleToRow(row, rule, csvFile); - } - - private void ApplyRuleToRow(CsvRow row, Rule rule, CsvFile file) - { - if (!MeetsRowConditions(row, rule, file)) return; - - _logger.LogTrace("Processor: File {File} meets rule at index {RuleIndex}. Applying rule...", - file.OriginalFileName, _processorOptions.Rules.IndexOf(rule)); - - foreach (var (columnName, fieldValue) in rule.Steps) - { - if (string.IsNullOrWhiteSpace(columnName)) - { - _logger.LogWarning("Processor: Not applying step for rule at index {RuleIndex} because no field name given", - _processorOptions.Rules.IndexOf(rule)); - continue; - } - - _logger.LogTrace("Processor: Row at index {RowIndex}: Adding field '{Field}' with value '{FieldValue}'", - _processorOptions.Rules.IndexOf(rule), columnName, fieldValue); - row.AddOrUpdateField(columnName, fieldValue); - } - } - - private bool MeetsRowConditions(CsvRow row, Rule rule, CsvFile file) - { - foreach (var condition in rule.Conditions) - { - var field = row.Fields.FirstOrDefault(field => field.Name == condition.Field); - if (field == null) - { - _logger.LogTrace( - "Processor: Row at index {RowIndex} does not meet condition at index {ConditionIndex}, " + - "because the field '{FieldName}' could not be found", - file.Rows.ToList().IndexOf(row), rule.Conditions.IndexOf(condition), condition.Field); - return false; - } - - if (field.Value == condition.Value) continue; - - _logger.LogTrace( - "Processor: Row at index {RowIndex} does not meet condition at index {ConditionIndex}, " + - "because the field '{FieldName}' with the value '{FieldValue}' " + - "does not have the desired value '{ConditionValue}'", - file.Rows.ToList().IndexOf(row), rule.Conditions.IndexOf(condition), condition.Field, - field.Value, condition.Value); - return false; - } - - // if we get here, all conditions have been met - return true; - } private async Task WaitUntilFileIsUnlockedAsync(IFileSystemInfo file) { diff --git a/src/CsvProc9000/Processors/IApplyRulesToCsvFile.cs b/src/CsvProc9000/Processors/IApplyRulesToCsvFile.cs new file mode 100644 index 0000000..1fdb704 --- /dev/null +++ b/src/CsvProc9000/Processors/IApplyRulesToCsvFile.cs @@ -0,0 +1,9 @@ +using CsvProc9000.Csv; + +namespace CsvProc9000.Processors +{ + public interface IApplyRulesToCsvFile + { + void Apply(CsvFile csvFile); + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Processors/ISaveCsvFile.cs b/src/CsvProc9000/Processors/ISaveCsvFile.cs new file mode 100644 index 0000000..bbfbc30 --- /dev/null +++ b/src/CsvProc9000/Processors/ISaveCsvFile.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using CsvProc9000.Csv; + +namespace CsvProc9000.Processors +{ + public interface ISaveCsvFile + { + Task SaveToAsync( + CsvFile file, + string destinationFileName, + string delimiter); + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Processors/SaveCsvFile.cs b/src/CsvProc9000/Processors/SaveCsvFile.cs new file mode 100644 index 0000000..86423e6 --- /dev/null +++ b/src/CsvProc9000/Processors/SaveCsvFile.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CsvProc9000.Csv; +using JetBrains.Annotations; + +namespace CsvProc9000.Processors +{ + public class SaveCsvFile : ISaveCsvFile + { + private readonly IFileSystem _fileSystem; + + public SaveCsvFile( + [NotNull] IFileSystem fileSystem) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + } + + public async Task SaveToAsync( + CsvFile file, + string destinationFileName, + string delimiter) + { + var contentStringBuilder = new StringBuilder(); + + var columns = DetermineColumns(file); + AddHeaderRow(contentStringBuilder, delimiter, columns); + AddRows(contentStringBuilder, file, delimiter, columns); + + var content = contentStringBuilder.ToString(); + await _fileSystem.File.WriteAllTextAsync(destinationFileName, content); + } + + private static List DetermineColumns(CsvFile file) + { + var columns = file.Rows + .SelectMany(row => row.Fields) + .Select(field => field.Column) + .Distinct() + .OrderBy(column => column.Index) + .ToList(); + return columns; + } + + private static void AddHeaderRow( + StringBuilder contentStringBuilder, + string delimiter, + IEnumerable columns) + { + contentStringBuilder.AppendJoin(delimiter, columns.Select(column => column.Name)); + contentStringBuilder.AppendLine(); + } + + private static void AddRows( + StringBuilder contentStringBuilder, + CsvFile file, + string delimiter, + List columns) + { + foreach (var row in file.Rows) + { + AddRow(contentStringBuilder, delimiter, columns, row); + contentStringBuilder.AppendLine(); + } + } + + private static void AddRow(StringBuilder contentStringBuilder, string delimiter, List columns, CsvRow row) + { + var firstIteration = true; + + foreach (var column in columns) + { + // append the delimiter to the previous field when get here not in the first iteration + if (firstIteration) firstIteration = false; + else contentStringBuilder.Append(delimiter); + + AddField(contentStringBuilder, row, column); + } + } + + private static void AddField(StringBuilder contentStringBuilder, CsvRow row, CsvColumn column) + { + var field = row.Fields.FirstOrDefault(f => f.Column == column); + var fieldValue = string.Empty; + + if (field != null) + fieldValue = field.Value; + + contentStringBuilder.Append(fieldValue); + } + } +} \ No newline at end of file diff --git a/src/CsvProc9000/Program.cs b/src/CsvProc9000/Program.cs index 076e057..f4463f6 100644 --- a/src/CsvProc9000/Program.cs +++ b/src/CsvProc9000/Program.cs @@ -73,6 +73,8 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); } diff --git a/src/CsvProc9000/appsettings.Development.json b/src/CsvProc9000/appsettings.Development.json index fee4aa0..660d768 100644 --- a/src/CsvProc9000/appsettings.Development.json +++ b/src/CsvProc9000/appsettings.Development.json @@ -21,10 +21,25 @@ "Value": "Standardversand" } ], - "Steps": { - "SHIPMENTTYPE": "NP", - "Verpackungsart": 0 - } + "Changes": [ + { + "Field": "SHIPMENTTYPE", + "Value": "NP" + }, + { + "Field": "Verpackungsart", + "Value": "0" + }, + { + "Field": "EbayName", + "Value": "mrmaddin95" + }, + { + "Field": "Versandart", + "Value": "Krosse Krabbe Pizza", + "FieldIndex": 2 + } + ] } ] }