Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions src/MiniExcel/OpenXml/ExcelOpenXmlSheetReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,32 +84,32 @@ public IEnumerable<IDictionary<string, object>> QueryRange(bool useHeaderRow, st

public IEnumerable<IDictionary<string, object>> QueryRange(bool useHeaderRow, string sheetName, int startRowIndex, int startColumnIndex, int? endRowIndex, int? endColumnIndex)
{
if (startRowIndex <= 0)
if (startRowIndex is <= 0 or > ReferenceHelper.MaxRowNumber)
{
throw new ArgumentOutOfRangeException(nameof(startRowIndex), "Start row index is 1-based and must be greater than 0.");
throw new ArgumentOutOfRangeException(nameof(startRowIndex), $"Start row index must be between 1 and {ReferenceHelper.MaxRowNumber}.");
}
if (startColumnIndex <= 0)
if (startColumnIndex is <= 0 or > ReferenceHelper.MaxColumnNumber)
{
throw new ArgumentOutOfRangeException(nameof(startColumnIndex), "Start column index is 1-based and must be greater than 0.");
throw new ArgumentOutOfRangeException(nameof(startColumnIndex), $"Start column index must be between 1 and {ReferenceHelper.MaxColumnNumber}.");
}
// convert to 0-based
startColumnIndex--;
startRowIndex--;

if (endRowIndex.HasValue)
{
if (endRowIndex.Value <= 0)
if (endRowIndex.Value is <= 0 or > ReferenceHelper.MaxRowNumber)
{
throw new ArgumentOutOfRangeException(nameof(endRowIndex), "End row index is 1-based and must be greater than 0.");
throw new ArgumentOutOfRangeException(nameof(endRowIndex), $"End row index must be between 1 and {ReferenceHelper.MaxRowNumber}.");
}
// convert to 0-based
endRowIndex--;
}
if (endColumnIndex.HasValue)
{
if (endColumnIndex.Value <= 0)
if (endColumnIndex.Value is <= 0 or > ReferenceHelper.MaxColumnNumber)
{
throw new ArgumentOutOfRangeException(nameof(endColumnIndex), "End column index is 1-based and must be greater than 0.");
throw new ArgumentOutOfRangeException(nameof(endColumnIndex), $"End column index must be between 1 and {ReferenceHelper.MaxColumnNumber}.");
}
// convert to 0-based
endColumnIndex--;
Expand Down Expand Up @@ -164,15 +164,29 @@ internal IEnumerable<IDictionary<string, object>> InternalQueryRange(bool useHea
var headRows = new Dictionary<int, string>();
int rowIndex = -1;
bool isFirstRow = true;
long synthesizedCellCount = 0;
while (!reader.EOF)
{
if (XmlReaderHelper.IsStartElement(reader, "row", _ns))
{
var nextRowIndex = rowIndex + 1;
if (int.TryParse(reader.GetAttribute("r"), out int arValue))
rowIndex = arValue - 1; // The row attribute is 1-based
var rowAttribute = reader.GetAttribute("r");
if (rowAttribute != null)
{
if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out var rowNumber)
|| rowNumber is < 1 or > ReferenceHelper.MaxRowNumber)
{
throw new InvalidDataException($"Row index '{rowAttribute}' is outside Excel's valid range.");
}

rowIndex = rowNumber - 1; // The row attribute is 1-based
}
else
{
rowIndex++;
if (rowIndex >= ReferenceHelper.MaxRowNumber)
throw new InvalidDataException("The worksheet contains more rows than Excel supports.");
}

if (rowIndex < startRowIndex)
{
Expand All @@ -188,6 +202,24 @@ internal IEnumerable<IDictionary<string, object>> InternalQueryRange(bool useHea
break;
}

if (!_config.IgnoreEmptyRows && _config.MaxSynthesizedCells.HasValue)
{
var expectedRowIndex = isFirstRow ? startRowIndex : nextRowIndex;
var emptyRowCount = Math.Max(0, rowIndex - expectedRowIndex);
var columnCount = useHeaderRow
? Math.Max(1, headRows.Count)
: Math.Max(1, maxColumnIndex - startColumnIndex + 1);

var selfClosingRowCount = reader.IsEmptyElement ? 1 : 0;
synthesizedCellCount += (long)(emptyRowCount + selfClosingRowCount) * columnCount;

if (synthesizedCellCount > _config.MaxSynthesizedCells.Value)
{
throw new InvalidDataException(
$"The worksheet exceeds the configured limit of {_config.MaxSynthesizedCells.Value} synthesized empty cells.");
}
}

foreach (var row in QueryRow(reader, isFirstRow, startRowIndex, nextRowIndex, rowIndex, startColumnIndex, endColumnIndex, maxColumnIndex, withoutCR, useHeaderRow, headRows, _mergeCells))
{
if (isFirstRow)
Expand Down Expand Up @@ -643,6 +675,8 @@ private CellAndColumn ReadCellAndSetColumnIndex(XmlReader reader, int columnInde
//TODO:need to check only need nextColumnIndex or columnIndex
else if (ReferenceHelper.ParseReference(aR, out int referenceColumn, out _))
newColumnIndex = referenceColumn - 1; // ParseReference is 1-based
else if (!string.IsNullOrEmpty(aR) && referenceColumn > ReferenceHelper.MaxColumnNumber)
throw new InvalidDataException($"Cell reference '{aR}' is invalid.");
else
newColumnIndex = columnIndex;

Expand Down
6 changes: 6 additions & 0 deletions src/MiniExcel/OpenXml/OpenXmlConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public class OpenXmlConfiguration : Configuration
public bool WriteEmptyStringAsNull { get; set; } = false;
public bool TrimColumnNames { get; set; } = true;
public bool IgnoreEmptyRows { get; set; } = false;

/// <summary>
/// Maximum number of cells that may be synthesized for implicit empty rows during a query.
/// Each synthesized row counts as at least one cell. Set to <see langword="null" /> to disable the limit.
/// </summary>
public long? MaxSynthesizedCells { get; set; } = 100_000;
public bool EnableSharedStringCache { get; set; } = true;
public long SharedStringCacheSize { get; set; } = 5 * 1024 * 1024;

Expand Down
7 changes: 6 additions & 1 deletion src/MiniExcel/Utils/ReferenceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

internal static class ReferenceHelper
{
public const int MaxColumnNumber = 16_384;
public const int MaxRowNumber = 1_048_576;

public static string GetCellNumber(string cell)
{
return cell
Expand Down Expand Up @@ -70,6 +73,8 @@ public static bool ParseReference(string value, out int column, out int row)
position++;
column *= 26;
column += c - offset;
if (column > MaxColumnNumber)
return false;
continue;
}

Expand Down Expand Up @@ -105,6 +110,6 @@ public static bool ParseReference(string value, out int column, out int row)
row = row * 10 + digit;
}

return row > 0;
return row is > 0 and <= MaxRowNumber;
}
}
19 changes: 19 additions & 0 deletions tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
{
private readonly ITestOutputHelper _output = output;

[Fact]
public async Task QueryAsyncRejectsRowIndexOutsideWorksheetLimits()
{
using var path = AutoDeletingPath.Create();
MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } });
Helpers.ReplaceFirstSheetXml(path.ToString(), """
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1" />
<sheetData>
<row r="2000000000"><c r="A2000000000" t="str"><v>x</v></c></row>
</sheetData>
</worksheet>
""");

var rows = await MiniExcel.QueryAsync(path.ToString());
Assert.Throws<InvalidDataException>(() => rows.First());
}

[Fact]
public async Task Issue997()
{
Expand All @@ -33,7 +52,7 @@
["Empty cells"] = new[]
{
new { Name = "Name", Amount = "Amount" },
new { Name = "Charlie", Amount = (string)null }

Check warning on line 55 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / build

Nullability of reference types in value of type '<anonymous type: string Name, string? Amount>' doesn't match target type '<anonymous type: string Name, string Amount>'.

Check warning on line 55 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / build

Converting null literal or possible null value to non-nullable type.

Check warning on line 55 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Nullability of reference types in value of type '<anonymous type: string Name, string? Amount>' doesn't match target type '<anonymous type: string Name, string Amount>'.

Check warning on line 55 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Converting null literal or possible null value to non-nullable type.
}
};
MiniExcel.SaveAs(xlsx, sheets, printHeader: false);
Expand Down Expand Up @@ -352,7 +371,7 @@

private class Issue243Dto
{
public string Name { get; set; }

Check warning on line 374 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Non-nullable property 'Name' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public int Age { get; set; }
public DateTime InDate { get; set; }
}
Expand Down Expand Up @@ -486,11 +505,11 @@
var sheets = new DataSet();

var users = JsonConvert.DeserializeObject<DataTable>(JsonConvert.SerializeObject(new[] { new { Name = "Jack", Age = 25 }, new { Name = "Mike", Age = 44 } }));
users.TableName = "users";

Check warning on line 508 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 508 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Dereference of a possibly null reference.
sheets.Tables.Add(users);

var department = JsonConvert.DeserializeObject<DataTable>(JsonConvert.SerializeObject(new[] { new { ID = "01", Name = "HR" }, new { ID = "02", Name = "IT" } }));
department.TableName = "department";

Check warning on line 512 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 512 in tests/MiniExcelTests/MiniExcelIssueAsyncTests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Dereference of a possibly null reference.
sheets.Tables.Add(department);

var rowsWritten = await MiniExcel.SaveAsAsync(path, sheets);
Expand Down
80 changes: 80 additions & 0 deletions tests/MiniExcelTests/MiniExcelIssueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,86 @@
{
private readonly ITestOutputHelper _output = output;

[Theory]
[InlineData("1048577")]
[InlineData("2000000000")]
public void QueryRejectsRowIndexOutsideWorksheetLimits(string rowNumber)
{
using var path = AutoDeletingPath.Create();
MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } });
Helpers.ReplaceFirstSheetXml(path.ToString(), $"""
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1" />
<sheetData>
<row r="{rowNumber}"><c r="A{rowNumber}" t="str"><v>x</v></c></row>
</sheetData>
</worksheet>
""");

Assert.Throws<InvalidDataException>(() => MiniExcel.Query(path.ToString()).First());
}

[Fact]
public void QueryRejectsDimensionOutsideWorksheetLimits()
{
using var path = AutoDeletingPath.Create();
MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } });
Helpers.ReplaceFirstSheetXml(path.ToString(), """
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1:XFE1" />
<sheetData><row r="1"><c r="A1" t="str"><v>x</v></c></row></sheetData>
</worksheet>
""");

Assert.Throws<InvalidDataException>(() => MiniExcel.Query(path.ToString()).First());
}

[Fact]
public void QueryRangeAcceptsMaximumWorksheetCoordinates()
{
using var path = AutoDeletingPath.Create();
MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } });
Helpers.ReplaceFirstSheetXml(path.ToString(), """
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="XFD1048576" />
<sheetData>
<row r="1048576"><c r="XFD1048576" t="str"><v>x</v></c></row>
</sheetData>
</worksheet>
""");

var row = MiniExcel.QueryRange(
path.ToString(),
startRowIndex: ReferenceHelper.MaxRowNumber,
startColumnIndex: ReferenceHelper.MaxColumnNumber,
endRowIndex: ReferenceHelper.MaxRowNumber,
endColumnIndex: ReferenceHelper.MaxColumnNumber).Single();

Assert.Equal("x", row.XFD);
}

[Fact]
public void QueryRejectsWorksheetExceedingSynthesizedCellLimit()
{
using var path = AutoDeletingPath.Create();
MiniExcel.SaveAs(path.ToString(), new[] { new { Value = "control" } });
Helpers.ReplaceFirstSheetXml(path.ToString(), """
<?xml version="1.0" encoding="utf-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1:XFD1048576" />
<sheetData>
<row r="1048576"><c r="XFD1048576" t="str"><v>x</v></c></row>
</sheetData>
</worksheet>
""");

var exception = Assert.Throws<InvalidDataException>(() => MiniExcel.Query(path.ToString()).First());
Assert.Contains("synthesized empty cells", exception.Message);
}

/// <summary>
/// https://github.com/mini-software/MiniExcel/issues/549
/// </summary>
Expand Down Expand Up @@ -406,7 +486,7 @@
var sheets = MiniExcel.GetSheetNames(path);
foreach (var sheetName in sheets)
{
var dt = MiniExcel.QueryAsDataTable(path, useHeaderRow: true, sheetName: sheetName, configuration: config);

Check warning on line 489 in tests/MiniExcelTests/MiniExcelIssueTests.cs

View workflow job for this annotation

GitHub Actions / build

'MiniExcel.QueryAsDataTable(string, bool, string, ExcelType, string, IConfiguration)' is obsolete: 'QueryAsDataTable is not recommended, because it'll load all data into memory.'
}
}

Expand Down
2 changes: 2 additions & 0 deletions tests/MiniExcelTests/MiniExcelOpenXmlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ public void ParseValidCellReference(string reference, int expectedColumn, int ex
[InlineData("A0")]
[InlineData("A1x")]
[InlineData("A2147483648")]
[InlineData("A1048577")]
[InlineData("XFE1")]
public void RejectInvalidCellReference(string reference)
{
Assert.False(MiniExcelLibs.Utils.ReferenceHelper.ParseReference(reference, out _, out _));
Expand Down
15 changes: 15 additions & 0 deletions tests/MiniExcelTests/Utils/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ internal static string GetZipFileContent(string zipPath, string filePath)
return doc.ToString();
}

internal static void ReplaceFirstSheetXml(string path, string worksheetXml)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite);
using var archive = new ZipArchive(stream, ZipArchiveMode.Update, false, Encoding.UTF8);
var sheet = archive.Entries.Single(w =>
w.FullName.StartsWith("xl/worksheets/sheet1", StringComparison.OrdinalIgnoreCase) ||
w.FullName.StartsWith("/xl/worksheets/sheet1", StringComparison.OrdinalIgnoreCase));
var entryName = sheet.FullName;
sheet.Delete();

var replacement = archive.CreateEntry(entryName);
using var writer = new StreamWriter(replacement.Open(), new UTF8Encoding(false));
writer.Write(worksheetXml);
}

internal static string? GetFirstSheetDimensionRefValue(string path)
{
var ns = new XmlNamespaceManager(new NameTable());
Expand Down
Loading