Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
40b0016
WIP: DTOSS-8925 file transform skeleton
ianfnelson May 15, 2025
200229c
feat: Started implementing File Transform Function
alex-clayton-1 May 16, 2025
c90818c
feat: Transform function downloads file from blob storage
alex-clayton-1 May 16, 2025
56588d7
test: Started writing tests for FileTransformFunction
alex-clayton-1 May 16, 2025
2fe6582
test: Added extra test for downloading blob
alex-clayton-1 May 16, 2025
1318ab6
refactor: Extracted some code into a separate method
alex-clayton-1 May 16, 2025
770fce3
feat: Added AppConfiguration to FileTransformFunction
alex-clayton-1 May 16, 2025
9f3a883
Merge branch 'main' into feat/DTOSS-8925-transform-skeleton
alex-clayton-1 May 16, 2025
16d2fd1
feat: csvHelper nuget added, FileParser class updated
Warren-Pitterson May 19, 2025
5835466
Merge branch 'main' into feat/DTOSS-8753-CSV-Parser
Warren-Pitterson May 19, 2025
92530bd
feat: refactor to FileParser, DI to file transform
Warren-Pitterson May 19, 2025
9710809
test: add file parser tests
Warren-Pitterson May 19, 2025
d37c270
Merge branch 'main' into feat/DTOSS-8753-CSV-Parser
Warren-Pitterson May 19, 2025
ca75c8b
refactor: extract fileRecordtype to Enum class, refactor FileParser f…
Warren-Pitterson May 19, 2025
a2f6f04
test: wrong scenario removed
Warren-Pitterson May 19, 2025
e53a9eb
fix: handle empty record identifiers gracefully
Warren-Pitterson May 20, 2025
89c2f5f
feat: FileParser, Headers removed, tests updated, SonarQube fixes, Te…
Warren-Pitterson May 20, 2025
9066d62
Merge branch 'main' into feat/DTOSS-8753-CSV-Parser
Warren-Pitterson May 20, 2025
f10db2b
fix: file formatting fix
Warren-Pitterson May 20, 2025
1ef28f2
fix: removed enum suffix
Warren-Pitterson May 20, 2025
c2e48fb
test: test performance
Warren-Pitterson May 20, 2025
463acbb
fix: removed skip
Warren-Pitterson May 20, 2025
cf62758
refactor: move enum back within class, renamed const to be PascalCase…
Warren-Pitterson May 20, 2025
fec6e48
refactor: FileHeaderRecordMap and FileTrailerRecordMap extracted into…
Warren-Pitterson May 20, 2025
0c39238
refactor: ParseDataRecord
Warren-Pitterson May 20, 2025
623b223
test: removed fileParser tests
Warren-Pitterson May 20, 2025
b6ced87
fix: object initialiser added
Warren-Pitterson May 20, 2025
b7e77c0
refactor: FilerParserTests, extracted out of FileTransformFunction an…
Warren-Pitterson May 20, 2025
c6f0a55
test: updated to correct namespace
Warren-Pitterson May 20, 2025
63034d2
refactor: remove unused project reference
Warren-Pitterson May 21, 2025
36241d0
refactor: nested classmap classes into FileParser class
Warren-Pitterson May 21, 2025
4451d5f
fix: added missing brace
Warren-Pitterson May 21, 2025
b368707
refactor: variable names updated to match
Warren-Pitterson May 21, 2025
8359e12
refactor: extract CSV reader helper methods for better maintainability
Warren-Pitterson May 21, 2025
edd035c
test: move test class, changed namespace
Warren-Pitterson May 21, 2025
96c68a5
chore: formatting
Warren-Pitterson May 21, 2025
001fad1
feat: FileParser uses test files instead of string literals
Warren-Pitterson May 21, 2025
00e46f3
test: extracted helper methods and removed null coalescing operator f…
Warren-Pitterson May 21, 2025
bf3f228
refactor: moved null check
Warren-Pitterson May 21, 2025
0919089
fix: removed unnecessary trim extension method
Warren-Pitterson May 21, 2025
0538c16
feat: new line ending added to .csv test files and renamed valid file
Warren-Pitterson May 21, 2025
b480425
Merge branch 'main' into feat/DTOSS-8753-CSV-Parser
Warren-Pitterson May 21, 2025
5b6b7a2
test: move test file location and extension
Warren-Pitterson May 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/FileParser.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,143 @@
using CsvHelper;
using CsvHelper.Configuration;
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models;
using System.Globalization;
using System.Text;

namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents;

public class FileParser : IFileParser
{
private const string HeaderIdentifier = "NBSSAPPT_HDR";
private const string FieldsIdentifier = "NBSSAPPT_FLDS";
private const string DataIdentifier = "NBSSAPPT_DATA";
private const string TrailerIdentifier = "NBSSAPPT_END";
private const int RecordTypeIdentifier = 0;

/// <summary>
/// Parse a stream of appointment data
/// </summary>
public ParsedFile Parse(Stream stream)
{
// TODO - implement this
throw new NotImplementedException();
if (stream == null)
{
throw new ArgumentNullException(nameof(stream), "Stream cannot be null");
}

var result = new ParsedFile();

using var reader = CreateStreamReader(stream);
using var csv = CreateCsvReader(reader);

var rowNumber = 0;
var fields = new List<string>();

while (csv.Read())
{
var recordIdentifier = GetFieldValue(csv, RecordTypeIdentifier);

switch (recordIdentifier)
{
case HeaderIdentifier:
result.FileHeader = ParseHeader(csv);
break;

case FieldsIdentifier:
fields = ParseFields(csv);
break;

case DataIdentifier:
rowNumber++;
result.DataRecords.Add(ParseDataRecord(csv, fields, rowNumber));
break;

case TrailerIdentifier:
result.FileTrailer = ParseTrailer(csv);
break;

default:
throw new InvalidOperationException($"Unknown record identifier: {recordIdentifier}");
}
}

return result;
}

private static List<string> ParseFields(CsvReader csv)
{
return Enumerable.Range(1, csv.Parser.Count - 1)
.Select(i => GetFieldValue(csv, i))
.Where(x => !string.IsNullOrEmpty(x))
.ToList()!;
}

private static string? GetFieldValue(CsvReader csv, int index) => index < csv.Parser.Count ? csv.GetField(index) : null;
private static StreamReader CreateStreamReader(Stream stream) => new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true);
private static FileHeaderRecord ParseHeader(CsvReader csv) => csv.GetRecord<FileHeaderRecord>();
private static FileTrailerRecord ParseTrailer(CsvReader csv) => csv.GetRecord<FileTrailerRecord>();
private static CsvReader CreateCsvReader(StreamReader reader)
{
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = "|",
Quote = '"',
Escape = '\\',
HasHeaderRecord = false,
Mode = CsvMode.RFC4180,
BadDataFound = null
};

var csv = new CsvReader(reader, config);
csv.Context.RegisterClassMap<FileHeaderRecordMap>();
csv.Context.RegisterClassMap<FileTrailerRecordMap>();

return csv;
}

private static FileDataRecord ParseDataRecord(CsvReader csv, List<string> columnHeadings, int rowNumber)
{
if (columnHeadings.Count == 0)
{
throw new InvalidOperationException("Field headers (NBSSAPPT_FLDS) must appear before data records.");
}

const int dataFieldStartIndex = 1;

var record = new FileDataRecord { RowNumber = rowNumber };

foreach (var (heading, index) in columnHeadings.Select((header, index) => (header, index + dataFieldStartIndex)))
{
if (index < csv.Parser.Count)
{
record.Fields[heading] = GetFieldValue(csv, index) ?? string.Empty;
}
}

return record;
}

public sealed class FileTrailerRecordMap : ClassMap<FileTrailerRecord>
{
public FileTrailerRecordMap()
{
Map(m => m.RecordTypeIdentifier).Index(0);
Map(m => m.ExtractId).Index(1);
Map(m => m.TransferEndDate).Index(2);
Map(m => m.TransferEndTime).Index(3);
Map(m => m.RecordCount).Index(4);
}
}

public sealed class FileHeaderRecordMap : ClassMap<FileHeaderRecord>
{
public FileHeaderRecordMap()
{
Map(m => m.RecordTypeIdentifier).Index(0);
Map(m => m.ExtractId).Index(1);
Map(m => m.TransferStartDate).Index(2);
Map(m => m.TransferStartTime).Index(3);
Map(m => m.RecordCount).Index(4);
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ public class ParsedFile
{
public FileHeaderRecord? FileHeader { get; set; }
public FileTrailerRecord? FileTrailer { get; set; }
public required List<FileDataRecord> DataRecords { get; set; } = [];
public List<FileDataRecord> DataRecords { get; set; } = [];
}
10 changes: 7 additions & 3 deletions src/ServiceLayer.Mesh/Functions/FileTransformFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using ServiceLayer.Data;
using ServiceLayer.Data.Models;
using ServiceLayer.Mesh.Configuration;
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents;
using ServiceLayer.Mesh.Messaging;
using ServiceLayer.Mesh.Storage;

Expand All @@ -14,7 +15,8 @@ public class FileTransformFunction(
ILogger<FileTransformFunction> logger,
ServiceLayerDbContext serviceLayerDbContext,
IMeshFilesBlobStore meshFileBlobStore,
IFileTransformFunctionConfiguration configuration)
IFileTransformFunctionConfiguration configuration,
IFileParser fileParser)
{
[Function("FileTransformFunction")]
public async Task Run([QueueTrigger("%FileTransformQueueName%")] FileTransformQueueMessage message)
Expand All @@ -25,7 +27,7 @@ public async Task Run([QueueTrigger("%FileTransformQueueName%")] FileTransformQu

if (file == null)
{
logger.LogWarning("File with id: {fileId} not found in MeshFiles table.", message.FileId);
logger.LogWarning("File with id: {FileId} not found in MeshFiles table.", message.FileId);
return;
}

Expand All @@ -39,6 +41,8 @@ public async Task Run([QueueTrigger("%FileTransformQueueName%")] FileTransformQu

var fileContent = await meshFileBlobStore.DownloadAsync(file);

var parsedfile = fileParser.Parse(fileContent);

// TODO - take dependency on IEnumerable<IFileTransformer>.
// After initial common checks against database, find the appropriate implementation of IFileTransformer to handle the functionality that differs between file type.
}
Expand All @@ -59,7 +63,7 @@ private bool IsFileSuitableForTransformation(MeshFile file)
(file.Status == MeshFileStatus.Transforming && file.LastUpdatedUtc > DateTime.UtcNow.AddHours(-configuration.StaleHours)))
{
logger.LogWarning(
"File with id: {fileId} found in MeshFiles table but is not suitable for transformation. Status: {status}, LastUpdatedUtc: {lastUpdatedUtc}.",
"File with id: {FileId} found in MeshFiles table but is not suitable for transformation. Status: {Status}, LastUpdatedUtc: {LastUpdatedUtc}.",
file.FileId,
file.Status,
file.LastUpdatedUtc.ToTimestamp());
Expand Down
2 changes: 2 additions & 0 deletions src/ServiceLayer.Mesh/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using ServiceLayer.Mesh.Configuration;
using ServiceLayer.Mesh.Messaging;
using ServiceLayer.Data;
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents;

var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
Expand Down Expand Up @@ -50,6 +51,7 @@

services.AddSingleton<IFileExtractQueueClient, FileExtractQueueClient>();
services.AddSingleton<IFileTransformQueueClient, FileTransformQueueClient>();
services.AddSingleton<IFileParser, FileParser>();

services.AddSingleton(provider =>
{
Expand Down
1 change: 1 addition & 0 deletions src/ServiceLayer.Mesh/ServiceLayer.Mesh.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.22.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<!-- Application Insights isn't enabled by default. See https://aka.ms/AAt8mw4. -->
<!-- <PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" /> -->
<!-- <PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" /> -->
Expand Down
Loading
Loading