From 45b4bfefdcbb2cd405c35958fb4d8df03111ced0 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 2 Jun 2026 11:47:41 +0200 Subject: [PATCH 1/8] Changed namespace of API entry point from MiniExcelLib.Core to MiniExcelLib It makes more sense for consumers of the library for the main `MiniExcel` class to find it under the simpler mainspace --- src/MiniExcel.Core/MiniExcel.cs | 7 +++++-- src/MiniExcel.OpenXml/FluentMapping/MappedRow.cs | 2 +- src/MiniExcel.OpenXml/Reader/OpenXmlReader.Mapped.cs | 1 + src/MiniExcel/MiniExcel.cs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/MiniExcel.Core/MiniExcel.cs b/src/MiniExcel.Core/MiniExcel.cs index 29f2dfa6..fcfa381e 100644 --- a/src/MiniExcel.Core/MiniExcel.cs +++ b/src/MiniExcel.Core/MiniExcel.cs @@ -1,8 +1,11 @@ -namespace MiniExcelLib.Core; +using MiniExcelLib.Core; + +// ReSharper disable once CheckNamespace +namespace MiniExcelLib; public static class MiniExcel { public static readonly MiniExcelExporterProvider Exporters = new(); public static readonly MiniExcelImporterProvider Importers = new(); public static readonly MiniExcelTemplaterProvider Templaters = new(); -} \ No newline at end of file +} diff --git a/src/MiniExcel.OpenXml/FluentMapping/MappedRow.cs b/src/MiniExcel.OpenXml/FluentMapping/MappedRow.cs index 2c29e5d2..f25ecf32 100644 --- a/src/MiniExcel.OpenXml/FluentMapping/MappedRow.cs +++ b/src/MiniExcel.OpenXml/FluentMapping/MappedRow.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.OpenXml; +namespace MiniExcelLib.OpenXml.FluentMapping; public struct MappedRow(int rowIndex) { diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Mapped.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Mapped.cs index 6ef18057..c27dd830 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Mapped.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Mapped.cs @@ -1,3 +1,4 @@ +using MiniExcelLib.OpenXml.FluentMapping; using MiniExcelLib.OpenXml.Styles; namespace MiniExcelLib.OpenXml.Reader; diff --git a/src/MiniExcel/MiniExcel.cs b/src/MiniExcel/MiniExcel.cs index 217a7555..a30c2631 100644 --- a/src/MiniExcel/MiniExcel.cs +++ b/src/MiniExcel/MiniExcel.cs @@ -7,7 +7,7 @@ using MiniExcelLib.OpenXml.Picture; using Zomp.SyncMethodGenerator; -using NewMiniExcel = MiniExcelLib.Core.MiniExcel; +using NewMiniExcel = MiniExcelLib.MiniExcel; using NewOpenXmlConfiguration = MiniExcelLib.OpenXml.OpenXmlConfiguration; // ReSharper disable once CheckNamespace From abe8c7cfef4d6ee09bbf5faec9af66032f8adb3b Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 2 Jun 2026 11:50:35 +0200 Subject: [PATCH 2/8] Fixed minor issues in test files Added `AutoDeletingPath` where missing, removed unused pragmas and moved `TestDto` class to models file --- .../Issues/GithubIssuesAsyncTests.cs | 6 ++-- .../Issues/GithubIssuesTests.cs | 4 --- .../Main/MiniExcelCsvAsyncTests.cs | 29 +++++++------------ tests/MiniExcel.Csv.Tests/Main/Models.cs | 7 +++++ .../Issues/MiniExcelGithubIssuesAsyncTests.cs | 1 - .../Issues/MiniExcelGithubIssuesTests.cs | 27 ++++++++--------- .../Utils/PathHelper.cs | 18 +----------- 7 files changed, 35 insertions(+), 57 deletions(-) create mode 100644 tests/MiniExcel.Csv.Tests/Main/Models.cs diff --git a/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesAsyncTests.cs b/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesAsyncTests.cs index b9ab20f7..b76faa75 100644 --- a/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesAsyncTests.cs +++ b/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesAsyncTests.cs @@ -30,12 +30,12 @@ public async Task Issue89() Assert.Equal(Issue89Dto.WorkState.Fired, rows1[1].State); Assert.Equal(Issue89Dto.WorkState.Leave, rows1[2].State); - var outputPath = PathHelper.GetTempPath(); - var rowsWritten = await MiniExcel.Exporters.GetOpenXmlExporter().ExportAsync(outputPath, rows1); + using var outputPath = AutoDeletingPath.Create(); + var rowsWritten = await MiniExcel.Exporters.GetOpenXmlExporter().ExportAsync(outputPath.ToString(), rows1); Assert.Single(rowsWritten); Assert.Equal(3, rowsWritten[0]); - var rows2 = await MiniExcel.Importers.GetOpenXmlImporter().QueryAsync(outputPath).ToListAsync(); + var rows2 = await MiniExcel.Importers.GetOpenXmlImporter().QueryAsync(outputPath.ToString()).ToListAsync(); Assert.Equal(Issue89Dto.WorkState.OnDuty, rows2[0].State); Assert.Equal(Issue89Dto.WorkState.Fired, rows2[1].State); Assert.Equal(Issue89Dto.WorkState.Leave, rows2[2].State); diff --git a/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesTests.cs b/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesTests.cs index 90f2dc0f..106c4977 100644 --- a/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesTests.cs +++ b/tests/MiniExcel.Csv.Tests/Issues/GithubIssuesTests.cs @@ -346,9 +346,7 @@ public void TestIssue261() public void TestIssue279() { var path = PathHelper.GetFile("/csv/TestHeader.csv"); -#pragma warning disable CS0618 // Type or member is obsolete using var dt = _csvImporter.QueryAsDataTable(path); -#pragma warning restore CS0618 Assert.Equal("A1", dt.Rows[0]["Column1"]); Assert.Equal("A2", dt.Rows[1]["Column1"]); Assert.Equal("B1", dt.Rows[0]["Column2"]); @@ -409,9 +407,7 @@ public void TestIssue293() public void TestIssue298() { var path = PathHelper.GetFile("/csv/TestIssue298.csv"); -#pragma warning disable CS0618 // Type or member is obsolete using var dt = _csvImporter.QueryAsDataTable(path); -#pragma warning restore CS0618 Assert.Equal(["ID", "Name", "Age"], dt.Columns.Cast().Select(x => x.ColumnName)); } diff --git a/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs b/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs index e4d90c2d..99acab91 100644 --- a/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs +++ b/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs @@ -202,13 +202,6 @@ public async Task SaveAsByDataTableTest() Assert.Equal("2021-01-02 00:00:00", records[1].d); } - - private class Test - { - public string? C1 { get; set; } - public string? C2 { get; set; } - } - [Fact] public async Task CsvExcelTypeTest() { @@ -282,7 +275,7 @@ await _csvExporter.ExportAsync(path, new[] await using (var stream = File.OpenRead(path)) { - var rows = _csvImporter.Query(stream).ToList(); + var rows = _csvImporter.Query(stream).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Equal("B1", rows[0].C2); Assert.Equal("A2", rows[1].C1); @@ -290,7 +283,7 @@ await _csvExporter.ExportAsync(path, new[] } { - var rows = _csvImporter.Query(path).ToList(); + var rows = _csvImporter.Query(path).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Equal("B1", rows[0].C2); Assert.Equal("A2", rows[1].C1); @@ -312,7 +305,7 @@ await _csvExporter.ExportAsync(path, new[] await using (var stream = File.OpenRead(path)) { - var rows = _csvImporter.Query(stream).ToList(); + var rows = _csvImporter.Query(stream).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Equal(string.Empty, rows[0].C2); Assert.Equal(string.Empty, rows[1].C1); @@ -320,7 +313,7 @@ await _csvExporter.ExportAsync(path, new[] } { - var rows = _csvImporter.Query(path).ToList(); + var rows = _csvImporter.Query(path).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Equal(string.Empty, rows[0].C2); Assert.Equal(string.Empty, rows[1].C1); @@ -330,7 +323,7 @@ await _csvExporter.ExportAsync(path, new[] var config = new CsvConfiguration { ReadEmptyStringAsNull = true }; await using (var stream = File.OpenRead(path)) { - var rows = _csvImporter.Query(stream, configuration: config).ToList(); + var rows = _csvImporter.Query(stream, configuration: config).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Null(rows[0].C2); Assert.Null(rows[1].C1); @@ -338,7 +331,7 @@ await _csvExporter.ExportAsync(path, new[] } { - var rows = _csvImporter.Query(path, configuration: config).ToList(); + var rows = _csvImporter.Query(path, configuration: config).ToList(); Assert.Equal("A1", rows[0].C1); Assert.Null(rows[0].C2); Assert.Null(rows[1].C1); @@ -352,18 +345,16 @@ public async Task SaveAsByAsyncEnumerable() using var file = AutoDeletingPath.Create(); var path = file.ToString(); -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - static async IAsyncEnumerable GetValues() + static async IAsyncEnumerable GetValues() { - yield return new Test { C1 = "A1", C2 = "B1" }; - yield return new Test { C1 = "A2", C2 = "B2" }; + yield return await Task.FromResult(new TestDto { C1 = "A1", C2 = "B1" }); + yield return await Task.FromResult(new TestDto { C1 = "A2", C2 = "B2" }); } -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously var rowsWritten = await _csvExporter.ExportAsync(path, GetValues()); Assert.Equal(2, rowsWritten); - var results = _csvImporter.Query(path).ToList(); + var results = _csvImporter.Query(path).ToList(); Assert.Equal(2, results.Count); Assert.Equal("A1", results[0].C1); Assert.Equal("B1", results[0].C2); diff --git a/tests/MiniExcel.Csv.Tests/Main/Models.cs b/tests/MiniExcel.Csv.Tests/Main/Models.cs new file mode 100644 index 00000000..2e07a78d --- /dev/null +++ b/tests/MiniExcel.Csv.Tests/Main/Models.cs @@ -0,0 +1,7 @@ +namespace MiniExcelLib.Csv.Tests.Main; + +internal class TestDto +{ + public string? C1 { get; set; } + public string? C2 { get; set; } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs index abeda8d8..a7961e7f 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs @@ -854,7 +854,6 @@ public async Task Issue223() using var dt = await _excelImporter.QueryAsDataTableAsync(path.ToString()); -#pragma warning restore CS0618 var columns = dt.Columns; Assert.Equal(typeof(object), columns[0].DataType); Assert.Equal(typeof(object), columns[1].DataType); diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs index 529531ab..a763bc9e 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs @@ -984,9 +984,8 @@ public void Issue226() [Fact] public void Issue227() { - var path = PathHelper.GetTempPath("xlsm"); - Assert.Throws(() => _excelExporter.Export(path, new[] { new { V = "A1" }, new { V = "A2" } })); - File.Delete(path); + using var path = AutoDeletingPath.Create(Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsm")); + Assert.Throws(() => _excelExporter.Export(path.ToString(), new[] { new { V = "A1" }, new { V = "A2" } })); var path1 = PathHelper.GetFile("xlsx/TestIssue227.xlsm"); var rows1 = _excelImporter.Query(path1).ToList(); @@ -1444,7 +1443,9 @@ public void TestIssue294() [Fact] public void TestIssue304() { - var path = PathHelper.GetTempFilePath(); + using var path = AutoDeletingPath.Create(); + var filePath = path.ToString(); + var value = new[] { new { Name="github", Image=File.ReadAllBytes(PathHelper.GetFile("images/github_logo.png"))}, @@ -1453,13 +1454,13 @@ public void TestIssue304() new { Name="reddit", Image=File.ReadAllBytes(PathHelper.GetFile("images/reddit_logo.png"))}, new { Name="stackoverflow", Image=File.ReadAllBytes(PathHelper.GetFile("images/stackoverflow_logo.png"))}, }; - _excelExporter.Export(path, value); + _excelExporter.Export(filePath, value); - Assert.Contains("/xl/media/", SheetHelper.GetZipFileContent(path, "xl/drawings/_rels/drawing1.xml.rels")); - Assert.Contains("ext cx=\"609600\" cy=\"190500\"", SheetHelper.GetZipFileContent(path, "xl/drawings/drawing1.xml")); - Assert.Contains("/xl/drawings/drawing1.xml", SheetHelper.GetZipFileContent(path, "[Content_Types].xml")); - Assert.Contains("drawing r:id=", SheetHelper.GetZipFileContent(path, "xl/worksheets/sheet1.xml")); - Assert.Contains("../drawings/drawing1.xml", SheetHelper.GetZipFileContent(path, "xl/worksheets/_rels/sheet1.xml.rels")); + Assert.Contains("/xl/media/", SheetHelper.GetZipFileContent(filePath, "xl/drawings/_rels/drawing1.xml.rels")); + Assert.Contains("ext cx=\"609600\" cy=\"190500\"", SheetHelper.GetZipFileContent(filePath, "xl/drawings/drawing1.xml")); + Assert.Contains("/xl/drawings/drawing1.xml", SheetHelper.GetZipFileContent(filePath, "[Content_Types].xml")); + Assert.Contains("drawing r:id=", SheetHelper.GetZipFileContent(filePath, "xl/worksheets/sheet1.xml")); + Assert.Contains("../drawings/drawing1.xml", SheetHelper.GetZipFileContent(filePath, "xl/worksheets/_rels/sheet1.xml.rels")); } // https://github.com/mini-software/MiniExcel/issues/305 @@ -2629,15 +2630,15 @@ public void TestIssue773() [Fact] public void TestIssue789() { - var path = PathHelper.GetTempPath(); + using var path = AutoDeletingPath.Create(); var value = new[] { new Dictionary { {"no","1"} }, new Dictionary { {"no","2"} }, new Dictionary { {"no","3"} }, }; - _excelExporter.Export(path, value); + _excelExporter.Export(path.ToString(), value); - var xml = SheetHelper.GetZipFileContent(path, "xl/worksheets/sheet1.xml"); + var xml = SheetHelper.GetZipFileContent(path.ToString(), "xl/worksheets/sheet1.xml"); Assert.Contains("", xml); } diff --git a/tests/MiniExcel.Tests.Common/Utils/PathHelper.cs b/tests/MiniExcel.Tests.Common/Utils/PathHelper.cs index 19322e34..c6b70ccc 100644 --- a/tests/MiniExcel.Tests.Common/Utils/PathHelper.cs +++ b/tests/MiniExcel.Tests.Common/Utils/PathHelper.cs @@ -3,20 +3,4 @@ public static class PathHelper { public static string GetFile(string fileName) => $"../../../../data/{fileName}"; - - public static string GetTempPath(string extension = "xlsx") - { - var method = new System.Diagnostics.StackTrace().GetFrame(1)?.GetMethod(); - - var path = Path.Combine(Path.GetTempPath(), $"{method?.DeclaringType?.Name}_{method?.Name}.{extension}") - .Replace("<", string.Empty) - .Replace(">", string.Empty); - - if (File.Exists(path)) - File.Delete(path); - - return path; - } - - public static string GetTempFilePath(string extension = "xlsx") => $"{Path.GetTempPath()}{Guid.NewGuid()}.{extension}"; -} \ No newline at end of file +} From 8841ee1eb0911331d2614657fb23f37628f1cd26 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 2 Jun 2026 12:47:52 +0200 Subject: [PATCH 3/8] Changed RetrieveComments and QueryTables API methods The `sheetName` parameter now defaults to null and the first worksheet of the Excel workbook will be selected even if its name is not "Sheet1" --- src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 12 ++++++------ .../Reader/OpenXmlReader.Comments.cs | 14 +++++++++----- .../Reader/OpenXmlReader.Tables.cs | 15 ++++++++++----- .../Comments/CommentsRetrievalAsyncTests.cs | 2 +- .../Tables/MiniExcelOpenXmlTableAsyncTests.cs | 10 +++++----- .../Tables/MiniExcelOpenXmlTableTests.cs | 6 +++--- tests/data/xlsx/TestQueryTable.xlsx | Bin 12339 -> 12328 bytes 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 45e678ac..0c9ae1e5 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -472,7 +472,7 @@ public async Task> GetColumnNamesAsync(Stream stream, bool h /// The returned provides access to both threaded comments and legacy note comments, along with the associated metadata. /// [CreateSyncVersion] - public async Task RetrieveCommentsAsync(string path, string? sheetName, CancellationToken cancellationToken = default) + public async Task RetrieveCommentsAsync(string path, string? sheetName = null, CancellationToken cancellationToken = default) { var stream = FileHelper.OpenSharedRead(path); await using var disposableStream = stream.ConfigureAwait(false); @@ -492,7 +492,7 @@ public async Task RetrieveCommentsAsync(string path, string? s /// The returned provides access to both threaded comments and legacy note comments, along with the associated metadata. /// [CreateSyncVersion] - public async Task RetrieveCommentsAsync(Stream stream, string? sheetName, bool leaveOpen = false, CancellationToken cancellationToken = default) + public async Task RetrieveCommentsAsync(Stream stream, string? sheetName = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false); @@ -510,7 +510,7 @@ public async Task RetrieveCommentsAsync(Stream stream, string? /// This method reads from the specified table within a stream and yields rows as dynamic objects with properties based on the table's column names. /// [CreateSyncVersion] - public async IAsyncEnumerable QueryTableAsync(string path, string sheetName = "Sheet1", string tableName = "Table1", [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable QueryTableAsync(string path, string? sheetName = null, string tableName = "Table1", [EnumeratorCancellation] CancellationToken cancellationToken = default) { var stream = FileHelper.OpenSharedRead(path); await using var disposableStream = stream.ConfigureAwait(false); @@ -533,7 +533,7 @@ public async IAsyncEnumerable QueryTableAsync(string path, string sheet /// This method reads from the specified table within a stream and yields rows as dynamic objects with properties based on the table's column names. /// [CreateSyncVersion] - public async IAsyncEnumerable QueryTableAsync(Stream stream, string sheetName = "Sheet1", string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable QueryTableAsync(Stream stream, string? sheetName = null, string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); await foreach (var table in reader.QueryTableAsync(sheetName, tableName, false, cancellationToken).ConfigureAwait(false)) @@ -553,7 +553,7 @@ public async IAsyncEnumerable QueryTableAsync(Stream stream, string she /// This method reads from the specified table within a stream and maps each row to an instance of the provided type. The mapping is based on property/field names matching column headers. /// [CreateSyncVersion] - public async IAsyncEnumerable QueryTableAsync(string path, string sheetName = "Sheet1", string tableName = "Table1", [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable QueryTableAsync(string path, string? sheetName = null, string tableName = "Table1", [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { var stream = FileHelper.OpenSharedRead(path); @@ -578,7 +578,7 @@ public async IAsyncEnumerable QueryTableAsync(string path, string sheetNam /// This method reads from the specified table within a stream and maps each row to an instance of the provided type. The mapping is based on property/field names matching column headers. /// [CreateSyncVersion] - public async IAsyncEnumerable QueryTableAsync(Stream stream, string sheetName = "Sheet1", string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable QueryTableAsync(Stream stream, string? sheetName = null, string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Comments.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Comments.cs index dc0bfb44..80f7aff6 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Comments.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Comments.cs @@ -10,13 +10,17 @@ internal partial class OpenXmlReader [CreateSyncVersion] internal async Task ReadCommentsAsync(string? sheetName, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(sheetName)) - throw new ArgumentException("sheetName cannot be null or empty", nameof(sheetName)); - SetWorkbookRels(Archive.EntryCollection); - var sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name.Equals(sheetName, StringComparison.CurrentCultureIgnoreCase)); + + var sheetRecord = string.IsNullOrEmpty(sheetName) + ? _sheetRecords?.FirstOrDefault() + : _sheetRecords?.SingleOrDefault(s => s.Name.Equals(sheetName, StringComparison.CurrentCultureIgnoreCase)); + if (sheetRecord?.Path?.Split('/')[^1] is not { } sheetFile) - throw new InvalidDataException($"There is no sheet named {sheetName}"); + throw new InvalidDataException("A valid worksheet could not be found."); + + if (string.IsNullOrEmpty(sheetName)) + sheetName = sheetRecord.Name; if (Archive.GetEntry($"xl/worksheets/_rels/{sheetFile}.rels") is not { } rel) return new CommentResultSet(sheetName, [], []); diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Tables.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Tables.cs index 7ed37456..14409d5e 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Tables.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Tables.cs @@ -3,7 +3,7 @@ namespace MiniExcelLib.OpenXml.Reader; internal partial class OpenXmlReader { [CreateSyncVersion] - internal IAsyncEnumerable QueryTableAsync(string sheetName, string tableName, CancellationToken cancellationToken = default) + internal IAsyncEnumerable QueryTableAsync(string? sheetName, string tableName, CancellationToken cancellationToken = default) where T : class, new() { var query = QueryTableAsync(sheetName, tableName, true, cancellationToken); @@ -11,7 +11,7 @@ internal IAsyncEnumerable QueryTableAsync(string sheetName, string tableNa } [CreateSyncVersion] - internal async IAsyncEnumerable> QueryTableAsync(string sheetName, string tableName, bool prependHeaders, [EnumeratorCancellation] CancellationToken cancellationToken = default) + internal async IAsyncEnumerable> QueryTableAsync(string? sheetName, string tableName, bool prependHeaders, [EnumeratorCancellation] CancellationToken cancellationToken = default) { TableInfo? table = null; await foreach (var item in GetTableInfosAsync(sheetName, cancellationToken).ConfigureAwait(false)) @@ -74,11 +74,16 @@ internal IAsyncEnumerable QueryTableAsync(string sheetName, string tableNa } [CreateSyncVersion] - private async IAsyncEnumerable GetTableInfosAsync(string sheetName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + private async IAsyncEnumerable GetTableInfosAsync(string? sheetName, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var rels = await GetWorkbookRelsAsync(Archive.EntryCollection, cancellationToken).ConfigureAwait(false); - if (rels?.Find(x => x.Name.Equals(sheetName, StringComparison.OrdinalIgnoreCase)) is not { Path: { } path }) - throw new InvalidDataException($"Worksheet {sheetName} was not found."); + + var sheetRecord = string.IsNullOrEmpty(sheetName) + ? rels?.FirstOrDefault() + : rels?.Find(x => x.Name.Equals(sheetName, StringComparison.OrdinalIgnoreCase)); + + if (sheetRecord is not { Path: { } path }) + throw new InvalidDataException("A valid worksheet could not be found."); List tables = []; var sheetFilename = path.Split('/')[^1]; diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs index bcbacf9e..6c857353 100644 --- a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs @@ -9,7 +9,7 @@ public class CommentsRetrievalAsyncTests [Fact] public async Task SheetWithCommentsAndNotesTestAsync() { - var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet1"); + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx")); var (firstComment, secondComment) = (commentSet.Comments[0], commentSet.Comments[1]); Assert.Equal("sheet1", commentSet.SheetName, ignoreCase: true); diff --git a/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableAsyncTests.cs index 4dad49db..f5e0c074 100644 --- a/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableAsyncTests.cs @@ -16,7 +16,7 @@ public async Task QueryTableAsync_FromFilePath_ReturnsDynamicRows() var path = PathHelper.GetFile("xlsx/TestQueryTable.xlsx"); // Act - var rows = await _excelImporter.QueryTableAsync(path, "Sheet1", "Table1").ToListAsync(); + var rows = await _excelImporter.QueryTableAsync(path).ToListAsync(); // Assert Assert.Equal(3, rows.Count); @@ -34,10 +34,10 @@ public async Task QueryTableAsync_FromStream_ReturnsDynamicRows() // Arrange var path = PathHelper.GetFile("xlsx/TestQueryTable.xlsx"); await using var stream = File.OpenRead(path); - + // Act - var rows = await _excelImporter.QueryTableAsync(stream, "Sheet1", "Table1").ToListAsync(); - + var rows = await _excelImporter.QueryTableAsync(stream).ToListAsync(); + // Assert Assert.Equal(3, rows.Count); Assert.Equal("bbb", rows[1].Col1); @@ -55,7 +55,7 @@ public async Task QueryTableAsync_Generic_FromFilePath_ReturnsTypedRows() var path = PathHelper.GetFile("xlsx/TestQueryTable.xlsx"); // Act - var rows = await _excelImporter.QueryTableAsync(path, "Sheet1", "Table1").ToListAsync(); + var rows = await _excelImporter.QueryTableAsync(path).ToListAsync(); // Assert Assert.Equal(3, rows.Count); diff --git a/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableTests.cs b/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableTests.cs index f282521a..058b1e4d 100644 --- a/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Tables/MiniExcelOpenXmlTableTests.cs @@ -17,7 +17,7 @@ public void QueryTable_FromFilePath_ReturnsDynamicRows() var path = PathHelper.GetFile("xlsx/TestQueryTable.xlsx"); // Act - var rows = _excelImporter.QueryTable(path, "Sheet1", "Table1").ToList(); + var rows = _excelImporter.QueryTable(path).ToList(); // Assert Assert.Equal(3, rows.Count); @@ -37,7 +37,7 @@ public void QueryTable_FromStream_ReturnsDynamicRows() using var stream = File.OpenRead(path); // Act - var rows = _excelImporter.QueryTable(stream, "Sheet1", "Table1").ToList(); + var rows = _excelImporter.QueryTable(stream).ToList(); // Assert Assert.Equal(3, rows.Count); @@ -56,7 +56,7 @@ public void QueryTable_Generic_FromFilePath_ReturnsTypedRows() var path = PathHelper.GetFile("xlsx/TestQueryTable.xlsx"); // Act - var rows = _excelImporter.QueryTable(path, "Sheet1", "Table1").ToList(); + var rows = _excelImporter.QueryTable(path).ToList(); // Assert Assert.Equal(3, rows.Count); diff --git a/tests/data/xlsx/TestQueryTable.xlsx b/tests/data/xlsx/TestQueryTable.xlsx index c50390158e80148c4c3475933fa4fe2003a1c083..ce2311dfde27df86ec4e7c08284aa862214bce5d 100644 GIT binary patch delta 2407 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 WY6xxzx>OCo&j6^JJbMA+x8T2okY!5% delta 2441 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 BVXpuH From 42d3c344c6d40008f021062750c49e1651338eb8 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 2 Jun 2026 15:32:32 +0200 Subject: [PATCH 4/8] Addressing minor warnings throughout the codebase --- .../BenchmarkSections/CreateExcelBenchmark.cs | 11 +- .../BenchmarkSections/QueryExcelBenchmark.cs | 25 ++-- .../TemplateExcelBenchmark.cs | 11 +- .../BenchmarkSections/XlsxAsyncBenchmark.cs | 7 +- .../Exceptions/ValueNotAssignableException.cs | 6 +- .../Reflection/ColumnMappingsProvider.cs | 24 ++-- .../Reflection/MiniExcelMapper.cs | 2 +- .../WriteAdapters/DataTableWriteAdapter.cs | 2 +- src/MiniExcel.Csv/Api/CsvImporter.cs | 4 +- src/MiniExcel.Csv/CsvReader.cs | 1 - src/MiniExcel.Csv/CsvWriter.cs | 10 +- src/MiniExcel.OpenXml/Models/SheetRecord.cs | 8 +- .../Reader/OpenXmlReader.MergeCells.cs | 11 +- src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 31 ++--- src/MiniExcel/MiniExcelConverter.cs | 1 - .../Main/MiniExcelOpenXmlTests.cs | 117 ++++-------------- tests/MiniExcel.OpenXml.Tests/Main/Models.cs | 15 +++ 17 files changed, 111 insertions(+), 175 deletions(-) diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs index e80b77a7..7dcd50d0 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs @@ -5,7 +5,6 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using MiniExcelLib.Benchmarks.Utils; -using MiniExcelLib.Core; using MiniExcelLib.OpenXml; using MiniExcelLib.OpenXml.FluentMapping; using MiniExcelLib.OpenXml.FluentMapping.Api; @@ -16,8 +15,8 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class CreateExcelBenchmark : BenchmarkBase { - private OpenXmlExporter _exporter; - private MappingExporter _simpleMappingExporter; + private OpenXmlExporter _exporter = null!; + private MappingExporter _simpleMappingExporter = null!; [GlobalSetup] public void SetUp() @@ -117,13 +116,13 @@ public void OpenXmlSdkCreateByDomModeTest() using var spreadsheetDocument = SpreadsheetDocument.Create(path.FilePath, SpreadsheetDocumentType.Workbook); // By default, AutoSave = true, Editable = true, and Type = xlsx. - WorkbookPart workbookpart = spreadsheetDocument.AddWorkbookPart(); + var workbookpart = spreadsheetDocument.AddWorkbookPart(); workbookpart.Workbook = new Workbook(); - WorksheetPart worksheetPart = workbookpart.AddNewPart(); + var worksheetPart = workbookpart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(new SheetData()); - Sheets sheets = spreadsheetDocument.WorkbookPart!.Workbook.AppendChild(new Sheets()); + var sheets = spreadsheetDocument.WorkbookPart!.Workbook!.AppendChild(new Sheets()); sheets.Append(new Sheet { diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs index 4d06bd45..d82879ce 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs @@ -4,7 +4,6 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using ExcelDataReader; -using MiniExcelLib.Core; using MiniExcelLib.OpenXml; using MiniExcelLib.OpenXml.FluentMapping; using MiniExcelLib.OpenXml.FluentMapping.Api; @@ -15,8 +14,8 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class QueryExcelBenchmark : BenchmarkBase { - private OpenXmlImporter _importer; - private MappingImporter _mappingImporter; + private OpenXmlImporter _importer = null!; + private MappingImporter _mappingImporter = null!; [GlobalSetup] public void SetUp() @@ -55,7 +54,7 @@ public void MiniExcel_Query() { foreach (var row in _importer.Query(FilePath)) { - var value = row; + _ = row; } } @@ -70,7 +69,7 @@ public void MiniExcel_Query_Mapping() { foreach (var row in _mappingImporter.Query(FilePath)) { - var value = row; + _ = row; } } @@ -83,7 +82,7 @@ public void ExcelDataReader_QueryFirst_Test() reader.Read(); for (var i = 0; i < reader.FieldCount; i++) { - var value = reader.GetValue(i); + _ = reader.GetValue(i); } } @@ -97,7 +96,7 @@ public void ExcelDataReader_Query_Test() { for (var i = 0; i < reader.FieldCount; i++) { - var value = reader.GetValue(i); + _ = reader.GetValue(i); } } } @@ -122,7 +121,7 @@ public void Epplus_Query_Test() { for (var col = start.Column; col <= end.Column; col++) { - object cellValue = workSheet.Cells[row, col].Text; + _ = workSheet.Cells[row, col].Text; } } } @@ -162,11 +161,12 @@ public void NPOI_Query_Test() { for (var j = row.FirstCellNum; j <= row.LastCellNum; j++) { - var cellValue = row.GetCell(j)?.StringCellValue; + _ = row.GetCell(j)?.StringCellValue; } } } } + [Benchmark(Description = "OpenXmlSDK QueryFirst")] public void OpenXmlSDK_QueryFirst_Test() { @@ -175,8 +175,9 @@ public void OpenXmlSDK_QueryFirst_Test() var workbookPart = spreadsheetDocument.WorkbookPart; var worksheetPart = workbookPart!.WorksheetParts.First(); - var sheetData = worksheetPart.Worksheet.Elements().First(); + var sheetData = worksheetPart.Worksheet!.Elements().First(); var firstRow = sheetData.Elements().First(); + _ = firstRow; } [Benchmark(Description = "OpenXmlSDK Query")] @@ -187,10 +188,10 @@ public void OpenXmlSDK_Query_Test() var workbookPart = spreadsheetDocument.WorkbookPart; var worksheetPart = workbookPart!.WorksheetParts.First(); - var sheetData = worksheetPart.Worksheet.Elements().First(); + var sheetData = worksheetPart.Worksheet!.Elements().First(); foreach(var row in sheetData.Elements()) { - var cellValue = row; + _ = row; } } } \ No newline at end of file diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs index 8a37a075..d70775b0 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Attributes; using ClosedXML.Report; using MiniExcelLib.Benchmarks.Utils; -using MiniExcelLib.Core; using MiniExcelLib.OpenXml; using MiniExcelLib.OpenXml.FluentMapping; using MiniExcelLib.OpenXml.FluentMapping.Api; @@ -10,9 +9,9 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class TemplateExcelBenchmark : BenchmarkBase { - private OpenXmlTemplater _templater; - private MappingTemplater _mappingTemplater; - private OpenXmlExporter _exporter; + private OpenXmlTemplater _templater = null!; + private MappingTemplater _mappingTemplater = null!; + private OpenXmlExporter _exporter = null!; public class Employee { @@ -44,7 +43,7 @@ public void MiniExcel_Template_Generate_Test() var value = new { employees = Enumerable.Range(1, RowCount) - .Select(s => new + .Select(_ => new { name = "Jack", department = "HR" @@ -90,7 +89,7 @@ public void MiniExcel_Mapping_Template_Generate_Test() using var outputPath = AutoDeletingPath.Create(); var employees = Enumerable.Range(1, RowCount) - .Select(s => new Employee + .Select(_ => new Employee { Name = "Jack", Department = "HR" diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/XlsxAsyncBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/XlsxAsyncBenchmark.cs index ea90613b..5117c047 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/XlsxAsyncBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/XlsxAsyncBenchmark.cs @@ -1,14 +1,13 @@ using BenchmarkDotNet.Attributes; using MiniExcelLib.Benchmarks.Utils; -using MiniExcelLib.Core; using MiniExcelLib.OpenXml; namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class XlsxAsyncBenchmark : BenchmarkBase { - private OpenXmlExporter _exporter; - private OpenXmlTemplater _templater; + private OpenXmlExporter _exporter = null!; + private OpenXmlTemplater _templater = null!; [GlobalSetup] public void Setup() @@ -35,7 +34,7 @@ public async Task MiniExcel_Template_Generate_Async_Test() var value = new { employees = Enumerable.Range(1, RowCount) - .Select(s => new + .Select(_ => new { name = "Jack", department = "HR" diff --git a/src/MiniExcel.Core/Exceptions/ValueNotAssignableException.cs b/src/MiniExcel.Core/Exceptions/ValueNotAssignableException.cs index 8a70834d..5b376f9c 100644 --- a/src/MiniExcel.Core/Exceptions/ValueNotAssignableException.cs +++ b/src/MiniExcel.Core/Exceptions/ValueNotAssignableException.cs @@ -1,10 +1,10 @@ namespace MiniExcelLib.Core.Exceptions; -public class ValueNotAssignableException(string columnName, int row, object value, Type columnType, string message) +public class ValueNotAssignableException(string columnName, int row, object? value, Type columnType, string message) : InvalidCastException(message) { public string ColumnName { get; set; } = columnName; public int Row { get; set; } = row; - public object Value { get; set; } = value; + public object? Value { get; set; } = value; public Type ColumnType { get; set; } = columnType; -} \ No newline at end of file +} diff --git a/src/MiniExcel.Core/Reflection/ColumnMappingsProvider.cs b/src/MiniExcel.Core/Reflection/ColumnMappingsProvider.cs index e3d4ad4d..f60ea87a 100644 --- a/src/MiniExcel.Core/Reflection/ColumnMappingsProvider.cs +++ b/src/MiniExcel.Core/Reflection/ColumnMappingsProvider.cs @@ -22,8 +22,8 @@ internal static List GetMappingsForImport(Type type, str throw new InvalidMappingException($"{type.Name} must contain at least one mappable property or field.", type); var firstDuplicateIndexGroup = mappings - .Where(m => m?.ExcelColumnIndex > -1) - .GroupBy(m => m?.ExcelColumnIndex) + .Where(m => m.ExcelColumnIndex > -1) + .GroupBy(m => m.ExcelColumnIndex) .FirstOrDefault(g => g.Count() > 1); if (firstDuplicateIndexGroup?.FirstOrDefault() is { } duplicate) @@ -31,16 +31,16 @@ internal static List GetMappingsForImport(Type type, str var maxKey = keys.Last(); var maxIndex = CellReferenceConverter.GetNumericalIndex(maxKey); - foreach (var p in mappings) + foreach (var map in mappings) { - if (p?.ExcelColumnIndex is null) + if (map.ExcelColumnIndex is null) continue; - if (p.ExcelColumnIndex > maxIndex) - throw new InvalidMappingException($"The defined MiniExcelColumnIndex({p.ExcelColumnIndex}) exceeds the worksheets size({maxIndex})", type, p.MemberAccessor.MemberInfo); + if (map.ExcelColumnIndex > maxIndex) + throw new InvalidMappingException($"The defined MiniExcelColumnIndex({map.ExcelColumnIndex}) exceeds the worksheets size({maxIndex})", type, map.MemberAccessor.MemberInfo); - if (p.ExcelColumnName is null) - throw new InvalidMappingException($"The defined MiniExcelColumnIndex({p.ExcelColumnIndex}) for type {type.Name}.{p.MemberAccessor.Name} does not match the defined MiniExcelColumnName({p.ExcelColumnName})", type, p.MemberAccessor.MemberInfo); + if (map.ExcelColumnName is null) + throw new InvalidMappingException($"The defined MiniExcelColumnIndex({map.ExcelColumnIndex}) for type {type.Name}.{map.MemberAccessor.Name} does not match the defined MiniExcelColumnName({map.ExcelColumnName})", type, map.MemberAccessor.MemberInfo); } return mappings; @@ -180,14 +180,14 @@ private static void SetDictionaryColumnInfo(List mappin var map = new MiniExcelColumnMapping { Key = key, - ExcelColumnName = key?.ToString() + ExcelColumnName = key.ToString() }; // TODO:Dictionary value type is not fixed var isIgnore = false; if (configuration.DynamicColumns is { Length: > 0 }) { - var dynamicColumn = configuration.DynamicColumns.SingleOrDefault(x => x.Key == key?.ToString()); + var dynamicColumn = configuration.DynamicColumns.SingleOrDefault(x => x.Key == key.ToString()); if (dynamicColumn is not null) { map.Nullable = true; @@ -239,11 +239,11 @@ internal static bool TryGetColumnMappings(Type? type, MiniExcelBaseConfiguration return true; } - internal static List GetColumnMappingFromValue(object value, MiniExcelBaseConfiguration configuration) => value switch + internal static List? GetColumnMappingFromValue(object? value, MiniExcelBaseConfiguration configuration) => value switch { IDictionary genericDictionary => GetDictionaryColumnInfo(genericDictionary, null, configuration), IDictionary dictionary => GetDictionaryColumnInfo(null, dictionary, configuration), - _ => value.GetType().GetMappingsForExport(configuration) + _ => value?.GetType().GetMappingsForExport(configuration) ?? [] }; private static bool ValueIsNeededToDetermineProperties(Type type) => diff --git a/src/MiniExcel.Core/Reflection/MiniExcelMapper.cs b/src/MiniExcel.Core/Reflection/MiniExcelMapper.cs index 3997ef01..597f29ff 100644 --- a/src/MiniExcel.Core/Reflection/MiniExcelMapper.cs +++ b/src/MiniExcel.Core/Reflection/MiniExcelMapper.cs @@ -77,7 +77,7 @@ public static partial class MiniExcelMapper } } - public static object? MapValue(T v, MiniExcelColumnMapping map, object itemValue, int rowIndex, MiniExcelBaseConfiguration config, Func? stringDecoderFunc = null) where T : class, new() + public static object? MapValue(T v, MiniExcelColumnMapping map, object? itemValue, int rowIndex, MiniExcelBaseConfiguration config, Func? stringDecoderFunc = null) where T : class, new() { try { diff --git a/src/MiniExcel.Core/WriteAdapters/DataTableWriteAdapter.cs b/src/MiniExcel.Core/WriteAdapters/DataTableWriteAdapter.cs index be82aa9a..3c551d14 100644 --- a/src/MiniExcel.Core/WriteAdapters/DataTableWriteAdapter.cs +++ b/src/MiniExcel.Core/WriteAdapters/DataTableWriteAdapter.cs @@ -16,7 +16,7 @@ public List GetColumns() var mappings = new List(); for (var i = 0; i < _dataTable.Columns.Count; i++) { - var columnName = _dataTable.Columns[i].Caption ?? _dataTable.Columns[i].ColumnName; + var columnName = _dataTable.Columns[i].Caption; var map = ColumnMappingsProvider.GetColumnMappingFromDynamicConfiguration(columnName, _configuration); mappings.Add(map); } diff --git a/src/MiniExcel.Csv/Api/CsvImporter.cs b/src/MiniExcel.Csv/Api/CsvImporter.cs index e83603d6..79eb3c8a 100644 --- a/src/MiniExcel.Csv/Api/CsvImporter.cs +++ b/src/MiniExcel.Csv/Api/CsvImporter.cs @@ -147,11 +147,11 @@ public async Task QueryAsDataTableAsync(Stream stream, bool hasHeader cancellationToken.ThrowIfCancellationRequested(); var columnName = hasHeaderRow ? entry.Value?.ToString() : entry.Key; - if (!string.IsNullOrWhiteSpace(columnName)) // avoid #298 : Column '' does not belong to table + if (columnName is { Length: > 0 }) // avoid #298 : Column '' does not belong to table { var column = new DataColumn(columnName, typeof(object)) { Caption = columnName }; dt.Columns.Add(column); - columnDict.Add(entry.Key, columnName!); //same column name throw exception??? + columnDict.Add(entry.Key, columnName); //same column name throw exception??? } } diff --git a/src/MiniExcel.Csv/CsvReader.cs b/src/MiniExcel.Csv/CsvReader.cs index fdbc5275..994d46c0 100644 --- a/src/MiniExcel.Csv/CsvReader.cs +++ b/src/MiniExcel.Csv/CsvReader.cs @@ -123,7 +123,6 @@ internal CsvReader(Stream stream, IMiniExcelConfiguration? configuration, bool l [CreateSyncVersion] public IAsyncEnumerable QueryAsync(string? sheetName, string startCell, bool mapHeaderAsData, CancellationToken cancellationToken = default) where T : class, new() { - const int rowOffset = 0; // ranged queries are not supported for csv var records = QueryAsync(false, sheetName, startCell, cancellationToken); return MiniExcelMapper.MapQueryAsync(records, 0, mapHeaderAsData, false, _config, null, cancellationToken); } diff --git a/src/MiniExcel.Csv/CsvWriter.cs b/src/MiniExcel.Csv/CsvWriter.cs index d22df409..464839cb 100644 --- a/src/MiniExcel.Csv/CsvWriter.cs +++ b/src/MiniExcel.Csv/CsvWriter.cs @@ -37,8 +37,7 @@ private static void RemoveTrailingSeparator(StringBuilder rowBuilder) } [CreateSyncVersion] - private async Task WriteValuesAsync(StreamWriter writer, object values, string separator, string newLine, - IProgress? progress = null, CancellationToken cancellationToken = default) + private async Task WriteValuesAsync(object values, string newLine, IProgress? progress = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -148,10 +147,7 @@ private async Task WriteValuesAsync(StreamWriter writer, object values, str [CreateSyncVersion] public async Task SaveAsAsync(IProgress? progress = null, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - - var seperator = _configuration.Seperator.ToString(); - var newLine = _configuration.NewLine; + var newLine = _configuration.NewLine; if (_value is null) { @@ -165,7 +161,7 @@ public async Task SaveAsAsync(IProgress? progress = null, Cancellati return []; } - var rowsWritten = await WriteValuesAsync(_writer, _value, seperator, newLine, progress, cancellationToken).ConfigureAwait(false); + var rowsWritten = await WriteValuesAsync(_value, newLine, progress, cancellationToken).ConfigureAwait(false); await _writer.FlushAsync( #if NET cancellationToken diff --git a/src/MiniExcel.OpenXml/Models/SheetRecord.cs b/src/MiniExcel.OpenXml/Models/SheetRecord.cs index dded142a..cf1a8009 100644 --- a/src/MiniExcel.OpenXml/Models/SheetRecord.cs +++ b/src/MiniExcel.OpenXml/Models/SheetRecord.cs @@ -1,11 +1,11 @@ namespace MiniExcelLib.OpenXml.Models; -internal sealed class SheetRecord(string name, string state, uint id, string rid, bool active) +internal sealed class SheetRecord(string name, string? state, uint id, string? rid, bool active) { public string Name { get; } = name; - public string State { get; set; } = state; + public string? State { get; set; } = state; public uint Id { get; } = id; - public string Rid { get; set; } = rid; + public string? Rid { get; set; } = rid; public string? Path { get; set; } public bool Active { get; } = active; @@ -19,4 +19,4 @@ public SheetInfo ToSheetInfo(uint index) throw new ArgumentException($"Unable to parse sheet state. Sheet name: {Name}"); } -} \ No newline at end of file +} diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.MergeCells.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.MergeCells.cs index 4c260002..999e846e 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.MergeCells.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.MergeCells.cs @@ -31,14 +31,13 @@ internal partial class OpenXmlReader if (reader.IsStartElement("mergeCell", Ns)) { var refAttr = reader.GetAttribute("ref"); - var refs = refAttr.Split(':'); - if (refs.Length == 1) + if (refAttr?.Split(':') is not [var startCell, var endCell]) continue; - CellReferenceConverter.TryParseCellReference(refs[0], out var x1, out var y1); - CellReferenceConverter.TryParseCellReference(refs[1], out var x2, out var y2); + CellReferenceConverter.TryParseCellReference(startCell, out var x1, out var y1); + CellReferenceConverter.TryParseCellReference(endCell, out var x2, out var y2); - mergeCells.MergesValues.Add(refs[0], null); + mergeCells.MergesValues.Add(startCell, null); // foreach range var isFirst = true; @@ -47,7 +46,7 @@ internal partial class OpenXmlReader for (int y = y1; y <= y2; y++) { if (!isFirst) - mergeCells.MergesMap.Add(CellReferenceConverter.GetCellFromCoordinates(x, y), refs[0]); + mergeCells.MergesMap.Add(CellReferenceConverter.GetCellFromCoordinates(x, y), startCell); isFirst = false; } } diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index f8061cfa..c07493dd 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -45,7 +45,7 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC [CreateSyncVersion] public IAsyncEnumerable QueryAsync(string? sheetName, string startCell, bool mapHeaderAsData, CancellationToken cancellationToken = default) where T : class, new() { - sheetName ??= MiniExcelPropertyHelper.GetExcelSheetInfo(typeof(T), _config)?.ExcelSheetName; + sheetName ??= MiniExcelPropertyHelper.GetExcelSheetInfo(typeof(T), _config).ExcelSheetName; var query = QueryAsync(false, sheetName, startCell, cancellationToken); if (!CellReferenceConverter.TryParseCellReference(startCell, out _, out var rowOffset)) @@ -168,7 +168,6 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC if (!maxRowColumnIndexResult.IsSuccess) yield break; - var maxRowIndex = maxRowColumnIndexResult.MaxRowIndex; var maxColumnIndex = maxRowColumnIndexResult.MaxColumnIndex; var withoutCr = maxRowColumnIndexResult.WithoutCr; @@ -348,7 +347,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) if (sheetName is not null) { SetWorkbookRels(Archive.EntryCollection); - var sheetRecord = _sheetRecords.SingleOrDefault(s => s.Name == sheetName); + var sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name == sheetName); if (sheetRecord is null) { if (_config.DynamicSheets is null) @@ -356,7 +355,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) if (_config.DynamicSheets.FirstOrDefault(ds => ds.Key == sheetName) is { } sheetConfig) { - sheetRecord = _sheetRecords.SingleOrDefault(s => s.Name == sheetConfig.Name); + sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name == sheetConfig.Name); } } sheetEntry = sheets.Single(w => w.FullName.TrimStart('/') == $"xl/{sheetRecord?.Path}" || w.FullName == sheetRecord?.Path?.TrimStart('/')); @@ -364,7 +363,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) else if (sheets.Length > 1) { SetWorkbookRels(Archive.EntryCollection); - var s = _sheetRecords[0]; + var s = _sheetRecords![0]; sheetEntry = sheets.Single(w => w.FullName.TrimStart('/') == $"xl/{s.Path}" || w.FullName.TrimStart('/') == s.Path?.TrimStart('/')); } else @@ -497,14 +496,18 @@ await reader.SkipAsync() { if (reader.IsStartElement("sheet", Ns)) { - yield return new SheetRecord( - reader.GetAttribute("name"), - reader.GetAttribute("state"), - uint.Parse(reader.GetAttribute("sheetId")), - reader.GetAttribute("id", RelationshiopNs), - sheetCount == activeSheetIndex - ); - sheetCount++; + if (reader.GetAttribute("name") is { } sheetName and not "") + { + yield return new SheetRecord( + sheetName, + reader.GetAttribute("state"), + uint.TryParse(reader.GetAttribute("sheetId"), out var sheetId) ? sheetId : 0, + reader.GetAttribute("id", RelationshiopNs), + sheetCount == activeSheetIndex + ); + sheetCount++; + } + await reader.SkipAsync() #if NET .WaitAsync(cancellationToken) @@ -657,7 +660,7 @@ private void ConvertCellValue(string rawValue, string aT, int xfIndex, out objec case ExcelDataTypes.SharedString: if (int.TryParse(rawValue, style, invariantCulture, out var sstIndex)) { - if (sstIndex >= 0 && sstIndex < SharedStrings?.Count) + if (sstIndex >= 0 && sstIndex < SharedStrings.Count) { value = XmlHelper.DecodeString(SharedStrings[sstIndex]); } diff --git a/src/MiniExcel/MiniExcelConverter.cs b/src/MiniExcel/MiniExcelConverter.cs index 8e860a17..ae500a97 100644 --- a/src/MiniExcel/MiniExcelConverter.cs +++ b/src/MiniExcel/MiniExcelConverter.cs @@ -1,4 +1,3 @@ -using MiniExcelLib.Core; using MiniExcelLib.Core.Helpers; using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; diff --git a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs index 356c356e..d3cb0975 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs @@ -64,27 +64,6 @@ public void SaveAsControlChracter() var rows1 = _excelImporter.Query(path.ToString()).Select(s => s.Test).ToArray(); } - private class SaveAsControlChracterVO - { - public string Test { get; set; } - } - - private class ExcelAttributeDemo - { - [MiniExcelColumnName("Column1")] - public string Test1 { get; set; } - [MiniExcelColumnName("Column2")] - public string Test2 { get; set; } - [MiniExcelIgnore] - public string Test3 { get; set; } - [MiniExcelColumnIndex("I")] // system will convert "I" to 8 index - public string Test4 { get; set; } - public string Test5 { get; } //wihout set will ignore - public string Test6 { get; private set; } //un-public set will ignore - [MiniExcelColumnIndex(3)] // start with 0 - public string Test7 { get; set; } - } - [Fact] public void CustomAttributeWihoutVaildPropertiesTest() { @@ -133,16 +112,6 @@ public void SaveAsCustomAttributesTest() Assert.Null(rows[0].Test6); } - private class CustomAttributesWihoutVaildPropertiesTestPoco - { - [MiniExcelIgnore] - public string Test3 { get; set; } - public string Test5 { get; } - public string Test6 { get; private set; } - } - - - [Fact] public void QueryCastToIDictionary() { @@ -317,15 +286,10 @@ public void TestDynamicQueryBasic_hasHeaderRow() } } - private class DemoPocoHelloWorld - { - public string HelloWorld1 { get; set; } - } - public class UserAccount { public Guid ID { get; set; } - public string Name { get; set; } + public string? Name { get; set; } public DateTime BoD { get; set; } public int Age { get; set; } public bool VIP { get; set; } @@ -365,27 +329,12 @@ public void QueryStrongTypeMapping_Test() } } - private class AutoCheckType - { - public Guid? Guid { get; set; } - public bool? Bool { get; set; } - public DateTime? Datetime { get; set; } - public string String { get; set; } - } - [Fact] public void AutoCheckTypeTest() { var path = PathHelper.GetFile("xlsx/TestTypeMapping_AutoCheckFormat.xlsx"); using var stream = FileHelper.OpenRead(path); - var rows = _excelImporter.Query(stream).ToList(); - } - - private class ExcelUriDemo - { - public string Name { get; set; } - public int Age { get; set; } - public Uri Url { get; set; } + var rows = _excelImporter.Query(stream).ToList(); } [Fact] @@ -400,13 +349,6 @@ public void UriMappingTest() Assert.Equal(new Uri("https://friendly-utilization.net"), rows[1].Url); } - private class SimpleAccount - { - public string Name { get; set; } - public int Age { get; set; } - public string Mail { get; set; } - public decimal Points { get; set; } - } [Fact] public void TrimColumnNamesTest() { @@ -478,16 +420,6 @@ public void QueryDataReaderCheckTest(string path) } } - [Fact] - public void QueryCustomStyle() - { - var path = PathHelper.GetFile("xlsx/TestWihoutRAttribute.xlsx"); - using (var stream = File.OpenRead(path)) - { - // TODO: does this need filling? - } - } - [Fact] public void QuerySheetWithoutRAttribute() { @@ -521,11 +453,6 @@ public void FixDimensionJustOneColumnParsingError_Test() Assert.Equal(2, rows.Count); } - private class SaveAsFileWithDimensionByICollectionTestType - { - public string A { get; set; } - public string B { get; set; } - } [Fact] public void SaveAsFileWithDimensionByICollection() { @@ -697,7 +624,7 @@ public void SaveAsFileWithDimension() public void SaveAsByDataTableTest() { { - var now = DateTime.Now; + var now = new DateTime(2026, 6, 2, 15, 1, 47); using var file = AutoDeletingPath.Create(); var path = file.ToString(); @@ -721,8 +648,8 @@ public void SaveAsByDataTableTest() Assert.Equal(@"""<>+-*//}{\\n", ws.Cells["A2"].Value.ToString()); Assert.Equal("1234567890", ws.Cells["B2"].Value.ToString()); - Assert.True(ws.Cells["C2"].Value.ToString() == true.ToString()); - Assert.True(ws.Cells["D2"].Value.ToString() == now.ToString()); + Assert.True(ws.Cells["C2"].GetCellValue()); + Assert.Equal(now, ws.Cells["D2"].GetCellValue()); Assert.Equal("R&D", ws.Name); } @@ -849,7 +776,7 @@ public void SaveAsFrozenRowsAndColumnsTest() // Test enumerable using var path = AutoDeletingPath.Create(); - _excelExporter.Export( + _excelExporter.Export( path.ToString(), new[] { @@ -881,7 +808,7 @@ public void SaveAsFrozenRowsAndColumnsTest() table.Rows.Add("Hello World", -1234567890, false, DateTime.Now.Date); using var pathTable = AutoDeletingPath.Create(); - _excelExporter.Export(pathTable.ToString(), table, configuration: config); + _excelExporter.Export(pathTable.ToString(), table, configuration: config); Assert.Equal("A1:D3", SheetHelper.GetFirstSheetDimensionRefValue(pathTable.ToString())); // data reader @@ -972,7 +899,7 @@ public void SaveAsByDapperRows() private class Demo { - public string Column1 { get; set; } + public string? Column1 { get; set; } public decimal Column2 { get; set; } } [Fact] @@ -1153,7 +1080,7 @@ public void SaveAsSpecialAndTypeCreateTest() [Fact] public void SaveAsFileEpplusCanReadTest() { - var now = DateTime.Now; + var now = new DateTime(2026, 6, 2, 15, 2, 33); using var path = AutoDeletingPath.Create(); var rowsWritten = _excelExporter.Export(path.ToString(), new[] { @@ -1173,14 +1100,14 @@ public void SaveAsFileEpplusCanReadTest() Assert.Equal(@"""<>+-*//}{\\n", ws.Cells["A2"].Value.ToString()); Assert.Equal("1234567890", ws.Cells["B2"].Value.ToString()); - Assert.True(ws.Cells["C2"].Value.ToString() == true.ToString()); - Assert.True(ws.Cells["D2"].Value.ToString() == now.ToString()); + Assert.True(ws.Cells["C2"].GetCellValue()); + Assert.Equal(now, ws.Cells["D2"].GetValue()); } [Fact] public void SavaAsClosedXmlCanReadTest() { - var now = DateTime.Now; + var now = new DateTime(2026, 6, 2, 15, 3, 19); using var path = AutoDeletingPath.Create(); var rowsWritten = _excelExporter.Export(path.ToString(), new[] { @@ -1201,8 +1128,8 @@ public void SavaAsClosedXmlCanReadTest() Assert.Equal(@"""<>+-*//}{\\n", ws.Cell("A2").Value.ToString()); Assert.Equal("1234567890", ws.Cell("B2").Value.ToString()); - Assert.Equal(bool.TrueString, ws.Cell("C2").Value.ToString(), ignoreCase: true); - Assert.True(ws.Cell("D2").Value.ToString() == now.ToString()); + Assert.True(ws.Cell("C2").GetValue()); + Assert.Equal(now, ws.Cell("D2").GetDateTime()); Assert.Equal("R&D", ws.Name); } @@ -1412,7 +1339,7 @@ public void DynamicColumnsConfigurationIsUsedWhenCreatingExcelUsingDataTable() [Fact] public void InsertSheetTest() { - var now = DateTime.Now; + var now = new DateTime(2026, 6, 2, 15, 4, 51); using var file = AutoDeletingPath.Create(); var path = file.ToString(); @@ -1438,8 +1365,8 @@ public void InsertSheetTest() Assert.Equal(@"""<>+-*//}{\\n", sheet1.Cells["A2"].Value.ToString()); Assert.Equal("1234567890", sheet1.Cells["B2"].Value.ToString()); - Assert.True(sheet1.Cells["C2"].Value.ToString() == true.ToString()); - Assert.True(sheet1.Cells["D2"].Value.ToString() == now.ToString()); + Assert.True(sheet1.Cells["C2"].GetCellValue()); + Assert.Equal(now, sheet1.Cells["D2"].GetCellValue()); Assert.Equal("Sheet1", sheet1.Name); } @@ -1622,7 +1549,7 @@ public void SheetDimensionsTest_MultiSheet() private class ExcelFieldMappingTest { [MiniExcelColumnName("Column1")] - public string Test1; + public string? Test1; [MiniExcelColumnName("Column2")] public int Test2; @@ -1671,10 +1598,10 @@ public void QueryFieldsAsDynamicTest() private class MixedFieldPropertyTest { [MiniExcelColumnName("F1")] - public string Field1; + public string? Field1; [MiniExcelColumnName("P1")] - public string Prop1 { get; set; } + public string? Prop1 { get; set; } } [Fact] @@ -1695,10 +1622,10 @@ public void ExportAndQueryMixedFieldAndPropertyTest() private class FieldsWithoutAttributeTest { // field without attribute should not be included for export - public string NotMappedField; + public string? NotMappedField; [MiniExcelColumnName("Mapped")] - public string MappedField; + public string? MappedField; } [Fact] diff --git a/tests/MiniExcel.OpenXml.Tests/Main/Models.cs b/tests/MiniExcel.OpenXml.Tests/Main/Models.cs index 9d607630..d05c51e0 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/Models.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/Models.cs @@ -29,6 +29,21 @@ internal class DemoPocoHelloWorld public string? HelloWorld1 { get; set; } } +internal class ExcelUriDemo +{ + public string? Name { get; set; } + public int Age { get; set; } + public Uri? Url { get; set; } +} + +internal class SimpleAccount +{ + public string? Name { get; set; } + public int Age { get; set; } + public string? Mail { get; set; } + public decimal Points { get; set; } +} + internal class SaveAsControlChracterVO { public string? Test { get; set; } From d7850904399bce8f19d375763967dde4a1673a29 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 2 Jun 2026 20:28:15 +0200 Subject: [PATCH 5/8] Added async dispose pattern to OpenXmlReader and CsvReader Changed `IMiniExcelReader` interface to inherit from `IAsyncDisposable`, implemented pattern in `OpenXmlReader` and `CsvReader`. Also addressed `ZipArchive` not being disposed correctly at the end of a session and updated the tests that were affected. --- V2-Upgrade-Notes.md | 3 +- .../Abstractions/IMiniExcelReader.cs | 2 +- src/MiniExcel.Core/MiniExcelDataReader.cs | 11 ++-- src/MiniExcel.Csv/Api/CsvImporter.cs | 23 +++++--- src/MiniExcel.Csv/CsvConfiguration.cs | 6 ++- src/MiniExcel.Csv/CsvReader.cs | 18 ++++++- src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 52 +++++++++++++------ src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 27 +++++++--- .../Writer/OpenXmlWriter.CopyInsert.cs | 4 +- src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs | 4 +- src/MiniExcel/MiniExcel.cs | 34 ++++++------ src/MiniExcel/MiniExcelConverter.cs | 8 +-- .../Issues/MiniExcelGithubIssuesAsyncTests.cs | 8 +-- .../Main/MiniExcelOpenXmlAsyncTests.cs | 2 +- .../Main/MiniExcelOpenXmlTests.cs | 10 ++-- ...MiniExcelOpenXmlMultipleSheetAsyncTests.cs | 20 ++++--- .../MiniExcelOpenXmlMultipleSheetTests.cs | 30 +++++------ 17 files changed, 162 insertions(+), 100 deletions(-) diff --git a/V2-Upgrade-Notes.md b/V2-Upgrade-Notes.md index 53bdff76..62a80ea6 100644 --- a/V2-Upgrade-Notes.md +++ b/V2-Upgrade-Notes.md @@ -15,4 +15,5 @@ so the return type for `OpenXmlImporter.QueryAsync` is `IAsyncEnumerable` ins - When applying a template, unlike version 1.x, the flag for overwriting an already existing file must be provided explicitly. - `leaveOpen` parameter has been added to most methods that take a stream as input in both `OpenXmlImporter` and `CsvImporter` to configure whether the stream must be disposed after the operation performed is completed. - `useHeaderRow` parameter in multiple `OpenXmlImporter` methods has been renamed to `hasHeaderRow` for making its usage clearer. -- `CsvExporter.Export` API methods, not being required to return the same type of `OpenXmlExporter.Export`, now return `int` instead of `int[]`. \ No newline at end of file +- `CsvExporter.Export` API methods, not being required to return the same type of `OpenXmlExporter.Export`, now return `int` instead of `int[]`. +- Most `OpenXmlImporter` and `CsvImporter` methods that take a stream as input now take an additional `leaveOpen` boolean parameter, to set to `true` explicitly if you want the stream to be left open at the of the operation. \ No newline at end of file diff --git a/src/MiniExcel.Core/Abstractions/IMiniExcelReader.cs b/src/MiniExcel.Core/Abstractions/IMiniExcelReader.cs index f2a58c86..c2813aa1 100644 --- a/src/MiniExcel.Core/Abstractions/IMiniExcelReader.cs +++ b/src/MiniExcel.Core/Abstractions/IMiniExcelReader.cs @@ -1,6 +1,6 @@ namespace MiniExcelLib.Core.Abstractions; -public partial interface IMiniExcelReader : IDisposable +public partial interface IMiniExcelReader : IDisposable, IAsyncDisposable { [CreateSyncVersion] IAsyncEnumerable> QueryAsync(bool hasHeaderRow, string? sheetName, string startCell, CancellationToken cancellationToken = default); diff --git a/src/MiniExcel.Core/MiniExcelDataReader.cs b/src/MiniExcel.Core/MiniExcelDataReader.cs index 1128a815..e76d30b5 100644 --- a/src/MiniExcel.Core/MiniExcelDataReader.cs +++ b/src/MiniExcel.Core/MiniExcelDataReader.cs @@ -21,7 +21,7 @@ public object this[int i] public object this[string name] => GetValue(GetOrdinal(name)); - public int Depth { get; private set; } = -1; + public int Depth => 0; public int FieldCount { get; private set; } public bool IsClosed { get; private set; } public int RecordsAffected => 0; @@ -82,7 +82,6 @@ public bool Read() if (_isAsyncSource) throw new InvalidOperationException("The data reader was configured to execute asynchronously"); - Depth++; if (_isFirst) { _isFirst = false; @@ -100,7 +99,6 @@ public async Task ReadAsync(CancellationToken cancellationToken = default) if (!_isAsyncSource) return await Task.FromResult(Read()).ConfigureAwait(false); - Depth++; if (_isFirst) { _isFirst = false; @@ -290,10 +288,9 @@ public void Close() if (_isAsyncSource) { - if (_asyncSource is IDisposable disposable) - disposable.Dispose(); - else - _asyncSource!.DisposeAsync(); // fire and forget if all other options are exhausted + if (_asyncSource is IDisposable disposable) disposable.Dispose(); + // necessary fallback when the synchronous Close is called despite the data reader being initialized asynchronously + else Task.Run(async () => await _asyncSource!.DisposeAsync().ConfigureAwait(false)).GetAwaiter().GetResult(); } else { diff --git a/src/MiniExcel.Csv/Api/CsvImporter.cs b/src/MiniExcel.Csv/Api/CsvImporter.cs index 79eb3c8a..e512d8e4 100644 --- a/src/MiniExcel.Csv/Api/CsvImporter.cs +++ b/src/MiniExcel.Csv/Api/CsvImporter.cs @@ -45,8 +45,10 @@ public async IAsyncEnumerable QueryAsync(Stream stream, bool treatHeaderAs CsvConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { - using var csv = new CsvReader(stream, configuration, leaveOpen); - await foreach (var item in csv.QueryAsync(null, "A1", treatHeaderAsData, cancellationToken).ConfigureAwait(false)) + var reader = new CsvReader(stream, configuration, leaveOpen); + await using var disposableReader = reader.ConfigureAwait(false); + + await foreach (var item in reader.QueryAsync(null, "A1", treatHeaderAsData, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -86,8 +88,10 @@ public async IAsyncEnumerable QueryAsync(string path, bool hasHeaderRow public async IAsyncEnumerable QueryAsync(Stream stream, bool hasHeaderRow = false, CsvConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using var excelReader = new CsvReader(stream, configuration, leaveOpen); - await foreach (var item in excelReader.QueryAsync(hasHeaderRow, null, "A1", cancellationToken).ConfigureAwait(false)) + var reader = new CsvReader(stream, configuration, leaveOpen); + await using var disposableReader = reader.ConfigureAwait(false); + + await foreach (var item in reader.QueryAsync(hasHeaderRow, null, "A1", cancellationToken).ConfigureAwait(false)) yield return item; } @@ -134,7 +138,9 @@ public async Task QueryAsDataTableAsync(Stream stream, bool hasHeader { var dt = new DataTable(); var first = true; - using var reader = new CsvReader(stream, configuration, leaveOpen); + var reader = new CsvReader(stream, configuration, leaveOpen); + await using var disposableReader = reader.ConfigureAwait(false); + var rows = reader.QueryAsync(false, null, "A1", cancellationToken); var columnDict = new Dictionary(); @@ -194,7 +200,7 @@ public async Task> GetColumnNamesAsync(string path, bool has { var stream = FileHelper.OpenSharedRead(path); await using var disposableStream = stream.ConfigureAwait(false); - return await GetColumnNamesAsync(stream, hasHeaderRow, configuration, cancellationToken).ConfigureAwait(false); + return await GetColumnNamesAsync(stream, hasHeaderRow, configuration, false, cancellationToken).ConfigureAwait(false); } /// @@ -203,13 +209,14 @@ public async Task> GetColumnNamesAsync(string path, bool has /// The stream containing the CSV data. /// If true, the first row values are used as column names. If false, column letters (A, B, C, etc.) are used. Default is false. /// Optional configuration settings (delimiters, encoding, etc.). + /// True to leave the stream open after the operation is completed, otherwise false. /// A token to cancel the asynchronous operation. /// A collection of column names from the specified location, or an empty collection if the sheet is empty. [CreateSyncVersion] public async Task> GetColumnNamesAsync(Stream stream, bool hasHeaderRow = false, - CsvConfiguration? configuration = null, CancellationToken cancellationToken = default) + CsvConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { - var enumerator = QueryAsync(stream, hasHeaderRow, configuration, leaveOpen: false, cancellationToken).GetAsyncEnumerator(cancellationToken); + var enumerator = QueryAsync(stream, hasHeaderRow, configuration, leaveOpen: leaveOpen, cancellationToken).GetAsyncEnumerator(cancellationToken); await using var disposableEnumerator = enumerator.ConfigureAwait(false); if (await enumerator.MoveNextAsync().ConfigureAwait(false)) diff --git a/src/MiniExcel.Csv/CsvConfiguration.cs b/src/MiniExcel.Csv/CsvConfiguration.cs index 3334f5c7..54bbc2ef 100644 --- a/src/MiniExcel.Csv/CsvConfiguration.cs +++ b/src/MiniExcel.Csv/CsvConfiguration.cs @@ -15,6 +15,8 @@ public class CsvConfiguration : MiniExcelBaseConfiguration public bool AlwaysQuote { get; set; } = false; public bool QuoteWhitespaces { get; set; } = true; public Func? SplitFn { get; set; } - public Func StreamReaderFunc { get; set; } = stream => new StreamReader(stream, DefaultEncoding); + + // we leave the stream open by default and close it in the CsvReader unless the consumer decides to keep it open. + public Func StreamReaderFunc { get; set; } = stream => new StreamReader(stream, DefaultEncoding, true, 1024, true); public Func StreamWriterFunc { get; set; } = stream => new StreamWriter(stream, DefaultEncoding); -} \ No newline at end of file +} diff --git a/src/MiniExcel.Csv/CsvReader.cs b/src/MiniExcel.Csv/CsvReader.cs index 994d46c0..a53e9ee7 100644 --- a/src/MiniExcel.Csv/CsvReader.cs +++ b/src/MiniExcel.Csv/CsvReader.cs @@ -11,6 +11,8 @@ internal sealed partial class CsvReader : IMiniExcelReader private readonly bool _leaveOpen; private readonly CsvConfiguration _config; + private bool _disposed; + internal CsvReader(Stream stream, IMiniExcelConfiguration? configuration, bool leaveOpen = false) { _stream = stream; @@ -29,7 +31,7 @@ internal CsvReader(Stream stream, IMiniExcelConfiguration? configuration, bool l if (_stream.CanSeek) _stream.Position = 0; - var reader = _config.StreamReaderFunc(_stream); + using var reader = _config.StreamReaderFunc(_stream); var firstRow = true; var headRows = new Dictionary(); @@ -174,7 +176,19 @@ private string[] Split(string row) public void Dispose() { - if (!_leaveOpen) + if (!_leaveOpen && !_disposed) + { _stream.Dispose(); + _disposed = true; + } + } + + public async ValueTask DisposeAsync() + { + if (!_leaveOpen && !_disposed) + { + await _stream.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } } } diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 0c9ae1e5..85d70914 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -48,7 +48,9 @@ public async IAsyncEnumerable QueryAsync(Stream stream, string? sheetName string startCell = "A1", bool treatHeaderAsData = false, OpenXmlConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { - using var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var item in reader.QueryAsync(sheetName, startCell, treatHeaderAsData, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -95,7 +97,9 @@ public async IAsyncEnumerable QueryAsync(Stream stream, bool hasHeaderR string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var item in reader.QueryAsync(hasHeaderRow, sheetName, startCell, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -142,7 +146,9 @@ public async IAsyncEnumerable QueryRangeAsync(Stream stream, bool hasHe string? sheetName = null, string startCell = "A1", string? endCell = null, OpenXmlConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var item in reader.QueryRangeAsync(hasHeaderRow, sheetName, startCell, endCell, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -191,7 +197,9 @@ public async IAsyncEnumerable QueryRangeAsync(Stream stream, bool hasHe int? endColumnIndex = null, OpenXmlConfiguration? configuration = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var item in reader.QueryRangeAsync(hasHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -243,11 +251,13 @@ public async Task QueryAsDataTableAsync(Stream stream, bool hasHeader string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { - sheetName ??= (await GetSheetNamesAsync(stream, false, cancellationToken).ConfigureAwait(false)).First(); + sheetName ??= (await GetSheetNamesAsync(stream, true, cancellationToken).ConfigureAwait(false)).First(); var dt = new DataTable(sheetName); var first = true; - using var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + var rows = reader.QueryAsync(false, sheetName, startCell, cancellationToken); var columnDict = new Dictionary(); @@ -326,7 +336,9 @@ public async Task> GetSheetNamesAsync(Stream stream, bool leaveOpen { var archive = await OpenXmlZip.CreateAsync(stream, leaveOpen: true, cancellationToken: cancellationToken).ConfigureAwait(false); await using var disposableArchive = archive.ConfigureAwait(false); - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); var rels = await OpenXmlReader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); return rels?.Select(s => s.Name).ToList() ?? []; @@ -364,9 +376,7 @@ public async Task> GetSheetInformationsAsync(string path, Cancel public async Task> GetSheetInformationsAsync(Stream stream, bool leaveOpen = false, CancellationToken cancellationToken = default) { var archive = await OpenXmlZip.CreateAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); - await using var disposableArchve = archive.ConfigureAwait(false); - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); var rels = await OpenXmlReader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); return rels?.Select((s, i) => s.ToSheetInfo((uint)i)).ToList() ?? []; @@ -409,7 +419,9 @@ public async Task> GetSheetDimensionsAsync(string path, Cancel [CreateSyncVersion] public async Task> GetSheetDimensionsAsync(Stream stream, bool leaveOpen = false, CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + return await reader.GetDimensionsAsync(cancellationToken).ConfigureAwait(false); } @@ -494,7 +506,9 @@ public async Task RetrieveCommentsAsync(string path, string? s [CreateSyncVersion] public async Task RetrieveCommentsAsync(Stream stream, string? sheetName = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false); } @@ -515,7 +529,9 @@ public async IAsyncEnumerable QueryTableAsync(string path, string? shee var stream = FileHelper.OpenSharedRead(path); await using var disposableStream = stream.ConfigureAwait(false); - using var reader = await OpenXmlReader.CreateAsync(stream, null, false, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, false, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var table in reader.QueryTableAsync(sheetName, tableName, false, cancellationToken).ConfigureAwait(false)) yield return table; } @@ -535,7 +551,9 @@ public async IAsyncEnumerable QueryTableAsync(string path, string? shee [CreateSyncVersion] public async IAsyncEnumerable QueryTableAsync(Stream stream, string? sheetName = null, string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var table in reader.QueryTableAsync(sheetName, tableName, false, cancellationToken).ConfigureAwait(false)) yield return table; } @@ -559,7 +577,9 @@ public async IAsyncEnumerable QueryTableAsync(string path, string? sheetNa var stream = FileHelper.OpenSharedRead(path); await using var disposableStream = stream.ConfigureAwait(false); - using var reader = await OpenXmlReader.CreateAsync(stream, null, false, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, false, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var table in reader.QueryTableAsync(sheetName, tableName, cancellationToken).ConfigureAwait(false)) yield return table; } @@ -581,7 +601,9 @@ public async IAsyncEnumerable QueryTableAsync(string path, string? sheetNa public async IAsyncEnumerable QueryTableAsync(Stream stream, string? sheetName = null, string tableName = "Table1", bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { - using var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + await foreach (var table in reader.QueryTableAsync(sheetName, tableName, cancellationToken).ConfigureAwait(false)) yield return table; } diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index c07493dd..cdf339ca 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -394,7 +394,7 @@ private static void SetCellsValueAndHeaders(object? cellValue, bool hasHeaderRow { var cellValueString = cellValue?.ToString(); if (!string.IsNullOrWhiteSpace(cellValueString)) - headRows.Add(columnIndex, cellValueString); + headRows.Add(columnIndex, cellValueString!); } else if (headRows.TryGetValue(columnIndex, out var key)) { @@ -977,14 +977,29 @@ internal static async Task TryGetMaxRowColumnIndexAs return new GetMaxRowColumnIndexResult(true, withoutCr, maxRowIndex, maxColumnIndex); } - public void Dispose() + private void DisposeCore() { - if (_disposed) - return; - if (SharedStrings is SharedStringsDiskCache cache) cache.Dispose(); + } + + public void Dispose() + { + if (!_disposed) + { + DisposeCore(); + Archive.Dispose(); + _disposed = true; + } + } - _disposed = true; + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + DisposeCore(); + await Archive.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } } } diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs index cef48071..4092b7ba 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs @@ -48,7 +48,9 @@ public async Task CopyAndInsertAsync(bool overwriteSheet = false, IProgress using var disposableOldArchive = _oldArchive; using var disposableNewArchive = _archive; #endif - using var reader = await OpenXmlReader.CreateAsync(_oldStream!, _configuration, cancellationToken: cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(_oldStream!, _configuration, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + var rels = await OpenXmlReader.GetWorkbookRelsAsync(_oldArchive!.Entries, cancellationToken).ConfigureAwait(false) ?? []; _sheets.AddRange(rels diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs index a0af25a3..30c1f2bb 100644 --- a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs @@ -105,7 +105,9 @@ public async Task InsertAsync(bool overwriteSheet = false, IProgress? #endif await using var sbc = _sheetStyleBuilderContext.ConfigureAwait(false); - using var reader = await OpenXmlReader.CreateAsync(_stream, _configuration, cancellationToken: cancellationToken).ConfigureAwait(false); + var reader = await OpenXmlReader.CreateAsync(_stream, _configuration, leaveOpen: true, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + var rels = await OpenXmlReader.GetWorkbookRelsAsync(_archive.Entries, cancellationToken).ConfigureAwait(false) ?? []; _sheets.AddRange(rels diff --git a/src/MiniExcel/MiniExcel.cs b/src/MiniExcel/MiniExcel.cs index a30c2631..1ad3817c 100644 --- a/src/MiniExcel/MiniExcel.cs +++ b/src/MiniExcel/MiniExcel.cs @@ -47,8 +47,8 @@ public static MiniExcelDataReader GetReader(this Stream stream, bool useHeaderRo var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => ExcelImporter.GetDataReader(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration), - ExcelType.CSV => CsvImporter.GetDataReader(stream, useHeaderRow), + ExcelType.XLSX => ExcelImporter.GetDataReader(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration, leaveOpen: true), + ExcelType.CSV => CsvImporter.GetDataReader(stream, useHeaderRow, leaveOpen: true), _ => throw new NotSupportedException($"Type {type} is not a valid Excel type") }; } @@ -127,8 +127,8 @@ public static async Task SaveAsAsync(this Stream stream, object value, bo var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => ExcelImporter.QueryAsync(stream, sheetName, startCell, hasHeader, configuration as NewOpenXmlConfiguration, false, cancellationToken), - ExcelType.CSV => CsvImporter.QueryAsync(stream, hasHeader, configuration as Csv.CsvConfiguration, false, cancellationToken), + ExcelType.XLSX => ExcelImporter.QueryAsync(stream, sheetName, startCell, hasHeader, configuration as NewOpenXmlConfiguration, leaveOpen: true, cancellationToken), + ExcelType.CSV => CsvImporter.QueryAsync(stream, hasHeader, configuration as Csv.CsvConfiguration, leaveOpen: true, cancellationToken), _ => throw new InvalidDataException($"Type {type} is not a valid Excel type") }; } @@ -151,8 +151,8 @@ public static IAsyncEnumerable QueryAsync(this Stream stream, bool useH var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => ExcelImporter.QueryAsync(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration, false, cancellationToken), - ExcelType.CSV => CsvImporter.QueryAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration,false, cancellationToken), + ExcelType.XLSX => ExcelImporter.QueryAsync(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration, leaveOpen: true, cancellationToken), + ExcelType.CSV => CsvImporter.QueryAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration,leaveOpen: true, cancellationToken), _ => throw new InvalidDataException($"Type {type} is not a valid Excel type") }; } @@ -184,8 +184,8 @@ public static IAsyncEnumerable QueryRangeAsync(this Stream stream, bool var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => ExcelImporter.QueryRangeAsync(stream, useHeaderRow, sheetName, startCell, endCell, configuration as NewOpenXmlConfiguration, false, cancellationToken), - ExcelType.CSV => CsvImporter.QueryAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, false, cancellationToken), + ExcelType.XLSX => ExcelImporter.QueryRangeAsync(stream, useHeaderRow, sheetName, startCell, endCell, configuration as NewOpenXmlConfiguration, leaveOpen: true, cancellationToken), + ExcelType.CSV => CsvImporter.QueryAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, leaveOpen: true, cancellationToken), _ => throw new InvalidDataException($"Type {type} is not a valid Excel type") }; } @@ -208,7 +208,7 @@ public static IAsyncEnumerable QueryRangeAsync(this Stream stream, bool var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => ExcelImporter.QueryRangeAsync(stream, useHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, configuration as NewOpenXmlConfiguration, false, cancellationToken), + ExcelType.XLSX => ExcelImporter.QueryRangeAsync(stream, useHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, configuration as NewOpenXmlConfiguration, leaveOpen: true, cancellationToken), ExcelType.CSV => throw new NotSupportedException("QueryRange is not supported for csv"), _ => throw new InvalidDataException($"Type {type} is not a valid Excel type") }; @@ -291,8 +291,8 @@ public static async Task QueryAsDataTableAsync(this Stream stream, bo var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => await ExcelImporter.QueryAsDataTableAsync(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration, false, cancellationToken).ConfigureAwait(false), - ExcelType.CSV => await CsvImporter.QueryAsDataTableAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, false, cancellationToken).ConfigureAwait(false), + ExcelType.XLSX => await ExcelImporter.QueryAsDataTableAsync(stream, useHeaderRow, sheetName, startCell, configuration as NewOpenXmlConfiguration, leaveOpen: true, cancellationToken).ConfigureAwait(false), + ExcelType.CSV => await CsvImporter.QueryAsDataTableAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, leaveOpen: true, cancellationToken).ConfigureAwait(false), _ => throw new InvalidDataException($"Excel type {type} is not a valid Excel type") }; } @@ -303,7 +303,7 @@ public static async Task> GetSheetNamesAsync(string path, NewOpenXm [CreateSyncVersion] public static async Task> GetSheetNamesAsync(this Stream stream, NewOpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) - => await ExcelImporter.GetSheetNamesAsync(stream, false, cancellationToken).ConfigureAwait(false); + => await ExcelImporter.GetSheetNamesAsync(stream, true, cancellationToken).ConfigureAwait(false); [CreateSyncVersion] public static async Task> GetSheetInformationsAsync(string path, NewOpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) @@ -311,7 +311,7 @@ public static async Task> GetSheetInformationsAsync(string path, [CreateSyncVersion] public static async Task> GetSheetInformationsAsync(this Stream stream, NewOpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) - => await ExcelImporter.GetSheetInformationsAsync(stream, false, cancellationToken).ConfigureAwait(false); + => await ExcelImporter.GetSheetInformationsAsync(stream, true, cancellationToken).ConfigureAwait(false); [CreateSyncVersion] public static async Task> GetColumnsAsync(string path, bool useHeaderRow = false, string? sheetName = null, ExcelType excelType = ExcelType.UNKNOWN, string startCell = "A1", IConfiguration? configuration = null, CancellationToken cancellationToken = default) @@ -331,8 +331,8 @@ public static async Task> GetColumnsAsync(this Stream stream var type = stream.GetExcelType(excelType); return type switch { - ExcelType.XLSX => await ExcelImporter.GetColumnNamesAsync(stream, useHeaderRow, sheetName, startCell, false, cancellationToken).ConfigureAwait(false), - ExcelType.CSV => await CsvImporter.GetColumnNamesAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, cancellationToken).ConfigureAwait(false), + ExcelType.XLSX => await ExcelImporter.GetColumnNamesAsync(stream, useHeaderRow, sheetName, startCell, leaveOpen: true, cancellationToken).ConfigureAwait(false), + ExcelType.CSV => await CsvImporter.GetColumnNamesAsync(stream, useHeaderRow, configuration as Csv.CsvConfiguration, leaveOpen: true, cancellationToken).ConfigureAwait(false), _ => throw new InvalidDataException($"Excel type {type} is not a valid Excel type") }; } @@ -343,7 +343,7 @@ public static async Task> GetSheetDimensionsAsync(string path, [CreateSyncVersion] public static async Task> GetSheetDimensionsAsync(this Stream stream, CancellationToken cancellationToken = default) - => await ExcelImporter.GetSheetDimensionsAsync(stream, false, cancellationToken).ConfigureAwait(false); + => await ExcelImporter.GetSheetDimensionsAsync(stream, true, cancellationToken).ConfigureAwait(false); [CreateSyncVersion] public static async Task ConvertCsvToXlsxAsync(string csv, string xlsx, CancellationToken cancellationToken = default) @@ -360,4 +360,4 @@ public static async Task ConvertXlsxToCsvAsync(string xlsx, string csv, Cancella [CreateSyncVersion] public static async Task ConvertXlsxToCsvAsync(Stream xlsx, Stream csv, CancellationToken cancellationToken = default) => await MiniExcelConverter.ConvertXlsxToCsvAsync(xlsx, csv, cancellationToken: cancellationToken).ConfigureAwait(false); -} \ No newline at end of file +} diff --git a/src/MiniExcel/MiniExcelConverter.cs b/src/MiniExcel/MiniExcelConverter.cs index ae500a97..331f9d45 100644 --- a/src/MiniExcel/MiniExcelConverter.cs +++ b/src/MiniExcel/MiniExcelConverter.cs @@ -7,12 +7,13 @@ namespace MiniExcelLib; public static partial class MiniExcelConverter { + /// The origin stream is left open. [CreateSyncVersion] public static async Task ConvertCsvToXlsxAsync(Stream csv, Stream xlsx, bool csvHasHeader = false, CancellationToken cancellationToken = default) { var value = MiniExcel.Importers .GetCsvImporter() - .QueryAsync(csv, hasHeaderRow: csvHasHeader, cancellationToken: cancellationToken); + .QueryAsync(csv, hasHeaderRow: csvHasHeader, leaveOpen: true, cancellationToken: cancellationToken); await MiniExcel.Exporters .GetOpenXmlExporter() @@ -38,16 +39,17 @@ public static async Task ConvertXlsxToCsvAsync(string xlsx, string csvPath, bool await ConvertXlsxToCsvAsync(xlsxStream, csvStream, xlsxHasHeader, cancellationToken).ConfigureAwait(false); } + /// The origin stream is left open. [CreateSyncVersion] public static async Task ConvertXlsxToCsvAsync(Stream xlsx, Stream csv, bool xlsxHasHeader = true, CancellationToken cancellationToken = default) { var value = MiniExcel.Importers .GetOpenXmlImporter() - .QueryAsync(xlsx, hasHeaderRow: xlsxHasHeader, cancellationToken: cancellationToken) + .QueryAsync(xlsx, hasHeaderRow: xlsxHasHeader, leaveOpen: true, cancellationToken: cancellationToken) .ConfigureAwait(false); await MiniExcel.Exporters .GetCsvExporter() .ExportAsync(csv, value, printHeader: xlsxHasHeader, cancellationToken: cancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs index a7961e7f..64210668 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesAsyncTests.cs @@ -848,12 +848,12 @@ public async Task Issue223() new() { { "A", Guid.NewGuid() }, { "B", "HelloWorld" } } ]; using var path = AutoDeletingPath.Create(); - var rowsWritten = await _excelExporter.ExportAsync(path.ToString(), value); + var rowsWritten = await _excelExporter.ExportAsync(path.ToString(), value); Assert.Single(rowsWritten); Assert.Equal(3, rowsWritten[0]); - using var dt = await _excelImporter.QueryAsDataTableAsync(path.ToString()); + using var dt = await _excelImporter.QueryAsDataTableAsync(path.ToString()); var columns = dt.Columns; Assert.Equal(typeof(object), columns[0].DataType); Assert.Equal(typeof(object), columns[1].DataType); @@ -911,7 +911,7 @@ public async Task Issue229() { var path = PathHelper.GetFile("xlsx/TestIssue229.xlsx"); - using var dt = await _excelImporter.QueryAsDataTableAsync(path); + using var dt = await _excelImporter.QueryAsDataTableAsync(path); foreach (DataColumn column in dt.Columns) { @@ -983,7 +983,7 @@ public async Task Issue233() { var path = PathHelper.GetFile("xlsx/TestIssue233.xlsx"); - using var dt = await _excelImporter.QueryAsDataTableAsync(path); + using var dt = await _excelImporter.QueryAsDataTableAsync(path); var rows = dt.Rows; Assert.Equal(0.55, rows[0]["Size"]); diff --git a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs index 7551dc33..c3c51b12 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs @@ -1484,7 +1484,7 @@ public async Task ExportDataTableWithProgressTest() Assert.Equal(cellCount, progress.Value); ms.Seek(0, SeekOrigin.Begin); - var resultDataTable = await _excelImporter.QueryAsDataTableAsync(ms); + var resultDataTable = await _excelImporter.QueryAsDataTableAsync(ms, leaveOpen: true); //Confirm the data is correct Assert.Equal(dataTable.Rows.Count, resultDataTable.Rows.Count); diff --git a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs index d3cb0975..913be34f 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlTests.cs @@ -720,7 +720,7 @@ public void SaveAsByIEnumerableIDictionary() using (var stream = File.OpenRead(path)) { - var rows = _excelImporter.Query(stream, hasHeaderRow: false).ToList(); + var rows = _excelImporter.Query(stream, hasHeaderRow: false, leaveOpen: true).ToList(); Assert.Equal("Column1", rows[0].A); Assert.Equal("Column2", rows[0].B); Assert.Equal("MiniExcel", rows[1].A); @@ -728,12 +728,12 @@ public void SaveAsByIEnumerableIDictionary() Assert.Equal("Github", rows[2].A); Assert.Equal(2, rows[2].B); - Assert.Equal("R&D", _excelImporter.GetSheetNames(stream)[0]); + Assert.Equal("R&D", _excelImporter.GetSheetNames(stream)[0]); } using (var stream = File.OpenRead(path)) { - var rows = _excelImporter.Query(stream, hasHeaderRow: true).ToList(); + var rows = _excelImporter.Query(stream, hasHeaderRow: true, leaveOpen: true).ToList(); Assert.Equal(2, rows.Count); Assert.Equal("MiniExcel", rows[0].Column1); @@ -741,7 +741,7 @@ public void SaveAsByIEnumerableIDictionary() Assert.Equal("Github", rows[1].Column1); Assert.Equal(2, rows[1].Column2); - Assert.Equal("success!", _excelImporter.GetSheetNames(stream)[1]); + Assert.Equal("success!", _excelImporter.GetSheetNames(stream)[1]); } Assert.Equal("A1:B3", SheetHelper.GetFirstSheetDimensionRefValue(path)); @@ -757,7 +757,7 @@ public void SaveAsByIEnumerableIDictionary() using (var stream = File.OpenRead(path)) { - var rows = _excelImporter.Query(stream, hasHeaderRow: false).ToList(); + var rows = _excelImporter.Query(stream, hasHeaderRow: false).ToList(); Assert.Equal(3, rows.Count); } diff --git a/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetAsyncTests.cs index eaa469d3..654cf78a 100644 --- a/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetAsyncTests.cs @@ -27,24 +27,22 @@ public async Task SpecifySheetNameQueryTest() Assert.Equal(2, rows3[0].B); await Assert.ThrowsAsync(async () => await _excelImporter.QueryAsync(path, sheetName: "xxxx").ToListAsync()); - await using var stream = File.OpenRead(path); - - var rows4 = await _excelImporter.QueryAsync(stream, sheetName: "Sheet3").Cast>().ToListAsync(); + var rows4 = await _excelImporter.QueryAsync(path, sheetName: "Sheet3").Cast>().ToListAsync(); Assert.Equal(5, rows4.Count); Assert.Equal(3d, rows4[0]["A"]); Assert.Equal(3d, rows4[0]["B"]); - var rows5 = await _excelImporter.QueryAsync(stream, sheetName: "Sheet2").Cast>().ToListAsync(); + var rows5 = await _excelImporter.QueryAsync(path, sheetName: "Sheet2").Cast>().ToListAsync(); Assert.Equal(12, rows5.Count); Assert.Equal(1d, rows5[0]["A"]); Assert.Equal(1d, rows5[0]["B"]); - var rows6 = await _excelImporter.QueryAsync(stream, sheetName: "Sheet1").Cast>().ToListAsync(); + var rows6 = await _excelImporter.QueryAsync(path, sheetName: "Sheet1").Cast>().ToListAsync(); Assert.Equal(12, rows6.Count); Assert.Equal(2d, rows6[0]["A"]); Assert.Equal(2d, rows6[0]["B"]); - var rows7 = await _excelImporter.QueryAsync(stream, sheetName: "Sheet1").Cast>().ToListAsync(); + var rows7 = await _excelImporter.QueryAsync(path, sheetName: "Sheet1").Cast>().ToListAsync(); Assert.Equal(12, rows7.Count); Assert.Equal(2d, rows7[0]["A"]); Assert.Equal(2d, rows7[0]["B"]); @@ -55,8 +53,8 @@ public async Task MultiSheetsQueryBasicTest() { var path = PathHelper.GetFile("xlsx/TestMultiSheet.xlsx"); await using var stream = File.OpenRead(path); - _ = await _excelImporter.QueryAsync(stream, sheetName: "Sheet1").ToListAsync(); - _ = await _excelImporter.QueryAsync(stream, sheetName: "Sheet2").ToListAsync(); + _ = await _excelImporter.QueryAsync(stream, sheetName: "Sheet1", leaveOpen: true).ToListAsync(); + _ = await _excelImporter.QueryAsync(stream, sheetName: "Sheet2", leaveOpen: true).ToListAsync(); _ = await _excelImporter.QueryAsync(stream, sheetName: "Sheet3").ToListAsync(); } @@ -75,12 +73,12 @@ public async Task MultiSheetsQueryTest() await using var stream = File.OpenRead(path); - var sheetNames2 = await _excelImporter.GetSheetNamesAsync(stream); - + var sheetNames2 = await _excelImporter.GetSheetNamesAsync(stream, leaveOpen: true); + Assert.Equal(["Sheet1", "Sheet2", "Sheet3"], sheetNames2); foreach (var sheetName in sheetNames2) { - _ = await _excelImporter.QueryAsync(stream, sheetName: sheetName).ToListAsync(); + _ = await _excelImporter.QueryAsync(stream, sheetName: sheetName, leaveOpen: true).ToListAsync(); } } } diff --git a/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetTests.cs b/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetTests.cs index 300eea8c..6cd94f6c 100644 --- a/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MultipleSheets/MiniExcelOpenXmlMultipleSheetTests.cs @@ -29,17 +29,17 @@ public void SpecifySheetNameQueryTest() using var stream = File.OpenRead(path); - var rows4 = _excelImporter.Query(stream, sheetName: "Sheet3").ToList(); + var rows4 = _excelImporter.Query(stream, sheetName: "Sheet3", leaveOpen: true).ToList(); Assert.Equal(5, rows4.Count); Assert.Equal(3, rows4[0].A); Assert.Equal(3, rows4[0].B); - var rows5 = _excelImporter.Query(stream, sheetName: "Sheet2").ToList(); + var rows5 = _excelImporter.Query(stream, sheetName: "Sheet2", leaveOpen: true).ToList(); Assert.Equal(12, rows5.Count); Assert.Equal(1, rows5[0].A); Assert.Equal(1, rows5[0].B); - var rows6 = _excelImporter.Query(stream, sheetName: "Sheet1").ToList(); + var rows6 = _excelImporter.Query(stream, sheetName: "Sheet1", leaveOpen: true).ToList(); Assert.Equal(12, rows6.Count); Assert.Equal(2, rows6[0].A); Assert.Equal(2, rows6[0].B); @@ -75,12 +75,12 @@ public void MultiSheetsQueryTest() Assert.Equal(["Sheet1", "Sheet2", "Sheet3"], sheetNames1); using var stream = File.OpenRead(path); - var sheetNames2 = _excelImporter.GetSheetNames(stream).ToList(); + var sheetNames2 = _excelImporter.GetSheetNames(stream, leaveOpen: true).ToList(); Assert.Equal(["Sheet1", "Sheet2", "Sheet3"], sheetNames2); foreach (var sheetName in sheetNames2) { - var rows = _excelImporter.Query(stream, sheetName: sheetName).ToList(); + var rows = _excelImporter.Query(stream, sheetName: sheetName, leaveOpen: true).ToList(); Assert.NotEmpty(rows); } } @@ -91,21 +91,21 @@ public void ExcelSheetAttributeIsUsedWhenReadExcel() var path = PathHelper.GetFile("xlsx/TestDynamicSheet.xlsx"); using (var stream = File.OpenRead(path)) { - var users = _excelImporter.Query(stream).ToList(); + var users = _excelImporter.Query(stream, leaveOpen: true).ToList(); Assert.Equal(2, users.Count); Assert.Equal("Jack", users[0].Name); - var departments = _excelImporter.Query(stream).ToList(); + var departments = _excelImporter.Query(stream).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); } { - var users = _excelImporter.Query(path).ToList(); + var users = _excelImporter.Query(path).ToList(); Assert.Equal(2, users.Count); Assert.Equal("Jack", users[0].Name); - var departments = _excelImporter.Query(path).ToList(); + var departments = _excelImporter.Query(path).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); } @@ -127,34 +127,34 @@ public void DynamicSheetConfigurationIsUsedWhenReadExcel() using (var stream = File.OpenRead(path)) { // take first sheet as default - var users = _excelImporter.Query(stream, configuration: configuration, hasHeaderRow: true).ToList(); + var users = _excelImporter.Query(stream, configuration: configuration, hasHeaderRow: true, leaveOpen: true).ToList(); Assert.Equal(2, users.Count); Assert.Equal("Jack", users[0].Name); // take second sheet by sheet name - var departments = _excelImporter.Query(stream, sheetName: "Departments", configuration: configuration, hasHeaderRow: true).ToList(); + var departments = _excelImporter.Query(stream, sheetName: "Departments", configuration: configuration, hasHeaderRow: true, leaveOpen: true).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); // take second sheet by sheet key - departments = _excelImporter.Query(stream, sheetName: "departmentSheet", configuration: configuration, hasHeaderRow: true).ToList(); + departments = _excelImporter.Query(stream, sheetName: "departmentSheet", configuration: configuration, hasHeaderRow: true).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); } { // take first sheet as default - var users = _excelImporter.Query(path, configuration: configuration, hasHeaderRow: true).ToList(); + var users = _excelImporter.Query(path, configuration: configuration, hasHeaderRow: true).ToList(); Assert.Equal(2, users.Count); Assert.Equal("Jack", users[0].Name); // take second sheet by sheet name - var departments = _excelImporter.Query(path, sheetName: "Departments", configuration: configuration, hasHeaderRow: true).ToList(); + var departments = _excelImporter.Query(path, sheetName: "Departments", configuration: configuration, hasHeaderRow: true).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); // take second sheet by sheet key - departments = _excelImporter.Query(path, sheetName: "departmentSheet", configuration: configuration, hasHeaderRow: true).ToList(); + departments = _excelImporter.Query(path, sheetName: "departmentSheet", configuration: configuration, hasHeaderRow: true).ToList(); Assert.Equal(2, departments.Count); Assert.Equal("HR", departments[0].Name); } From 07db530c3c52bf7ab13406a17a1300fcb9ef967c Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 6 Jun 2026 01:25:37 +0200 Subject: [PATCH 6/8] Perfected data reader implementation Separated `MiniExcelDataReader` into `CsvDataReader` and `OpenXmlDataReader` implementations, making them inherit the new `MiniExcelDataReaderBase` abstract class. After these changes `OpenXmlDataReader` now supports reading all sheets of a workbook through the `NextResult` and `NextResultAsync` methods. Updated CsvImporter and OpenXmlImporter to return the new reader types and to create/dispose streams/readers safely, added unit tests and CSV test data, and updated README examples. --- README-V2.md | 8 +- .../Abstractions/IMiniExcelDataReader.cs | 3 +- src/MiniExcel.Core/MiniExcelDataReader.cs | 326 ------------------ src/MiniExcel.Core/MiniExcelDataReaderBase.cs | 317 +++++++++++++++++ src/MiniExcel.Csv/Api/CsvImporter.cs | 35 +- src/MiniExcel.Csv/CsvDataReader.cs | 106 ++++++ src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 93 +++-- src/MiniExcel.OpenXml/OpenXmlDataReader.cs | 209 +++++++++++ src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 36 +- src/MiniExcel/MiniExcel.cs | 2 +- .../DataReader/CsvDataReaderAsyncTests.cs | 123 +++++++ .../DataReader/CsvDataReaderTests.cs | 123 +++++++ .../Main/MiniExcelCsvAsyncTests.cs | 10 +- .../DataReader/OpenXmlDataReaderAsyncTests.cs | 156 +++++++++ .../DataReader/OpenXmlDataReaderTests.cs | 156 +++++++++ .../Main/MiniExcelOpenXmlAsyncTests.cs | 24 ++ .../csv/TestDataReaderCustomSeparator.csv | 3 + tests/data/csv/TestDataReaderHeader.csv | 3 + tests/data/csv/TestDataReaderNoHeader.csv | 2 + 19 files changed, 1344 insertions(+), 391 deletions(-) delete mode 100644 src/MiniExcel.Core/MiniExcelDataReader.cs create mode 100644 src/MiniExcel.Core/MiniExcelDataReaderBase.cs create mode 100644 src/MiniExcel.Csv/CsvDataReader.cs create mode 100644 src/MiniExcel.OpenXml/OpenXmlDataReader.cs create mode 100644 tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderAsyncTests.cs create mode 100644 tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderTests.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderAsyncTests.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderTests.cs create mode 100644 tests/data/csv/TestDataReaderCustomSeparator.csv create mode 100644 tests/data/csv/TestDataReaderHeader.csv create mode 100644 tests/data/csv/TestDataReaderNoHeader.csv diff --git a/README-V2.md b/README-V2.md index 88e53fe2..4210a908 100644 --- a/README-V2.md +++ b/README-V2.md @@ -208,7 +208,7 @@ Check what we are planning for future versions [here](https://github.com/mini-so The code for the benchmarks can be found in [MiniExcel.Benchmarks](benchmarks/MiniExcel.Benchmarks/Program.cs). -The file used to test performance is [**Test1,000,000x10.xlsx**](benchmarks/MiniExcel.Benchmarks/Test1%2C000%2C000x10.xlsx), a 32MB document containing 1,000,000 rows * 10 columns whose cells are filled with the string "HelloWorld". +The main file used to test performance is [**Test100,000x10.xlsx**](benchmarks/MiniExcel.Benchmarks/Test1%2C000%2C000x10.xlsx), a document containing 100,000 rows * 10 columns whose cells are filled with unique strings. To run all the benchmarks use: @@ -1606,12 +1606,12 @@ There is support for reading one cell at a time using a custom `IDataReader`: ```csharp var importer = MiniExcel.Importers.GetOpenXmlImporter(); -using var reader = importer.GetDataReader(path, useHeaderRow: true); +using OpenXmlDataReader reader = importer.GetDataReader(path, hasHeaderRow: true); // or var importer = MiniExcel.Importers.GetCsvImporter(); -using var reader = importer.GetDataReader(path, useHeaderRow: true); +using CsvDataReader reader = importer.GetDataReader(path, hasHeaderRow: true); while (reader.Read()) @@ -1622,6 +1622,8 @@ while (reader.Read()) } } ``` +When not providing a specific worksheet name, all sheets will be available sequentially by calling the `NextResult` method on the `OpenXmlDataReader`. +Calling the `NextResult` method on the `CsvDataReader` will always raise a `NotSupportedException` instead. #### Add records diff --git a/src/MiniExcel.Core/Abstractions/IMiniExcelDataReader.cs b/src/MiniExcel.Core/Abstractions/IMiniExcelDataReader.cs index 95e51bfa..e20492b0 100644 --- a/src/MiniExcel.Core/Abstractions/IMiniExcelDataReader.cs +++ b/src/MiniExcel.Core/Abstractions/IMiniExcelDataReader.cs @@ -4,4 +4,5 @@ public interface IMiniExcelDataReader : IDataReader, IAsyncDisposable { Task CloseAsync(); Task ReadAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file + Task NextResultAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MiniExcel.Core/MiniExcelDataReader.cs b/src/MiniExcel.Core/MiniExcelDataReader.cs deleted file mode 100644 index e76d30b5..00000000 --- a/src/MiniExcel.Core/MiniExcelDataReader.cs +++ /dev/null @@ -1,326 +0,0 @@ -namespace MiniExcelLib.Core; - -public sealed class MiniExcelDataReader : IMiniExcelDataReader -{ - private readonly IEnumerator>? _source; - private readonly IAsyncEnumerator>? _asyncSource; - private readonly Dictionary _ordinals = []; - private readonly Stream _stream; - private readonly bool _leaveOpen; - - private bool _isEmpty; - private List _columns = []; - private DataTable? _schema; - - private readonly bool _isAsyncSource; - private bool _isFirst = true; - - public object this[int i] - => GetValue(i); - - public object this[string name] - => GetValue(GetOrdinal(name)); - - public int Depth => 0; - public int FieldCount { get; private set; } - public bool IsClosed { get; private set; } - public int RecordsAffected => 0; - - - private MiniExcelDataReader(Stream? stream, IEnumerable>? values, bool leaveOpen) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _source = values?.GetEnumerator() ?? throw new ArgumentNullException(nameof(values)); - _leaveOpen = leaveOpen; - } - - public static MiniExcelDataReader Create(Stream? stream, IEnumerable> values, bool leaveOpen = false) - { - var reader = new MiniExcelDataReader(stream, values, leaveOpen); - if (reader._source!.MoveNext()) - { - reader._columns = reader._source.Current?.Keys.ToList() ?? []; - reader.FieldCount = reader._columns.Count; - } - else - { - reader._isEmpty = true; - } - - return reader; - } - - private MiniExcelDataReader(Stream? stream, IAsyncEnumerable>? values, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _asyncSource = values?.GetAsyncEnumerator() ?? throw new ArgumentNullException(nameof(values)); - _leaveOpen = leaveOpen; - _isAsyncSource = true; - } - - public static async Task CreateAsync(Stream? stream, IAsyncEnumerable> values, bool leaveOpen = false) - { - var reader = new MiniExcelDataReader(stream, values, leaveOpen); - if (await reader._asyncSource!.MoveNextAsync().ConfigureAwait(false)) - { - reader._columns = reader._asyncSource.Current?.Keys.ToList() ?? []; - reader.FieldCount = reader._columns.Count; - } - else - { - reader._isEmpty = true; - } - - return reader; - } - - public bool Read() - { - if (IsClosed) - throw new InvalidOperationException("The data reader has been closed"); - - if (_isAsyncSource) - throw new InvalidOperationException("The data reader was configured to execute asynchronously"); - - if (_isFirst) - { - _isFirst = false; - return !_isEmpty; - } - - return _source!.MoveNext(); - } - - public async Task ReadAsync(CancellationToken cancellationToken = default) - { - if (IsClosed) - throw new InvalidOperationException("The data reader has been closed"); - - if (!_isAsyncSource) - return await Task.FromResult(Read()).ConfigureAwait(false); - - if (_isFirst) - { - _isFirst = false; - return !_isEmpty; - } - - return await _asyncSource!.MoveNextAsync().ConfigureAwait(false); - } - - public IDataReader GetData(int i) - => throw new NotSupportedException(); - - public Type GetFieldType(int i) - => typeof(object); - - public string GetDataTypeName(int i) - => typeof(object).FullName!; - - /// - /// This method will alway throw a - /// - public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) - => throw new NotSupportedException("MiniExcelDataReader does not support this method"); - - public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) - { - var s = GetString(i); - var len = Math.Min(length, s.Length - (int)fieldoffset); - var subs = s.Substring((int)fieldoffset, len); - - if (buffer is not null) - subs.AsSpan().CopyTo(buffer.AsSpan(bufferoffset)); - - return subs.Length; - } - - public bool GetBoolean(int i) => GetValue(i) switch - { - bool b => b, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToBoolean(value) - }; - - public byte GetByte(int i) => GetValue(i) is { } value - ? Convert.ToByte(value) - : throw new InvalidOperationException("The value is null"); - - public char GetChar(int i) => GetValue(i) switch - { - char c => c, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToChar(value) - }; - - public DateTime GetDateTime(int i) => GetValue(i) switch - { - DateTime dt => dt, - double d => DateTime.FromOADate(d), - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDateTime(value) - }; - - public decimal GetDecimal(int i) => GetValue(i) switch - { - decimal d => d, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDecimal(value) - }; - - public float GetFloat(int i) => GetValue(i) switch - { - float f => f, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToSingle(value) - }; - - public double GetDouble(int i) => GetValue(i) switch - { - double d => d, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDouble(value) - }; - - public Guid GetGuid(int i) => GetValue(i) switch - { - Guid g => g, - string s => Guid.Parse(s), - byte[] b => new Guid(b), - null => throw new InvalidOperationException("The value is null"), - var value => throw new InvalidCastException($"The value {value} cannot be cast to Guid"), - }; - - public short GetInt16(int i) => GetValue(i) switch - { - short s => s, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt16(value) - }; - - public int GetInt32(int i) => GetValue(i) switch - { - int s => s, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt32(value) - }; - - public long GetInt64(int i) => GetValue(i) switch - { - long l => l, - null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt64(value) - }; - - public string GetString(int i) => GetValue(i) switch - { - string s => s, - var value => Convert.ToString(value) ?? "" - }; - - public object GetValue(int i) - { - var currentRow = _isAsyncSource - ? _asyncSource?.Current - : _source?.Current; - - return currentRow is not null - ? currentRow[_columns[i]] ?? DBNull.Value - : throw new InvalidOperationException("Current row is not available."); - } - - public int GetValues(object?[] values) - { - var count = Math.Min(values.Length, FieldCount); - - for (int i = 0; i < count; i++) - values[i] = GetValue(i); - - return count; - } - - public bool IsDBNull(int i) - => GetValue(i) is null or DBNull; - - public string GetName(int i) - => _columns[i]; - - public int GetOrdinal(string name) - { - if (name is null) - throw new ArgumentNullException(nameof(name)); - - if (_ordinals.TryGetValue(name, out var ordinal)) - return ordinal; - - var ord = _columns.IndexOf(name); - _ordinals[name] = ord; - - return ord; - } - - public DataTable GetSchemaTable() - { - if (_schema is null) - { - _schema = new DataTable(); - _schema.Columns.Add("ColumnOrdinal"); - _schema.Columns.Add("ColumnName"); - - for (int i = 0; i < _columns.Count; i++) - { - _schema.Rows.Add(i, _columns[i]); - } - } - - return _schema; - } - - /// - /// This method will alway return false - /// - public bool NextResult() => false; - - public void Close() - { - if (IsClosed) - return; - - if (_isAsyncSource) - { - if (_asyncSource is IDisposable disposable) disposable.Dispose(); - // necessary fallback when the synchronous Close is called despite the data reader being initialized asynchronously - else Task.Run(async () => await _asyncSource!.DisposeAsync().ConfigureAwait(false)).GetAwaiter().GetResult(); - } - else - { - _source!.Dispose(); - } - - if (!_leaveOpen) - _stream.Dispose(); - - IsClosed = true; - } - - public async Task CloseAsync() - { - if (IsClosed) - return; - - if (_isAsyncSource) - await _asyncSource!.DisposeAsync().ConfigureAwait(false); - - _source?.Dispose(); - if (!_leaveOpen) - await _stream.DisposeAsync().ConfigureAwait(false); - - IsClosed = true; - } - - public void Dispose() - => Close(); - - public async ValueTask DisposeAsync() - => await CloseAsync().ConfigureAwait(false); -} diff --git a/src/MiniExcel.Core/MiniExcelDataReaderBase.cs b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs new file mode 100644 index 00000000..5d4dcad1 --- /dev/null +++ b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs @@ -0,0 +1,317 @@ +namespace MiniExcelLib.Core; + +public abstract class MiniExcelDataReaderBase : IMiniExcelDataReader +{ + protected readonly IMiniExcelReader MiniExcelReader; + + protected IEnumerator>? Source; + protected IAsyncEnumerator>? AsyncSource; + protected readonly bool IsAsyncSource; + + protected readonly Dictionary Ordinals = []; + + protected bool IsEmpty; + protected bool HasHeaderRow; + + protected List Columns = []; + protected DataTable? Schema; + + protected bool IsFirstRow = true; + + public virtual object this[int i] + => GetValue(i); + + public virtual object this[string name] + => GetValue(GetOrdinal(name)); + + public int Depth => 0; + public int RecordsAffected => -1; + public int FieldCount { get; protected set; } + + private bool _disposed; + public bool IsClosed { get; protected set; } + + + protected MiniExcelDataReaderBase(IMiniExcelReader miniExcelReader, bool hasHeaderRow, bool isAsyncSource) + { + MiniExcelReader = miniExcelReader; + HasHeaderRow = hasHeaderRow; + IsAsyncSource = isAsyncSource; + } + + public virtual bool Read() + { + if (_disposed) + throw new ObjectDisposedException("This data reader has been disposed."); + + if (IsClosed) + throw new InvalidOperationException("This data reader has been closed."); + + if (IsAsyncSource) + throw new InvalidOperationException("This data reader was configured to execute asynchronously"); + + if (IsFirstRow) + { + IsFirstRow = false; + return !IsEmpty; + } + + return Source!.MoveNext(); + } + + public virtual async Task ReadAsync(CancellationToken cancellationToken = default) + { + if (_disposed) + throw new ObjectDisposedException("This data reader has been disposed."); + + if (IsClosed) + throw new InvalidOperationException("The data reader has been closed"); + + if (!IsAsyncSource) + return await Task.FromResult(Read()).ConfigureAwait(false); + + if (IsFirstRow) + { + IsFirstRow = false; + return !IsEmpty; + } + + return await AsyncSource!.MoveNextAsync().ConfigureAwait(false); + } + + public virtual IDataReader GetData(int i) + => throw new NotSupportedException(); + + public virtual Type GetFieldType(int i) + => typeof(object); + + public virtual string GetDataTypeName(int i) + => typeof(object).FullName!; + + /// + /// This method will alway throw a + /// + public virtual long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) + => throw new NotSupportedException("MiniExcelDataReader does not support this method"); + + public virtual long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) + { + var s = GetString(i); + var len = Math.Min(length, s.Length - (int)fieldoffset); + var subs = s.Substring((int)fieldoffset, len); + + if (buffer is not null) + subs.AsSpan().CopyTo(buffer.AsSpan(bufferoffset)); + + return subs.Length; + } + + public virtual bool GetBoolean(int i) => GetValue(i) switch + { + bool b => b, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToBoolean(value) + }; + + public virtual byte GetByte(int i) => GetValue(i) is { } value + ? Convert.ToByte(value) + : throw new InvalidOperationException("The value is null"); + + public virtual char GetChar(int i) => GetValue(i) switch + { + char c => c, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToChar(value) + }; + + public virtual DateTime GetDateTime(int i) => GetValue(i) switch + { + DateTime dt => dt, + double d => DateTime.FromOADate(d), + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToDateTime(value) + }; + + public virtual decimal GetDecimal(int i) => GetValue(i) switch + { + decimal d => d, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToDecimal(value) + }; + + public virtual float GetFloat(int i) => GetValue(i) switch + { + float f => f, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToSingle(value) + }; + + public virtual double GetDouble(int i) => GetValue(i) switch + { + double d => d, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToDouble(value) + }; + + public virtual Guid GetGuid(int i) => GetValue(i) switch + { + Guid g => g, + string s => Guid.Parse(s), + byte[] b => new Guid(b), + null => throw new InvalidOperationException("The value is null"), + var value => throw new InvalidCastException($"The value {value} cannot be cast to Guid"), + }; + + public virtual short GetInt16(int i) => GetValue(i) switch + { + short s => s, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToInt16(value) + }; + + public virtual int GetInt32(int i) => GetValue(i) switch + { + int s => s, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToInt32(value) + }; + + public virtual long GetInt64(int i) => GetValue(i) switch + { + long l => l, + null => throw new InvalidOperationException("The value is null"), + var value => Convert.ToInt64(value) + }; + + public virtual string GetString(int i) => GetValue(i) switch + { + string s => s, + var value => Convert.ToString(value) ?? "" + }; + + public virtual object GetValue(int i) + { + var currentRow = IsAsyncSource + ? AsyncSource?.Current + : Source?.Current; + + return currentRow is not null + ? currentRow[Columns[i]] ?? DBNull.Value + : throw new InvalidOperationException("Current row is not available."); + } + + public virtual int GetValues(object?[] values) + { + var count = Math.Min(values.Length, FieldCount); + + for (int i = 0; i < count; i++) + values[i] = GetValue(i); + + return count; + } + + public virtual bool IsDBNull(int i) + => GetValue(i) is null or DBNull; + + public virtual string GetName(int i) + => Columns[i]; + + public int GetOrdinal(string name) + { + if (name is null) + throw new ArgumentNullException(nameof(name)); + + if (Ordinals.TryGetValue(name, out var ordinal)) + return ordinal; + + var ord = Columns.IndexOf(name); + Ordinals[name] = ord; + + return ord; + } + + public DataTable GetSchemaTable() + { + if (Schema is null) + { + Schema = new DataTable(); + Schema.Columns.Add("ColumnOrdinal", typeof(int)); + Schema.Columns.Add("ColumnName", typeof(string)); + + for (int i = 0; i < Columns.Count; i++) + { + Schema.Rows.Add(i, Columns[i]); + } + } + + return Schema; + } + + public virtual bool NextResult() + => throw new NotImplementedException(); + + public virtual Task NextResultAsync(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + + public void Close() + => Dispose(); + + public async Task CloseAsync() + => await DisposeAsync().ConfigureAwait(false); + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + if (IsAsyncSource) + { + if (AsyncSource is IDisposable disposable) disposable.Dispose(); + // necessary fallback when the data reader is being disposed synchronously despite having being initialized asynchronously + else Task.Run(async () => await AsyncSource!.DisposeAsync().ConfigureAwait(false)).GetAwaiter().GetResult(); + } + else + { + Source!.Dispose(); + } + + MiniExcelReader.Dispose(); + Schema?.Dispose(); + + IsClosed = true; + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual async ValueTask DisposeAsyncCore() + { + if (_disposed) + return; + + if (IsAsyncSource) + await AsyncSource!.DisposeAsync().ConfigureAwait(false); + + Schema?.Dispose(); + await MiniExcelReader.DisposeAsync().ConfigureAwait(false); + + IsClosed = true; + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(false); + + GC.SuppressFinalize(this); + } +} diff --git a/src/MiniExcel.Csv/Api/CsvImporter.cs b/src/MiniExcel.Csv/Api/CsvImporter.cs index e512d8e4..28dc4b7a 100644 --- a/src/MiniExcel.Csv/Api/CsvImporter.cs +++ b/src/MiniExcel.Csv/Api/CsvImporter.cs @@ -1,5 +1,3 @@ -using MiniExcelLib.Core; - // ReSharper disable once CheckNamespace namespace MiniExcelLib.Csv; @@ -235,16 +233,14 @@ public async Task> GetColumnNamesAsync(Stream stream, bool h /// The path to the CSV document. /// If true, the first row is used as column headers. Default is false. /// Optional configuration settings (delimiters, encoding, etc.). /// - /// The returned implements and supports its standard reading patterns. + /// The returned implements and supports its standard reading patterns. /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called from it. /// For asynchronous reading scenarios, use instead. /// - public MiniExcelDataReader GetDataReader(string path, bool hasHeaderRow = false, CsvConfiguration? configuration = null) + public CsvDataReader GetDataReader(string path, bool hasHeaderRow = false, CsvConfiguration? configuration = null) { var stream = FileHelper.OpenSharedRead(path); - var values = Query(stream, hasHeaderRow, configuration, leaveOpen: false).Cast>(); - - return MiniExcelDataReader.Create(stream, values); + return CsvDataReader.Create(stream, hasHeaderRow, configuration, leaveOpen: false); } /// @@ -255,14 +251,13 @@ public MiniExcelDataReader GetDataReader(string path, bool hasHeaderRow = false, /// Optional configuration settings (delimiters, encoding, etc.). /// True to leave the stream open after the data reader is disposed, otherwise false. /// - /// The returned implements and supports its standard reading patterns. + /// The returned implements and supports its standard reading patterns. /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called from it. /// For asynchronous reading scenarios, use instead. /// - public MiniExcelDataReader GetDataReader(Stream stream, bool hasHeaderRow = false, CsvConfiguration ? configuration = null, bool leaveOpen = false) + public CsvDataReader GetDataReader(Stream stream, bool hasHeaderRow = false, CsvConfiguration ? configuration = null, bool leaveOpen = false) { - var values = Query(stream, hasHeaderRow, configuration, leaveOpen).Cast>(); - return MiniExcelDataReader.Create(stream, values, leaveOpen); + return CsvDataReader.Create(stream, hasHeaderRow, configuration, leaveOpen); } /// @@ -270,19 +265,18 @@ public MiniExcelDataReader GetDataReader(Stream stream, bool hasHeaderRow = fals /// /// The path to the CSV document. /// If true, the first row is used as column headers. Default is false. - /// Optional configuration settings (delimiters, encoding, etc.). /// A token to cancel the asynchronous operation. + /// Optional configuration settings (delimiters, encoding, etc.). + /// /// A token to cancel the asynchronous operation. /// - /// The returned implements and supports its standard reading patterns. + /// The returned implements and supports its standard reading patterns. /// The data reader returned by this method is designed to supports asynchronous reads, but will not throw an exception if a synchronous operation is performed. /// Still, it's advised to use for synchronous reads instead. /// - public async Task GetAsyncDataReader(string path, bool hasHeaderRow = false, + public async Task GetAsyncDataReader(string path, bool hasHeaderRow = false, CsvConfiguration? configuration = null, CancellationToken cancellationToken = default) { var stream = FileHelper.OpenSharedRead(path); - var values = QueryAsync(stream, hasHeaderRow, configuration, leaveOpen: false, cancellationToken); - - return await MiniExcelDataReader.CreateAsync(stream, values.CastToDictionary(cancellationToken)).ConfigureAwait(false); + return await CsvDataReader.CreateAsync(stream, hasHeaderRow, configuration, leaveOpen: false, cancellationToken).ConfigureAwait(false); } /// @@ -294,15 +288,14 @@ public async Task GetAsyncDataReader(string path, bool hasH /// True to leave the stream open after the data reader is disposed, otherwise false. /// A token to cancel the asynchronous operation. /// - /// The returned implements and supports its standard reading patterns. + /// The returned implements and supports its standard reading patterns. /// The data reader returned by this method is designed to supports asynchronous reads, but will not throw an exception if a synchronous operation is performed. /// Still, it's advised to use for synchronous reads instead. /// - public async Task GetAsyncDataReader(Stream stream, bool hasHeaderRow = false, + public async Task GetAsyncDataReader(Stream stream, bool hasHeaderRow = false, CsvConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { - var values = QueryAsync(stream, hasHeaderRow, configuration, leaveOpen, cancellationToken); - return await MiniExcelDataReader.CreateAsync(stream, values.CastToDictionary(cancellationToken), leaveOpen).ConfigureAwait(false); + return await CsvDataReader.CreateAsync(stream, hasHeaderRow, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); } #endregion diff --git a/src/MiniExcel.Csv/CsvDataReader.cs b/src/MiniExcel.Csv/CsvDataReader.cs new file mode 100644 index 00000000..aacc44c9 --- /dev/null +++ b/src/MiniExcel.Csv/CsvDataReader.cs @@ -0,0 +1,106 @@ +using MiniExcelLib.Core; + +namespace MiniExcelLib.Csv; + +public sealed class CsvDataReader : MiniExcelDataReaderBase +{ + private CsvDataReader(CsvReader reader, bool hasHeaderRow, bool isAsyncSource) + : base(reader, hasHeaderRow, isAsyncSource) + { + } + + internal static CsvDataReader Create(Stream stream, bool hasHeaderRow = false, CsvConfiguration? configuration = null, bool leaveOpen = false) + { + CsvReader? reader = null; + CsvDataReader? dataReader = null; + + try + { + reader = new CsvReader(stream, configuration, leaveOpen); + dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: false) + { + Source = reader.Query(hasHeaderRow, null, "A1").GetEnumerator(), + }; + + if (dataReader.Source!.MoveNext()) + { + dataReader.Columns = dataReader.Source.Current?.Keys.ToList() ?? []; + dataReader.FieldCount = dataReader.Columns.Count; + } + else + { + dataReader.IsEmpty = true; + } + + var result = dataReader; + dataReader = null; + reader = null; + stream = null!; + + return result; + } + finally + { + dataReader?.Dispose(); + reader?.Dispose(); + + if (!leaveOpen) + ((Stream?)stream)?.Dispose(); + } + } + + internal static async Task CreateAsync(Stream stream, bool hasHeaderRow = false, CsvConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) + { + CsvReader? reader = null; + CsvDataReader? dataReader = null; + + try + { + reader = new CsvReader(stream, configuration, leaveOpen); + dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: true) + { + AsyncSource = reader.QueryAsync(hasHeaderRow, null, "A1", cancellationToken).GetAsyncEnumerator(cancellationToken) + }; + + if (await dataReader.AsyncSource.MoveNextAsync().ConfigureAwait(false)) + { + dataReader.Columns = dataReader.AsyncSource.Current?.Keys.ToList() ?? []; + dataReader.FieldCount = dataReader.Columns.Count; + } + else + { + dataReader.IsEmpty = true; + } + + var result = dataReader; + dataReader = null; + reader = null; + stream = null!; + + return result; + } + finally + { + if (dataReader is not null) + await dataReader.DisposeAsync().ConfigureAwait(false); + + if (reader?.DisposeAsync() is { } disposeTask) + await disposeTask.ConfigureAwait(false); + + if (!leaveOpen && (Stream?)stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// This method will throw + /// + public override bool NextResult() + => throw new NotSupportedException(); + + /// + /// This method will throw + /// + public override Task NextResultAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); +} diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 85d70914..541950e7 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -617,21 +617,37 @@ public async IAsyncEnumerable QueryTableAsync(Stream stream, string? sheet /// /// The path to the Excel document. /// If true, the first row is used as column headers. Default is false. - /// The name of the worksheet to read. If not provided, the first sheet is used. + /// The name of the worksheet to read. If not provided, all worksheets will be fetched in order and will be accessible through the NextResult method. /// The starting cell reference (e.g."C2"). Default is "A1". /// Optional configuration settings. /// - /// The returned implements and supports its standard reading patterns. - /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called from it. + /// The returned implements and supports its standard reading patterns. + /// Parameters and will be applied to all worksheets. + /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called on it. /// For asynchronous reading scenarios, use instead. /// - public MiniExcelDataReader GetDataReader(string path, bool hasHeaderRow = false, + public OpenXmlDataReader GetDataReader(string path, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null) { - var stream = FileHelper.OpenSharedRead(path); - var values = Query(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen: false).Cast>(); - - return MiniExcelDataReader.Create(stream, values, leaveOpen: false); + Stream? stream = null; + OpenXmlDataReader? dataReader = null; + + try + { + stream = FileHelper.OpenSharedRead(path); + dataReader = OpenXmlDataReader.Create(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen: false); + + var result = dataReader; + dataReader = null; + stream = null; + + return result; + } + finally + { + dataReader?.Dispose(); + stream?.Dispose(); + } } /// @@ -639,20 +655,20 @@ public MiniExcelDataReader GetDataReader(string path, bool hasHeaderRow = false, /// /// The stream containing the Excel file data. /// If true, the first row is used as column headers. Default is false. - /// The name of the worksheet to read. If not provided, the first sheet is used. + /// The name of the worksheet to read. If not provided, all worksheets will be fetched in order and will be accessible through the NextResult method. /// The starting cell reference (e.g."C2"). Default is "A1". /// Optional configuration settings. /// True to leave the stream open after the data reader is disposed, otherwise false. /// - /// The returned implements and supports its standard reading patterns. - /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called from it. + /// The returned implements and supports its standard reading patterns. + /// Parameters and will be applied to all worksheets. + /// The data reader returned by this method is designed to perform synchronous, blocking reads, and will throw if an asynchronous operation is called on it /// For asynchronous reading scenarios, use instead. /// - public MiniExcelDataReader GetDataReader(Stream stream, bool hasHeaderRow = false, + public OpenXmlDataReader GetDataReader(Stream stream, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false) { - var values = Query(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen).Cast>(); - return MiniExcelDataReader.Create(stream, values, leaveOpen); + return OpenXmlDataReader.Create(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen); } /// @@ -660,22 +676,41 @@ public MiniExcelDataReader GetDataReader(Stream stream, bool hasHeaderRow = fals /// /// The path to the Excel document. /// If true, the first row is used as column headers. Default is false. - /// The name of the worksheet to read. If null, the first sheet is used. + /// The name of the worksheet to read. If not provided, all worksheets will be fetched in order and will be accessible through the NextResult method. /// The starting cell reference (e.g."C2"). Default is "A1". /// Optional configuration settings. /// A token to cancel the asynchronous operation. /// - /// The returned implements and supports its standard reading patterns. - /// The data reader returned by this method is designed to supports asynchronous reads, but will not throw an exception if a synchronous operation is performed. - /// Still, it's advised to use for synchronous reads instead. + /// The returned implements and supports its standard reading patterns. + /// Parameters and will be applied to all worksheets. + /// The data reader returned by this method is designed to supports asynchronous reads but will not throw an exception if a synchronous operation is performed; + /// still it's advised to use for synchronous reads instead. /// - public async Task GetAsyncDataReader(string path, bool hasHeaderRow = false, + public async Task GetAsyncDataReader(string path, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { - var stream = FileHelper.OpenSharedRead(path); - var values = QueryAsync(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen: false, cancellationToken); + Stream? stream = null; + OpenXmlDataReader? dataReader = null; - return await MiniExcelDataReader.CreateAsync(stream, values.CastToDictionary(cancellationToken), leaveOpen: false).ConfigureAwait(false); + try + { + stream = FileHelper.OpenSharedRead(path); + dataReader = await OpenXmlDataReader.CreateAsync(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen: false, cancellationToken).ConfigureAwait(false); + + var result = dataReader; + dataReader = null; + stream = null; + + return result; + } + finally + { + if (dataReader is not null) + await dataReader.DisposeAsync().ConfigureAwait(false); + + if (stream?.DisposeAsync() is { } streamDisposeTask) + await streamDisposeTask.ConfigureAwait(false); + } } /// @@ -683,22 +718,22 @@ public async Task GetAsyncDataReader(string path, bool hasH /// /// The stream containing the Excel file data. /// If true, the first row is used as column headers. Default is false. - /// The name of the worksheet to read. If null, the first sheet is used. + /// The name of the worksheet to read. If not provided, all worksheets will be fetched in order and will be accessible through the NextResult method. /// The starting cell reference (e.g."C2"). Default is "A1". /// Optional configuration settings. /// True to leave the stream open after the data reader is disposed, otherwise false. /// A token to cancel the asynchronous operation. /// - /// The returned implements and supports its standard reading patterns. - /// The data reader returned by this method is designed to supports asynchronous reads, but will not throw an exception if a synchronous operation is performed. - /// Still, it's advised to use for synchronous reads instead. + /// The returned implements and supports its standard reading patterns. + /// Parameters and will be applied to all worksheets. + /// The data reader returned by this method is designed to supports asynchronous reads but will not throw an exception if a synchronous operation is performed; + /// still it's advised to use for synchronous reads instead. /// - public async Task GetAsyncDataReader(Stream stream, bool hasHeaderRow = false, + public async Task GetAsyncDataReader(Stream stream, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) { - var values = QueryAsync(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen, cancellationToken); - return await MiniExcelDataReader.CreateAsync(stream, values.CastToDictionary(cancellationToken), leaveOpen).ConfigureAwait(false); + return await OpenXmlDataReader.CreateAsync(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); } #endregion diff --git a/src/MiniExcel.OpenXml/OpenXmlDataReader.cs b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs new file mode 100644 index 00000000..2327ccca --- /dev/null +++ b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs @@ -0,0 +1,209 @@ +using MiniExcelLib.OpenXml.Reader; + +namespace MiniExcelLib.OpenXml; + +public sealed class OpenXmlDataReader : MiniExcelDataReaderBase +{ + private readonly bool _expectsSingleResult; + + private readonly string[] _sheetNames; + private int _currentSheetIndex; + private string _currentSheetName; + + private string _startCell; + + + private OpenXmlDataReader(OpenXmlReader reader, string sheetName, bool hasHeaderRow, string startCell, bool isAsyncSource, bool expectsSingleResult, string[] sheetNames) + : base(reader, hasHeaderRow, isAsyncSource) + { + _startCell = startCell; + _expectsSingleResult = expectsSingleResult; + _sheetNames = sheetNames; + _currentSheetName = sheetName; + } + + internal static OpenXmlDataReader Create(Stream stream, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false) + { + OpenXmlReader? reader = null; + OpenXmlDataReader? dataReader = null; + + try + { + reader = OpenXmlReader.Create(stream, configuration, leaveOpen); + + bool isSingleResult = false; + string[] sheetNames = []; + if (string.IsNullOrEmpty(sheetName)) + { + var sheets = OpenXmlReader.GetWorkbookRels(reader.Archive.EntryCollection); + sheetNames = sheets?.Select(s => s.Name).ToArray() ?? []; + } + else + { + isSingleResult = true; + } + + dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: false, isSingleResult, sheetNames) + { + Source = reader.Query(hasHeaderRow, sheetName, startCell).GetEnumerator(), + }; + + if (dataReader.Source!.MoveNext()) + { + dataReader.Columns = dataReader.Source.Current?.Keys.ToList() ?? []; + dataReader.FieldCount = dataReader.Columns.Count; + } + else + { + dataReader.IsEmpty = true; + } + + var result = dataReader; + dataReader = null; + reader = null; + stream = null!; + + return result; + } + finally + { + dataReader?.Dispose(); + reader?.Dispose(); + ((Stream?)stream)?.Dispose(); + } + } + + internal static async Task CreateAsync(Stream stream, bool hasHeaderRow = false, string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, bool leaveOpen = false, CancellationToken cancellationToken = default) + { + OpenXmlReader? reader = null; + OpenXmlDataReader? dataReader = null; + + try + { + reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); + + bool isSingleResult = false; + string[] sheetNames = []; + if (string.IsNullOrEmpty(sheetName)) + { + var sheets = await OpenXmlReader.GetWorkbookRelsAsync(reader.Archive.EntryCollection, cancellationToken).ConfigureAwait(false); + sheetNames = sheets?.Select(s => s.Name).ToArray() ?? []; + } + else + { + isSingleResult = true; + } + + dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: true, isSingleResult, sheetNames) + { + AsyncSource = reader.QueryAsync(hasHeaderRow, sheetName, startCell, cancellationToken).GetAsyncEnumerator(cancellationToken), + }; + + if (await dataReader.AsyncSource.MoveNextAsync().ConfigureAwait(false)) + { + dataReader.Columns = dataReader.AsyncSource.Current?.Keys.ToList() ?? []; + dataReader.FieldCount = dataReader.Columns.Count; + } + else + { + dataReader.IsEmpty = true; + } + + var result = dataReader; + dataReader = null; + reader = null; + stream = null!; + + return result; + } + finally + { + if (dataReader is not null) + await dataReader.DisposeAsync().ConfigureAwait(false); + + if (reader?.DisposeAsync() is { } disposeTask) + await disposeTask.ConfigureAwait(false); + + if (!leaveOpen && (Stream?)stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// Returns the name of the worksheet currently being read. + /// + public string GetWorksheetName() => _currentSheetName; + + private void NextResultCore() + { + Source!.Dispose(); + Source = MiniExcelReader.Query(HasHeaderRow, _sheetNames[_currentSheetIndex], _startCell).GetEnumerator(); + + if (Source!.MoveNext()) + { + Columns = Source.Current?.Keys.ToList() ?? []; + FieldCount = Columns.Count; + } + else + { + IsEmpty = true; + } + } + + public override bool NextResult() + { + if (IsClosed) + throw new InvalidOperationException("The data reader has been closed"); + + if (IsAsyncSource) + throw new InvalidOperationException("The data reader was configured to execute asynchronously"); + + if (_expectsSingleResult || _currentSheetIndex + 1 >= _sheetNames.Length) + return false; + + Schema = null; + Ordinals.Clear(); + + _currentSheetIndex++; + _currentSheetName = _sheetNames[_currentSheetIndex]; + + NextResultCore(); + return true; + } + + public override async Task NextResultAsync(CancellationToken cancellationToken = default) + { + if (IsClosed) + throw new InvalidOperationException("The data reader has been closed"); + + if (_expectsSingleResult || _currentSheetIndex + 1 >= _sheetNames.Length) + return false; + + Schema = null; + Ordinals.Clear(); + + _currentSheetIndex++; + _currentSheetName = _sheetNames[_currentSheetIndex]; + + if (!IsAsyncSource) + { + await Task.Run(NextResultCore, cancellationToken).ConfigureAwait(false); + return true; + } + + await AsyncSource!.DisposeAsync().ConfigureAwait(false); + AsyncSource = MiniExcelReader.QueryAsync(HasHeaderRow, _sheetNames[_currentSheetIndex], _startCell, cancellationToken).GetAsyncEnumerator(cancellationToken); + + if (await AsyncSource!.MoveNextAsync().ConfigureAwait(false)) + { + Columns = AsyncSource.Current?.Keys.ToList() ?? []; + FieldCount = Columns.Count; + } + else + { + IsEmpty = true; + } + + return true; + } +} diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index cdf339ca..c5bb8996 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -27,13 +27,39 @@ private OpenXmlReader(OpenXmlZip archive, IMiniExcelConfiguration? configuration [CreateSyncVersion] internal static async Task CreateAsync(Stream stream, IMiniExcelConfiguration? configuration, bool leaveOpen = false, CancellationToken cancellationToken = default) { - ThrowHelper.ThrowIfInvalidOpenXml(stream); + OpenXmlZip? archive = null; + OpenXmlReader? reader = null; - var archive = await OpenXmlZip.CreateAsync(stream, leaveOpen: leaveOpen, cancellationToken: cancellationToken).ConfigureAwait(false); - var reader = new OpenXmlReader(archive, configuration); - await reader.SetSharedStringsAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowHelper.ThrowIfInvalidOpenXml(stream); + + archive = await OpenXmlZip.CreateAsync(stream, leaveOpen: leaveOpen, cancellationToken: cancellationToken).ConfigureAwait(false); + reader = new OpenXmlReader(archive, configuration); + await reader.SetSharedStringsAsync(cancellationToken).ConfigureAwait(false); + + var result = reader; + reader = null; + archive = null; + stream = null!; + + return result; + } + finally + { +#if SYNC_ONLY + reader?.Dispose(); +#else + if (reader?.DisposeAsync() is { } disposeTask) + await disposeTask.ConfigureAwait(false); +#endif - return reader; + if (archive is not null) + await archive.DisposeAsync().ConfigureAwait(false); + + if (!leaveOpen && (Stream?)stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); + } } [CreateSyncVersion] diff --git a/src/MiniExcel/MiniExcel.cs b/src/MiniExcel/MiniExcel.cs index 1ad3817c..6fb631d1 100644 --- a/src/MiniExcel/MiniExcel.cs +++ b/src/MiniExcel/MiniExcel.cs @@ -1,6 +1,5 @@ using System.Data; using MiniExcelLib; -using MiniExcelLib.Core; using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; using MiniExcelLib.OpenXml.Models; @@ -9,6 +8,7 @@ using NewMiniExcel = MiniExcelLib.MiniExcel; using NewOpenXmlConfiguration = MiniExcelLib.OpenXml.OpenXmlConfiguration; +using MiniExcelDataReader = MiniExcelLib.Core.MiniExcelDataReaderBase; // ReSharper disable once CheckNamespace namespace MiniExcelLibs; diff --git a/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderAsyncTests.cs b/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderAsyncTests.cs new file mode 100644 index 00000000..fdaf139b --- /dev/null +++ b/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderAsyncTests.cs @@ -0,0 +1,123 @@ +namespace MiniExcelLib.Csv.Tests.DataReader; + +public class CsvDataReaderAsyncTests +{ + private readonly CsvImporter _csvImporter = MiniExcel.Importers.GetCsvImporter(); + + [Fact] + public async Task GetDataReader_WithSimpleData_ReturnsValidDataReader() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + + Assert.Equal("Name", reader.GetName(0)); + Assert.Equal("Age", reader.GetName(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("John", reader.GetString(0)); + Assert.Equal(30, reader.GetInt32(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("Jane", reader.GetString(0)); + Assert.Equal(25, reader.GetInt32(1)); + Assert.False(await reader.ReadAsync()); + } + + [Fact] + public async Task GetDataReader_WithoutHeaderRow_ReturnsDataWithoutHeaders() + { + var path = PathHelper.GetFile("csv/TestDataReaderNoHeader.csv"); + await using var stream = File.OpenRead(path); + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: false); + + Assert.Equal("A", reader.GetName(0)); + Assert.Equal("B", reader.GetName(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("Value1", reader.GetValue(0)); + Assert.Equal("Value2", reader.GetValue(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("Value3", reader.GetValue(0)); + Assert.Equal("Value4", reader.GetValue(1)); + Assert.False(await reader.ReadAsync()); + } + + [Fact] + public async Task GetDataReader_WithCustomSeparator_AppliesConfiguration() + { + var path = PathHelper.GetFile("csv/TestDataReaderCustomSeparator.csv"); + await using var stream = File.OpenRead(path); + + var importConfig = new CsvConfiguration { Seperator = ';' }; + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true, configuration: importConfig); + + Assert.Equal("Name", reader.GetName(0)); + Assert.Equal("Age", reader.GetName(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("John", reader.GetValue(0)); + Assert.Equal("30", reader.GetValue(1)); + Assert.True(await reader.ReadAsync()); + Assert.Equal("Jane", reader.GetValue(0)); + Assert.Equal("25", reader.GetValue(1)); + Assert.False(await reader.ReadAsync()); + } + + [Fact] + public async Task GetDataReader_WithLeaveOpenFalse_DisposesStream() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + var stream = File.OpenRead(path); + await using (var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: false, leaveOpen: false)) + { + await reader.ReadAsync(); + } + + Assert.False(stream.CanRead); + await Assert.ThrowsAsync(() => stream.ReadAsync([], 0, 0)); + } + + [Fact] + public async Task GetDataReader_WithLeaveOpenTrue_DoesNotDisposeStream() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + await using (var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: false, leaveOpen: true)) + { + await reader.ReadAsync(); + } + + Assert.True(stream.CanRead); + } + + [Fact] + public async Task GetDataReader_GetSchemaTable_ReturnsColumnInfo() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + using var schemaTable = reader.GetSchemaTable(); + + Assert.NotNull(schemaTable); + Assert.Equal(2, schemaTable.Rows.Count); + } + + [Fact] + public async Task GetDataReader_GetOrdinal_ReturnsColumnIndex() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + + Assert.Equal(0, reader.GetOrdinal("Name")); + Assert.Equal(1, reader.GetOrdinal("Age")); + } + + [Fact] + public async Task GetDataReader_NextResult_ThrowsNotSupportedException() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + + await Assert.ThrowsAsync(async () => await reader.NextResultAsync()); + } +} diff --git a/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderTests.cs b/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderTests.cs new file mode 100644 index 00000000..b18fd93a --- /dev/null +++ b/tests/MiniExcel.Csv.Tests/DataReader/CsvDataReaderTests.cs @@ -0,0 +1,123 @@ +namespace MiniExcelLib.Csv.Tests.DataReader; + +public class CsvDataReaderTests +{ + private readonly CsvImporter _csvImporter = MiniExcel.Importers.GetCsvImporter(); + + [Fact] + public void GetDataReader_WithSimpleData_ReturnsValidDataReader() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + using var stream = File.OpenRead(path); + using var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: true); + + Assert.Equal("Name", reader.GetName(0)); + Assert.Equal("Age", reader.GetName(1)); + Assert.True(reader.Read()); + Assert.Equal("John", reader.GetString(0)); + Assert.Equal(30, reader.GetInt32(1)); + Assert.True(reader.Read()); + Assert.Equal("Jane", reader.GetString(0)); + Assert.Equal(25, reader.GetInt32(1)); + Assert.False(reader.Read()); + } + + [Fact] + public void GetDataReader_WithoutHeaderRow_ReturnsDataWithoutHeaders() + { + var path = PathHelper.GetFile("csv/TestDataReaderNoHeader.csv"); + using var stream = File.OpenRead(path); + using var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: false); + + Assert.Equal("A", reader.GetName(0)); + Assert.Equal("B", reader.GetName(1)); + Assert.True(reader.Read()); + Assert.Equal("Value1", reader.GetValue(0)); + Assert.Equal("Value2", reader.GetValue(1)); + Assert.True(reader.Read()); + Assert.Equal("Value3", reader.GetValue(0)); + Assert.Equal("Value4", reader.GetValue(1)); + Assert.False(reader.Read()); + } + + [Fact] + public void GetDataReader_WithCustomSeparator_AppliesConfiguration() + { + var path = PathHelper.GetFile("csv/TestDataReaderCustomSeparator.csv"); + using var stream = File.OpenRead(path); + + var importConfig = new CsvConfiguration { Seperator = ';' }; + using var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: true, configuration: importConfig); + + Assert.Equal("Name", reader.GetName(0)); + Assert.Equal("Age", reader.GetName(1)); + Assert.True(reader.Read()); + Assert.Equal("John", reader.GetValue(0)); + Assert.Equal("30", reader.GetValue(1)); + Assert.True(reader.Read()); + Assert.Equal("Jane", reader.GetValue(0)); + Assert.Equal("25", reader.GetValue(1)); + Assert.False(reader.Read()); + } + + [Fact] + public void GetDataReader_WithLeaveOpenFalse_DisposesStream() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + var stream = File.OpenRead(path); + using (var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: false, leaveOpen: false)) + { + reader.Read(); + } + + Assert.False(stream.CanRead); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void GetDataReader_WithLeaveOpenTrue_DoesNotDisposeStream() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + using var stream = File.OpenRead(path); + using (var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: false, leaveOpen: true)) + { + reader.Read(); + } + + Assert.True(stream.CanRead); + } + + [Fact] + public void GetDataReader_GetSchemaTable_ReturnsColumnInfo() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + using var stream = File.OpenRead(path); + + using var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: true); + using var schemaTable = reader.GetSchemaTable(); + + Assert.NotNull(schemaTable); + Assert.Equal(2, schemaTable.Rows.Count); + } + + [Fact] + public void GetDataReader_GetOrdinal_ReturnsColumnIndex() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + using var stream = File.OpenRead(path); + using var reader = _csvImporter.GetDataReader(stream, hasHeaderRow: true); + + Assert.Equal(0, reader.GetOrdinal("Name")); + Assert.Equal(1, reader.GetOrdinal("Age")); + } + + [Fact] + public async Task GetDataReader_NextResult_ThrowsNotSupportedException() + { + var path = PathHelper.GetFile("csv/TestDataReaderHeader.csv"); + await using var stream = File.OpenRead(path); + await using var reader = await _csvImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + + Assert.Throws(() => reader.NextResult()); + } +} diff --git a/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs b/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs index 99acab91..a83dcc2f 100644 --- a/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs +++ b/tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs @@ -102,7 +102,7 @@ public async Task SaveAsByDictionary() Assert.Equal(2, rowsWritten); using var reader = new StreamReader(path); - using var csv = new global::CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); var records = csv.GetRecords().ToList(); Assert.Equal(@"""<>+-*//}{\\n", records[0].a); @@ -143,7 +143,7 @@ public async Task SaveAsByDictionary() Assert.Equal(2, rowsWritten); using var reader = new StreamReader(path); - using var csv = new global::CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); var records = csv.GetRecords().ToList(); var row1 = records[0] as IDictionary; @@ -188,7 +188,7 @@ public async Task SaveAsByDataTableTest() Assert.Equal(2, rowsWritten); using var reader = new StreamReader(path2); - using var csv = new global::CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); var records = csv.GetRecords().ToList(); Assert.Equal(@"""<>+-*//}{\\n", records[0].a); @@ -224,7 +224,7 @@ public async Task CsvExcelTypeTest() Assert.Equal("Test2", rows1[1].B); using var reader = new StreamReader(path); - using var csv = new global::CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); var rows2 = csv.GetRecords().ToList(); Assert.Equal("Test1", rows2[0].A); @@ -409,4 +409,4 @@ public async Task ExportDataTableWithProgressTest() } } } -} \ No newline at end of file +} diff --git a/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderAsyncTests.cs new file mode 100644 index 00000000..9bd124ce --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderAsyncTests.cs @@ -0,0 +1,156 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.DataReader; + +public class OpenXmlDataReaderAsyncTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + static OpenXmlDataReaderAsyncTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + [Fact] + public async Task GetDataReader_WithSimpleData_ReturnsValidDataReader() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + await using var stream = File.OpenRead(path); + await using var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: true); + + Assert.Equal(8, reader.FieldCount); + Assert.Equal("ID", reader.GetName(0)); + Assert.Equal("Name", reader.GetName(1)); + Assert.Equal("BoD", reader.GetName(2)); + Assert.Equal("Age", reader.GetName(3)); + Assert.Equal("VIP", reader.GetName(4)); + Assert.Equal("Mail", reader.GetName(5)); + Assert.Equal("Points", reader.GetName(6)); + + Assert.True(await reader.ReadAsync()); + Assert.Equal(Guid.Parse("78DE23D2-DCB6-BD3D-EC67-C112BBC322A2"), reader.GetGuid(0)); + Assert.Equal("Wade", reader.GetString(1)); + Assert.Equal(new DateTime(2020, 9, 27), reader.GetDateTime(2)); + Assert.Equal(36, reader.GetInt32(3)); + Assert.False(reader.GetBoolean(4)); + Assert.Equal(5019.12, reader.GetDouble(6)); + } + + [Fact] + public async Task GetDataReader_WithoutHeaderRow_ReturnsDataWithoutHeaders() + { + var path = PathHelper.GetFile("xlsx/TestStrictOpenXml.xlsx"); + await using var stream = File.OpenRead(path); + await using var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: false); + + // First row should be headers when hasHeaderRow is false + Assert.True(await reader.ReadAsync()); + Assert.Equal("A", reader.GetName(0)); + Assert.Equal("B", reader.GetName(1)); + Assert.Equal("C", reader.GetName(2)); + } + + [Fact] + public async Task GetDataReader_WithSpecificSheet_ReadsCorrectSheet() + { + var path = PathHelper.GetFile("xlsx/TestMultiSheet.xlsx"); + await using var stream = File.OpenRead(path); + await using var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: true, sheetName: "Sheet2"); + + Assert.Equal("Sheet2", reader.GetWorksheetName()); + Assert.True(await reader.ReadAsync()); + Assert.Equal(1d, reader.GetValue(0)); + } + + [Fact] + public async Task GetDataReader_WithStartCell_SkipsToStartingCell() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + await using var stream = File.OpenRead(path); + await using var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: false, startCell: "C3"); + + Assert.True(await reader.ReadAsync()); + Assert.Equal(new DateTime(2020, 10, 25), reader.GetDateTime(0)); + Assert.Equal(44, reader.GetInt32(1)); + Assert.True(reader.GetBoolean(2)); + Assert.Equal("elit.elit.fermentum@enim.edu", reader.GetString(3)); + Assert.Equal(7028.46, reader.GetDouble(4)); + } + + [Fact] + public async Task GetDataReader_WithLeaveOpenFalse_DisposesStream() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + + var stream = File.OpenRead(path); + await using (var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: false, leaveOpen: false)) + { + await reader.ReadAsync(); + } + + Assert.False(stream.CanRead); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public async Task GetDataReader_WithLeaveOpenTrue_DoesNotDisposeStream() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + + var stream = File.OpenRead(path); + await using (var reader = await _excelImporter.GetAsyncDataReader(stream, hasHeaderRow: false, leaveOpen: true)) + { + await reader.ReadAsync(); + } + + Assert.True(stream.CanRead); + } + + [Fact] + public async Task GetDataReader_GetSchemaTable_ReturnsColumnInfo() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + await using var reader = await _excelImporter.GetAsyncDataReader(path, hasHeaderRow: true); + using var schemaTable = reader.GetSchemaTable(); + + Assert.NotNull(schemaTable); + Assert.Equal(8, schemaTable.Rows.Count); + } + + [Fact] + public async Task GetDataReader_GetOrdinal_ReturnsColumnIndex() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + await using var reader = await _excelImporter.GetAsyncDataReader(path, hasHeaderRow: true); + + Assert.Equal(0, reader.GetOrdinal("ID")); + Assert.Equal(1, reader.GetOrdinal("Name")); + Assert.Equal(2, reader.GetOrdinal("BoD")); + Assert.Equal(3, reader.GetOrdinal("Age")); + Assert.Equal(4, reader.GetOrdinal("VIP")); + Assert.Equal(5, reader.GetOrdinal("Mail")); + Assert.Equal(6, reader.GetOrdinal("Points")); + } + + [Fact] + public async Task GetDataReader_WithMultipleSheets_ReadsAllSheets() + { + var path = PathHelper.GetFile("xlsx/TestMultiSheet.xlsx"); + await using var reader = await _excelImporter.GetAsyncDataReader(path); + + Assert.Equal("Sheet1", reader.GetWorksheetName()); + Assert.True(await reader.ReadAsync()); + Assert.Equal(2d, reader.GetValue(0)); + + // Move to next result set + Assert.True(await reader.NextResultAsync()); + Assert.Equal("Sheet2", reader.GetWorksheetName()); + Assert.True(await reader.ReadAsync()); + Assert.Equal(1d, reader.GetValue(0)); + Assert.True(await reader.NextResultAsync()); + Assert.Equal("Sheet3", reader.GetWorksheetName()); + Assert.True(await reader.ReadAsync()); + Assert.Equal(3d, reader.GetValue(0)); + Assert.False(await reader.NextResultAsync()); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderTests.cs b/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderTests.cs new file mode 100644 index 00000000..2e8f907f --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/DataReader/OpenXmlDataReaderTests.cs @@ -0,0 +1,156 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.DataReader; + +public class OpenXmlDataReaderTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + static OpenXmlDataReaderTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + [Fact] + public void GetDataReader_WithSimpleData_ReturnsValidDataReader() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + using var stream = File.OpenRead(path); + using var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: true); + + Assert.Equal(8, reader.FieldCount); + Assert.Equal("ID", reader.GetName(0)); + Assert.Equal("Name", reader.GetName(1)); + Assert.Equal("BoD", reader.GetName(2)); + Assert.Equal("Age", reader.GetName(3)); + Assert.Equal("VIP", reader.GetName(4)); + Assert.Equal("Mail", reader.GetName(5)); + Assert.Equal("Points", reader.GetName(6)); + + Assert.True(reader.Read()); + Assert.Equal(Guid.Parse("78DE23D2-DCB6-BD3D-EC67-C112BBC322A2"), reader.GetGuid(0)); + Assert.Equal("Wade", reader.GetString(1)); + Assert.Equal(new DateTime(2020, 9, 27), reader.GetDateTime(2)); + Assert.Equal(36, reader.GetInt32(3)); + Assert.False(reader.GetBoolean(4)); + Assert.Equal(5019.12, reader.GetDouble(6)); + } + + [Fact] + public void GetDataReader_WithoutHeaderRow_ReturnsDataWithoutHeaders() + { + var path = PathHelper.GetFile("xlsx/TestStrictOpenXml.xlsx"); + using var stream = File.OpenRead(path); + using var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: false); + + // First row should be headers when hasHeaderRow is false + Assert.True(reader.Read()); + Assert.Equal("A", reader.GetName(0)); + Assert.Equal("B", reader.GetName(1)); + Assert.Equal("C", reader.GetName(2)); + } + + [Fact] + public void GetDataReader_WithSpecificSheet_ReadsCorrectSheet() + { + var path = PathHelper.GetFile("xlsx/TestMultiSheet.xlsx"); + using var stream = File.OpenRead(path); + using var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: true, sheetName: "Sheet2"); + + Assert.Equal("Sheet2", reader.GetWorksheetName()); + Assert.True(reader.Read()); + Assert.Equal(1d, reader.GetValue(0)); + } + + [Fact] + public void GetDataReader_WithStartCell_SkipsToStartingCell() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + using var stream = File.OpenRead(path); + using var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: false, startCell: "C3"); + + Assert.True(reader.Read()); + Assert.Equal(new DateTime(2020, 10, 25), reader.GetDateTime(0)); + Assert.Equal(44, reader.GetInt32(1)); + Assert.True(reader.GetBoolean(2)); + Assert.Equal("elit.elit.fermentum@enim.edu", reader.GetString(3)); + Assert.Equal(7028.46, reader.GetDouble(4)); + } + + [Fact] + public void GetDataReader_WithLeaveOpenFalse_DisposesStream() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + + var stream = File.OpenRead(path); + using (var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: false, leaveOpen: false)) + { + reader.Read(); + } + + Assert.False(stream.CanRead); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void GetDataReader_WithLeaveOpenTrue_DoesNotDisposeStream() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + + var stream = File.OpenRead(path); + using (var reader = _excelImporter.GetDataReader(stream, hasHeaderRow: false, leaveOpen: true)) + { + reader.Read(); + } + + Assert.True(stream.CanRead); + } + + [Fact] + public void GetDataReader_GetSchemaTable_ReturnsColumnInfo() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + using var reader = _excelImporter.GetDataReader(path, hasHeaderRow: true); + using var schemaTable = reader.GetSchemaTable(); + + Assert.NotNull(schemaTable); + Assert.Equal(8, schemaTable.Rows.Count); + } + + [Fact] + public void GetDataReader_GetOrdinal_ReturnsColumnIndex() + { + var path = PathHelper.GetFile("xlsx/TestTypeMapping.xlsx"); + using var reader = _excelImporter.GetDataReader(path, hasHeaderRow: true); + + Assert.Equal(0, reader.GetOrdinal("ID")); + Assert.Equal(1, reader.GetOrdinal("Name")); + Assert.Equal(2, reader.GetOrdinal("BoD")); + Assert.Equal(3, reader.GetOrdinal("Age")); + Assert.Equal(4, reader.GetOrdinal("VIP")); + Assert.Equal(5, reader.GetOrdinal("Mail")); + Assert.Equal(6, reader.GetOrdinal("Points")); + } + + [Fact] + public void GetDataReader_WithMultipleSheets_ReadsAllSheets() + { + var path = PathHelper.GetFile("xlsx/TestMultiSheet.xlsx"); + using var reader = _excelImporter.GetDataReader(path); + + Assert.Equal("Sheet1", reader.GetWorksheetName()); + Assert.True(reader.Read()); + Assert.Equal(2d, reader.GetValue(0)); + + // Move to next result set + Assert.True(reader.NextResult()); + Assert.Equal("Sheet2", reader.GetWorksheetName()); + Assert.True(reader.Read()); + Assert.Equal(1d, reader.GetValue(0)); + Assert.True(reader.NextResult()); + Assert.Equal("Sheet3", reader.GetWorksheetName()); + Assert.True(reader.Read()); + Assert.Equal(3d, reader.GetValue(0)); + Assert.False(reader.NextResult()); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs index c3c51b12..27e41bca 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs @@ -1800,4 +1800,28 @@ await _excelExporter.ExportAsync( CultureInfo.CurrentUICulture = ogCulture; } } + + [Fact] + public async Task MultipleResultSets() + { + await using var stream =File.OpenRead(PathHelper.GetFile("xlsx/TestTypeMapping.xlsx")); + await using var dr = await OpenXmlDataReader.CreateAsync(stream, hasHeaderRow: true, leaveOpen: true); + await dr.ReadAsync(); + var v1 = dr.GetValue(0); + var nr = await dr.NextResultAsync(); + await dr.ReadAsync(); + var v2 = dr.GetValue(0); + } + + [Fact] + public void MultipleResultSets2() + { + using var stream = File.OpenRead(PathHelper.GetFile("xlsx/TestMultiSheet.xlsx")); + using var dr = OpenXmlDataReader.Create(stream, leaveOpen: true); + dr.Read(); + var v1 = dr.GetValue(0); + var nr = dr.NextResult(); + dr.Read(); + var v2 = dr.GetValue(0); + } } diff --git a/tests/data/csv/TestDataReaderCustomSeparator.csv b/tests/data/csv/TestDataReaderCustomSeparator.csv new file mode 100644 index 00000000..50feb3bb --- /dev/null +++ b/tests/data/csv/TestDataReaderCustomSeparator.csv @@ -0,0 +1,3 @@ +Name;Age +John;30 +Jane;25 \ No newline at end of file diff --git a/tests/data/csv/TestDataReaderHeader.csv b/tests/data/csv/TestDataReaderHeader.csv new file mode 100644 index 00000000..a417c3ed --- /dev/null +++ b/tests/data/csv/TestDataReaderHeader.csv @@ -0,0 +1,3 @@ +Name,Age +John,30 +Jane,25 \ No newline at end of file diff --git a/tests/data/csv/TestDataReaderNoHeader.csv b/tests/data/csv/TestDataReaderNoHeader.csv new file mode 100644 index 00000000..aaf34258 --- /dev/null +++ b/tests/data/csv/TestDataReaderNoHeader.csv @@ -0,0 +1,2 @@ +Value1,Value2 +Value3,Value4 \ No newline at end of file From d9037d8d799da719eb5b27579e60b621c247381a Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 6 Jun 2026 15:04:39 +0200 Subject: [PATCH 7/8] Stream disposal oversight fix --- src/MiniExcel.Core/MiniExcelDataReaderBase.cs | 13 +++++++------ src/MiniExcel.OpenXml/OpenXmlDataReader.cs | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/MiniExcel.Core/MiniExcelDataReaderBase.cs b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs index 5d4dcad1..f315e9fe 100644 --- a/src/MiniExcel.Core/MiniExcelDataReaderBase.cs +++ b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs @@ -269,13 +269,14 @@ protected virtual void Dispose(bool disposing) { if (IsAsyncSource) { - if (AsyncSource is IDisposable disposable) disposable.Dispose(); - // necessary fallback when the data reader is being disposed synchronously despite having being initialized asynchronously - else Task.Run(async () => await AsyncSource!.DisposeAsync().ConfigureAwait(false)).GetAwaiter().GetResult(); + if (AsyncSource is IDisposable disposable) + disposable.Dispose(); + else if (AsyncSource is not null) // necessary fallback when the data reader is being disposed synchronously despite having being initialized asynchronously + Task.Run(async () => await AsyncSource.DisposeAsync().ConfigureAwait(false)).GetAwaiter().GetResult(); } else { - Source!.Dispose(); + Source?.Dispose(); } MiniExcelReader.Dispose(); @@ -298,8 +299,8 @@ protected virtual async ValueTask DisposeAsyncCore() if (_disposed) return; - if (IsAsyncSource) - await AsyncSource!.DisposeAsync().ConfigureAwait(false); + if (IsAsyncSource && AsyncSource is not null) + await AsyncSource.DisposeAsync().ConfigureAwait(false); Schema?.Dispose(); await MiniExcelReader.DisposeAsync().ConfigureAwait(false); diff --git a/src/MiniExcel.OpenXml/OpenXmlDataReader.cs b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs index 2327ccca..7b08e400 100644 --- a/src/MiniExcel.OpenXml/OpenXmlDataReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs @@ -69,7 +69,9 @@ internal static OpenXmlDataReader Create(Stream stream, bool hasHeaderRow = fals { dataReader?.Dispose(); reader?.Dispose(); - ((Stream?)stream)?.Dispose(); + + if (!leaveOpen) + ((Stream?)stream)?.Dispose(); } } From bf8f6c37ff2246c0c994ef684cc2fc1975123943 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 6 Jun 2026 16:04:26 +0200 Subject: [PATCH 8/8] Added configuration to MiniExcelDataReaderBase fetching methods and fixed tests and sample data --- src/MiniExcel.Core/MiniExcelDataReaderBase.cs | 26 ++++++++++-------- src/MiniExcel.Csv/CsvDataReader.cs | 10 ++++--- src/MiniExcel.OpenXml/OpenXmlDataReader.cs | 12 ++++---- .../Issues/MiniExcelGithubIssuesTests.cs | 4 +-- .../Main/MiniExcelOpenXmlAsyncTests.cs | 4 +-- tests/data/xlsx/TestTypeMapping.xlsx | Bin 19393 -> 19323 bytes 6 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/MiniExcel.Core/MiniExcelDataReaderBase.cs b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs index f315e9fe..73275a98 100644 --- a/src/MiniExcel.Core/MiniExcelDataReaderBase.cs +++ b/src/MiniExcel.Core/MiniExcelDataReaderBase.cs @@ -3,6 +3,7 @@ public abstract class MiniExcelDataReaderBase : IMiniExcelDataReader { protected readonly IMiniExcelReader MiniExcelReader; + protected readonly MiniExcelBaseConfiguration MiniExcelConfiguration; protected IEnumerator>? Source; protected IAsyncEnumerator>? AsyncSource; @@ -32,11 +33,12 @@ public virtual object this[string name] public bool IsClosed { get; protected set; } - protected MiniExcelDataReaderBase(IMiniExcelReader miniExcelReader, bool hasHeaderRow, bool isAsyncSource) + protected MiniExcelDataReaderBase(IMiniExcelReader miniExcelReader, bool hasHeaderRow, bool isAsyncSource, MiniExcelBaseConfiguration configuration) { MiniExcelReader = miniExcelReader; HasHeaderRow = hasHeaderRow; IsAsyncSource = isAsyncSource; + MiniExcelConfiguration = configuration; } public virtual bool Read() @@ -110,18 +112,18 @@ public virtual long GetChars(int i, long fieldoffset, char[]? buffer, int buffer { bool b => b, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToBoolean(value) + var value => Convert.ToBoolean(value, MiniExcelConfiguration.Culture) }; public virtual byte GetByte(int i) => GetValue(i) is { } value - ? Convert.ToByte(value) + ? Convert.ToByte(value, MiniExcelConfiguration.Culture) : throw new InvalidOperationException("The value is null"); public virtual char GetChar(int i) => GetValue(i) switch { char c => c, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToChar(value) + var value => Convert.ToChar(value, MiniExcelConfiguration.Culture) }; public virtual DateTime GetDateTime(int i) => GetValue(i) switch @@ -129,28 +131,28 @@ public virtual byte GetByte(int i) => GetValue(i) is { } value DateTime dt => dt, double d => DateTime.FromOADate(d), null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDateTime(value) + var value => Convert.ToDateTime(value, MiniExcelConfiguration.Culture) }; public virtual decimal GetDecimal(int i) => GetValue(i) switch { decimal d => d, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDecimal(value) + var value => Convert.ToDecimal(value, MiniExcelConfiguration.Culture) }; public virtual float GetFloat(int i) => GetValue(i) switch { float f => f, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToSingle(value) + var value => Convert.ToSingle(value, MiniExcelConfiguration.Culture) }; public virtual double GetDouble(int i) => GetValue(i) switch { double d => d, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToDouble(value) + var value => Convert.ToDouble(value, MiniExcelConfiguration.Culture) }; public virtual Guid GetGuid(int i) => GetValue(i) switch @@ -166,27 +168,27 @@ public virtual byte GetByte(int i) => GetValue(i) is { } value { short s => s, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt16(value) + var value => Convert.ToInt16(value, MiniExcelConfiguration.Culture) }; public virtual int GetInt32(int i) => GetValue(i) switch { int s => s, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt32(value) + var value => Convert.ToInt32(value, MiniExcelConfiguration.Culture) }; public virtual long GetInt64(int i) => GetValue(i) switch { long l => l, null => throw new InvalidOperationException("The value is null"), - var value => Convert.ToInt64(value) + var value => Convert.ToInt64(value ,MiniExcelConfiguration.Culture) }; public virtual string GetString(int i) => GetValue(i) switch { string s => s, - var value => Convert.ToString(value) ?? "" + var value => Convert.ToString(value, MiniExcelConfiguration.Culture) ?? "" }; public virtual object GetValue(int i) diff --git a/src/MiniExcel.Csv/CsvDataReader.cs b/src/MiniExcel.Csv/CsvDataReader.cs index aacc44c9..40925de7 100644 --- a/src/MiniExcel.Csv/CsvDataReader.cs +++ b/src/MiniExcel.Csv/CsvDataReader.cs @@ -4,8 +4,8 @@ namespace MiniExcelLib.Csv; public sealed class CsvDataReader : MiniExcelDataReaderBase { - private CsvDataReader(CsvReader reader, bool hasHeaderRow, bool isAsyncSource) - : base(reader, hasHeaderRow, isAsyncSource) + private CsvDataReader(CsvReader reader, bool hasHeaderRow, bool isAsyncSource, CsvConfiguration configuration) + : base(reader, hasHeaderRow, isAsyncSource, configuration) { } @@ -13,11 +13,12 @@ internal static CsvDataReader Create(Stream stream, bool hasHeaderRow = false, C { CsvReader? reader = null; CsvDataReader? dataReader = null; + configuration ??= CsvConfiguration.Default; try { reader = new CsvReader(stream, configuration, leaveOpen); - dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: false) + dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: false, configuration) { Source = reader.Query(hasHeaderRow, null, "A1").GetEnumerator(), }; @@ -53,11 +54,12 @@ internal static async Task CreateAsync(Stream stream, bool hasHea { CsvReader? reader = null; CsvDataReader? dataReader = null; + configuration ??= CsvConfiguration.Default; try { reader = new CsvReader(stream, configuration, leaveOpen); - dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: true) + dataReader = new CsvDataReader(reader, hasHeaderRow, isAsyncSource: true, configuration) { AsyncSource = reader.QueryAsync(hasHeaderRow, null, "A1", cancellationToken).GetAsyncEnumerator(cancellationToken) }; diff --git a/src/MiniExcel.OpenXml/OpenXmlDataReader.cs b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs index 7b08e400..3f4abd4f 100644 --- a/src/MiniExcel.OpenXml/OpenXmlDataReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlDataReader.cs @@ -13,8 +13,8 @@ public sealed class OpenXmlDataReader : MiniExcelDataReaderBase private string _startCell; - private OpenXmlDataReader(OpenXmlReader reader, string sheetName, bool hasHeaderRow, string startCell, bool isAsyncSource, bool expectsSingleResult, string[] sheetNames) - : base(reader, hasHeaderRow, isAsyncSource) + private OpenXmlDataReader(OpenXmlReader reader, string sheetName, bool hasHeaderRow, string startCell, bool isAsyncSource, bool expectsSingleResult, string[] sheetNames, OpenXmlConfiguration configuration) + : base(reader, hasHeaderRow, isAsyncSource, configuration) { _startCell = startCell; _expectsSingleResult = expectsSingleResult; @@ -26,6 +26,7 @@ internal static OpenXmlDataReader Create(Stream stream, bool hasHeaderRow = fals { OpenXmlReader? reader = null; OpenXmlDataReader? dataReader = null; + configuration ??= OpenXmlConfiguration.Default; try { @@ -43,7 +44,7 @@ internal static OpenXmlDataReader Create(Stream stream, bool hasHeaderRow = fals isSingleResult = true; } - dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: false, isSingleResult, sheetNames) + dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: false, isSingleResult, sheetNames, configuration) { Source = reader.Query(hasHeaderRow, sheetName, startCell).GetEnumerator(), }; @@ -79,7 +80,8 @@ internal static async Task CreateAsync(Stream stream, bool ha { OpenXmlReader? reader = null; OpenXmlDataReader? dataReader = null; - + configuration ??= OpenXmlConfiguration.Default; + try { reader = await OpenXmlReader.CreateAsync(stream, configuration, leaveOpen, cancellationToken).ConfigureAwait(false); @@ -96,7 +98,7 @@ internal static async Task CreateAsync(Stream stream, bool ha isSingleResult = true; } - dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: true, isSingleResult, sheetNames) + dataReader = new OpenXmlDataReader(reader, sheetName ?? sheetNames[0], hasHeaderRow, startCell, isAsyncSource: true, isSingleResult, sheetNames, configuration) { AsyncSource = reader.QueryAsync(hasHeaderRow, sheetName, startCell, cancellationToken).GetAsyncEnumerator(cancellationToken), }; diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs index a763bc9e..7c191ef8 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs @@ -1997,9 +1997,9 @@ public void TestIssue408() Assert.Equal(100, dt.Rows.Count); Assert.Equal("78DE23D2-DCB6-BD3D-EC67-C112BBC322A2", dt.Rows[0]["ID"]); Assert.Equal("Wade", dt.Rows[0]["Name"]); - Assert.Equal("27/09/2020", dt.Rows[0]["BoD"]); + Assert.Equal(new DateTime(2020, 9, 27), dt.Rows[0]["BoD"]); Assert.Equal(36d, dt.Rows[0]["Age"]); - Assert.False(Convert.ToBoolean(dt.Rows[0]["VIP"])); + Assert.False((bool)dt.Rows[0]["VIP"]); Assert.Equal(5019.12, dt.Rows[0]["Points"]); } diff --git a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs index 27e41bca..db5f388f 100644 --- a/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Main/MiniExcelOpenXmlAsyncTests.cs @@ -267,9 +267,9 @@ public async Task QueryStrongTypeMapping_Test() Assert.Equal("78DE23D2-DCB6-BD3D-EC67-C112BBC322A2", rows[0].ID); Assert.Equal("Wade", rows[0].Name); - Assert.Equal("27/09/2020", rows[0].BoD); + Assert.Equal(new DateTime(2020, 9, 27), rows[0].BoD); Assert.Equal(36, rows[0].Age); - Assert.Equal(bool.FalseString, rows[0].VIP); + Assert.False(rows[0].VIP); Assert.Equal(5019.12d, rows[0].Points); Assert.Null(rows[0].IgnoredProperty); } diff --git a/tests/data/xlsx/TestTypeMapping.xlsx b/tests/data/xlsx/TestTypeMapping.xlsx index c6e7bdf989e823c2add31bcbd85c4311aef1e68a..4f27ee242bea2965ffd8aec321fc1c4da4ed8641 100644 GIT binary patch delta 13295 zcmZ8|byOWuukYdDE(MAfcXuc*2Q3tLr#KY1B7-}$xNC7M6pFhPEAH-6-2K3#_r3Ss zb?^K!Yt~G5CX?isWM|LrN`ZMyhN)ge06-2O5URl-5H|t{gb4zHJnh Gq=U9UN>~ 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 delta 13340 zcmZ8|Wl$Y!vo-GS?(Xg`3GPmCcMA@Cg1ZIx;O_3h-93144-UaD=e$+#ckee<`_GCDJKWt`i5NhV&hupyDzO2Kl14q*rA_HiZf5%O@Q4lrCqt4sJP0s_F+%A9wXM*- z)@q!92Ylm#pQ>Uu+v2sELa?3E7^RZZlEYo-Q>pP+xBbMK$Z3hV6DE%K1ig>Zf=~yF z3)QRg8lz#TLZESA%uoM;bC#KvYFVOHc$vUh3M!Rj)s%+EkY{Q2x0PaNnDgpRk()32 z)@U~;-4tpB1z?C_UI3PXGou>N%1s=$7Sm$CoD_5Slp%8C3#dz#m$&`ph>aRPU)Z*K z78RhjN8anribq4`YEgdE>X$=`dJu2A*whaX!)0Vyafo8cw|Gj#saAua6pzWaT(4*9 z6~-$^78E)%F08oLAB2|bzX6HWimvbaRiV#J>s4iDWp*$hUI5g_rc_Mx-}QL3<{$#s zl0>5P3xubzf5w37da##~?y-JA+epaIr?E_OH}RJ@%KV`+tZXy*;xby|J~q%dlaDpQ zWNUIV$jEVg%=3uKV{=k}Pkz3n=lkvf|1&Sv^Tj|vW*a2(1kU}aNSa5X@?hU#a#*{! z_hMKqK=Ux2?E-i%kxocB&1A=RDsjCW&IYN^Vw?1*cn-dW5a$1l9anI`I7K`dYeFXGB&Lo778ef`C4Zv?O?;}`4Io2OhA zyCicMa@Og%Y0|aY7V?-s@?Ijlxa?LalOaBw;bujVK|{%qyF|I$5d}-kgT!iRf#-xk znRaOE0bzlGz4(7*GFYOmECG;Zu;xf8``w?^_&s2|L*BgyF9mioYDM@bM9}%_hLv8v zLq4^t)m104;gauyN$@KH01NOWmrq>wuYMZ-ydE&%vs`E8w{u5H|$H;!B% zo8S2L_^yZmZP{-RTNj&m^P@YhAdrseMGkMvyeLoDajxjwaIVP93UCex`fb?9V%Gif zYt04;lNfDotrK1JIOZ&c*+0~KrvOi#Yob+g*Sl}ylRFkYx}ry6IV4VFLQYOOOKXS6 zqkYe>+&mt+qE45*Q5&xVLI8MHMH6ulT_Zya65l$4ymf;?CJMJ!F9 z4!%y@--c!GSo&Wwt={yEDX4Cp@ou&3LU*Pyymv`55Li`o;#IsUnpJV~gxbBot=;c< z`~{v)b3Lz{@ZQH*f$aD5-1#_Cl4jSC;f9Pe-dv{=QS3_qV7WH+xw#6moO!qB6-C*6 zNZkE9Uz0gJ`RYPx7mK7+aFJN zP7o`LV;1WBb3>1y;z`d1Eo$cUsZ-qW`>)qW?}pdwJG$7@%9KRnhW*!m(x}ND!#RyC zt3~U3&;uR7vv)ZG8J z4YY>Kf^So>mFRxsd-cuL)J)fw_U=M&6S9@+7NC?C(SRQ&r>&ydJFSS|IDKxG7r^H? z<)$z)iV1IHgq-aLX?1slnvd#lWM87dJZf?iYifX8yP6It-jb-YhD@ zbor=K$$@NXE^<=~bcgWAbgxlXD>uq3H}NFDZh>%09wbv(@y2kHKx=eJsH&bz5RHo9w!!V*&chX!2$LXcRdki#A7iEYdNc*F!L+heyLRnT-~$)b^(tW@lNSbj{3Zv{9Zz)>HM7v+3ZJsX$S85GM(`>iTwQELjf`2)ag8lLUS!;F zq#+1GG;K+L+sG

30#{dMtpMmPkW8dh^4Ih@QI)I6CJClW?Wzx5BYXnE6KHq4vAbz(34^3L&4d8Yno zIrOy<>^kH*-hMLR1J~NG(yPd{lJ_|lhhON=oYrf6Q(ayvMLQy+Wf!0ztSFBM48>zWnba^8X<>DRr$WmGAfSigMz2h3l{^g=#cgUGvb-R>db(VnT zq3c$0)xyDhb{joFr7+wXI?R5NK(7U2rQ^Z&Z8Du$^0ZsdaeIs@0$LDmGyzZVp=-fm z*3zFzr5i8x^4W8oC5OG}B&_;+(=i@`s=Kkg z=QK2GyD?QO(2J)(QNz48JE3IlQ-vj>0GyDLK36mJdJ9#IV`> zRTFOL_T%?eE3mu?-@A~*(T5Q!NTd2t)KHrle#wHztKBW!B9(P%3yFYHjnLGJtp5zj zyi}MjO;RJ(>>c-+fW{HYtaZavUCxQW;yKDVZXyGaH>&Ny=T)nnLZ0S~C-;HjP$0GT zkTjlv;*z{O`(c$tSpKllD7j%lLe)tdTYhJI^|P>k#|NTM_q@}btpC0{zgpotI%etT zGD69R?o4v0vC5cPok&;K2tyX69nN?YKNyZAQjJa*o)MHGsHisftjiAk@@5=#7ej%> z&xuTc^hj!9w*lUp6`32PF_|0epC+Mu!IU#&q(pSyV!;!nGFO{DoNeywei+}#tQz~S zE;eR01c_fIWQG2)`N`&%5Q)+4;=fSWll(U<`}gZZdgT&svC-#p8hs7oe~OLV4XA}h z?6VOtW>M9fvDcY~K~S1twHXgoT&D20jS zOnsb~d}L*WU;bdUqo0gXXofRL?=j+;YWAvRs-ht^@k95D>GH)7A-IchnwSpaY>QD; zL+}pj@K%}avP&OnO9|{=7vn>VYfaF@_0>6(A>(Y4r^9oSt%6H_uYur|-rCb8Aq=4h zAbKc#@H5*v5dt~0)zS8j`T}l27AvO+0m>UMH~I*ZroXD3NxlQiFUI=dQaSWTgrQdaa@xMN-5_1_G$(tm3-BY$eM4IzgvLn#@`dm$lvRrn$fW7EFjN;j*3gA{G=oK-TyHLz#tCIMt@-F z;cT`7Zb@tc-KAG^{*CBD)kG*m+&v{_y^`ubhao>#M`Q4V>B~9q3*d6(qezFxGX2Ni zc5&r6joh<)^1F;*4R6)Msphujk@=mLLemmGhEXpb)1D84 zM0E*3w&w$z{|_)-no30JTst5$KY8dgHeo0|Mh{InK}X-eUSYzoeuz5dZhlTDP1K!; z+40#@oL|-@O&46EM=j>`K08X?znvE^%= zV(HcxBc<$kQnYl*yMMo%!0>RzMFF^CCC+}bq{Aob?CHStUv^irA{<+NK3H{+EZ1fm z80&y5p&*CG_n~$5WxKr?KgZA%zA-Z$T>nw-89F3Ccjt|5Ag5a*;|*)h5(X%g;!Oetj${e~(bD{9)`@$p8hJQycJPgM<;? zXly+G1M+x*fEz~;XM*trX4rV!a-@T%J*7<+{Tcz~rhK9ie}4%AOXbei#7I1E36pTL4JzCZasd^i@4~rR$J%@ByT{#(!~nC zu|beX*cU6`Bu|nz?SmOFf57k^uJ{=f{!rI9i2H-*KTrJ!nP?5u7kb?n(5M!~D{0#2 znxc70eIKhGK&Ts_ZsqMx5Bq7rCNX~x8_7k|pRItb|4z#^6)4*AF*5bVHN32n|2<5D z?-mp|jz9M$Wn-oeLOYRS4?DV!L&Is%ErM3ypV&SlteuUK(b!k6)Zu*y`)qF=fPcb6rvBwdCSrZ%&T`M%P}w9j7_vVfx#_?acnPVD%>K)PoOXFrlwdu z!zw2Gk|rn4rh$V}m>y!lVcHifs5WOV&$+2g!jOe!E-IkMiasCtb6GsYnpN)I<4fE662N-Xm^4<4ti#Y{1$ zy?3xQAhS8qs%kF*=2IrcK6Z3(Amm3%nZIQP)_RPA_yWd;BVkf_UhrKM>=P>pY+}E0&$!&VEbMnm{bBi;buindB6~!J{8a zKQbfOV1Bfbdd*W;O*b}g#(sOzaqNa4!ajDQR@nKGqZ7?02oj(YvOP~&wXr?~Ty~Sk z>^646Fi2ia)|(uiyFB@iXNDT5PxfO4zbJ{1T%t(Xl?(*?$KxbiuTU8R(2f~-@l7)5 zpi8yejzD&ukxQsx{osQIG^J8vnQ_IH3lqmbhB6!5!5##!gY9 zhofCUGXFb|QpkXK7(NiJ$i%V5_ImtNQ6IU!hDnX}Ty5`zMmojfisCw~MPykjg7FBj zGU3$hCPC7B9W94j zojwkES97~~n$Y``T6C9Mp`X=a!xv1iMrLS}tE9kMuM@GX?(n2xcy9Ng!4H4Wjwns1 z{r=ZQ{S)P`_KB)!ILh=R&Rs?WR8`q4t6Or1bGqbHM(;)xtd3RXIm2fd!* zhlG4E(@cA_^0W~pP`6LK(fP*}lQZqo_q5u`D55BDKiBPImR~&We)aOR|GM@woqGI^ z590u%{XR<;H8PI*pYM-#y{3+}UIeW+)LI6GGwc5FZV$u#xu5QI=JHx;3jc+3fKpU2DB^o82uy`8Jy=w8KLsy zPR{52??4?grLnw)esSKr*;5)8FKs01r5frygt#_#2i~H_uus7D3}?u z;t%}u##xdwK_{d?$4}v?PDKOQTh^Je7qB_c^QN~nGy1K&Mt;AnVyjjN%LtsHGyew~ zl(gXzEAyf=Wa_A5&M(Q}hv>~1(L*jwq4bPJU1L1LK3`c(wxEv{>@yyqBmuVs!o=hm zuKZr3(pm+05s|ErB$Rpwftwq}!BwIn~~;JRa#e}bfz%v-UZMcu`k|D6_e z#%2x+L^C5fQ=-oPA(`fuN;-+pKfy+#oa3sXhwH${3f@41okNfC2~1;iDR8XKL~lih z*BM`X?S$>;WQ^vW|eLT?NJcfm=5*MYf`v}_UepSlH)>C8?A?Y@d0 z?s*BNu(xj&jok#63jMLziMSQtJjz``3F%)LZtn>~J9WqhrwgpAY73d2(U2shJwb1|9ZrIf?N zhkP{|Y}Zr$9=80b)Q*JF?t*UpDKLMKOV2annOUr4kKAq63PR$0pmKG!VH@ThAA|3f zQ^C#+V5sJLPang7Hi;|-NW-Oui03(BIHtS*G5dy*Hl~83YZDYAegebY1(_X4jp`dU z0YnN(kL1q8>m7iv5QQL3tCsGwmR>1LY}#55tfRHjnm^5`CysL-nZ;?5Zc+OAPyY=b z`jHoXNN4U7D^cF_L&Mo5g`G0A2d{MK;)>x`<38w2c35QQ*Z>$Q(Oy4Ub9`^#BHkAs z-?d9pu6ZCaDqxDsNKx>6^?rCfxA6n7evauRf4behf2v(2JtWz_B?NnU86LNMZ}opW zzb%9HcDixd1x{`WAIkb2MY}D$1#|(yi;L#C;h0VXZIIt8Xp8urr@K!dd6Y4&M__#O zp+s9DuEu(!2Ou?4>w4c8=NRa!_ep1M|H4z)ojBaSba|3{FzzY3!F=i&rPbWLINsqs zul>HT{ONhgXvv7@y=@anz|0bI6gk!Zu@9;f?V{7;X&kRG_GI1d$h#9B?W>TPe~_HPGX^WP4< ze{SRZw-+U3Dsx#V4Mp`vIeqT3>_%-^1=~f^k+fL*U%*xqJhDNi`}sB6S=43Q;W}z! zmPerPg9fj{C{_q9_;o3h#^@f8w2_l55ZgI~ri)Qcr8pbQbHoMszO&d&#Ll1U%f-N= z)yvLSvEO0r7Pl4!a?zF-V{B^3A(|Rjw6vCSQ(Ks2Lk+8<; zMs%u}{XEf}4Q@Ikn1k1@jL9^FC>kBkatJkuRsz=o!cZj%rDV0nfS(RDt)yQ}9-Vla=UeA9Wh!#7h$$HhT1> z&z~cBdqkHUGwUC1KblRPlQ!7D47Y>-ioOXWlI9^c9>D;tX(XE_Om@$gc{>OsWRIS6 zas(gxj;m-o*djlptUynEkC~&MSXCj`58q6p&eDRpwQcTdQZBH4+@dr4f!k1MZeBsP z!*dff3<7sW7<+2U9&}oy)HK*1)!OU(l0qL?*33@0Wlmi-gP?}fxIx^0XSUi555rM`s|ZzJ17&)C+)QQ$r#GzR#Vy#r;&`jc zT={8Db?y-Bs(8`C?Jy6zE5fTZ%^?W=?tfL^l%5)wA5T7IGk54*_2HNPmGHB5PNMacR97H0CEUc0wjWD+Pb8^(atMygN#TKgIWC!_&EDzdZc^l#T6 zp@k4Rm>C-kY=|#WjENA?H%KLHN%q@vyBB>tA|~?Vo>?&y94HapKUy)g;&o!K=bncj z&H37pe6&?ELllbz;TKLB_n87}UB9K=adH{>yL|O$aTt^lc6B>R*qrCoeWKjqBAP|g z<9*Z9Lp#^~WGmuX;_r#4{L1Sja(9y{>i@j;es?F7+tYU`Jh=sQdJE*{z8~(GYzp5E zJ8ni#zrw%o_wx~VaK5?w_@(8RbgxY-`vW2*Bz#3DJ1fW~yhi6k`(NJ<6eX|N$88>m z=bp#4srH?^UwufrU;n7WzTcF99!IstG-yLs{I~(Uterw2K zYc*YjVe{VIujPQF@;bI+?VaC|>V-U|8wv;6b;+_U6@ z4R;H4ff{D9`O-Ymy86mC{p!J+Ly`)bzf4^d4VWkUa-HCO4qYcMMUhx6#no+LILtFv z{J8MO_EPZ}lmYvxBJ9OqFOu`OGN02D;CEDF1_-bYI{*MdY!f9<7DrYBhFJk+eN5y4 z4L4Ors9GiwHU~E9^#W=8{JFM^7{d+z>N)Vlm(>C4AZ<6(34P+TS9*QA_YLO!)$|L~ zL7hr&R4Q$dKgomxj>dj(Q1f+(IJKJ&cZx)1t_fB+HVh%u69H*8KQyU#9t ztj%E{KS8C-uwMxTXWU2{^*&WoaF^=yLFaL6%sV#upF0p{5$DBA+#W_mq(rT*$t8}G|((CI_w zVTt3(w0g_y3~QXE=jK`ankX4hMstp!DOA%yi8;JH5^TgT4huqT^{dpEo6ghX-^mwR zNFun_-Xt?(yN!e>8!V{`E*QjW1&ZV0`{jcUjTul`v24q51<=wRYip>lWtUTCt5TDY zfW=}UKT_OyUPt5IokIUjClRD&uOkU+mhI5f~%j&&Qln;{$#}ou~16Lb@`f0 zKG?0{EwnG)f+%6X@F%x?y zrxw2nrNZJhVIoNI!ulDqhWWPCb2&3Z4d7TH>4(P!1s83u~NIz@9?Jwt`9+>V_WjdX%a!#{izoT1D^+M+>G z_J4=pIz4teN*uctcEBIm zpyyQ8y61_xGH2=E;^03A6QCOCA_qkl@CfH)T4+|7rzAj+F*CnK@;kMT(U8SxM(-2! zub!!9RJvU628aa@{w#K=P?G3loen~q&ZRsDI28s|o(Q{HMfpx>$cTkP9Sk60YwC5l z+mD|M(5D7Vds{mMZ9xtNXo%%aUO(ioiU(xu3EOx@2RCCtO3OFJ@|N|c%$&FM&?*@4 z+wecC5C^HSJQ;$-EJW|;4}`Kj`bpM(pQFJO32Qw>r&=7Kkbepera*lI2R9g#kt%cg z?lFc1_UD-kXCsccV_oU0MF$YYAA}SUgzOcM`)lKixA`!y!)zHE=^#!JvWzS)u9~k; z2(||}Wd^1-SWAjU_&bR5Vt**jT|RA< zKG$Cyy^XD}BP|=%{d~-I=!NE0*}py7Cqei;pp%0MVs@sJ{avaNyhR{gItW3AQjCOK z#+@U-Y5P+ci2k{8MM&_Wj1f9j7w}M_KOu_*Do)k`Q&_Qqd3OolfZ0 zR4{Uc6lJQz2wm!YgoAq`;P_C|XdN^00ZMj2NW@XN)l3nMddEN|pb=a{G@wJmQ>-n5 zs?}biHWY{ZW|vR1nifdC8mo+2n|KWD4phnEz06a;@AnpDy>av+VUwNOsMeaOw20ss zl5st9FKeWVZJHqykvYiY)YoF;L)7)p7!FkYy>mhhB`)Gw;{d2i;|jN_72%gxQG6!4 zWkU607;*lTCKF(tfX62WSL^^0l_$1+IjkJg*B7%f8xP+y?*zChvW7`|uE)%ChR>?` z{^WsP;#)-H!ch^}249=_8sj!w#`9Yg2m96^V337FI6@KlO<))*E&9&x7d%v<>S#41 zL$)NPtKiAXR0=mZu|2^r7XvTb)WRBcK#brT))FXJF}gI*MtY4@)g>Ff{wc}X`VR&@ zoikaS4T3TSUp2s0klz|gdUMnvJQreoH;!X&`Uv(p&*M0>KE-uJm|luvE@(vs6O5fn zEQfcrsr%cTD5-pQziN8E0G{aAah8U!V*2M`0iV9yZ;-j8hcs*+sguPy zcdP%EHNy>Y`rHq%sU)L^!OOM5-4=EA}1`Bdo0U_9+RI1lD%U-VFVEF753HnEb zuN3RnBtsvJAN5XA>M z7-9!H`mq@1owf|Abr7#2QkTS4*Un8P3+uEPpr9LBza!3A62%YI3u4%ibe{Z+C(+jF z6_N{kmpV~?Z?5rYB#Abumnx}DJjxEq)f(O}CLr<0#94ZgJKFcG|LM;N%C@3>?sXD1vp8gE0Z^2hSiwOCAO0Po14R|W)>zxy|*XM_iFI(Vt^Q4Wqs{|20K@t{OY#=LZs#r@)0ry5u2JPlpi4V*!?A{IrScnUs{G~yW1f)bPnpWRyfoTnj^ zIrNwj%MOa)zLwkb)nqiaQje6}@O77HmX7d znJ{^BT{DUuk^0cfXQo-nj*E@@-%kGs8T)0CRJ<9SmYMhN#_)+jhfyGqu(3^!55YM( zFmRvop<3|ngs@CWxI}9kuXewkxC$-~QXCnY)mn!b{|=^aC5E4tVRiC@@u-aa=FQXCWK{toped*$qNYvk0JE4)!m9I}hxtxPuTR!^?dFAWq}f4F9^U zYFCGEnZeotm#`|SyB!L?pIC46J>tMOWP;NW^U7bns1HgOkO z%-@kKkB}yCLsb&mbtW>4cS`l+P~&vIpeZ9Gw8NtqCO_0FIf_0kTfCQL%CVr_Ryc8h zeKl6vJrMnNnEGWFc-}!DS`;GM@_0oFtNuhpLFCq|fyO34e}*nLW&>5$i%=Q@X7Q`! z_r@CpQJb)5PQhJUKsa=uxb|nC!9H7)@g*)X%QJ5&5)2G^KeL3Da)XHY3^JbWI)1i`tqpYQhkxWj=&zTA293*9NFL#*`Q=Hu$tSazZxY}e>~aY-GT%_Jh!lu)_>udFC$Mv0 z^gSO}&WEqPf2N&QB#$$HnV32MWmYrWvTU?a#!#t$0?h>Wh^8ZA2gh`VPAwtLEP+zx z(8(D2QU70q8n%&6KBe1V~78XUrf~sEzSmu=nZX(h3I(q zIkf>@awD;lloGZ=Oa9ihJFkM}wwtXeo+GFb3d3~(Y-d3rRh$i@Rvje_KA~470q0RY z7vTXf-0icUXXc^BSdK#qe_IPH%sh3N)&_}Oe=YDWtSH8Rh1z712g{EgDh@wT*hxJ} ztu(D!EL$*kfV=Hm)@X)#`2wc`qfIvK6djR-E*_S+l`1(illcq@RTToikO{Ib1X;b8 zCkEEy6^OX_3ENJPKF@%%&h`Li2~1PQvk0f8VC1-WDiGznMS=JMMT<_f+Cb9D?kTrk zyBu&E<)+Wcq_6-}8~6e>LSAX-RWRfhn1vXqK{j7M0pll1G$xK<>h)Q#l5rjv8mhSuz{}37h}RsThKOlskh;0*vk>t?1pWned*^yJfTThOfDS z@}U`uE5#*5@WgD9+%RFIVAHxWIRuQQGd)mf0j?D&#WezzF2PS#OEa-hw054dyPjRE z__$If`#n+}Vz_uv0xMch43lIaAUw%U2jeHVj~B)?RWPe zok_fYe8|E;ZQTjt@Ze~-ZIp2H!EdoP$(0j_%E_^#ZQX(gw;3pHdNy8x-9vw@<+=gg zUA(WF?K+a=fym7^O`jd`qFR;nv}+;h?nQL3mzbrGXuh3LQ)F8%8SrAeJwwANR+_RNp9v+gHlSc-|n+=4OU)8zQ})34GfQgU@Bdc zG@nACrOOm&9pmOKAfWL^Aw)>meramR54#CyLsiPH<#DVX1OM{TP$0%xwJ`$F@6u_P z(tnLl(Fmq{TXddpb1JZf*e-8h{l-znY@JA}BeG<`<67ym93#gLmoJxM7?Q;23P7tc zqpIgkjoSjz#A$PNIKRpH|K#|(Vpbr9O!Dc_qK5+q9x6t$c|YO#S_e1}P$WBQ!3`(9 zZ{<`1@j29JNk$aYN#av6IY$AS?4SuKqFKh=b1em5E2Aac95}oVi*kQ6T~j0sCYp8pjMzskt6#xwS_e6%ddWHI=w?jtoOMN zd?h0->SSTnOfcIvP;Y@rIHQ!5R6kd9d{sC)y-+nGeB6sch=&;W@-gsg^#IHH=djXC zYf#VX7J|p%RB>wQcZs9*m^xZAL`~)7dmJgPP2}^(XM_O(yV=X%oqKO#5Tt`S%BWt+ zKV7SeRWJBx92J7BV4Wq|2bZriP4U$Uf{{{g!^DXD2z|4hQ2ms|$-*r6=~Gc4z9AI7 zY#&-+XcYoXa736EDjXogvwe@P&OrRv4J96T(G~eScFW_5u)|W-X|euv$d!5-0hSd8 zJc#K2voi;QSgcq#SlLl62FJM9@%Te}inc?&kG}t=g3+8us@bBB@EjDgDJc9cpn`{j z<17uygMOiJLOq~wqN6J;_j7z*pg_M_*?ZyAVcI1G$m>a#s!+*+(I2@W!Vcdp9~lZA zL|zUnj@?=wF0Mb&ps(r79)<0keiuSkwS4KowO_wz^z@P^36L9ng@4nEUL++^evB1{ z5%Af584MHIK1AzzZ7>qmgoO9ZBOwj}2Lrom=#n4<2co}=EAO(Qg{$>rf&e}k0E|me- z;&(U$N#?XX>|@hY$W>VEGTFau6 zecX0*tej=lR)5iN^zr0mneFQAC4OwhI?fY_8`0`CbkWL1R|ZQE&6ir~n2D1u_2zyq zg!qm5&%g-Yf^_*;>PJ+B8oo*MfCGd+I(1;O9h|iDbt<|TR7*1Vw*?MV?!U4t&Ohc(QwSi89CcZo zf6rLzMe=*H@r*{QTwKP><`#RAdo@->;OElt6U6%}HzhszRiBG>bWSx@Zc4q0d)s+( zs2x02b#ytf{WD%@Gk9jbmT;917nWMWIVs6n6CFr$2)qPCmm8+5vheD?l-WGXm z%|TpO9RP;vtH6|1;C@8$xNA>(8gDAIo023ndJ=Xw{ltFJM~s$31L$h3;b1IvR^C`n zLAy^&Dm$|2wroG>sR;j@J0G(~wEuIzHBn8T L5++3EpFjTxU9B)O